diff --git a/Sense/server/app/admin/router/sense_device.go b/Sense/server/app/admin/router/sense_device.go index d8987b6..541deeb 100644 --- a/Sense/server/app/admin/router/sense_device.go +++ b/Sense/server/app/admin/router/sense_device.go @@ -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) } } diff --git a/Sense/server/app/admin/router/sense_quota.go b/Sense/server/app/admin/router/sense_quota.go new file mode 100644 index 0000000..5111798 --- /dev/null +++ b/Sense/server/app/admin/router/sense_quota.go @@ -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 := "a.API{} + r := v1.Group("/quota").Use(auth.MiddlewareFunc()).Use(middleware.AuthCheckRole()).Use(actions.PermissionAction()) + r.GET("", api.Get) + r.PUT("", api.Update) +} diff --git a/Sense/server/app/sense/device/apis/device.go b/Sense/server/app/sense/device/apis/device.go index da1d995..693f5c2 100644 --- a/Sense/server/app/sense/device/apis/device.go +++ b/Sense/server/app/sense/device/apis/device.go @@ -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: diff --git a/Sense/server/app/sense/device/service/device.go b/Sense/server/app/sense/device/service/device.go index 579e2fd..044dc3b 100644 --- a/Sense/server/app/sense/device/service/device.go +++ b/Sense/server/app/sense/device/service/device.go @@ -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 diff --git a/Sense/server/app/sense/device/service/device_test.go b/Sense/server/app/sense/device/service/device_test.go index 3e6d44a..e2fc6f3 100644 --- a/Sense/server/app/sense/device/service/device_test.go +++ b/Sense/server/app/sense/device/service/device_test.go @@ -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{}, "a.Setting{}, "a.Change{}); err != nil { + t.Fatal(err) + } + if err = db.Create("a.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("a.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("a.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 diff --git a/Sense/server/app/sense/device/service/dto/device.go b/Sense/server/app/sense/device/service/dto/device.go index 4b51090..7876846 100644 --- a/Sense/server/app/sense/device/service/dto/device.go +++ b/Sense/server/app/sense/device/service/dto/device.go @@ -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"` diff --git a/Sense/server/app/sense/provisioning/apis.go b/Sense/server/app/sense/provisioning/apis.go index 5c6589a..b62e222 100644 --- a/Sense/server/app/sense/provisioning/apis.go +++ b/Sense/server/app/sense/provisioning/apis.go @@ -12,6 +12,8 @@ import ( "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 } @@ -137,6 +139,10 @@ func (e *API) writeError(err error) { 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, "批量开通操作失败") } diff --git a/Sense/server/app/sense/provisioning/service.go b/Sense/server/app/sense/provisioning/service.go index d957ecf..f08faae 100644 --- a/Sense/server/app/sense/provisioning/service.go +++ b/Sense/server/app/sense/provisioning/service.go @@ -5,9 +5,7 @@ import ( "errors" "fmt" "net/url" - "os" "sort" - "strconv" "strings" "time" @@ -20,6 +18,7 @@ import ( "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 ( @@ -36,23 +35,11 @@ type Service struct { Activator Activator } -func QuotaFromEnvironment() int { - value := strings.TrimSpace(os.Getenv("SENSE_PROVISIONING_QUOTA")) - if value == "" { - return 16 - } - parsed, err := strconv.Atoi(value) - if err != nil || parsed < 1 || parsed > 100000 { - return 16 - } - return parsed -} - -func (s *Service) quota() int { +func (s *Service) quotaLimit() (int, error) { if s.Quota > 0 { - return s.Quota + return s.Quota, nil } - return QuotaFromEnvironment() + return quota.ReadLimit(s.Orm) } func (s *Service) CreateBatch(request CreateBatchRequest) (BatchResponse, error) { @@ -71,12 +58,15 @@ func (s *Service) CreateBatch(request CreateBatchRequest) (BatchResponse, error) if err := s.Orm.Table("sense_devices").Where("status <> ?", "disabled").Count(&existingDevices).Error; err != nil { return BatchResponse{}, fmt.Errorf("count provisioned devices: %w", err) } - quota := s.quota() - available := quota - int(existingDevices) + 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: quota, ExistingCount: int(existingDevices), TotalCount: len(request.Rows)} + 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{} @@ -351,6 +341,10 @@ func safeFailure(err error) (string, string) { 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", "设备开通失败,请检查网络、地址和凭据后重试" } diff --git a/Sense/server/app/sense/provisioning/service_test.go b/Sense/server/app/sense/provisioning/service_test.go index ed78bae..8a4c093 100644 --- a/Sense/server/app/sense/provisioning/service_test.go +++ b/Sense/server/app/sense/provisioning/service_test.go @@ -153,17 +153,6 @@ func TestExecuteRequiresCredentialsWithoutCallingActivator(t *testing.T) { } } -func TestQuotaFromEnvironment(t *testing.T) { - t.Setenv("SENSE_PROVISIONING_QUOTA", "24") - if got := QuotaFromEnvironment(); got != 24 { - t.Fatalf("quota=%d", got) - } - t.Setenv("SENSE_PROVISIONING_QUOTA", "invalid") - if got := QuotaFromEnvironment(); got != 16 { - t.Fatalf("fallback quota=%d", got) - } -} - func TestConcurrentCreateUsesOneIdempotentBatch(t *testing.T) { service := provisioningService(t, 16) sqlDB, err := service.Orm.DB() diff --git a/Sense/server/app/sense/quota/apis.go b/Sense/server/app/sense/quota/apis.go new file mode 100644 index 0000000..25ca830 --- /dev/null +++ b/Sense/server/app/sense/quota/apis.go @@ -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, "容量与配额操作失败") + } +} diff --git a/Sense/server/app/sense/quota/dto.go b/Sense/server/app/sense/quota/dto.go new file mode 100644 index 0000000..cd21d8d --- /dev/null +++ b/Sense/server/app/sense/quota/dto.go @@ -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"` +} diff --git a/Sense/server/app/sense/quota/models.go b/Sense/server/app/sense/quota/models.go new file mode 100644 index 0000000..bab7a2a --- /dev/null +++ b/Sense/server/app/sense/quota/models.go @@ -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" } diff --git a/Sense/server/app/sense/quota/service.go b/Sense/server/app/sense/quota/service.go new file mode 100644 index 0000000..1a96a54 --- /dev/null +++ b/Sense/server/app/sense/quota/service.go @@ -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 +} diff --git a/Sense/server/app/sense/quota/service_test.go b/Sense/server/app/sense/quota/service_test.go new file mode 100644 index 0000000..74aeb34 --- /dev/null +++ b/Sense/server/app/sense/quota/service_test.go @@ -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) + } +} diff --git a/Sense/server/cmd/migrate/migration/version/2026082812000_quota.go b/Sense/server/cmd/migrate/migration/version/2026082812000_quota.go new file mode 100644 index 0000000..4e4987e --- /dev/null +++ b/Sense/server/cmd/migrate/migration/version/2026082812000_quota.go @@ -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("a.Setting{}, "a.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 +} diff --git a/Sense/server/cmd/migrate/migration/version/2026082812000_quota_test.go b/Sense/server/cmd/migrate/migration/version/2026082812000_quota_test.go new file mode 100644 index 0000000..607cbfa --- /dev/null +++ b/Sense/server/cmd/migrate/migration/version/2026082812000_quota_test.go @@ -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) +} diff --git a/Sense/ui/src/api/sense/device.js b/Sense/ui/src/api/sense/device.js index 51e1826..ff0351c 100644 --- a/Sense/ui/src/api/sense/device.js +++ b/Sense/ui/src/api/sense/device.js @@ -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 }) } diff --git a/Sense/ui/src/api/sense/quota.js b/Sense/ui/src/api/sense/quota.js new file mode 100644 index 0000000..b9caa85 --- /dev/null +++ b/Sense/ui/src/api/sense/quota.js @@ -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 }) +} diff --git a/Sense/ui/src/views/sense/device/index.vue b/Sense/ui/src/views/sense/device/index.vue index 125597f..5169c39 100644 --- a/Sense/ui/src/views/sense/device/index.vue +++ b/Sense/ui/src/views/sense/device/index.vue @@ -69,6 +69,7 @@ 更新凭据 停用 + 启用 @@ -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(() => {}) } } } diff --git a/Sense/ui/src/views/sense/quota/index.vue b/Sense/ui/src/views/sense/quota/index.vue new file mode 100644 index 0000000..a3740bd --- /dev/null +++ b/Sense/ui/src/views/sense/quota/index.vue @@ -0,0 +1,177 @@ + + + + + + + 容量与配额 + 查看当前占用与安全闸状态。配额是交付配置,不等于单机性能承诺。 + + + 新增设备 + 调整配额 + + + + + {{ gate.detail }} + + + + 当前配置配额{{ readableLimit }}来源:{{ sourceLabel }} + 已占用设备{{ summary.used }} 路已接入和待接入均占用 + 剩余可用{{ readableRemaining }}写入时再次原子校验 + 配额读取状态{{ summary.readStatus === 'readable' ? '可读取' : '读取失败' }}最后读取:{{ formatTime(summary.lastReadAt) }} + + + + 占用情况{{ usageCaption }} + + + + 设备占用明细停用设备不占用配额;重新启用时会执行安全闸校验。 + + + + + + + + 查询重置 + + + + + {{ deviceStatusLabel(scope.row.status) }} + {{ scope.row.occupied ? '占用 1 路' : '不占用' }} + {{ formatTime(scope.row.updatedAt) }} + + + 启用 + 查看 + + + + + + 交付档位与验证状态“未验证”不代表系统承诺可稳定承载,必须先完成目标硬件性能测试。 + + {{ scope.row.limit }} 路 + 当前配置未配置 + {{ tierValidationLabel(scope.row.validation) }} + + + + + + + 降低到当前占用以下不会中断已有视频,但会阻止新增与启用,直到占用回到配额内。 + + + + + + 取消确认调整 + + + + + + + + diff --git a/Sense/ui/src/views/sense/quota/quotaState.js b/Sense/ui/src/views/sense/quota/quotaState.js new file mode 100644 index 0000000..51475f6 --- /dev/null +++ b/Sense/ui/src/views/sense/quota/quotaState.js @@ -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' ? '已验证' : '未验证' +} diff --git a/Sense/ui/tests/unit/sense/quotaState.spec.js b/Sense/ui/tests/unit/sense/quotaState.spec.js new file mode 100644 index 0000000..04d118f --- /dev/null +++ b/Sense/ui/tests/unit/sense/quotaState.spec.js @@ -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) + }) +}) diff --git a/docs/02-architecture-and-code-map.md b/docs/02-architecture-and-code-map.md index 9568bcc..66120f8 100644 --- a/docs/02-architecture-and-code-map.md +++ b/docs/02-architecture-and-code-map.md @@ -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 # 架构与代码地图 @@ -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 批量开通代码入口 + +工单 #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 本地事件只读链路 + +- 内部模型、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 运维中心代码路径 + +- 后端入口:`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 容量与配额代码路径 + +- 领域模型、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 模型,不访问其数据库,也不定义跨项目配额契约。 + diff --git a/docs/03-business-rules-and-glossary.md b/docs/03-business-rules-and-glossary.md index 265a734..8e40edf 100644 --- a/docs/03-business-rules-and-glossary.md +++ b/docs/03-business-rules-and-glossary.md @@ -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 # 业务规则与术语 @@ -154,3 +154,37 @@ synchronized_at: 2026-08-27T09:04:59Z - Gitea Wiki 只保存长期有效的项目事实;核心页面由 `wiki-docs.json` 显式映射。 - `docs/task/` 是按人工明确要求形成的专项或历史兼容快照,可能不完整或不是最新状态,不得替代工单。 - 默认不创建、导出或更新任务快照;导出过程不自动删除本地历史文件。 + + +## Sense 本地事件候选规则 + +- “本地事件候选”是 Sense 内部匿名记录,不是 Bell Event 或 Alert;任何跨项目输出必须等待版本化协调契约。 +- 候选状态与证据状态是两组独立状态,不能用证据成功推断事件已确认,也不能用事件已确认推断证据成功。 +- 列表和详情只读;新增、确认、删除、重试或送达不属于本页面。 +- 每条记录保存明确的 `retain_until`,页面展示实际到期时间;保留时长由服务端生产者/策略决定,页面不虚构全局固定天数。 +- 合成夹具只由测试显式装载,生产启动和迁移都不会自动写入假事件。 +- GET 列表和详情继续经过 Sense JWT、Casbin RBAC、数据权限中间件及系统操作审计。 + + + +## Sense 运维状态规则 + +- 运维问题是设备、媒体或本地推理适配器的本地状态,不是 Event 或 Bell Alert;不得进入 Bell 业务预警队列。 +- 问题必须同时保留期望态、实际态、差异、建议动作、对象版本和安全闸;退避问题还要展示下次重试时间。 +- 设备认证失败和时间漂移复用既有视频接入探测;媒体失败复用既有 MediaMTX 对账循环。 +- 受控重试必须校验对象版本并防止同一对象并发执行;实施/运维和站点管理员可执行,viewer 只读。 +- 孤儿媒体路由只能显示“隔离待确认”,不得从运维中心自动删除。 +- Brain 未配置或未安装时显示本地推理 unavailable,不读取 Brain 数据库,也不阻断设备/媒体运维。 + + + +## Sense 容量与配额规则 + +- 配额是 Sense 单产品的交付配置,不是单机性能承诺;默认初始化为 16 路,数据库、循环、分页和列表容量不得以 16 为硬上限。 +- 配额占用按非停用设备计算:已接入和待接入各占用一路,停用设备不占用;重新启用必须重新经过配额安全闸并回到待接入状态。 +- 设备新增、重新启用及批量开通写入必须使用同一 PostgreSQL 配额事实源。写事务锁定单行配额配置、读取当前占用、检查剩余量后再写设备,防止并发超配。 +- 配额缺失、非法或读取失败时,新增、启用和批量相关写入必须拒绝;已有设备、视频流和只读查询继续可用。 +- 降低配额到当前占用以下不会自动停用设备或中断视频;剩余量按 0 显示,后续新增/启用持续拒绝,直到占用回到配额内。 +- 32/64/128 只展示当前配置和目标硬件性能测试状态。未验证不得解释为支持或稳定承载承诺。 +- 配额调整必须记录旧值、新值、操作人、时间和原因;viewer 与实施/运维角色只读,只有站点管理员可调整配额和重新启用设备。 + diff --git a/docs/09-product-requirements.md b/docs/09-product-requirements.md index ea7df60..0018a8b 100644 --- a/docs/09-product-requirements.md +++ b/docs/09-product-requirements.md @@ -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 # 产品需求 @@ -190,3 +190,37 @@ YoVision 首个可交付目标是在民办寄宿学校以默认 16 路高风险 - Sense 账号不能登录 Bell,Bell 账号不能登录 Sense。 - 三个项目独立构建、测试和版本化;Sense/Bell 独立迁移、备份、恢复和打包。 - 集成断开不阻断各自核心能力,恢复后按 Outbox 和幂等收据继续。 + + +## Sense 批量开通长期规则 + +SEN-004 已在工单 #72 实现、合入 `dev` 并通过用户验收。Sense 可独立导入 CSV/XLSX 摄像头清单,服务端预校验地址、重复项和剩余配额,再按条目执行接入。批次保留创建时的配额与占用快照,但新的批次与设备写入必须读取 SEN-011 的统一配额事实源;16 不是数据库、循环、分页或单机容量硬上限。 + +导入清单禁止账号和密码字段。ONVIF/RTSP 凭据只在受控表单与单次执行请求中短暂存在,随后进入既有加密 Vault;批次、条目、响应、日志和结果导出都不得包含凭据。批次允许部分成功,成功设备不因其他条目失败而回滚;批量重试与单项重试只领取失败条目,并通过批次幂等键、持久化设备引用和条件状态更新避免重复创建设备。 + + + +## Sense 本地事件候选 + +SEN-008 在 Sense 内提供只读的本地事件候选查询。网管或非技术人员可按发生时间、候选状态、证据状态、规则引用和关键词筛选,查看候选详情、证据处理结果与逐条保留到期时间。 + +候选状态只表示 Sense 内部的 `candidate`(候选)或 `confirmed`(已确认事件);证据状态独立表示 `pending`(处理中)、`success`(成功)或 `failed`(失败)。本能力在 Brain、Bell 均未启动时仍可使用。本地候选不是跨项目事件契约,不等同于 Bell Alert,也不表示已经向 Bell 送达。 + + + +## Sense 运维中心 + +SEN-009 由 Sense 独立提供设备、媒体和可选本地推理的运维概览。页面以“期望态—实际态—未收敛差异—下一步动作”展示认证失败、退避等待、时间漂移、孤儿安全闸和能力未安装,面向网管或非技术运维人员,不暴露凭据或内部数据库结构。 + +运维中心在 Brain、Bell 均未启动时可用。本地推理适配器尚未建立协调契约时显示 unavailable;该状态不导致页面失败。所有问题均为 Sense 运维事实,不等同于 Bell 业务 Alert,也不会从本页面发送给 Bell。 + +读取默认只读。受控重试仅对明确可重试的设备/媒体问题开放,必须通过 RBAC、对象版本和在途状态校验并写入脱敏审计。孤儿媒体资源只隔离和提示,不提供自动删除。 + + + +## Sense 容量与配额长期规则 + +SEN-011 将默认 16 路实现为可配置的 Sense 本地交付配额。统一配置保存在 PostgreSQL;新增设备、重新启用和批量开通在写事务内原子校验。配额不可读时拒绝这些写入,但已有设备、视频流和读取保持可用。降低配额不会自动停用现有设备。 + +容量页面只展示 16/32/64/128 的当前配置和性能测试状态。除已经验证的 16 路学校试点基线外,其余档位在完成目标硬件压测前都不得解释为单机承载承诺。 + diff --git a/docs/delivery/deployment-and-operations.md b/docs/delivery/deployment-and-operations.md index ccfcaa5..84004cf 100644 --- a/docs/delivery/deployment-and-operations.md +++ b/docs/delivery/deployment-and-operations.md @@ -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 # YoVision 部署与运维 @@ -80,3 +80,15 @@ Sense\start_sense.bat - MediaMTX 启用时进程、路径和播放链路状态可定位。 - Supervisor 不会与手工进程重复占用端口。 - 日志和文档没有秘密;未验证的 Brain、Bell、真机或生产行为明确标注。 + + +## 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 地址。条目失败时按页面原因检查网络、获准网段、凭据和接入状态;仅重试失败项,不删除已成功设备。日志、导出和问题记录不得粘贴摄像头凭据。 +
查看当前占用与安全闸状态。配额是交付配置,不等于单机性能承诺。
停用设备不占用配额;重新启用时会执行安全闸校验。
“未验证”不代表系统承诺可稳定承载,必须先完成目标硬件性能测试。