Compare commits

..
Author SHA1 Message Date
QiuSW 12f5419f21 feat: 实现容量与配额安全闸 (#75) 2026-08-28 11:55:11 +08:00
ila debf662138 Merge PR #118: Sense 运维中心 (#74)
将 #74 实现合入 dev;工单保持待验收。
2026-08-28 11:05:11 +08:00
QiuSW 184f101a86 feat: 实现 Sense 运维中心 (#74) 2026-08-28 11:02:34 +08:00
ila 4157d90e9f Merge PR #117: Sense 本地事件候选查看
关联 #73;合入 dev,工单保持待验收。
2026-08-28 10:26:58 +08:00
QiuSW 028519a710 feat: 实现 Sense 本地事件只读查看 (#73) 2026-08-28 10:26:13 +08:00
ila 44b60b5b46 Merge PR #116: Sense 批量开通与失败项重试 (#72)
实现已合入 dev,工单 #72 保持待验收。
2026-08-28 09:54:30 +08:00
QiuSW ae19e59703 feat: 实现 Sense 批量开通与失败重试 (#72) 2026-08-28 09:53:00 +08:00
ila 2e709aa4ff merge: 记录三项目并行工单验收 (#113)
#10、#62、#71 的用户验收状态已写入 Wiki 并同步核心镜像;未验证边界保持不变。
2026-08-27 23:30:15 +08:00
QiuSW 870cb4acec docs: 记录三项目并行工单验收 (#113) 2026-08-27 23:29:52 +08:00
ila 9c874734af merge: 同步三项目并行实施事实 (#113)
Wiki 已回读,18 页镜像一致,DevHarness strict 与 48 项测试通过。#10、#62、#71、#113 均保持待验收。
2026-08-27 19:14:17 +08:00
69 changed files with 4747 additions and 21 deletions
@@ -22,6 +22,7 @@ func registerSenseDeviceRouter(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMi
r.POST("", api.Insert)
r.PUT("/:id", api.Update)
r.PUT("/:id/disable", api.Disable)
r.PUT("/:id/enable", api.Enable)
r.PUT("/:id/credentials", api.UpdateCredentials)
}
}
@@ -0,0 +1,19 @@
package router
import (
"github.com/gin-gonic/gin"
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/local_event"
"git.ilapage.cn/ila/yovision/Sense/server/common/actions"
"git.ilapage.cn/ila/yovision/Sense/server/common/middleware"
)
func init() { routerCheckRole = append(routerCheckRole, registerSenseLocalEventRouter) }
func registerSenseLocalEventRouter(v1 *gin.RouterGroup, auth *jwt.GinJWTMiddleware) {
api := &local_event.API{}
r := v1.Group("/local-events").Use(auth.MiddlewareFunc()).Use(middleware.AuthCheckRole()).Use(actions.PermissionAction())
r.GET("", api.List)
r.GET("/:id", api.Get)
}
@@ -0,0 +1,29 @@
package router
import (
"testing"
"github.com/gin-gonic/gin"
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
)
func TestLocalEventRouterIsReadOnly(t *testing.T) {
gin.SetMode(gin.TestMode)
engine := gin.New()
registerSenseLocalEventRouter(engine.Group("/api/v1"), &jwt.GinJWTMiddleware{})
routes := make(map[string]struct{})
for _, route := range engine.Routes() {
routes[route.Method+" "+route.Path] = struct{}{}
}
for _, expected := range []string{"GET /api/v1/local-events", "GET /api/v1/local-events/:id"} {
if _, ok := routes[expected]; !ok {
t.Fatalf("missing local event route: %s", expected)
}
}
for route := range routes {
if route != "GET /api/v1/local-events" && route != "GET /api/v1/local-events/:id" {
t.Fatalf("unexpected mutable local event route: %s", route)
}
}
}
@@ -0,0 +1,20 @@
package router
import (
"github.com/gin-gonic/gin"
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/operations"
"git.ilapage.cn/ila/yovision/Sense/server/common/actions"
"git.ilapage.cn/ila/yovision/Sense/server/common/middleware"
)
func init() { routerCheckRole = append(routerCheckRole, registerSenseOperationsRouter) }
func registerSenseOperationsRouter(v1 *gin.RouterGroup, auth *jwt.GinJWTMiddleware) {
api := &operations.API{}
r := v1.Group("/operations").Use(auth.MiddlewareFunc()).Use(middleware.AuthCheckRole()).Use(actions.PermissionAction())
r.GET("", api.List)
r.GET("/:id", api.Get)
r.POST("/:id/retry", api.Retry)
}
@@ -0,0 +1,31 @@
package router
import (
"net/http"
"testing"
"github.com/gin-gonic/gin"
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
)
func TestSenseOperationsRoutes(t *testing.T) {
gin.SetMode(gin.TestMode)
engine := gin.New()
registerSenseOperationsRouter(engine.Group("/api/v1"), &jwt.GinJWTMiddleware{})
wanted := map[string]bool{
http.MethodGet + " /api/v1/operations": false,
http.MethodGet + " /api/v1/operations/:id": false,
http.MethodPost + " /api/v1/operations/:id/retry": false,
}
for _, route := range engine.Routes() {
key := route.Method + " " + route.Path
if _, ok := wanted[key]; ok {
wanted[key] = true
}
}
for route, found := range wanted {
if !found {
t.Fatalf("route not registered: %s", route)
}
}
}
@@ -0,0 +1,24 @@
package router
import (
"github.com/gin-gonic/gin"
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/provisioning"
"git.ilapage.cn/ila/yovision/Sense/server/common/actions"
"git.ilapage.cn/ila/yovision/Sense/server/common/middleware"
)
func init() { routerCheckRole = append(routerCheckRole, registerSenseProvisioningRouter) }
func registerSenseProvisioningRouter(v1 *gin.RouterGroup, auth *jwt.GinJWTMiddleware) {
api := &provisioning.API{}
r := v1.Group("/provisioning/batches").Use(auth.MiddlewareFunc()).Use(middleware.AuthCheckRole()).Use(actions.PermissionAction())
r.GET("", api.List)
r.POST("", api.Create)
r.GET("/:id", api.Get)
r.POST("/:id/execute", api.Execute)
r.POST("/:id/retry-failed", api.RetryFailed)
r.POST("/:id/items/:itemId/retry", api.RetryItem)
r.GET("/:id/export", api.Export)
}
@@ -0,0 +1,19 @@
package router
import (
"github.com/gin-gonic/gin"
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/quota"
"git.ilapage.cn/ila/yovision/Sense/server/common/actions"
"git.ilapage.cn/ila/yovision/Sense/server/common/middleware"
)
func init() { routerCheckRole = append(routerCheckRole, registerSenseQuotaRouter) }
func registerSenseQuotaRouter(v1 *gin.RouterGroup, auth *jwt.GinJWTMiddleware) {
api := &quota.API{}
r := v1.Group("/quota").Use(auth.MiddlewareFunc()).Use(middleware.AuthCheckRole()).Use(actions.PermissionAction())
r.GET("", api.Get)
r.PUT("", api.Update)
}
@@ -14,6 +14,7 @@ import (
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/credential"
deviceService "git.ilapage.cn/ila/yovision/Sense/server/app/sense/device/service"
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/device/service/dto"
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/quota"
)
type Device struct{ api.Api }
@@ -106,6 +107,25 @@ func (e Device) Disable(c *gin.Context) {
e.OK(response, "设备已停用")
}
func (e Device) Enable(c *gin.Context) {
service := deviceService.Device{}
if err := e.MakeContext(c).MakeOrm().MakeService(&service.Service).Errors; err != nil {
e.Error(http.StatusInternalServerError, err, "服务初始化失败")
return
}
req := dto.EnableReq{ID: c.Param("id"), UpdateBy: user.GetUserId(c)}
if err := bindStrictJSON(c, &req); err != nil {
e.Error(http.StatusBadRequest, err, "请求内容格式不正确")
return
}
var response dto.DeviceResponse
if err := service.Enable(&req, &response); err != nil {
e.writeServiceError(err)
return
}
e.OK(response, "设备已启用,请重新完成视频接入验证")
}
func (e Device) UpdateCredentials(c *gin.Context) {
service := deviceService.Device{}
if err := e.MakeContext(c).MakeOrm().MakeService(&service.Service).Errors; err != nil {
@@ -133,6 +153,10 @@ func (e Device) writeServiceError(err error) {
e.Error(http.StatusNotFound, err, err.Error())
case errors.Is(err, deviceService.ErrVersionConflict):
e.Error(http.StatusConflict, err, err.Error())
case errors.Is(err, quota.ErrExceeded):
e.Error(http.StatusConflict, err, "当前配额已满,无法新增或启用设备")
case errors.Is(err, quota.ErrUnavailable):
e.Error(http.StatusServiceUnavailable, err, "配额配置不可读取,已拒绝新增或启用设备")
case errors.Is(err, credential.ErrKeyUnavailable):
e.Error(http.StatusServiceUnavailable, err, "摄像头凭据安全配置不可用")
default:
@@ -15,6 +15,7 @@ import (
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/credential"
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/device/models"
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/device/service/dto"
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/quota"
)
var (
@@ -108,7 +109,12 @@ func (e *Device) Insert(req *dto.CreateReq, response *dto.DeviceResponse) error
}
model.CreateBy = req.CreateBy
model.UpdateBy = req.CreateBy
if err = e.Orm.Create(&model).Error; err != nil {
if err = quota.WithAvailableSlot(e.Orm, func(tx *gorm.DB) error {
return tx.Create(&model).Error
}); err != nil {
if errors.Is(err, quota.ErrUnavailable) || errors.Is(err, quota.ErrExceeded) {
return err
}
return fmt.Errorf("create device: %w", err)
}
return e.Get(model.ID, response)
@@ -155,6 +161,35 @@ func (e *Device) Disable(req *dto.DisableReq, response *dto.DeviceResponse) erro
return e.Get(req.ID, response)
}
func (e *Device) Enable(req *dto.EnableReq, response *dto.DeviceResponse) error {
if req.Version < 1 {
return ErrInvalidDevice
}
err := quota.WithAvailableSlot(e.Orm, func(tx *gorm.DB) error {
result := tx.Model(&models.Device{}).
Where("id = ? AND version = ? AND status = ?", req.ID, req.Version, models.StatusDisabled).
Updates(map[string]any{
"status": models.StatusPending,
"version": req.Version + 1, "update_by": req.UpdateBy, "updated_at": time.Now().UTC(),
})
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return e.notFoundOrConflictWith(tx, req.ID)
}
return nil
})
if err != nil {
if errors.Is(err, quota.ErrUnavailable) || errors.Is(err, quota.ErrExceeded) ||
errors.Is(err, ErrDeviceNotFound) || errors.Is(err, ErrVersionConflict) {
return err
}
return fmt.Errorf("enable device: %w", err)
}
return e.Get(req.ID, response)
}
func (e *Device) UpdateCredentials(req *dto.CredentialUpdateReq, response *dto.DeviceResponse) error {
if req.Version < 1 || strings.TrimSpace(req.ONVIFUsername) == "" || req.ONVIFPassword == "" {
return ErrInvalidDevice
@@ -13,6 +13,7 @@ import (
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/credential"
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/device/models"
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/device/service/dto"
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/quota"
)
func testDeviceService(t *testing.T) (*Device, *gorm.DB) {
@@ -21,7 +22,10 @@ func testDeviceService(t *testing.T) (*Device, *gorm.DB) {
if err != nil {
t.Fatal(err)
}
if err = db.AutoMigrate(&models.Device{}, &credential.DeviceCredential{}); err != nil {
if err = db.AutoMigrate(&models.Device{}, &credential.DeviceCredential{}, &quota.Setting{}, &quota.Change{}); err != nil {
t.Fatal(err)
}
if err = db.Create(&quota.Setting{ID: quota.SettingID, Limit: 32, Source: "test", Version: 1}).Error; err != nil {
t.Fatal(err)
}
key := make([]byte, 32)
@@ -36,6 +40,38 @@ func testDeviceService(t *testing.T) (*Device, *gorm.DB) {
return service, db
}
func TestCreateAndEnableUseQuotaSafetyGate(t *testing.T) {
service, db := testDeviceService(t)
if err := db.Model(&quota.Setting{}).Where("id = ?", quota.SettingID).Update("limit", 1).Error; err != nil {
t.Fatal(err)
}
var first dto.DeviceResponse
if err := service.Insert(&dto.CreateReq{Name: "第一路", Modality: "video", Capabilities: []string{"video"}}, &first); err != nil {
t.Fatal(err)
}
var rejected dto.DeviceResponse
if err := service.Insert(&dto.CreateReq{Name: "第二路", Modality: "video", Capabilities: []string{"video"}}, &rejected); err != quota.ErrExceeded {
t.Fatalf("second create error=%v", err)
}
var disabled dto.DeviceResponse
if err := service.Disable(&dto.DisableReq{ID: first.ID, Version: first.Version}, &disabled); err != nil {
t.Fatal(err)
}
var second dto.DeviceResponse
if err := service.Insert(&dto.CreateReq{Name: "第二路", Modality: "video", Capabilities: []string{"video"}}, &second); err != nil {
t.Fatal(err)
}
if err := service.Enable(&dto.EnableReq{ID: disabled.ID, Version: disabled.Version}, &rejected); err != quota.ErrExceeded {
t.Fatalf("enable over quota error=%v", err)
}
if err := db.Delete(&quota.Setting{}, quota.SettingID).Error; err != nil {
t.Fatal(err)
}
if err := service.Insert(&dto.CreateReq{Name: "第三路", Modality: "video", Capabilities: []string{"video"}}, &rejected); err != quota.ErrUnavailable {
t.Fatalf("create with unreadable quota error=%v", err)
}
}
func TestDeviceLifecycleUsesAllowlistedFieldsAndOptimisticVersion(t *testing.T) {
service, _ := testDeviceService(t)
var created dto.DeviceResponse
@@ -36,6 +36,12 @@ type DisableReq struct {
UpdateBy int `json:"-"`
}
type EnableReq struct {
ID string `json:"-"`
Version int64 `json:"version"`
UpdateBy int `json:"-"`
}
type CredentialUpdateReq struct {
ID string `json:"-"`
ONVIFUsername string `json:"onvifUsername"`
@@ -0,0 +1,82 @@
package local_event
import (
"errors"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/go-admin-team/go-admin-core/sdk/api"
"github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth/user"
"git.ilapage.cn/ila/yovision/Sense/server/common"
)
type API struct{ api.Api }
func (e *API) service(c *gin.Context) (*Service, error) {
service := &Service{}
if err := e.MakeContext(c).MakeOrm().MakeService(&service.Service).Errors; err != nil {
return nil, err
}
return service, nil
}
func (e *API) List(c *gin.Context) {
service, err := e.service(c)
if err != nil {
e.writeError(err)
return
}
request := &PageRequest{}
if err = e.MakeContext(c).Bind(request).Errors; err != nil {
e.audit(c, service, "List", auditFailure, "本地事件查询条件格式不正确")
e.Error(http.StatusBadRequest, err, "查询条件格式不正确")
return
}
list, count, err := service.List(request)
if err != nil {
e.audit(c, service, "List", auditFailure, "本地事件列表查询失败")
e.writeError(err)
return
}
e.audit(c, service, "List", auditSuccess, "读取本地事件列表")
e.PageOK(list, int(count), request.GetPageIndex(), request.GetPageSize(), "查询成功")
}
func (e *API) Get(c *gin.Context) {
service, err := e.service(c)
if err != nil {
e.writeError(err)
return
}
item, err := service.Get(c.Param("id"))
if err != nil {
e.audit(c, service, "Get", auditFailure, "本地事件详情查询失败")
e.writeError(err)
return
}
e.audit(c, service, "Get", auditSuccess, "读取本地事件详情 "+item.ID)
e.OK(item, "查询成功")
}
func (e *API) audit(c *gin.Context, service *Service, action, status, remark string) {
err := WriteAccessAudit(service.Orm, AccessAudit{
Action: action, Status: status, Username: user.GetUserName(c), UserID: user.GetUserId(c),
ClientIP: common.GetClientIP(c), Route: c.FullPath(), Remark: remark, At: time.Now(),
})
if err != nil {
api.GetRequestLogger(c).Errorf("local event access audit failed: %s", err.Error())
}
}
func (e *API) writeError(err error) {
switch {
case errors.Is(err, ErrInvalidFilter):
e.Error(http.StatusBadRequest, err, ErrInvalidFilter.Error())
case errors.Is(err, ErrNotFound):
e.Error(http.StatusNotFound, err, ErrNotFound.Error())
default:
e.Error(http.StatusInternalServerError, err, "本地事件查询失败")
}
}
@@ -0,0 +1,38 @@
package local_event
import (
"time"
"gorm.io/gorm"
adminModels "git.ilapage.cn/ila/yovision/Sense/server/app/admin/models"
)
const (
auditSuccess = "1"
auditFailure = "2"
)
type AccessAudit struct {
Action string
Status string
Username string
UserID int
ClientIP string
Route string
Remark string
At time.Time
}
// WriteAccessAudit persists a minimal, synchronous GoAdmin operation record.
// It deliberately excludes query values, response bodies and evidence data.
func WriteAccessAudit(db *gorm.DB, input AccessAudit) error {
model := adminModels.SysOperaLog{
Title: "本地事件", BusinessType: "query", Method: "local_event.API." + input.Action,
RequestMethod: "GET", OperatorType: "1", OperName: input.Username, OperUrl: input.Route,
OperIp: input.ClientIP, Status: input.Status, OperTime: input.At.UTC(), Remark: input.Remark,
CreatedAt: input.At.UTC(), UpdatedAt: input.At.UTC(),
}
model.CreateBy, model.UpdateBy = input.UserID, input.UserID
return db.Create(&model).Error
}
+36
View File
@@ -0,0 +1,36 @@
package local_event
import (
"time"
commonDTO "git.ilapage.cn/ila/yovision/Sense/server/common/dto"
)
type PageRequest struct {
commonDTO.Pagination `search:"-"`
CandidateState string `form:"candidateState"`
EvidenceState string `form:"evidenceState"`
RuleRef string `form:"ruleRef"`
Keyword string `form:"keyword"`
OccurredAfter string `form:"occurredAfter"`
OccurredBefore string `form:"occurredBefore"`
}
type Response struct {
ID string `json:"id"`
OccurredAt time.Time `json:"occurredAt"`
SourceRef string `json:"sourceRef"`
SourceLabel string `json:"sourceLabel"`
RuleRef string `json:"ruleRef"`
RuleName string `json:"ruleName"`
RuleVersion string `json:"ruleVersion"`
ZoneRef string `json:"zoneRef"`
CandidateState string `json:"candidateState"`
EvidenceState string `json:"evidenceState"`
EvidenceRef string `json:"evidenceRef,omitempty"`
EvidenceDetail string `json:"evidenceDetail,omitempty"`
RetainUntil time.Time `json:"retainUntil"`
RetentionState string `json:"retentionState"`
RemainingDays int `json:"remainingDays"`
CreatedAt time.Time `json:"createdAt"`
}
@@ -0,0 +1,20 @@
package local_event
import (
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// SeedSyntheticFixture explicitly installs anonymous development/test data.
// Production startup never calls this function.
func SeedSyntheticFixture(db *gorm.DB, now time.Time) error {
now = now.UTC().Truncate(time.Second)
items := []EventCandidate{
{ID: "00000000-0000-4000-8000-000000000001", OccurredAt: now.Add(-18 * time.Minute), SourceRef: "SEN-CAM-03", SourceLabel: "东门通道", RuleRef: "rule-zone-intrusion", RuleName: "区域闯入", RuleVersion: "v3", ZoneRef: "ZONE-EAST-01", CandidateState: CandidateStateCandidate, EvidenceState: EvidenceStateSuccess, EvidenceRef: "local-evidence/fixture-001", EvidenceDetail: "已生成事件截图与短片索引", RetainUntil: now.Add(12 * 24 * time.Hour)},
{ID: "00000000-0000-4000-8000-000000000002", OccurredAt: now.Add(-23 * time.Minute), SourceRef: "SEN-CAM-08", SourceLabel: "仓库北区", RuleRef: "rule-person-stay", RuleName: "人员滞留", RuleVersion: "v2", ZoneRef: "ZONE-WAREHOUSE-02", CandidateState: CandidateStateConfirmed, EvidenceState: EvidenceStatePending, EvidenceDetail: "证据短片正在生成", RetainUntil: now.Add(12 * 24 * time.Hour)},
{ID: "00000000-0000-4000-8000-000000000003", OccurredAt: now.Add(-31 * time.Minute), SourceRef: "SEN-CAM-11", SourceLabel: "设备间入口", RuleRef: "rule-zone-intrusion", RuleName: "区域闯入", RuleVersion: "v3", ZoneRef: "ZONE-EQUIPMENT-01", CandidateState: CandidateStateCandidate, EvidenceState: EvidenceStateFailed, EvidenceDetail: "源视频片段暂不可用", RetainUntil: now.Add(2 * 24 * time.Hour)},
}
return db.Clauses(clause.OnConflict{DoNothing: true}).Create(&items).Error
}
@@ -0,0 +1,38 @@
package local_event
import (
"time"
common "git.ilapage.cn/ila/yovision/Sense/server/common/models"
)
const (
CandidateStateCandidate = "candidate"
CandidateStateConfirmed = "confirmed"
EvidenceStatePending = "pending"
EvidenceStateSuccess = "success"
EvidenceStateFailed = "failed"
)
// EventCandidate is a Sense-internal, anonymous event candidate. It is not a
// cross-product event contract and does not represent a Bell alert.
type EventCandidate struct {
ID string `gorm:"size:36;primaryKey" json:"id"`
OccurredAt time.Time `gorm:"not null;index" json:"occurredAt"`
SourceRef string `gorm:"size:128;not null;index" json:"sourceRef"`
SourceLabel string `gorm:"size:128;not null;default:''" json:"sourceLabel"`
RuleRef string `gorm:"size:128;not null;index" json:"ruleRef"`
RuleName string `gorm:"size:128;not null" json:"ruleName"`
RuleVersion string `gorm:"size:64;not null;default:''" json:"ruleVersion"`
ZoneRef string `gorm:"size:128;not null;default:''" json:"zoneRef"`
CandidateState string `gorm:"size:32;not null;index" json:"candidateState"`
EvidenceState string `gorm:"size:32;not null;index" json:"evidenceState"`
EvidenceRef string `gorm:"size:512;not null;default:''" json:"evidenceRef,omitempty"`
EvidenceDetail string `gorm:"size:512;not null;default:''" json:"evidenceDetail,omitempty"`
RetainUntil time.Time `gorm:"not null;index" json:"retainUntil"`
common.ControlBy
common.ModelTime
}
func (EventCandidate) TableName() string { return "sense_local_event_candidates" }
@@ -0,0 +1,145 @@
package local_event
import (
"errors"
"fmt"
"strings"
"time"
coreService "github.com/go-admin-team/go-admin-core/sdk/service"
"gorm.io/gorm"
)
var (
ErrInvalidFilter = errors.New("本地事件查询条件不符合要求")
ErrNotFound = errors.New("本地事件候选不存在")
)
type Service struct {
coreService.Service
Now func() time.Time
}
func (s *Service) List(request *PageRequest) ([]Response, int64, error) {
filter, err := normalizeFilter(request)
if err != nil {
return nil, 0, err
}
query := s.Orm.Model(&EventCandidate{})
if filter.CandidateState != "" {
query = query.Where("candidate_state = ?", filter.CandidateState)
}
if filter.EvidenceState != "" {
query = query.Where("evidence_state = ?", filter.EvidenceState)
}
if filter.RuleRef != "" {
query = query.Where("rule_ref = ?", filter.RuleRef)
}
if filter.occurredAfter != nil {
query = query.Where("occurred_at >= ?", *filter.occurredAfter)
}
if filter.occurredBefore != nil {
query = query.Where("occurred_at <= ?", *filter.occurredBefore)
}
if filter.Keyword != "" {
pattern := "%" + strings.ToLower(filter.Keyword) + "%"
query = query.Where("LOWER(id) LIKE ? OR LOWER(source_ref) LIKE ? OR LOWER(source_label) LIKE ? OR LOWER(rule_name) LIKE ?", pattern, pattern, pattern, pattern)
}
var count int64
if err = query.Count(&count).Error; err != nil {
return nil, 0, fmt.Errorf("count local event candidates: %w", err)
}
var models []EventCandidate
if err = query.Order("occurred_at DESC, id DESC").Limit(filter.GetPageSize()).Offset((filter.GetPageIndex() - 1) * filter.GetPageSize()).Find(&models).Error; err != nil {
return nil, 0, fmt.Errorf("list local event candidates: %w", err)
}
result := make([]Response, 0, len(models))
for _, model := range models {
result = append(result, s.response(model))
}
return result, count, nil
}
func (s *Service) Get(id string) (Response, error) {
id = strings.TrimSpace(id)
if id == "" || len(id) > 64 {
return Response{}, ErrInvalidFilter
}
var model EventCandidate
if err := s.Orm.First(&model, "id = ?", id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return Response{}, ErrNotFound
}
return Response{}, fmt.Errorf("get local event candidate: %w", err)
}
return s.response(model), nil
}
type normalizedFilter struct {
*PageRequest
occurredAfter *time.Time
occurredBefore *time.Time
}
func normalizeFilter(request *PageRequest) (*normalizedFilter, error) {
if request == nil {
request = &PageRequest{}
}
request.CandidateState = strings.TrimSpace(request.CandidateState)
request.EvidenceState = strings.TrimSpace(request.EvidenceState)
request.RuleRef = strings.TrimSpace(request.RuleRef)
request.Keyword = strings.TrimSpace(request.Keyword)
if request.GetPageSize() > 100 || len([]rune(request.Keyword)) > 128 || len(request.RuleRef) > 128 {
return nil, ErrInvalidFilter
}
if request.CandidateState != "" && request.CandidateState != CandidateStateCandidate && request.CandidateState != CandidateStateConfirmed {
return nil, ErrInvalidFilter
}
if request.EvidenceState != "" && request.EvidenceState != EvidenceStatePending && request.EvidenceState != EvidenceStateSuccess && request.EvidenceState != EvidenceStateFailed {
return nil, ErrInvalidFilter
}
result := &normalizedFilter{PageRequest: request}
var err error
if strings.TrimSpace(request.OccurredAfter) != "" {
value, parseErr := time.Parse(time.RFC3339, request.OccurredAfter)
if parseErr != nil {
return nil, ErrInvalidFilter
}
result.occurredAfter = &value
}
if strings.TrimSpace(request.OccurredBefore) != "" {
value, parseErr := time.Parse(time.RFC3339, request.OccurredBefore)
if parseErr != nil {
return nil, ErrInvalidFilter
}
result.occurredBefore = &value
}
if result.occurredAfter != nil && result.occurredBefore != nil && result.occurredAfter.After(*result.occurredBefore) {
err = ErrInvalidFilter
}
if err != nil {
return nil, err
}
return result, nil
}
func (s *Service) response(model EventCandidate) Response {
now := time.Now().UTC()
if s.Now != nil {
now = s.Now().UTC()
}
remaining := int(model.RetainUntil.Sub(now).Hours() / 24)
state := "active"
if !model.RetainUntil.After(now) {
remaining, state = 0, "expired"
} else if remaining < 3 {
state = "expiring"
}
return Response{
ID: model.ID, OccurredAt: model.OccurredAt, SourceRef: model.SourceRef, SourceLabel: model.SourceLabel,
RuleRef: model.RuleRef, RuleName: model.RuleName, RuleVersion: model.RuleVersion, ZoneRef: model.ZoneRef,
CandidateState: model.CandidateState, EvidenceState: model.EvidenceState, EvidenceRef: model.EvidenceRef,
EvidenceDetail: model.EvidenceDetail, RetainUntil: model.RetainUntil, RetentionState: state,
RemainingDays: remaining, CreatedAt: model.CreatedAt,
}
}
@@ -0,0 +1,128 @@
package local_event
import (
"encoding/json"
"strings"
"testing"
"time"
coreService "github.com/go-admin-team/go-admin-core/sdk/service"
"github.com/google/uuid"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
adminModels "git.ilapage.cn/ila/yovision/Sense/server/app/admin/models"
commonDTO "git.ilapage.cn/ila/yovision/Sense/server/common/dto"
)
func localEventService(t *testing.T, now time.Time) *Service {
t.Helper()
db, err := gorm.Open(sqlite.Open("file:"+uuid.NewString()+"?mode=memory&cache=shared"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
sqlDB, err := db.DB()
if err != nil {
t.Fatal(err)
}
sqlDB.SetMaxOpenConns(1)
if err = db.AutoMigrate(&EventCandidate{}, &adminModels.SysOperaLog{}); err != nil {
t.Fatal(err)
}
return &Service{Service: coreService.Service{Orm: db}, Now: func() time.Time { return now }}
}
func TestSyntheticFixtureSupportsIndependentListFilterAndDetail(t *testing.T) {
now := time.Date(2026, 8, 28, 2, 0, 0, 0, time.UTC)
service := localEventService(t, now)
if err := SeedSyntheticFixture(service.Orm, now); err != nil {
t.Fatal(err)
}
// Seeding is explicit and idempotent; no Bell or Brain service is involved.
if err := SeedSyntheticFixture(service.Orm, now); err != nil {
t.Fatal(err)
}
items, count, err := service.List(&PageRequest{CandidateState: CandidateStateCandidate, EvidenceState: EvidenceStateFailed, Keyword: "SEN-CAM-11"})
if err != nil {
t.Fatal(err)
}
if count != 1 || len(items) != 1 || items[0].RetentionState != "expiring" || items[0].RemainingDays != 2 {
t.Fatalf("unexpected filtered fixture: count=%d items=%#v", count, items)
}
detail, err := service.Get(items[0].ID)
if err != nil {
t.Fatal(err)
}
if detail.EvidenceState != EvidenceStateFailed || detail.CandidateState != CandidateStateCandidate || detail.EvidenceDetail == "" {
t.Fatalf("unexpected detail: %#v", detail)
}
encoded, _ := json.Marshal(detail)
if strings.Contains(strings.ToLower(string(encoded)), "bell") || strings.Contains(strings.ToLower(string(encoded)), "brain") {
t.Fatalf("local response leaked a cross-project schema field: %s", encoded)
}
}
func TestListPaginatesAndOrdersNewestFirst(t *testing.T) {
now := time.Date(2026, 8, 28, 2, 0, 0, 0, time.UTC)
service := localEventService(t, now)
if err := SeedSyntheticFixture(service.Orm, now); err != nil {
t.Fatal(err)
}
first, count, err := service.List(&PageRequest{Pagination: pagination(1, 2)})
if err != nil {
t.Fatal(err)
}
second, _, err := service.List(&PageRequest{Pagination: pagination(2, 2)})
if err != nil {
t.Fatal(err)
}
if count != 3 || len(first) != 2 || len(second) != 1 || !first[0].OccurredAt.After(first[1].OccurredAt) || !first[1].OccurredAt.After(second[0].OccurredAt) {
t.Fatalf("unexpected pagination: first=%#v second=%#v count=%d", first, second, count)
}
}
func TestListRejectsInvalidStatesTimesAndOversizedPages(t *testing.T) {
now := time.Date(2026, 8, 28, 2, 0, 0, 0, time.UTC)
service := localEventService(t, now)
cases := []*PageRequest{
{CandidateState: "delivered"},
{EvidenceState: "unknown"},
{OccurredAfter: "not-a-time"},
{OccurredAfter: now.Format(time.RFC3339), OccurredBefore: now.Add(-time.Hour).Format(time.RFC3339)},
{Pagination: pagination(1, 101)},
}
for _, request := range cases {
if _, _, err := service.List(request); err != ErrInvalidFilter {
t.Fatalf("request %#v: expected ErrInvalidFilter, got %v", request, err)
}
}
}
func TestGetReportsMissingCandidate(t *testing.T) {
service := localEventService(t, time.Now().UTC())
if _, err := service.Get("00000000-0000-4000-8000-000000000099"); err != ErrNotFound {
t.Fatalf("expected ErrNotFound, got %v", err)
}
}
func TestAccessAuditIsSynchronousAndExcludesEventData(t *testing.T) {
now := time.Date(2026, 8, 28, 2, 0, 0, 0, time.UTC)
service := localEventService(t, now)
if err := WriteAccessAudit(service.Orm, AccessAudit{Action: "Get", Status: auditSuccess, Username: "operator", UserID: 7, ClientIP: "127.0.0.1", Route: "/api/v1/local-events/:id", Remark: "读取本地事件详情 fixture-1", At: now}); err != nil {
t.Fatal(err)
}
var stored adminModels.SysOperaLog
if err := service.Orm.First(&stored).Error; err != nil {
t.Fatal(err)
}
if stored.Title != "本地事件" || stored.Status != auditSuccess || stored.CreateBy != 7 || stored.OperUrl != "/api/v1/local-events/:id" {
t.Fatalf("unexpected access audit: %#v", stored)
}
if stored.OperParam != "" || stored.JsonResult != "" {
t.Fatalf("audit persisted query or response data: %#v", stored)
}
}
func pagination(index, size int) commonDTO.Pagination {
return commonDTO.Pagination{PageIndex: index, PageSize: size}
}
+107
View File
@@ -0,0 +1,107 @@
package operations
import (
"errors"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/gin-gonic/gin/binding"
"github.com/go-admin-team/go-admin-core/sdk/api"
"github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth/user"
coreService "github.com/go-admin-team/go-admin-core/sdk/service"
"git.ilapage.cn/ila/yovision/Sense/server/common"
)
const auditSuccess, auditFailure = "1", "2"
type API struct{ api.Api }
func (e *API) service(c *gin.Context) (*Service, error) {
base := coreService.Service{}
if err := e.MakeContext(c).MakeOrm().MakeService(&base).Errors; err != nil {
return nil, err
}
return NewService(base.Orm), nil
}
func (e *API) List(c *gin.Context) {
service, err := e.service(c)
if err != nil {
e.writeError(err)
return
}
request := PageRequest{}
if err = e.MakeContext(c).Bind(&request).Errors; err != nil {
e.audit(c, service, "List", auditFailure, "运维中心查询条件格式不正确")
e.Error(http.StatusBadRequest, err, "查询条件格式不正确")
return
}
response, err := service.List(request)
if err != nil {
e.audit(c, service, "List", auditFailure, "运维中心列表查询失败")
e.writeError(err)
return
}
e.audit(c, service, "List", auditSuccess, "读取运维中心问题列表")
e.OK(response, "查询成功")
}
func (e *API) Get(c *gin.Context) {
service, err := e.service(c)
if err != nil {
e.writeError(err)
return
}
item, err := service.Get(c.Param("id"))
if err != nil {
e.audit(c, service, "Get", auditFailure, "运维问题详情查询失败")
e.writeError(err)
return
}
e.audit(c, service, "Get", auditSuccess, "读取运维问题详情 "+item.ID)
e.OK(item, "查询成功")
}
func (e *API) Retry(c *gin.Context) {
service, err := e.service(c)
if err != nil {
e.writeError(err)
return
}
request := RetryRequest{}
if err = e.MakeContext(c).Bind(&request, binding.JSON).Errors; err != nil {
e.audit(c, service, "Retry", auditFailure, "受控重试请求格式不正确")
e.Error(http.StatusBadRequest, err, "请求格式不正确")
return
}
item, err := service.Retry(c.Request.Context(), c.Param("id"), request.ExpectedVersion, user.GetUserId(c))
if err != nil {
e.audit(c, service, "Retry", auditFailure, "受控重试被拒绝")
e.writeError(err)
return
}
e.audit(c, service, "Retry", auditSuccess, "受控重试已排队 "+item.ID)
e.OK(item, "重试任务已排队")
}
func (e *API) audit(c *gin.Context, service *Service, action, status, remark string) {
err := WriteAudit(service.DB, Audit{Action: action, Method: c.Request.Method, Status: status, Username: user.GetUserName(c), UserID: user.GetUserId(c), ClientIP: common.GetClientIP(c), Route: c.FullPath(), Remark: remark, At: time.Now()})
if err != nil {
api.GetRequestLogger(c).Errorf("operations audit failed: %s", err.Error())
}
}
func (e *API) writeError(err error) {
switch {
case errors.Is(err, ErrInvalidFilter):
e.Error(http.StatusBadRequest, err, err.Error())
case errors.Is(err, ErrVersionConflict), errors.Is(err, ErrRetryInProgress), errors.Is(err, ErrRetryNotAllowed):
e.Error(http.StatusConflict, err, err.Error())
case errors.Is(err, ErrProblemNotFound):
e.Error(http.StatusNotFound, err, err.Error())
default:
e.Error(http.StatusInternalServerError, err, "运维中心操作失败")
}
}
@@ -0,0 +1,26 @@
package operations
import (
"time"
"gorm.io/gorm"
adminModels "git.ilapage.cn/ila/yovision/Sense/server/app/admin/models"
)
type Audit struct {
Action, Method, Status, Username, ClientIP, Route, Remark string
UserID int
At time.Time
}
func WriteAudit(db *gorm.DB, input Audit) error {
model := adminModels.SysOperaLog{
Title: "运维中心", BusinessType: "other", Method: "operations.API." + input.Action,
RequestMethod: input.Method, OperatorType: "1", OperName: input.Username, OperUrl: input.Route,
OperIp: input.ClientIP, Status: input.Status, OperTime: input.At.UTC(), Remark: input.Remark,
CreatedAt: input.At.UTC(), UpdatedAt: input.At.UTC(),
}
model.CreateBy, model.UpdateBy = input.UserID, input.UserID
return db.Create(&model).Error
}
+68
View File
@@ -0,0 +1,68 @@
package operations
import (
"time"
commonDto "git.ilapage.cn/ila/yovision/Sense/server/common/dto"
)
const (
ObjectDevice = "device"
ObjectMedia = "media"
ObjectLocalInference = "local_inference"
ProblemAuthentication = "authentication_failed"
ProblemBackoff = "backoff_wait"
ProblemClockDrift = "clock_drift"
ProblemOrphan = "orphan_safety_gate"
ProblemUnavailable = "capability_unavailable"
ProblemUnready = "not_converged"
)
type PageRequest struct {
commonDto.Pagination `search:"-"`
ObjectType string `form:"objectType"`
ProblemType string `form:"problemType"`
Severity string `form:"severity"`
Keyword string `form:"keyword"`
}
type RetryRequest struct {
ExpectedVersion int64 `json:"expectedVersion"`
}
type Summary struct {
ManagedCount int `json:"managedCount"`
ConvergedCount int `json:"convergedCount"`
ActionableProblems int `json:"actionableProblems"`
LocalInference string `json:"localInference"`
}
type Problem struct {
ID string `json:"id"`
ObjectType string `json:"objectType"`
ObjectID string `json:"objectId"`
ObjectName string `json:"objectName"`
Location string `json:"location,omitempty"`
ProblemType string `json:"problemType"`
Severity string `json:"severity"`
Expected string `json:"expected"`
Actual string `json:"actual"`
Difference string `json:"difference"`
NextAction string `json:"nextAction"`
NextRetryAt *time.Time `json:"nextRetryAt,omitempty"`
LastSuccessAt *time.Time `json:"lastSuccessAt,omitempty"`
AttemptCount int `json:"attemptCount"`
Retryable bool `json:"retryable"`
RetryInProgress bool `json:"retryInProgress"`
Version int64 `json:"version"`
SafetyGate string `json:"safetyGate"`
OperationalOnly bool `json:"operationalOnly"`
UpdatedAt time.Time `json:"updatedAt"`
}
type PageResponse struct {
Summary Summary `json:"summary"`
List []Problem `json:"list"`
Count int64 `json:"count"`
}
@@ -0,0 +1,359 @@
package operations
import (
"context"
"errors"
"fmt"
"sort"
"strings"
"time"
"gorm.io/gorm"
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/admission"
deviceModels "git.ilapage.cn/ila/yovision/Sense/server/app/sense/device/models"
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/media"
coreService "github.com/go-admin-team/go-admin-core/sdk/service"
)
var (
ErrInvalidFilter = errors.New("运维中心查询条件不符合要求")
ErrProblemNotFound = errors.New("运维问题不存在或已经收敛")
ErrVersionConflict = errors.New("状态已经变化,请刷新后重试")
ErrRetryInProgress = errors.New("该对象已有重试任务")
ErrRetryNotAllowed = errors.New("该问题不允许重试")
)
type Service struct {
DB *gorm.DB
Now func() time.Time
LocalInferenceConfigured bool
DeviceRetry func(context.Context, admission.ProbeRequest) error
}
type admissionResult struct {
DeviceID string
Address string
Status string
Detail string
CheckedAt time.Time
}
func (admissionResult) TableName() string { return "sense_admission_results" }
func NewService(db *gorm.DB) *Service {
service := &Service{DB: db, Now: time.Now, LocalInferenceConfigured: false}
service.DeviceRetry = func(ctx context.Context, request admission.ProbeRequest) error {
base := coreService.Service{}
base.Orm = db
runtimeService, err := admission.NewRuntime(base)
if err != nil {
return err
}
_, err = runtimeService.Probe(ctx, request)
if err == nil {
// Keep the existing admission boundary: route intent is best-effort and
// must not turn a successful device probe into a MediaMTX failure.
_ = media.EnsureDeviceRoutes(ctx, db, request.DeviceID)
}
return err
}
return service
}
func (s *Service) List(request PageRequest) (PageResponse, error) {
if err := validateFilter(request); err != nil {
return PageResponse{}, err
}
problems, summary, err := s.project()
if err != nil {
return PageResponse{}, err
}
filtered := make([]Problem, 0, len(problems))
keyword := strings.ToLower(strings.TrimSpace(request.Keyword))
for _, item := range problems {
if request.ObjectType != "" && item.ObjectType != request.ObjectType {
continue
}
if request.ProblemType != "" && item.ProblemType != request.ProblemType {
continue
}
if request.Severity != "" && item.Severity != request.Severity {
continue
}
searchable := strings.ToLower(strings.Join([]string{item.ObjectID, item.ObjectName, item.Location, item.Difference}, " "))
if keyword != "" && !strings.Contains(searchable, keyword) {
continue
}
filtered = append(filtered, item)
}
pageIndex, pageSize := request.GetPageIndex(), request.GetPageSize()
if pageSize > 100 {
pageSize = 100
}
start := (pageIndex - 1) * pageSize
if start > len(filtered) {
start = len(filtered)
}
end := start + pageSize
if end > len(filtered) {
end = len(filtered)
}
return PageResponse{Summary: summary, List: filtered[start:end], Count: int64(len(filtered))}, nil
}
func (s *Service) Get(id string) (Problem, error) {
problems, _, err := s.project()
if err != nil {
return Problem{}, err
}
for _, item := range problems {
if item.ID == id {
return item, nil
}
}
return Problem{}, ErrProblemNotFound
}
func (s *Service) Retry(ctx context.Context, id string, expectedVersion int64, userID int) (Problem, error) {
if expectedVersion < 1 {
return Problem{}, ErrVersionConflict
}
problem, err := s.Get(id)
if err != nil {
return Problem{}, err
}
if !problem.Retryable {
if problem.RetryInProgress {
return Problem{}, ErrRetryInProgress
}
return Problem{}, ErrRetryNotAllowed
}
now := s.now().UTC()
switch problem.ObjectType {
case ObjectDevice:
var admissionState admissionResult
if err := s.DB.First(&admissionState, "device_id = ?", problem.ObjectID).Error; err != nil || strings.TrimSpace(admissionState.Address) == "" {
return Problem{}, ErrRetryNotAllowed
}
result := s.DB.Model(&deviceModels.Device{}).
Where("id = ? AND version = ? AND retry_requested_at IS NULL", problem.ObjectID, expectedVersion).
Updates(map[string]any{"retry_requested_at": now, "version": expectedVersion + 1, "updated_at": now})
if result.Error != nil {
return Problem{}, fmt.Errorf("queue device retry: %w", result.Error)
}
if result.RowsAffected == 0 {
return Problem{}, s.retryConflict(ObjectDevice, problem.ObjectID, expectedVersion)
}
if s.DeviceRetry != nil {
err = s.DeviceRetry(ctx, admission.ProbeRequest{DeviceID: problem.ObjectID, Address: admissionState.Address, Version: expectedVersion + 1, UpdateBy: userID})
if err != nil {
clearErr := s.DB.Model(&deviceModels.Device{}).Where("id = ? AND version = ?", problem.ObjectID, expectedVersion+1).Update("retry_requested_at", nil).Error
if clearErr != nil {
return Problem{}, errors.Join(fmt.Errorf("execute device retry: %w", err), fmt.Errorf("clear device retry gate: %w", clearErr))
}
return Problem{}, fmt.Errorf("execute device retry: %w", err)
}
}
case ObjectMedia:
result := s.DB.Model(&media.Route{}).
Where("id = ? AND version = ? AND actual <> ?", problem.ObjectID, expectedVersion, "retry_pending").
Updates(map[string]any{"actual": "retry_pending", "next_retry_at": now, "detail": "已请求受控重试,等待视频服务执行", "version": expectedVersion + 1, "updated_at": now})
if result.Error != nil {
return Problem{}, fmt.Errorf("queue media retry: %w", result.Error)
}
if result.RowsAffected == 0 {
return Problem{}, s.retryConflict(ObjectMedia, problem.ObjectID, expectedVersion)
}
default:
return Problem{}, ErrRetryNotAllowed
}
updated, err := s.Get(id)
if errors.Is(err, ErrProblemNotFound) {
problem.Actual, problem.Difference, problem.NextAction = "converged", "重试完成,状态已经收敛", "无需处理"
problem.Retryable, problem.RetryInProgress, problem.Version, problem.UpdatedAt = false, false, expectedVersion+2, now
return problem, nil
}
return updated, err
}
func (s *Service) project() ([]Problem, Summary, error) {
var devices []deviceModels.Device
if err := s.DB.Order("updated_at DESC").Find(&devices).Error; err != nil {
return nil, Summary{}, fmt.Errorf("list operation devices: %w", err)
}
var admissions []admissionResult
if err := s.DB.Find(&admissions).Error; err != nil {
return nil, Summary{}, fmt.Errorf("list admission results: %w", err)
}
var routes []media.Route
if err := s.DB.Order("updated_at DESC").Find(&routes).Error; err != nil {
return nil, Summary{}, fmt.Errorf("list operation media routes: %w", err)
}
admissionByDevice := make(map[string]admissionResult, len(admissions))
for _, item := range admissions {
admissionByDevice[item.DeviceID] = item
}
deviceByID := make(map[string]deviceModels.Device, len(devices))
problems := make([]Problem, 0)
summary := Summary{ManagedCount: len(devices) + len(routes), LocalInference: "configured"}
for _, device := range devices {
deviceByID[device.ID] = device
if problem, ok := deviceProblem(device, admissionByDevice[device.ID]); ok {
problems = append(problems, problem)
summary.ActionableProblems++
} else {
summary.ConvergedCount++
}
}
for _, route := range routes {
if problem, ok := mediaProblem(route, deviceByID); ok {
problems = append(problems, problem)
summary.ActionableProblems++
} else {
summary.ConvergedCount++
}
}
if !s.LocalInferenceConfigured {
summary.LocalInference = "unavailable"
now := s.now().UTC()
problems = append(problems, Problem{
ID: "local_inference:adapter", ObjectType: ObjectLocalInference, ObjectID: "adapter",
ObjectName: "本地推理适配器", ProblemType: ProblemUnavailable, Severity: "info",
Expected: "可选", Actual: "unavailable", Difference: "未配置本地推理适配器;不影响设备和媒体运维",
NextAction: "无需处理", SafetyGate: "不读取 Brain 数据库,不阻断本页", OperationalOnly: true, UpdatedAt: now,
})
}
sort.SliceStable(problems, func(i, j int) bool {
rank := map[string]int{"high": 0, "medium": 1, "info": 2}
if rank[problems[i].Severity] != rank[problems[j].Severity] {
return rank[problems[i].Severity] < rank[problems[j].Severity]
}
return problems[i].UpdatedAt.After(problems[j].UpdatedAt)
})
return problems, summary, nil
}
func deviceProblem(device deviceModels.Device, admission admissionResult) (Problem, bool) {
if device.Status == deviceModels.StatusDisabled {
return Problem{}, false
}
actual, detail, updatedAt := device.AdapterStatus, "设备尚未完成接入验证", device.UpdatedAt
if admission.DeviceID != "" {
actual, detail, updatedAt = admission.Status, admission.Detail, admission.CheckedAt
}
if device.Status == deviceModels.StatusActive && (actual == "ready" || actual == "verified") {
return Problem{}, false
}
problemType, severity, nextAction := ProblemUnready, "medium", "检查设备和接入配置后重试"
text := strings.ToLower(actual + " " + detail)
if strings.Contains(text, "auth") || strings.Contains(text, "认证") || strings.Contains(text, "凭据") {
problemType, severity, nextAction = ProblemAuthentication, "high", "确认设备账号未变更后执行受控重试"
} else if strings.Contains(text, "clock") || strings.Contains(text, "time drift") || strings.Contains(text, "时间漂移") || strings.Contains(text, "时钟") {
problemType, nextAction = ProblemClockDrift, "检查设备 NTP 和时区后重新检测"
}
retrying := device.RetryRequestedAt != nil
retryable := !retrying && strings.TrimSpace(admission.Address) != ""
if admission.DeviceID == "" || strings.TrimSpace(admission.Address) == "" {
nextAction = "先到视频接入完成地址与凭据验证"
}
return Problem{
ID: "device:" + device.ID, ObjectType: ObjectDevice, ObjectID: device.ID, ObjectName: device.Name,
Location: device.Location, ProblemType: problemType, Severity: severity, Expected: "active / ready", Actual: actual,
Difference: detail, NextAction: nextAction, AttemptCount: boolInt(retrying), Retryable: retryable,
RetryInProgress: retrying, Version: device.Version, SafetyGate: "校验设备版本且同一设备仅允许一个在途重试",
OperationalOnly: true, UpdatedAt: updatedAt,
}, true
}
func mediaProblem(route media.Route, devices map[string]deviceModels.Device) (Problem, bool) {
device, exists := devices[route.DeviceID]
name, location := route.Path, ""
if exists {
name, location = device.Name+" / "+route.Path, device.Location
}
if !exists {
return Problem{
ID: "media:" + route.ID, ObjectType: ObjectMedia, ObjectID: route.ID, ObjectName: name,
ProblemType: ProblemOrphan, Severity: "medium", Expected: "路由关联有效设备", Actual: "已隔离待确认",
Difference: "媒体路由存在,但找不到有效设备归属", NextAction: "人工核对;不会自动删除", Version: route.Version,
SafetyGate: "孤儿资源仅隔离和提示,本接口没有删除动作", OperationalOnly: true, UpdatedAt: route.UpdatedAt,
}, true
}
converged := (route.Desired == media.DesiredRunning && (route.Actual == "ready" || route.Actual == "waiting")) || (route.Desired == media.DesiredStopped && route.Actual == "stopped")
if converged {
return Problem{}, false
}
problemType, nextAction := ProblemUnready, "检查视频服务状态后重试"
if route.NextRetryAt != nil {
problemType, nextAction = ProblemBackoff, "等待退避到期或执行受控提前重试"
}
retrying := route.Actual == "retry_pending"
return Problem{
ID: "media:" + route.ID, ObjectType: ObjectMedia, ObjectID: route.ID, ObjectName: name, Location: location,
ProblemType: problemType, Severity: "medium", Expected: route.Desired, Actual: route.Actual, Difference: route.Detail,
NextAction: nextAction, NextRetryAt: route.NextRetryAt, AttemptCount: route.FailureCount, Retryable: !retrying,
RetryInProgress: retrying, Version: route.Version, SafetyGate: "校验路由版本且只排队,不删除路径或修改凭据",
OperationalOnly: true, UpdatedAt: route.UpdatedAt,
}, true
}
func (s *Service) retryConflict(objectType, objectID string, expectedVersion int64) error {
var version int64
var inProgress bool
switch objectType {
case ObjectDevice:
var item deviceModels.Device
if err := s.DB.Select("version", "retry_requested_at").First(&item, "id = ?", objectID).Error; err != nil {
return ErrProblemNotFound
}
version, inProgress = item.Version, item.RetryRequestedAt != nil
case ObjectMedia:
var item media.Route
if err := s.DB.Select("version", "actual").First(&item, "id = ?", objectID).Error; err != nil {
return ErrProblemNotFound
}
version, inProgress = item.Version, item.Actual == "retry_pending"
}
if inProgress {
return ErrRetryInProgress
}
if version != expectedVersion {
return ErrVersionConflict
}
return ErrVersionConflict
}
func (s *Service) now() time.Time {
if s.Now != nil {
return s.Now()
}
return time.Now()
}
func validateFilter(request PageRequest) error {
valid := func(value string, values ...string) bool {
if value == "" {
return true
}
for _, candidate := range values {
if value == candidate {
return true
}
}
return false
}
if !valid(request.ObjectType, ObjectDevice, ObjectMedia, ObjectLocalInference) ||
!valid(request.ProblemType, ProblemAuthentication, ProblemBackoff, ProblemClockDrift, ProblemOrphan, ProblemUnavailable, ProblemUnready) ||
!valid(request.Severity, "high", "medium", "info") || len([]rune(request.Keyword)) > 128 {
return ErrInvalidFilter
}
return nil
}
func boolInt(value bool) int {
if value {
return 1
}
return 0
}
@@ -0,0 +1,187 @@
package operations
import (
"context"
"errors"
"testing"
"time"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
adminModels "git.ilapage.cn/ila/yovision/Sense/server/app/admin/models"
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/admission"
deviceModels "git.ilapage.cn/ila/yovision/Sense/server/app/sense/device/models"
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/media"
)
func operationsTestService(t *testing.T) (*Service, *gorm.DB, time.Time) {
t.Helper()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err = db.AutoMigrate(&deviceModels.Device{}, &admissionResult{}, &media.Route{}, &adminModels.SysOperaLog{}); err != nil {
t.Fatal(err)
}
now := time.Date(2026, 8, 28, 2, 0, 0, 0, time.UTC)
service := NewService(db)
service.Now = func() time.Time { return now }
service.LocalInferenceConfigured = false
service.DeviceRetry = nil
return service, db, now
}
func TestProjectionCoversOperationsStatesAndIndependentBoundaries(t *testing.T) {
service, db, now := operationsTestService(t)
devices := []deviceModels.Device{
{ID: "ready", Name: "东门", Location: "一层", Modality: "video", CapabilitiesJSON: "[]", Status: "active", AdapterStatus: "ready", Version: 1},
{ID: "auth", Name: "仓库", Location: "北区", Modality: "video", CapabilitiesJSON: "[]", Status: "active", AdapterStatus: "verification_failed", Version: 3},
{ID: "clock", Name: "南门", Location: "室外", Modality: "video", CapabilitiesJSON: "[]", Status: "active", AdapterStatus: "verification_failed", Version: 4},
}
for index := range devices {
devices[index].CreatedAt, devices[index].UpdatedAt = now, now
}
if err := db.Create(&devices).Error; err != nil {
t.Fatal(err)
}
admissions := []admissionResult{
{DeviceID: "ready", Address: "http://192.0.2.10/onvif", Status: "ready", Detail: "接入验证完成", CheckedAt: now},
{DeviceID: "auth", Address: "http://192.0.2.11/onvif", Status: "authentication_failed", Detail: "ONVIF 认证失败", CheckedAt: now},
{DeviceID: "clock", Address: "http://192.0.2.12/onvif", Status: "clock_drift", Detail: "设备时间漂移 96 秒", CheckedAt: now},
}
if err := db.Create(&admissions).Error; err != nil {
t.Fatal(err)
}
next := now.Add(5 * time.Minute)
routes := []media.Route{
{ID: "auth:main", DeviceID: "auth", ProfileToken: "main", Path: "sense_auth", Desired: "running", Actual: "apply_failed", Detail: "媒体路径配置失败", FailureCount: 2, NextRetryAt: &next, Version: 5, UpdatedAt: now},
{ID: "missing:main", DeviceID: "missing", ProfileToken: "main", Path: "sense_orphan", Desired: "running", Actual: "ready", Detail: "上游拉流正常", Version: 1, UpdatedAt: now},
}
if err := db.Create(&routes).Error; err != nil {
t.Fatal(err)
}
response, err := service.List(PageRequest{})
if err != nil {
t.Fatal(err)
}
if response.Summary.ManagedCount != 5 || response.Summary.ConvergedCount != 1 || response.Summary.ActionableProblems != 4 || response.Summary.LocalInference != "unavailable" || response.Count != 5 {
t.Fatalf("unexpected summary: %#v count=%d", response.Summary, response.Count)
}
wanted := map[string]bool{ProblemAuthentication: false, ProblemClockDrift: false, ProblemBackoff: false, ProblemOrphan: false, ProblemUnavailable: false}
for _, item := range response.List {
wanted[item.ProblemType] = true
if !item.OperationalOnly {
t.Fatalf("problem can be mistaken for Bell alert: %#v", item)
}
}
for state, found := range wanted {
if !found {
t.Fatalf("missing problem state %s: %#v", state, response.List)
}
}
filtered, err := service.List(PageRequest{ObjectType: ObjectDevice, ProblemType: ProblemClockDrift, Keyword: "南门"})
if err != nil || filtered.Count != 1 || filtered.List[0].ObjectID != "clock" {
t.Fatalf("filtered=%#v err=%v", filtered, err)
}
}
func TestControlledRetryUsesVersionAndInProgressGate(t *testing.T) {
service, db, now := operationsTestService(t)
device := deviceModels.Device{ID: "auth", Name: "东门", Modality: "video", CapabilitiesJSON: "[]", Status: "active", AdapterStatus: "verification_failed", Version: 3}
device.CreatedAt, device.UpdatedAt = now, now
if err := db.Create(&device).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&admissionResult{DeviceID: "auth", Address: "http://192.0.2.11/onvif", Status: "authentication_failed", Detail: "认证失败", CheckedAt: now}).Error; err != nil {
t.Fatal(err)
}
item, err := service.Retry(context.Background(), "device:auth", 3, 7)
if err != nil || !item.RetryInProgress || item.Retryable || item.Version != 4 {
t.Fatalf("item=%#v err=%v", item, err)
}
if _, err = service.Retry(context.Background(), "device:auth", 3, 7); !errors.Is(err, ErrRetryInProgress) {
t.Fatalf("expected in-progress gate, got %v", err)
}
var stored deviceModels.Device
if err = db.First(&stored, "id = ?", "auth").Error; err != nil || stored.RetryRequestedAt == nil {
t.Fatalf("stored=%#v err=%v", stored, err)
}
}
func TestMediaRetryQueuesWithoutDeletingOrChangingCredentials(t *testing.T) {
service, db, now := operationsTestService(t)
device := deviceModels.Device{ID: "camera", Name: "仓库", Modality: "video", CapabilitiesJSON: "[]", Status: "active", AdapterStatus: "ready", Version: 1}
device.CreatedAt, device.UpdatedAt = now, now
if err := db.Create(&device).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&admissionResult{DeviceID: "camera", Address: "http://192.0.2.20/onvif", Status: "ready", Detail: "接入完成", CheckedAt: now}).Error; err != nil {
t.Fatal(err)
}
next := now.Add(time.Minute)
route := media.Route{ID: "camera:main", DeviceID: "camera", ProfileToken: "main", Path: "sense_camera", Desired: "running", Actual: "apply_failed", FailureCount: 2, NextRetryAt: &next, Detail: "配置失败", Version: 6, UpdatedAt: now}
if err := db.Create(&route).Error; err != nil {
t.Fatal(err)
}
item, err := service.Retry(context.Background(), "media:camera:main", 6, 7)
if err != nil || item.Actual != "retry_pending" || item.Version != 7 || !item.RetryInProgress {
t.Fatalf("item=%#v err=%v", item, err)
}
var count int64
if err = db.Model(&media.Route{}).Where("id = ?", route.ID).Count(&count).Error; err != nil || count != 1 {
t.Fatalf("route was removed: count=%d err=%v", count, err)
}
}
func TestOrphanAndUnavailableCannotRetry(t *testing.T) {
service, db, now := operationsTestService(t)
route := media.Route{ID: "missing:main", DeviceID: "missing", ProfileToken: "main", Path: "orphan", Desired: "running", Actual: "ready", Version: 1, UpdatedAt: now}
if err := db.Create(&route).Error; err != nil {
t.Fatal(err)
}
if _, err := service.Retry(context.Background(), "media:missing:main", 1, 7); !errors.Is(err, ErrRetryNotAllowed) {
t.Fatalf("orphan retry should be rejected: %v", err)
}
if _, err := service.Retry(context.Background(), "local_inference:adapter", 1, 7); !errors.Is(err, ErrRetryNotAllowed) {
t.Fatalf("optional adapter retry should be rejected: %v", err)
}
}
func TestDeviceRetryUsesExistingAdmissionPortAndOperatorIdentity(t *testing.T) {
service, db, now := operationsTestService(t)
device := deviceModels.Device{ID: "clock", Name: "南门", Modality: "video", CapabilitiesJSON: "[]", Status: "active", AdapterStatus: "verification_failed", Version: 3}
device.CreatedAt, device.UpdatedAt = now, now
if err := db.Create(&device).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&admissionResult{DeviceID: "clock", Address: "http://192.0.2.12/onvif", Status: "clock_drift", Detail: "设备时间漂移", CheckedAt: now}).Error; err != nil {
t.Fatal(err)
}
called := false
service.DeviceRetry = func(_ context.Context, request admission.ProbeRequest) error {
called = true
if request.DeviceID != "clock" || request.Address != "http://192.0.2.12/onvif" || request.Version != 4 || request.UpdateBy != 9 {
t.Fatalf("unexpected retry request: %#v", request)
}
return db.Model(&deviceModels.Device{}).Where("id = ? AND version = ?", request.DeviceID, request.Version).Updates(map[string]any{"retry_requested_at": nil, "version": request.Version + 1}).Error
}
item, err := service.Retry(context.Background(), "device:clock", 3, 9)
if err != nil || !called || item.Version != 5 || item.RetryInProgress {
t.Fatalf("item=%#v called=%v err=%v", item, called, err)
}
}
func TestAuditIsMinimalAndDesensitized(t *testing.T) {
_, db, now := operationsTestService(t)
if err := WriteAudit(db, Audit{Action: "Retry", Method: "POST", Status: "1", Username: "operator", UserID: 7, ClientIP: "127.0.0.1", Route: "/api/v1/operations/:id/retry", Remark: "受控重试已排队 device:synthetic", At: now}); err != nil {
t.Fatal(err)
}
var stored adminModels.SysOperaLog
if err := db.First(&stored).Error; err != nil {
t.Fatal(err)
}
if stored.Title != "运维中心" || stored.RequestMethod != "POST" || stored.OperParam != "" || stored.JsonResult != "" || stored.CreateBy != 7 {
t.Fatalf("unexpected audit: %#v", stored)
}
}
+176
View File
@@ -0,0 +1,176 @@
package provisioning
import (
"encoding/csv"
"encoding/json"
"errors"
"io"
"net/http"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"github.com/go-admin-team/go-admin-core/sdk/api"
"github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth/user"
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/quota"
)
type API struct{ api.Api }
func (e *API) service(c *gin.Context) (*Service, error) {
service := &Service{}
if err := e.MakeContext(c).MakeOrm().MakeService(&service.Service).Errors; err != nil {
return nil, err
}
return service, nil
}
func (e *API) List(c *gin.Context) {
service, err := e.service(c)
if err != nil {
e.writeError(err)
return
}
request := BatchPageRequest{}
if err = e.MakeContext(c).Bind(&request).Errors; err != nil {
e.Error(http.StatusBadRequest, err, "查询条件格式不正确")
return
}
list, count, err := service.ListBatches(&request)
if err != nil {
e.writeError(err)
return
}
e.PageOK(list, int(count), request.GetPageIndex(), request.GetPageSize(), "查询成功")
}
func (e *API) Get(c *gin.Context) {
service, err := e.service(c)
if err != nil {
e.writeError(err)
return
}
response, err := service.GetBatch(c.Param("id"))
if err != nil {
e.writeError(err)
return
}
e.OK(response, "查询成功")
}
func (e *API) Create(c *gin.Context) {
service, err := e.service(c)
if err != nil {
e.writeError(err)
return
}
request := CreateBatchRequest{CreateBy: user.GetUserId(c)}
if err = bindStrictJSON(c, &request); err != nil {
e.Error(http.StatusBadRequest, err, "导入内容格式不正确")
return
}
response, err := service.CreateBatch(request)
if err != nil {
e.writeError(err)
return
}
e.OK(response, "批次已导入并完成预校验")
}
func (e *API) Execute(c *gin.Context) { e.execute(c, false, "") }
func (e *API) RetryFailed(c *gin.Context) { e.execute(c, true, "") }
func (e *API) RetryItem(c *gin.Context) { e.execute(c, true, c.Param("itemId")) }
func (e *API) execute(c *gin.Context, retryFailed bool, itemID string) {
service, err := e.service(c)
if err != nil {
e.writeError(err)
return
}
request := ExecuteRequest{UpdateBy: user.GetUserId(c)}
defer func() { clearCredentials(request.Credentials) }()
if err = bindStrictJSON(c, &request); err != nil {
e.Error(http.StatusBadRequest, err, "执行内容格式不正确")
return
}
response, err := service.Execute(c.Request.Context(), c.Param("id"), itemID, retryFailed, request)
if err != nil {
e.writeError(err)
return
}
e.OK(response, "批量开通处理完成")
}
func (e *API) Export(c *gin.Context) {
service, err := e.service(c)
if err != nil {
e.writeError(err)
return
}
batch, err := service.GetBatch(c.Param("id"))
if err != nil {
e.writeError(err)
return
}
c.Header("Content-Type", "text/csv; charset=utf-8")
c.Header("Content-Disposition", "attachment; filename=provisioning-"+batch.ID+".csv")
c.Status(http.StatusOK)
writer := csv.NewWriter(c.Writer)
if err = writer.Write([]string{"line_number", "name", "location", "address", "status", "failure_code", "detail", "device_id"}); err != nil {
e.Logger.Error(err)
return
}
for _, item := range batch.Items {
if err = writer.Write([]string{strconv.Itoa(item.LineNumber), item.Name, item.Location, item.Address, item.Status, item.FailureCode, item.Detail, item.DeviceID}); err != nil {
e.Logger.Error(err)
return
}
}
writer.Flush()
if err = writer.Error(); err != nil {
e.Logger.Error(err)
}
}
func (e *API) writeError(err error) {
switch {
case errors.Is(err, ErrInvalidRequest):
e.Error(http.StatusBadRequest, err, err.Error())
case errors.Is(err, ErrBatchNotFound), errors.Is(err, ErrItemNotFound):
e.Error(http.StatusNotFound, err, err.Error())
case errors.Is(err, quota.ErrExceeded):
e.Error(http.StatusConflict, err, "当前配额已满,无法继续批量开通")
case errors.Is(err, quota.ErrUnavailable):
e.Error(http.StatusServiceUnavailable, err, "配额配置不可读取,已拒绝批量开通写入")
default:
e.Error(http.StatusInternalServerError, err, "批量开通操作失败")
}
}
func bindStrictJSON(c *gin.Context, target any) error {
if !strings.HasPrefix(strings.ToLower(strings.TrimSpace(c.GetHeader("Content-Type"))), "application/json") {
return errors.New("content type must be application/json")
}
decoder := json.NewDecoder(http.MaxBytesReader(c.Writer, c.Request.Body, 2<<20))
decoder.DisallowUnknownFields()
if err := decoder.Decode(target); err != nil {
return err
}
if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
if err == nil {
return errors.New("request body must contain one JSON object")
}
return err
}
return nil
}
func clearCredentials(values []CredentialInput) {
for index := range values {
values[index].ONVIFUsername = ""
values[index].ONVIFPassword = ""
values[index].RTSPUsername = ""
values[index].RTSPPassword = ""
}
}
@@ -0,0 +1,32 @@
package provisioning
import (
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
)
func TestCreateRequestRejectsCredentialColumns(t *testing.T) {
gin.SetMode(gin.TestMode)
recorder := httptest.NewRecorder()
context, _ := gin.CreateTestContext(recorder)
context.Request = httptest.NewRequest("POST", "/api/v1/provisioning/batches", strings.NewReader(`{
"idempotencyKey":"import-1",
"rows":[{"lineNumber":1,"name":"东门摄像机","location":"东门","address":"http://192.0.2.10/onvif","password":"must-not-be-accepted"}]
}`))
context.Request.Header.Set("Content-Type", "application/json")
var request CreateBatchRequest
if err := bindStrictJSON(context, &request); err == nil || !strings.Contains(err.Error(), "unknown field") {
t.Fatalf("expected unknown credential field to be rejected, got %v", err)
}
}
func TestClearCredentialsOverwritesTransientValues(t *testing.T) {
values := []CredentialInput{{ONVIFUsername: "installer", ONVIFPassword: "temporary-secret", RTSPUsername: "stream", RTSPPassword: "stream-secret"}}
clearCredentials(values)
if values[0].ONVIFUsername != "" || values[0].ONVIFPassword != "" || values[0].RTSPUsername != "" || values[0].RTSPPassword != "" {
t.Fatalf("credentials were not cleared: %#v", values[0])
}
}
@@ -0,0 +1,68 @@
package provisioning
import (
"time"
commonDTO "git.ilapage.cn/ila/yovision/Sense/server/common/dto"
)
type BatchPageRequest struct {
commonDTO.Pagination `search:"-"`
Status string `form:"status"`
}
type ImportRow struct {
LineNumber int `json:"lineNumber"`
Name string `json:"name"`
Location string `json:"location"`
Address string `json:"address"`
}
type CreateBatchRequest struct {
IdempotencyKey string `json:"idempotencyKey"`
Rows []ImportRow `json:"rows"`
CreateBy int `json:"-"`
}
type CredentialInput struct {
ItemID string `json:"itemId"`
ONVIFUsername string `json:"onvifUsername"`
ONVIFPassword string `json:"onvifPassword"`
RTSPSameAsONVIF bool `json:"rtspSameAsOnvif"`
RTSPUsername string `json:"rtspUsername"`
RTSPPassword string `json:"rtspPassword"`
}
type ExecuteRequest struct {
Credentials []CredentialInput `json:"credentials"`
UpdateBy int `json:"-"`
}
type ItemResponse struct {
ID string `json:"id"`
LineNumber int `json:"lineNumber"`
Name string `json:"name"`
Location string `json:"location"`
Address string `json:"address"`
Status string `json:"status"`
FailureCode string `json:"failureCode,omitempty"`
Detail string `json:"detail,omitempty"`
DeviceID string `json:"deviceId,omitempty"`
Attempts int `json:"attempts"`
LastTriedAt *time.Time `json:"lastTriedAt,omitempty"`
}
type BatchResponse struct {
ID string `json:"id"`
IdempotencyKey string `json:"idempotencyKey"`
Status string `json:"status"`
QuotaLimit int `json:"quotaLimit"`
ExistingCount int `json:"existingCount"`
TotalCount int `json:"totalCount"`
ReadyCount int `json:"readyCount"`
SuccessCount int `json:"successCount"`
FailureCount int `json:"failureCount"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
Items []ItemResponse `json:"items,omitempty"`
}
@@ -0,0 +1,58 @@
package provisioning
import (
"time"
common "git.ilapage.cn/ila/yovision/Sense/server/common/models"
)
const (
BatchReady = "ready"
BatchRunning = "running"
BatchSucceeded = "succeeded"
BatchPartial = "partial"
BatchFailed = "failed"
ItemInvalid = "invalid"
ItemReady = "ready"
ItemQuotaExceeded = "quota_exceeded"
ItemRunning = "running"
ItemSucceeded = "succeeded"
ItemFailed = "failed"
)
type Batch struct {
ID string `gorm:"size:36;primaryKey" json:"id"`
IdempotencyKey string `gorm:"size:128;not null;uniqueIndex" json:"idempotencyKey"`
Status string `gorm:"size:32;not null;index" json:"status"`
QuotaLimit int `gorm:"not null" json:"quotaLimit"`
ExistingCount int `gorm:"not null" json:"existingCount"`
TotalCount int `gorm:"not null" json:"totalCount"`
ReadyCount int `gorm:"not null" json:"readyCount"`
SuccessCount int `gorm:"not null" json:"successCount"`
FailureCount int `gorm:"not null" json:"failureCount"`
common.ControlBy
common.ModelTime
Items []Item `gorm:"foreignKey:BatchID" json:"items,omitempty"`
}
func (Batch) TableName() string { return "sense_provisioning_batches" }
type Item struct {
ID string `gorm:"size:36;primaryKey" json:"id"`
BatchID string `gorm:"size:36;not null;uniqueIndex:batch_line;index" json:"batchId"`
LineNumber int `gorm:"not null;uniqueIndex:batch_line" json:"lineNumber"`
Name string `gorm:"size:128;not null" json:"name"`
Location string `gorm:"size:255;not null;default:''" json:"location"`
Address string `gorm:"size:1024;not null" json:"address"`
Status string `gorm:"size:32;not null;index" json:"status"`
FailureCode string `gorm:"size:64;not null;default:''" json:"failureCode,omitempty"`
Detail string `gorm:"size:512;not null;default:''" json:"detail,omitempty"`
DeviceID string `gorm:"size:36;not null;default:'';index" json:"deviceId,omitempty"`
Attempts int `gorm:"not null;default:0" json:"attempts"`
LastTriedAt *time.Time `json:"lastTriedAt,omitempty"`
common.ControlBy
common.ModelTime
}
func (Item) TableName() string { return "sense_provisioning_items" }
@@ -0,0 +1,360 @@
package provisioning
import (
"context"
"errors"
"fmt"
"net/url"
"sort"
"strings"
"time"
coreService "github.com/go-admin-team/go-admin-core/sdk/service"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgconn"
"gorm.io/gorm"
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/admission"
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/credential"
deviceService "git.ilapage.cn/ila/yovision/Sense/server/app/sense/device/service"
deviceDTO "git.ilapage.cn/ila/yovision/Sense/server/app/sense/device/service/dto"
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/quota"
)
var (
ErrInvalidRequest = errors.New("批量开通请求不符合要求")
ErrBatchNotFound = errors.New("批量开通批次不存在")
ErrItemNotFound = errors.New("批量开通条目不存在")
)
type Activator func(context.Context, *Service, *Item, CredentialInput, int) (string, string, string, error)
type Service struct {
coreService.Service
Quota int
Activator Activator
}
func (s *Service) quotaLimit() (int, error) {
if s.Quota > 0 {
return s.Quota, nil
}
return quota.ReadLimit(s.Orm)
}
func (s *Service) CreateBatch(request CreateBatchRequest) (BatchResponse, error) {
request.IdempotencyKey = strings.TrimSpace(request.IdempotencyKey)
if request.IdempotencyKey == "" || len(request.IdempotencyKey) > 128 || len(request.Rows) == 0 || len(request.Rows) > 1000 {
return BatchResponse{}, ErrInvalidRequest
}
var existing Batch
if err := s.Orm.Preload("Items", func(db *gorm.DB) *gorm.DB { return db.Order("line_number") }).First(&existing, "idempotency_key = ?", request.IdempotencyKey).Error; err == nil {
return batchResponse(existing), nil
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return BatchResponse{}, fmt.Errorf("read provisioning idempotency key: %w", err)
}
var existingDevices int64
if err := s.Orm.Table("sense_devices").Where("status <> ?", "disabled").Count(&existingDevices).Error; err != nil {
return BatchResponse{}, fmt.Errorf("count provisioned devices: %w", err)
}
quotaLimit, err := s.quotaLimit()
if err != nil {
return BatchResponse{}, err
}
available := quotaLimit - int(existingDevices)
if available < 0 {
available = 0
}
batch := Batch{ID: uuid.NewString(), IdempotencyKey: request.IdempotencyKey, Status: BatchReady, QuotaLimit: quotaLimit, ExistingCount: int(existingDevices), TotalCount: len(request.Rows)}
batch.CreateBy, batch.UpdateBy = request.CreateBy, request.CreateBy
seenLines := map[int]bool{}
seenAddresses := map[string]bool{}
ready := 0
for _, row := range request.Rows {
item := Item{ID: uuid.NewString(), BatchID: batch.ID, LineNumber: row.LineNumber, Name: strings.TrimSpace(row.Name), Location: strings.TrimSpace(row.Location), Address: strings.TrimSpace(row.Address), Status: ItemReady}
item.CreateBy, item.UpdateBy = request.CreateBy, request.CreateBy
code, detail := validateImportRow(item, seenLines, seenAddresses)
if code != "" {
item.Status, item.FailureCode, item.Detail = ItemInvalid, code, detail
} else if ready >= available {
item.Status, item.FailureCode, item.Detail = ItemQuotaExceeded, "quota_exceeded", "超出当前可用配额,请调整配额或批次后重试"
} else {
ready++
}
batch.Items = append(batch.Items, item)
}
batch.ReadyCount = ready
if ready == 0 {
batch.Status = BatchFailed
}
if err := s.Orm.Transaction(func(tx *gorm.DB) error {
if err := tx.Omit("Items").Create(&batch).Error; err != nil {
return err
}
return tx.Create(&batch.Items).Error
}); err != nil {
if isDuplicateKey(err) {
return s.GetBatchByKey(request.IdempotencyKey)
}
return BatchResponse{}, fmt.Errorf("create provisioning batch: %w", err)
}
return s.GetBatch(batch.ID)
}
func validateImportRow(item Item, seenLines map[int]bool, seenAddresses map[string]bool) (string, string) {
if item.LineNumber < 1 || seenLines[item.LineNumber] {
return "invalid_line_number", "行号必须为正整数且批次内唯一"
}
seenLines[item.LineNumber] = true
if item.Name == "" || len([]rune(item.Name)) > 128 || len([]rune(item.Location)) > 255 {
return "invalid_device", "设备名称不能为空,名称或安装位置长度不能超过限制"
}
parsed, err := url.Parse(item.Address)
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Hostname() == "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" {
return "invalid_address", "设备地址必须是无账号、查询参数和片段的 HTTP(S) 地址"
}
key := strings.ToLower(parsed.String())
if seenAddresses[key] {
return "duplicate_address", "同一批次中设备地址不能重复"
}
seenAddresses[key] = true
return "", ""
}
func (s *Service) ListBatches(request *BatchPageRequest) ([]BatchResponse, int64, error) {
query := s.Orm.Model(&Batch{})
if request.Status != "" {
query = query.Where("status = ?", request.Status)
}
var count int64
if err := query.Count(&count).Error; err != nil {
return nil, 0, err
}
pageSize := request.GetPageSize()
if pageSize > 100 {
pageSize = 100
}
var batches []Batch
if err := query.Order("created_at DESC").Limit(pageSize).Offset((request.GetPageIndex() - 1) * pageSize).Find(&batches).Error; err != nil {
return nil, 0, err
}
result := make([]BatchResponse, 0, len(batches))
for _, batch := range batches {
result = append(result, batchResponse(batch))
}
return result, count, nil
}
func (s *Service) GetBatch(id string) (BatchResponse, error) {
var batch Batch
if err := s.Orm.Preload("Items", func(db *gorm.DB) *gorm.DB { return db.Order("line_number") }).First(&batch, "id = ?", id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return BatchResponse{}, ErrBatchNotFound
}
return BatchResponse{}, err
}
return batchResponse(batch), nil
}
func (s *Service) GetBatchByKey(key string) (BatchResponse, error) {
var batch Batch
if err := s.Orm.Preload("Items", func(db *gorm.DB) *gorm.DB { return db.Order("line_number") }).First(&batch, "idempotency_key = ?", key).Error; err != nil {
return BatchResponse{}, err
}
return batchResponse(batch), nil
}
func (s *Service) Execute(ctx context.Context, batchID string, itemID string, retryFailed bool, request ExecuteRequest) (BatchResponse, error) {
credentialByItem := make(map[string]CredentialInput, len(request.Credentials))
for _, input := range request.Credentials {
if input.ItemID == "" || credentialByItem[input.ItemID].ItemID != "" {
return BatchResponse{}, ErrInvalidRequest
}
credentialByItem[input.ItemID] = input
}
var batch Batch
if err := s.Orm.First(&batch, "id = ?", batchID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return BatchResponse{}, ErrBatchNotFound
}
return BatchResponse{}, err
}
query := s.Orm.Where("batch_id = ?", batchID)
if itemID != "" {
query = query.Where("id = ?", itemID)
}
if retryFailed {
query = query.Where("status = ?", ItemFailed)
} else {
query = query.Where("status = ?", ItemReady)
}
var items []Item
if err := query.Order("line_number").Find(&items).Error; err != nil {
return BatchResponse{}, err
}
if itemID != "" && len(items) == 0 {
return BatchResponse{}, ErrItemNotFound
}
if len(items) == 0 {
return s.GetBatch(batchID)
}
if err := s.Orm.Model(&Batch{}).Where("id = ?", batchID).Updates(map[string]any{"status": BatchRunning, "update_by": request.UpdateBy}).Error; err != nil {
return BatchResponse{}, fmt.Errorf("mark provisioning batch running: %w", err)
}
activate := s.Activator
if activate == nil {
activate = defaultActivate
}
for index := range items {
item := &items[index]
now := time.Now().UTC()
expectedStatus := ItemReady
if retryFailed {
expectedStatus = ItemFailed
}
claim := s.Orm.Model(&Item{}).Where("id = ? AND status = ?", item.ID, expectedStatus).Updates(map[string]any{"status": ItemRunning, "attempts": gorm.Expr("attempts + 1"), "last_tried_at": now, "update_by": request.UpdateBy})
if claim.Error != nil {
return BatchResponse{}, fmt.Errorf("mark provisioning item running: %w", claim.Error)
}
if claim.RowsAffected == 0 {
continue
}
input, ok := credentialByItem[item.ID]
if !ok || strings.TrimSpace(input.ONVIFUsername) == "" || input.ONVIFPassword == "" {
if err := s.failItem(item.ID, request.UpdateBy, "credentials_required", "请安全填写该设备的账号密码后重试"); err != nil {
return BatchResponse{}, err
}
continue
}
deviceID, status, detail, err := activate(ctx, s, item, input, request.UpdateBy)
if deviceID != "" && deviceID != item.DeviceID {
item.DeviceID = deviceID
if err := s.Orm.Model(&Item{}).Where("id = ?", item.ID).Update("device_id", deviceID).Error; err != nil {
return BatchResponse{}, fmt.Errorf("link provisioning item to device: %w", err)
}
}
if err != nil {
code, safeDetail := safeFailure(err)
if updateErr := s.failItem(item.ID, request.UpdateBy, code, safeDetail); updateErr != nil {
return BatchResponse{}, updateErr
}
continue
}
if status != "ready" {
if detail == "" {
detail = "设备或视频流尚未通过验证"
}
if err := s.failItem(item.ID, request.UpdateBy, status, detail); err != nil {
return BatchResponse{}, err
}
continue
}
if err := s.Orm.Model(&Item{}).Where("id = ?", item.ID).Updates(map[string]any{"status": ItemSucceeded, "failure_code": "", "detail": "设备已开通并通过视频验证", "update_by": request.UpdateBy}).Error; err != nil {
return BatchResponse{}, fmt.Errorf("mark provisioning item succeeded: %w", err)
}
}
if err := s.refreshBatch(batchID, request.UpdateBy); err != nil {
return BatchResponse{}, err
}
return s.GetBatch(batchID)
}
func isDuplicateKey(err error) bool {
if errors.Is(err, gorm.ErrDuplicatedKey) {
return true
}
var postgresError *pgconn.PgError
return errors.As(err, &postgresError) && postgresError.Code == "23505"
}
func (s *Service) failItem(id string, updateBy int, code, detail string) error {
if err := s.Orm.Model(&Item{}).Where("id = ?", id).Updates(map[string]any{"status": ItemFailed, "failure_code": code, "detail": detail, "update_by": updateBy}).Error; err != nil {
return fmt.Errorf("mark provisioning item failed: %w", err)
}
return nil
}
func (s *Service) refreshBatch(batchID string, updateBy int) error {
var items []Item
if err := s.Orm.Where("batch_id = ?", batchID).Find(&items).Error; err != nil {
return err
}
ready, success, failure := 0, 0, 0
for _, item := range items {
switch item.Status {
case ItemReady, ItemRunning:
ready++
case ItemSucceeded:
success++
case ItemFailed:
failure++
}
}
status := BatchFailed
if success == len(items) {
status = BatchSucceeded
} else if success > 0 {
status = BatchPartial
} else if ready > 0 {
status = BatchReady
}
return s.Orm.Model(&Batch{}).Where("id = ?", batchID).Updates(map[string]any{"status": status, "ready_count": ready, "success_count": success, "failure_count": failure, "update_by": updateBy}).Error
}
func defaultActivate(ctx context.Context, service *Service, item *Item, input CredentialInput, updateBy int) (string, string, string, error) {
device := deviceService.Device{Service: service.Service}
deviceID := item.DeviceID
var response deviceDTO.DeviceResponse
if deviceID == "" {
if err := device.Insert(&deviceDTO.CreateReq{Name: item.Name, Location: item.Location, Modality: "video", Capabilities: []string{"video"}, CreateBy: updateBy}, &response); err != nil {
return "", "", "", err
}
deviceID = response.ID
if err := service.Orm.Model(&Item{}).Where("id = ?", item.ID).Update("device_id", deviceID).Error; err != nil {
return deviceID, "", "", err
}
} else if err := device.Get(deviceID, &response); err != nil {
return deviceID, "", "", err
}
if err := device.UpdateCredentials(&deviceDTO.CredentialUpdateReq{ID: deviceID, ONVIFUsername: input.ONVIFUsername, ONVIFPassword: input.ONVIFPassword, RTSPSameAsONVIF: input.RTSPSameAsONVIF, RTSPUsername: input.RTSPUsername, RTSPPassword: input.RTSPPassword, Version: response.Version, UpdateBy: updateBy}, &response); err != nil {
return deviceID, "", "", err
}
probeService, err := admission.NewRuntime(service.Service)
if err != nil {
return deviceID, "", "", err
}
result, err := probeService.Probe(ctx, admission.ProbeRequest{DeviceID: deviceID, Address: item.Address, Version: response.Version, UpdateBy: updateBy})
if err != nil {
return deviceID, "", "", err
}
return deviceID, result.Status, result.Detail, nil
}
func safeFailure(err error) (string, string) {
switch {
case errors.Is(err, credential.ErrKeyUnavailable):
return "credential_key_unavailable", "摄像头凭据安全配置不可用"
case errors.Is(err, credential.ErrCredentialNotConfigured):
return "credentials_required", "请安全填写摄像头账号密码后重试"
case errors.Is(err, deviceService.ErrInvalidDevice), errors.Is(err, admission.ErrInvalid):
return "invalid_device", "设备信息或凭据不符合要求"
case errors.Is(err, deviceService.ErrVersionConflict), errors.Is(err, admission.ErrConflict):
return "version_conflict", "设备已被其他操作更新,请重试"
case errors.Is(err, quota.ErrExceeded):
return "quota_exceeded", "当前配额已满,请调整配额或停用其他设备后重试"
case errors.Is(err, quota.ErrUnavailable):
return "quota_unavailable", "配额配置不可读取,已拒绝新增或启用设备"
default:
return "activation_failed", "设备开通失败,请检查网络、地址和凭据后重试"
}
}
func batchResponse(batch Batch) BatchResponse {
response := BatchResponse{ID: batch.ID, IdempotencyKey: batch.IdempotencyKey, Status: batch.Status, QuotaLimit: batch.QuotaLimit, ExistingCount: batch.ExistingCount, TotalCount: batch.TotalCount, ReadyCount: batch.ReadyCount, SuccessCount: batch.SuccessCount, FailureCount: batch.FailureCount, CreatedAt: batch.CreatedAt, UpdatedAt: batch.UpdatedAt, Items: make([]ItemResponse, 0, len(batch.Items))}
sort.Slice(batch.Items, func(i, j int) bool { return batch.Items[i].LineNumber < batch.Items[j].LineNumber })
for _, item := range batch.Items {
response.Items = append(response.Items, ItemResponse{ID: item.ID, LineNumber: item.LineNumber, Name: item.Name, Location: item.Location, Address: item.Address, Status: item.Status, FailureCode: item.FailureCode, Detail: item.Detail, DeviceID: item.DeviceID, Attempts: item.Attempts, LastTriedAt: item.LastTriedAt})
}
return response
}
@@ -0,0 +1,267 @@
package provisioning
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"sync"
"sync/atomic"
"testing"
coreService "github.com/go-admin-team/go-admin-core/sdk/service"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgconn"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
deviceModels "git.ilapage.cn/ila/yovision/Sense/server/app/sense/device/models"
)
func provisioningService(t *testing.T, quota int) *Service {
t.Helper()
db, err := gorm.Open(sqlite.Open("file:"+uuid.NewString()+"?mode=memory&cache=shared"), &gorm.Config{TranslateError: true})
if err != nil {
t.Fatal(err)
}
sqlDB, err := db.DB()
if err != nil {
t.Fatal(err)
}
sqlDB.SetMaxOpenConns(1)
if err = db.AutoMigrate(&deviceModels.Device{}, &Batch{}, &Item{}); err != nil {
t.Fatal(err)
}
return &Service{Service: coreService.Service{Orm: db}, Quota: quota}
}
func TestCreateBatchValidatesQuotaAndIsIdempotent(t *testing.T) {
service := provisioningService(t, 2)
if err := service.Orm.Create(&deviceModels.Device{ID: "existing", Name: "已接入摄像机", Modality: "video", Status: "active", AdapterStatus: "ready", Version: 1}).Error; err != nil {
t.Fatal(err)
}
request := CreateBatchRequest{
IdempotencyKey: "import-001",
CreateBy: 7,
Rows: []ImportRow{
{LineNumber: 1, Name: "东门摄像机", Location: "东门", Address: "http://192.0.2.10/onvif"},
{LineNumber: 2, Name: "重复地址", Location: "东门", Address: "http://192.0.2.10/onvif"},
{LineNumber: 3, Name: "西门摄像机", Location: "西门", Address: "https://192.0.2.11/onvif"},
},
}
created, err := service.CreateBatch(request)
if err != nil {
t.Fatal(err)
}
if created.QuotaLimit != 2 || created.ExistingCount != 1 || created.ReadyCount != 1 || len(created.Items) != 3 {
t.Fatalf("unexpected batch: %#v", created)
}
if created.Items[0].Status != ItemReady || created.Items[1].Status != ItemInvalid || created.Items[2].Status != ItemQuotaExceeded {
t.Fatalf("unexpected item statuses: %#v", created.Items)
}
request.Rows = []ImportRow{{LineNumber: 1, Name: "不应覆盖", Address: "http://192.0.2.99/onvif"}}
repeated, err := service.CreateBatch(request)
if err != nil {
t.Fatal(err)
}
if repeated.ID != created.ID || repeated.TotalCount != 3 || repeated.Items[0].Name != "东门摄像机" {
t.Fatalf("idempotent replay changed the batch: %#v", repeated)
}
}
func TestExecuteSupportsPartialSuccessAndFailedOnlyRetry(t *testing.T) {
service := provisioningService(t, 16)
created, err := service.CreateBatch(CreateBatchRequest{
IdempotencyKey: "execute-001",
Rows: []ImportRow{
{LineNumber: 1, Name: "东门摄像机", Address: "http://192.0.2.10/onvif"},
{LineNumber: 2, Name: "西门摄像机", Address: "http://192.0.2.11/onvif"},
},
})
if err != nil {
t.Fatal(err)
}
calls := map[int]int{}
service.Activator = func(_ context.Context, _ *Service, item *Item, input CredentialInput, _ int) (string, string, string, error) {
calls[item.LineNumber]++
if input.ONVIFPassword != "temporary-secret" {
t.Fatalf("activator did not receive the transient credential")
}
if item.LineNumber == 2 && calls[item.LineNumber] == 1 {
return "device-2", "", "", errors.New("synthetic network failure containing temporary-secret")
}
return "device-" + string(rune('0'+item.LineNumber)), "ready", "验证通过", nil
}
credentials := make([]CredentialInput, 0, len(created.Items))
for _, item := range created.Items {
credentials = append(credentials, CredentialInput{ItemID: item.ID, ONVIFUsername: "installer", ONVIFPassword: "temporary-secret", RTSPSameAsONVIF: true})
}
partial, err := service.Execute(context.Background(), created.ID, "", false, ExecuteRequest{Credentials: credentials, UpdateBy: 8})
if err != nil {
t.Fatal(err)
}
if partial.Status != BatchPartial || partial.SuccessCount != 1 || partial.FailureCount != 1 {
t.Fatalf("expected partial success, got %#v", partial)
}
if partial.Items[1].Detail == "" || strings.Contains(partial.Items[1].Detail, "temporary-secret") {
t.Fatalf("unsafe failure detail: %q", partial.Items[1].Detail)
}
retried, err := service.Execute(context.Background(), created.ID, "", true, ExecuteRequest{Credentials: []CredentialInput{{ItemID: created.Items[1].ID, ONVIFUsername: "installer", ONVIFPassword: "temporary-secret", RTSPSameAsONVIF: true}}, UpdateBy: 8})
if err != nil {
t.Fatal(err)
}
if retried.Status != BatchSucceeded || retried.SuccessCount != 2 || calls[1] != 1 || calls[2] != 2 {
t.Fatalf("failed-only retry was not idempotent: response=%#v calls=%#v", retried, calls)
}
encoded, err := json.Marshal(retried)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(encoded), "temporary-secret") || strings.Contains(string(encoded), "installer") {
t.Fatalf("response contains credentials: %s", encoded)
}
var stored []Item
if err = service.Orm.Find(&stored).Error; err != nil {
t.Fatal(err)
}
storedJSON, _ := json.Marshal(stored)
if strings.Contains(string(storedJSON), "temporary-secret") || strings.Contains(string(storedJSON), "installer") {
t.Fatalf("provisioning rows contain credentials: %s", storedJSON)
}
}
func TestExecuteRequiresCredentialsWithoutCallingActivator(t *testing.T) {
service := provisioningService(t, 16)
created, err := service.CreateBatch(CreateBatchRequest{IdempotencyKey: "missing-credentials", Rows: []ImportRow{{LineNumber: 1, Name: "东门摄像机", Address: "http://192.0.2.10/onvif"}}})
if err != nil {
t.Fatal(err)
}
called := false
service.Activator = func(context.Context, *Service, *Item, CredentialInput, int) (string, string, string, error) {
called = true
return "", "", "", nil
}
result, err := service.Execute(context.Background(), created.ID, "", false, ExecuteRequest{})
if err != nil {
t.Fatal(err)
}
if called || result.Status != BatchFailed || result.Items[0].FailureCode != "credentials_required" {
t.Fatalf("missing credentials were not handled safely: %#v called=%v", result, called)
}
}
func TestConcurrentCreateUsesOneIdempotentBatch(t *testing.T) {
service := provisioningService(t, 16)
sqlDB, err := service.Orm.DB()
if err != nil {
t.Fatal(err)
}
sqlDB.SetMaxOpenConns(1)
request := CreateBatchRequest{IdempotencyKey: "concurrent-import", Rows: []ImportRow{{LineNumber: 1, Name: "东门摄像机", Address: "http://192.0.2.10/onvif"}}}
const workers = 8
ids := make(chan string, workers)
errs := make(chan error, workers)
var wait sync.WaitGroup
for index := 0; index < workers; index++ {
wait.Add(1)
go func() {
defer wait.Done()
batch, createErr := service.CreateBatch(request)
if createErr != nil {
errs <- createErr
return
}
ids <- batch.ID
}()
}
wait.Wait()
close(ids)
close(errs)
for createErr := range errs {
t.Fatalf("concurrent create failed: %v", createErr)
}
var first string
for id := range ids {
if first == "" {
first = id
} else if id != first {
t.Fatalf("idempotent creates returned different batches: %q and %q", first, id)
}
}
var count int64
if err = service.Orm.Model(&Batch{}).Count(&count).Error; err != nil || count != 1 {
t.Fatalf("batch count=%d err=%v", count, err)
}
}
func TestSixteenDeviceBaselineSmoke(t *testing.T) {
service := provisioningService(t, 16)
rows := make([]ImportRow, 0, 16)
for index := 1; index <= 16; index++ {
rows = append(rows, ImportRow{LineNumber: index, Name: fmt.Sprintf("摄像机-%02d", index), Address: fmt.Sprintf("http://192.0.2.%d/onvif", index)})
}
created, err := service.CreateBatch(CreateBatchRequest{IdempotencyKey: "sixteen-device-smoke", Rows: rows})
if err != nil {
t.Fatal(err)
}
if created.ReadyCount != 16 {
t.Fatalf("ready count=%d", created.ReadyCount)
}
service.Activator = func(_ context.Context, _ *Service, item *Item, _ CredentialInput, _ int) (string, string, string, error) {
return "device-" + item.ID, "ready", "验证通过", nil
}
credentials := make([]CredentialInput, 0, 16)
for _, item := range created.Items {
credentials = append(credentials, CredentialInput{ItemID: item.ID, ONVIFUsername: "installer", ONVIFPassword: "temporary-secret", RTSPSameAsONVIF: true})
}
completed, err := service.Execute(context.Background(), created.ID, "", false, ExecuteRequest{Credentials: credentials})
if err != nil {
t.Fatal(err)
}
if completed.Status != BatchSucceeded || completed.SuccessCount != 16 || completed.FailureCount != 0 {
t.Fatalf("unexpected 16-device result: %#v", completed)
}
}
func TestConcurrentExecuteClaimsAnItemOnce(t *testing.T) {
service := provisioningService(t, 16)
created, err := service.CreateBatch(CreateBatchRequest{IdempotencyKey: "concurrent-execute", Rows: []ImportRow{{LineNumber: 1, Name: "东门摄像机", Address: "http://192.0.2.10/onvif"}}})
if err != nil {
t.Fatal(err)
}
var calls atomic.Int32
service.Activator = func(_ context.Context, _ *Service, item *Item, _ CredentialInput, _ int) (string, string, string, error) {
calls.Add(1)
return "device-" + item.ID, "ready", "验证通过", nil
}
request := ExecuteRequest{Credentials: []CredentialInput{{ItemID: created.Items[0].ID, ONVIFUsername: "installer", ONVIFPassword: "temporary-secret", RTSPSameAsONVIF: true}}}
var wait sync.WaitGroup
errs := make(chan error, 2)
for index := 0; index < 2; index++ {
wait.Add(1)
go func() {
defer wait.Done()
_, executeErr := service.Execute(context.Background(), created.ID, "", false, request)
errs <- executeErr
}()
}
wait.Wait()
close(errs)
for executeErr := range errs {
if executeErr != nil {
t.Fatal(executeErr)
}
}
if calls.Load() != 1 {
t.Fatalf("activator calls=%d", calls.Load())
}
}
func TestPostgresDuplicateKeyDetection(t *testing.T) {
if !isDuplicateKey(&pgconn.PgError{Code: "23505"}) || isDuplicateKey(&pgconn.PgError{Code: "23503"}) {
t.Fatal("PostgreSQL duplicate-key detection is incorrect")
}
}
+73
View File
@@ -0,0 +1,73 @@
package quota
import (
"errors"
"net/http"
"github.com/gin-gonic/gin"
"github.com/gin-gonic/gin/binding"
"github.com/go-admin-team/go-admin-core/sdk/api"
"github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth/user"
coreService "github.com/go-admin-team/go-admin-core/sdk/service"
)
type API struct{ api.Api }
func (e *API) service(c *gin.Context) (*Service, error) {
base := coreService.Service{}
if err := e.MakeContext(c).MakeOrm().MakeService(&base).Errors; err != nil {
return nil, err
}
return &Service{DB: base.Orm}, nil
}
func (e *API) Get(c *gin.Context) {
service, err := e.service(c)
if err != nil {
e.writeError(err)
return
}
request := PageRequest{}
if err = e.MakeContext(c).Bind(&request).Errors; err != nil {
e.Error(http.StatusBadRequest, err, "查询条件格式不正确")
return
}
response, err := service.Overview(request)
if err != nil {
e.writeError(err)
return
}
e.OK(response, "查询成功")
}
func (e *API) Update(c *gin.Context) {
service, err := e.service(c)
if err != nil {
e.writeError(err)
return
}
request := UpdateRequest{UpdateBy: user.GetUserId(c)}
if err = e.MakeContext(c).Bind(&request, binding.JSON).Errors; err != nil {
e.Error(http.StatusBadRequest, err, "请求内容格式不正确")
return
}
response, err := service.Update(request)
if err != nil {
e.writeError(err)
return
}
e.OK(response, "配额已更新")
}
func (e *API) writeError(err error) {
switch {
case errors.Is(err, ErrInvalid):
e.Error(http.StatusBadRequest, err, err.Error())
case errors.Is(err, ErrExceeded), errors.Is(err, ErrVersionConflict):
e.Error(http.StatusConflict, err, err.Error())
case errors.Is(err, ErrUnavailable):
e.Error(http.StatusServiceUnavailable, err, err.Error())
default:
e.Error(http.StatusInternalServerError, err, "容量与配额操作失败")
}
}
+60
View File
@@ -0,0 +1,60 @@
package quota
import (
"time"
commonDto "git.ilapage.cn/ila/yovision/Sense/server/common/dto"
)
const (
ReadStatusReadable = "readable"
ReadStatusUnreadable = "unreadable"
)
type PageRequest struct {
commonDto.Pagination `search:"-"`
Keyword string `form:"keyword"`
Status string `form:"status"`
}
type UpdateRequest struct {
Limit int `json:"limit"`
Reason string `json:"reason"`
Version int64 `json:"version"`
UpdateBy int `json:"-"`
}
type Summary struct {
Limit int `json:"limit"`
Used int `json:"used"`
Remaining int `json:"remaining"`
ReadStatus string `json:"readStatus"`
Source string `json:"source"`
Version int64 `json:"version"`
LastReadAt time.Time `json:"lastReadAt"`
LastChanged time.Time `json:"lastChangedAt,omitempty"`
}
type DeviceOccupancy struct {
ID string `json:"id"`
Name string `json:"name"`
Location string `json:"location"`
Status string `json:"status"`
Occupied bool `json:"occupied"`
Version int64 `json:"version"`
UpdatedAt time.Time `json:"updatedAt"`
}
type Tier struct {
Limit int `json:"limit"`
Configured bool `json:"configured"`
Validation string `json:"validation"`
DeliveryStatus string `json:"deliveryStatus"`
}
type Overview struct {
Summary Summary `json:"summary"`
List []DeviceOccupancy `json:"list"`
Count int64 `json:"count"`
Tiers []Tier `json:"tiers"`
}
+31
View File
@@ -0,0 +1,31 @@
package quota
import (
"time"
common "git.ilapage.cn/ila/yovision/Sense/server/common/models"
)
const SettingID uint = 1
type Setting struct {
ID uint `gorm:"primaryKey;autoIncrement:false" json:"id"`
Limit int `gorm:"not null" json:"limit"`
Source string `gorm:"size:32;not null;default:'database'" json:"source"`
Version int64 `gorm:"not null;default:1" json:"version"`
common.ControlBy
common.ModelTime
}
func (Setting) TableName() string { return "sense_quota_settings" }
type Change struct {
ID string `gorm:"size:36;primaryKey" json:"id"`
OldLimit int `gorm:"not null" json:"oldLimit"`
NewLimit int `gorm:"not null" json:"newLimit"`
Reason string `gorm:"size:512;not null" json:"reason"`
ChangedBy int `gorm:"not null;index" json:"changedBy"`
CreatedAt time.Time `gorm:"not null;index" json:"createdAt"`
}
func (Change) TableName() string { return "sense_quota_changes" }
+168
View File
@@ -0,0 +1,168 @@
package quota
import (
"errors"
"fmt"
"strings"
"time"
"github.com/google/uuid"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
var (
ErrUnavailable = errors.New("配额配置不可读取")
ErrExceeded = errors.New("当前配额已满")
ErrInvalid = errors.New("配额配置不符合要求")
ErrVersionConflict = errors.New("配额已被其他用户修改,请刷新后重试")
)
type Service struct{ DB *gorm.DB }
func (s Service) Overview(request PageRequest) (Overview, error) {
if s.DB == nil {
return Overview{}, ErrUnavailable
}
request.Keyword = strings.TrimSpace(request.Keyword)
if request.Status != "" && request.Status != "active" && request.Status != "pending" && request.Status != "disabled" {
return Overview{}, ErrInvalid
}
used, err := occupiedCount(s.DB)
if err != nil {
return Overview{}, fmt.Errorf("count quota occupancy: %w", err)
}
now := time.Now().UTC()
summary := Summary{Used: int(used), ReadStatus: ReadStatusUnreadable, LastReadAt: now}
var setting Setting
if err = s.DB.First(&setting, "id = ?", SettingID).Error; err == nil && validLimit(setting.Limit) {
summary.Limit = setting.Limit
summary.Remaining = setting.Limit - int(used)
if summary.Remaining < 0 {
summary.Remaining = 0
}
summary.ReadStatus = ReadStatusReadable
summary.Source = setting.Source
summary.Version = setting.Version
summary.LastChanged = setting.UpdatedAt
}
query := s.DB.Table("sense_devices").Select("id, name, location, status, version, updated_at")
if request.Keyword != "" {
pattern := "%" + strings.ToLower(request.Keyword) + "%"
query = query.Where("LOWER(name) LIKE ? OR LOWER(location) LIKE ?", pattern, pattern)
}
if request.Status != "" {
query = query.Where("status = ?", request.Status)
}
var count int64
if err = query.Count(&count).Error; err != nil {
return Overview{}, fmt.Errorf("count quota devices: %w", err)
}
pageSize := request.GetPageSize()
if pageSize > 100 {
pageSize = 100
}
var rows []DeviceOccupancy
if err = query.Order("updated_at DESC").Limit(pageSize).Offset((request.GetPageIndex() - 1) * pageSize).Scan(&rows).Error; err != nil {
return Overview{}, fmt.Errorf("list quota devices: %w", err)
}
for index := range rows {
rows[index].Occupied = rows[index].Status != "disabled"
}
return Overview{Summary: summary, List: rows, Count: count, Tiers: deliveryTiers(summary.Limit)}, nil
}
func (s Service) Update(request UpdateRequest) (Summary, error) {
request.Reason = strings.TrimSpace(request.Reason)
if s.DB == nil || !validLimit(request.Limit) || request.Version < 1 || request.Reason == "" || len([]rune(request.Reason)) > 512 {
return Summary{}, ErrInvalid
}
err := s.DB.Transaction(func(tx *gorm.DB) error {
setting, err := lockSetting(tx)
if err != nil {
return err
}
if setting.Version != request.Version {
return ErrVersionConflict
}
now := time.Now().UTC()
result := tx.Model(&Setting{}).Where("id = ? AND version = ?", SettingID, request.Version).Updates(map[string]any{
"limit": request.Limit, "source": "database", "version": request.Version + 1,
"update_by": request.UpdateBy, "updated_at": now,
})
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return ErrVersionConflict
}
return tx.Create(&Change{ID: uuid.NewString(), OldLimit: setting.Limit, NewLimit: request.Limit, Reason: request.Reason, ChangedBy: request.UpdateBy, CreatedAt: now}).Error
})
if err != nil {
return Summary{}, err
}
overview, err := s.Overview(PageRequest{})
return overview.Summary, err
}
func ReadLimit(db *gorm.DB) (int, error) {
if db == nil {
return 0, ErrUnavailable
}
var setting Setting
if err := db.First(&setting, "id = ?", SettingID).Error; err != nil || !validLimit(setting.Limit) {
return 0, ErrUnavailable
}
return setting.Limit, nil
}
func WithAvailableSlot(db *gorm.DB, write func(*gorm.DB) error) error {
if db == nil {
return ErrUnavailable
}
return db.Transaction(func(tx *gorm.DB) error {
setting, err := lockSetting(tx)
if err != nil {
return err
}
used, err := occupiedCount(tx)
if err != nil {
return fmt.Errorf("count quota occupancy: %w", err)
}
if used >= int64(setting.Limit) {
return ErrExceeded
}
return write(tx)
})
}
func lockSetting(tx *gorm.DB) (Setting, error) {
var setting Setting
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&setting, "id = ?", SettingID).Error
if err != nil || !validLimit(setting.Limit) {
return Setting{}, ErrUnavailable
}
return setting, nil
}
func occupiedCount(db *gorm.DB) (int64, error) {
var count int64
err := db.Table("sense_devices").Where("status <> ?", "disabled").Count(&count).Error
return count, err
}
func validLimit(limit int) bool { return limit >= 1 && limit <= 100000 }
func deliveryTiers(configured int) []Tier {
tiers := []Tier{
{Limit: 16, Validation: "verified", DeliveryStatus: "默认学校试点交付档位"},
{Limit: 32, Validation: "unverified", DeliveryStatus: "需完成目标硬件压测后启用"},
{Limit: 64, Validation: "unverified", DeliveryStatus: "不作单机容量承诺"},
{Limit: 128, Validation: "unverified", DeliveryStatus: "当前不在交付承诺范围"},
}
for index := range tiers {
tiers[index].Configured = tiers[index].Limit == configured
}
return tiers
}
@@ -0,0 +1,130 @@
package quota
import (
"sync"
"testing"
"github.com/google/uuid"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
deviceModels "git.ilapage.cn/ila/yovision/Sense/server/app/sense/device/models"
)
func testQuotaDB(t *testing.T, limit int) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open("file:"+uuid.NewString()+"?mode=memory&cache=shared"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
sqlDB, err := db.DB()
if err != nil {
t.Fatal(err)
}
sqlDB.SetMaxOpenConns(1)
if err = db.AutoMigrate(&deviceModels.Device{}, &Setting{}, &Change{}); err != nil {
t.Fatal(err)
}
if err = db.Create(&Setting{ID: SettingID, Limit: limit, Source: "test", Version: 1}).Error; err != nil {
t.Fatal(err)
}
return db
}
func TestOverviewShowsOccupancyPaginationAndDeliveryBoundaries(t *testing.T) {
db := testQuotaDB(t, 16)
for index, status := range []string{"active", "pending", "disabled"} {
device := deviceModels.Device{ID: uuid.NewString(), Name: "设备", Location: "位置", Modality: "video", CapabilitiesJSON: "[]", Status: status, AdapterStatus: "ready", Version: int64(index + 1)}
if err := db.Create(&device).Error; err != nil {
t.Fatal(err)
}
}
request := PageRequest{}
request.PageIndex, request.PageSize = 1, 64
response, err := (Service{DB: db}).Overview(request)
if err != nil {
t.Fatal(err)
}
if response.Summary.Limit != 16 || response.Summary.Used != 2 || response.Summary.Remaining != 14 || response.Count != 3 || len(response.List) != 3 {
t.Fatalf("unexpected overview: %#v", response)
}
if response.Tiers[0].Validation != "verified" || response.Tiers[1].Validation != "unverified" || !response.Tiers[0].Configured {
t.Fatalf("delivery tiers overpromise capacity: %#v", response.Tiers)
}
}
func TestUnreadableQuotaKeepsReadsAndRejectsWrites(t *testing.T) {
db := testQuotaDB(t, 16)
if err := db.Create(&deviceModels.Device{ID: uuid.NewString(), Name: "已有设备", Modality: "video", CapabilitiesJSON: "[]", Status: "active", AdapterStatus: "ready", Version: 1}).Error; err != nil {
t.Fatal(err)
}
if err := db.Delete(&Setting{}, SettingID).Error; err != nil {
t.Fatal(err)
}
response, err := (Service{DB: db}).Overview(PageRequest{})
if err != nil {
t.Fatal(err)
}
if response.Summary.ReadStatus != ReadStatusUnreadable || response.Summary.Used != 1 || len(response.List) != 1 {
t.Fatalf("read-only fallback failed: %#v", response)
}
if err = WithAvailableSlot(db, func(*gorm.DB) error { return nil }); err != ErrUnavailable {
t.Fatalf("write with unreadable quota error=%v", err)
}
}
func TestUpdateAllowsLowerLimitWithoutStoppingExistingDevices(t *testing.T) {
db := testQuotaDB(t, 16)
for index := 0; index < 2; index++ {
if err := db.Create(&deviceModels.Device{ID: uuid.NewString(), Name: "设备", Modality: "video", CapabilitiesJSON: "[]", Status: "active", AdapterStatus: "ready", Version: 1}).Error; err != nil {
t.Fatal(err)
}
}
summary, err := (Service{DB: db}).Update(UpdateRequest{Limit: 1, Reason: "测试降低配额", Version: 1, UpdateBy: 7})
if err != nil {
t.Fatal(err)
}
if summary.Limit != 1 || summary.Used != 2 || summary.Remaining != 0 {
t.Fatalf("unexpected lowered quota: %#v", summary)
}
var devices int64
if err = db.Model(&deviceModels.Device{}).Count(&devices).Error; err != nil || devices != 2 {
t.Fatalf("existing devices changed: count=%d err=%v", devices, err)
}
var changes int64
if err = db.Model(&Change{}).Count(&changes).Error; err != nil || changes != 1 {
t.Fatalf("audit changes=%d err=%v", changes, err)
}
}
func TestConcurrentSlotReservationDoesNotExceedQuota(t *testing.T) {
db := testQuotaDB(t, 4)
const workers = 12
var wait sync.WaitGroup
var accepted int
var lock sync.Mutex
for index := 0; index < workers; index++ {
wait.Add(1)
go func() {
defer wait.Done()
err := WithAvailableSlot(db, func(tx *gorm.DB) error {
return tx.Create(&deviceModels.Device{ID: uuid.NewString(), Name: "并发设备", Modality: "video", CapabilitiesJSON: "[]", Status: "pending", AdapterStatus: "ready", Version: 1}).Error
})
if err == nil {
lock.Lock()
accepted++
lock.Unlock()
} else if err != ErrExceeded {
t.Errorf("unexpected reservation error: %v", err)
}
}()
}
wait.Wait()
if accepted != 4 {
t.Fatalf("accepted=%d want=4", accepted)
}
var count int64
if err := db.Model(&deviceModels.Device{}).Where("status <> ?", "disabled").Count(&count).Error; err != nil || count != 4 {
t.Fatalf("occupied=%d err=%v", count, err)
}
}
@@ -0,0 +1,75 @@
package version
import (
"fmt"
"runtime"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/provisioning"
"git.ilapage.cn/ila/yovision/Sense/server/cmd/migrate/migration"
migrationModels "git.ilapage.cn/ila/yovision/Sense/server/cmd/migrate/migration/models"
common "git.ilapage.cn/ila/yovision/Sense/server/common/models"
)
func init() {
_, fileName, _, _ := runtime.Caller(0)
migration.Migrate.SetVersion(migration.GetFilename(fileName), migrateSenseProvisioning)
}
func migrateSenseProvisioning(db *gorm.DB, version string) error {
return db.Transaction(func(tx *gorm.DB) error {
if err := tx.AutoMigrate(&provisioning.Batch{}, &provisioning.Item{}, &deviceCasbinRule{}); err != nil {
return err
}
root, err := ensureDeviceMenu(tx, migrationModels.SysMenu{MenuName: senseLayoutMenuName, Title: "视频感知", Icon: "video-camera", Path: "/sense", MenuType: "M", Component: "Layout", Sort: 5, Visible: "0", IsFrame: "1"})
if err != nil {
return err
}
page, err := ensureDeviceMenu(tx, migrationModels.SysMenu{MenuName: "SenseProvisioning", Title: "批量开通", Icon: "upload", Path: "provisioning", Paths: fmt.Sprintf("/0/%d", root.MenuId), MenuType: "C", Permission: "sense:provisioning:list", ParentId: root.MenuId, Component: "/sense/provisioning/index", Sort: 2, Visible: "0", IsFrame: "1"})
if err != nil {
return err
}
definitions := []struct{ name, title, action, permission string }{
{"SenseProvisioningImport", "导入批次", "POST", "sense:provisioning:import"},
{"SenseProvisioningExecute", "执行开通", "POST", "sense:provisioning:execute"},
{"SenseProvisioningRetry", "重试失败项", "POST", "sense:provisioning:retry"},
{"SenseProvisioningExport", "导出结果", "GET", "sense:provisioning:export"},
}
buttons := make([]migrationModels.SysMenu, 0, len(definitions))
for index, definition := range definitions {
button, err := ensureDeviceMenu(tx, migrationModels.SysMenu{MenuName: definition.name, Title: definition.title, MenuType: "F", Action: definition.action, Permission: definition.permission, ParentId: page.MenuId, Paths: fmt.Sprintf("/0/%d/%d", root.MenuId, page.MenuId), Sort: index + 1, Visible: "1", IsFrame: "1"})
if err != nil {
return err
}
buttons = append(buttons, button)
}
allMenus := append([]migrationModels.SysMenu{page}, buttons...)
for _, role := range []string{"implementation_operator", "site_admin"} {
if err = attachDeviceRole(tx, role, allMenus); err != nil {
return err
}
}
if err = attachDeviceRole(tx, "viewer", []migrationModels.SysMenu{page, buttons[3]}); err != nil {
return err
}
read := [][2]string{{"/api/v1/provisioning/batches", "GET"}, {"/api/v1/provisioning/batches/:id", "GET"}, {"/api/v1/provisioning/batches/:id/export", "GET"}}
write := [][2]string{{"/api/v1/provisioning/batches", "POST"}, {"/api/v1/provisioning/batches/:id/execute", "POST"}, {"/api/v1/provisioning/batches/:id/retry-failed", "POST"}, {"/api/v1/provisioning/batches/:id/items/:itemId/retry", "POST"}}
for _, role := range []string{"implementation_operator", "site_admin", "viewer"} {
policies := append([][2]string{}, read...)
if role != "viewer" {
policies = append(policies, write...)
}
for _, policy := range policies {
if err = tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&deviceCasbinRule{Ptype: "p", V0: role, V1: policy[0], V2: policy[1]}).Error; err != nil {
return err
}
}
}
if err = rebuildSenseMenuPaths(tx, root.MenuId, "/0"); err != nil {
return err
}
return tx.Create(&common.Migration{Version: version}).Error
})
}
@@ -0,0 +1,61 @@
package version
import (
"os"
"testing"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/provisioning"
migrationModels "git.ilapage.cn/ila/yovision/Sense/server/cmd/migrate/migration/models"
common "git.ilapage.cn/ila/yovision/Sense/server/common/models"
)
func TestProvisioningMigrationOnPostgres(t *testing.T) {
dsn := os.Getenv("SENSE_PROVISIONING_MIGRATION_TEST_DATABASE_URL")
if dsn == "" {
t.Skip("set SENSE_PROVISIONING_MIGRATION_TEST_DATABASE_URL to run the PostgreSQL migration test")
}
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
const schema = "sense_provisioning_72_test"
if err = db.Exec("DROP SCHEMA IF EXISTS " + schema + " CASCADE").Error; err != nil {
t.Fatal(err)
}
if err = db.Exec("CREATE SCHEMA " + schema).Error; err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = db.Exec("DROP SCHEMA IF EXISTS " + schema + " CASCADE").Error })
sqlDB, err := db.DB()
if err != nil {
t.Fatal(err)
}
sqlDB.SetMaxOpenConns(1)
if err = db.Exec("SET search_path TO " + schema).Error; err != nil {
t.Fatal(err)
}
if err = db.AutoMigrate(&migrationModels.SysRole{}, &migrationModels.SysMenu{}, &deviceCasbinRule{}, &common.Migration{}); err != nil {
t.Fatal(err)
}
for _, role := range []string{"implementation_operator", "site_admin", "viewer"} {
if err = db.Create(&migrationModels.SysRole{RoleName: role, RoleKey: role, Status: "2"}).Error; err != nil {
t.Fatal(err)
}
}
const version = "2026082809000_provisioning.go"
if err = migrateSenseProvisioning(db, version); err != nil {
t.Fatal(err)
}
var menus, policies, batches, items, applied int64
db.Model(&migrationModels.SysMenu{}).Where("menu_name LIKE ?", "SenseProvisioning%").Count(&menus)
db.Model(&deviceCasbinRule{}).Where("v1 LIKE ?", "/api/v1/provisioning/%").Count(&policies)
db.Model(&provisioning.Batch{}).Count(&batches)
db.Model(&provisioning.Item{}).Count(&items)
db.Model(&common.Migration{}).Where("version = ?", version).Count(&applied)
if menus != 5 || policies != 17 || batches != 0 || items != 0 || applied != 1 {
t.Fatalf("menus=%d policies=%d batches=%d items=%d applied=%d", menus, policies, batches, items, applied)
}
}
@@ -0,0 +1,53 @@
package version
import (
"fmt"
"runtime"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/local_event"
"git.ilapage.cn/ila/yovision/Sense/server/cmd/migrate/migration"
migrationModels "git.ilapage.cn/ila/yovision/Sense/server/cmd/migrate/migration/models"
common "git.ilapage.cn/ila/yovision/Sense/server/common/models"
)
func init() {
_, fileName, _, _ := runtime.Caller(0)
migration.Migrate.SetVersion(migration.GetFilename(fileName), migrateSenseLocalEvent)
}
func migrateSenseLocalEvent(db *gorm.DB, version string) error {
return db.Transaction(func(tx *gorm.DB) error {
if err := tx.AutoMigrate(&local_event.EventCandidate{}, &deviceCasbinRule{}); err != nil {
return err
}
root, err := ensureDeviceMenu(tx, migrationModels.SysMenu{MenuName: senseLayoutMenuName, Title: "视频感知", Icon: "video-camera", Path: "/sense", MenuType: "M", Component: "Layout", Sort: 5, Visible: "0", IsFrame: "1"})
if err != nil {
return err
}
page, err := ensureDeviceMenu(tx, migrationModels.SysMenu{MenuName: "SenseLocalEvent", Title: "本地事件", Icon: "bell", Path: "local-event", Paths: fmt.Sprintf("/0/%d", root.MenuId), MenuType: "C", Permission: "sense:local-event:list", ParentId: root.MenuId, Component: "/sense/local-event/index", Sort: 6, Visible: "0", IsFrame: "1"})
if err != nil {
return err
}
detail, err := ensureDeviceMenu(tx, migrationModels.SysMenu{MenuName: "SenseLocalEventDetail", Title: "查看本地事件", MenuType: "F", Action: "GET", Permission: "sense:local-event:detail", ParentId: page.MenuId, Paths: fmt.Sprintf("/0/%d/%d", root.MenuId, page.MenuId), Sort: 1, Visible: "1", IsFrame: "1"})
if err != nil {
return err
}
for _, role := range []string{"implementation_operator", "site_admin", "viewer"} {
if err = attachDeviceRole(tx, role, []migrationModels.SysMenu{page, detail}); err != nil {
return err
}
for _, policy := range [][2]string{{"/api/v1/local-events", "GET"}, {"/api/v1/local-events/:id", "GET"}} {
if err = tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&deviceCasbinRule{Ptype: "p", V0: role, V1: policy[0], V2: policy[1]}).Error; err != nil {
return err
}
}
}
if err = rebuildSenseMenuPaths(tx, root.MenuId, "/0"); err != nil {
return err
}
return tx.Create(&common.Migration{Version: version}).Error
})
}
@@ -0,0 +1,60 @@
package version
import (
"os"
"testing"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/local_event"
migrationModels "git.ilapage.cn/ila/yovision/Sense/server/cmd/migrate/migration/models"
common "git.ilapage.cn/ila/yovision/Sense/server/common/models"
)
func TestLocalEventMigrationOnPostgres(t *testing.T) {
dsn := os.Getenv("SENSE_LOCAL_EVENT_MIGRATION_TEST_DATABASE_URL")
if dsn == "" {
t.Skip("set SENSE_LOCAL_EVENT_MIGRATION_TEST_DATABASE_URL to run the PostgreSQL migration test")
}
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
const schema = "sense_local_event_73_test"
if err = db.Exec("DROP SCHEMA IF EXISTS " + schema + " CASCADE").Error; err != nil {
t.Fatal(err)
}
if err = db.Exec("CREATE SCHEMA " + schema).Error; err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = db.Exec("DROP SCHEMA IF EXISTS " + schema + " CASCADE").Error })
sqlDB, err := db.DB()
if err != nil {
t.Fatal(err)
}
sqlDB.SetMaxOpenConns(1)
if err = db.Exec("SET search_path TO " + schema).Error; err != nil {
t.Fatal(err)
}
if err = db.AutoMigrate(&migrationModels.SysRole{}, &migrationModels.SysMenu{}, &deviceCasbinRule{}, &common.Migration{}); err != nil {
t.Fatal(err)
}
for _, role := range []string{"implementation_operator", "site_admin", "viewer"} {
if err = db.Create(&migrationModels.SysRole{RoleName: role, RoleKey: role, Status: "2"}).Error; err != nil {
t.Fatal(err)
}
}
const version = "2026082810000_local_event.go"
if err = migrateSenseLocalEvent(db, version); err != nil {
t.Fatal(err)
}
var menus, policies, rows, applied int64
db.Model(&migrationModels.SysMenu{}).Where("menu_name LIKE ?", "SenseLocalEvent%").Count(&menus)
db.Model(&deviceCasbinRule{}).Where("v1 LIKE ?", "/api/v1/local-events%").Count(&policies)
db.Model(&local_event.EventCandidate{}).Count(&rows)
db.Model(&common.Migration{}).Where("version = ?", version).Count(&applied)
if menus != 2 || policies != 6 || rows != 0 || applied != 1 {
t.Fatalf("menus=%d policies=%d rows=%d applied=%d", menus, policies, rows, applied)
}
}
@@ -0,0 +1,62 @@
package version
import (
"fmt"
"runtime"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"git.ilapage.cn/ila/yovision/Sense/server/cmd/migrate/migration"
migrationModels "git.ilapage.cn/ila/yovision/Sense/server/cmd/migrate/migration/models"
common "git.ilapage.cn/ila/yovision/Sense/server/common/models"
)
func init() {
_, fileName, _, _ := runtime.Caller(0)
migration.Migrate.SetVersion(migration.GetFilename(fileName), migrateSenseOperations)
}
func migrateSenseOperations(db *gorm.DB, version string) error {
return db.Transaction(func(tx *gorm.DB) error {
root, err := ensureDeviceMenu(tx, migrationModels.SysMenu{MenuName: senseLayoutMenuName, Title: "视频感知", Icon: "video-camera", Path: "/sense", MenuType: "M", Component: "Layout", Sort: 5, Visible: "0", IsFrame: "1"})
if err != nil {
return err
}
page, err := ensureDeviceMenu(tx, migrationModels.SysMenu{MenuName: "SenseOperations", Title: "运维中心", Icon: "operation", Path: "operations", Paths: fmt.Sprintf("/0/%d", root.MenuId), MenuType: "C", Permission: "sense:operations:list", ParentId: root.MenuId, Component: "/sense/operations/index", Sort: 7, Visible: "0", IsFrame: "1"})
if err != nil {
return err
}
detail, err := ensureDeviceMenu(tx, migrationModels.SysMenu{MenuName: "SenseOperationsDetail", Title: "查看运维详情", MenuType: "F", Action: "GET", Permission: "sense:operations:detail", ParentId: page.MenuId, Paths: fmt.Sprintf("/0/%d/%d", root.MenuId, page.MenuId), Sort: 1, Visible: "1", IsFrame: "1"})
if err != nil {
return err
}
retry, err := ensureDeviceMenu(tx, migrationModels.SysMenu{MenuName: "SenseOperationsRetry", Title: "受控重试", MenuType: "F", Action: "POST", Permission: "sense:operations:retry", ParentId: page.MenuId, Paths: fmt.Sprintf("/0/%d/%d", root.MenuId, page.MenuId), Sort: 2, Visible: "1", IsFrame: "1"})
if err != nil {
return err
}
readPolicies := [][2]string{{"/api/v1/operations", "GET"}, {"/api/v1/operations/:id", "GET"}}
for _, role := range []string{"implementation_operator", "site_admin", "viewer"} {
if err = attachDeviceRole(tx, role, []migrationModels.SysMenu{page, detail}); err != nil {
return err
}
for _, policy := range readPolicies {
if err = tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&deviceCasbinRule{Ptype: "p", V0: role, V1: policy[0], V2: policy[1]}).Error; err != nil {
return err
}
}
}
for _, role := range []string{"implementation_operator", "site_admin"} {
if err = attachDeviceRole(tx, role, []migrationModels.SysMenu{retry}); err != nil {
return err
}
if err = tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&deviceCasbinRule{Ptype: "p", V0: role, V1: "/api/v1/operations/:id/retry", V2: "POST"}).Error; err != nil {
return err
}
}
if err = rebuildSenseMenuPaths(tx, root.MenuId, "/0"); err != nil {
return err
}
return tx.Create(&common.Migration{Version: version}).Error
})
}
@@ -0,0 +1,85 @@
package version
import (
"os"
"testing"
"gorm.io/driver/postgres"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
migrationModels "git.ilapage.cn/ila/yovision/Sense/server/cmd/migrate/migration/models"
common "git.ilapage.cn/ila/yovision/Sense/server/common/models"
)
func TestOperationsMigrationAddsLeastPrivilegeMenus(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err = db.AutoMigrate(&migrationModels.SysRole{}, &migrationModels.SysMenu{}, &deviceCasbinRule{}, &common.Migration{}); err != nil {
t.Fatal(err)
}
for _, role := range []string{"implementation_operator", "site_admin", "viewer"} {
if err = db.Create(&migrationModels.SysRole{RoleName: role, RoleKey: role, Status: "2"}).Error; err != nil {
t.Fatal(err)
}
}
const version = "2026082811000_operations.go"
if err = migrateSenseOperations(db, version); err != nil {
t.Fatal(err)
}
var menus, reads, retries, viewerRetries, applied int64
db.Model(&migrationModels.SysMenu{}).Where("menu_name LIKE ?", "SenseOperations%").Count(&menus)
db.Model(&deviceCasbinRule{}).Where("v1 IN ?", []string{"/api/v1/operations", "/api/v1/operations/:id"}).Count(&reads)
db.Model(&deviceCasbinRule{}).Where("v1 = ? AND v2 = ?", "/api/v1/operations/:id/retry", "POST").Count(&retries)
db.Model(&deviceCasbinRule{}).Where("v0 = ? AND v1 = ?", "viewer", "/api/v1/operations/:id/retry").Count(&viewerRetries)
db.Model(&common.Migration{}).Where("version = ?", version).Count(&applied)
if menus != 3 || reads != 6 || retries != 2 || viewerRetries != 0 || applied != 1 {
t.Fatalf("menus=%d reads=%d retries=%d viewerRetries=%d applied=%d", menus, reads, retries, viewerRetries, applied)
}
}
func TestOperationsMigrationOnPostgres(t *testing.T) {
dsn := os.Getenv("SENSE_OPERATIONS_MIGRATION_TEST_DATABASE_URL")
if dsn == "" {
t.Skip("set SENSE_OPERATIONS_MIGRATION_TEST_DATABASE_URL to run the PostgreSQL migration test")
}
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
const schema = "sense_operations_74_test"
if err = db.Exec("DROP SCHEMA IF EXISTS " + schema + " CASCADE").Error; err != nil {
t.Fatal(err)
}
if err = db.Exec("CREATE SCHEMA " + schema).Error; err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = db.Exec("DROP SCHEMA IF EXISTS " + schema + " CASCADE").Error })
sqlDB, err := db.DB()
if err != nil {
t.Fatal(err)
}
sqlDB.SetMaxOpenConns(1)
if err = db.Exec("SET search_path TO " + schema).Error; err != nil {
t.Fatal(err)
}
if err = db.AutoMigrate(&migrationModels.SysRole{}, &migrationModels.SysMenu{}, &deviceCasbinRule{}, &common.Migration{}); err != nil {
t.Fatal(err)
}
for _, role := range []string{"implementation_operator", "site_admin", "viewer"} {
if err = db.Create(&migrationModels.SysRole{RoleName: role, RoleKey: role, Status: "2"}).Error; err != nil {
t.Fatal(err)
}
}
if err = migrateSenseOperations(db, "2026082811000_operations.go"); err != nil {
t.Fatal(err)
}
var menus, policies int64
db.Model(&migrationModels.SysMenu{}).Where("menu_name LIKE ?", "SenseOperations%").Count(&menus)
db.Model(&deviceCasbinRule{}).Where("v1 LIKE ?", "/api/v1/operations%").Count(&policies)
if menus != 3 || policies != 8 {
t.Fatalf("menus=%d policies=%d", menus, policies)
}
}
@@ -0,0 +1,100 @@
package version
import (
"fmt"
"os"
"runtime"
"strconv"
"strings"
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/quota"
"git.ilapage.cn/ila/yovision/Sense/server/cmd/migrate/migration"
migrationModels "git.ilapage.cn/ila/yovision/Sense/server/cmd/migrate/migration/models"
common "git.ilapage.cn/ila/yovision/Sense/server/common/models"
)
func init() {
_, fileName, _, _ := runtime.Caller(0)
migration.Migrate.SetVersion(migration.GetFilename(fileName), migrateSenseQuota)
}
func migrateSenseQuota(db *gorm.DB, version string) error {
return db.Transaction(func(tx *gorm.DB) error {
if err := tx.AutoMigrate(&quota.Setting{}, &quota.Change{}); err != nil {
return err
}
now := time.Now().UTC()
setting := quota.Setting{ID: quota.SettingID, Limit: initialQuotaLimit(), Source: "migration", Version: 1}
setting.CreatedAt, setting.UpdatedAt = now, now
if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&setting).Error; err != nil {
return err
}
root, err := ensureDeviceMenu(tx, migrationModels.SysMenu{
MenuName: senseLayoutMenuName, Title: "视频感知", Icon: "video-camera", Path: "/sense",
MenuType: "M", Component: "Layout", Sort: 5, Visible: "0", IsFrame: "1",
})
if err != nil {
return err
}
page, err := ensureDeviceMenu(tx, migrationModels.SysMenu{
MenuName: "SenseQuota", Title: "容量与配额", Icon: "data-line", Path: "quota",
Paths: fmt.Sprintf("/0/%d", root.MenuId), MenuType: "C", Permission: "sense:quota:list",
ParentId: root.MenuId, Component: "/sense/quota/index", Sort: 8, Visible: "0", IsFrame: "1",
})
if err != nil {
return err
}
update, err := ensureDeviceMenu(tx, migrationModels.SysMenu{
MenuName: "SenseQuotaUpdate", Title: "调整配额", MenuType: "F", Action: "PUT",
Permission: "sense:quota:update", ParentId: page.MenuId,
Paths: fmt.Sprintf("/0/%d/%d", root.MenuId, page.MenuId), Sort: 1, Visible: "1", IsFrame: "1",
})
if err != nil {
return err
}
var devicePage migrationModels.SysMenu
if err = tx.Where("menu_name = ?", "SenseDeviceManage").First(&devicePage).Error; err != nil {
return err
}
enable, err := ensureDeviceMenu(tx, migrationModels.SysMenu{
MenuName: "SenseDeviceEnable", Title: "启用设备", MenuType: "F", Action: "PUT",
Permission: "sense:device:enable", ParentId: devicePage.MenuId,
Paths: fmt.Sprintf("/0/%d/%d", root.MenuId, devicePage.MenuId), Sort: 5, Visible: "1", IsFrame: "1",
})
if err != nil {
return err
}
for _, role := range []string{"implementation_operator", "site_admin", "viewer"} {
if err = attachDeviceRole(tx, role, []migrationModels.SysMenu{page}); err != nil {
return err
}
if err = tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&deviceCasbinRule{Ptype: "p", V0: role, V1: "/api/v1/quota", V2: "GET"}).Error; err != nil {
return err
}
}
if err = attachDeviceRole(tx, "site_admin", []migrationModels.SysMenu{update, enable}); err != nil {
return err
}
for _, policy := range [][2]string{{"/api/v1/quota", "PUT"}, {"/api/v1/devices/:id/enable", "PUT"}} {
if err = tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&deviceCasbinRule{Ptype: "p", V0: "site_admin", V1: policy[0], V2: policy[1]}).Error; err != nil {
return err
}
}
if err = rebuildSenseMenuPaths(tx, root.MenuId, "/0"); err != nil {
return err
}
return tx.Create(&common.Migration{Version: version}).Error
})
}
func initialQuotaLimit() int {
value, err := strconv.Atoi(strings.TrimSpace(os.Getenv("SENSE_PROVISIONING_QUOTA")))
if err != nil || value < 1 || value > 100000 {
return 16
}
return value
}
@@ -0,0 +1,90 @@
package version
import (
"os"
"testing"
"gorm.io/driver/postgres"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/quota"
migrationModels "git.ilapage.cn/ila/yovision/Sense/server/cmd/migrate/migration/models"
common "git.ilapage.cn/ila/yovision/Sense/server/common/models"
)
func prepareQuotaMigrationDB(t *testing.T, db *gorm.DB) {
t.Helper()
if err := db.AutoMigrate(&migrationModels.SysRole{}, &migrationModels.SysMenu{}, &deviceCasbinRule{}, &common.Migration{}); err != nil {
t.Fatal(err)
}
for _, role := range []string{"implementation_operator", "site_admin", "viewer"} {
if err := db.Create(&migrationModels.SysRole{RoleName: role, RoleKey: role, Status: "2"}).Error; err != nil {
t.Fatal(err)
}
}
if err := db.Create(&migrationModels.SysMenu{MenuName: "SenseDeviceManage", Title: "设备管理", Path: "devices", MenuType: "C", Component: "/sense/device/index"}).Error; err != nil {
t.Fatal(err)
}
}
func assertQuotaMigration(t *testing.T, db *gorm.DB, version string) {
t.Helper()
var setting quota.Setting
if err := db.First(&setting, "id = ?", quota.SettingID).Error; err != nil {
t.Fatal(err)
}
var menus, reads, updates, enables, viewerWrites, applied int64
db.Model(&migrationModels.SysMenu{}).Where("menu_name IN ?", []string{"SenseQuota", "SenseQuotaUpdate", "SenseDeviceEnable"}).Count(&menus)
db.Model(&deviceCasbinRule{}).Where("v1 = ? AND v2 = ?", "/api/v1/quota", "GET").Count(&reads)
db.Model(&deviceCasbinRule{}).Where("v1 = ? AND v2 = ?", "/api/v1/quota", "PUT").Count(&updates)
db.Model(&deviceCasbinRule{}).Where("v1 = ? AND v2 = ?", "/api/v1/devices/:id/enable", "PUT").Count(&enables)
db.Model(&deviceCasbinRule{}).Where("v0 = ? AND v2 = ?", "viewer", "PUT").Count(&viewerWrites)
db.Model(&common.Migration{}).Where("version = ?", version).Count(&applied)
if setting.Limit != 16 || menus != 3 || reads != 3 || updates != 1 || enables != 1 || viewerWrites != 0 || applied != 1 {
t.Fatalf("setting=%#v menus=%d reads=%d updates=%d enables=%d viewerWrites=%d applied=%d", setting, menus, reads, updates, enables, viewerWrites, applied)
}
}
func TestQuotaMigrationAddsDefaultAndLeastPrivilegeMenus(t *testing.T) {
t.Setenv("SENSE_PROVISIONING_QUOTA", "")
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
prepareQuotaMigrationDB(t, db)
const version = "2026082812000_quota.go"
if err = migrateSenseQuota(db, version); err != nil {
t.Fatal(err)
}
assertQuotaMigration(t, db, version)
}
func TestQuotaMigrationOnPostgres(t *testing.T) {
dsn := os.Getenv("SENSE_QUOTA_MIGRATION_TEST_DATABASE_URL")
if dsn == "" {
t.Skip("set SENSE_QUOTA_MIGRATION_TEST_DATABASE_URL to run the PostgreSQL migration test")
}
t.Setenv("SENSE_PROVISIONING_QUOTA", "")
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
const schema = "sense_quota_75_test"
if err = db.Exec("DROP SCHEMA IF EXISTS " + schema + " CASCADE").Error; err != nil {
t.Fatal(err)
}
if err = db.Exec("CREATE SCHEMA " + schema).Error; err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = db.Exec("DROP SCHEMA IF EXISTS " + schema + " CASCADE").Error })
if err = db.Exec("SET search_path TO " + schema).Error; err != nil {
t.Fatal(err)
}
prepareQuotaMigrationDB(t, db)
const version = "2026082812000_quota.go"
if err = migrateSenseQuota(db, version); err != nil {
t.Fatal(err)
}
assertQuotaMigration(t, db, version)
}
+4
View File
@@ -20,6 +20,10 @@ export function disableDevice(id, data) {
return request({ url: `/api/v1/devices/${id}/disable`, method: 'put', data })
}
export function enableDevice(id, data) {
return request({ url: `/api/v1/devices/${id}/enable`, method: 'put', data })
}
export function updateDeviceCredentials(id, data) {
return request({ url: `/api/v1/devices/${id}/credentials`, method: 'put', data })
}
+9
View File
@@ -0,0 +1,9 @@
import request from '@/utils/request'
export function listLocalEvents(query) {
return request({ url: '/api/v1/local-events', method: 'get', params: query })
}
export function getLocalEvent(id) {
return request({ url: `/api/v1/local-events/${encodeURIComponent(id)}`, method: 'get' })
}
+13
View File
@@ -0,0 +1,13 @@
import request from '@/utils/request'
export function listOperationProblems(query) {
return request({ url: '/api/v1/operations', method: 'get', params: query })
}
export function getOperationProblem(id) {
return request({ url: `/api/v1/operations/${encodeURIComponent(id)}`, method: 'get' })
}
export function retryOperationProblem(id, expectedVersion) {
return request({ url: `/api/v1/operations/${encodeURIComponent(id)}/retry`, method: 'post', data: { expectedVersion }})
}
+36
View File
@@ -0,0 +1,36 @@
import request from '@/utils/request'
import axios from 'axios'
import { getToken } from '@/utils/auth'
export function listProvisioningBatches(query) {
return request({ url: '/api/v1/provisioning/batches', method: 'get', params: query })
}
export function getProvisioningBatch(id) {
return request({ url: `/api/v1/provisioning/batches/${id}`, method: 'get' })
}
export function createProvisioningBatch(data) {
return request({ url: '/api/v1/provisioning/batches', method: 'post', data })
}
export function executeProvisioningBatch(id, data) {
return request({ url: `/api/v1/provisioning/batches/${id}/execute`, method: 'post', data })
}
export function retryProvisioningFailures(id, data) {
return request({ url: `/api/v1/provisioning/batches/${id}/retry-failed`, method: 'post', data })
}
export function retryProvisioningItem(batchId, itemId, data) {
return request({ url: `/api/v1/provisioning/batches/${batchId}/items/${itemId}/retry`, method: 'post', data })
}
export async function downloadProvisioningBatch(id) {
const baseURL = String(process.env.VUE_APP_BASE_API || '').replace(/\/$/, '')
const response = await axios.get(`${baseURL}/api/v1/provisioning/batches/${id}/export`, {
responseType: 'blob',
headers: { Authorization: `Bearer ${getToken()}` }
})
return response.data
}
+9
View File
@@ -0,0 +1,9 @@
import request from '@/utils/request'
export function getQuotaOverview(query) {
return request({ url: '/api/v1/quota', method: 'get', params: query })
}
export function updateQuota(data) {
return request({ url: '/api/v1/quota', method: 'put', data })
}
+10
View File
@@ -69,6 +69,7 @@
<el-button v-permisaction="['sense:device:credential']" type="primary" link size="small" :icon="Key" @click="handleCredential(scope.row)">更新凭据</el-button>
<el-divider direction="vertical" />
<el-button v-if="scope.row.status !== 'disabled'" v-permisaction="['sense:device:disable']" type="danger" link size="small" @click="handleDisable(scope.row)">停用</el-button>
<el-button v-else v-permisaction="['sense:device:enable']" type="success" link size="small" @click="handleEnable(scope.row)">启用</el-button>
</template>
</el-table-column>
</el-table>
@@ -141,6 +142,7 @@ import { Edit, Key, Plus, Refresh, Search } from '@element-plus/icons-vue'
import {
addDevice,
disableDevice,
enableDevice,
getDevice,
listDevices,
updateDevice,
@@ -284,6 +286,14 @@ export default {
this.msgSuccess(response.msg)
this.getList()
}).catch(() => {})
},
handleEnable(row) {
this.$confirm(`启用“${row.name}”会占用一路配额,并要求重新完成视频接入验证。是否继续?`, '启用设备', {
confirmButtonText: '确认启用', cancelButtonText: '取消', type: 'warning'
}).then(() => enableDevice(row.id, disableDevicePayload(row.version))).then(response => {
this.msgSuccess(response.msg)
this.getList()
}).catch(() => {})
}
}
}
@@ -0,0 +1,155 @@
<template>
<BasicLayout>
<template #wrapper>
<el-card class="box-card">
<div class="page-header">
<div>
<h3>本地事件候选</h3>
<p>查看 Sense 在本机形成的匿名事件候选及证据处理状态。</p>
</div>
<el-button :icon="Refresh" @click="loadEvents">刷新</el-button>
</div>
<el-alert type="info" :closable="false" show-icon class="boundary-alert">
<template #title>本页不依赖 Bell</template>
这里展示的是 Sense 本地事件候选,不代表已生成或已送达 Bell 预警;Bell 未安装或未启动时仍可正常查看。
</el-alert>
<el-row :gutter="12" class="summary-row" aria-label="当前页事件概览">
<el-col :xs="24" :sm="8"><div class="summary-item"><span>当前页候选</span><strong>{{ pageSummary.candidate }}</strong></div></el-col>
<el-col :xs="24" :sm="8"><div class="summary-item"><span>当前页已确认</span><strong>{{ pageSummary.confirmed }}</strong></div></el-col>
<el-col :xs="24" :sm="8"><div class="summary-item"><span>当前页证据失败</span><strong>{{ pageSummary.failed }}</strong></div></el-col>
</el-row>
<el-form :model="query" label-width="78px" class="filter-form" @submit.prevent="search">
<el-form-item label="发生时间" class="time-field">
<el-date-picker v-model="occurredRange" type="datetimerange" start-placeholder="开始时间" end-placeholder="结束时间" range-separator="至" :clearable="true" />
</el-form-item>
<el-form-item label="候选状态">
<el-select v-model="query.candidateState" clearable placeholder="全部">
<el-option label="候选" value="candidate" /><el-option label="已确认事件" value="confirmed" />
</el-select>
</el-form-item>
<el-form-item label="证据状态">
<el-select v-model="query.evidenceState" clearable placeholder="全部">
<el-option label="处理中" value="pending" /><el-option label="成功" value="success" /><el-option label="失败" value="failed" />
</el-select>
</el-form-item>
<el-form-item label="规则来源"><el-input v-model="query.ruleRef" clearable placeholder="规则编号" /></el-form-item>
<el-form-item label="关键词"><el-input v-model="query.keyword" clearable placeholder="事件 / 摄像头 / 规则" @keyup.enter="search" /></el-form-item>
<el-form-item class="filter-actions">
<el-button type="primary" :icon="Search" native-type="submit">查询</el-button>
<el-button :icon="RefreshLeft" @click="reset">重置</el-button>
</el-form-item>
</el-form>
<el-table v-loading="loading" :data="events" border stripe empty-text="没有符合条件的本地事件候选">
<el-table-column prop="occurredAt" label="发生时间" width="176">
<template #default="scope">{{ parseTime(scope.row.occurredAt) }}</template>
</el-table-column>
<el-table-column prop="id" label="事件编号" min-width="205" show-overflow-tooltip />
<el-table-column label="摄像头" min-width="145">
<template #default="scope"><div>{{ scope.row.sourceRef }}</div><small>{{ scope.row.sourceLabel || '未命名位置' }}</small></template>
</el-table-column>
<el-table-column label="规则来源" min-width="160">
<template #default="scope"><div>{{ scope.row.ruleName }}</div><small>{{ scope.row.ruleRef }} {{ scope.row.ruleVersion }}</small></template>
</el-table-column>
<el-table-column label="候选状态" width="118" align="center">
<template #default="scope"><el-tag :type="candidateType(scope.row.candidateState)">{{ candidateLabel(scope.row.candidateState) }}</el-tag></template>
</el-table-column>
<el-table-column label="证据状态" width="104" align="center">
<template #default="scope"><el-tag :type="evidenceType(scope.row.evidenceState)">{{ evidenceLabel(scope.row.evidenceState) }}</el-tag></template>
</el-table-column>
<el-table-column label="保留期限" width="142">
<template #default="scope"><span :class="{ 'retention-warning': scope.row.retentionState === 'expiring' }">{{ retentionLabel(scope.row) }}</span></template>
</el-table-column>
<el-table-column label="操作" width="94" fixed="right">
<template #default="scope"><el-button v-permisaction="['sense:local-event:detail']" type="primary" link @click="openDetail(scope.row.id)">查看详情</el-button></template>
</el-table-column>
</el-table>
<pagination v-show="total > 0" v-model:current-page="query.pageIndex" v-model:page-size="query.pageSize" :total="total" @pagination="loadEvents" />
<p class="retention-help">保留策略由 Sense 服务端配置;列表显示每条记录的实际到期时间,到期清理策略不由本页面修改。列表和详情读取均受权限控制并进入系统操作审计。</p>
</el-card>
<el-dialog v-model="detailOpen" title="本地事件详情" width="min(760px, calc(100vw - 24px))" :close-on-click-modal="false">
<el-descriptions v-if="selected" :column="2" border class="detail-descriptions">
<el-descriptions-item label="事件编号">{{ selected.id }}</el-descriptions-item>
<el-descriptions-item label="发生时间">{{ parseTime(selected.occurredAt) }}</el-descriptions-item>
<el-descriptions-item label="摄像头">{{ selected.sourceRef }} / {{ selected.sourceLabel || '未命名位置' }}</el-descriptions-item>
<el-descriptions-item label="规则来源">{{ selected.ruleName }} {{ selected.ruleVersion }}</el-descriptions-item>
<el-descriptions-item label="区域引用">{{ selected.zoneRef || '-' }}</el-descriptions-item>
<el-descriptions-item label="保留期限">{{ retentionLabel(selected) }}</el-descriptions-item>
<el-descriptions-item label="候选状态"><el-tag :type="candidateType(selected.candidateState)">{{ candidateLabel(selected.candidateState) }}</el-tag></el-descriptions-item>
<el-descriptions-item label="证据状态"><el-tag :type="evidenceType(selected.evidenceState)">{{ evidenceLabel(selected.evidenceState) }}</el-tag></el-descriptions-item>
<el-descriptions-item label="Bell 送达状态" :span="2"><strong>不适用</strong> — 本地候选未发送到 Bell,本页不推断 Bell Alert 状态。</el-descriptions-item>
</el-descriptions>
<el-alert v-if="selected" :title="evidenceTitle" :type="evidenceType(selected.evidenceState)" :closable="false" show-icon class="evidence-alert">
{{ selected.evidenceDetail || '暂无更多证据处理说明' }}<template v-if="selected.evidenceRef"><br>本地证据引用:{{ selected.evidenceRef }}</template>
</el-alert>
<template #footer><el-button @click="detailOpen = false">关闭</el-button></template>
</el-dialog>
</template>
</BasicLayout>
</template>
<script setup>
import { computed, onMounted, reactive, ref } from 'vue'
import { Refresh, RefreshLeft, Search } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { getLocalEvent, listLocalEvents } from '@/api/sense/local-event'
import { buildLocalEventQuery, candidateLabel, candidateType, evidenceLabel, evidenceType, retentionLabel } from './localEventState'
defineOptions({ name: 'SenseLocalEvent' })
const loading = ref(false)
const events = ref([])
const total = ref(0)
const detailOpen = ref(false)
const selected = ref(null)
const now = Date.now()
const occurredRange = ref([new Date(now - 24 * 60 * 60 * 1000), new Date(now)])
const query = reactive({ pageIndex: 1, pageSize: 10, candidateState: '', evidenceState: '', ruleRef: '', keyword: '' })
const pageSummary = computed(() => events.value.reduce((value, item) => {
if (item.candidateState === 'candidate') value.candidate += 1
if (item.candidateState === 'confirmed') value.confirmed += 1
if (item.evidenceState === 'failed') value.failed += 1
return value
}, { candidate: 0, confirmed: 0, failed: 0 }))
const evidenceTitle = computed(() => `证据处理:${evidenceLabel(selected.value?.evidenceState)}`)
function unwrap(response) { return response?.data?.data ?? response?.data ?? response }
async function loadEvents() {
loading.value = true
try {
const payload = unwrap(await listLocalEvents(buildLocalEventQuery(query, occurredRange.value)))
events.value = payload?.list || payload?.data || []
total.value = payload?.count || 0
} catch (error) {
ElMessage.error(error.message || '本地事件查询失败')
} finally {
loading.value = false
}
}
function search() { query.pageIndex = 1; loadEvents() }
function reset() {
query.pageIndex = 1; query.candidateState = ''; query.evidenceState = ''; query.ruleRef = ''; query.keyword = ''
const current = Date.now(); occurredRange.value = [new Date(current - 24 * 60 * 60 * 1000), new Date(current)]
loadEvents()
}
async function openDetail(id) {
try {
selected.value = unwrap(await getLocalEvent(id))
detailOpen.value = true
} catch (error) {
ElMessage.error(error.message || '本地事件详情查询失败')
}
}
onMounted(loadEvents)
</script>
<style scoped>
.page-header{display:flex;align-items:flex-start;justify-content:space-between;gap:16px}.page-header h3{margin:0 0 6px}.page-header p{margin:0;color:#909399}.boundary-alert{margin:16px 0}.summary-row{margin-bottom:16px}.summary-item{display:flex;align-items:center;justify-content:space-between;min-height:68px;padding:12px 16px;border:1px solid #ebeef5;border-radius:4px}.summary-item span{color:#606266}.summary-item strong{font-size:24px}.filter-form{display:flex;align-items:flex-end;flex-wrap:wrap;gap:0 12px;margin-bottom:2px}.filter-form .el-form-item{width:210px}.filter-form .time-field{width:420px}.filter-form .filter-actions{width:auto}.filter-form :deep(.el-select),.filter-form :deep(.el-date-editor){width:100%}small{color:#909399}.retention-warning{color:#e6a23c}.retention-help{margin:12px 0 0;color:#909399;font-size:12px}.evidence-alert{margin-top:16px}@media (max-width:768px){.filter-form .el-form-item,.filter-form .time-field{width:100%}.detail-descriptions :deep(.el-descriptions__body .el-descriptions__table){display:block;overflow-x:auto}}
</style>
@@ -0,0 +1,41 @@
export const candidateLabels = { candidate: '候选', confirmed: '已确认事件' }
export const evidenceLabels = { pending: '处理中', success: '成功', failed: '失败' }
export function candidateLabel(value) {
return candidateLabels[value] || value || '-'
}
export function candidateType(value) {
return value === 'confirmed' ? 'success' : 'primary'
}
export function evidenceLabel(value) {
return evidenceLabels[value] || value || '-'
}
export function evidenceType(value) {
return ({ pending: 'warning', success: 'success', failed: 'danger' })[value] || 'info'
}
export function buildLocalEventQuery(query, occurredRange) {
const result = {
pageIndex: query.pageIndex,
pageSize: query.pageSize,
candidateState: query.candidateState || undefined,
evidenceState: query.evidenceState || undefined,
ruleRef: String(query.ruleRef || '').trim() || undefined,
keyword: String(query.keyword || '').trim() || undefined
}
if (occurredRange?.length === 2) {
result.occurredAfter = new Date(occurredRange[0]).toISOString()
result.occurredBefore = new Date(occurredRange[1]).toISOString()
}
return result
}
export function retentionLabel(item) {
if (!item?.retainUntil) return '-'
if (item.retentionState === 'expired') return '已到期'
if (item.retentionState === 'expiring') return `${item.remainingDays} 天内到期`
return `保留至 ${String(item.retainUntil).slice(0, 10)}`
}
@@ -0,0 +1,170 @@
<template>
<BasicLayout>
<template #wrapper>
<el-card class="box-card">
<div class="page-header">
<div>
<h3>运维中心</h3>
<p>集中查看设备、媒体和可选本地推理的期望态、实际态与未收敛问题。</p>
</div>
<el-button :icon="Refresh" :loading="loading" @click="load">刷新状态</el-button>
</div>
<el-alert type="info" :closable="false" show-icon class="boundary-alert">
<template #title>本地推理是可选能力</template>
Brain 未安装时显示“不可用(未安装)”,不会导致页面失败,也不会阻断设备和媒体运维。运维异常不会发送到 Bell 作为业务预警。
</el-alert>
<el-row :gutter="12" class="summary-row" aria-label="运维概览">
<el-col :xs="24" :sm="8">
<div class="summary-item"><span>设备与媒体已收敛</span><strong>{{ summary.convergedCount }} / {{ summary.managedCount }}</strong></div>
</el-col>
<el-col :xs="24" :sm="8">
<div class="summary-item"><span>待处理问题</span><strong class="warning-number">{{ summary.actionableProblems }}</strong></div>
</el-col>
<el-col :xs="24" :sm="8">
<div class="summary-item"><span>本地推理适配器</span><strong class="adapter-state">{{ inferenceState }}</strong></div>
</el-col>
</el-row>
<div class="section-header">
<div><h4>问题队列</h4><p>默认只读诊断;只有明确可重试的项目才显示受控重试。</p></div>
</div>
<el-form :model="query" label-width="76px" class="filter-form" @submit.prevent="search">
<el-form-item label="对象类型">
<el-select v-model="query.objectType" clearable placeholder="全部">
<el-option label="设备" value="device" /><el-option label="媒体" value="media" /><el-option label="本地推理" value="local_inference" />
</el-select>
</el-form-item>
<el-form-item label="问题类型">
<el-select v-model="query.problemType" clearable placeholder="全部">
<el-option label="认证失败" value="authentication_failed" /><el-option label="退避等待" value="backoff_wait" />
<el-option label="时间漂移" value="clock_drift" /><el-option label="孤儿安全闸" value="orphan_safety_gate" />
<el-option label="能力未安装" value="capability_unavailable" /><el-option label="尚未收敛" value="not_converged" />
</el-select>
</el-form-item>
<el-form-item label="级别">
<el-select v-model="query.severity" clearable placeholder="全部">
<el-option label="高" value="high" /><el-option label="中" value="medium" /><el-option label="提示" value="info" />
</el-select>
</el-form-item>
<el-form-item label="关键词"><el-input v-model="query.keyword" clearable placeholder="设备、位置或差异" @keyup.enter="search" /></el-form-item>
<el-form-item class="filter-actions">
<el-button type="primary" :icon="Search" native-type="submit">查询</el-button>
<el-button :icon="RefreshLeft" @click="reset">重置</el-button>
</el-form-item>
</el-form>
<el-table v-loading="loading" :data="problems" border stripe empty-text="当前筛选条件下没有未收敛问题">
<el-table-column label="对象" min-width="170">
<template #default="scope"><strong>{{ scope.row.objectName }}</strong><div class="muted">{{ objectLabel(scope.row.objectType) }} · {{ scope.row.location || scope.row.objectId }}</div></template>
</el-table-column>
<el-table-column label="问题" min-width="138"><template #default="scope"><div>{{ problemLabel(scope.row.problemType) }}</div><small>{{ scope.row.id }}</small></template></el-table-column>
<el-table-column label="期望态" min-width="110"><template #default="scope">{{ stateLabel(scope.row.expected) }}</template></el-table-column>
<el-table-column label="实际态" min-width="120"><template #default="scope"><el-tag size="small" :type="severityType(scope.row.severity)">{{ stateLabel(scope.row.actual) }}</el-tag></template></el-table-column>
<el-table-column prop="difference" label="未收敛差异" min-width="210" show-overflow-tooltip />
<el-table-column label="下次动作" min-width="190"><template #default="scope"><div>{{ scope.row.nextAction }}</div><small v-if="scope.row.nextRetryAt">自动重试:{{ formatTime(scope.row.nextRetryAt) }}</small></template></el-table-column>
<el-table-column label="级别" width="78" align="center"><template #default="scope"><el-tag size="small" :type="severityType(scope.row.severity)">{{ severityLabel(scope.row.severity) }}</el-tag></template></el-table-column>
<el-table-column label="操作" width="156" fixed="right">
<template #default="scope">
<el-button v-permisaction="['sense:operations:detail']" type="primary" link @click="openDetail(scope.row.id)">详情</el-button>
<el-button v-if="canRetry(scope.row)" v-permisaction="['sense:operations:retry']" type="warning" link @click="confirmRetry(scope.row)">受控重试</el-button>
<span v-else-if="scope.row.retryInProgress" class="muted">重试已排队</span>
</template>
</el-table-column>
</el-table>
<pagination v-show="total > 0" v-model:current-page="query.pageIndex" v-model:page-size="query.pageSize" :total="total" @pagination="load" />
<p class="safety-help">受控重试会校验权限、当前版本和并发占用,并写入操作审计。孤儿资源仅隔离和提示,本页没有删除操作。</p>
</el-card>
<el-dialog v-model="detailOpen" title="状态详情" width="min(760px, calc(100vw - 24px))" :close-on-click-modal="false">
<el-descriptions v-if="selected" :column="2" border>
<el-descriptions-item label="问题编号">{{ selected.id }}</el-descriptions-item>
<el-descriptions-item label="对象">{{ selected.objectName }}</el-descriptions-item>
<el-descriptions-item label="期望态">{{ stateLabel(selected.expected) }}</el-descriptions-item>
<el-descriptions-item label="实际态">{{ stateLabel(selected.actual) }}</el-descriptions-item>
<el-descriptions-item label="未收敛差异" :span="2">{{ selected.difference }}</el-descriptions-item>
<el-descriptions-item label="退避 / 下次重试">{{ selected.nextRetryAt ? formatTime(selected.nextRetryAt) : '无退避' }}</el-descriptions-item>
<el-descriptions-item label="尝试次数">{{ selected.attemptCount }}</el-descriptions-item>
<el-descriptions-item label="建议处理" :span="2">{{ selected.nextAction }}</el-descriptions-item>
</el-descriptions>
<el-alert v-if="selected" title="安全闸已启用" type="warning" :closable="false" show-icon class="safety-alert">{{ selected.safetyGate }}</el-alert>
<p class="boundary-text">Brain/Bell 状态不会参与本地设备和媒体重试判定。</p>
<template #footer>
<el-button @click="detailOpen = false">关闭</el-button>
<el-button v-if="canRetry(selected)" v-permisaction="['sense:operations:retry']" type="warning" @click="confirmRetry(selected)">受控重试</el-button>
</template>
</el-dialog>
</template>
</BasicLayout>
</template>
<script setup>
import { onMounted, reactive, ref } from 'vue'
import { Refresh, RefreshLeft, Search } from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { getOperationProblem, listOperationProblems, retryOperationProblem } from '@/api/sense/operations'
import { buildOperationsQuery, canRetry, objectLabel, problemLabel, severityLabel, severityType, stateLabel } from './operationsState'
defineOptions({ name: 'SenseOperations' })
const loading = ref(false)
const problems = ref([])
const total = ref(0)
const summary = reactive({ managedCount: 0, convergedCount: 0, actionableProblems: 0, localInference: 'unavailable' })
const query = reactive({ pageIndex: 1, pageSize: 10, objectType: '', problemType: '', severity: '', keyword: '' })
const detailOpen = ref(false)
const selected = ref(null)
const inferenceState = ref('不可用(未安装)')
function unwrap(response) { return response?.data?.data ?? response?.data ?? response }
function formatTime(value) { return value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '—' }
async function load() {
loading.value = true
try {
const payload = unwrap(await listOperationProblems(buildOperationsQuery(query))) || {}
problems.value = payload.list || []
total.value = payload.count || 0
Object.assign(summary, payload.summary || { managedCount: 0, convergedCount: 0, actionableProblems: 0 })
inferenceState.value = summary.localInference === 'configured' ? '已配置' : '不可用(未安装)'
} catch (error) {
ElMessage.error(error.message || '运维状态加载失败')
} finally {
loading.value = false
}
}
function search() { query.pageIndex = 1; load() }
function reset() { Object.assign(query, { pageIndex: 1, objectType: '', problemType: '', severity: '', keyword: '' }); load() }
async function openDetail(id) {
try {
selected.value = unwrap(await getOperationProblem(id))
detailOpen.value = true
} catch (error) {
ElMessage.error(error.message || '状态详情加载失败')
}
}
async function confirmRetry(item) {
try {
await ElMessageBox.confirm(`将对“${item.objectName}”发起一次重试。此操作不会修改凭据,也不会删除媒体资源。`, '确认受控重试', {
type: 'warning', confirmButtonText: '确认重试', cancelButtonText: '取消'
})
await retryOperationProblem(item.id, item.version)
ElMessage.success('重试任务已排队')
detailOpen.value = false
await load()
} catch (error) {
if (error !== 'cancel' && error !== 'close') ElMessage.warning(error.message || '重试未排队,请刷新状态后再试')
}
}
onMounted(load)
</script>
<style scoped>
.page-header,.section-header{display:flex;align-items:flex-start;justify-content:space-between;gap:16px}.page-header h3,.section-header h4{margin:0 0 6px}.page-header p,.section-header p{margin:0;color:#909399}.boundary-alert{margin:16px 0}.summary-row{margin-bottom:20px}.summary-item{display:flex;align-items:center;justify-content:space-between;min-height:72px;padding:12px 16px;border:1px solid #ebeef5;border-radius:4px}.summary-item span{color:#606266}.summary-item strong{font-size:22px}.summary-item .warning-number{color:#e6a23c}.summary-item .adapter-state{font-size:15px;color:#909399}.filter-form{display:flex;align-items:flex-end;flex-wrap:wrap;gap:0 12px;margin:16px 0 2px}.filter-form .el-form-item{width:210px}.filter-form .filter-actions{width:auto}.filter-form :deep(.el-select){width:100%}.muted,small{color:#909399;font-size:12px}.safety-help,.boundary-text{margin:12px 0 0;color:#909399;font-size:12px}.safety-alert{margin-top:16px}@media(max-width:768px){.filter-form .el-form-item{width:100%}.page-header{align-items:stretch;flex-direction:column}.summary-item{margin-bottom:8px}}
</style>
@@ -0,0 +1,50 @@
const objectLabels = {
device: '设备',
media: '媒体',
local_inference: '本地推理'
}
const problemLabels = {
authentication_failed: '认证失败',
backoff_wait: '退避等待',
clock_drift: '时间漂移',
orphan_safety_gate: '孤儿安全闸',
capability_unavailable: '能力未安装',
not_converged: '尚未收敛'
}
const stateLabels = {
active: '已启用',
ready: '就绪',
running: '运行',
stopped: '已停止',
waiting: '等待拉流',
pending: '等待处理',
retry_pending: '重试已排队',
authentication_failed: '认证失败',
clock_drift: '时间漂移',
apply_failed: '配置失败',
process_unavailable: '进程不可用',
status_unavailable: '状态不可用',
unavailable: '不可用(未安装)'
}
export function objectLabel(value) { return objectLabels[value] || value || '未知' }
export function problemLabel(value) { return problemLabels[value] || value || '未知问题' }
export function stateLabel(value) {
return String(value || '').split(' / ').map(item => stateLabels[item] || item).join(' / ') || '未知'
}
export function severityLabel(value) { return ({ high: '高', medium: '中', info: '提示' })[value] || value || '未知' }
export function severityType(value) { return ({ high: 'danger', medium: 'warning', info: 'info' })[value] || 'info' }
export function canRetry(item) { return Boolean(item?.retryable && !item?.retryInProgress && item?.version > 0) }
export function buildOperationsQuery(query) {
return {
pageIndex: Number(query.pageIndex) || 1,
pageSize: Number(query.pageSize) || 10,
objectType: String(query.objectType || '').trim(),
problemType: String(query.problemType || '').trim(),
severity: String(query.severity || '').trim(),
keyword: String(query.keyword || '').trim()
}
}
@@ -0,0 +1,200 @@
<template>
<BasicLayout>
<template #wrapper>
<el-card class="box-card">
<div class="page-header">
<div>
<h3>摄像头批量开通</h3>
<p>按模板导入、检查配额、临时填写凭据并执行开通;密码不会保存在批次记录中。</p>
</div>
<el-button :icon="Refresh" @click="loadBatches">刷新记录</el-button>
</div>
<el-steps :active="activeStep" finish-status="success" simple class="workflow-steps">
<el-step title="导入清单" />
<el-step title="预校验" />
<el-step title="填写凭据" />
<el-step title="查看结果" />
</el-steps>
<el-alert title="默认交付配额为 16 路,实际可开通数量以服务端预校验结果为准。模板不得包含账号或密码列。" type="info" :closable="false" show-icon />
<div class="toolbar">
<el-upload v-permisaction="['sense:provisioning:import']" :auto-upload="false" :show-file-list="false" accept=".xlsx,.xls,.csv" :on-change="handleFile">
<el-button type="primary" :icon="Upload">选择清单</el-button>
</el-upload>
<el-button :icon="Download" @click="downloadTemplate">下载模板</el-button>
<span class="file-name">{{ fileName || '尚未选择文件' }}</span>
</div>
<el-table v-if="importRows.length" :data="importRows" border stripe max-height="360">
<el-table-column prop="lineNumber" label="行号" width="80" />
<el-table-column prop="name" label="设备名称" min-width="150" />
<el-table-column prop="location" label="安装位置" min-width="160" />
<el-table-column prop="address" label="ONVIF 地址" min-width="250" show-overflow-tooltip />
</el-table>
<div v-if="importRows.length" class="import-actions">
<el-button v-permisaction="['sense:provisioning:import']" type="primary" :loading="importing" @click="submitImport">提交预校验</el-button>
<span>共 {{ importRows.length }} 条;提交后服务端会检查格式、重复地址和剩余配额。</span>
</div>
<el-divider />
<div class="section-title">
<h4>开通批次</h4>
<el-select v-model="statusFilter" placeholder="全部状态" clearable @change="loadBatches">
<el-option v-for="item in statusOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</div>
<el-table v-loading="loading" :data="batches" border stripe>
<el-table-column prop="createdAt" label="创建时间" width="180">
<template #default="scope">{{ parseTime(scope.row.createdAt) }}</template>
</el-table-column>
<el-table-column label="进度" min-width="190">
<template #default="scope">总数 {{ scope.row.totalCount }} / 可执行 {{ scope.row.readyCount }} / 成功 {{ scope.row.successCount }} / 失败 {{ scope.row.failureCount }}</template>
</el-table-column>
<el-table-column label="配额" width="140">
<template #default="scope">已用 {{ scope.row.existingCount }} / {{ scope.row.quotaLimit }}</template>
</el-table-column>
<el-table-column label="状态" width="110" align="center">
<template #default="scope"><el-tag :type="statusType(scope.row.status)">{{ statusLabel(scope.row.status) }}</el-tag></template>
</el-table-column>
<el-table-column label="操作" width="230" fixed="right">
<template #default="scope">
<el-button type="primary" link @click="openBatch(scope.row.id)">查看</el-button>
<el-button v-if="scope.row.readyCount" v-permisaction="['sense:provisioning:execute']" type="primary" link @click="openCredentials(scope.row.id, false)">填写凭据</el-button>
<el-button v-if="scope.row.failureCount" v-permisaction="['sense:provisioning:retry']" type="warning" link @click="openCredentials(scope.row.id, true)">重试失败</el-button>
</template>
</el-table-column>
</el-table>
<pagination v-show="total > 0" v-model:current-page="query.pageIndex" v-model:page-size="query.pageSize" :total="total" @pagination="loadBatches" />
</el-card>
<el-dialog v-model="detailOpen" title="批次详情" width="min(980px, calc(100vw - 24px))" :close-on-click-modal="false">
<div v-if="selectedBatch" class="summary-cards" role="status" aria-live="polite">
<el-tag>{{ statusLabel(selectedBatch.status) }}</el-tag>
<span>配额 {{ selectedBatch.existingCount }} / {{ selectedBatch.quotaLimit }}</span>
<span>成功 {{ selectedBatch.successCount }}</span>
<span>失败 {{ selectedBatch.failureCount }}</span>
</div>
<el-table v-if="selectedBatch" :data="selectedBatch.items" border stripe max-height="470">
<el-table-column prop="lineNumber" label="行号" width="70" />
<el-table-column prop="name" label="设备名称" min-width="130" />
<el-table-column prop="location" label="安装位置" min-width="130" />
<el-table-column prop="address" label="地址" min-width="210" show-overflow-tooltip />
<el-table-column label="状态" width="110"><template #default="scope"><el-tag :type="statusType(scope.row.status)">{{ statusLabel(scope.row.status) }}</el-tag></template></el-table-column>
<el-table-column prop="detail" label="说明" min-width="200" show-overflow-tooltip />
<el-table-column label="操作" width="90" fixed="right"><template #default="scope"><el-button v-if="scope.row.status === 'failed'" v-permisaction="['sense:provisioning:retry']" type="warning" link @click="openSingleRetry(scope.row)">重试</el-button></template></el-table-column>
</el-table>
<template #footer>
<el-button v-permisaction="['sense:provisioning:export']" :icon="Download" @click="exportSelected">导出结果</el-button>
<el-button @click="detailOpen = false">关闭</el-button>
</template>
</el-dialog>
<el-dialog v-model="credentialOpen" :title="singleItemId ? '重试单个失败项' : retryMode ? '重试失败项' : '填写临时凭据并开通'" width="min(1180px, calc(100vw - 24px))" :close-on-click-modal="false" @closed="clearCredentials">
<el-alert title="凭据仅用于本次请求,提交成功或关闭窗口后立即从页面内存清除。" type="warning" :closable="false" show-icon />
<el-table :data="credentialItems" border stripe max-height="500" class="credential-table">
<el-table-column prop="name" label="设备" min-width="130" />
<el-table-column prop="address" label="地址" min-width="190" show-overflow-tooltip />
<el-table-column label="ONVIF 用户名" min-width="145"><template #default="scope"><el-input v-model="credentials[scope.row.id].onvifUsername" autocomplete="off" /></template></el-table-column>
<el-table-column label="ONVIF 密码" min-width="160"><template #default="scope"><el-input v-model="credentials[scope.row.id].onvifPassword" type="password" show-password autocomplete="new-password" /></template></el-table-column>
<el-table-column label="RTSP" width="120"><template #default="scope"><el-checkbox v-model="credentials[scope.row.id].rtspSameAsOnvif">同上</el-checkbox></template></el-table-column>
<el-table-column label="RTSP 用户名" min-width="145"><template #default="scope"><el-input v-model="credentials[scope.row.id].rtspUsername" :disabled="credentials[scope.row.id].rtspSameAsOnvif" autocomplete="off" /></template></el-table-column>
<el-table-column label="RTSP 密码" min-width="160"><template #default="scope"><el-input v-model="credentials[scope.row.id].rtspPassword" :disabled="credentials[scope.row.id].rtspSameAsOnvif" type="password" show-password autocomplete="new-password" /></template></el-table-column>
</el-table>
<template #footer>
<el-button type="primary" :loading="executing" @click="executeBatch">确认执行</el-button>
<el-button @click="credentialOpen = false">取消</el-button>
</template>
</el-dialog>
</template>
</BasicLayout>
</template>
<script setup>
import { computed, onMounted, reactive, ref } from 'vue'
import { Download, Refresh, Upload } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import * as XLSX from 'xlsx'
import { createProvisioningBatch, downloadProvisioningBatch, executeProvisioningBatch, getProvisioningBatch, listProvisioningBatches, retryProvisioningFailures, retryProvisioningItem } from '@/api/sense/provisioning'
import { clearCredentialMap, createBatchPayload, credentialPayload, normalizeImportRows } from './provisioningPayload'
defineOptions({ name: 'SenseProvisioning' })
const loading = ref(false); const importing = ref(false); const executing = ref(false)
const batches = ref([]); const total = ref(0); const statusFilter = ref(''); const fileName = ref(''); const importRows = ref([])
const selectedBatch = ref(null); const detailOpen = ref(false); const credentialOpen = ref(false); const retryMode = ref(false)
const singleItemId = ref('')
const credentials = reactive({}); const query = reactive({ pageIndex: 1, pageSize: 10 })
const statusOptions = [{ value: 'ready', label: '待执行' }, { value: 'partial', label: '部分成功' }, { value: 'succeeded', label: '已完成' }, { value: 'failed', label: '失败' }]
const activeStep = computed(() => credentialOpen.value ? 2 : selectedBatch.value?.status === 'succeeded' || selectedBatch.value?.status === 'partial' ? 3 : importRows.value.length ? 1 : 0)
const credentialItems = computed(() => (selectedBatch.value?.items || []).filter(item => singleItemId.value ? item.id === singleItemId.value : retryMode.value ? item.status === 'failed' : item.status === 'ready'))
function unwrap(response) { return response?.data?.data ?? response?.data ?? response }
function statusLabel(status) { return ({ ready: '待执行', running: '执行中', succeeded: '已完成', partial: '部分成功', failed: '失败', invalid: '格式错误', quota_exceeded: '超出配额' })[status] || status }
function statusType(status) { return ({ succeeded: 'success', partial: 'warning', failed: 'danger', invalid: 'danger', quota_exceeded: 'info', ready: 'primary' })[status] || 'info' }
function saveText(content, name, type = 'text/csv;charset=utf-8') { const url = URL.createObjectURL(new Blob([content], { type })); const link = document.createElement('a'); link.href = url; link.download = name; link.click(); URL.revokeObjectURL(url) }
function saveBlob(content, name) { const url = URL.createObjectURL(content); const link = document.createElement('a'); link.href = url; link.download = name; link.click(); URL.revokeObjectURL(url) }
async function loadBatches() {
loading.value = true
try {
const payload = unwrap(await listProvisioningBatches({ ...query, status: statusFilter.value }))
batches.value = payload?.list || payload?.data || []
total.value = payload?.count || 0
} finally { loading.value = false }
}
async function handleFile(file) {
try {
const workbook = XLSX.read(await file.raw.arrayBuffer(), { type: 'array' })
const sheet = workbook.Sheets[workbook.SheetNames[0]]
const rows = XLSX.utils.sheet_to_json(sheet, { defval: '' })
importRows.value = normalizeImportRows(rows)
fileName.value = file.name
if (!importRows.value.length) ElMessage.warning('清单中没有可导入的数据')
} catch (error) { ElMessage.error(error.message || '无法读取清单文件') }
}
async function submitImport() {
if (!importRows.value.length) return ElMessage.warning('请先选择清单')
importing.value = true
try {
const key = `sense-${Date.now()}-${Math.random().toString(16).slice(2)}`
selectedBatch.value = unwrap(await createProvisioningBatch(createBatchPayload(importRows.value, key)))
detailOpen.value = true
ElMessage.success('预校验完成')
await loadBatches()
} catch (error) { ElMessage.error(error.message || '清单预校验失败') } finally { importing.value = false }
}
async function openBatch(id) { selectedBatch.value = unwrap(await getProvisioningBatch(id)); detailOpen.value = true }
async function openCredentials(id, retry) {
selectedBatch.value = unwrap(await getProvisioningBatch(id)); retryMode.value = retry; singleItemId.value = ''
credentialItems.value.forEach(item => { credentials[item.id] = { onvifUsername: '', onvifPassword: '', rtspSameAsOnvif: true, rtspUsername: '', rtspPassword: '' } })
credentialOpen.value = true
}
function openSingleRetry(item) { retryMode.value = true; singleItemId.value = item.id; credentials[item.id] = { onvifUsername: '', onvifPassword: '', rtspSameAsOnvif: true, rtspUsername: '', rtspPassword: '' }; detailOpen.value = false; credentialOpen.value = true }
function clearCredentials() { clearCredentialMap(credentials); Object.keys(credentials).forEach(key => delete credentials[key]); singleItemId.value = '' }
async function executeBatch() {
if (credentialItems.value.some(item => !credentials[item.id]?.onvifUsername || !credentials[item.id]?.onvifPassword)) return ElMessage.warning('请填写所有待处理设备的 ONVIF 用户名和密码')
if (credentialItems.value.some(item => credentials[item.id]?.rtspSameAsOnvif === false && (!credentials[item.id]?.rtspUsername || !credentials[item.id]?.rtspPassword))) return ElMessage.warning('请填写所有独立 RTSP 凭据')
executing.value = true
try {
const payload = credentialPayload(credentialItems.value, credentials)
const action = singleItemId.value
? retryProvisioningItem(selectedBatch.value.id, singleItemId.value, payload)
: retryMode.value
? retryProvisioningFailures(selectedBatch.value.id, payload)
: executeProvisioningBatch(selectedBatch.value.id, payload)
selectedBatch.value = unwrap(await action)
clearCredentials(); credentialOpen.value = false; detailOpen.value = true
ElMessage.success('批量开通处理完成')
await loadBatches()
} catch (error) { clearCredentials(); ElMessage.error(error.message || '批量开通失败') } finally { executing.value = false }
}
async function exportSelected() { if (!selectedBatch.value) return; try { saveBlob(await downloadProvisioningBatch(selectedBatch.value.id), `sense-provisioning-${selectedBatch.value.id}.csv`) } catch (error) { ElMessage.error(error.message || '结果导出失败') } }
function downloadTemplate() { saveText('\uFEFFline_number,name,location,address\r\n1,东门摄像机,教学楼一楼东门,http://192.0.2.10/onvif', 'sense-provisioning-template.csv') }
onMounted(loadBatches)
</script>
<style scoped>
.page-header,.section-title,.toolbar,.import-actions,.summary-cards{display:flex;align-items:center;gap:12px}.page-header,.section-title{justify-content:space-between}.page-header h3,.section-title h4{margin:0 0 6px}.page-header p{margin:0;color:#909399}.workflow-steps{margin:18px 0}.toolbar{margin:18px 0}.file-name,.import-actions span{color:#606266;font-size:13px}.import-actions{justify-content:flex-end;margin-top:16px}.section-title{margin-bottom:12px}.section-title .el-select{width:160px}.summary-cards{margin-bottom:14px}.credential-table{margin-top:16px}
</style>
@@ -0,0 +1,58 @@
const aliases = {
lineNumber: ['lineNumber', 'line_number', '序号', '行号'],
name: ['name', '设备名称', '摄像头名称'],
location: ['location', '安装位置', '位置'],
address: ['address', '设备地址', 'ONVIF地址', 'onvif_address']
}
function readAlias(row, names) {
const key = names.find(name => Object.prototype.hasOwnProperty.call(row, name))
return key ? row[key] : ''
}
export function normalizeImportRows(rows) {
return rows.map((row, index) => ({
lineNumber: Number(readAlias(row, aliases.lineNumber)) || index + 1,
name: String(readAlias(row, aliases.name) || '').trim(),
location: String(readAlias(row, aliases.location) || '').trim(),
address: String(readAlias(row, aliases.address) || '').trim()
}))
}
export function createBatchPayload(rows, idempotencyKey) {
return {
idempotencyKey: String(idempotencyKey || '').trim(),
rows: normalizeImportRows(rows).map(row => ({
lineNumber: row.lineNumber,
name: row.name,
location: row.location,
address: row.address
}))
}
}
export function credentialPayload(items, credentialByItem) {
return {
credentials: items.map(item => {
const value = credentialByItem[item.id] || {}
const same = value.rtspSameAsOnvif !== false
return {
itemId: item.id,
onvifUsername: String(value.onvifUsername || ''),
onvifPassword: String(value.onvifPassword || ''),
rtspSameAsOnvif: same,
rtspUsername: same ? '' : String(value.rtspUsername || ''),
rtspPassword: same ? '' : String(value.rtspPassword || '')
}
})
}
}
export function clearCredentialMap(credentialByItem) {
Object.keys(credentialByItem).forEach(key => {
credentialByItem[key].onvifUsername = ''
credentialByItem[key].onvifPassword = ''
credentialByItem[key].rtspUsername = ''
credentialByItem[key].rtspPassword = ''
})
}
+177
View File
@@ -0,0 +1,177 @@
<template>
<BasicLayout>
<template #wrapper>
<el-card class="box-card">
<div class="page-header">
<div>
<h3>容量与配额</h3>
<p>查看当前占用与安全闸状态。配额是交付配置,不等于单机性能承诺。</p>
</div>
<div class="header-actions">
<el-button v-permisaction="['sense:device:add']" :icon="Plus" :disabled="!gate.writable" @click="router.push('/sense/devices')">新增设备</el-button>
<el-button
v-permisaction="['sense:quota:update']"
type="primary"
:icon="Setting"
:disabled="summary.readStatus !== 'readable'"
@click="openQuotaDialog"
>调整配额</el-button>
</div>
</div>
<el-alert :title="gate.title" :type="gate.type" :closable="false" show-icon class="gate-alert">
{{ gate.detail }}
</el-alert>
<el-row :gutter="12" class="summary-row" aria-label="容量摘要">
<el-col :xs="24" :sm="12" :lg="6"><div class="summary-item"><span>当前配置配额</span><strong>{{ readableLimit }}</strong><small>来源:{{ sourceLabel }}</small></div></el-col>
<el-col :xs="24" :sm="12" :lg="6"><div class="summary-item"><span>已占用设备</span><strong>{{ summary.used }} 路</strong><small>已接入和待接入均占用</small></div></el-col>
<el-col :xs="24" :sm="12" :lg="6"><div class="summary-item"><span>剩余可用</span><strong>{{ readableRemaining }}</strong><small>写入时再次原子校验</small></div></el-col>
<el-col :xs="24" :sm="12" :lg="6"><div class="summary-item"><span>配额读取状态</span><strong class="read-state"><el-tag :type="summary.readStatus === 'readable' ? 'success' : 'danger'">{{ summary.readStatus === 'readable' ? '可读取' : '读取失败' }}</el-tag></strong><small>最后读取:{{ formatTime(summary.lastReadAt) }}</small></div></el-col>
</el-row>
<section class="usage-section">
<div class="section-header"><h4>占用情况</h4><span>{{ usageCaption }}</span></div>
<el-progress :percentage="percent" :status="gate.type === 'error' ? 'exception' : gate.type === 'warning' ? 'warning' : ''" />
</section>
<div class="section-header"><div><h4>设备占用明细</h4><p>停用设备不占用配额;重新启用时会执行安全闸校验。</p></div></div>
<el-form :model="query" class="filter-form" label-width="76px" @submit.prevent="search">
<el-form-item label="状态">
<el-select v-model="query.status" clearable placeholder="全部状态">
<el-option label="已接入" value="active" /><el-option label="待接入" value="pending" /><el-option label="已停用" value="disabled" />
</el-select>
</el-form-item>
<el-form-item label="设备名称"><el-input v-model="query.keyword" clearable placeholder="请输入设备名称或位置" @keyup.enter="search" /></el-form-item>
<el-form-item class="filter-actions"><el-button type="primary" :icon="Search" native-type="submit">查询</el-button><el-button :icon="RefreshLeft" @click="reset">重置</el-button></el-form-item>
</el-form>
<el-table v-loading="loading" :data="devices" border stripe empty-text="当前没有设备">
<el-table-column prop="name" label="设备名称" min-width="160" />
<el-table-column prop="location" label="安装位置" min-width="180" show-overflow-tooltip />
<el-table-column label="接入状态" width="110" align="center"><template #default="scope"><el-tag :type="scope.row.status === 'active' ? 'success' : scope.row.status === 'disabled' ? 'info' : 'warning'">{{ deviceStatusLabel(scope.row.status) }}</el-tag></template></el-table-column>
<el-table-column label="配额占用" width="110" align="center"><template #default="scope">{{ scope.row.occupied ? '占用 1 路' : '不占用' }}</template></el-table-column>
<el-table-column label="更新时间" width="180" align="center"><template #default="scope">{{ formatTime(scope.row.updatedAt) }}</template></el-table-column>
<el-table-column label="操作" width="100" fixed="right">
<template #default="scope">
<el-button
v-if="scope.row.status === 'disabled'"
v-permisaction="['sense:device:enable']"
type="success"
link
:disabled="!gate.writable"
@click="enable(scope.row)"
>启用</el-button>
<el-button v-else type="primary" link @click="router.push('/sense/devices')">查看</el-button>
</template>
</el-table-column>
</el-table>
<pagination v-show="total > 0" v-model:current-page="query.pageIndex" v-model:page-size="query.pageSize" :total="total" @pagination="load" />
<div class="section-header tier-header"><div><h4>交付档位与验证状态</h4><p>“未验证”不代表系统承诺可稳定承载,必须先完成目标硬件性能测试。</p></div></div>
<el-table :data="tiers" border>
<el-table-column label="档位" width="100"><template #default="scope">{{ scope.row.limit }} 路</template></el-table-column>
<el-table-column label="当前配置" width="120"><template #default="scope"><el-tag v-if="scope.row.configured" type="success">当前配置</el-tag><span v-else>未配置</span></template></el-table-column>
<el-table-column label="性能测试" width="120"><template #default="scope"><el-tag :type="scope.row.validation === 'verified' ? 'success' : 'warning'">{{ tierValidationLabel(scope.row.validation) }}</el-tag></template></el-table-column>
<el-table-column prop="deliveryStatus" label="说明" min-width="220" />
</el-table>
</el-card>
<el-dialog v-model="dialogOpen" title="调整配额" width="min(520px, calc(100vw - 24px))" :close-on-click-modal="false" @closed="resetDialog">
<el-alert title="调整前请确认目标硬件已经完成容量测试" type="warning" :closable="false" show-icon class="dialog-alert">
降低到当前占用以下不会中断已有视频,但会阻止新增与启用,直到占用回到配额内。
</el-alert>
<el-form ref="quotaFormRef" :model="quotaForm" :rules="quotaRules" label-width="110px">
<el-form-item label="新配额(路)" prop="limit"><el-input-number v-model="quotaForm.limit" :min="1" :max="100000" controls-position="right" /></el-form-item>
<el-form-item label="变更原因" prop="reason"><el-input v-model.trim="quotaForm.reason" maxlength="512" show-word-limit placeholder="请输入变更原因" /></el-form-item>
</el-form>
<template #footer><el-button @click="dialogOpen = false">取消</el-button><el-button type="primary" :loading="saving" @click="saveQuota">确认调整</el-button></template>
</el-dialog>
</template>
</BasicLayout>
</template>
<script setup>
import { computed, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { Plus, RefreshLeft, Search, Setting } from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { getQuotaOverview, updateQuota } from '@/api/sense/quota'
import { enableDevice } from '@/api/sense/device'
import { buildQuotaQuery, deviceStatusLabel, quotaGate, tierValidationLabel, usagePercent } from './quotaState'
defineOptions({ name: 'SenseQuota' })
const loading = ref(false)
const router = useRouter()
const saving = ref(false)
const devices = ref([])
const tiers = ref([])
const total = ref(0)
const summary = reactive({ limit: 0, used: 0, remaining: 0, readStatus: 'unreadable', source: '', version: 0 })
const query = reactive({ pageIndex: 1, pageSize: 10, keyword: '', status: '' })
const dialogOpen = ref(false)
const quotaFormRef = ref()
const quotaForm = reactive({ limit: 16, reason: '' })
const quotaRules = {
limit: [{ required: true, type: 'number', min: 1, max: 100000, message: '请输入 1 至 100000 的整数配额', trigger: 'change' }],
reason: [{ required: true, message: '请输入变更原因,以便审计追溯', trigger: 'blur' }]
}
const gate = computed(() => quotaGate(summary))
const percent = computed(() => usagePercent(summary))
const readableLimit = computed(() => summary.readStatus === 'readable' ? `${summary.limit} 路` : '—')
const readableRemaining = computed(() => summary.readStatus === 'readable' ? `${summary.remaining} 路` : '—')
const sourceLabel = computed(() => summary.readStatus === 'readable' ? (summary.source === 'migration' ? '初始化配置' : '数据库配置') : '不可读取')
const usageCaption = computed(() => summary.readStatus === 'readable' ? `已占用 ${summary.used} / ${summary.limit} 路(${percent.value}%)` : `已占用 ${summary.used} 路;配额上限不可读取`)
function unwrap(response) { return response?.data?.data ?? response?.data ?? response }
function formatTime(value) { return value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '—' }
async function load() {
loading.value = true
try {
const payload = unwrap(await getQuotaOverview(buildQuotaQuery(query))) || {}
Object.assign(summary, payload.summary || {})
devices.value = payload.list || []
tiers.value = payload.tiers || []
total.value = payload.count || 0
} catch (error) {
ElMessage.error(error.message || '容量与配额加载失败')
} finally {
loading.value = false
}
}
function search() { query.pageIndex = 1; load() }
function reset() { Object.assign(query, { pageIndex: 1, keyword: '', status: '' }); load() }
function openQuotaDialog() { quotaForm.limit = summary.limit; quotaForm.reason = ''; dialogOpen.value = true }
function resetDialog() { quotaForm.reason = ''; quotaFormRef.value?.clearValidate() }
async function enable(row) {
try {
await ElMessageBox.confirm(`启用“${row.name}”会占用一路配额,并要求重新完成视频接入验证。是否继续?`, '启用设备', {
type: 'warning', confirmButtonText: '确认启用', cancelButtonText: '取消'
})
await enableDevice(row.id, { version: row.version })
ElMessage.success('设备已启用,请重新完成视频接入验证')
await load()
} catch (error) {
if (error !== 'cancel' && error !== 'close') ElMessage.warning(error.message || '设备启用失败')
}
}
async function saveQuota() {
if (!await quotaFormRef.value.validate().catch(() => false)) return
saving.value = true
try {
await updateQuota({ limit: quotaForm.limit, reason: quotaForm.reason, version: summary.version })
ElMessage.success('配额已更新')
dialogOpen.value = false
await load()
} catch (error) {
ElMessage.warning(error.message || '配额更新失败,请刷新后重试')
} finally {
saving.value = false
}
}
onMounted(load)
</script>
<style scoped>
.page-header,.section-header{display:flex;align-items:flex-start;justify-content:space-between;gap:16px}.page-header h3,.section-header h4{margin:0 0 6px}.page-header p,.section-header p{margin:0;color:#909399}.header-actions{display:flex;gap:8px;flex-wrap:wrap}.gate-alert{margin:16px 0}.summary-row{margin-bottom:18px}.summary-item{display:flex;flex-direction:column;min-height:108px;padding:14px 16px;border:1px solid #ebeef5;border-radius:4px}.summary-item span,.summary-item small{color:#606266}.summary-item strong{margin:8px 0 4px;font-size:24px}.summary-item .read-state{font-size:16px}.usage-section{margin:4px 0 22px;padding:16px;border:1px solid #ebeef5}.filter-form{display:flex;align-items:flex-end;flex-wrap:wrap;gap:0 12px;margin:16px 0 2px}.filter-form .el-form-item{width:250px}.filter-form .filter-actions{width:auto}.filter-form :deep(.el-select){width:100%}.tier-header{margin-top:24px}.dialog-alert{margin-bottom:18px}@media(max-width:768px){.page-header{align-items:stretch;flex-direction:column}.filter-form .el-form-item{width:100%}.summary-item{margin-bottom:8px}}
</style>
@@ -0,0 +1,48 @@
export function buildQuotaQuery(query) {
return {
pageIndex: Math.max(1, Number(query.pageIndex) || 1),
pageSize: Math.min(100, Math.max(1, Number(query.pageSize) || 10)),
keyword: String(query.keyword || '').trim(),
status: String(query.status || '').trim()
}
}
export function quotaGate(summary = {}) {
if (summary.readStatus !== 'readable') {
return {
type: 'error',
title: '配额配置不可读取',
detail: '为避免超配额,新增和启用请求已关闭;已有设备、视频流与查询不受影响。请检查配置来源后重试。',
writable: false
}
}
if ((Number(summary.remaining) || 0) <= 0) {
return {
type: 'warning',
title: '当前配额已满',
detail: '新增和启用请求将被拒绝;已有设备、视频流与查询继续正常运行。',
writable: false
}
}
return {
type: 'success',
title: '安全闸正常',
detail: '新增和启用设备可继续;系统会在写入时再次原子校验剩余配额。',
writable: true
}
}
export function usagePercent(summary = {}) {
const limit = Number(summary.limit) || 0
const used = Number(summary.used) || 0
if (limit <= 0) return 0
return Math.min(100, Math.max(0, Math.round((used / limit) * 100)))
}
export function deviceStatusLabel(status) {
return ({ active: '已接入', pending: '待接入', disabled: '已停用' })[status] || status || '未知'
}
export function tierValidationLabel(value) {
return value === 'verified' ? '已验证' : '未验证'
}
@@ -0,0 +1,15 @@
import request from '@/utils/request'
import { getLocalEvent, listLocalEvents } from '@/api/sense/local-event'
jest.mock('@/utils/request', () => jest.fn())
describe('Sense local event API', () => {
beforeEach(() => request.mockReset())
test('uses read-only local endpoints', () => {
listLocalEvents({ pageIndex: 1, pageSize: 10 })
getLocalEvent('event/unsafe')
expect(request).toHaveBeenNthCalledWith(1, { url: '/api/v1/local-events', method: 'get', params: { pageIndex: 1, pageSize: 10 }})
expect(request).toHaveBeenNthCalledWith(2, { url: '/api/v1/local-events/event%2Funsafe', method: 'get' })
})
})
@@ -0,0 +1,23 @@
import { buildLocalEventQuery, candidateLabel, evidenceLabel, evidenceType, retentionLabel } from '@/views/sense/local-event/localEventState'
describe('Sense local event presentation state', () => {
test('keeps candidate and evidence states distinct', () => {
expect(candidateLabel('candidate')).toBe('候选')
expect(candidateLabel('confirmed')).toBe('已确认事件')
expect(evidenceLabel('pending')).toBe('处理中')
expect(evidenceLabel('success')).toBe('成功')
expect(evidenceLabel('failed')).toBe('失败')
expect(evidenceType('failed')).toBe('danger')
})
test('builds an allowlisted query with RFC3339 time bounds', () => {
const result = buildLocalEventQuery({ pageIndex: 2, pageSize: 20, candidateState: 'candidate', evidenceState: 'failed', ruleRef: ' rule-1 ', keyword: ' camera-3 ', ignored: 'not-sent' }, ['2026-08-27T00:00:00Z', '2026-08-28T00:00:00Z'])
expect(result).toEqual({ pageIndex: 2, pageSize: 20, candidateState: 'candidate', evidenceState: 'failed', ruleRef: 'rule-1', keyword: 'camera-3', occurredAfter: '2026-08-27T00:00:00.000Z', occurredBefore: '2026-08-28T00:00:00.000Z' })
})
test('describes retention without inventing a global duration', () => {
expect(retentionLabel({ retainUntil: '2026-09-09T00:00:00Z', retentionState: 'active', remainingDays: 12 })).toBe('保留至 2026-09-09')
expect(retentionLabel({ retainUntil: '2026-08-30T00:00:00Z', retentionState: 'expiring', remainingDays: 2 })).toBe('2 天内到期')
expect(retentionLabel({ retainUntil: '2026-08-01T00:00:00Z', retentionState: 'expired', remainingDays: 0 })).toBe('已到期')
})
})
@@ -0,0 +1,17 @@
import request from '@/utils/request'
import { getOperationProblem, listOperationProblems, retryOperationProblem } from '@/api/sense/operations'
jest.mock('@/utils/request', () => jest.fn())
describe('Sense operations API', () => {
beforeEach(() => request.mockReset())
test('encodes identifiers and sends only the expected version for controlled retry', () => {
listOperationProblems({ pageIndex: 1, pageSize: 10 })
getOperationProblem('media:camera/unsafe')
retryOperationProblem('media:camera/unsafe', 7)
expect(request).toHaveBeenNthCalledWith(1, { url: '/api/v1/operations', method: 'get', params: { pageIndex: 1, pageSize: 10 }})
expect(request).toHaveBeenNthCalledWith(2, { url: '/api/v1/operations/media%3Acamera%2Funsafe', method: 'get' })
expect(request).toHaveBeenNthCalledWith(3, { url: '/api/v1/operations/media%3Acamera%2Funsafe/retry', method: 'post', data: { expectedVersion: 7 }})
})
})
@@ -0,0 +1,23 @@
import { buildOperationsQuery, canRetry, objectLabel, problemLabel, severityType, stateLabel } from '@/views/sense/operations/operationsState'
describe('Sense operations presentation state', () => {
test('uses operator-facing state labels', () => {
expect(objectLabel('local_inference')).toBe('本地推理')
expect(problemLabel('orphan_safety_gate')).toBe('孤儿安全闸')
expect(stateLabel('unavailable')).toBe('不可用(未安装)')
expect(stateLabel('active / ready')).toBe('已启用 / 就绪')
expect(severityType('high')).toBe('danger')
})
test('allows retry only when the server projection says it is safe', () => {
expect(canRetry({ retryable: true, retryInProgress: false, version: 2 })).toBe(true)
expect(canRetry({ retryable: true, retryInProgress: true, version: 2 })).toBe(false)
expect(canRetry({ retryable: false, retryInProgress: false, version: 2 })).toBe(false)
})
test('builds an allowlisted query', () => {
expect(buildOperationsQuery({ pageIndex: 2, pageSize: 20, objectType: ' device ', problemType: 'clock_drift', severity: ' medium ', keyword: ' 南门 ', ignored: 'not-sent' })).toEqual({
pageIndex: 2, pageSize: 20, objectType: 'device', problemType: 'clock_drift', severity: 'medium', keyword: '南门'
})
})
})
@@ -0,0 +1,20 @@
import axios from 'axios'
import { downloadProvisioningBatch } from '@/api/sense/provisioning'
import { getToken } from '@/utils/auth'
jest.mock('axios', () => ({ get: jest.fn() }))
jest.mock('@/utils/auth', () => ({ getToken: jest.fn() }))
jest.mock('@/utils/request', () => jest.fn())
describe('Sense provisioning result export', () => {
test('downloads the audited server export as a blob', async() => {
const blob = new Blob(['status'], { type: 'text/csv' })
getToken.mockReturnValue('synthetic-token')
axios.get.mockResolvedValue({ data: blob })
await expect(downloadProvisioningBatch('batch-1')).resolves.toBe(blob)
expect(axios.get).toHaveBeenCalledWith(expect.stringContaining('/api/v1/provisioning/batches/batch-1/export'), {
responseType: 'blob',
headers: { Authorization: 'Bearer synthetic-token' }
})
})
})
@@ -0,0 +1,19 @@
import { clearCredentialMap, createBatchPayload, credentialPayload, normalizeImportRows } from '@/views/sense/provisioning/provisioningPayload'
describe('Sense provisioning payloads', () => {
test('normalizes Chinese template headers and excludes unknown columns', () => {
const rows = normalizeImportRows([{ 序号: 3, 设备名称: ' 东门摄像机 ', 安装位置: ' 东门 ', ONVIF地址: 'http://192.0.2.10/onvif', 密码: 'must-not-be-sent' }])
expect(createBatchPayload(rows, ' import-1 ')).toEqual({
idempotencyKey: 'import-1',
rows: [{ lineNumber: 3, name: '东门摄像机', location: '东门', address: 'http://192.0.2.10/onvif' }]
})
})
test('credential payload is allowlisted and can be cleared in place', () => {
const values = { item1: { onvifUsername: 'installer', onvifPassword: 'temporary-secret', rtspSameAsOnvif: true, rtspUsername: 'ignored', rtspPassword: 'ignored', unexpected: 'ignored' }}
expect(credentialPayload([{ id: 'item1' }], values)).toEqual({ credentials: [{ itemId: 'item1', onvifUsername: 'installer', onvifPassword: 'temporary-secret', rtspSameAsOnvif: true, rtspUsername: '', rtspPassword: '' }] })
clearCredentialMap(values)
expect(values.item1.onvifPassword).toBe('')
expect(values.item1.onvifUsername).toBe('')
})
})
@@ -0,0 +1,21 @@
import { buildQuotaQuery, quotaGate, usagePercent } from '@/views/sense/quota/quotaState'
describe('Sense quota state', () => {
test('keeps pagination independent from the configured quota', () => {
expect(buildQuotaQuery({ pageIndex: 2, pageSize: 64, keyword: ' 东门 ', status: 'active' })).toEqual({
pageIndex: 2, pageSize: 64, keyword: '东门', status: 'active'
})
})
test('closes writes when full or unreadable while preserving an actionable explanation', () => {
expect(quotaGate({ readStatus: 'readable', remaining: 4 }).writable).toBe(true)
expect(quotaGate({ readStatus: 'readable', remaining: 0 })).toMatchObject({ writable: false, title: '当前配额已满' })
expect(quotaGate({ readStatus: 'unreadable', remaining: 4 })).toMatchObject({ writable: false, title: '配额配置不可读取' })
})
test('bounds the occupancy percentage without treating quota as a list limit', () => {
expect(usagePercent({ used: 12, limit: 16 })).toBe(75)
expect(usagePercent({ used: 20, limit: 16 })).toBe(100)
expect(usagePercent({ used: 12, limit: 0 })).toBe(0)
})
})
+6 -6
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Project-Profile
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Project-Profile.-
wiki_revision: 08df06204a840a5eca0139ae711f11d9c100817e
synchronized_at: 2026-08-27T11:07:33Z
wiki_revision: 5894b3f4e3152420bd9addd63c1ce80205a6fd80
synchronized_at: 2026-08-27T15:22:01Z
<!-- gitea-wiki-mirror:end -->
# 项目档案
@@ -19,7 +19,7 @@ synchronized_at: 2026-08-27T11:07:33Z
| 首期客户场景 | 民办寄宿学校,默认 16 路高风险点位 |
| 首期规则 | 越线、危险区域、聚集等匿名安全规则;不启用人脸 |
| 产品形态 | Sense 与 Bell 两个独立销售产品,Brain 为独立推理交付单元 |
| 当前阶段 | Sense 独立纵切、Brain Python 骨架、Bell GoAdmin 产品骨架已合入 `dev` 并待用户验收;旧实现归档于 `explore`,`main` 仍为审核基线 |
| 当前阶段 | Sense 独立纵切、Brain Python 骨架、Bell GoAdmin 产品骨架已通过用户验收并合入 `dev`;旧实现归档于 `explore`,`main` 仍为审核基线 |
| 历史来源 | `D:\OPC\yovision_old`,只读追溯 |
## DevHarness 来源与基线
@@ -108,20 +108,20 @@ Sense、Bell 共用的可复现技术基线记录在仓库根 `goadmin-baseline.
<!-- sense-runtime:start -->
## Sense 重建状态
Sense 已从冻结 go-admin/go-admin-ui 源码独立派生,并完成设备、视频接入、MediaMTX、单路监看、区域配置与 Windows 交付的独立纵切。工单 #71 已从当前源码重新打包并通过隔离 PostgreSQL 17、Digest ONVIF/合成 RTSP、独立 MediaMTX、Chrome 外壳和冷启动回归;当前成果已合入 `dev`,仍等待用户验收。现场真机、16 路长稳和跨项目链路不在本轮结论内。
Sense 已从冻结 go-admin/go-admin-ui 源码独立派生,并完成设备、视频接入、MediaMTX、单路监看、区域配置与 Windows 交付的独立纵切。工单 #71 已从当前源码重新打包并通过隔离 PostgreSQL 17、Digest ONVIF/合成 RTSP、独立 MediaMTX、Chrome 外壳和冷启动回归;当前成果已合入 `dev`,并于 2026-08-27 通过用户验收。现场真机、16 路长稳和跨项目链路不在本轮结论内。
<!-- sense-runtime:end -->
<!-- bell-runtime:start -->
## Bell 重建状态
Bell 已从与 Sense 相同的冻结 go-admin/go-admin-ui 基线独立派生到 `Bell/server/` 与 `Bell/ui/`,保留来源和 MIT 许可证证据,以及独立 PostgreSQL、JWT、token key 和首次管理员边界。当前最小启用骨架已通过后端、前端和隔离 PostgreSQL smoke 并合入 `dev`,仍等待用户验收;事件、规则、Alert 等业务能力继续按独立工单迁移。
Bell 已从与 Sense 相同的冻结 go-admin/go-admin-ui 基线独立派生到 `Bell/server/` 与 `Bell/ui/`,保留来源和 MIT 许可证证据,以及独立 PostgreSQL、JWT、token key 和首次管理员边界。当前最小启用骨架已通过后端、前端和隔离 PostgreSQL smoke,并于 2026-08-27 通过用户验收、合入 `dev`;事件、规则、Alert 等业务能力继续按独立工单迁移。
<!-- bell-runtime:end -->
<!-- brain-runtime:start -->
## Brain 初始化状态
Brain 已建立 CPython 3.11.15 / PyTorch 2.12.1 的无界面包骨架,提供安装、版本、runtime-info 与 CPU/CUDA smoke 入口。CPU wheel、包测试和 CPU tensor smoke 已通过并合入 `dev`,仍等待用户验收;CUDA wheel、真实 GPU、视频、模型、规则、事件与部署尚未验证或实现。
Brain 已建立 CPython 3.11.15 / PyTorch 2.12.1 的无界面包骨架,提供安装、版本、runtime-info 与 CPU/CUDA smoke 入口。CPU wheel、包测试和 CPU tensor smoke 已通过,并于 2026-08-27 通过用户验收、合入 `dev`;CUDA wheel、真实 GPU、视频、模型、规则、事件与部署尚未验证或实现。
<!-- brain-runtime:end -->
## 分支治理
+43 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Architecture-and-Code-Map
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Architecture-and-Code-Map.-
wiki_revision: 3c95336ab96aa1e245f67c598299f2904f0e4437
synchronized_at: 2026-08-27T11:07:58Z
wiki_revision: 502d61f5c765c6c9b71432cdbbf0159c9b9c1071
synchronized_at: 2026-08-28T03:50:51Z
<!-- gitea-wiki-mirror:end -->
# 架构与代码地图
@@ -174,3 +174,44 @@ Bell 使用独立 PostgreSQL、JWT realm、token key 和首次迁移管理员环
- `dev_scripts/harness.py sync` 只从 Gitea Wiki 导出 `wiki-docs.json` 映射的核心镜像;`sync --check` 只检查一致性。
- `archive`、`export` 和 `export --all` 只在人工明确要求时处理可选任务快照,任务快照不进入核心映射。
- `dev_scripts/wiki_docs.py` 负责 Gitea Wiki 读取、revision、镜像头、脏文件保护和安全路径校验。
<!-- sense-provisioning:start -->
## Sense 批量开通代码入口
工单 #72 的领域代码位于 `Sense/server/app/sense/provisioning/`,GoAdmin JWT/Casbin/PermissionAction 路由注册在 `Sense/server/app/admin/router/sense_provisioning.go`,迁移位于 `Sense/server/cmd/migrate/migration/version/2026082809000_provisioning.go`。前端页面为 `Sense/ui/src/views/sense/provisioning/index.vue`,API 封装为 `Sense/ui/src/api/sense/provisioning.js`;页面复用 BasicLayout、动态菜单、Axios、Element Plus Steps/Upload/Form/Table/Pagination/Dialog/Tag/Alert/Button 和权限指令。
PostgreSQL 表 `sense_provisioning_batches` 保存幂等键、配额快照和汇总状态,`sense_provisioning_items` 保存行号、设备信息、逐项状态、失败原因、尝试次数和已建立的设备引用;两表都没有凭据字段。执行链按条目条件领取 ready/failed 状态,调用既有 Device、Credential Vault 与 Admission 服务,成功项不整体回滚。API 根路径为 `/api/v1/provisioning/batches`,覆盖创建/预校验、列表、详情、执行、失败项重试、单项重试和无秘密 CSV 导出。
<!-- sense-provisioning:end -->
<!-- sense-local-events:start -->
## Sense 本地事件只读链路
- 内部模型、DTO、服务、API 与合成夹具:`Sense/server/app/sense/local_event/`。
- GoAdmin 路由:`Sense/server/app/admin/router/sense_local_event.go`,仅开放 `GET /api/v1/local-events` 和 `GET /api/v1/local-events/:id`。
- PostgreSQL 表:`sense_local_event_candidates`;迁移同时建立“本地事件”菜单、详情功能权限和 implementation_operator/site_admin/viewer 的只读 Casbin 策略。
- go-admin-ui 页面:`Sense/ui/src/views/sense/local-event/index.vue`;复用 BasicLayout、Element Plus 表单、表格、分页、Dialog、Tag、权限指令与 Axios 封装。
- API 响应只包含 Sense 内部候选、匿名源引用、规则引用、证据状态和保留信息,不包含 Bell/Brain schema 或送达字段。页面上的 Bell 送达状态固定解释为“不适用”,避免把本地候选冒充外部预警。
- 列表和详情成功/失败读取会同步写入脱敏的 `sys_opera_log`,不依赖可关闭的全局数据库日志开关;记录只含动作、路由、操作者、结果和候选 ID,不保存筛选值、响应或证据内容。拒绝访问继续由认证/RBAC 身份审计记录。
<!-- sense-local-events:end -->
<!-- sense-operations:start -->
## Sense 运维中心代码路径
- 后端入口:`Sense/server/app/admin/router/sense_operations.go`。
- 领域投影:`Sense/server/app/sense/operations/`,只读聚合 `sense_devices`、`sense_admission_results` 和 `sense_media_routes`,不建立第二套运维状态表。
- API:`GET /api/v1/operations`、`GET /api/v1/operations/:id`、`POST /api/v1/operations/:id/retry`;均复用 GoAdmin JWT、Casbin、PermissionAction 和响应封装。
- 设备重试先用版本 CAS 取得在途所有权,再调用既有 admission Probe;失败时释放在途闸。媒体重试以 CAS 写入 `retry_pending` 和到期时间,由既有 MediaMTX 对账循环执行。
- 前端:`Sense/ui/src/views/sense/operations/` 与 `Sense/ui/src/api/sense/operations.js`,复用 BasicLayout、Element Plus 表单、表格、分页、Dialog、Tag 和权限按钮。
- 本模块不导入 Brain/Bell 模型,不访问其数据库,不定义共享契约。
<!-- sense-operations:end -->
<!-- sense-quota:start -->
## Sense 容量与配额代码路径
- 领域模型、DTO、服务、API 与测试:`Sense/server/app/sense/quota/`。
- GoAdmin 路由:`Sense/server/app/admin/router/sense_quota.go`;API 为 `GET /api/v1/quota` 和 `PUT /api/v1/quota`,复用 JWT、Casbin、PermissionAction 和 GoAdmin 响应封装。
- PostgreSQL 表:`sense_quota_settings` 保存单行配置与版本,`sense_quota_changes` 保存不可变变更记录。迁移 `2026082812000_quota.go` 初始化默认 16、动态菜单、最小权限及设备启用权限。
- 设备新增和重新启用由 `quota.WithAvailableSlot` 在事务内锁定配置行、计算非停用设备占用并执行写入;设备服务与批量开通服务共用该事实源。
- 前端:`Sense/ui/src/views/sense/quota/` 与 `Sense/ui/src/api/sense/quota.js`;复用 BasicLayout、Axios、Element Plus Alert/Row/Progress/Form/Table/Pagination/Dialog/Tag/Button 和权限指令。
- 本模块不导入 Brain/Bell 模型,不访问其数据库,也不定义跨项目配额契约。
<!-- sense-quota:end -->
+36 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Business-Rules-and-Glossary
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Business-Rules-and-Glossary.-
wiki_revision: 96fca4d2aa411725282bd3911c133efc75f7146a
synchronized_at: 2026-08-27T09:04:59Z
wiki_revision: f7a08ae4eaa7557f2b8a470d895b06567f77d0e1
synchronized_at: 2026-08-28T03:51:05Z
<!-- gitea-wiki-mirror:end -->
# 业务规则与术语
@@ -154,3 +154,37 @@ synchronized_at: 2026-08-27T09:04:59Z
- Gitea Wiki 只保存长期有效的项目事实;核心页面由 `wiki-docs.json` 显式映射。
- `docs/task/` 是按人工明确要求形成的专项或历史兼容快照,可能不完整或不是最新状态,不得替代工单。
- 默认不创建、导出或更新任务快照;导出过程不自动删除本地历史文件。
<!-- sense-local-events:start -->
## Sense 本地事件候选规则
- “本地事件候选”是 Sense 内部匿名记录,不是 Bell Event 或 Alert;任何跨项目输出必须等待版本化协调契约。
- 候选状态与证据状态是两组独立状态,不能用证据成功推断事件已确认,也不能用事件已确认推断证据成功。
- 列表和详情只读;新增、确认、删除、重试或送达不属于本页面。
- 每条记录保存明确的 `retain_until`,页面展示实际到期时间;保留时长由服务端生产者/策略决定,页面不虚构全局固定天数。
- 合成夹具只由测试显式装载,生产启动和迁移都不会自动写入假事件。
- GET 列表和详情继续经过 Sense JWT、Casbin RBAC、数据权限中间件及系统操作审计。
<!-- sense-local-events:end -->
<!-- sense-operations:start -->
## Sense 运维状态规则
- 运维问题是设备、媒体或本地推理适配器的本地状态,不是 Event 或 Bell Alert;不得进入 Bell 业务预警队列。
- 问题必须同时保留期望态、实际态、差异、建议动作、对象版本和安全闸;退避问题还要展示下次重试时间。
- 设备认证失败和时间漂移复用既有视频接入探测;媒体失败复用既有 MediaMTX 对账循环。
- 受控重试必须校验对象版本并防止同一对象并发执行;实施/运维和站点管理员可执行,viewer 只读。
- 孤儿媒体路由只能显示“隔离待确认”,不得从运维中心自动删除。
- Brain 未配置或未安装时显示本地推理 unavailable,不读取 Brain 数据库,也不阻断设备/媒体运维。
<!-- sense-operations:end -->
<!-- sense-quota:start -->
## Sense 容量与配额规则
- 配额是 Sense 单产品的交付配置,不是单机性能承诺;默认初始化为 16 路,数据库、循环、分页和列表容量不得以 16 为硬上限。
- 配额占用按非停用设备计算:已接入和待接入各占用一路,停用设备不占用;重新启用必须重新经过配额安全闸并回到待接入状态。
- 设备新增、重新启用及批量开通写入必须使用同一 PostgreSQL 配额事实源。写事务锁定单行配额配置、读取当前占用、检查剩余量后再写设备,防止并发超配。
- 配额缺失、非法或读取失败时,新增、启用和批量相关写入必须拒绝;已有设备、视频流和只读查询继续可用。
- 降低配额到当前占用以下不会自动停用设备或中断视频;剩余量按 0 显示,后续新增/启用持续拒绝,直到占用回到配额内。
- 32/64/128 只展示当前配置和目标硬件性能测试状态。未验证不得解释为支持或稳定承载承诺。
- 配额调整必须记录旧值、新值、操作人、时间和原因;viewer 与实施/运维角色只读,只有站点管理员可调整配额和重新启用设备。
<!-- sense-quota:end -->
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Local-Development-and-Verification
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Local-Development-and-Verification.-
wiki_revision: 09106b8f9352a0eb9ba7a8cc5f4a4143ae0ba3d2
synchronized_at: 2026-08-27T11:08:14Z
wiki_revision: e1755deff68c95188e6bf2f759c7ec93293c8715
synchronized_at: 2026-08-27T15:22:48Z
<!-- gitea-wiki-mirror:end -->
# 本地开发与验证
@@ -63,9 +63,9 @@ Sense/Bell 的 Go、Node 与 pnpm 基线已冻结并记录于下文;Brain 的
| 项目 | 构建 | 测试 | 运行 | 当前状态 |
|---|---|---|---|---|
| Sense | `cd Sense/server; go build ./...`、`cd Sense/ui; corepack pnpm@9.15.1 build:prod` | `go test ./...`、`go vet ./...`、前端 lint/单测、独立 E2E | `sense server -c <仓库外配置>` | GoAdmin 产品骨架与独立纵切已建立,待用户验收 |
| Brain | `Brain\.venv\Scripts\python.exe -m pip install -e "Brain[dev]"` | `python -m pytest Brain/tests/test_package.py -q`、CPU/CUDA smoke | `python -m yovision_brain --runtime-info` | Python/PyTorch 骨架已建立;CPU 已验证,CUDA 未验证 |
| Bell | `cd Bell/server; go build ./...`、`cd Bell/ui; corepack pnpm@9.15.1 build:prod` | `go test ./...`、`go vet ./...`、前端 lint/单测 | `go run . server -c config/settings.yml` | 冻结 GoAdmin 产品骨架已建立,待用户验收 |
| Sense | `cd Sense/server; go build ./...`、`cd Sense/ui; corepack pnpm@9.15.1 build:prod` | `go test ./...`、`go vet ./...`、前端 lint/单测、独立 E2E | `sense server -c <仓库外配置>` | GoAdmin 产品骨架与独立纵切已于 2026-08-27 通过验收 |
| Brain | `Brain\.venv\Scripts\python.exe -m pip install -e "Brain[dev]"` | `python -m pytest Brain/tests/test_package.py -q`、CPU/CUDA smoke | `python -m yovision_brain --runtime-info` | Python/PyTorch 骨架已于 2026-08-27 通过验收;CPU 已验证,CUDA 未验证 |
| Bell | `cd Bell/server; go build ./...`、`cd Bell/ui; corepack pnpm@9.15.1 build:prod` | `go test ./...`、`go vet ./...`、前端 lint/单测 | `go run . server -c config/settings.yml` | 冻结 GoAdmin 产品骨架已于 2026-08-27 通过验收 |
不得复制旧仓库命令来填空。每个骨架工单必须同时建立 README、可复制命令和最小测试。
+36 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Product-Requirements
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Product-Requirements.-
wiki_revision: e912a1ca1410e01680a0f11f6199ccb42cd8fe8f
synchronized_at: 2026-08-27T09:06:08Z
wiki_revision: e298b5a31fcc39e3e2d9b4df543a4bc50d94bcaa
synchronized_at: 2026-08-28T03:53:13Z
<!-- gitea-wiki-mirror:end -->
# 产品需求
@@ -190,3 +190,37 @@ YoVision 首个可交付目标是在民办寄宿学校以默认 16 路高风险
- Sense 账号不能登录 Bell,Bell 账号不能登录 Sense。
- 三个项目独立构建、测试和版本化;Sense/Bell 独立迁移、备份、恢复和打包。
- 集成断开不阻断各自核心能力,恢复后按 Outbox 和幂等收据继续。
<!-- sense-provisioning:start -->
## Sense 批量开通长期规则
SEN-004 已在工单 #72 实现、合入 `dev` 并通过用户验收。Sense 可独立导入 CSV/XLSX 摄像头清单,服务端预校验地址、重复项和剩余配额,再按条目执行接入。批次保留创建时的配额与占用快照,但新的批次与设备写入必须读取 SEN-011 的统一配额事实源;16 不是数据库、循环、分页或单机容量硬上限。
导入清单禁止账号和密码字段。ONVIF/RTSP 凭据只在受控表单与单次执行请求中短暂存在,随后进入既有加密 Vault;批次、条目、响应、日志和结果导出都不得包含凭据。批次允许部分成功,成功设备不因其他条目失败而回滚;批量重试与单项重试只领取失败条目,并通过批次幂等键、持久化设备引用和条件状态更新避免重复创建设备。
<!-- sense-provisioning:end -->
<!-- sense-local-events:start -->
## Sense 本地事件候选
SEN-008 在 Sense 内提供只读的本地事件候选查询。网管或非技术人员可按发生时间、候选状态、证据状态、规则引用和关键词筛选,查看候选详情、证据处理结果与逐条保留到期时间。
候选状态只表示 Sense 内部的 `candidate`(候选)或 `confirmed`(已确认事件);证据状态独立表示 `pending`(处理中)、`success`(成功)或 `failed`(失败)。本能力在 Brain、Bell 均未启动时仍可使用。本地候选不是跨项目事件契约,不等同于 Bell Alert,也不表示已经向 Bell 送达。
<!-- sense-local-events:end -->
<!-- sense-operations:start -->
## Sense 运维中心
SEN-009 由 Sense 独立提供设备、媒体和可选本地推理的运维概览。页面以“期望态—实际态—未收敛差异—下一步动作”展示认证失败、退避等待、时间漂移、孤儿安全闸和能力未安装,面向网管或非技术运维人员,不暴露凭据或内部数据库结构。
运维中心在 Brain、Bell 均未启动时可用。本地推理适配器尚未建立协调契约时显示 unavailable;该状态不导致页面失败。所有问题均为 Sense 运维事实,不等同于 Bell 业务 Alert,也不会从本页面发送给 Bell。
读取默认只读。受控重试仅对明确可重试的设备/媒体问题开放,必须通过 RBAC、对象版本和在途状态校验并写入脱敏审计。孤儿媒体资源只隔离和提示,不提供自动删除。
<!-- sense-operations:end -->
<!-- sense-quota:start -->
## Sense 容量与配额长期规则
SEN-011 将默认 16 路实现为可配置的 Sense 本地交付配额。统一配置保存在 PostgreSQL;新增设备、重新启用和批量开通在写事务内原子校验。配额不可读时拒绝这些写入,但已有设备、视频流和读取保持可用。降低配额不会自动停用现有设备。
容量页面只展示 16/32/64/128 的当前配置和性能测试状态。除已经验证的 16 路学校试点基线外,其余档位在完成目标硬件压测前都不得解释为单机承载承诺。
<!-- sense-quota:end -->
+14 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Deployment-and-Operations
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Deployment-and-Operations.-
wiki_revision: 367c6aed6eaeece793622431c7d1b11b1b9dbec5
synchronized_at: 2026-08-27T09:07:20Z
wiki_revision: 95965345e43d7fc403486c8afaecf36d79b757d0
synchronized_at: 2026-08-28T03:53:21Z
<!-- gitea-wiki-mirror:end -->
# YoVision 部署与运维
@@ -80,3 +80,15 @@ Sense\start_sense.bat
- MediaMTX 启用时进程、路径和播放链路状态可定位。
- Supervisor 不会与手工进程重复占用端口。
- 日志和文档没有秘密;未验证的 Brain、Bell、真机或生产行为明确标注。
<!-- sense-provisioning:start -->
## Sense 批量开通与容量配额配置排错
数据库迁移 `2026082812000_quota.go` 首次创建统一配额配置:读取当次迁移进程的可选 `SENSE_PROVISIONING_QUOTA` 正整数作为初值,未设置或无效时使用 16。迁移完成后,运行期配额以 PostgreSQL `sense_quota_settings` 为事实源,并由“容量与配额”页面受控调整;后续修改环境变量不会覆盖数据库值。每个批量开通批次仍记录创建时的配额与占用快照。
升级后看不到“容量与配额”时,确认迁移成功并重新登录刷新动态菜单。implementation_operator、site_admin、viewer 可读取容量;只有 site_admin 可调整配额和重新启用设备。调整必须填写原因。降低配额不会停止已有流;当占用达到或超过配额时,新增和启用返回冲突。
页面显示“配额配置不可读取”时,先确认 `sense_quota_settings` 的 ID 1 记录存在、limit 为 1–100000 的整数且数据库可读;不要通过手工插入设备绕过安全闸。配置不可读时已有流和查询应继续,新设备、重新启用和批量开通写入会返回服务不可用。32/64/128 的“未验证”状态不能作为容量承诺。
批量导入被拒绝时还应检查模板只含 line_number/name/location/address,地址必须为不带账号、查询参数或片段的 HTTP(S) ONVIF 地址。条目失败时按页面原因检查网络、获准网段、凭据和接入状态;仅重试失败项,不删除已成功设备。日志、导出和问题记录不得粘贴摄像头凭据。
<!-- sense-provisioning:end -->