Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea76db579c | ||
|
|
4a605f6482 | ||
|
|
d61e6d5ee1 | ||
|
|
7274bd42f5 | ||
|
|
02a5af5e3b | ||
|
|
e06904272a | ||
|
|
d681fd1345 | ||
|
|
bdd78523b5 | ||
|
|
2db49fc955 | ||
|
|
a9fd8e8a47 | ||
|
|
12f5419f21 | ||
|
|
debf662138 |
@@ -0,0 +1,9 @@
|
||||
[
|
||||
{
|
||||
"id": "secondary",
|
||||
"name": "备用媒体分片",
|
||||
"mode": "external",
|
||||
"controlAPI": "http://127.0.0.1:19997",
|
||||
"capacity": 24
|
||||
}
|
||||
]
|
||||
@@ -13,6 +13,8 @@ SENSE_MEDIAMTX_MODE=disabled
|
||||
SENSE_MEDIAMTX_BINARY=
|
||||
SENSE_MEDIAMTX_CONFIG=
|
||||
SENSE_MEDIAMTX_API=http://127.0.0.1:9997
|
||||
SENSE_MEDIAMTX_CAPACITY=
|
||||
SENSE_MEDIAMTX_SHARDS_FILE=
|
||||
SENSE_WEB_ROOT=web
|
||||
SENSE_AUTO_MIGRATE=true
|
||||
SENSE_POSTGRES_BIN=
|
||||
|
||||
@@ -13,6 +13,10 @@ SENSE_MEDIAMTX_MODE=managed
|
||||
SENSE_MEDIAMTX_BINARY=bin\mediamtx.exe
|
||||
SENSE_MEDIAMTX_CONFIG=config\mediamtx.yml
|
||||
SENSE_MEDIAMTX_API=http://127.0.0.1:9997
|
||||
# Leave capacity empty to use the current database quota. Optional extra
|
||||
# shards are read from a repository-external JSON file and must use loopback APIs.
|
||||
SENSE_MEDIAMTX_CAPACITY=
|
||||
SENSE_MEDIAMTX_SHARDS_FILE=
|
||||
SENSE_WEB_ROOT=web
|
||||
SENSE_AUTO_MIGRATE=true
|
||||
SENSE_POSTGRES_BIN=
|
||||
|
||||
@@ -81,6 +81,7 @@ try {
|
||||
Copy-Item -LiteralPath (Join-Path $senseRoot 'config\sense.demo.env.example') -Destination (Join-Path $staging 'config\sense.demo.env.example')
|
||||
Copy-Item -LiteralPath (Join-Path $senseRoot 'config\sense.demo.env.example') -Destination (Join-Path $staging 'config\sense.demo.env')
|
||||
Copy-Item -LiteralPath (Join-Path $senseRoot 'config\mediamtx.yml') -Destination (Join-Path $staging 'config\mediamtx.yml')
|
||||
Copy-Item -LiteralPath (Join-Path $senseRoot 'config\mediamtx-shards.example.json') -Destination (Join-Path $staging 'config\mediamtx-shards.example.json')
|
||||
Copy-Item -LiteralPath (Join-Path $serverRoot 'config\db.sql') -Destination (Join-Path $staging 'config\db.sql')
|
||||
Copy-Item -LiteralPath (Join-Path $serverRoot 'config\pg.sql') -Destination (Join-Path $staging 'config\pg.sql')
|
||||
Copy-Item -LiteralPath (Join-Path $senseRoot 'README-WINDOWS.md') -Destination (Join-Path $staging 'README-WINDOWS.md')
|
||||
|
||||
@@ -9,7 +9,7 @@ $required = @(
|
||||
'migrate-sense.bat', 'backup-sense.bat', 'restore-sense.bat',
|
||||
'initialize-admin.bat', 'README-WINDOWS.md', 'config\sense.env',
|
||||
'config\sense.env.example', 'config\sense.demo.env',
|
||||
'config\mediamtx.yml', 'config\db.sql', 'config\pg.sql',
|
||||
'config\mediamtx.yml', 'config\mediamtx-shards.example.json', 'config\db.sql', 'config\pg.sql',
|
||||
'web\index.html', 'scripts\runtime\sense-common.ps1'
|
||||
)
|
||||
foreach ($relative in $required) {
|
||||
|
||||
@@ -7,7 +7,8 @@ $script:SenseAllowedEnvironment = @(
|
||||
'SENSE_CREDENTIAL_KEY', 'SENSE_ONVIF_DISCOVERY_IP',
|
||||
'SENSE_ONVIF_ALLOWED_CIDRS', 'SENSE_MEDIAMTX_MODE',
|
||||
'SENSE_MEDIAMTX_BINARY', 'SENSE_MEDIAMTX_CONFIG',
|
||||
'SENSE_MEDIAMTX_API', 'SENSE_WEB_ROOT', 'SENSE_AUTO_MIGRATE',
|
||||
'SENSE_MEDIAMTX_API', 'SENSE_MEDIAMTX_CAPACITY',
|
||||
'SENSE_MEDIAMTX_SHARDS_FILE', 'SENSE_WEB_ROOT', 'SENSE_AUTO_MIGRATE',
|
||||
'SENSE_POSTGRES_BIN'
|
||||
)
|
||||
|
||||
@@ -185,6 +186,7 @@ function Initialize-SenseRuntime {
|
||||
}
|
||||
$mediaBinary = Resolve-SenseConfiguredPath -PackageRoot $PackageRoot -Value (Get-SenseEnvironmentValue -Name 'SENSE_MEDIAMTX_BINARY')
|
||||
$mediaConfig = Resolve-SenseConfiguredPath -PackageRoot $PackageRoot -Value (Get-SenseEnvironmentValue -Name 'SENSE_MEDIAMTX_CONFIG')
|
||||
$mediaShardsFile = Resolve-SenseConfiguredPath -PackageRoot $PackageRoot -Value (Get-SenseEnvironmentValue -Name 'SENSE_MEDIAMTX_SHARDS_FILE')
|
||||
if ($mediaMode -eq 'managed') {
|
||||
if (-not (Test-Path -LiteralPath $mediaBinary -PathType Leaf)) { throw 'Managed MediaMTX binary not found. Set SENSE_MEDIAMTX_BINARY to mediamtx.exe.' }
|
||||
if (-not (Test-Path -LiteralPath $mediaConfig -PathType Leaf)) { throw 'Managed MediaMTX configuration not found. Set SENSE_MEDIAMTX_CONFIG.' }
|
||||
@@ -192,6 +194,9 @@ function Initialize-SenseRuntime {
|
||||
if ($mediaMode -eq 'external' -and -not (Test-SenseTcpEndpoint -HostName $apiUri.Host -Port $apiUri.Port)) {
|
||||
throw "External MediaMTX Control API is unreachable at $($apiUri.Host):$($apiUri.Port)."
|
||||
}
|
||||
if (-not [string]::IsNullOrWhiteSpace($mediaShardsFile) -and -not (Test-Path -LiteralPath $mediaShardsFile -PathType Leaf)) {
|
||||
throw 'SENSE_MEDIAMTX_SHARDS_FILE does not exist.'
|
||||
}
|
||||
|
||||
$webRoot = Resolve-SenseConfiguredPath -PackageRoot $PackageRoot -Value (Get-SenseEnvironmentValue -Name 'SENSE_WEB_ROOT' -Default 'web')
|
||||
if (-not (Test-Path -LiteralPath (Join-Path $webRoot 'index.html') -PathType Leaf)) { throw 'Sense web assets are missing. Rebuild or replace the delivery package.' }
|
||||
@@ -205,6 +210,7 @@ function Initialize-SenseRuntime {
|
||||
[Environment]::SetEnvironmentVariable('SENSE_MEDIAMTX_CONFIG', '', 'Process')
|
||||
}
|
||||
[Environment]::SetEnvironmentVariable('SENSE_MEDIAMTX_API', $mediaAPI, 'Process')
|
||||
[Environment]::SetEnvironmentVariable('SENSE_MEDIAMTX_SHARDS_FILE', $mediaShardsFile, 'Process')
|
||||
|
||||
$runtimeDir = Join-Path $PackageRoot 'data\runtime'
|
||||
$logDir = Join-Path $PackageRoot 'logs'
|
||||
|
||||
@@ -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/edge_node"
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/common/actions"
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/common/middleware"
|
||||
)
|
||||
|
||||
func init() { routerCheckRole = append(routerCheckRole, registerSenseEdgeNodeRouter) }
|
||||
|
||||
func registerSenseEdgeNodeRouter(v1 *gin.RouterGroup, auth *jwt.GinJWTMiddleware) {
|
||||
api := &edge_node.API{}
|
||||
r := v1.Group("/edge-nodes").Use(auth.MiddlewareFunc()).Use(middleware.AuthCheckRole()).Use(actions.PermissionAction())
|
||||
r.GET("", api.List)
|
||||
r.GET("/:id", api.Get)
|
||||
}
|
||||
@@ -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/media_shard"
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/common/actions"
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/common/middleware"
|
||||
)
|
||||
|
||||
func init() { routerCheckRole = append(routerCheckRole, registerSenseMediaShardRouter) }
|
||||
|
||||
func registerSenseMediaShardRouter(v1 *gin.RouterGroup, auth *jwt.GinJWTMiddleware) {
|
||||
api := &media_shard.API{}
|
||||
r := v1.Group("/media-shards").Use(auth.MiddlewareFunc()).Use(middleware.AuthCheckRole()).Use(actions.PermissionAction())
|
||||
r.GET("", api.List)
|
||||
r.GET("/:id", api.Get)
|
||||
r.GET("/:id/migration-preflight", api.Preflight)
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
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/ops_alert"
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/common/actions"
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/common/middleware"
|
||||
)
|
||||
|
||||
func init() { routerCheckRole = append(routerCheckRole, registerSenseOpsAlertRouter) }
|
||||
|
||||
func registerSenseOpsAlertRouter(v1 *gin.RouterGroup, auth *jwt.GinJWTMiddleware) {
|
||||
api := &ops_alert.API{}
|
||||
r := v1.Group("/ops-alerts").Use(auth.MiddlewareFunc()).Use(middleware.AuthCheckRole()).Use(actions.PermissionAction())
|
||||
r.GET("", api.List)
|
||||
r.GET("/:id", api.Get)
|
||||
r.POST("/evaluate", api.Evaluate)
|
||||
r.POST("/:id/acknowledge", api.Acknowledge)
|
||||
r.POST("/:id/recover", api.Recover)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSenseOpsAlertRoutes(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
engine := gin.New()
|
||||
registerSenseOpsAlertRouter(engine.Group("/api/v1"), &jwt.GinJWTMiddleware{})
|
||||
wanted := map[string]bool{
|
||||
http.MethodGet + " /api/v1/ops-alerts": false,
|
||||
http.MethodGet + " /api/v1/ops-alerts/:id": false,
|
||||
http.MethodPost + " /api/v1/ops-alerts/evaluate": false,
|
||||
http.MethodPost + " /api/v1/ops-alerts/:id/acknowledge": false,
|
||||
http.MethodPost + " /api/v1/ops-alerts/:id/recover": 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,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/outbox"
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/common/actions"
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/common/middleware"
|
||||
)
|
||||
|
||||
func init() { routerCheckRole = append(routerCheckRole, registerSenseOutboxRouter) }
|
||||
func registerSenseOutboxRouter(v1 *gin.RouterGroup, auth *jwt.GinJWTMiddleware) {
|
||||
api := &outbox.API{}
|
||||
r := v1.Group("/outbox").Use(auth.MiddlewareFunc()).Use(middleware.AuthCheckRole()).Use(actions.PermissionAction())
|
||||
r.GET("", api.List)
|
||||
r.GET("/:id", api.Get)
|
||||
r.POST("/:id/requeue", api.Requeue)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSenseOutboxRoutes(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
engine := gin.New()
|
||||
registerSenseOutboxRouter(engine.Group("/api/v1"), &jwt.GinJWTMiddleware{})
|
||||
wanted := map[string]bool{http.MethodGet + " /api/v1/outbox": false, http.MethodGet + " /api/v1/outbox/:id": false, http.MethodPost + " /api/v1/outbox/:id/requeue": 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,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)
|
||||
}
|
||||
@@ -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{}, "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
|
||||
|
||||
@@ -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,81 @@
|
||||
package edge_node
|
||||
|
||||
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"
|
||||
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) audit(c *gin.Context, service *Service, action, status, remark string) {
|
||||
if err := WriteAudit(service.Orm, 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()}); err != nil {
|
||||
api.GetRequestLogger(c).Errorf("edge node audit failed: %s", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func (e *API) writeError(err error) {
|
||||
switch {
|
||||
case errors.Is(err, ErrInvalidQuery):
|
||||
e.Error(http.StatusBadRequest, err, err.Error())
|
||||
case errors.Is(err, ErrNotFound):
|
||||
e.Error(http.StatusNotFound, err, err.Error())
|
||||
default:
|
||||
e.Error(http.StatusInternalServerError, err, "边缘节点查询失败")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package edge_node
|
||||
|
||||
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: "edge_node.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
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package edge_node
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
commonDto "git.ilapage.cn/ila/yovision/Sense/server/common/dto"
|
||||
)
|
||||
|
||||
const HeartbeatTimeout = 90 * time.Second
|
||||
|
||||
type PageRequest struct {
|
||||
commonDto.Pagination `search:"-"`
|
||||
}
|
||||
|
||||
type Summary struct {
|
||||
Total int `json:"total"`
|
||||
Online int `json:"online"`
|
||||
Offline int `json:"offline"`
|
||||
Recovering int `json:"recovering"`
|
||||
LoadUsed int `json:"loadUsed"`
|
||||
LoadCapacity int `json:"loadCapacity"`
|
||||
BackfillQueueDepth int `json:"backfillQueueDepth"`
|
||||
HeartbeatTimeout int64 `json:"heartbeatTimeoutSeconds"`
|
||||
}
|
||||
|
||||
type Response struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Location string `json:"location"`
|
||||
Status string `json:"status"`
|
||||
RuntimeVersion string `json:"runtimeVersion"`
|
||||
ModelVersion string `json:"modelVersion"`
|
||||
StartedAt time.Time `json:"startedAt"`
|
||||
UptimeSeconds int64 `json:"uptimeSeconds"`
|
||||
LastHeartbeatAt time.Time `json:"lastHeartbeatAt"`
|
||||
LastCollectedAt time.Time `json:"lastCollectedAt"`
|
||||
FreshnessSeconds int64 `json:"freshnessSeconds"`
|
||||
Stale bool `json:"stale"`
|
||||
LoadUsed int `json:"loadUsed"`
|
||||
LoadCapacity int `json:"loadCapacity"`
|
||||
ControlTunnelStatus string `json:"controlTunnelStatus"`
|
||||
ControlTunnelDetail string `json:"controlTunnelDetail"`
|
||||
ControlLatencyMs int `json:"controlLatencyMs"`
|
||||
VideoPlaneStatus string `json:"videoPlaneStatus"`
|
||||
VideoPlaneDetail string `json:"videoPlaneDetail"`
|
||||
ActiveStreamCount int `json:"activeStreamCount"`
|
||||
BackfillStatus string `json:"backfillStatus"`
|
||||
BackfillQueueDepth int `json:"backfillQueueDepth"`
|
||||
RecoveryPhase string `json:"recoveryPhase"`
|
||||
ProjectionVersion int64 `json:"projectionVersion"`
|
||||
Events []Event `json:"events,omitempty"`
|
||||
}
|
||||
|
||||
type PageResponse struct {
|
||||
Summary Summary `json:"summary"`
|
||||
List []Response `json:"list"`
|
||||
Count int64 `json:"count"`
|
||||
}
|
||||
|
||||
// HeartbeatSample is an internal Sense adapter input. It is intentionally not
|
||||
// exposed as an HTTP contract by this task.
|
||||
type HeartbeatSample struct {
|
||||
Node
|
||||
CollectedAt time.Time
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package edge_node
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// SeedSyntheticFixture is explicit test/development data. Production startup
|
||||
// and migrations never call it.
|
||||
func SeedSyntheticFixture(db *gorm.DB, now time.Time) error {
|
||||
now = now.UTC().Truncate(time.Second)
|
||||
items := []Node{
|
||||
{ID: "SEN-EDGE-01", Name: "主楼边缘节点", Location: "主楼机房", RuntimeVersion: "sense-edge 0.1.0", ModelVersion: "detector-v3", StartedAt: now.Add(-7 * 24 * time.Hour), LastHeartbeatAt: now.Add(-18 * time.Second), LastCollectedAt: now.Add(-18 * time.Second), LoadUsed: 6, LoadCapacity: 16, ControlTunnelStatus: ChannelReady, ControlTunnelDetail: "控制隧道正常", ControlLatencyMs: 24, VideoPlaneStatus: ChannelReady, VideoPlaneDetail: "6 路视频正常", ActiveStreamCount: 6, BackfillStatus: BackfillIdle, ProjectionVersion: 1},
|
||||
{ID: "SEN-EDGE-02", Name: "仓库边缘节点", Location: "仓库弱电间", RuntimeVersion: "sense-edge 0.1.0", ModelVersion: "detector-v3", StartedAt: now.Add(-3 * 24 * time.Hour), LastHeartbeatAt: now.Add(-4 * time.Minute), LastCollectedAt: now.Add(-4 * time.Minute), LoadUsed: 4, LoadCapacity: 16, ControlTunnelStatus: ChannelReady, ControlTunnelDetail: "最后已知:控制隧道正常", ControlLatencyMs: 31, VideoPlaneStatus: ChannelReady, VideoPlaneDetail: "最后已知:4 路视频正常", ActiveStreamCount: 4, BackfillStatus: BackfillPending, BackfillQueueDepth: 12, ProjectionVersion: 1},
|
||||
{ID: "SEN-EDGE-03", Name: "南门边缘节点", Location: "南门岗亭", RuntimeVersion: "sense-edge 0.1.0", ModelVersion: "detector-v3", StartedAt: now.Add(-10 * time.Hour), LastHeartbeatAt: now.Add(-12 * time.Second), LastCollectedAt: now.Add(-12 * time.Second), LoadUsed: 2, LoadCapacity: 8, ControlTunnelStatus: ChannelReady, ControlTunnelDetail: "控制隧道已恢复", ControlLatencyMs: 46, VideoPlaneStatus: ChannelUnavailable, VideoPlaneDetail: "视频数据面恢复中", ActiveStreamCount: 0, BackfillStatus: BackfillPending, BackfillQueueDepth: 5, RecoveryPhase: "video_reconnecting", ProjectionVersion: 2},
|
||||
}
|
||||
return db.Clauses(clause.OnConflict{DoNothing: true}).Create(&items).Error
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package edge_node
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
common "git.ilapage.cn/ila/yovision/Sense/server/common/models"
|
||||
)
|
||||
|
||||
const (
|
||||
StatusOnline = "online"
|
||||
StatusOffline = "offline"
|
||||
StatusRecovering = "recovering"
|
||||
|
||||
ChannelReady = "ready"
|
||||
ChannelUnavailable = "unavailable"
|
||||
BackfillIdle = "idle"
|
||||
BackfillPending = "pending"
|
||||
)
|
||||
|
||||
// Node is the Sense-owned last-known projection of one edge runtime. It is
|
||||
// deliberately not a shared identity or a cross-product protocol model.
|
||||
type Node struct {
|
||||
ID string `gorm:"size:64;primaryKey" json:"id"`
|
||||
Name string `gorm:"size:128;not null" json:"name"`
|
||||
Location string `gorm:"size:256;not null;default:''" json:"location"`
|
||||
RuntimeVersion string `gorm:"size:64;not null;default:''" json:"runtimeVersion"`
|
||||
ModelVersion string `gorm:"size:64;not null;default:''" json:"modelVersion"`
|
||||
StartedAt time.Time `gorm:"not null" json:"startedAt"`
|
||||
LastHeartbeatAt time.Time `gorm:"not null;index" json:"lastHeartbeatAt"`
|
||||
LastCollectedAt time.Time `gorm:"not null" json:"lastCollectedAt"`
|
||||
LoadUsed int `gorm:"not null;default:0" json:"loadUsed"`
|
||||
LoadCapacity int `gorm:"not null;default:0" json:"loadCapacity"`
|
||||
ControlTunnelStatus string `gorm:"size:32;not null;default:'unavailable'" json:"controlTunnelStatus"`
|
||||
ControlTunnelDetail string `gorm:"size:256;not null;default:''" json:"controlTunnelDetail"`
|
||||
ControlLatencyMs int `gorm:"not null;default:0" json:"controlLatencyMs"`
|
||||
VideoPlaneStatus string `gorm:"size:32;not null;default:'unavailable'" json:"videoPlaneStatus"`
|
||||
VideoPlaneDetail string `gorm:"size:256;not null;default:''" json:"videoPlaneDetail"`
|
||||
ActiveStreamCount int `gorm:"not null;default:0" json:"activeStreamCount"`
|
||||
BackfillStatus string `gorm:"size:32;not null;default:'idle'" json:"backfillStatus"`
|
||||
BackfillQueueDepth int `gorm:"not null;default:0" json:"backfillQueueDepth"`
|
||||
RecoveryPhase string `gorm:"size:32;not null;default:''" json:"recoveryPhase"`
|
||||
ProjectionVersion int64 `gorm:"not null;default:1" json:"projectionVersion"`
|
||||
common.ControlBy
|
||||
common.ModelTime
|
||||
}
|
||||
|
||||
func (Node) TableName() string { return "sense_edge_nodes" }
|
||||
|
||||
// Event records projection state transitions without storing credentials or
|
||||
// raw heartbeat payloads.
|
||||
type Event struct {
|
||||
ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
NodeID string `gorm:"size:64;not null;index" json:"nodeId"`
|
||||
EventType string `gorm:"size:32;not null;index" json:"eventType"`
|
||||
FromStatus string `gorm:"size:32;not null;default:''" json:"fromStatus"`
|
||||
ToStatus string `gorm:"size:32;not null" json:"toStatus"`
|
||||
Detail string `gorm:"size:256;not null;default:''" json:"detail"`
|
||||
OccurredAt time.Time `gorm:"not null;index" json:"occurredAt"`
|
||||
}
|
||||
|
||||
func (Event) TableName() string { return "sense_edge_node_events" }
|
||||
@@ -0,0 +1,167 @@
|
||||
package edge_node
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
coreService "github.com/go-admin-team/go-admin-core/sdk/service"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidQuery = errors.New("边缘节点查询条件不符合要求")
|
||||
ErrNotFound = errors.New("边缘节点不存在")
|
||||
ErrInvalidSample = errors.New("边缘节点心跳样本不符合要求")
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
coreService.Service
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
func NewService(db *gorm.DB) *Service {
|
||||
return &Service{Service: coreService.Service{Orm: db}, Now: time.Now}
|
||||
}
|
||||
|
||||
func (s *Service) List(request PageRequest) (PageResponse, error) {
|
||||
if request.GetPageSize() > 100 {
|
||||
return PageResponse{}, ErrInvalidQuery
|
||||
}
|
||||
var count int64
|
||||
if err := s.Orm.Model(&Node{}).Count(&count).Error; err != nil {
|
||||
return PageResponse{}, fmt.Errorf("count edge nodes: %w", err)
|
||||
}
|
||||
var models []Node
|
||||
if err := s.Orm.Order("name ASC, id ASC").Limit(request.GetPageSize()).Offset((request.GetPageIndex() - 1) * request.GetPageSize()).Find(&models).Error; err != nil {
|
||||
return PageResponse{}, fmt.Errorf("list edge nodes: %w", err)
|
||||
}
|
||||
result := PageResponse{List: make([]Response, 0, len(models)), Count: count}
|
||||
result.Summary.HeartbeatTimeout = int64(HeartbeatTimeout / time.Second)
|
||||
var all []Node
|
||||
if err := s.Orm.Find(&all).Error; err != nil {
|
||||
return PageResponse{}, fmt.Errorf("summarize edge nodes: %w", err)
|
||||
}
|
||||
result.Summary.Total = len(all)
|
||||
for _, item := range all {
|
||||
status := s.status(item)
|
||||
switch status {
|
||||
case StatusOffline:
|
||||
result.Summary.Offline++
|
||||
case StatusRecovering:
|
||||
result.Summary.Recovering++
|
||||
default:
|
||||
result.Summary.Online++
|
||||
}
|
||||
result.Summary.LoadUsed += item.LoadUsed
|
||||
result.Summary.LoadCapacity += item.LoadCapacity
|
||||
result.Summary.BackfillQueueDepth += item.BackfillQueueDepth
|
||||
}
|
||||
for _, item := range models {
|
||||
result.List = append(result.List, s.response(item, nil))
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *Service) Get(id string) (Response, error) {
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" || len(id) > 64 {
|
||||
return Response{}, ErrInvalidQuery
|
||||
}
|
||||
var model Node
|
||||
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 edge node: %w", err)
|
||||
}
|
||||
var events []Event
|
||||
if err := s.Orm.Where("node_id = ?", id).Order("occurred_at DESC, id DESC").Limit(20).Find(&events).Error; err != nil {
|
||||
return Response{}, fmt.Errorf("list edge node events: %w", err)
|
||||
}
|
||||
return s.response(model, events), nil
|
||||
}
|
||||
|
||||
// ApplyHeartbeat updates the Sense-owned projection atomically and records
|
||||
// only state transitions. Callers are internal adapters, not public APIs.
|
||||
func (s *Service) ApplyHeartbeat(ctx context.Context, sample HeartbeatSample) error {
|
||||
now := sample.CollectedAt.UTC()
|
||||
if now.IsZero() {
|
||||
now = s.now()
|
||||
}
|
||||
sample.ID = strings.TrimSpace(sample.ID)
|
||||
if sample.ID == "" || len(sample.ID) > 64 || strings.TrimSpace(sample.Name) == "" || sample.LastHeartbeatAt.IsZero() || sample.StartedAt.IsZero() || sample.LoadUsed < 0 || sample.LoadCapacity < 0 || sample.LoadUsed > sample.LoadCapacity || sample.BackfillQueueDepth < 0 {
|
||||
return ErrInvalidSample
|
||||
}
|
||||
sample.LastHeartbeatAt = sample.LastHeartbeatAt.UTC()
|
||||
sample.LastCollectedAt = now
|
||||
return s.Orm.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var previous Node
|
||||
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&previous, "id = ?", sample.ID).Error
|
||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return fmt.Errorf("lock edge node projection: %w", err)
|
||||
}
|
||||
from := ""
|
||||
if err == nil {
|
||||
from = statusAt(previous, now)
|
||||
sample.CreatedAt = previous.CreatedAt
|
||||
sample.ProjectionVersion = previous.ProjectionVersion + 1
|
||||
} else {
|
||||
sample.ProjectionVersion = 1
|
||||
}
|
||||
if err = tx.Save(&sample.Node).Error; err != nil {
|
||||
return fmt.Errorf("save edge node projection: %w", err)
|
||||
}
|
||||
to := statusAt(sample.Node, now)
|
||||
if from == to && from != "" {
|
||||
return nil
|
||||
}
|
||||
if from == StatusOffline {
|
||||
timeoutAt := previous.LastHeartbeatAt.UTC().Add(HeartbeatTimeout)
|
||||
if err = tx.Create(&Event{NodeID: sample.ID, EventType: "heartbeat_timeout", FromStatus: StatusOnline, ToStatus: StatusOffline, Detail: "节点心跳超过 90 秒未更新", OccurredAt: timeoutAt}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
eventType, detail := "heartbeat_received", "收到节点首次心跳"
|
||||
if from == StatusOffline {
|
||||
eventType, detail = "node_recovered", "节点心跳恢复,通道状态继续独立收敛"
|
||||
} else if to == StatusRecovering {
|
||||
eventType, detail = "recovery_started", "节点进入恢复阶段"
|
||||
}
|
||||
return tx.Create(&Event{NodeID: sample.ID, EventType: eventType, FromStatus: from, ToStatus: to, Detail: detail, OccurredAt: now}).Error
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) response(model Node, events []Event) Response {
|
||||
now := s.now()
|
||||
freshness := int64(now.Sub(model.LastHeartbeatAt.UTC()).Seconds())
|
||||
if freshness < 0 {
|
||||
freshness = 0
|
||||
}
|
||||
uptime := int64(model.LastHeartbeatAt.UTC().Sub(model.StartedAt.UTC()).Seconds())
|
||||
if uptime < 0 {
|
||||
uptime = 0
|
||||
}
|
||||
return Response{ID: model.ID, Name: model.Name, Location: model.Location, Status: statusAt(model, now), RuntimeVersion: model.RuntimeVersion, ModelVersion: model.ModelVersion, StartedAt: model.StartedAt, UptimeSeconds: uptime, LastHeartbeatAt: model.LastHeartbeatAt, LastCollectedAt: model.LastCollectedAt, FreshnessSeconds: freshness, Stale: freshness > int64(HeartbeatTimeout/time.Second), LoadUsed: model.LoadUsed, LoadCapacity: model.LoadCapacity, ControlTunnelStatus: model.ControlTunnelStatus, ControlTunnelDetail: model.ControlTunnelDetail, ControlLatencyMs: model.ControlLatencyMs, VideoPlaneStatus: model.VideoPlaneStatus, VideoPlaneDetail: model.VideoPlaneDetail, ActiveStreamCount: model.ActiveStreamCount, BackfillStatus: model.BackfillStatus, BackfillQueueDepth: model.BackfillQueueDepth, RecoveryPhase: model.RecoveryPhase, ProjectionVersion: model.ProjectionVersion, Events: events}
|
||||
}
|
||||
|
||||
func (s *Service) status(model Node) string { return statusAt(model, s.now()) }
|
||||
func (s *Service) now() time.Time {
|
||||
if s.Now != nil {
|
||||
return s.Now().UTC()
|
||||
}
|
||||
return time.Now().UTC()
|
||||
}
|
||||
|
||||
func statusAt(model Node, now time.Time) string {
|
||||
if now.UTC().Sub(model.LastHeartbeatAt.UTC()) > HeartbeatTimeout {
|
||||
return StatusOffline
|
||||
}
|
||||
if strings.TrimSpace(model.RecoveryPhase) != "" && model.RecoveryPhase != "converged" {
|
||||
return StatusRecovering
|
||||
}
|
||||
return StatusOnline
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package edge_node
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func edgeNodeTestService(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(&Node{}, &Event{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now := time.Date(2026, 8, 28, 4, 0, 0, 0, time.UTC)
|
||||
service := NewService(db)
|
||||
service.Now = func() time.Time { return now }
|
||||
return service, db, now
|
||||
}
|
||||
|
||||
func TestListClassifiesOnlineOfflineAndRecoveringWithoutErasingLastKnownState(t *testing.T) {
|
||||
service, db, now := edgeNodeTestService(t)
|
||||
if err := SeedSyntheticFixture(db, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
page, err := service.List(PageRequest{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if page.Count != 3 || page.Summary.Online != 1 || page.Summary.Offline != 1 || page.Summary.Recovering != 1 || page.Summary.BackfillQueueDepth != 17 {
|
||||
t.Fatalf("unexpected summary: %+v", page.Summary)
|
||||
}
|
||||
var offline Response
|
||||
for _, item := range page.List {
|
||||
if item.Status == StatusOffline {
|
||||
offline = item
|
||||
}
|
||||
}
|
||||
if !offline.Stale || offline.ControlTunnelStatus != ChannelReady || offline.VideoPlaneStatus != ChannelReady || offline.BackfillQueueDepth != 12 {
|
||||
t.Fatalf("offline projection lost last-known state: %+v", offline)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyHeartbeatRecordsRecoveryAndPreservesIndependentChannelConvergence(t *testing.T) {
|
||||
service, db, now := edgeNodeTestService(t)
|
||||
old := Node{ID: "EDGE-1", Name: "旧节点", StartedAt: now.Add(-time.Hour), LastHeartbeatAt: now.Add(-5 * time.Minute), LastCollectedAt: now.Add(-5 * time.Minute), LoadCapacity: 16, ControlTunnelStatus: ChannelReady, VideoPlaneStatus: ChannelReady, BackfillStatus: BackfillPending, BackfillQueueDepth: 8, ProjectionVersion: 3}
|
||||
if err := db.Create(&old).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sample := HeartbeatSample{Node: Node{ID: "EDGE-1", Name: "旧节点", StartedAt: old.StartedAt, LastHeartbeatAt: now.Add(-5 * time.Second), LoadUsed: 4, LoadCapacity: 16, ControlTunnelStatus: ChannelReady, VideoPlaneStatus: ChannelUnavailable, BackfillStatus: BackfillPending, BackfillQueueDepth: 3, RecoveryPhase: "video_reconnecting"}, CollectedAt: now}
|
||||
if err := service.ApplyHeartbeat(context.Background(), sample); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
item, err := service.Get("EDGE-1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if item.Status != StatusRecovering || item.ProjectionVersion != 4 || item.VideoPlaneStatus != ChannelUnavailable || item.BackfillQueueDepth != 3 {
|
||||
t.Fatalf("unexpected recovery projection: %+v", item)
|
||||
}
|
||||
if len(item.Events) != 2 || item.Events[0].EventType != "node_recovered" || item.Events[0].FromStatus != StatusOffline || item.Events[0].ToStatus != StatusRecovering || item.Events[1].EventType != "heartbeat_timeout" || item.Events[1].ToStatus != StatusOffline {
|
||||
t.Fatalf("unexpected events: %+v", item.Events)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyHeartbeatRejectsInvalidSampleAndDoesNotPersist(t *testing.T) {
|
||||
service, db, now := edgeNodeTestService(t)
|
||||
err := service.ApplyHeartbeat(context.Background(), HeartbeatSample{Node: Node{ID: "EDGE-2", Name: "节点", StartedAt: now.Add(-time.Hour), LastHeartbeatAt: now, LoadUsed: 17, LoadCapacity: 16}, CollectedAt: now})
|
||||
if !errors.Is(err, ErrInvalidSample) {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
var count int64
|
||||
db.Model(&Node{}).Count(&count)
|
||||
if count != 0 {
|
||||
t.Fatalf("count=%d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRejectsMissingOrMalformedID(t *testing.T) {
|
||||
service, _, _ := edgeNodeTestService(t)
|
||||
if _, err := service.Get(""); !errors.Is(err, ErrInvalidQuery) {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if _, err := service.Get("missing"); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListRejectsOversizedPage(t *testing.T) {
|
||||
service, _, _ := edgeNodeTestService(t)
|
||||
request := PageRequest{}
|
||||
request.PageSize = 101
|
||||
if _, err := service.List(request); !errors.Is(err, ErrInvalidQuery) {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package local_event
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/outbox"
|
||||
)
|
||||
|
||||
// CreateWithOutbox commits the local candidate and its internal delivery
|
||||
// record atomically. The payload remains Sense-internal and is not a Bell or
|
||||
// Brain contract.
|
||||
func CreateWithOutbox(ctx context.Context, db *gorm.DB, candidate EventCandidate, payload map[string]interface{}, now time.Time) error {
|
||||
encoded, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode local event outbox payload: %w", err)
|
||||
}
|
||||
return db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Create(&candidate).Error; err != nil {
|
||||
return fmt.Errorf("create local event candidate: %w", err)
|
||||
}
|
||||
_, err = outbox.Enqueue(tx, outbox.EnqueueInput{InternalType: "local_event_candidate", BusinessRef: candidate.ID, IdempotencyKey: "local-event:" + candidate.ID + ":v1", PayloadJSON: encoded}, now)
|
||||
return err
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package local_event
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/outbox"
|
||||
)
|
||||
|
||||
func TestCreateWithOutboxCommitsAndRollsBackAtomically(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open("file:"+uuid.NewString()+"?mode=memory&cache=shared"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sqlDB, _ := db.DB()
|
||||
sqlDB.SetMaxOpenConns(1)
|
||||
if err = db.AutoMigrate(&EventCandidate{}, &outbox.Message{}, &outbox.DeliveryRecord{}, &outbox.Attempt{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now := time.Date(2026, 8, 28, 10, 0, 0, 0, time.UTC)
|
||||
candidate := EventCandidate{ID: uuid.NewString(), OccurredAt: now, SourceRef: "SEN-CAM-01", RuleRef: "rule-1", RuleName: "区域闯入", CandidateState: CandidateStateCandidate, EvidenceState: EvidenceStatePending, RetainUntil: now.Add(24 * time.Hour)}
|
||||
if err = CreateWithOutbox(context.Background(), db, candidate, map[string]interface{}{"eventId": candidate.ID}, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var candidates, messages int64
|
||||
db.Model(&EventCandidate{}).Count(&candidates)
|
||||
db.Model(&outbox.Message{}).Count(&messages)
|
||||
if candidates != 1 || messages != 1 {
|
||||
t.Fatalf("candidates=%d messages=%d", candidates, messages)
|
||||
}
|
||||
duplicate := candidate
|
||||
duplicate.ID = candidate.ID
|
||||
if err = CreateWithOutbox(context.Background(), db, duplicate, map[string]interface{}{"eventId": duplicate.ID}, now); err == nil {
|
||||
t.Fatal("expected duplicate transaction failure")
|
||||
}
|
||||
db.Model(&EventCandidate{}).Count(&candidates)
|
||||
db.Model(&outbox.Message{}).Count(&messages)
|
||||
if candidates != 1 || messages != 1 {
|
||||
t.Fatalf("atomic rollback failed candidates=%d messages=%d", candidates, messages)
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,8 @@ import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/media_shard"
|
||||
)
|
||||
|
||||
var runtimeState struct {
|
||||
@@ -41,6 +43,27 @@ func StartRuntime(parent context.Context, db *gorm.DB) error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
shards := media_shard.NewService(db, func(probeCtx context.Context, endpoint string) error {
|
||||
probe, probeErr := NewHTTPController(endpoint)
|
||||
if probeErr != nil {
|
||||
return probeErr
|
||||
}
|
||||
return probe.Health(probeCtx)
|
||||
})
|
||||
specs, err := media_shard.SpecsFromEnvironment(db, mode, config.APIBase)
|
||||
if err != nil {
|
||||
cancel()
|
||||
return err
|
||||
}
|
||||
if err = shards.SyncSpecs(ctx, specs); err != nil {
|
||||
cancel()
|
||||
return err
|
||||
}
|
||||
if err = shards.RefreshAll(ctx); err != nil {
|
||||
cancel()
|
||||
return err
|
||||
}
|
||||
service.WithShardService(shards)
|
||||
runtimeState.Lock()
|
||||
if runtimeState.cancel != nil {
|
||||
runtimeState.cancel()
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/credential"
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/media_shard"
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/reconcile"
|
||||
)
|
||||
|
||||
@@ -26,10 +27,16 @@ type Service struct {
|
||||
controller Controller
|
||||
process *Supervisor
|
||||
config RuntimeConfig
|
||||
shards *media_shard.Service
|
||||
now func() time.Time
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func (s *Service) WithShardService(shards *media_shard.Service) *Service {
|
||||
s.shards = shards
|
||||
return s
|
||||
}
|
||||
|
||||
func NewService(db *gorm.DB, controller Controller, process *Supervisor, config RuntimeConfig) *Service {
|
||||
return &Service{db: db, controller: controller, process: process, config: config, now: time.Now}
|
||||
}
|
||||
@@ -76,11 +83,19 @@ func (s *Service) ensureDevice(ctx context.Context, deviceID string, reactivate
|
||||
return err
|
||||
}
|
||||
}
|
||||
if s.shards != nil {
|
||||
if _, err := s.shards.EnsureAssignment(ctx, id); err != nil {
|
||||
return fmt.Errorf("assign media route to shard: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) Run(ctx context.Context) {
|
||||
if s.shards != nil {
|
||||
_ = s.shards.RefreshAll(ctx)
|
||||
}
|
||||
_ = s.EnsureAllVerified(ctx)
|
||||
_ = s.ReconcileDue(ctx)
|
||||
interval := s.config.PollInterval
|
||||
@@ -94,6 +109,9 @@ func (s *Service) Run(ctx context.Context) {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
if s.shards != nil {
|
||||
_ = s.shards.RefreshAll(ctx)
|
||||
}
|
||||
_ = s.ReconcileDue(ctx)
|
||||
}
|
||||
}
|
||||
@@ -127,10 +145,11 @@ func (s *Service) Refresh(ctx context.Context, id string) (RouteResponse, error)
|
||||
if err := s.db.WithContext(ctx).First(&route, "id = ?", id).Error; err != nil {
|
||||
return RouteResponse{}, ErrNotFound
|
||||
}
|
||||
if err := s.ensureControl(ctx); err != nil {
|
||||
controller, err := s.controllerForRoute(ctx, route)
|
||||
if err != nil {
|
||||
return s.saveFailure(ctx, route, "process_unavailable", "MediaMTX 未启动或 Control API 未就绪", err)
|
||||
}
|
||||
status, err := s.controller.Status(ctx, route.Path)
|
||||
status, err := controller.Status(ctx, route.Path)
|
||||
if err != nil {
|
||||
return s.saveFailure(ctx, route, "status_unavailable", "尚未取得媒体路径状态", err)
|
||||
}
|
||||
@@ -159,12 +178,13 @@ func (s *Service) Reconcile(ctx context.Context, id string) (RouteResponse, erro
|
||||
return RouteResponse{}, err
|
||||
}
|
||||
if route.Desired == DesiredStopped {
|
||||
if s.controller != nil {
|
||||
_ = s.controller.Delete(ctx, route.Path)
|
||||
if controller, err := s.controllerForRoute(ctx, route); err == nil {
|
||||
_ = controller.Delete(ctx, route.Path)
|
||||
}
|
||||
return s.saveSuccess(ctx, route, "stopped", false, 0, "媒体路径已停止")
|
||||
}
|
||||
if err := s.ensureControl(ctx); err != nil {
|
||||
controller, err := s.controllerForRoute(ctx, route)
|
||||
if err != nil {
|
||||
return s.saveFailure(ctx, route, "process_unavailable", "MediaMTX 未启动或 Control API 未就绪", err)
|
||||
}
|
||||
var profile admissionProfile
|
||||
@@ -175,10 +195,10 @@ func (s *Service) Reconcile(ctx context.Context, id string) (RouteResponse, erro
|
||||
if err != nil {
|
||||
return s.saveFailure(ctx, route, "credential_unavailable", "RTSP 凭据不可用", err)
|
||||
}
|
||||
if err = s.controller.Apply(ctx, Source{Path: route.Path, URI: profile.StreamURI, Username: value.Username, Password: value.Password}); err != nil {
|
||||
if err = controller.Apply(ctx, Source{Path: route.Path, URI: profile.StreamURI, Username: value.Username, Password: value.Password}); err != nil {
|
||||
return s.saveFailure(ctx, route, "apply_failed", "媒体路径配置失败", err)
|
||||
}
|
||||
status, err := s.controller.Status(ctx, route.Path)
|
||||
status, err := controller.Status(ctx, route.Path)
|
||||
if err != nil {
|
||||
return s.saveFailure(ctx, route, "status_unavailable", "尚未取得媒体路径状态", err)
|
||||
}
|
||||
@@ -191,6 +211,43 @@ func (s *Service) Reconcile(ctx context.Context, id string) (RouteResponse, erro
|
||||
return s.saveSuccess(ctx, route, "waiting", false, status.Readers, "等待播放器连接并按需拉流")
|
||||
}
|
||||
|
||||
func (s *Service) controllerForRoute(ctx context.Context, route Route) (Controller, error) {
|
||||
if s.shards == nil {
|
||||
if err := s.ensureControl(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.controller, nil
|
||||
}
|
||||
shard, err := s.shards.ResolveRoute(ctx, route.ID)
|
||||
if errors.Is(err, media_shard.ErrNotFound) {
|
||||
shard, err = s.shards.EnsureAssignment(ctx, route.ID)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if shard.Status != media_shard.StatusRunning {
|
||||
return nil, ErrRuntimeUnavailable
|
||||
}
|
||||
if shard.ID == "primary" {
|
||||
if err = s.ensureControl(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.controller, nil
|
||||
}
|
||||
endpoint, err := s.shards.ControlAPI(ctx, shard.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
controller, err := NewHTTPController(endpoint)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = controller.Health(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return controller, nil
|
||||
}
|
||||
|
||||
func (s *Service) ensureControl(ctx context.Context) error {
|
||||
if s.controller == nil {
|
||||
return ErrRuntimeUnavailable
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
package media_shard
|
||||
|
||||
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"
|
||||
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), nil
|
||||
}
|
||||
|
||||
func (e *API) List(c *gin.Context) {
|
||||
service, err := e.service(c)
|
||||
if err != nil {
|
||||
e.writeError(err)
|
||||
return
|
||||
}
|
||||
result, err := service.List(c.Request.Context())
|
||||
if err != nil {
|
||||
e.audit(c, service, "List", auditFailure, "媒体分片列表查询失败")
|
||||
e.writeError(err)
|
||||
return
|
||||
}
|
||||
e.audit(c, service, "List", auditSuccess, "读取媒体分片列表")
|
||||
e.OK(result, "查询成功")
|
||||
}
|
||||
|
||||
func (e *API) Get(c *gin.Context) {
|
||||
service, err := e.service(c)
|
||||
if err != nil {
|
||||
e.writeError(err)
|
||||
return
|
||||
}
|
||||
result, err := service.Get(c.Request.Context(), c.Param("id"))
|
||||
if err != nil {
|
||||
e.audit(c, service, "Get", auditFailure, "媒体分片详情查询失败")
|
||||
e.writeError(err)
|
||||
return
|
||||
}
|
||||
e.audit(c, service, "Get", auditSuccess, "读取媒体分片详情 "+result.ID)
|
||||
e.OK(result, "查询成功")
|
||||
}
|
||||
|
||||
func (e *API) Preflight(c *gin.Context) {
|
||||
service, err := e.service(c)
|
||||
if err != nil {
|
||||
e.writeError(err)
|
||||
return
|
||||
}
|
||||
result, err := service.Preflight(c.Request.Context(), c.Param("id"))
|
||||
if err != nil {
|
||||
e.audit(c, service, "Preflight", auditFailure, "媒体分片迁移预检失败")
|
||||
e.writeError(err)
|
||||
return
|
||||
}
|
||||
e.audit(c, service, "Preflight", auditSuccess, "执行媒体分片只读迁移预检 "+result.SourceShardID)
|
||||
e.OK(result, "预检完成")
|
||||
}
|
||||
|
||||
func (e *API) audit(c *gin.Context, service *Service, action, status, remark string) {
|
||||
if 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()}); err != nil {
|
||||
api.GetRequestLogger(c).Errorf("media shard audit failed: %s", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func (e *API) writeError(err error) {
|
||||
switch {
|
||||
case errors.Is(err, ErrInvalidID), errors.Is(err, ErrInvalidConfig):
|
||||
e.Error(http.StatusBadRequest, err, err.Error())
|
||||
case errors.Is(err, ErrNotFound):
|
||||
e.Error(http.StatusNotFound, err, err.Error())
|
||||
default:
|
||||
e.Error(http.StatusInternalServerError, err, "媒体分片查询失败")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package media_shard
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
adminModels "git.ilapage.cn/ila/yovision/Sense/server/app/admin/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
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: "media_shard.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()}
|
||||
return db.Create(&model).Error
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package media_shard
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/quota"
|
||||
)
|
||||
|
||||
var ErrInvalidConfig = errors.New("MediaMTX 分片配置不符合要求")
|
||||
|
||||
func SpecsFromEnvironment(db *gorm.DB, mode, primaryAPI string) ([]Spec, error) {
|
||||
capacity, err := quota.ReadLimit(db)
|
||||
if raw := strings.TrimSpace(os.Getenv("SENSE_MEDIAMTX_CAPACITY")); raw != "" {
|
||||
capacity, err = strconv.Atoi(raw)
|
||||
}
|
||||
if err != nil || capacity < 1 {
|
||||
return nil, fmt.Errorf("%w: primary capacity", ErrInvalidConfig)
|
||||
}
|
||||
primary := Spec{ID: "primary", Name: "主媒体分片", Mode: mode, ControlAPI: strings.TrimSpace(primaryAPI), Capacity: capacity}
|
||||
if err = validateSpec(primary, true); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := []Spec{primary}
|
||||
path := strings.TrimSpace(os.Getenv("SENSE_MEDIAMTX_SHARDS_FILE"))
|
||||
if path == "" {
|
||||
return result, nil
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read MediaMTX shards file: %w", err)
|
||||
}
|
||||
var extra []Spec
|
||||
if err = json.Unmarshal(data, &extra); err != nil {
|
||||
return nil, fmt.Errorf("%w: shards JSON", ErrInvalidConfig)
|
||||
}
|
||||
seen := map[string]bool{"primary": true}
|
||||
for _, item := range extra {
|
||||
item.ID, item.Name, item.Mode, item.ControlAPI = strings.TrimSpace(item.ID), strings.TrimSpace(item.Name), strings.ToLower(strings.TrimSpace(item.Mode)), strings.TrimSpace(item.ControlAPI)
|
||||
if item.Mode == "" {
|
||||
item.Mode = "external"
|
||||
}
|
||||
if seen[item.ID] || item.Mode != "external" {
|
||||
return nil, fmt.Errorf("%w: duplicate id or non-external extra shard", ErrInvalidConfig)
|
||||
}
|
||||
if err = validateSpec(item, false); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
seen[item.ID] = true
|
||||
result = append(result, item)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func validateSpec(spec Spec, primary bool) error {
|
||||
if spec.ID == "" || len(spec.ID) > 64 || spec.Name == "" || len([]rune(spec.Name)) > 128 || spec.Capacity < 1 || spec.Capacity > 100000 {
|
||||
return fmt.Errorf("%w: shard identity or capacity", ErrInvalidConfig)
|
||||
}
|
||||
if primary && spec.Mode != "managed" && spec.Mode != "external" {
|
||||
return fmt.Errorf("%w: primary mode", ErrInvalidConfig)
|
||||
}
|
||||
parsed, err := url.Parse(spec.ControlAPI)
|
||||
if err != nil || parsed.Scheme != "http" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" || parsed.Path != "" {
|
||||
return fmt.Errorf("%w: control API", ErrInvalidConfig)
|
||||
}
|
||||
host := parsed.Hostname()
|
||||
ip := net.ParseIP(host)
|
||||
if !strings.EqualFold(host, "localhost") && (ip == nil || !ip.IsLoopback()) {
|
||||
return fmt.Errorf("%w: control API must be loopback", ErrInvalidConfig)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package media_shard
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/quota"
|
||||
)
|
||||
|
||||
func TestSpecsFromEnvironmentUsesDatabaseQuotaAndExternalFile(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.AutoMigrate("a.Setting{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.Create("a.Setting{ID: quota.SettingID, Limit: 23, Source: "database", Version: 1}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path := filepath.Join(t.TempDir(), "shards.json")
|
||||
if err = os.WriteFile(path, []byte(`[{"id":"secondary","name":"备用分片","controlAPI":"http://127.0.0.1:19997","capacity":11}]`), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("SENSE_MEDIAMTX_CAPACITY", "")
|
||||
t.Setenv("SENSE_MEDIAMTX_SHARDS_FILE", path)
|
||||
specs, err := SpecsFromEnvironment(db, "managed", "http://127.0.0.1:9997")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(specs) != 2 || specs[0].Capacity != 23 || specs[1].Capacity != 11 || specs[1].Mode != "external" {
|
||||
t.Fatalf("unexpected specs: %+v", specs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpecsRejectsNonLoopbackControlAPI(t *testing.T) {
|
||||
t.Setenv("SENSE_MEDIAMTX_CAPACITY", "5")
|
||||
t.Setenv("SENSE_MEDIAMTX_SHARDS_FILE", "")
|
||||
if _, err := SpecsFromEnvironment(nil, "external", "http://192.0.2.1:9997"); err == nil {
|
||||
t.Fatal("expected unsafe control API to be rejected")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package media_shard
|
||||
|
||||
import "time"
|
||||
|
||||
type Spec struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Mode string `json:"mode"`
|
||||
ControlAPI string `json:"controlAPI"`
|
||||
Capacity int `json:"capacity"`
|
||||
}
|
||||
|
||||
type Summary struct {
|
||||
Total int `json:"total"`
|
||||
Available int `json:"available"`
|
||||
Failed int `json:"failed"`
|
||||
Configured int `json:"configuredCapacity"`
|
||||
Assigned int `json:"assignedPaths"`
|
||||
Impacted int `json:"impactedPaths"`
|
||||
}
|
||||
|
||||
type Response struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Mode string `json:"mode"`
|
||||
Status string `json:"status"`
|
||||
Detail string `json:"detail"`
|
||||
Capacity int `json:"capacity"`
|
||||
AssignedPaths int `json:"assignedPaths"`
|
||||
Remaining int `json:"remaining"`
|
||||
LastProbeAt *time.Time `json:"lastProbeAt,omitempty"`
|
||||
Stale bool `json:"stale"`
|
||||
}
|
||||
|
||||
type Impact struct {
|
||||
RouteID string `json:"routeId"`
|
||||
DeviceID string `json:"deviceId"`
|
||||
DeviceName string `json:"deviceName"`
|
||||
Location string `json:"location"`
|
||||
ProfileToken string `json:"profileToken"`
|
||||
Path string `json:"path"`
|
||||
Desired string `json:"desired"`
|
||||
Actual string `json:"actual"`
|
||||
}
|
||||
|
||||
type DetailResponse struct {
|
||||
Response
|
||||
Impact []Impact `json:"impact"`
|
||||
}
|
||||
|
||||
type PageResponse struct {
|
||||
Summary Summary `json:"summary"`
|
||||
List []Response `json:"list"`
|
||||
Count int64 `json:"count"`
|
||||
}
|
||||
|
||||
type Check struct {
|
||||
Name string `json:"name"`
|
||||
Passed bool `json:"passed"`
|
||||
Detail string `json:"detail"`
|
||||
}
|
||||
|
||||
type PreflightResponse struct {
|
||||
SourceShardID string `json:"sourceShardId"`
|
||||
TargetShardID string `json:"targetShardId,omitempty"`
|
||||
TargetShardName string `json:"targetShardName,omitempty"`
|
||||
ImpactedPaths int `json:"impactedPaths"`
|
||||
Ready bool `json:"ready"`
|
||||
ExecutionAuthorized bool `json:"executionAuthorized"`
|
||||
RequiresIssue bool `json:"requiresSeparateHighRiskIssue"`
|
||||
Checks []Check `json:"checks"`
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package media_shard
|
||||
|
||||
import "time"
|
||||
|
||||
const (
|
||||
StatusUnknown = "unknown"
|
||||
StatusRunning = "running"
|
||||
StatusFailed = "failed"
|
||||
StatusDisabled = "disabled"
|
||||
)
|
||||
|
||||
// Shard stores only a loopback control endpoint. It is deliberately omitted
|
||||
// from API response DTOs so credentials and control-plane topology cannot leak.
|
||||
type Shard struct {
|
||||
ID string `gorm:"size:64;primaryKey"`
|
||||
Name string `gorm:"size:128;not null"`
|
||||
Mode string `gorm:"size:16;not null"`
|
||||
ControlAPI string `gorm:"size:512;not null"`
|
||||
Capacity int `gorm:"not null"`
|
||||
Status string `gorm:"size:16;not null;index"`
|
||||
Detail string `gorm:"size:256;not null"`
|
||||
LastProbeAt *time.Time `gorm:"index"`
|
||||
ConfigVersion int64 `gorm:"not null;default:1"`
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
func (Shard) TableName() string { return "sense_media_shards" }
|
||||
|
||||
// Assignment is the stable, Sense-owned mapping between an existing media
|
||||
// route and a MediaMTX shard. Failures never rewrite this record automatically.
|
||||
type Assignment struct {
|
||||
RouteID string `gorm:"size:96;primaryKey"`
|
||||
ShardID string `gorm:"size:64;not null;index"`
|
||||
Algorithm string `gorm:"size:32;not null"`
|
||||
CreatedAt time.Time `gorm:"not null"`
|
||||
UpdatedAt time.Time `gorm:"not null"`
|
||||
}
|
||||
|
||||
func (Assignment) TableName() string { return "sense_media_shard_assignments" }
|
||||
|
||||
type routeProjection struct {
|
||||
ID string `gorm:"column:id"`
|
||||
DeviceID string `gorm:"column:device_id"`
|
||||
ProfileToken string `gorm:"column:profile_token"`
|
||||
Path string `gorm:"column:path"`
|
||||
Desired string `gorm:"column:desired"`
|
||||
Actual string `gorm:"column:actual"`
|
||||
}
|
||||
|
||||
func (routeProjection) TableName() string { return "sense_media_routes" }
|
||||
|
||||
type deviceProjection struct {
|
||||
ID string `gorm:"column:id"`
|
||||
Name string `gorm:"column:name"`
|
||||
Location string `gorm:"column:location"`
|
||||
}
|
||||
|
||||
func (deviceProjection) TableName() string { return "sense_devices" }
|
||||
@@ -0,0 +1,259 @@
|
||||
package media_shard
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrNotFound = errors.New("媒体分片不存在")
|
||||
ErrNoCapacity = errors.New("没有可用的媒体分片容量")
|
||||
ErrInvalidID = errors.New("媒体分片标识不符合要求")
|
||||
)
|
||||
|
||||
const staleAfter = 30 * time.Second
|
||||
|
||||
type Probe func(context.Context, string) error
|
||||
|
||||
type Service struct {
|
||||
DB *gorm.DB
|
||||
Probe Probe
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
func NewService(db *gorm.DB, probe Probe) *Service {
|
||||
return &Service{DB: db, Probe: probe, Now: time.Now}
|
||||
}
|
||||
|
||||
func (s *Service) SyncSpecs(ctx context.Context, specs []Spec) error {
|
||||
return s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
for _, spec := range specs {
|
||||
var current Shard
|
||||
err := tx.First(¤t, "id = ?", spec.ID).Error
|
||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return err
|
||||
}
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
current = Shard{ID: spec.ID, Status: StatusUnknown, Detail: "等待健康探测", ConfigVersion: 1}
|
||||
} else if current.Name != spec.Name || current.Mode != spec.Mode || current.ControlAPI != spec.ControlAPI || current.Capacity != spec.Capacity {
|
||||
current.ConfigVersion++
|
||||
}
|
||||
current.Name, current.Mode, current.ControlAPI, current.Capacity = spec.Name, spec.Mode, spec.ControlAPI, spec.Capacity
|
||||
if err = tx.Save(¤t).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
ids := make([]string, 0, len(specs))
|
||||
for _, spec := range specs {
|
||||
ids = append(ids, spec.ID)
|
||||
}
|
||||
return tx.Model(&Shard{}).Where("id NOT IN ?", ids).Updates(map[string]any{"status": StatusDisabled, "detail": "分片已从当前运行配置移除", "updated_at": s.now()}).Error
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) RefreshAll(ctx context.Context) error {
|
||||
var shards []Shard
|
||||
if err := s.DB.WithContext(ctx).Where("status <> ?", StatusDisabled).Find(&shards).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
var result error
|
||||
for _, shard := range shards {
|
||||
status, detail := StatusRunning, "Control API 正常"
|
||||
if s.Probe == nil || s.Probe(ctx, shard.ControlAPI) != nil {
|
||||
status, detail = StatusFailed, "Control API 不可用"
|
||||
}
|
||||
now := s.now()
|
||||
if err := s.DB.WithContext(ctx).Model(&Shard{}).Where("id = ?", shard.ID).Updates(map[string]any{"status": status, "detail": detail, "last_probe_at": &now, "updated_at": now}).Error; err != nil {
|
||||
result = errors.Join(result, err)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (s *Service) EnsureAssignment(ctx context.Context, routeID string) (Shard, error) {
|
||||
routeID = strings.TrimSpace(routeID)
|
||||
if routeID == "" {
|
||||
return Shard{}, ErrInvalidID
|
||||
}
|
||||
var selected Shard
|
||||
err := s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var existing Assignment
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&existing, "route_id = ?", routeID).Error; err == nil {
|
||||
return tx.First(&selected, "id = ?", existing.ShardID).Error
|
||||
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return err
|
||||
}
|
||||
var candidates []Shard
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("status IN ?", []string{StatusRunning, StatusUnknown}).Find(&candidates).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
counts, err := assignmentCounts(tx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
eligible := candidates[:0]
|
||||
for _, item := range candidates {
|
||||
if counts[item.ID] < int64(item.Capacity) {
|
||||
eligible = append(eligible, item)
|
||||
}
|
||||
}
|
||||
if len(eligible) == 0 {
|
||||
return ErrNoCapacity
|
||||
}
|
||||
sort.SliceStable(eligible, func(i, j int) bool { return rendezvous(routeID, eligible[i].ID) > rendezvous(routeID, eligible[j].ID) })
|
||||
selected = eligible[0]
|
||||
now := s.now()
|
||||
assignment := Assignment{RouteID: routeID, ShardID: selected.ID, Algorithm: "rendezvous-v1", CreatedAt: now, UpdatedAt: now}
|
||||
if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&assignment).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if assignment.RouteID != "" { // reload also handles a concurrent winner on PostgreSQL.
|
||||
if err := tx.First(&existing, "route_id = ?", routeID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.First(&selected, "id = ?", existing.ShardID).Error
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return selected, err
|
||||
}
|
||||
|
||||
func (s *Service) ResolveRoute(ctx context.Context, routeID string) (Shard, error) {
|
||||
var shard Shard
|
||||
err := s.DB.WithContext(ctx).Table("sense_media_shards AS s").Select("s.*").Joins("JOIN sense_media_shard_assignments a ON a.shard_id = s.id").Where("a.route_id = ?", routeID).Scan(&shard).Error
|
||||
if err != nil {
|
||||
return Shard{}, err
|
||||
}
|
||||
if shard.ID == "" {
|
||||
return Shard{}, ErrNotFound
|
||||
}
|
||||
return shard, nil
|
||||
}
|
||||
|
||||
func (s *Service) List(ctx context.Context) (PageResponse, error) {
|
||||
var shards []Shard
|
||||
if err := s.DB.WithContext(ctx).Order("name ASC, id ASC").Find(&shards).Error; err != nil {
|
||||
return PageResponse{}, err
|
||||
}
|
||||
counts, err := assignmentCounts(s.DB.WithContext(ctx))
|
||||
if err != nil {
|
||||
return PageResponse{}, err
|
||||
}
|
||||
result := PageResponse{List: make([]Response, 0, len(shards)), Count: int64(len(shards))}
|
||||
for _, shard := range shards {
|
||||
item := s.response(shard, int(counts[shard.ID]))
|
||||
result.List = append(result.List, item)
|
||||
result.Summary.Total++
|
||||
result.Summary.Configured += item.Capacity
|
||||
result.Summary.Assigned += item.AssignedPaths
|
||||
if item.Status == StatusRunning {
|
||||
result.Summary.Available++
|
||||
}
|
||||
if item.Status == StatusFailed {
|
||||
result.Summary.Failed++
|
||||
result.Summary.Impacted += item.AssignedPaths
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *Service) Get(ctx context.Context, id string) (DetailResponse, error) {
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" || len(id) > 64 {
|
||||
return DetailResponse{}, ErrInvalidID
|
||||
}
|
||||
var shard Shard
|
||||
if err := s.DB.WithContext(ctx).First(&shard, "id = ?", id).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return DetailResponse{}, ErrNotFound
|
||||
}
|
||||
return DetailResponse{}, err
|
||||
}
|
||||
impact, err := s.impact(ctx, id)
|
||||
if err != nil {
|
||||
return DetailResponse{}, err
|
||||
}
|
||||
return DetailResponse{Response: s.response(shard, len(impact)), Impact: impact}, nil
|
||||
}
|
||||
|
||||
func (s *Service) Preflight(ctx context.Context, id string) (PreflightResponse, error) {
|
||||
detail, err := s.Get(ctx, id)
|
||||
if err != nil {
|
||||
return PreflightResponse{}, err
|
||||
}
|
||||
result := PreflightResponse{SourceShardID: id, ImpactedPaths: len(detail.Impact), ExecutionAuthorized: false, RequiresIssue: true}
|
||||
result.Checks = append(result.Checks, Check{Name: "源分片状态", Passed: detail.Status == StatusRunning && !detail.Stale, Detail: detail.Detail})
|
||||
var candidates []Shard
|
||||
if err = s.DB.WithContext(ctx).Where("id <> ? AND status = ?", id, StatusRunning).Find(&candidates).Error; err != nil {
|
||||
return PreflightResponse{}, err
|
||||
}
|
||||
counts, err := assignmentCounts(s.DB.WithContext(ctx))
|
||||
if err != nil {
|
||||
return PreflightResponse{}, err
|
||||
}
|
||||
for _, item := range candidates {
|
||||
if item.Capacity-int(counts[item.ID]) >= len(detail.Impact) && (result.TargetShardID == "" || rendezvous(id, item.ID) > rendezvous(id, result.TargetShardID)) {
|
||||
result.TargetShardID, result.TargetShardName = item.ID, item.Name
|
||||
}
|
||||
}
|
||||
result.Checks = append(result.Checks, Check{Name: "目标容量", Passed: result.TargetShardID != "", Detail: map[bool]string{true: "存在可容纳全部受影响路径的健康目标分片", false: "没有可容纳全部受影响路径的健康目标分片"}[result.TargetShardID != ""]})
|
||||
result.Checks = append(result.Checks, Check{Name: "实施授权", Passed: false, Detail: "本工单只提供只读预检;实际迁移必须另建高风险工单并人工确认"})
|
||||
result.Ready = result.Checks[0].Passed && result.Checks[1].Passed
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *Service) impact(ctx context.Context, id string) ([]Impact, error) {
|
||||
var rows []Impact
|
||||
err := s.DB.WithContext(ctx).Table("sense_media_shard_assignments AS a").Select("r.id AS route_id, r.device_id, COALESCE(d.name, '') AS device_name, COALESCE(d.location, '') AS location, r.profile_token, r.path, r.desired, r.actual").Joins("JOIN sense_media_routes r ON r.id = a.route_id").Joins("LEFT JOIN sense_devices d ON d.id = r.device_id").Where("a.shard_id = ?", id).Order("d.name ASC, r.profile_token ASC").Scan(&rows).Error
|
||||
return rows, err
|
||||
}
|
||||
|
||||
func (s *Service) response(shard Shard, assigned int) Response {
|
||||
remaining := shard.Capacity - assigned
|
||||
if remaining < 0 {
|
||||
remaining = 0
|
||||
}
|
||||
stale := shard.LastProbeAt == nil || s.now().Sub(shard.LastProbeAt.UTC()) > staleAfter
|
||||
return Response{ID: shard.ID, Name: shard.Name, Mode: shard.Mode, Status: shard.Status, Detail: shard.Detail, Capacity: shard.Capacity, AssignedPaths: assigned, Remaining: remaining, LastProbeAt: shard.LastProbeAt, Stale: stale}
|
||||
}
|
||||
|
||||
func assignmentCounts(db *gorm.DB) (map[string]int64, error) {
|
||||
type row struct {
|
||||
ShardID string
|
||||
Count int64
|
||||
}
|
||||
var rows []row
|
||||
err := db.Model(&Assignment{}).Select("shard_id, COUNT(*) AS count").Group("shard_id").Scan(&rows).Error
|
||||
out := map[string]int64{}
|
||||
for _, r := range rows {
|
||||
out[r.ShardID] = r.Count
|
||||
}
|
||||
return out, err
|
||||
}
|
||||
func rendezvous(routeID, shardID string) uint64 {
|
||||
sum := sha256.Sum256([]byte(routeID + "\x00" + shardID))
|
||||
return binary.BigEndian.Uint64(sum[:8])
|
||||
}
|
||||
func (s *Service) now() time.Time {
|
||||
if s.Now != nil {
|
||||
return s.Now().UTC()
|
||||
}
|
||||
return time.Now().UTC()
|
||||
}
|
||||
|
||||
func (s *Service) ControlAPI(ctx context.Context, id string) (string, error) {
|
||||
var shard Shard
|
||||
if err := s.DB.WithContext(ctx).First(&shard, "id = ?", id).Error; err != nil {
|
||||
return "", fmt.Errorf("get media shard control endpoint: %w", err)
|
||||
}
|
||||
return shard.ControlAPI, nil
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package media_shard
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type testRoute struct{ ID, DeviceID, ProfileToken, Path, Desired, Actual string }
|
||||
|
||||
func (testRoute) TableName() string { return "sense_media_routes" }
|
||||
|
||||
type testDevice struct{ ID, Name, Location string }
|
||||
|
||||
func (testDevice) TableName() string { return "sense_devices" }
|
||||
|
||||
func testService(t *testing.T) (*Service, *gorm.DB) {
|
||||
t.Helper()
|
||||
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.AutoMigrate(&Shard{}, &Assignment{}, &testRoute{}, &testDevice{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now := time.Date(2026, 8, 28, 8, 0, 0, 0, time.UTC)
|
||||
service := NewService(db, func(context.Context, string) error { return nil })
|
||||
service.Now = func() time.Time { return now }
|
||||
return service, db
|
||||
}
|
||||
|
||||
func TestStableAssignmentUsesConfiguredCapacity(t *testing.T) {
|
||||
service, _ := testService(t)
|
||||
ctx := context.Background()
|
||||
if err := service.SyncSpecs(ctx, []Spec{{ID: "alpha", Name: "A", Mode: "external", ControlAPI: "http://127.0.0.1:9997", Capacity: 3}, {ID: "beta", Name: "B", Mode: "external", ControlAPI: "http://127.0.0.1:19997", Capacity: 2}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := service.RefreshAll(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first, err := service.EnsureAssignment(ctx, "route-1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := service.EnsureAssignment(ctx, "route-1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if first.ID != second.ID {
|
||||
t.Fatalf("assignment changed from %s to %s", first.ID, second.ID)
|
||||
}
|
||||
for _, id := range []string{"route-2", "route-3", "route-4", "route-5"} {
|
||||
if _, err = service.EnsureAssignment(ctx, id); err != nil {
|
||||
t.Fatalf("assign %s: %v", id, err)
|
||||
}
|
||||
}
|
||||
if _, err = service.EnsureAssignment(ctx, "route-6"); !errors.Is(err, ErrNoCapacity) {
|
||||
t.Fatalf("expected configured capacity error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFailureKeepsOwnershipAndLocatesImpact(t *testing.T) {
|
||||
service, db := testService(t)
|
||||
ctx := context.Background()
|
||||
if err := service.SyncSpecs(ctx, []Spec{{ID: "failed", Name: "故障分片", Mode: "external", ControlAPI: "http://127.0.0.1:9997", Capacity: 7}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := service.RefreshAll(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&testDevice{ID: "device-1", Name: "东门摄像机", Location: "东门"}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&testRoute{ID: "device-1:main", DeviceID: "device-1", ProfileToken: "main", Path: "sense_abc", Desired: "running", Actual: "ready"}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := service.EnsureAssignment(ctx, "device-1:main"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
service.Probe = func(context.Context, string) error { return errors.New("offline") }
|
||||
if err := service.RefreshAll(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
detail, err := service.Get(ctx, "failed")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if detail.Status != StatusFailed || len(detail.Impact) != 1 || detail.Impact[0].DeviceName != "东门摄像机" || detail.Impact[0].ProfileToken != "main" {
|
||||
t.Fatalf("unexpected detail: %+v", detail)
|
||||
}
|
||||
resolved, err := service.ResolveRoute(ctx, "device-1:main")
|
||||
if err != nil || resolved.ID != "failed" {
|
||||
t.Fatalf("failure rewrote ownership: %+v %v", resolved, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrationPreflightIsReadOnly(t *testing.T) {
|
||||
service, db := testService(t)
|
||||
ctx := context.Background()
|
||||
if err := service.SyncSpecs(ctx, []Spec{{ID: "source", Name: "源", Mode: "external", ControlAPI: "http://127.0.0.1:9997", Capacity: 1}, {ID: "target", Name: "目标", Mode: "external", ControlAPI: "http://127.0.0.1:19997", Capacity: 4}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := service.RefreshAll(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&testRoute{ID: "route", DeviceID: "d", ProfileToken: "p", Path: "path", Desired: "running", Actual: "ready"}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&Assignment{RouteID: "route", ShardID: "source", Algorithm: "rendezvous-v1", CreatedAt: service.now(), UpdatedAt: service.now()}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before := Assignment{}
|
||||
db.First(&before, "route_id = ?", "route")
|
||||
result, err := service.Preflight(ctx, "source")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
after := Assignment{}
|
||||
db.First(&after, "route_id = ?", "route")
|
||||
if !result.Ready || result.ExecutionAuthorized || !result.RequiresIssue || result.TargetShardID != "target" {
|
||||
t.Fatalf("unexpected preflight: %+v", result)
|
||||
}
|
||||
if before.ShardID != after.ShardID || !before.UpdatedAt.Equal(after.UpdatedAt) {
|
||||
t.Fatalf("preflight mutated assignment: before=%+v after=%+v", before, after)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package ops_alert
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/common"
|
||||
"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"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
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
|
||||
}
|
||||
response, 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, "读取运维告警详情 "+response.Alert.ID)
|
||||
e.OK(response, "查询成功")
|
||||
}
|
||||
func (e *API) Evaluate(c *gin.Context) {
|
||||
service, err := e.service(c)
|
||||
if err != nil {
|
||||
e.writeError(err)
|
||||
return
|
||||
}
|
||||
response, err := service.EvaluateSources(c.Request.Context())
|
||||
if err != nil {
|
||||
e.audit(c, service, "Evaluate", auditFailure, "健康事实刷新失败")
|
||||
e.writeError(err)
|
||||
return
|
||||
}
|
||||
e.audit(c, service, "Evaluate", auditSuccess, "刷新 Sense 内部健康事实")
|
||||
e.OK(response, "状态已刷新")
|
||||
}
|
||||
func (e *API) Acknowledge(c *gin.Context) { e.action(c, "Acknowledge") }
|
||||
func (e *API) Recover(c *gin.Context) { e.action(c, "Recover") }
|
||||
func (e *API) action(c *gin.Context, action string) {
|
||||
service, err := e.service(c)
|
||||
if err != nil {
|
||||
e.writeError(err)
|
||||
return
|
||||
}
|
||||
request := ActionRequest{}
|
||||
if err = e.MakeContext(c).Bind(&request, binding.JSON).Errors; err != nil {
|
||||
e.audit(c, service, action, auditFailure, "告警操作请求格式不正确")
|
||||
e.Error(http.StatusBadRequest, err, "请求格式不正确")
|
||||
return
|
||||
}
|
||||
var item Alert
|
||||
if action == "Acknowledge" {
|
||||
item, err = service.Acknowledge(c.Request.Context(), c.Param("id"), request, user.GetUserId(c))
|
||||
} else {
|
||||
item, err = service.Recover(c.Request.Context(), c.Param("id"), request, user.GetUserId(c))
|
||||
}
|
||||
if err != nil {
|
||||
e.audit(c, service, action, auditFailure, "运维告警状态操作被拒绝")
|
||||
e.writeError(err)
|
||||
return
|
||||
}
|
||||
e.audit(c, service, action, auditSuccess, action+" "+item.ID)
|
||||
e.OK(item, "操作成功")
|
||||
}
|
||||
func (e *API) audit(c *gin.Context, service *Service, action, status, remark string) {
|
||||
if 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()}); err != nil {
|
||||
api.GetRequestLogger(c).Errorf("ops alert audit failed: %s", err.Error())
|
||||
}
|
||||
}
|
||||
func (e *API) writeError(err error) {
|
||||
switch {
|
||||
case errors.Is(err, ErrInvalidFilter), errors.Is(err, ErrInvalidReason):
|
||||
e.Error(http.StatusBadRequest, err, err.Error())
|
||||
case errors.Is(err, ErrVersionConflict), errors.Is(err, ErrInvalidTransition), errors.Is(err, ErrRecoveryWindowOpen):
|
||||
e.Error(http.StatusConflict, err, err.Error())
|
||||
case errors.Is(err, ErrAlertNotFound):
|
||||
e.Error(http.StatusNotFound, err, err.Error())
|
||||
default:
|
||||
e.Error(http.StatusInternalServerError, err, "运维告警操作失败")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package ops_alert
|
||||
|
||||
import (
|
||||
adminModels "git.ilapage.cn/ila/yovision/Sense/server/app/admin/models"
|
||||
"gorm.io/gorm"
|
||||
"time"
|
||||
)
|
||||
|
||||
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: "ops_alert.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
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package ops_alert
|
||||
|
||||
import commonDto "git.ilapage.cn/ila/yovision/Sense/server/common/dto"
|
||||
|
||||
type PageRequest struct {
|
||||
commonDto.Pagination `search:"-"`
|
||||
AlertType string `form:"alertType"`
|
||||
State string `form:"state"`
|
||||
Keyword string `form:"keyword"`
|
||||
}
|
||||
|
||||
type Summary struct {
|
||||
Active int64 `json:"active"`
|
||||
Unacknowledged int64 `json:"unacknowledged"`
|
||||
Recovering int64 `json:"recovering"`
|
||||
}
|
||||
|
||||
type PageResponse struct {
|
||||
Summary Summary `json:"summary"`
|
||||
List []Alert `json:"list"`
|
||||
Count int64 `json:"count"`
|
||||
}
|
||||
|
||||
type DetailResponse struct {
|
||||
Alert Alert `json:"alert"`
|
||||
Transitions []Transition `json:"transitions"`
|
||||
}
|
||||
|
||||
type ActionRequest struct {
|
||||
ExpectedVersion int64 `json:"expectedVersion" binding:"required,min=1"`
|
||||
Reason string `json:"reason" binding:"required"`
|
||||
}
|
||||
|
||||
type EvaluateResponse struct {
|
||||
Observed int `json:"observed"`
|
||||
Abnormal int `json:"abnormal"`
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package ops_alert
|
||||
|
||||
import "time"
|
||||
|
||||
const (
|
||||
StateUnacknowledged = "unacknowledged"
|
||||
StateAcknowledged = "acknowledged"
|
||||
StateRecovering = "recovering"
|
||||
StateRecovered = "recovered"
|
||||
|
||||
TypeDeviceOffline = "device_offline"
|
||||
TypeAuthentication = "authentication_failed"
|
||||
TypeClockDrift = "clock_drift"
|
||||
TypeReconciliation = "reconciliation_failed"
|
||||
TypeMediaShard = "media_shard_failed"
|
||||
TypeControlTunnel = "control_tunnel_failed"
|
||||
)
|
||||
|
||||
type Alert struct {
|
||||
ID string `gorm:"size:36;primaryKey" json:"id"`
|
||||
Fingerprint string `gorm:"size:255;not null;uniqueIndex" json:"fingerprint"`
|
||||
AlertType string `gorm:"size:32;not null;index" json:"alertType"`
|
||||
Severity string `gorm:"size:16;not null;index" json:"severity"`
|
||||
ObjectType string `gorm:"size:32;not null;index" json:"objectType"`
|
||||
ObjectID string `gorm:"size:96;not null;index" json:"objectId"`
|
||||
ObjectName string `gorm:"size:128;not null" json:"objectName"`
|
||||
Location string `gorm:"size:255;not null;default:''" json:"location"`
|
||||
State string `gorm:"size:24;not null;index" json:"state"`
|
||||
Title string `gorm:"size:160;not null" json:"title"`
|
||||
Detail string `gorm:"size:512;not null" json:"detail"`
|
||||
NextAction string `gorm:"size:255;not null" json:"nextAction"`
|
||||
LastSourceVersion string `gorm:"size:128;not null;default:''" json:"-"`
|
||||
OccurrenceCount int `gorm:"not null;default:1" json:"occurrenceCount"`
|
||||
Cycle int `gorm:"not null;default:1" json:"cycle"`
|
||||
FirstSeenAt time.Time `gorm:"not null;index" json:"firstSeenAt"`
|
||||
LastSeenAt time.Time `gorm:"not null;index" json:"lastSeenAt"`
|
||||
HealthySince *time.Time `json:"healthySince,omitempty"`
|
||||
AcknowledgedAt *time.Time `json:"acknowledgedAt,omitempty"`
|
||||
AcknowledgedBy int `gorm:"not null;default:0" json:"acknowledgedBy,omitempty"`
|
||||
RecoveredAt *time.Time `json:"recoveredAt,omitempty"`
|
||||
RecoveredBy int `gorm:"not null;default:0" json:"recoveredBy,omitempty"`
|
||||
OperationalOnly bool `gorm:"not null;default:true" json:"operationalOnly"`
|
||||
Version int64 `gorm:"not null;default:1" json:"version"`
|
||||
CreatedAt time.Time `gorm:"not null" json:"createdAt"`
|
||||
UpdatedAt time.Time `gorm:"not null" json:"updatedAt"`
|
||||
}
|
||||
|
||||
func (Alert) TableName() string { return "sense_ops_alerts" }
|
||||
|
||||
type Transition struct {
|
||||
ID string `gorm:"size:36;primaryKey" json:"id"`
|
||||
AlertID string `gorm:"size:36;not null;index" json:"alertId"`
|
||||
Cycle int `gorm:"not null" json:"cycle"`
|
||||
Action string `gorm:"size:32;not null;index" json:"action"`
|
||||
FromState string `gorm:"size:24;not null;default:''" json:"fromState"`
|
||||
ToState string `gorm:"size:24;not null" json:"toState"`
|
||||
Reason string `gorm:"size:512;not null;default:''" json:"reason"`
|
||||
ActorUserID int `gorm:"not null;default:0" json:"actorUserId"`
|
||||
CreatedAt time.Time `gorm:"not null;index" json:"createdAt"`
|
||||
}
|
||||
|
||||
func (Transition) TableName() string { return "sense_ops_alert_transitions" }
|
||||
@@ -0,0 +1,326 @@
|
||||
package ops_alert
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
deviceModels "git.ilapage.cn/ila/yovision/Sense/server/app/sense/device/models"
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/edge_node"
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/media"
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/media_shard"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrAlertNotFound = errors.New("运维告警不存在")
|
||||
ErrInvalidFilter = errors.New("告警筛选条件不正确")
|
||||
ErrInvalidReason = errors.New("处理说明需为 6 至 256 个字符")
|
||||
ErrInvalidTransition = errors.New("当前告警状态不允许此操作")
|
||||
ErrVersionConflict = errors.New("告警状态已变化,请刷新后重试")
|
||||
ErrRecoveryWindowOpen = errors.New("健康恢复观察窗口尚未结束")
|
||||
)
|
||||
|
||||
const defaultRecoveryWindow = 5 * time.Minute
|
||||
|
||||
type Service struct {
|
||||
DB *gorm.DB
|
||||
Now func() time.Time
|
||||
RecoveryWindow time.Duration
|
||||
}
|
||||
|
||||
func NewService(db *gorm.DB) *Service {
|
||||
return &Service{DB: db, Now: time.Now, RecoveryWindow: defaultRecoveryWindow}
|
||||
}
|
||||
|
||||
func (s *Service) List(request PageRequest) (PageResponse, error) {
|
||||
if !validOptional(request.AlertType, alertTypes()) || !validOptional(request.State, states()) {
|
||||
return PageResponse{}, ErrInvalidFilter
|
||||
}
|
||||
query := s.DB.Model(&Alert{})
|
||||
if request.AlertType != "" {
|
||||
query = query.Where("alert_type = ?", request.AlertType)
|
||||
}
|
||||
if request.State != "" {
|
||||
query = query.Where("state = ?", request.State)
|
||||
}
|
||||
if keyword := strings.TrimSpace(request.Keyword); keyword != "" {
|
||||
like := "%" + keyword + "%"
|
||||
query = query.Where("object_name LIKE ? OR location LIKE ? OR title LIKE ? OR detail LIKE ?", like, like, like, like)
|
||||
}
|
||||
response := PageResponse{}
|
||||
if err := query.Count(&response.Count).Error; err != nil {
|
||||
return response, err
|
||||
}
|
||||
if err := query.Order("CASE WHEN state = 'unacknowledged' THEN 0 WHEN state = 'acknowledged' THEN 1 WHEN state = 'recovering' THEN 2 ELSE 3 END, last_seen_at DESC").Offset((request.GetPageIndex() - 1) * request.GetPageSize()).Limit(request.GetPageSize()).Find(&response.List).Error; err != nil {
|
||||
return response, err
|
||||
}
|
||||
active := []string{StateUnacknowledged, StateAcknowledged, StateRecovering}
|
||||
if err := s.DB.Model(&Alert{}).Where("state IN ?", active).Count(&response.Summary.Active).Error; err != nil {
|
||||
return response, err
|
||||
}
|
||||
if err := s.DB.Model(&Alert{}).Where("state = ?", StateUnacknowledged).Count(&response.Summary.Unacknowledged).Error; err != nil {
|
||||
return response, err
|
||||
}
|
||||
if err := s.DB.Model(&Alert{}).Where("state = ?", StateRecovering).Count(&response.Summary.Recovering).Error; err != nil {
|
||||
return response, err
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (s *Service) Get(id string) (DetailResponse, error) {
|
||||
response := DetailResponse{Transitions: []Transition{}}
|
||||
if err := s.DB.First(&response.Alert, "id = ?", id).Error; errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return response, ErrAlertNotFound
|
||||
} else if err != nil {
|
||||
return response, err
|
||||
}
|
||||
if err := s.DB.Where("alert_id = ?", id).Order("created_at DESC").Find(&response.Transitions).Error; err != nil {
|
||||
return response, err
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (s *Service) Acknowledge(ctx context.Context, id string, request ActionRequest, actor int) (Alert, error) {
|
||||
return s.transition(ctx, id, request, actor, StateUnacknowledged, StateAcknowledged, "acknowledge")
|
||||
}
|
||||
|
||||
func (s *Service) Recover(ctx context.Context, id string, request ActionRequest, actor int) (Alert, error) {
|
||||
return s.transition(ctx, id, request, actor, StateRecovering, StateRecovered, "recover")
|
||||
}
|
||||
|
||||
func (s *Service) transition(ctx context.Context, id string, request ActionRequest, actor int, expectedState, targetState, action string) (Alert, error) {
|
||||
reason := strings.TrimSpace(request.Reason)
|
||||
if utf8.RuneCountInString(reason) < 6 || utf8.RuneCountInString(reason) > 256 {
|
||||
return Alert{}, ErrInvalidReason
|
||||
}
|
||||
if request.ExpectedVersion < 1 {
|
||||
return Alert{}, ErrVersionConflict
|
||||
}
|
||||
now := s.Now().UTC()
|
||||
result := Alert{}
|
||||
err := s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var current Alert
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(¤t, "id = ?", id).Error; errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return ErrAlertNotFound
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
if current.Version != request.ExpectedVersion {
|
||||
return ErrVersionConflict
|
||||
}
|
||||
if current.State != expectedState {
|
||||
return ErrInvalidTransition
|
||||
}
|
||||
if action == "recover" && (current.HealthySince == nil || now.Sub(current.HealthySince.UTC()) < s.RecoveryWindow) {
|
||||
return ErrRecoveryWindowOpen
|
||||
}
|
||||
updates := map[string]any{"state": targetState, "version": current.Version + 1, "updated_at": now}
|
||||
if action == "acknowledge" {
|
||||
updates["acknowledged_at"], updates["acknowledged_by"] = now, actor
|
||||
} else {
|
||||
updates["recovered_at"], updates["recovered_by"] = now, actor
|
||||
}
|
||||
write := tx.Model(&Alert{}).Where("id = ? AND version = ? AND state = ?", id, current.Version, expectedState).Updates(updates)
|
||||
if write.Error != nil {
|
||||
return write.Error
|
||||
}
|
||||
if write.RowsAffected != 1 {
|
||||
return ErrVersionConflict
|
||||
}
|
||||
if err := tx.Create(&Transition{ID: uuid.NewString(), AlertID: id, Cycle: current.Cycle, Action: action, FromState: expectedState, ToState: targetState, Reason: reason, ActorUserID: actor, CreatedAt: now}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.First(&result, "id = ?", id).Error
|
||||
})
|
||||
return result, err
|
||||
}
|
||||
|
||||
type observation struct {
|
||||
Fingerprint, AlertType, Severity, ObjectType, ObjectID, ObjectName, Location, Title, Detail, NextAction, SourceVersion string
|
||||
Abnormal bool
|
||||
}
|
||||
|
||||
func (s *Service) EvaluateSources(ctx context.Context) (EvaluateResponse, error) {
|
||||
items, err := s.sourceObservations(ctx)
|
||||
if err != nil {
|
||||
return EvaluateResponse{}, err
|
||||
}
|
||||
response := EvaluateResponse{Observed: len(items)}
|
||||
for _, item := range items {
|
||||
if item.Abnormal {
|
||||
response.Abnormal++
|
||||
}
|
||||
if err = s.observe(ctx, item); err != nil {
|
||||
return response, err
|
||||
}
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (s *Service) observe(ctx context.Context, item observation) error {
|
||||
now := s.Now().UTC()
|
||||
return s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var current Alert
|
||||
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("fingerprint = ?", item.Fingerprint).First(¤t).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
if !item.Abnormal {
|
||||
return nil
|
||||
}
|
||||
current = Alert{ID: uuid.NewString(), Fingerprint: item.Fingerprint, AlertType: item.AlertType, Severity: item.Severity, ObjectType: item.ObjectType, ObjectID: item.ObjectID, ObjectName: item.ObjectName, Location: item.Location, State: StateUnacknowledged, Title: item.Title, Detail: item.Detail, NextAction: item.NextAction, LastSourceVersion: item.SourceVersion, OccurrenceCount: 1, Cycle: 1, FirstSeenAt: now, LastSeenAt: now, OperationalOnly: true, Version: 1, CreatedAt: now, UpdatedAt: now}
|
||||
if err = tx.Create(¤t).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Create(&Transition{ID: uuid.NewString(), AlertID: current.ID, Cycle: 1, Action: "detected", ToState: StateUnacknowledged, Reason: "健康事实首次满足告警规则", CreatedAt: now}).Error
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if item.Abnormal {
|
||||
updates := map[string]any{"severity": item.Severity, "object_name": item.ObjectName, "location": item.Location, "title": item.Title, "detail": item.Detail, "next_action": item.NextAction, "last_seen_at": now, "updated_at": now, "healthy_since": nil}
|
||||
if item.SourceVersion != current.LastSourceVersion {
|
||||
updates["last_source_version"], updates["occurrence_count"] = item.SourceVersion, current.OccurrenceCount+1
|
||||
}
|
||||
if current.State == StateRecovered {
|
||||
updates["state"], updates["cycle"], updates["first_seen_at"], updates["occurrence_count"], updates["acknowledged_at"], updates["acknowledged_by"], updates["recovered_at"], updates["recovered_by"] = StateUnacknowledged, current.Cycle+1, now, 1, nil, 0, nil, 0
|
||||
if err = tx.Create(&Transition{ID: uuid.NewString(), AlertID: current.ID, Cycle: current.Cycle + 1, Action: "reopened", FromState: StateRecovered, ToState: StateUnacknowledged, Reason: "健康事实再次异常", CreatedAt: now}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
} else if current.State == StateRecovering {
|
||||
target := StateUnacknowledged
|
||||
if current.AcknowledgedAt != nil {
|
||||
target = StateAcknowledged
|
||||
}
|
||||
updates["state"] = target
|
||||
if err = tx.Create(&Transition{ID: uuid.NewString(), AlertID: current.ID, Cycle: current.Cycle, Action: "relapsed", FromState: StateRecovering, ToState: target, Reason: "恢复观察期间再次异常", CreatedAt: now}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
updates["version"] = current.Version + 1
|
||||
return tx.Model(&Alert{}).Where("id = ? AND version = ?", current.ID, current.Version).Updates(updates).Error
|
||||
}
|
||||
if current.State == StateUnacknowledged || current.State == StateAcknowledged {
|
||||
if err = tx.Create(&Transition{ID: uuid.NewString(), AlertID: current.ID, Cycle: current.Cycle, Action: "health_restored", FromState: current.State, ToState: StateRecovering, Reason: "健康事实已恢复,进入稳定观察窗口", CreatedAt: now}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Model(&Alert{}).Where("id = ? AND version = ?", current.ID, current.Version).Updates(map[string]any{"state": StateRecovering, "healthy_since": now, "version": current.Version + 1, "updated_at": now}).Error
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
type admissionProjection struct {
|
||||
DeviceID, Status, Detail string
|
||||
CheckedAt time.Time
|
||||
}
|
||||
|
||||
func (admissionProjection) TableName() string { return "sense_admission_results" }
|
||||
|
||||
func (s *Service) sourceObservations(ctx context.Context) ([]observation, error) {
|
||||
now := s.Now().UTC()
|
||||
result := []observation{}
|
||||
var devices []deviceModels.Device
|
||||
if err := s.DB.WithContext(ctx).Find(&devices).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var admissions []admissionProjection
|
||||
if err := s.DB.WithContext(ctx).Find(&admissions).Error; err != nil && !isMissingTable(err) {
|
||||
return nil, err
|
||||
}
|
||||
byDevice := map[string]admissionProjection{}
|
||||
for _, item := range admissions {
|
||||
byDevice[item.DeviceID] = item
|
||||
}
|
||||
for _, device := range devices {
|
||||
if device.Status == deviceModels.StatusDisabled {
|
||||
continue
|
||||
}
|
||||
state, detail, observed := device.AdapterStatus, "尚未获得设备接入结果", device.UpdatedAt
|
||||
if item, ok := byDevice[device.ID]; ok {
|
||||
state, detail, observed = item.Status, item.Detail, item.CheckedAt
|
||||
}
|
||||
text := strings.ToLower(state + " " + detail)
|
||||
version := fmt.Sprintf("%d:%d", device.Version, observed.UnixNano())
|
||||
base := observation{ObjectType: "device", ObjectID: device.ID, ObjectName: device.Name, Location: device.Location, SourceVersion: version}
|
||||
result = append(result,
|
||||
withRule(base, TypeDeviceOffline, "high", "设备离线", detail, "检查设备供电、网络和地址后重新发现", containsAny(text, "offline", "unreachable", "timeout", "离线", "无法连接", "超时")),
|
||||
withRule(base, TypeAuthentication, "high", "设备认证失败", detail, "更新设备凭据后重新执行接入验证", containsAny(text, "authentication_failed", "unauthorized", "auth", "认证", "凭据")),
|
||||
withRule(base, TypeClockDrift, "medium", "设备时间漂移", detail, "校准设备时间后重新执行接入验证", containsAny(text, "clock_skew", "clock_drift", "time drift", "时间漂移", "时钟")),
|
||||
)
|
||||
}
|
||||
var routes []media.Route
|
||||
if err := s.DB.WithContext(ctx).Find(&routes).Error; err != nil && !isMissingTable(err) {
|
||||
return nil, err
|
||||
}
|
||||
for _, route := range routes {
|
||||
converged := (route.Desired == media.DesiredRunning && (route.Actual == "ready" || route.Actual == "waiting")) || (route.Desired == media.DesiredStopped && route.Actual == "stopped")
|
||||
base := observation{ObjectType: "media_route", ObjectID: route.ID, ObjectName: route.Path, SourceVersion: fmt.Sprintf("%d", route.Version)}
|
||||
result = append(result, withRule(base, TypeReconciliation, "medium", "媒体状态未收敛", route.Detail, "在运维中心核对期望态并执行受控重试", !converged))
|
||||
}
|
||||
var shards []media_shard.Shard
|
||||
if err := s.DB.WithContext(ctx).Find(&shards).Error; err != nil && !isMissingTable(err) {
|
||||
return nil, err
|
||||
}
|
||||
for _, shard := range shards {
|
||||
if shard.Status == media_shard.StatusDisabled {
|
||||
continue
|
||||
}
|
||||
stale := shard.LastProbeAt == nil || now.Sub(shard.LastProbeAt.UTC()) > 30*time.Second
|
||||
base := observation{ObjectType: "media_shard", ObjectID: shard.ID, ObjectName: shard.Name, SourceVersion: fmt.Sprintf("%d:%d", shard.ConfigVersion, timeValue(shard.LastProbeAt))}
|
||||
result = append(result, withRule(base, TypeMediaShard, "high", "媒体分片异常", shard.Detail, "检查 MediaMTX 进程和 Control API 后重新探测", shard.Status == media_shard.StatusFailed || stale))
|
||||
}
|
||||
var nodes []edge_node.Node
|
||||
if err := s.DB.WithContext(ctx).Find(&nodes).Error; err != nil && !isMissingTable(err) {
|
||||
return nil, err
|
||||
}
|
||||
for _, node := range nodes {
|
||||
base := observation{ObjectType: "edge_node", ObjectID: node.ID, ObjectName: node.Name, Location: node.Location, SourceVersion: fmt.Sprintf("%d:%d", node.ProjectionVersion, node.LastHeartbeatAt.UnixNano())}
|
||||
result = append(result,
|
||||
withRule(base, TypeDeviceOffline, "high", "边缘节点离线", "心跳超过 90 秒未更新", "检查节点进程、网络和机器身份后等待心跳恢复", now.Sub(node.LastHeartbeatAt.UTC()) > edge_node.HeartbeatTimeout),
|
||||
withRule(base, TypeControlTunnel, "high", "控制隧道异常", node.ControlTunnelDetail, "检查节点到 Sense 的控制通道,不影响业务预警数据", node.ControlTunnelStatus != edge_node.ChannelReady),
|
||||
)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func withRule(base observation, typ, severity, title, detail, next string, abnormal bool) observation {
|
||||
base.Fingerprint = typ + ":" + base.ObjectType + ":" + base.ObjectID
|
||||
base.AlertType = typ
|
||||
base.Severity = severity
|
||||
base.Title = title
|
||||
base.Detail = detail
|
||||
base.NextAction = next
|
||||
base.Abnormal = abnormal
|
||||
return base
|
||||
}
|
||||
func containsAny(value string, needles ...string) bool {
|
||||
for _, needle := range needles {
|
||||
if strings.Contains(value, needle) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
func isMissingTable(err error) bool {
|
||||
text := strings.ToLower(err.Error())
|
||||
return strings.Contains(text, "no such table") || strings.Contains(text, "does not exist")
|
||||
}
|
||||
func timeValue(value *time.Time) int64 {
|
||||
if value == nil {
|
||||
return 0
|
||||
}
|
||||
return value.UnixNano()
|
||||
}
|
||||
func validOptional(value string, allowed map[string]bool) bool { return value == "" || allowed[value] }
|
||||
func states() map[string]bool {
|
||||
return map[string]bool{StateUnacknowledged: true, StateAcknowledged: true, StateRecovering: true, StateRecovered: true}
|
||||
}
|
||||
func alertTypes() map[string]bool {
|
||||
return map[string]bool{TypeDeviceOffline: true, TypeAuthentication: true, TypeClockDrift: true, TypeReconciliation: true, TypeMediaShard: true, TypeControlTunnel: true}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
package ops_alert
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
adminModels "git.ilapage.cn/ila/yovision/Sense/server/app/admin/models"
|
||||
deviceModels "git.ilapage.cn/ila/yovision/Sense/server/app/sense/device/models"
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/edge_node"
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/media"
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/media_shard"
|
||||
)
|
||||
|
||||
func opsAlertTestService(t *testing.T) (*Service, *gorm.DB, time.Time) {
|
||||
t.Helper()
|
||||
db, err := gorm.Open(sqlite.Open("file:"+uuid.NewString()+"?mode=memory&cache=shared"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.AutoMigrate(&Alert{}, &Transition{}, &deviceModels.Device{}, &admissionProjection{}, &media.Route{}, &media_shard.Shard{}, &edge_node.Node{}, &adminModels.SysOperaLog{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now := time.Date(2026, 8, 28, 8, 0, 0, 0, time.UTC)
|
||||
service := NewService(db)
|
||||
service.Now = func() time.Time { return now }
|
||||
return service, db, now
|
||||
}
|
||||
|
||||
func TestEvaluateSourcesGeneratesSixTypesAndDeduplicates(t *testing.T) {
|
||||
service, db, now := opsAlertTestService(t)
|
||||
devices := []deviceModels.Device{
|
||||
{ID: "offline", Name: "东门摄像机", Location: "东门", Modality: "video", CapabilitiesJSON: "[]", Status: "pending", AdapterStatus: "verification_failed", Version: 1},
|
||||
{ID: "auth", Name: "仓库摄像机", Location: "仓库", Modality: "video", CapabilitiesJSON: "[]", Status: "pending", AdapterStatus: "verification_failed", Version: 2},
|
||||
{ID: "clock", Name: "南门摄像机", Location: "南门", Modality: "video", CapabilitiesJSON: "[]", Status: "pending", AdapterStatus: "verification_failed", Version: 3},
|
||||
}
|
||||
if err := db.Create(&devices).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
results := []admissionProjection{
|
||||
{DeviceID: "offline", Status: "device_offline", Detail: "设备离线,无法连接", CheckedAt: now},
|
||||
{DeviceID: "auth", Status: "authentication_failed", Detail: "设备认证失败", CheckedAt: now},
|
||||
{DeviceID: "clock", Status: "clock_skew", Detail: "设备时间漂移", CheckedAt: now},
|
||||
}
|
||||
if err := db.Create(&results).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&media.Route{ID: "route-1", DeviceID: "auth", ProfileToken: "main", Path: "sense_auth", Desired: "running", Actual: "apply_failed", Detail: "配置未收敛", Version: 4, UpdatedAt: now}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
probe := now
|
||||
if err := db.Create(&media_shard.Shard{ID: "shard-1", Name: "媒体服务 A", Mode: "local", ControlAPI: "http://127.0.0.1:9997", Capacity: 16, Status: media_shard.StatusFailed, Detail: "Control API 不可用", LastProbeAt: &probe, ConfigVersion: 1, CreatedAt: now, UpdatedAt: now}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&edge_node.Node{ID: "node-1", Name: "主楼边缘节点", Location: "机房", StartedAt: now.Add(-time.Hour), LastHeartbeatAt: now.Add(-10 * time.Second), LastCollectedAt: now.Add(-10 * time.Second), ControlTunnelStatus: edge_node.ChannelUnavailable, ControlTunnelDetail: "TLS 隧道断开", VideoPlaneStatus: edge_node.ChannelReady, ProjectionVersion: 7}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
response, err := service.EvaluateSources(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if response.Abnormal != 6 {
|
||||
t.Fatalf("abnormal=%d want 6", response.Abnormal)
|
||||
}
|
||||
var alerts []Alert
|
||||
if err = db.Find(&alerts).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(alerts) != 6 {
|
||||
t.Fatalf("alerts=%d want 6", len(alerts))
|
||||
}
|
||||
types := map[string]bool{}
|
||||
for _, item := range alerts {
|
||||
types[item.AlertType] = true
|
||||
if !item.OperationalOnly {
|
||||
t.Fatal("alert crossed operational boundary")
|
||||
}
|
||||
}
|
||||
for _, typ := range []string{TypeDeviceOffline, TypeAuthentication, TypeClockDrift, TypeReconciliation, TypeMediaShard, TypeControlTunnel} {
|
||||
if !types[typ] {
|
||||
t.Fatalf("missing type %s", typ)
|
||||
}
|
||||
}
|
||||
if _, err = service.EvaluateSources(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var count int64
|
||||
db.Model(&Alert{}).Count(&count)
|
||||
if count != 6 {
|
||||
t.Fatalf("duplicate active alerts: %d", count)
|
||||
}
|
||||
var offline Alert
|
||||
if err = db.First(&offline, "fingerprint = ?", TypeDeviceOffline+":device:offline").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if offline.OccurrenceCount != 1 {
|
||||
t.Fatalf("unchanged source counted twice: %d", offline.OccurrenceCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAcknowledgeRecoverAndReopenPreserveOneAlert(t *testing.T) {
|
||||
service, db, now := opsAlertTestService(t)
|
||||
device := deviceModels.Device{ID: "auth", Name: "仓库摄像机", Modality: "video", CapabilitiesJSON: "[]", Status: "pending", AdapterStatus: "verification_failed", Version: 1}
|
||||
if err := db.Create(&device).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result := admissionProjection{DeviceID: "auth", Status: "authentication_failed", Detail: "认证失败", CheckedAt: now}
|
||||
if err := db.Create(&result).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := service.EvaluateSources(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var alert Alert
|
||||
if err := db.First(&alert, "alert_type = ?", TypeAuthentication).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ack, err := service.Acknowledge(context.Background(), alert.ID, ActionRequest{ExpectedVersion: alert.Version, Reason: "已安排现场人员更新设备凭据"}, 7)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ack.State != StateAcknowledged || ack.AcknowledgedBy != 7 {
|
||||
t.Fatalf("unexpected acknowledgement: %+v", ack)
|
||||
}
|
||||
if _, err = service.Acknowledge(context.Background(), alert.ID, ActionRequest{ExpectedVersion: alert.Version, Reason: "并发重复确认不应成功"}, 8); !errors.Is(err, ErrVersionConflict) {
|
||||
t.Fatalf("want version conflict, got %v", err)
|
||||
}
|
||||
if err = db.Model(&admissionProjection{}).Where("device_id = ?", "auth").Updates(map[string]any{"status": "ready", "detail": "接入验证完成", "checked_at": now.Add(time.Minute)}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
service.Now = func() time.Time { return now.Add(time.Minute) }
|
||||
if _, err = service.EvaluateSources(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.First(&alert, "id = ?", alert.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if alert.State != StateRecovering || alert.HealthySince == nil {
|
||||
t.Fatalf("want recovering: %+v", alert)
|
||||
}
|
||||
if _, err = service.Recover(context.Background(), alert.ID, ActionRequest{ExpectedVersion: alert.Version, Reason: "恢复窗口尚未结束,不能关闭"}, 7); !errors.Is(err, ErrRecoveryWindowOpen) {
|
||||
t.Fatalf("want recovery window error, got %v", err)
|
||||
}
|
||||
service.Now = func() time.Time { return now.Add(7 * time.Minute) }
|
||||
recovered, err := service.Recover(context.Background(), alert.ID, ActionRequest{ExpectedVersion: alert.Version, Reason: "设备已稳定在线超过恢复观察窗口"}, 7)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if recovered.State != StateRecovered {
|
||||
t.Fatalf("state=%s", recovered.State)
|
||||
}
|
||||
if _, err = service.EvaluateSources(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.Model(&admissionProjection{}).Where("device_id = ?", "auth").Updates(map[string]any{"status": "authentication_failed", "detail": "认证再次失败", "checked_at": now.Add(8 * time.Minute)}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
service.Now = func() time.Time { return now.Add(8 * time.Minute) }
|
||||
if _, err = service.EvaluateSources(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var count int64
|
||||
db.Model(&Alert{}).Where("fingerprint = ?", recovered.Fingerprint).Count(&count)
|
||||
if count != 1 {
|
||||
t.Fatalf("reopen duplicated alert: %d", count)
|
||||
}
|
||||
if err = db.First(&alert, "id = ?", alert.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if alert.State != StateUnacknowledged || alert.Cycle != 2 {
|
||||
t.Fatalf("unexpected reopened alert: %+v", alert)
|
||||
}
|
||||
var transitions int64
|
||||
db.Model(&Transition{}).Where("alert_id = ?", alert.ID).Count(&transitions)
|
||||
if transitions < 5 {
|
||||
t.Fatalf("transitions=%d", transitions)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuditIsStoredOnlyInGoAdminOperationLog(t *testing.T) {
|
||||
_, db, now := opsAlertTestService(t)
|
||||
if err := WriteAudit(db, Audit{Action: "Acknowledge", Method: "POST", Status: "1", Username: "operator", UserID: 9, ClientIP: "127.0.0.1", Route: "/api/v1/ops-alerts/:id/acknowledge", Remark: "确认运维告警", At: now}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var log adminModels.SysOperaLog
|
||||
if err := db.First(&log).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if log.Title != "运维告警" || log.CreateBy != 9 {
|
||||
t.Fatalf("unexpected audit: %+v", log)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package outbox
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
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
|
||||
}
|
||||
response, 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, "读取可靠投递详情 "+response.Message.ID)
|
||||
e.OK(response, "查询成功")
|
||||
}
|
||||
|
||||
func (e *API) Requeue(c *gin.Context) {
|
||||
service, err := e.service(c)
|
||||
if err != nil {
|
||||
e.writeError(err)
|
||||
return
|
||||
}
|
||||
request := RequeueRequest{}
|
||||
if err = e.MakeContext(c).Bind(&request, binding.JSON).Errors; err != nil {
|
||||
e.audit(c, service, "Requeue", auditFailure, "死信重新排队请求格式不正确")
|
||||
e.Error(http.StatusBadRequest, err, "请求格式不正确")
|
||||
return
|
||||
}
|
||||
message, err := service.Requeue(c.Param("id"), request, user.GetUserId(c))
|
||||
if err != nil {
|
||||
e.audit(c, service, "Requeue", auditFailure, "死信重新排队被拒绝")
|
||||
e.writeError(err)
|
||||
return
|
||||
}
|
||||
e.audit(c, service, "Requeue", auditSuccess, "死信已重新排队 "+message.ID)
|
||||
e.OK(message, "已重新排队")
|
||||
}
|
||||
|
||||
func (e *API) audit(c *gin.Context, service *Service, action, status, remark string) {
|
||||
if err := WriteAudit(service.Orm, 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()}); err != nil {
|
||||
api.GetRequestLogger(c).Errorf("outbox audit failed: %s", err.Error())
|
||||
}
|
||||
}
|
||||
func (e *API) writeError(err error) {
|
||||
switch {
|
||||
case errors.Is(err, ErrInvalidInput):
|
||||
e.Error(http.StatusBadRequest, err, err.Error())
|
||||
case errors.Is(err, ErrNotFound):
|
||||
e.Error(http.StatusNotFound, err, err.Error())
|
||||
case errors.Is(err, ErrNotDead), errors.Is(err, ErrVersionConflict):
|
||||
e.Error(http.StatusConflict, err, err.Error())
|
||||
default:
|
||||
e.Error(http.StatusInternalServerError, err, "可靠投递操作失败")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package outbox
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
adminModels "git.ilapage.cn/ila/yovision/Sense/server/app/admin/models"
|
||||
)
|
||||
|
||||
const auditSuccess, auditFailure = "1", "2"
|
||||
|
||||
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: "outbox.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
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package outbox
|
||||
|
||||
import commonDTO "git.ilapage.cn/ila/yovision/Sense/server/common/dto"
|
||||
|
||||
type PageRequest struct {
|
||||
commonDTO.Pagination `search:"-"`
|
||||
State string `form:"state"`
|
||||
InternalType string `form:"internalType"`
|
||||
Keyword string `form:"keyword"`
|
||||
}
|
||||
|
||||
type Summary struct {
|
||||
Pending int64 `json:"pending"`
|
||||
Retry int64 `json:"retry"`
|
||||
Processing int64 `json:"processing"`
|
||||
Dead int64 `json:"dead"`
|
||||
}
|
||||
|
||||
type PageResponse struct {
|
||||
List []Message `json:"list"`
|
||||
Count int64 `json:"count"`
|
||||
Summary Summary `json:"summary"`
|
||||
}
|
||||
|
||||
type DetailResponse struct {
|
||||
Message Message `json:"message"`
|
||||
Attempts []Attempt `json:"attempts"`
|
||||
}
|
||||
|
||||
type RequeueRequest struct {
|
||||
ExpectedVersion int64 `json:"expectedVersion" binding:"required,min=1"`
|
||||
Reason string `json:"reason" binding:"required"`
|
||||
}
|
||||
|
||||
type EnqueueInput struct {
|
||||
InternalType string
|
||||
BusinessRef string
|
||||
IdempotencyKey string
|
||||
PayloadJSON []byte
|
||||
MaxAttempts int
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package outbox
|
||||
|
||||
import "time"
|
||||
|
||||
const (
|
||||
StatePending = "pending"
|
||||
StateProcessing = "processing"
|
||||
StateRetry = "retry"
|
||||
StateDead = "dead"
|
||||
StateDelivered = "delivered"
|
||||
)
|
||||
|
||||
// Message is a Sense-internal delivery record. PayloadJSON is deliberately
|
||||
// excluded from management APIs and is not a cross-product contract.
|
||||
type Message struct {
|
||||
ID string `gorm:"size:36;primaryKey" json:"id"`
|
||||
InternalType string `gorm:"size:64;not null;index" json:"internalType"`
|
||||
BusinessRef string `gorm:"size:128;not null;index" json:"businessRef"`
|
||||
IdempotencyKey string `gorm:"size:191;not null;uniqueIndex" json:"idempotencyKey"`
|
||||
PayloadJSON string `gorm:"column:payload;type:jsonb;not null" json:"-"`
|
||||
State string `gorm:"size:24;not null;index" json:"state"`
|
||||
AttemptCount int `gorm:"not null;default:0" json:"attemptCount"`
|
||||
MaxAttempts int `gorm:"not null;default:12" json:"maxAttempts"`
|
||||
AvailableAt time.Time `gorm:"not null;index" json:"availableAt"`
|
||||
LeaseOwner string `gorm:"size:128;not null;default:''" json:"leaseOwner,omitempty"`
|
||||
LeaseUntil *time.Time `gorm:"index" json:"leaseUntil,omitempty"`
|
||||
LastError string `gorm:"size:512;not null;default:''" json:"lastError,omitempty"`
|
||||
DeliveredAt *time.Time `json:"deliveredAt,omitempty"`
|
||||
Version int64 `gorm:"not null;default:1" json:"version"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
func (Message) TableName() string { return "sense_outbox_messages" }
|
||||
|
||||
// DeliveryRecord is permanent idempotency evidence. It is never deleted by
|
||||
// queue cleanup and prevents a delivered key from being processed again.
|
||||
type DeliveryRecord struct {
|
||||
IdempotencyKey string `gorm:"size:191;primaryKey" json:"idempotencyKey"`
|
||||
MessageID string `gorm:"size:36;not null;uniqueIndex" json:"messageId"`
|
||||
DeliveredAt time.Time `gorm:"not null" json:"deliveredAt"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
func (DeliveryRecord) TableName() string { return "sense_outbox_deliveries" }
|
||||
|
||||
type Attempt struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
MessageID string `gorm:"size:36;not null;index" json:"messageId"`
|
||||
Number int `gorm:"not null" json:"number"`
|
||||
Outcome string `gorm:"size:32;not null" json:"outcome"`
|
||||
Detail string `gorm:"size:512;not null;default:''" json:"detail"`
|
||||
Worker string `gorm:"size:128;not null;default:''" json:"worker,omitempty"`
|
||||
ActorUserID int `gorm:"not null;default:0" json:"actorUserId,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
func (Attempt) TableName() string { return "sense_outbox_attempts" }
|
||||
@@ -0,0 +1,92 @@
|
||||
package outbox
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestPostgresConcurrentWorkersDoNotClaimSameMessage(t *testing.T) {
|
||||
dsn := os.Getenv("SENSE_OUTBOX_TEST_DATABASE_URL")
|
||||
if dsn == "" {
|
||||
t.Skip("set SENSE_OUTBOX_TEST_DATABASE_URL to run the PostgreSQL multi-worker test")
|
||||
}
|
||||
base, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
schema := fmt.Sprintf("sense_outbox_78_%d", time.Now().UnixNano())
|
||||
if err = base.Exec("CREATE SCHEMA " + schema).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = base.Exec("DROP SCHEMA IF EXISTS " + schema + " CASCADE").Error })
|
||||
scoped, err := gorm.Open(postgres.Open(withSearchPath(dsn, schema)), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = scoped.AutoMigrate(&Message{}, &DeliveryRecord{}, &Attempt{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now := time.Date(2026, 8, 28, 12, 0, 0, 0, time.UTC)
|
||||
for index := 0; index < 20; index++ {
|
||||
if _, err = Enqueue(scoped, EnqueueInput{InternalType: "local_event_candidate", BusinessRef: fmt.Sprintf("event-%d", index), IdempotencyKey: fmt.Sprintf("event:%d:v1", index), PayloadJSON: []byte(`{}`)}, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
workers := []string{"worker-a", "worker-b"}
|
||||
results := make(chan []Message, len(workers))
|
||||
errorsCh := make(chan error, len(workers))
|
||||
var group sync.WaitGroup
|
||||
for _, worker := range workers {
|
||||
group.Add(1)
|
||||
go func(name string) {
|
||||
defer group.Done()
|
||||
relay := NewRelay(scoped)
|
||||
relay.Now = func() time.Time { return now }
|
||||
items, claimErr := relay.Claim(name, 20)
|
||||
if claimErr != nil {
|
||||
errorsCh <- claimErr
|
||||
return
|
||||
}
|
||||
results <- items
|
||||
}(worker)
|
||||
}
|
||||
group.Wait()
|
||||
close(results)
|
||||
close(errorsCh)
|
||||
for claimErr := range errorsCh {
|
||||
t.Fatal(claimErr)
|
||||
}
|
||||
seen := map[string]string{}
|
||||
for batch := range results {
|
||||
for _, item := range batch {
|
||||
if owner, exists := seen[item.ID]; exists {
|
||||
t.Fatalf("message %s claimed by %s and %s", item.ID, owner, item.LeaseOwner)
|
||||
}
|
||||
seen[item.ID] = item.LeaseOwner
|
||||
}
|
||||
}
|
||||
if len(seen) != 20 {
|
||||
t.Fatalf("claimed=%d want=20", len(seen))
|
||||
}
|
||||
}
|
||||
|
||||
func withSearchPath(dsn, schema string) string {
|
||||
if strings.Contains(dsn, "://") {
|
||||
parsed, err := url.Parse(dsn)
|
||||
if err == nil {
|
||||
query := parsed.Query()
|
||||
query.Set("search_path", schema)
|
||||
parsed.RawQuery = query.Encode()
|
||||
return parsed.String()
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(dsn) + " search_path=" + schema
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
package outbox
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrDuplicateIdempotency = errors.New("可靠投递幂等键已存在")
|
||||
ErrLeaseLost = errors.New("可靠投递租约已失效")
|
||||
)
|
||||
|
||||
func Enqueue(tx *gorm.DB, input EnqueueInput, now time.Time) (Message, error) {
|
||||
input.InternalType, input.BusinessRef, input.IdempotencyKey = strings.TrimSpace(input.InternalType), strings.TrimSpace(input.BusinessRef), strings.TrimSpace(input.IdempotencyKey)
|
||||
if input.InternalType == "" || len(input.InternalType) > 64 || input.BusinessRef == "" || len(input.BusinessRef) > 128 || input.IdempotencyKey == "" || len(input.IdempotencyKey) > 191 || !json.Valid(input.PayloadJSON) {
|
||||
return Message{}, ErrInvalidInput
|
||||
}
|
||||
if input.MaxAttempts == 0 {
|
||||
input.MaxAttempts = 12
|
||||
}
|
||||
if input.MaxAttempts < 1 || input.MaxAttempts > 100 {
|
||||
return Message{}, ErrInvalidInput
|
||||
}
|
||||
now = now.UTC()
|
||||
message := Message{ID: uuid.NewString(), InternalType: input.InternalType, BusinessRef: input.BusinessRef, IdempotencyKey: input.IdempotencyKey, PayloadJSON: string(input.PayloadJSON), State: StatePending, MaxAttempts: input.MaxAttempts, AvailableAt: now, Version: 1, CreatedAt: now, UpdatedAt: now}
|
||||
var existing int64
|
||||
if err := tx.Model(&Message{}).Where("idempotency_key = ?", input.IdempotencyKey).Count(&existing).Error; err != nil {
|
||||
return Message{}, fmt.Errorf("check outbox idempotency: %w", err)
|
||||
}
|
||||
if existing > 0 {
|
||||
return Message{}, ErrDuplicateIdempotency
|
||||
}
|
||||
if err := tx.Create(&message).Error; err != nil {
|
||||
return Message{}, fmt.Errorf("enqueue outbox message: %w", err)
|
||||
}
|
||||
return message, nil
|
||||
}
|
||||
|
||||
type Relay struct {
|
||||
DB *gorm.DB
|
||||
Now func() time.Time
|
||||
LeaseDuration time.Duration
|
||||
Backoff func(int) time.Duration
|
||||
}
|
||||
|
||||
func NewRelay(db *gorm.DB) *Relay {
|
||||
return &Relay{DB: db, Now: time.Now, LeaseDuration: 30 * time.Second, Backoff: defaultBackoff}
|
||||
}
|
||||
|
||||
func (r *Relay) Claim(worker string, limit int) ([]Message, error) {
|
||||
worker = strings.TrimSpace(worker)
|
||||
if worker == "" || len(worker) > 128 || limit < 1 || limit > 100 {
|
||||
return nil, ErrInvalidInput
|
||||
}
|
||||
now, leaseUntil := r.now(), r.now().Add(r.leaseDuration())
|
||||
claimed := make([]Message, 0, limit)
|
||||
err := r.DB.Transaction(func(tx *gorm.DB) error {
|
||||
var candidates []Message
|
||||
query := tx.Where("((state IN ?) AND available_at <= ?) OR (state = ? AND lease_until < ?)", []string{StatePending, StateRetry}, now, StateProcessing, now).Order("available_at ASC, created_at ASC").Limit(limit)
|
||||
if tx.Dialector.Name() == "postgres" {
|
||||
query = query.Clauses(clause.Locking{Strength: "UPDATE", Options: "SKIP LOCKED"})
|
||||
}
|
||||
if err := query.Find(&candidates).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for _, item := range candidates {
|
||||
result := tx.Model(&Message{}).Where("id = ? AND version = ?", item.ID, item.Version).Updates(map[string]interface{}{"state": StateProcessing, "lease_owner": worker, "lease_until": leaseUntil, "version": gorm.Expr("version + 1"), "updated_at": now})
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 1 {
|
||||
item.State, item.LeaseOwner, item.LeaseUntil, item.Version = StateProcessing, worker, &leaseUntil, item.Version+1
|
||||
claimed = append(claimed, item)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("claim outbox messages: %w", err)
|
||||
}
|
||||
return claimed, nil
|
||||
}
|
||||
|
||||
func (r *Relay) MarkSuccess(id, worker string) error {
|
||||
now := r.now()
|
||||
return r.DB.Transaction(func(tx *gorm.DB) error {
|
||||
var message Message
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&message, "id = ?", id).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
var delivered int64
|
||||
if err := tx.Model(&DeliveryRecord{}).Where("idempotency_key = ?", message.IdempotencyKey).Count(&delivered).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if delivered > 0 {
|
||||
return nil
|
||||
}
|
||||
if message.State != StateProcessing || message.LeaseOwner != worker || message.LeaseUntil == nil || !message.LeaseUntil.After(now) {
|
||||
return ErrLeaseLost
|
||||
}
|
||||
if err := tx.Create(&DeliveryRecord{IdempotencyKey: message.IdempotencyKey, MessageID: message.ID, DeliveredAt: now, CreatedAt: now}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
number := message.AttemptCount + 1
|
||||
if err := tx.Create(&Attempt{MessageID: message.ID, Number: number, Outcome: StateDelivered, Detail: "投递成功", Worker: worker, CreatedAt: now}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Model(&message).Updates(map[string]interface{}{"state": StateDelivered, "attempt_count": number, "delivered_at": now, "lease_owner": "", "lease_until": nil, "last_error": "", "version": gorm.Expr("version + 1"), "updated_at": now}).Error
|
||||
})
|
||||
}
|
||||
|
||||
func (r *Relay) MarkFailure(id, worker, detail string) error {
|
||||
now := r.now()
|
||||
detail = strings.TrimSpace(detail)
|
||||
if len(detail) > 512 {
|
||||
detail = detail[:512]
|
||||
}
|
||||
if detail == "" {
|
||||
detail = "投递失败"
|
||||
}
|
||||
return r.DB.Transaction(func(tx *gorm.DB) error {
|
||||
var message Message
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&message, "id = ?", id).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if message.State != StateProcessing || message.LeaseOwner != worker || message.LeaseUntil == nil || !message.LeaseUntil.After(now) {
|
||||
return ErrLeaseLost
|
||||
}
|
||||
nextAttempt := message.AttemptCount + 1
|
||||
state := StateRetry
|
||||
available := now.Add(r.backoff(nextAttempt))
|
||||
if nextAttempt >= message.MaxAttempts {
|
||||
state = StateDead
|
||||
available = now
|
||||
}
|
||||
if err := tx.Model(&message).Updates(map[string]interface{}{"state": state, "attempt_count": nextAttempt, "available_at": available, "lease_owner": "", "lease_until": nil, "last_error": detail, "version": gorm.Expr("version + 1"), "updated_at": now}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Create(&Attempt{MessageID: message.ID, Number: nextAttempt, Outcome: state, Detail: detail, Worker: worker, CreatedAt: now}).Error
|
||||
})
|
||||
}
|
||||
|
||||
func (r *Relay) now() time.Time {
|
||||
if r.Now != nil {
|
||||
return r.Now().UTC()
|
||||
}
|
||||
return time.Now().UTC()
|
||||
}
|
||||
func (r *Relay) leaseDuration() time.Duration {
|
||||
if r.LeaseDuration <= 0 {
|
||||
return 30 * time.Second
|
||||
}
|
||||
return r.LeaseDuration
|
||||
}
|
||||
func (r *Relay) backoff(attempt int) time.Duration {
|
||||
if r.Backoff != nil {
|
||||
return r.Backoff(attempt)
|
||||
}
|
||||
return defaultBackoff(attempt)
|
||||
}
|
||||
func defaultBackoff(attempt int) time.Duration {
|
||||
if attempt < 1 {
|
||||
attempt = 1
|
||||
}
|
||||
delay := time.Second * time.Duration(1<<min(attempt-1, 8))
|
||||
if delay > 5*time.Minute {
|
||||
return 5 * time.Minute
|
||||
}
|
||||
return delay
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package outbox
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func testDB(t *testing.T) *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, _ := db.DB()
|
||||
sqlDB.SetMaxOpenConns(1)
|
||||
if err = db.AutoMigrate(&Message{}, &DeliveryRecord{}, &Attempt{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func TestEnqueueClaimLeaseRecoveryBackoffAndIdempotentSuccess(t *testing.T) {
|
||||
db := testDB(t)
|
||||
now := time.Date(2026, 8, 28, 8, 0, 0, 0, time.UTC)
|
||||
message, err := Enqueue(db, EnqueueInput{InternalType: "local_event_candidate", BusinessRef: "event-1", IdempotencyKey: "local-event:event-1:v1", PayloadJSON: []byte(`{"event":"event-1"}`), MaxAttempts: 3}, now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = Enqueue(db, EnqueueInput{InternalType: "local_event_candidate", BusinessRef: "event-1", IdempotencyKey: message.IdempotencyKey, PayloadJSON: []byte(`{}`)}, now); !errors.Is(err, ErrDuplicateIdempotency) {
|
||||
t.Fatalf("expected duplicate error, got %v", err)
|
||||
}
|
||||
relay := NewRelay(db)
|
||||
relay.Now = func() time.Time { return now }
|
||||
relay.LeaseDuration = 10 * time.Second
|
||||
relay.Backoff = func(int) time.Duration { return 5 * time.Second }
|
||||
first, err := relay.Claim("worker-1", 1)
|
||||
if err != nil || len(first) != 1 {
|
||||
t.Fatalf("first claim=%#v err=%v", first, err)
|
||||
}
|
||||
second, err := relay.Claim("worker-2", 1)
|
||||
if err != nil || len(second) != 0 {
|
||||
t.Fatalf("concurrent claim=%#v err=%v", second, err)
|
||||
}
|
||||
now = now.Add(11 * time.Second)
|
||||
recovered, err := relay.Claim("worker-2", 1)
|
||||
if err != nil || len(recovered) != 1 || recovered[0].LeaseOwner != "worker-2" {
|
||||
t.Fatalf("recovered=%#v err=%v", recovered, err)
|
||||
}
|
||||
if err = relay.MarkFailure(message.ID, "worker-2", "temporary outage"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now = now.Add(4 * time.Second)
|
||||
waiting, _ := relay.Claim("worker-3", 1)
|
||||
if len(waiting) != 0 {
|
||||
t.Fatalf("claimed before backoff elapsed: %#v", waiting)
|
||||
}
|
||||
now = now.Add(2 * time.Second)
|
||||
retry, err := relay.Claim("worker-3", 1)
|
||||
if err != nil || len(retry) != 1 {
|
||||
t.Fatalf("retry=%#v err=%v", retry, err)
|
||||
}
|
||||
if err = relay.MarkSuccess(message.ID, "worker-3"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = relay.MarkSuccess(message.ID, "worker-3"); err != nil {
|
||||
t.Fatalf("idempotent success failed: %v", err)
|
||||
}
|
||||
var deliveries int64
|
||||
db.Model(&DeliveryRecord{}).Where("idempotency_key = ?", message.IdempotencyKey).Count(&deliveries)
|
||||
if deliveries != 1 {
|
||||
t.Fatalf("deliveries=%d", deliveries)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFailureBecomesDeadAndManualRequeuePreservesHistory(t *testing.T) {
|
||||
db := testDB(t)
|
||||
now := time.Date(2026, 8, 28, 9, 0, 0, 0, time.UTC)
|
||||
message, err := Enqueue(db, EnqueueInput{InternalType: "audit_projection", BusinessRef: "audit-1", IdempotencyKey: "audit:audit-1:v1", PayloadJSON: []byte(`{}`), MaxAttempts: 1}, now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
relay := NewRelay(db)
|
||||
relay.Now = func() time.Time { return now }
|
||||
claimed, _ := relay.Claim("worker", 1)
|
||||
if len(claimed) != 1 {
|
||||
t.Fatal("message not claimed")
|
||||
}
|
||||
if err = relay.MarkFailure(message.ID, "worker", "permanent failure"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
service := NewService(db)
|
||||
service.Now = func() time.Time { return now.Add(time.Minute) }
|
||||
detail, err := service.Get(message.ID)
|
||||
if err != nil || detail.Message.State != StateDead || len(detail.Attempts) != 1 {
|
||||
t.Fatalf("detail=%#v err=%v", detail, err)
|
||||
}
|
||||
requeued, err := service.Requeue(message.ID, RequeueRequest{ExpectedVersion: detail.Message.Version, Reason: "出口故障已排除"}, 7)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if requeued.State != StatePending {
|
||||
t.Fatalf("state=%s", requeued.State)
|
||||
}
|
||||
detail, _ = service.Get(message.ID)
|
||||
if len(detail.Attempts) != 2 || detail.Attempts[0].Outcome != "manual_requeue" || detail.Attempts[0].ActorUserID != 7 {
|
||||
t.Fatalf("attempt history=%#v", detail.Attempts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProductionCannotCreateTestSink(t *testing.T) {
|
||||
if _, err := NewTestSink("prod"); !errors.Is(err, ErrTestSinkForbidden) {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if _, err := NewTestSink("test"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package outbox
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
coreService "github.com/go-admin-team/go-admin-core/sdk/service"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidInput = errors.New("可靠投递请求不符合要求")
|
||||
ErrNotFound = errors.New("可靠投递记录不存在")
|
||||
ErrNotDead = errors.New("仅死信记录可以重新排队")
|
||||
ErrVersionConflict = errors.New("记录已变化,请刷新后重试")
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
coreService.Service
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
func NewService(db *gorm.DB) *Service {
|
||||
return &Service{Service: coreService.Service{Orm: db}, Now: time.Now}
|
||||
}
|
||||
|
||||
func (s *Service) List(request PageRequest) (PageResponse, error) {
|
||||
request.State = strings.TrimSpace(request.State)
|
||||
request.InternalType = strings.TrimSpace(request.InternalType)
|
||||
request.Keyword = strings.TrimSpace(request.Keyword)
|
||||
if request.GetPageSize() > 100 || utf8.RuneCountInString(request.Keyword) > 128 || len(request.InternalType) > 64 || (request.State != "" && !validState(request.State)) {
|
||||
return PageResponse{}, ErrInvalidInput
|
||||
}
|
||||
query := s.Orm.Model(&Message{})
|
||||
if request.State != "" {
|
||||
query = query.Where("state = ?", request.State)
|
||||
}
|
||||
if request.InternalType != "" {
|
||||
query = query.Where("internal_type = ?", request.InternalType)
|
||||
}
|
||||
if request.Keyword != "" {
|
||||
pattern := "%" + strings.ToLower(request.Keyword) + "%"
|
||||
query = query.Where("LOWER(id) LIKE ? OR LOWER(idempotency_key) LIKE ? OR LOWER(business_ref) LIKE ?", pattern, pattern, pattern)
|
||||
}
|
||||
var response PageResponse
|
||||
if err := query.Count(&response.Count).Error; err != nil {
|
||||
return PageResponse{}, fmt.Errorf("count outbox messages: %w", err)
|
||||
}
|
||||
if err := query.Order("created_at DESC, id DESC").Limit(request.GetPageSize()).Offset((request.GetPageIndex() - 1) * request.GetPageSize()).Find(&response.List).Error; err != nil {
|
||||
return PageResponse{}, fmt.Errorf("list outbox messages: %w", err)
|
||||
}
|
||||
for state, target := range map[string]*int64{StatePending: &response.Summary.Pending, StateRetry: &response.Summary.Retry, StateProcessing: &response.Summary.Processing, StateDead: &response.Summary.Dead} {
|
||||
if err := s.Orm.Model(&Message{}).Where("state = ?", state).Count(target).Error; err != nil {
|
||||
return PageResponse{}, fmt.Errorf("summarize outbox: %w", err)
|
||||
}
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (s *Service) Get(id string) (DetailResponse, error) {
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" || len(id) > 64 {
|
||||
return DetailResponse{}, ErrInvalidInput
|
||||
}
|
||||
var message Message
|
||||
if err := s.Orm.First(&message, "id = ?", id).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return DetailResponse{}, ErrNotFound
|
||||
}
|
||||
return DetailResponse{}, fmt.Errorf("get outbox message: %w", err)
|
||||
}
|
||||
var attempts []Attempt
|
||||
if err := s.Orm.Where("message_id = ?", id).Order("created_at DESC, id DESC").Limit(50).Find(&attempts).Error; err != nil {
|
||||
return DetailResponse{}, fmt.Errorf("list outbox attempts: %w", err)
|
||||
}
|
||||
return DetailResponse{Message: message, Attempts: attempts}, nil
|
||||
}
|
||||
|
||||
func (s *Service) Requeue(id string, request RequeueRequest, actorUserID int) (Message, error) {
|
||||
reason := strings.TrimSpace(request.Reason)
|
||||
if utf8.RuneCountInString(reason) < 6 || utf8.RuneCountInString(reason) > 256 || request.ExpectedVersion < 1 {
|
||||
return Message{}, ErrInvalidInput
|
||||
}
|
||||
now := s.now()
|
||||
var result Message
|
||||
err := s.Orm.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&result, "id = ?", strings.TrimSpace(id)).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return ErrNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
if result.Version != request.ExpectedVersion {
|
||||
return ErrVersionConflict
|
||||
}
|
||||
if result.State != StateDead {
|
||||
return ErrNotDead
|
||||
}
|
||||
result.State, result.AvailableAt, result.LastError = StatePending, now, ""
|
||||
result.LeaseOwner, result.LeaseUntil, result.Version = "", nil, result.Version+1
|
||||
if err := tx.Save(&result).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Create(&Attempt{MessageID: result.ID, Number: result.AttemptCount, Outcome: "manual_requeue", Detail: reason, ActorUserID: actorUserID, CreatedAt: now}).Error
|
||||
})
|
||||
if err != nil {
|
||||
return Message{}, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *Service) now() time.Time {
|
||||
if s.Now != nil {
|
||||
return s.Now().UTC()
|
||||
}
|
||||
return time.Now().UTC()
|
||||
}
|
||||
func validState(value string) bool {
|
||||
return value == StatePending || value == StateProcessing || value == StateRetry || value == StateDead || value == StateDelivered
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package outbox
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var ErrTestSinkForbidden = errors.New("production 模式禁止启用测试接收器")
|
||||
|
||||
type Sink interface{ Deliver(Message) error }
|
||||
|
||||
type TestSink struct{ Delivered []string }
|
||||
|
||||
func (s *TestSink) Deliver(message Message) error {
|
||||
s.Delivered = append(s.Delivered, message.IdempotencyKey)
|
||||
return nil
|
||||
}
|
||||
|
||||
func NewTestSink(applicationMode string) (*TestSink, error) {
|
||||
if strings.EqualFold(strings.TrimSpace(applicationMode), "prod") || strings.EqualFold(strings.TrimSpace(applicationMode), "production") {
|
||||
return nil, ErrTestSinkForbidden
|
||||
}
|
||||
return &TestSink{}, nil
|
||||
}
|
||||
@@ -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, "批量开通操作失败")
|
||||
}
|
||||
|
||||
@@ -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", "设备开通失败,请检查网络、地址和凭据后重试"
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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, "容量与配额操作失败")
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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" }
|
||||
@@ -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,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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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/edge_node"
|
||||
"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), migrateSenseEdgeNode)
|
||||
}
|
||||
|
||||
func migrateSenseEdgeNode(db *gorm.DB, version string) error {
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.AutoMigrate(&edge_node.Node{}, &edge_node.Event{}); 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: "SenseEdgeNode", Title: "边缘节点", Icon: "monitor", Path: "edge-node", Paths: fmt.Sprintf("/0/%d", root.MenuId), MenuType: "C", Permission: "sense:edge-node:list", ParentId: root.MenuId, Component: "/sense/edge-node/index", Sort: 9, Visible: "0", IsFrame: "1"})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
detail, err := ensureDeviceMenu(tx, migrationModels.SysMenu{MenuName: "SenseEdgeNodeDetail", Title: "查看节点详情", MenuType: "F", Action: "GET", Permission: "sense:edge-node: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/edge-nodes", "GET"}, {"/api/v1/edge-nodes/: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,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/edge_node"
|
||||
migrationModels "git.ilapage.cn/ila/yovision/Sense/server/cmd/migrate/migration/models"
|
||||
common "git.ilapage.cn/ila/yovision/Sense/server/common/models"
|
||||
)
|
||||
|
||||
func TestEdgeNodeMigrationAddsTablesAndReadOnlyPermissions(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 = "2026082813000_edge_node.go"
|
||||
if err = migrateSenseEdgeNode(db, version); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !db.Migrator().HasTable(&edge_node.Node{}) || !db.Migrator().HasTable(&edge_node.Event{}) {
|
||||
t.Fatal("edge node tables missing")
|
||||
}
|
||||
var menus, reads, writes, applied int64
|
||||
db.Model(&migrationModels.SysMenu{}).Where("menu_name LIKE ?", "SenseEdgeNode%").Count(&menus)
|
||||
db.Model(&deviceCasbinRule{}).Where("v1 LIKE ? AND v2 = ?", "/api/v1/edge-nodes%", "GET").Count(&reads)
|
||||
db.Model(&deviceCasbinRule{}).Where("v1 LIKE ? AND v2 <> ?", "/api/v1/edge-nodes%", "GET").Count(&writes)
|
||||
db.Model(&common.Migration{}).Where("version = ?", version).Count(&applied)
|
||||
if menus != 2 || reads != 6 || writes != 0 || applied != 1 {
|
||||
t.Fatalf("menus=%d reads=%d writes=%d applied=%d", menus, reads, writes, applied)
|
||||
}
|
||||
var fixtures int64
|
||||
db.Model(&edge_node.Node{}).Count(&fixtures)
|
||||
if fixtures != 0 {
|
||||
t.Fatalf("migration seeded %d fixtures", fixtures)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEdgeNodeMigrationOnPostgres(t *testing.T) {
|
||||
dsn := os.Getenv("SENSE_EDGE_NODE_MIGRATION_TEST_DATABASE_URL")
|
||||
if dsn == "" {
|
||||
t.Skip("set SENSE_EDGE_NODE_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_edge_node_76_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 = migrateSenseEdgeNode(db, "2026082813000_edge_node.go"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !db.Migrator().HasTable(&edge_node.Node{}) || !db.Migrator().HasTable(&edge_node.Event{}) {
|
||||
t.Fatal("edge node tables missing")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package version
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"runtime"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/media_shard"
|
||||
"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), migrateSenseMediaShard)
|
||||
}
|
||||
|
||||
func migrateSenseMediaShard(db *gorm.DB, version string) error {
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.AutoMigrate(&media_shard.Shard{}, &media_shard.Assignment{}); 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: "SenseMediaShard", Title: "媒体分片", Icon: "connection", Path: "media-shard", Paths: fmt.Sprintf("/0/%d", root.MenuId), MenuType: "C", Permission: "sense:media-shard:list", ParentId: root.MenuId, Component: "/sense/media-shard/index", Sort: 10, Visible: "0", IsFrame: "1"})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
detail, err := ensureDeviceMenu(tx, migrationModels.SysMenu{MenuName: "SenseMediaShardDetail", Title: "查看分片详情", MenuType: "F", Action: "GET", Permission: "sense:media-shard: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
|
||||
}
|
||||
preflight, err := ensureDeviceMenu(tx, migrationModels.SysMenu{MenuName: "SenseMediaShardPreflight", Title: "迁移预检", MenuType: "F", Action: "GET", Permission: "sense:media-shard:preflight", ParentId: page.MenuId, Paths: fmt.Sprintf("/0/%d/%d", root.MenuId, page.MenuId), Sort: 2, 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, preflight}); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, policy := range [][2]string{{"/api/v1/media-shards", "GET"}, {"/api/v1/media-shards/:id", "GET"}, {"/api/v1/media-shards/:id/migration-preflight", "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,86 @@
|
||||
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/media_shard"
|
||||
migrationModels "git.ilapage.cn/ila/yovision/Sense/server/cmd/migrate/migration/models"
|
||||
common "git.ilapage.cn/ila/yovision/Sense/server/common/models"
|
||||
)
|
||||
|
||||
func TestMediaShardMigrationAddsReadOnlySurfaceWithoutFixtures(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 = "2026082814000_media_shard.go"
|
||||
if err = migrateSenseMediaShard(db, version); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !db.Migrator().HasTable(&media_shard.Shard{}) || !db.Migrator().HasTable(&media_shard.Assignment{}) {
|
||||
t.Fatal("media shard tables missing")
|
||||
}
|
||||
var menus, reads, writes, fixtures, applied int64
|
||||
db.Model(&migrationModels.SysMenu{}).Where("menu_name LIKE ?", "SenseMediaShard%").Count(&menus)
|
||||
db.Model(&deviceCasbinRule{}).Where("v1 LIKE ? AND v2 = ?", "/api/v1/media-shards%", "GET").Count(&reads)
|
||||
db.Model(&deviceCasbinRule{}).Where("v1 LIKE ? AND v2 <> ?", "/api/v1/media-shards%", "GET").Count(&writes)
|
||||
db.Model(&media_shard.Shard{}).Count(&fixtures)
|
||||
db.Model(&common.Migration{}).Where("version = ?", version).Count(&applied)
|
||||
if menus != 3 || reads != 9 || writes != 0 || fixtures != 0 || applied != 1 {
|
||||
t.Fatalf("menus=%d reads=%d writes=%d fixtures=%d applied=%d", menus, reads, writes, fixtures, applied)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMediaShardMigrationOnPostgres(t *testing.T) {
|
||||
dsn := os.Getenv("SENSE_MEDIA_SHARD_MIGRATION_TEST_DATABASE_URL")
|
||||
if dsn == "" {
|
||||
t.Skip("set SENSE_MEDIA_SHARD_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_media_shard_77_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 = migrateSenseMediaShard(db, "2026082814000_media_shard.go"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !db.Migrator().HasTable(&media_shard.Shard{}) || !db.Migrator().HasTable(&media_shard.Assignment{}) {
|
||||
t.Fatal("media shard tables missing")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package version
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"runtime"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/outbox"
|
||||
"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), migrateSenseOutbox)
|
||||
}
|
||||
|
||||
func migrateSenseOutbox(db *gorm.DB, version string) error {
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.AutoMigrate(&outbox.Message{}, &outbox.DeliveryRecord{}, &outbox.Attempt{}); 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: "SenseOutbox", Title: "可靠投递", Icon: "connection", Path: "outbox", Paths: fmt.Sprintf("/0/%d", root.MenuId), MenuType: "C", Permission: "sense:outbox:list", ParentId: root.MenuId, Component: "/sense/outbox/index", Sort: 11, Visible: "0", IsFrame: "1"})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
detail, err := ensureDeviceMenu(tx, migrationModels.SysMenu{MenuName: "SenseOutboxDetail", Title: "查看投递详情", MenuType: "F", Action: "GET", Permission: "sense:outbox: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
|
||||
}
|
||||
requeue, err := ensureDeviceMenu(tx, migrationModels.SysMenu{MenuName: "SenseOutboxRequeue", Title: "死信重新排队", MenuType: "F", Action: "POST", Permission: "sense:outbox:requeue", ParentId: page.MenuId, Paths: fmt.Sprintf("/0/%d/%d", root.MenuId, page.MenuId), Sort: 2, 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/outbox", "GET"}, {"/api/v1/outbox/: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
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, role := range []string{"implementation_operator", "site_admin"} {
|
||||
if err = attachDeviceRole(tx, role, []migrationModels.SysMenu{requeue}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&deviceCasbinRule{Ptype: "p", V0: role, V1: "/api/v1/outbox/:id/requeue", 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,41 @@
|
||||
package version
|
||||
|
||||
import (
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/outbox"
|
||||
migrationModels "git.ilapage.cn/ila/yovision/Sense/server/cmd/migrate/migration/models"
|
||||
common "git.ilapage.cn/ila/yovision/Sense/server/common/models"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestOutboxMigrationAddsRBACWithoutFixtures(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 = "2026082815000_outbox.go"
|
||||
if err = migrateSenseOutbox(db, version); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !db.Migrator().HasTable(&outbox.Message{}) || !db.Migrator().HasTable(&outbox.DeliveryRecord{}) || !db.Migrator().HasTable(&outbox.Attempt{}) {
|
||||
t.Fatal("outbox tables missing")
|
||||
}
|
||||
var menus, reads, writes, fixtures, applied int64
|
||||
db.Model(&migrationModels.SysMenu{}).Where("menu_name LIKE ?", "SenseOutbox%").Count(&menus)
|
||||
db.Model(&deviceCasbinRule{}).Where("v1 LIKE ? AND v2 = ?", "/api/v1/outbox%", "GET").Count(&reads)
|
||||
db.Model(&deviceCasbinRule{}).Where("v1 = ? AND v2 = ?", "/api/v1/outbox/:id/requeue", "POST").Count(&writes)
|
||||
db.Model(&outbox.Message{}).Count(&fixtures)
|
||||
db.Model(&common.Migration{}).Where("version = ?", version).Count(&applied)
|
||||
if menus != 3 || reads != 6 || writes != 2 || fixtures != 0 || applied != 1 {
|
||||
t.Fatalf("menus=%d reads=%d writes=%d fixtures=%d applied=%d", menus, reads, writes, fixtures, applied)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package version
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/ops_alert"
|
||||
"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"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
func init() {
|
||||
_, fileName, _, _ := runtime.Caller(0)
|
||||
migration.Migrate.SetVersion(migration.GetFilename(fileName), migrateSenseOpsAlert)
|
||||
}
|
||||
|
||||
func migrateSenseOpsAlert(db *gorm.DB, version string) error {
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.AutoMigrate(&ops_alert.Alert{}, &ops_alert.Transition{}); 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: "SenseOpsAlert", Title: "运维告警", Icon: "warning", Path: "ops-alert", Paths: fmt.Sprintf("/0/%d", root.MenuId), MenuType: "C", Permission: "sense:ops-alert:list", ParentId: root.MenuId, Component: "/sense/ops-alert/index", Sort: 11, Visible: "0", IsFrame: "1"})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
detail, err := ensureDeviceMenu(tx, migrationModels.SysMenu{MenuName: "SenseOpsAlertDetail", Title: "查看告警详情", MenuType: "F", Action: "GET", Permission: "sense:ops-alert: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
|
||||
}
|
||||
refresh, err := ensureDeviceMenu(tx, migrationModels.SysMenu{MenuName: "SenseOpsAlertEvaluate", Title: "刷新告警状态", MenuType: "F", Action: "POST", Permission: "sense:ops-alert:evaluate", ParentId: page.MenuId, Paths: fmt.Sprintf("/0/%d/%d", root.MenuId, page.MenuId), Sort: 2, Visible: "1", IsFrame: "1"})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ack, err := ensureDeviceMenu(tx, migrationModels.SysMenu{MenuName: "SenseOpsAlertAcknowledge", Title: "确认运维告警", MenuType: "F", Action: "POST", Permission: "sense:ops-alert:acknowledge", ParentId: page.MenuId, Paths: fmt.Sprintf("/0/%d/%d", root.MenuId, page.MenuId), Sort: 3, Visible: "1", IsFrame: "1"})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
recover, err := ensureDeviceMenu(tx, migrationModels.SysMenu{MenuName: "SenseOpsAlertRecover", Title: "确认告警恢复", MenuType: "F", Action: "POST", Permission: "sense:ops-alert:recover", ParentId: page.MenuId, Paths: fmt.Sprintf("/0/%d/%d", root.MenuId, page.MenuId), Sort: 4, Visible: "1", IsFrame: "1"})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
readPolicies := [][2]string{{"/api/v1/ops-alerts", "GET"}, {"/api/v1/ops-alerts/: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{refresh, ack, recover}); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, policy := range [][2]string{{"/api/v1/ops-alerts/evaluate", "POST"}, {"/api/v1/ops-alerts/:id/acknowledge", "POST"}, {"/api/v1/ops-alerts/:id/recover", "POST"}} {
|
||||
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,39 @@
|
||||
package version
|
||||
|
||||
import (
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/ops_alert"
|
||||
migrationModels "git.ilapage.cn/ila/yovision/Sense/server/cmd/migrate/migration/models"
|
||||
common "git.ilapage.cn/ila/yovision/Sense/server/common/models"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSenseOpsAlertMigrationRBAC(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 = "2026082816000_ops_alert.go"
|
||||
if err = migrateSenseOpsAlert(db, version); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var pages, viewerWrites, operatorWrites int64
|
||||
db.Model(&migrationModels.SysMenu{}).Where("permission = ?", "sense:ops-alert:list").Count(&pages)
|
||||
db.Model(&deviceCasbinRule{}).Where("v0 = ? AND v1 LIKE ? AND v2 = ?", "viewer", "/api/v1/ops-alerts%", "POST").Count(&viewerWrites)
|
||||
db.Model(&deviceCasbinRule{}).Where("v0 = ? AND v1 LIKE ? AND v2 = ?", "implementation_operator", "/api/v1/ops-alerts%", "POST").Count(&operatorWrites)
|
||||
if pages != 1 || viewerWrites != 0 || operatorWrites != 3 {
|
||||
t.Fatalf("unexpected RBAC page=%d viewerWrites=%d operatorWrites=%d", pages, viewerWrites, operatorWrites)
|
||||
}
|
||||
if !db.Migrator().HasTable(&ops_alert.Alert{}) || !db.Migrator().HasTable(&ops_alert.Transition{}) {
|
||||
t.Fatal("ops alert tables missing")
|
||||
}
|
||||
}
|
||||
@@ -9,3 +9,5 @@ SENSE_ONVIF_ALLOWED_CIDRS=
|
||||
SENSE_MEDIAMTX_BINARY=
|
||||
SENSE_MEDIAMTX_CONFIG=
|
||||
SENSE_MEDIAMTX_API=http://127.0.0.1:9997
|
||||
SENSE_MEDIAMTX_CAPACITY=
|
||||
SENSE_MEDIAMTX_SHARDS_FILE=
|
||||
|
||||
@@ -10,6 +10,9 @@ import (
|
||||
"time"
|
||||
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/media"
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/media_shard"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func freeAddress(t *testing.T) string {
|
||||
@@ -25,6 +28,81 @@ func freeAddress(t *testing.T) string {
|
||||
return address
|
||||
}
|
||||
|
||||
func startTestMediaMTX(t *testing.T, binary string) string {
|
||||
t.Helper()
|
||||
apiAddress, rtspAddress := freeAddress(t), freeAddress(t)
|
||||
configPath := filepath.Join(t.TempDir(), "mediamtx.yml")
|
||||
config := fmt.Sprintf("logLevel: warn\napi: true\napiAddress: %s\nrtspAddress: %s\nrtspTransports: [tcp]\nrtmp: false\nhls: false\nwebrtc: false\nsrt: false\nmoq: false\nplayback: false\npaths: {}\n", apiAddress, rtspAddress)
|
||||
if err := os.WriteFile(configPath, []byte(config), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
supervisor := media.NewSupervisor(binary, configPath)
|
||||
if err := supervisor.Start(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
_ = supervisor.Stop(ctx)
|
||||
})
|
||||
controller, err := media.NewHTTPController("http://" + apiAddress)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
deadline := time.Now().Add(8 * time.Second)
|
||||
for {
|
||||
if err = controller.Health(context.Background()); err == nil {
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("MediaMTX did not become ready: %v", err)
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
return "http://" + apiAddress
|
||||
}
|
||||
|
||||
func TestTwoRealMediaMTXShardsAreIndependentlyUsable(t *testing.T) {
|
||||
binary := os.Getenv("SENSE_MEDIAMTX_TEST_BINARY")
|
||||
if binary == "" {
|
||||
t.Skip("set SENSE_MEDIAMTX_TEST_BINARY to run the real MediaMTX integration")
|
||||
}
|
||||
first, second := startTestMediaMTX(t, binary), startTestMediaMTX(t, binary)
|
||||
db, err := gorm.Open(sqlite.Open("file:real_media_shards?mode=memory&cache=shared"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.AutoMigrate(&media_shard.Shard{}, &media_shard.Assignment{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
service := media_shard.NewService(db, func(ctx context.Context, endpoint string) error {
|
||||
controller, buildErr := media.NewHTTPController(endpoint)
|
||||
if buildErr != nil {
|
||||
return buildErr
|
||||
}
|
||||
return controller.Health(ctx)
|
||||
})
|
||||
specs := []media_shard.Spec{{ID: "one", Name: "测试分片一", Mode: "external", ControlAPI: first, Capacity: 2}, {ID: "two", Name: "测试分片二", Mode: "external", ControlAPI: second, Capacity: 2}}
|
||||
if err = service.SyncSpecs(context.Background(), specs); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = service.RefreshAll(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, routeID := range []string{"route-a", "route-b", "route-c", "route-d"} {
|
||||
if _, err = service.EnsureAssignment(context.Background(), routeID); err != nil {
|
||||
t.Fatalf("assign %s: %v", routeID, err)
|
||||
}
|
||||
}
|
||||
result, err := service.List(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Summary.Available != 2 || result.Summary.Assigned != 4 || result.Summary.Configured != 4 {
|
||||
t.Fatalf("unexpected real shard summary: %+v", result.Summary)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRealMediaMTXControlLifecycle(t *testing.T) {
|
||||
binary := os.Getenv("SENSE_MEDIAMTX_TEST_BINARY")
|
||||
if binary == "" {
|
||||
@@ -32,7 +110,7 @@ func TestRealMediaMTXControlLifecycle(t *testing.T) {
|
||||
}
|
||||
apiAddress, rtspAddress := freeAddress(t), freeAddress(t)
|
||||
configPath := filepath.Join(t.TempDir(), "mediamtx.yml")
|
||||
config := fmt.Sprintf("logLevel: warn\napi: true\napiAddress: %s\nrtspAddress: %s\nrtmp: false\nhls: false\nwebrtc: false\nsrt: false\nplayback: false\npaths: {}\n", apiAddress, rtspAddress)
|
||||
config := fmt.Sprintf("logLevel: warn\napi: true\napiAddress: %s\nrtspAddress: %s\nrtspTransports: [tcp]\nrtmp: false\nhls: false\nwebrtc: false\nsrt: false\nmoq: false\nplayback: false\npaths: {}\n", apiAddress, rtspAddress)
|
||||
if err := os.WriteFile(configPath, []byte(config), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ try {
|
||||
[IO.File]::WriteAllText((Join-Path $temporary 'web\js\runtime.fixture.js'), 'fixture', (New-Object Text.UTF8Encoding($false)))
|
||||
[IO.File]::WriteAllText((Join-Path $temporary 'bin\mediamtx.exe'), 'fixture', (New-Object Text.UTF8Encoding($false)))
|
||||
[IO.File]::WriteAllText((Join-Path $temporary 'config\mediamtx.yml'), 'api: true', (New-Object Text.UTF8Encoding($false)))
|
||||
[IO.File]::WriteAllText((Join-Path $temporary 'config\mediamtx-shards.json'), '[]', (New-Object Text.UTF8Encoding($false)))
|
||||
|
||||
$webAssetAudit = Join-Path $senseRoot 'scripts\build\assert-web-assets.ps1'
|
||||
& $webAssetAudit -WebRoot (Join-Path $temporary 'web')
|
||||
@@ -55,6 +56,7 @@ try {
|
||||
'SENSE_MEDIAMTX_BINARY=bin\mediamtx.exe',
|
||||
'SENSE_MEDIAMTX_CONFIG=config\mediamtx.yml',
|
||||
'SENSE_MEDIAMTX_API=http://127.0.0.1:9997',
|
||||
'SENSE_MEDIAMTX_SHARDS_FILE=config\mediamtx-shards.json',
|
||||
'SENSE_WEB_ROOT=web'
|
||||
) -join "`n"
|
||||
[IO.File]::WriteAllText((Join-Path $temporary 'config\sense.env'), $envText, (New-Object Text.UTF8Encoding($false)))
|
||||
@@ -68,6 +70,7 @@ try {
|
||||
Assert-True $generatedSettings.Contains('timeout: 2592000') 'generated runtime settings must keep login valid for 30 days'
|
||||
Assert-True ((Get-SenseEnvironmentValue -Name 'SENSE_DATABASE_URL').Contains('p#&;=x')) 'special characters must survive env parsing'
|
||||
Assert-True ($generatedSettings.Contains('password=p#\u0026;=x')) 'special characters must be safely JSON-escaped in YAML'
|
||||
Assert-Equal (Join-Path $temporary 'config\mediamtx-shards.json') (Get-SenseEnvironmentValue -Name 'SENSE_MEDIAMTX_SHARDS_FILE') 'relative shard config path must resolve inside package root'
|
||||
Assert-True (-not ((Get-SenseDatabaseInfo (Get-SenseEnvironmentValue -Name 'SENSE_DATABASE_URL')).Sanitized.Contains('password='))) 'PostgreSQL tool arguments must not contain password'
|
||||
|
||||
[Environment]::SetEnvironmentVariable('SENSE_PORT', $null, 'Process')
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
export function listEdgeNodes(query) {
|
||||
return request({ url: '/api/v1/edge-nodes', method: 'get', params: query })
|
||||
}
|
||||
|
||||
export function getEdgeNode(id) {
|
||||
return request({ url: `/api/v1/edge-nodes/${encodeURIComponent(id)}`, method: 'get' })
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
export function listMediaShards() {
|
||||
return request({ url: '/api/v1/media-shards', method: 'get' })
|
||||
}
|
||||
|
||||
export function getMediaShard(id) {
|
||||
return request({ url: `/api/v1/media-shards/${encodeURIComponent(id)}`, method: 'get' })
|
||||
}
|
||||
|
||||
export function preflightMediaShard(id) {
|
||||
return request({ url: `/api/v1/media-shards/${encodeURIComponent(id)}/migration-preflight`, method: 'get' })
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
export function listOpsAlerts(query) {
|
||||
return request({ url: '/api/v1/ops-alerts', method: 'get', params: query })
|
||||
}
|
||||
|
||||
export function getOpsAlert(id) {
|
||||
return request({ url: `/api/v1/ops-alerts/${encodeURIComponent(id)}`, method: 'get' })
|
||||
}
|
||||
|
||||
export function evaluateOpsAlerts() {
|
||||
return request({ url: '/api/v1/ops-alerts/evaluate', method: 'post' })
|
||||
}
|
||||
|
||||
export function acknowledgeOpsAlert(id, data) {
|
||||
return request({ url: `/api/v1/ops-alerts/${encodeURIComponent(id)}/acknowledge`, method: 'post', data })
|
||||
}
|
||||
|
||||
export function recoverOpsAlert(id, data) {
|
||||
return request({ url: `/api/v1/ops-alerts/${encodeURIComponent(id)}/recover`, method: 'post', data })
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
export function listOutbox(query) {
|
||||
return request({ url: '/api/v1/outbox', method: 'get', params: query })
|
||||
}
|
||||
|
||||
export function getOutbox(id) {
|
||||
return request({
|
||||
url: `/api/v1/outbox/${encodeURIComponent(id)}`,
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
export function requeueOutbox(id, expectedVersion, reason) {
|
||||
return request({
|
||||
url: `/api/v1/outbox/${encodeURIComponent(id)}/requeue`,
|
||||
method: 'post',
|
||||
data: { expectedVersion, reason }
|
||||
})
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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,36 @@
|
||||
const stateLabels = {
|
||||
online: '在线',
|
||||
offline: '离线',
|
||||
recovering: '恢复中',
|
||||
ready: '正常',
|
||||
unavailable: '不可用',
|
||||
idle: '无待回填',
|
||||
pending: '等待回填'
|
||||
}
|
||||
|
||||
const eventLabels = {
|
||||
heartbeat_received: '首次心跳',
|
||||
heartbeat_timeout: '心跳超时',
|
||||
node_recovered: '心跳恢复',
|
||||
recovery_started: '开始恢复'
|
||||
}
|
||||
|
||||
export function stateLabel(value) { return stateLabels[value] || value || '未知' }
|
||||
export function eventLabel(value) { return eventLabels[value] || value || '状态变化' }
|
||||
export function stateType(value) { return ({ online: 'success', offline: 'danger', recovering: 'warning', ready: 'success', unavailable: 'danger', idle: 'info', pending: 'warning' })[value] || 'info' }
|
||||
export function freshnessText(item) {
|
||||
if (!item?.lastHeartbeatAt) return '从未收到心跳'
|
||||
const seconds = Math.max(0, Number(item.freshnessSeconds) || 0)
|
||||
if (item.stale) return `已陈旧 ${durationText(seconds)}`
|
||||
return `${durationText(seconds)}前`
|
||||
}
|
||||
export function durationText(seconds) {
|
||||
const value = Math.max(0, Number(seconds) || 0)
|
||||
if (value < 60) return `${Math.floor(value)} 秒`
|
||||
if (value < 3600) return `${Math.floor(value / 60)} 分钟`
|
||||
if (value < 86400) return `${Math.floor(value / 3600)} 小时`
|
||||
return `${Math.floor(value / 86400)} 天`
|
||||
}
|
||||
export function buildEdgeNodeQuery(query) {
|
||||
return { pageIndex: Number(query.pageIndex) || 1, pageSize: Number(query.pageSize) || 10 }
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
<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 v-if="summary.offline" type="warning" :closable="false" show-icon class="status-alert">
|
||||
<template #title>{{ summary.offline }} 个节点离线</template>
|
||||
离线阈值为 {{ summary.heartbeatTimeoutSeconds }} 秒。页面中的隧道、视频和回填数据是节点离线前的最后已知状态。
|
||||
</el-alert>
|
||||
|
||||
<el-row :gutter="12" class="summary-row" aria-label="边缘节点概览">
|
||||
<el-col v-for="item in summaryCards" :key="item.label" :xs="12" :sm="6">
|
||||
<div class="summary-item"><span>{{ item.label }}</span><strong :class="item.className">{{ item.value }}</strong></div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-table v-loading="loading" :data="nodes" border stripe empty-text="暂无边缘节点">
|
||||
<el-table-column label="节点" min-width="180">
|
||||
<template #default="scope"><strong>{{ scope.row.name }}</strong><div class="muted">{{ scope.row.id }} · {{ scope.row.location || '未填写位置' }}</div></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="106" align="center"><template #default="scope"><el-tag :type="stateType(scope.row.status)">{{ stateLabel(scope.row.status) }}</el-tag></template></el-table-column>
|
||||
<el-table-column label="最近心跳" min-width="150"><template #default="scope"><div>{{ freshnessText(scope.row) }}</div><small>{{ formatTime(scope.row.lastHeartbeatAt) }}</small></template></el-table-column>
|
||||
<el-table-column label="负载" min-width="112"><template #default="scope">{{ scope.row.loadUsed }} / {{ scope.row.loadCapacity }} 路</template></el-table-column>
|
||||
<el-table-column label="控制隧道" min-width="142"><template #default="scope"><el-tag size="small" :type="stateType(scope.row.controlTunnelStatus)">{{ stateLabel(scope.row.controlTunnelStatus) }}</el-tag><div class="muted">{{ scope.row.controlLatencyMs ? `${scope.row.controlLatencyMs} ms` : scope.row.controlTunnelDetail }}</div></template></el-table-column>
|
||||
<el-table-column label="视频数据面" min-width="150"><template #default="scope"><el-tag size="small" :type="stateType(scope.row.videoPlaneStatus)">{{ stateLabel(scope.row.videoPlaneStatus) }}</el-tag><div class="muted">{{ scope.row.activeStreamCount }} 路视频 · {{ scope.row.videoPlaneDetail }}</div></template></el-table-column>
|
||||
<el-table-column label="回填队列" min-width="128"><template #default="scope"><el-tag size="small" :type="stateType(scope.row.backfillStatus)">{{ stateLabel(scope.row.backfillStatus) }}</el-tag><div class="muted">{{ scope.row.backfillQueueDepth }} 条待回填</div></template></el-table-column>
|
||||
<el-table-column label="版本" min-width="130"><template #default="scope"><div>{{ scope.row.runtimeVersion || '—' }}</div><small>{{ scope.row.modelVersion || '未部署模型' }}</small></template></el-table-column>
|
||||
<el-table-column label="操作" width="92" fixed="right"><template #default="scope"><el-button v-permisaction="['sense:edge-node: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="load" />
|
||||
</el-card>
|
||||
|
||||
<el-dialog v-model="detailOpen" title="边缘节点详情" width="min(800px, calc(100vw - 24px))" :close-on-click-modal="false">
|
||||
<template v-if="selected">
|
||||
<el-alert v-if="selected.stale" title="当前显示最后已知状态" type="warning" :closable="false" show-icon class="detail-alert">最近心跳距今 {{ durationText(selected.freshnessSeconds) }};连接恢复前,下列通道数据不会继续更新。</el-alert>
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item label="节点">{{ selected.name }}({{ selected.id }})</el-descriptions-item><el-descriptions-item label="位置">{{ selected.location || '未填写' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="运行版本">{{ selected.runtimeVersion || '—' }}</el-descriptions-item><el-descriptions-item label="模型版本">{{ selected.modelVersion || '未部署' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="运行时长">{{ durationText(selected.uptimeSeconds) }}</el-descriptions-item><el-descriptions-item label="最近心跳">{{ formatTime(selected.lastHeartbeatAt) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="控制隧道">{{ stateLabel(selected.controlTunnelStatus) }} · {{ selected.controlTunnelDetail }}</el-descriptions-item><el-descriptions-item label="视频数据面">{{ stateLabel(selected.videoPlaneStatus) }} · {{ selected.videoPlaneDetail }}</el-descriptions-item>
|
||||
<el-descriptions-item label="回填队列">{{ stateLabel(selected.backfillStatus) }} · {{ selected.backfillQueueDepth }} 条</el-descriptions-item><el-descriptions-item label="恢复阶段">{{ recoveryText(selected) }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<h4 class="audit-title">状态审计</h4>
|
||||
<el-timeline v-if="selected.events?.length">
|
||||
<el-timeline-item v-for="event in selected.events" :key="event.id" :timestamp="formatTime(event.occurredAt)" placement="top"><strong>{{ eventLabel(event.eventType) }}</strong><div>{{ stateLabel(event.fromStatus) }} → {{ stateLabel(event.toStatus) }}</div><small>{{ event.detail }}</small></el-timeline-item>
|
||||
</el-timeline>
|
||||
<el-empty v-else description="暂无状态变化记录" :image-size="72" />
|
||||
</template>
|
||||
<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 } from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { getEdgeNode, listEdgeNodes } from '@/api/sense/edge-node'
|
||||
import { buildEdgeNodeQuery, durationText, eventLabel, freshnessText, stateLabel, stateType } from './edgeNodeState'
|
||||
|
||||
defineOptions({ name: 'SenseEdgeNode' })
|
||||
const loading = ref(false)
|
||||
const nodes = ref([])
|
||||
const total = ref(0)
|
||||
const detailOpen = ref(false)
|
||||
const selected = ref(null)
|
||||
const query = reactive({ pageIndex: 1, pageSize: 10 })
|
||||
const summary = reactive({ total: 0, online: 0, offline: 0, recovering: 0, loadUsed: 0, loadCapacity: 0, backfillQueueDepth: 0, heartbeatTimeoutSeconds: 90 })
|
||||
const summaryCards = computed(() => [
|
||||
{ label: '节点总数', value: summary.total }, { label: '在线 / 恢复中', value: `${summary.online} / ${summary.recovering}`, className: 'online-number' },
|
||||
{ label: '总负载', value: `${summary.loadUsed} / ${summary.loadCapacity} 路` }, { label: '待回填', value: `${summary.backfillQueueDepth} 条`, className: summary.backfillQueueDepth ? 'warning-number' : '' }
|
||||
])
|
||||
function unwrap(response) { return response?.data?.data ?? response?.data ?? response }
|
||||
function formatTime(value) { return value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '—' }
|
||||
function recoveryText(item) { return item.status === 'recovering' ? (item.recoveryPhase || '通道收敛中') : '已收敛' }
|
||||
async function load() { loading.value = true; try { const payload = unwrap(await listEdgeNodes(buildEdgeNodeQuery(query))) || {}; nodes.value = payload.list || []; total.value = payload.count || 0; Object.assign(summary, payload.summary || {}) } catch (error) { ElMessage.error(error.message || '边缘节点加载失败') } finally { loading.value = false } }
|
||||
async function openDetail(id) { try { selected.value = unwrap(await getEdgeNode(id)); detailOpen.value = true } catch (error) { ElMessage.error(error.message || '节点详情加载失败') } }
|
||||
onMounted(load)
|
||||
</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}.status-alert{margin:16px 0}.summary-row{margin:16px 0 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:20px}.online-number{color:#67c23a}.warning-number{color:#e6a23c}.muted,small{color:#909399;font-size:12px}.detail-alert{margin-bottom:16px}.audit-title{margin:20px 0 14px}@media(max-width:768px){.page-header{align-items:stretch;flex-direction:column}.summary-item{margin-bottom:8px;min-height:64px}.page-header .el-button{min-height:44px}}
|
||||
</style>
|
||||
@@ -0,0 +1,65 @@
|
||||
<template>
|
||||
<BasicLayout>
|
||||
<template #wrapper>
|
||||
<el-card class="box-card">
|
||||
<div class="page-header">
|
||||
<div><h3>媒体分片</h3><p>查看 MediaMTX 分片容量、路径归属与故障影响。故障不会自动迁移路径。</p></div>
|
||||
<el-button :icon="Refresh" :loading="loading" @click="load">刷新状态</el-button>
|
||||
</div>
|
||||
|
||||
<el-alert v-if="summary.failed" type="error" :closable="false" show-icon class="status-alert">
|
||||
<template #title>{{ summary.failed }} 个媒体分片异常,影响 {{ summary.impactedPaths }} 条路径</template>
|
||||
路径仍保持原分片归属,便于定位故障范围;请先查看详情,不要直接变更生产配置。
|
||||
</el-alert>
|
||||
|
||||
<el-row :gutter="12" class="summary-row" aria-label="媒体分片概览">
|
||||
<el-col v-for="item in summaryCards" :key="item.label" :xs="12" :sm="6"><div class="summary-item"><span>{{ item.label }}</span><strong :class="item.className">{{ item.value }}</strong></div></el-col>
|
||||
</el-row>
|
||||
|
||||
<el-table v-loading="loading" :data="shards" border stripe empty-text="暂无媒体分片运行记录">
|
||||
<el-table-column label="分片" min-width="180"><template #default="scope"><strong>{{ scope.row.name }}</strong><div class="muted">{{ scope.row.id }} · {{ stateLabel(scope.row.mode) }}</div></template></el-table-column>
|
||||
<el-table-column label="状态" width="112" align="center"><template #default="scope"><el-tag :type="stateType(scope.row.status)">{{ stateLabel(scope.row.status) }}</el-tag><div class="muted">{{ freshnessText(scope.row) }}</div></template></el-table-column>
|
||||
<el-table-column label="已分配 / 容量" min-width="190"><template #default="scope"><div>{{ scope.row.assignedPaths }} / {{ scope.row.capacity }} 条路径</div><el-progress :percentage="usagePercent(scope.row)" :status="usagePercent(scope.row) >= 90 ? 'warning' : ''" /></template></el-table-column>
|
||||
<el-table-column label="剩余容量" width="110" align="center"><template #default="scope">{{ scope.row.remaining }}</template></el-table-column>
|
||||
<el-table-column prop="detail" label="运行说明" min-width="210" show-overflow-tooltip />
|
||||
<el-table-column label="最近探测" min-width="170"><template #default="scope">{{ formatTime(scope.row.lastProbeAt) }}</template></el-table-column>
|
||||
<el-table-column label="操作" width="156" fixed="right"><template #default="scope"><el-button v-permisaction="['sense:media-shard:detail']" type="primary" link @click="openDetail(scope.row.id)">影响详情</el-button><el-button v-permisaction="['sense:media-shard:preflight']" type="primary" link @click="openPreflight(scope.row.id)">迁移预检</el-button></template></el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<el-dialog v-model="detailOpen" title="分片故障影响" width="min(880px, calc(100vw - 24px))" :close-on-click-modal="false">
|
||||
<template v-if="selected"><el-descriptions :column="2" border><el-descriptions-item label="分片">{{ selected.name }}({{ selected.id }})</el-descriptions-item><el-descriptions-item label="状态"><el-tag :type="stateType(selected.status)">{{ stateLabel(selected.status) }}</el-tag></el-descriptions-item><el-descriptions-item label="路径占用">{{ selected.assignedPaths }} / {{ selected.capacity }}</el-descriptions-item><el-descriptions-item label="运行说明">{{ selected.detail }}</el-descriptions-item></el-descriptions>
|
||||
<h4>受影响设备与 Profile</h4><el-table :data="selected.impact || []" border empty-text="此分片暂无路径归属"><el-table-column prop="deviceName" label="设备" min-width="150"><template #default="scope">{{ scope.row.deviceName || scope.row.deviceId }}<div class="muted">{{ scope.row.location || '未填写位置' }}</div></template></el-table-column><el-table-column prop="profileToken" label="Profile" min-width="130" /><el-table-column prop="path" label="媒体路径" min-width="180" /><el-table-column prop="actual" label="最近状态" width="120" /></el-table>
|
||||
</template><template #footer><el-button @click="detailOpen = false">关闭</el-button></template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="preflightOpen" title="跨分片迁移预检(只读)" width="min(720px, calc(100vw - 24px))" :close-on-click-modal="false">
|
||||
<template v-if="preflight"><el-alert :type="preflight.ready ? 'warning' : 'error'" :title="preflightConclusion(preflight)" :closable="false" show-icon /><el-descriptions :column="2" border class="preflight-summary"><el-descriptions-item label="源分片">{{ preflight.sourceShardId }}</el-descriptions-item><el-descriptions-item label="候选目标">{{ preflight.targetShardName || '无可用目标' }}</el-descriptions-item><el-descriptions-item label="影响路径">{{ preflight.impactedPaths }}</el-descriptions-item><el-descriptions-item label="执行授权">未授权</el-descriptions-item></el-descriptions><el-table :data="preflight.checks || []" border><el-table-column label="结果" width="90"><template #default="scope"><el-tag :type="scope.row.passed ? 'success' : 'danger'">{{ scope.row.passed ? '通过' : '未通过' }}</el-tag></template></el-table-column><el-table-column prop="name" label="检查项" width="130" /><el-table-column prop="detail" label="说明" min-width="260" /></el-table></template>
|
||||
<template #footer><span class="read-only-note">本页面不会修改任何路径归属。</span><el-button @click="preflightOpen = false">关闭</el-button></template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
</BasicLayout>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { Refresh } from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { getMediaShard, listMediaShards, preflightMediaShard } from '@/api/sense/media-shard'
|
||||
import { freshnessText, preflightConclusion, stateLabel, stateType, usagePercent } from './mediaShardState'
|
||||
|
||||
defineOptions({ name: 'SenseMediaShard' })
|
||||
const loading = ref(false); const shards = ref([]); const selected = ref(null); const preflight = ref(null); const detailOpen = ref(false); const preflightOpen = ref(false)
|
||||
const summary = reactive({ total: 0, available: 0, failed: 0, configuredCapacity: 0, assignedPaths: 0, impactedPaths: 0 })
|
||||
const summaryCards = computed(() => [{ label: '分片总数', value: summary.total }, { label: '可用 / 异常', value: `${summary.available} / ${summary.failed}`, className: summary.failed ? 'danger-number' : 'success-number' }, { label: '已分配 / 总容量', value: `${summary.assignedPaths} / ${summary.configuredCapacity}` }, { label: '故障影响', value: `${summary.impactedPaths} 条路径`, className: summary.impactedPaths ? 'danger-number' : '' }])
|
||||
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 listMediaShards()) || {}; shards.value = payload.list || []; Object.assign(summary, payload.summary || {}) } catch (error) { ElMessage.error(error.message || '媒体分片加载失败') } finally { loading.value = false } }
|
||||
async function openDetail(id) { try { selected.value = unwrap(await getMediaShard(id)); detailOpen.value = true } catch (error) { ElMessage.error(error.message || '分片详情加载失败') } }
|
||||
async function openPreflight(id) { try { preflight.value = unwrap(await preflightMediaShard(id)); preflightOpen.value = true } catch (error) { ElMessage.error(error.message || '迁移预检失败') } }
|
||||
onMounted(load)
|
||||
</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}.status-alert{margin:16px 0}.summary-row{margin:16px 0 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:20px}.success-number{color:#67c23a}.danger-number{color:#f56c6c}.muted{color:#909399;font-size:12px;margin-top:4px}.preflight-summary{margin:16px 0}.read-only-note{margin-right:16px;color:#909399}@media(max-width:768px){.page-header{align-items:stretch;flex-direction:column}.summary-item{margin-bottom:8px;min-height:64px}.page-header .el-button{min-height:44px}}
|
||||
</style>
|
||||
@@ -0,0 +1,6 @@
|
||||
const labels = { running: '运行正常', failed: '运行异常', unknown: '等待探测', disabled: '已停用', managed: 'Sense 托管', external: '外部运行' }
|
||||
export function stateLabel(value) { return labels[value] || value || '未知' }
|
||||
export function stateType(value) { return ({ running: 'success', failed: 'danger', unknown: 'warning', disabled: 'info' })[value] || 'info' }
|
||||
export function usagePercent(item) { const capacity = Number(item?.capacity) || 0; return capacity > 0 ? Math.min(100, Math.round((Number(item.assignedPaths) || 0) * 100 / capacity)) : 0 }
|
||||
export function freshnessText(item) { if (!item?.lastProbeAt) return '尚未探测'; return item.stale ? '状态已陈旧' : '刚刚更新' }
|
||||
export function preflightConclusion(result) { if (!result) return ''; if (!result.ready) return '当前不满足迁移前置条件'; return '预检通过,但仍需单独建立高风险工单并人工确认' }
|
||||
@@ -0,0 +1,109 @@
|
||||
<template>
|
||||
<BasicLayout>
|
||||
<template #wrapper>
|
||||
<el-card class="box-card">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h3>运维告警</h3>
|
||||
<p>处理设备、媒体服务和边缘节点的运行异常。</p>
|
||||
</div>
|
||||
<el-button v-permisaction="['sense:ops-alert:evaluate']" :icon="Refresh" :loading="refreshing" @click="refreshSources">刷新状态</el-button>
|
||||
</div>
|
||||
|
||||
<el-alert class="boundary-alert" type="info" :closable="false" show-icon title="这里只处理系统运行问题">
|
||||
运维告警不会创建本地安全事件,也不会发送到 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.active }}</strong></div></el-col>
|
||||
<el-col :xs="24" :sm="8"><div class="summary-item"><span>待确认</span><strong class="danger-number">{{ summary.unacknowledged }}</strong></div></el-col>
|
||||
<el-col :xs="24" :sm="8"><div class="summary-item"><span>恢复观察</span><strong class="primary-number">{{ summary.recovering }}</strong></div></el-col>
|
||||
</el-row>
|
||||
|
||||
<el-form :model="query" label-width="76px" class="filter-form" @submit.prevent="search">
|
||||
<el-form-item label="告警类型">
|
||||
<el-select v-model="query.alertType" clearable placeholder="全部类型">
|
||||
<el-option v-for="item in typeOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="处理状态">
|
||||
<el-select v-model="query.state" clearable placeholder="全部状态">
|
||||
<el-option label="待确认" value="unacknowledged" /><el-option label="已确认" value="acknowledged" />
|
||||
<el-option label="恢复观察" value="recovering" /><el-option label="已恢复" value="recovered" />
|
||||
</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="alerts" border stripe empty-text="当前筛选条件下没有运维告警">
|
||||
<el-table-column label="告警" min-width="180"><template #default="scope"><strong>{{ scope.row.title }}</strong><div class="muted">{{ alertTypeLabel(scope.row.alertType) }}</div></template></el-table-column>
|
||||
<el-table-column label="对象" min-width="170"><template #default="scope"><strong>{{ scope.row.objectName }}</strong><div class="muted">{{ scope.row.location || scope.row.objectId }}</div></template></el-table-column>
|
||||
<el-table-column label="状态" width="110" align="center"><template #default="scope"><el-tag :type="alertStateType(scope.row.state)" size="small">{{ alertStateLabel(scope.row.state) }}</el-tag></template></el-table-column>
|
||||
<el-table-column label="首次 / 最近发现" min-width="190"><template #default="scope"><div>{{ formatTime(scope.row.firstSeenAt) }}</div><small>{{ formatTime(scope.row.lastSeenAt) }}</small></template></el-table-column>
|
||||
<el-table-column prop="occurrenceCount" label="重复次数" width="92" align="center" />
|
||||
<el-table-column label="操作" width="184" fixed="right">
|
||||
<template #default="scope">
|
||||
<el-button v-permisaction="['sense:ops-alert:detail']" type="primary" link @click="openDetail(scope.row.id)">详情</el-button>
|
||||
<el-button v-if="canAcknowledge(scope.row)" v-permisaction="['sense:ops-alert:acknowledge']" type="warning" link @click="openAction(scope.row, 'acknowledge')">确认</el-button>
|
||||
<el-button v-if="canRecover(scope.row)" v-permisaction="['sense:ops-alert:recover']" type="success" link @click="openAction(scope.row, 'recover')">恢复</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" />
|
||||
<p class="help-text">同一对象的同类故障使用唯一指纹去重;恢复后再次发生会复用历史记录并开启新的处理周期。</p>
|
||||
</el-card>
|
||||
|
||||
<el-dialog v-model="detailOpen" title="运维告警详情" width="min(780px, calc(100vw - 24px))">
|
||||
<el-descriptions v-if="selected" :column="2" border>
|
||||
<el-descriptions-item label="告警编号">{{ selected.id }}</el-descriptions-item><el-descriptions-item label="处理状态"><el-tag :type="alertStateType(selected.state)">{{ alertStateLabel(selected.state) }}</el-tag></el-descriptions-item>
|
||||
<el-descriptions-item label="异常对象">{{ selected.objectName }}</el-descriptions-item><el-descriptions-item label="发现次数">{{ selected.occurrenceCount }}</el-descriptions-item>
|
||||
<el-descriptions-item label="异常详情" :span="2">{{ selected.detail }}</el-descriptions-item><el-descriptions-item label="建议处理" :span="2">{{ selected.nextAction }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<el-alert class="detail-boundary" type="warning" :closable="false" show-icon title="业务隔离边界">该记录仅用于 Sense 运维,不会生成安全事件或 Bell 业务预警。</el-alert>
|
||||
<el-timeline v-if="transitions.length" class="timeline"><el-timeline-item v-for="item in transitions" :key="item.id" :timestamp="formatTime(item.createdAt)"><strong>{{ actionLabel(item.action) }}</strong><div class="muted">{{ item.reason }}</div></el-timeline-item></el-timeline>
|
||||
<template #footer><el-button @click="detailOpen = false">关闭</el-button></template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="actionOpen" :title="actionMode === 'acknowledge' ? '确认运维告警' : '确认故障恢复'" width="min(520px, calc(100vw - 24px))" :close-on-click-modal="false">
|
||||
<el-alert :type="actionMode === 'acknowledge' ? 'warning' : 'success'" :closable="false" show-icon :title="actionMode === 'acknowledge' ? '确认不代表恢复' : '仅在健康状态已稳定后关闭告警'" />
|
||||
<el-form ref="actionFormRef" :model="actionForm" :rules="actionRules" label-position="top" class="action-form">
|
||||
<el-form-item label="处理说明" prop="reason"><el-input v-model="actionForm.reason" type="textarea" :rows="4" maxlength="256" show-word-limit placeholder="请输入至少 6 个字符,说明处理人、措施或恢复依据" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer><el-button @click="actionOpen = false">取消</el-button><el-button :type="actionMode === 'acknowledge' ? 'warning' : 'success'" :loading="submitting" @click="submitAction">确认提交</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 } from 'element-plus'
|
||||
import { acknowledgeOpsAlert, evaluateOpsAlerts, getOpsAlert, listOpsAlerts, recoverOpsAlert } from '@/api/sense/ops-alert'
|
||||
import { alertStateLabel, alertStateType, alertTypeLabel, buildOpsAlertQuery, canAcknowledge, canRecover } from './opsAlertState'
|
||||
|
||||
defineOptions({ name: 'SenseOpsAlert' })
|
||||
const loading = ref(false); const refreshing = ref(false); const submitting = ref(false); const detailOpen = ref(false); const actionOpen = ref(false)
|
||||
const alerts = ref([]); const total = ref(0); const selected = ref(null); const transitions = ref([]); const actionMode = ref('acknowledge'); const actionFormRef = ref()
|
||||
const summary = reactive({ active: 0, unacknowledged: 0, recovering: 0 })
|
||||
const query = reactive({ pageIndex: 1, pageSize: 10, alertType: '', state: '', keyword: '' })
|
||||
const actionForm = reactive({ reason: '', expectedVersion: 0 })
|
||||
const actionRules = { reason: [{ required: true, message: '请输入处理说明', trigger: 'blur' }, { min: 6, max: 256, message: '处理说明需为 6 至 256 个字符', trigger: 'blur' }] }
|
||||
const typeOptions = ['device_offline', 'authentication_failed', 'clock_drift', 'reconciliation_failed', 'media_shard_failed', 'control_tunnel_failed'].map(value => ({ value, label: alertTypeLabel(value) }))
|
||||
function unwrap(response) { return response?.data?.data ?? response?.data ?? response }
|
||||
function formatTime(value) { return value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '—' }
|
||||
function actionLabel(value) { return ({ detected: '发现异常', acknowledge: '人工确认', health_restored: '健康恢复', recover: '确认恢复', relapsed: '恢复失败', reopened: '再次发生' })[value] || value }
|
||||
async function load() { loading.value = true; try { const payload = unwrap(await listOpsAlerts(buildOpsAlertQuery(query))) || {}; alerts.value = payload.list || []; total.value = payload.count || 0; Object.assign(summary, payload.summary || { active: 0, unacknowledged: 0, recovering: 0 }) } catch (error) { ElMessage.error(error.message || '运维告警加载失败') } finally { loading.value = false } }
|
||||
async function refreshSources() { refreshing.value = true; try { await evaluateOpsAlerts(); ElMessage.success('状态已刷新'); await load() } catch (error) { ElMessage.error(error.message || '健康状态刷新失败') } finally { refreshing.value = false } }
|
||||
function search() { query.pageIndex = 1; load() }
|
||||
function reset() { Object.assign(query, { pageIndex: 1, alertType: '', state: '', keyword: '' }); load() }
|
||||
async function openDetail(id) { try { const payload = unwrap(await getOpsAlert(id)) || {}; selected.value = payload.alert; transitions.value = payload.transitions || []; detailOpen.value = true } catch (error) { ElMessage.error(error.message || '告警详情加载失败') } }
|
||||
function openAction(item, mode) { selected.value = item; actionMode.value = mode; actionForm.reason = ''; actionForm.expectedVersion = item.version; actionOpen.value = true }
|
||||
async function submitAction() { if (!await actionFormRef.value.validate().catch(() => false)) return; submitting.value = true; try { const payload = { expectedVersion: actionForm.expectedVersion, reason: actionForm.reason.trim() }; if (actionMode.value === 'acknowledge') await acknowledgeOpsAlert(selected.value.id, payload); else await recoverOpsAlert(selected.value.id, payload); ElMessage.success(actionMode.value === 'acknowledge' ? '告警已确认' : '告警已恢复'); actionOpen.value = false; detailOpen.value = false; await load() } catch (error) { ElMessage.warning(error.message || '状态已变化,请刷新后重试') } finally { submitting.value = false } }
|
||||
onMounted(load)
|
||||
</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: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}.danger-number{color:#f56c6c}.primary-number{color:#409eff}.filter-form{display:flex;align-items:flex-end;flex-wrap:wrap;gap:0 12px;margin:16px 0 2px}.filter-form .el-form-item{width:230px}.filter-form .filter-actions{width:auto}.filter-form :deep(.el-select){width:100%}.muted,small,.help-text{color:#909399;font-size:12px}.help-text{margin:12px 0 0}.detail-boundary{margin:16px 0}.timeline{padding-top:8px}.action-form{margin-top:16px}@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,30 @@
|
||||
const typeLabels = {
|
||||
device_offline: '设备离线',
|
||||
authentication_failed: '认证失败',
|
||||
clock_drift: '时间漂移',
|
||||
reconciliation_failed: '状态对账失败',
|
||||
media_shard_failed: '媒体分片异常',
|
||||
control_tunnel_failed: '控制隧道异常'
|
||||
}
|
||||
|
||||
const stateLabels = {
|
||||
unacknowledged: '待确认',
|
||||
acknowledged: '已确认',
|
||||
recovering: '恢复观察',
|
||||
recovered: '已恢复'
|
||||
}
|
||||
|
||||
export function alertTypeLabel(value) { return typeLabels[value] || value || '未知类型' }
|
||||
export function alertStateLabel(value) { return stateLabels[value] || value || '未知状态' }
|
||||
export function alertStateType(value) { return ({ unacknowledged: 'danger', acknowledged: 'warning', recovering: 'primary', recovered: 'success' })[value] || 'info' }
|
||||
export function canAcknowledge(item) { return item?.state === 'unacknowledged' && item?.version > 0 }
|
||||
export function canRecover(item) { return item?.state === 'recovering' && item?.version > 0 }
|
||||
export function buildOpsAlertQuery(query) {
|
||||
return {
|
||||
pageIndex: Number(query.pageIndex) || 1,
|
||||
pageSize: Number(query.pageSize) || 10,
|
||||
alertType: String(query.alertType || '').trim(),
|
||||
state: String(query.state || '').trim(),
|
||||
keyword: String(query.keyword || '').trim()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,447 @@
|
||||
<template>
|
||||
<BasicLayout>
|
||||
<template #wrapper>
|
||||
<el-card class="box-card">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h3>可靠投递</h3>
|
||||
<p>查看 Sense 内部待投递记录、重试进度和人工恢复。</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>未配置外部投递出口不影响 Sense 核心功能</template>
|
||||
内部记录会安全保留,正式 connector 由后续协调工单提供;测试接收器在
|
||||
production 模式不可启用。
|
||||
</el-alert>
|
||||
|
||||
<el-row :gutter="12" class="summary-row" aria-label="可靠投递队列概览">
|
||||
<el-col
|
||||
v-for="card in summaryCards"
|
||||
:key="card.key"
|
||||
:xs="12"
|
||||
:sm="6"
|
||||
><div class="summary-item">
|
||||
<span>{{ card.label }}</span><strong :class="card.className">{{ summary[card.key] }}</strong>
|
||||
</div></el-col>
|
||||
</el-row>
|
||||
|
||||
<el-form
|
||||
:model="query"
|
||||
label-width="76px"
|
||||
class="filter-form"
|
||||
@submit.prevent="search"
|
||||
>
|
||||
<el-form-item label="关键词"><el-input
|
||||
v-model="query.keyword"
|
||||
clearable
|
||||
placeholder="内部记录、业务引用或幂等键"
|
||||
@keyup.enter="search"
|
||||
/></el-form-item>
|
||||
<el-form-item label="状态"><el-select
|
||||
v-model="query.state"
|
||||
clearable
|
||||
placeholder="全部状态"
|
||||
><el-option
|
||||
v-for="(label, value) in stateLabels"
|
||||
:key="value"
|
||||
:label="label"
|
||||
:value="value"
|
||||
/></el-select></el-form-item>
|
||||
<el-form-item label="内部类型"><el-select
|
||||
v-model="query.internalType"
|
||||
clearable
|
||||
placeholder="全部类型"
|
||||
><el-option
|
||||
v-for="(label, value) in typeLabels"
|
||||
:key="value"
|
||||
:label="label"
|
||||
:value="value"
|
||||
/></el-select></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="items"
|
||||
border
|
||||
stripe
|
||||
empty-text="当前筛选条件下没有可靠投递记录"
|
||||
>
|
||||
<el-table-column
|
||||
label="内部记录"
|
||||
min-width="255"
|
||||
><template #default="scope"><strong>{{ typeLabel(scope.row.internalType) }}</strong>
|
||||
<div class="code-text">{{ scope.row.id }}</div>
|
||||
<small>幂等键:{{ scope.row.idempotencyKey }}</small></template></el-table-column>
|
||||
<el-table-column
|
||||
label="状态"
|
||||
width="112"
|
||||
align="center"
|
||||
><template #default="scope"><el-tag :type="stateType(scope.row.state)">{{
|
||||
stateLabel(scope.row.state)
|
||||
}}</el-tag></template></el-table-column>
|
||||
<el-table-column
|
||||
label="尝试"
|
||||
width="74"
|
||||
align="center"
|
||||
><template #default="scope">{{ scope.row.attemptCount }} 次</template></el-table-column>
|
||||
<el-table-column
|
||||
label="下次动作"
|
||||
min-width="180"
|
||||
><template #default="scope">{{
|
||||
nextAction(scope.row)
|
||||
}}</template></el-table-column>
|
||||
<el-table-column
|
||||
label="租约"
|
||||
min-width="142"
|
||||
><template #default="scope"><div>{{ scope.row.leaseOwner || "未领取" }}</div>
|
||||
<small v-if="scope.row.leaseUntil">{{
|
||||
formatTime(scope.row.leaseUntil)
|
||||
}}</small></template></el-table-column>
|
||||
<el-table-column
|
||||
label="最近结果"
|
||||
min-width="210"
|
||||
><template #default="scope">{{
|
||||
scope.row.lastError ||
|
||||
(scope.row.state === "delivered" ? "投递成功" : "尚未失败")
|
||||
}}</template></el-table-column>
|
||||
<el-table-column
|
||||
label="操作"
|
||||
width="150"
|
||||
fixed="right"
|
||||
><template #default="scope"><el-button
|
||||
v-permisaction="['sense:outbox:detail']"
|
||||
type="primary"
|
||||
link
|
||||
@click="openDetail(scope.row.id)"
|
||||
>详情</el-button><el-button
|
||||
v-if="scope.row.state === 'dead'"
|
||||
v-permisaction="['sense:outbox:requeue']"
|
||||
type="warning"
|
||||
link
|
||||
@click="openRequeue(scope.row)"
|
||||
>重新排队</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"
|
||||
/>
|
||||
<p class="boundary-text">
|
||||
页面不展示内部
|
||||
payload、外部凭据或机器身份;人工恢复保留原业务记录、幂等键和失败历史。
|
||||
</p>
|
||||
</el-card>
|
||||
|
||||
<el-dialog
|
||||
v-model="detailOpen"
|
||||
title="投递记录详情"
|
||||
width="min(780px, calc(100vw - 24px))"
|
||||
:close-on-click-modal="false"
|
||||
>
|
||||
<el-descriptions v-if="selected" :column="2" border>
|
||||
<el-descriptions-item label="内部记录">{{
|
||||
selected.message.id
|
||||
}}</el-descriptions-item><el-descriptions-item label="内部类型">{{
|
||||
typeLabel(selected.message.internalType)
|
||||
}}</el-descriptions-item>
|
||||
<el-descriptions-item
|
||||
label="幂等键"
|
||||
:span="2"
|
||||
><span class="code-text">{{
|
||||
selected.message.idempotencyKey
|
||||
}}</span></el-descriptions-item><el-descriptions-item label="当前状态"><el-tag :type="stateType(selected.message.state)">{{
|
||||
stateLabel(selected.message.state)
|
||||
}}</el-tag></el-descriptions-item><el-descriptions-item label="业务引用">{{
|
||||
selected.message.businessRef
|
||||
}}</el-descriptions-item>
|
||||
<el-descriptions-item label="最近结果" :span="2">{{
|
||||
selected.message.lastError || "尚未失败"
|
||||
}}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<h4 class="timeline-title">处理时间线</h4>
|
||||
<el-timeline v-if="selected"><el-timeline-item
|
||||
v-for="attempt in selected.attempts"
|
||||
:key="attempt.id"
|
||||
:timestamp="formatTime(attempt.createdAt)"
|
||||
placement="top"
|
||||
><strong>第 {{ attempt.number }} 次 · {{ attempt.outcome }}</strong>
|
||||
<div>{{ attempt.detail }}</div>
|
||||
<small v-if="attempt.actorUserId">操作人编号:{{ attempt.actorUserId }}</small></el-timeline-item><el-timeline-item
|
||||
v-if="!selected.attempts.length"
|
||||
:timestamp="formatTime(selected.message.createdAt)"
|
||||
>与业务记录在同一事务中创建</el-timeline-item></el-timeline>
|
||||
<template #footer><el-button @click="detailOpen = false">关闭</el-button></template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog
|
||||
v-model="requeueOpen"
|
||||
title="将死信重新排队"
|
||||
width="min(560px, calc(100vw - 24px))"
|
||||
:close-on-click-modal="false"
|
||||
@closed="resetRequeue"
|
||||
>
|
||||
<el-alert
|
||||
title="这是受控恢复操作"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
>只创建新的投递尝试,不修改原业务记录或删除失败历史。执行前请先排除失败原因。</el-alert>
|
||||
<el-form
|
||||
ref="requeueFormRef"
|
||||
:model="requeueForm"
|
||||
:rules="requeueRules"
|
||||
label-position="top"
|
||||
class="requeue-form"
|
||||
><el-form-item
|
||||
label="恢复原因"
|
||||
prop="reason"
|
||||
><el-input
|
||||
v-model="requeueForm.reason"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
maxlength="256"
|
||||
show-word-limit
|
||||
placeholder="例如:出口配置已恢复,已核对幂等键和目标状态"
|
||||
/></el-form-item></el-form>
|
||||
<p class="boundary-text">
|
||||
原因将与操作人、对象版本和时间一起写入脱敏审计。
|
||||
</p>
|
||||
<template #footer><el-button @click="requeueOpen = false">取消</el-button><el-button
|
||||
type="warning"
|
||||
:loading="requeueLoading"
|
||||
@click="confirmRequeue"
|
||||
>确认重新排队</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 { getOutbox, listOutbox, requeueOutbox } from '@/api/sense/outbox'
|
||||
import {
|
||||
buildOutboxQuery,
|
||||
formatTime,
|
||||
nextAction,
|
||||
stateLabel,
|
||||
stateLabels,
|
||||
stateType,
|
||||
typeLabel,
|
||||
typeLabels
|
||||
} from './outboxState'
|
||||
|
||||
defineOptions({ name: 'SenseOutbox' })
|
||||
const loading = ref(false)
|
||||
const items = ref([])
|
||||
const total = ref(0)
|
||||
const detailOpen = ref(false)
|
||||
const selected = ref(null)
|
||||
const requeueOpen = ref(false)
|
||||
const requeueLoading = ref(false)
|
||||
const requeueTarget = ref(null)
|
||||
const requeueFormRef = ref(null)
|
||||
const summary = reactive({ pending: 0, retry: 0, processing: 0, dead: 0 })
|
||||
const query = reactive({
|
||||
pageIndex: 1,
|
||||
pageSize: 10,
|
||||
state: '',
|
||||
internalType: '',
|
||||
keyword: ''
|
||||
})
|
||||
const requeueForm = reactive({ reason: '' })
|
||||
const requeueRules = {
|
||||
reason: [
|
||||
{ required: true, message: '请填写恢复原因', trigger: 'blur' },
|
||||
{ min: 6, max: 256, message: '请填写 6 至 256 个字符', trigger: 'blur' }
|
||||
]
|
||||
}
|
||||
const summaryCards = computed(() => [
|
||||
{ key: 'pending', label: '等待投递' },
|
||||
{ key: 'retry', label: '重试等待', className: 'warning-number' },
|
||||
{ key: 'processing', label: '处理中 / 租约' },
|
||||
{ key: 'dead', label: '死信待处理', className: 'danger-number' }
|
||||
])
|
||||
function unwrap(response) {
|
||||
return response?.data?.data ?? response?.data ?? response
|
||||
}
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const payload = unwrap(await listOutbox(buildOutboxQuery(query))) || {}
|
||||
items.value = payload.list || []
|
||||
total.value = payload.count || 0
|
||||
Object.assign(
|
||||
summary,
|
||||
payload.summary || { pending: 0, retry: 0, processing: 0, dead: 0 }
|
||||
)
|
||||
} catch (error) {
|
||||
ElMessage.error(error.message || '可靠投递状态加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
function search() {
|
||||
query.pageIndex = 1
|
||||
load()
|
||||
}
|
||||
function reset() {
|
||||
Object.assign(query, {
|
||||
pageIndex: 1,
|
||||
state: '',
|
||||
internalType: '',
|
||||
keyword: ''
|
||||
})
|
||||
load()
|
||||
}
|
||||
async function openDetail(id) {
|
||||
try {
|
||||
selected.value = unwrap(await getOutbox(id))
|
||||
detailOpen.value = true
|
||||
} catch (error) {
|
||||
ElMessage.error(error.message || '投递详情加载失败')
|
||||
}
|
||||
}
|
||||
function openRequeue(item) {
|
||||
requeueTarget.value = item
|
||||
requeueOpen.value = true
|
||||
}
|
||||
function resetRequeue() {
|
||||
requeueForm.reason = ''
|
||||
requeueTarget.value = null
|
||||
requeueFormRef.value?.clearValidate()
|
||||
}
|
||||
async function confirmRequeue() {
|
||||
if (!requeueFormRef.value || !requeueTarget.value) return
|
||||
const valid = await requeueFormRef.value.validate().catch(() => false)
|
||||
if (!valid) return
|
||||
requeueLoading.value = true
|
||||
try {
|
||||
await requeueOutbox(
|
||||
requeueTarget.value.id,
|
||||
requeueTarget.value.version,
|
||||
requeueForm.reason.trim()
|
||||
)
|
||||
ElMessage.success('死信已重新排队,原失败历史已保留')
|
||||
requeueOpen.value = false
|
||||
detailOpen.value = false
|
||||
await load()
|
||||
} catch (error) {
|
||||
ElMessage.warning(error.message || '重新排队失败,请刷新状态后重试')
|
||||
} finally {
|
||||
requeueLoading.value = false
|
||||
}
|
||||
}
|
||||
onMounted(load)
|
||||
</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: 18px;
|
||||
}
|
||||
.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: 22px;
|
||||
}
|
||||
.warning-number {
|
||||
color: #e6a23c;
|
||||
}
|
||||
.danger-number {
|
||||
color: #f56c6c;
|
||||
}
|
||||
.filter-form {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
flex-wrap: wrap;
|
||||
gap: 0 12px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.filter-form .el-form-item {
|
||||
width: 260px;
|
||||
}
|
||||
.filter-form .filter-actions {
|
||||
width: auto;
|
||||
}
|
||||
.filter-form :deep(.el-select) {
|
||||
width: 100%;
|
||||
}
|
||||
.code-text {
|
||||
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.boundary-text,
|
||||
small {
|
||||
color: #909399;
|
||||
font-size: 12px;
|
||||
}
|
||||
.boundary-text {
|
||||
margin: 12px 0 0;
|
||||
}
|
||||
.timeline-title {
|
||||
margin: 20px 0 14px;
|
||||
}
|
||||
.requeue-form {
|
||||
margin-top: 16px;
|
||||
}
|
||||
@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,54 @@
|
||||
export const stateLabels = {
|
||||
pending: '等待投递',
|
||||
processing: '处理中',
|
||||
retry: '重试等待',
|
||||
dead: '死信',
|
||||
delivered: '已投递'
|
||||
}
|
||||
export const typeLabels = {
|
||||
local_event_candidate: '本地事件候选',
|
||||
audit_projection: '审计投影'
|
||||
}
|
||||
|
||||
export function stateLabel(value) {
|
||||
return stateLabels[value] || value || '—'
|
||||
}
|
||||
export function stateType(value) {
|
||||
return (
|
||||
{
|
||||
pending: 'info',
|
||||
processing: 'primary',
|
||||
retry: 'warning',
|
||||
dead: 'danger',
|
||||
delivered: 'success'
|
||||
}[value] || 'info'
|
||||
)
|
||||
}
|
||||
export function typeLabel(value) {
|
||||
return typeLabels[value] || value || '—'
|
||||
}
|
||||
export function buildOutboxQuery(query) {
|
||||
return {
|
||||
pageIndex: query.pageIndex,
|
||||
pageSize: query.pageSize,
|
||||
state: query.state || undefined,
|
||||
internalType: query.internalType || undefined,
|
||||
keyword: String(query.keyword || '').trim() || undefined
|
||||
}
|
||||
}
|
||||
export function nextAction(item, now = Date.now()) {
|
||||
if (item.state === 'dead') return '等待人工处理'
|
||||
if (item.state === 'processing') { return item.leaseUntil ? `租约至 ${formatTime(item.leaseUntil)}` : '处理中' }
|
||||
if (item.state === 'retry') {
|
||||
return item.availableAt && new Date(item.availableAt).getTime() > now
|
||||
? `自动重试 ${formatTime(item.availableAt)}`
|
||||
: '等待重试领取'
|
||||
}
|
||||
if (item.state === 'delivered') return '已完成'
|
||||
return '等待 worker 领取'
|
||||
}
|
||||
export function formatTime(value) {
|
||||
return value
|
||||
? new Date(value).toLocaleString('zh-CN', { hour12: false })
|
||||
: '—'
|
||||
}
|
||||
@@ -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,14 @@
|
||||
import request from '@/utils/request'
|
||||
import { getEdgeNode, listEdgeNodes } from '@/api/sense/edge-node'
|
||||
|
||||
jest.mock('@/utils/request', () => jest.fn())
|
||||
|
||||
describe('Sense edge node API', () => {
|
||||
beforeEach(() => request.mockReset())
|
||||
test('uses read-only endpoints and encodes identifiers', () => {
|
||||
listEdgeNodes({ pageIndex: 1, pageSize: 10 })
|
||||
getEdgeNode('edge/site 01')
|
||||
expect(request).toHaveBeenNthCalledWith(1, { url: '/api/v1/edge-nodes', method: 'get', params: { pageIndex: 1, pageSize: 10 } })
|
||||
expect(request).toHaveBeenNthCalledWith(2, { url: '/api/v1/edge-nodes/edge%2Fsite%2001', method: 'get' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,18 @@
|
||||
import { buildEdgeNodeQuery, durationText, eventLabel, freshnessText, stateLabel, stateType } from '@/views/sense/edge-node/edgeNodeState'
|
||||
|
||||
describe('Sense edge node presentation state', () => {
|
||||
test('uses operator-facing connection and channel labels', () => {
|
||||
expect(stateLabel('offline')).toBe('离线')
|
||||
expect(stateLabel('recovering')).toBe('恢复中')
|
||||
expect(stateType('offline')).toBe('danger')
|
||||
expect(eventLabel('node_recovered')).toBe('心跳恢复')
|
||||
})
|
||||
test('makes stale last-known data explicit', () => {
|
||||
expect(freshnessText({ stale: true, freshnessSeconds: 245, lastHeartbeatAt: '2026-08-28T00:00:00Z' })).toBe('已陈旧 4 分钟')
|
||||
expect(freshnessText({ stale: false, freshnessSeconds: 18, lastHeartbeatAt: '2026-08-28T00:00:00Z' })).toBe('18 秒前')
|
||||
expect(durationText(172800)).toBe('2 天')
|
||||
})
|
||||
test('sends only allowlisted pagination fields', () => {
|
||||
expect(buildEdgeNodeQuery({ pageIndex: '2', pageSize: 20, ignored: 'not-sent' })).toEqual({ pageIndex: 2, pageSize: 20 })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,8 @@
|
||||
jest.mock('@/utils/request', () => jest.fn(config => config))
|
||||
import request from '@/utils/request'
|
||||
import { getMediaShard, listMediaShards, preflightMediaShard } from '@/api/sense/media-shard'
|
||||
|
||||
describe('Sense media shard API', () => {
|
||||
beforeEach(() => request.mockClear())
|
||||
test('exposes only read operations and safely encodes ids', () => { expect(listMediaShards()).toEqual({ url: '/api/v1/media-shards', method: 'get' }); expect(getMediaShard('a/b')).toEqual({ url: '/api/v1/media-shards/a%2Fb', method: 'get' }); expect(preflightMediaShard('a b')).toEqual({ url: '/api/v1/media-shards/a%20b/migration-preflight', method: 'get' }); expect(request).toHaveBeenCalledTimes(3) })
|
||||
})
|
||||
@@ -0,0 +1,7 @@
|
||||
import { freshnessText, preflightConclusion, stateLabel, stateType, usagePercent } from '@/views/sense/media-shard/mediaShardState'
|
||||
|
||||
describe('Sense media shard presentation state', () => {
|
||||
test('uses clear operator-facing labels and non-color-only status', () => { expect(stateLabel('failed')).toBe('运行异常'); expect(stateType('failed')).toBe('danger'); expect(stateLabel('external')).toBe('外部运行') })
|
||||
test('derives usage from configured capacity instead of a fixed tier', () => { expect(usagePercent({ assignedPaths: 7, capacity: 23 })).toBe(30); expect(usagePercent({ assignedPaths: 99, capacity: 0 })).toBe(0) })
|
||||
test('makes stale data and read-only preflight explicit', () => { expect(freshnessText({ lastProbeAt: '2026-08-28T00:00:00Z', stale: true })).toBe('状态已陈旧'); expect(preflightConclusion({ ready: true })).toContain('高风险工单'); expect(preflightConclusion({ ready: false })).toContain('不满足') })
|
||||
})
|
||||
@@ -0,0 +1,18 @@
|
||||
import request from '@/utils/request'
|
||||
import { acknowledgeOpsAlert, evaluateOpsAlerts, getOpsAlert, listOpsAlerts, recoverOpsAlert } from '@/api/sense/ops-alert'
|
||||
|
||||
jest.mock('@/utils/request', () => jest.fn(config => config))
|
||||
|
||||
describe('Sense operational alert API', () => {
|
||||
beforeEach(() => request.mockClear())
|
||||
|
||||
test('exposes read, refresh and optimistic state actions', () => {
|
||||
const query = { pageIndex: 1, pageSize: 10, state: 'unacknowledged' }
|
||||
const action = { expectedVersion: 3, reason: '已安排现场人员处理设备故障' }
|
||||
expect(listOpsAlerts(query)).toEqual({ url: '/api/v1/ops-alerts', method: 'get', params: query })
|
||||
expect(getOpsAlert('alert/a b')).toEqual({ url: '/api/v1/ops-alerts/alert%2Fa%20b', method: 'get' })
|
||||
expect(evaluateOpsAlerts()).toEqual({ url: '/api/v1/ops-alerts/evaluate', method: 'post' })
|
||||
expect(acknowledgeOpsAlert('alert/a b', action)).toEqual({ url: '/api/v1/ops-alerts/alert%2Fa%20b/acknowledge', method: 'post', data: action })
|
||||
expect(recoverOpsAlert('alert/a b', action)).toEqual({ url: '/api/v1/ops-alerts/alert%2Fa%20b/recover', method: 'post', data: action })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,25 @@
|
||||
import { alertStateLabel, alertStateType, alertTypeLabel, buildOpsAlertQuery, canAcknowledge, canRecover } from '@/views/sense/ops-alert/opsAlertState'
|
||||
|
||||
describe('Sense operational alert presentation state', () => {
|
||||
test('uses ordinary operations wording and six alert types', () => {
|
||||
expect(alertTypeLabel('device_offline')).toBe('设备离线')
|
||||
expect(alertTypeLabel('authentication_failed')).toBe('认证失败')
|
||||
expect(alertTypeLabel('clock_drift')).toBe('时间漂移')
|
||||
expect(alertTypeLabel('reconciliation_failed')).toBe('状态对账失败')
|
||||
expect(alertTypeLabel('media_shard_failed')).toBe('媒体分片异常')
|
||||
expect(alertTypeLabel('control_tunnel_failed')).toBe('控制隧道异常')
|
||||
expect(alertStateLabel('recovering')).toBe('恢复观察')
|
||||
expect(alertStateType('recovered')).toBe('success')
|
||||
})
|
||||
|
||||
test('enforces the approved state action boundary', () => {
|
||||
expect(canAcknowledge({ state: 'unacknowledged', version: 1 })).toBe(true)
|
||||
expect(canAcknowledge({ state: 'acknowledged', version: 2 })).toBe(false)
|
||||
expect(canRecover({ state: 'recovering', version: 3 })).toBe(true)
|
||||
expect(canRecover({ state: 'acknowledged', version: 3 })).toBe(false)
|
||||
})
|
||||
|
||||
test('builds an allowlisted query', () => {
|
||||
expect(buildOpsAlertQuery({ pageIndex: '2', pageSize: 20, alertType: ' clock_drift ', state: ' recovering ', keyword: ' 南门 ', ignored: true })).toEqual({ pageIndex: 2, pageSize: 20, alertType: 'clock_drift', state: 'recovering', keyword: '南门' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,27 @@
|
||||
import request from '@/utils/request'
|
||||
import { getOutbox, listOutbox, requeueOutbox } from '@/api/sense/outbox'
|
||||
|
||||
jest.mock('@/utils/request', () => jest.fn())
|
||||
|
||||
describe('Sense outbox API', () => {
|
||||
beforeEach(() => request.mockReset())
|
||||
test('encodes identifiers and sends only the recovery fields', () => {
|
||||
listOutbox({ pageIndex: 1, pageSize: 10 })
|
||||
getOutbox('outbox/id unsafe')
|
||||
requeueOutbox('outbox/id unsafe', 4, '出口故障已排除')
|
||||
expect(request).toHaveBeenNthCalledWith(1, {
|
||||
url: '/api/v1/outbox',
|
||||
method: 'get',
|
||||
params: { pageIndex: 1, pageSize: 10 }
|
||||
})
|
||||
expect(request).toHaveBeenNthCalledWith(2, {
|
||||
url: '/api/v1/outbox/outbox%2Fid%20unsafe',
|
||||
method: 'get'
|
||||
})
|
||||
expect(request).toHaveBeenNthCalledWith(3, {
|
||||
url: '/api/v1/outbox/outbox%2Fid%20unsafe/requeue',
|
||||
method: 'post',
|
||||
data: { expectedVersion: 4, reason: '出口故障已排除' }
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,37 @@
|
||||
import {
|
||||
buildOutboxQuery,
|
||||
nextAction,
|
||||
stateLabel,
|
||||
stateType,
|
||||
typeLabel
|
||||
} from '@/views/sense/outbox/outboxState'
|
||||
|
||||
describe('Sense outbox presentation state', () => {
|
||||
test('uses operator-facing labels and non-color-only states', () => {
|
||||
expect(stateLabel('dead')).toBe('死信')
|
||||
expect(stateType('dead')).toBe('danger')
|
||||
expect(typeLabel('local_event_candidate')).toBe('本地事件候选')
|
||||
})
|
||||
test('builds an allowlisted trimmed query', () => {
|
||||
expect(
|
||||
buildOutboxQuery({
|
||||
pageIndex: 2,
|
||||
pageSize: 20,
|
||||
state: 'retry',
|
||||
internalType: 'local_event_candidate',
|
||||
keyword: ' key ',
|
||||
ignored: 'no'
|
||||
})
|
||||
).toEqual({
|
||||
pageIndex: 2,
|
||||
pageSize: 20,
|
||||
state: 'retry',
|
||||
internalType: 'local_event_candidate',
|
||||
keyword: 'key'
|
||||
})
|
||||
})
|
||||
test('explains the next recovery action', () => {
|
||||
expect(nextAction({ state: 'dead' })).toBe('等待人工处理')
|
||||
expect(nextAction({ state: 'pending' })).toBe('等待 worker 领取')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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: 578ddbaae3d7e846037d958085e40609cd398bef
|
||||
synchronized_at: 2026-08-28T08:02:32Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 架构与代码地图
|
||||
@@ -174,3 +174,65 @@ 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 -->
|
||||
|
||||
<!-- sense-edge-nodes:start -->
|
||||
## Sense 边缘节点代码路径
|
||||
|
||||
- 领域投影、DTO、服务、审计、合成夹具与测试:`Sense/server/app/sense/edge_node/`。
|
||||
- GoAdmin 路由:`Sense/server/app/admin/router/sense_edge_node.go`;只开放 `GET /api/v1/edge-nodes` 与 `GET /api/v1/edge-nodes/:id`,复用 JWT、Casbin、PermissionAction 和统一响应封装。
|
||||
- PostgreSQL 表:`sense_edge_nodes` 保存最后已知投影,`sense_edge_node_events` 保存不可变状态转换;迁移 `2026082813000_edge_node.go` 建立动态菜单及 implementation_operator/site_admin/viewer 的只读权限,不写入夹具。
|
||||
- go-admin-ui 页面:`Sense/ui/src/views/sense/edge-node/`;API 封装:`Sense/ui/src/api/sense/edge-node.js`。页面复用 BasicLayout、Element Plus 表格/分页/Dialog/Tag/Alert 和权限指令。
|
||||
- 内部 `ApplyHeartbeat` 是未来 Sense 适配器的投影写入口,本工单不把它暴露为 HTTP API,也不定义跨项目契约。
|
||||
<!-- sense-edge-nodes:end -->
|
||||
|
||||
<!-- sense-media-shards:start -->
|
||||
## Sense MediaMTX 分片代码路径
|
||||
|
||||
- 分片模型、容量分配、健康探测、故障影响和只读迁移预检位于 `Sense/server/app/sense/media_shard/`;既有媒体路由仍由 `Sense/server/app/sense/media/` 管理。
|
||||
- PostgreSQL 表 `sense_media_shards` 保存运行配置投影和健康状态,`sense_media_shard_assignments` 保存路径到分片的稳定归属;`sense_media_routes` 继续作为路径状态事实源。
|
||||
- 新路径在健康且有剩余配置容量的分片间使用 `rendezvous-v1` 确定性分配。已有归属在分片故障时保持不变,不自动迁移。
|
||||
- 主分片继续复用既有 MediaMTX Supervisor;额外分片通过仓库外 JSON 配置接入本机回环 Control API,并按 external 实例管理,不由 Sense 启停。
|
||||
- GoAdmin 路由位于 `Sense/server/app/admin/router/sense_media_shard.go`,只开放列表、详情和迁移预检三个 GET 接口。go-admin-ui 页面位于 `Sense/ui/src/views/sense/media-shard/`,复用 BasicLayout、Element Plus 表格、进度、Dialog、Tag、Alert 和权限指令。
|
||||
- 迁移 `2026082814000_media_shard.go` 建表、注册动态菜单并为 implementation_operator、site_admin、viewer 建立只读权限;生产迁移和启动不写入合成分片。
|
||||
<!-- sense-media-shards:end -->
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user