package area import ( "context" "encoding/json" "errors" "fmt" "strings" "time" "unicode/utf8" "github.com/google/uuid" "gorm.io/gorm" "gorm.io/gorm/clause" ) var ( ErrInvalidRequest = errors.New("区域配置请求不符合要求") ErrNotFound = errors.New("区域配置不存在") ErrConflict = errors.New("区域配置已被其他用户更新,请刷新后重试") ErrProfileMissing = errors.New("绑定的视频 Profile 不可用,请先完成视频接入") ) type Service struct{ db *gorm.DB } func NewService(db *gorm.DB) *Service { return &Service{db: db} } type routeSnapshot struct { RouteID string `gorm:"column:route_id"` DeviceID string `gorm:"column:device_id"` DeviceName string `gorm:"column:device_name"` DeviceLocation string `gorm:"column:device_location"` ProfileToken string `gorm:"column:profile_token"` ProfileName string `gorm:"column:profile_name"` Width int `gorm:"column:width"` Height int `gorm:"column:height"` Encoding string `gorm:"column:encoding"` Verification string `gorm:"column:verification_status"` } func (s *Service) List(ctx context.Context, request PageRequest) ([]Response, int64, error) { request.PageIndex, request.PageSize = normalizePage(request.PageIndex, request.PageSize) if utf8.RuneCountInString(request.Keyword) > 128 || (request.Kind != "" && request.Kind != KindPolygon && request.Kind != KindDirectionLine) { return nil, 0, ErrInvalidRequest } query := s.db.WithContext(ctx).Model(&Definition{}) if keyword := strings.TrimSpace(request.Keyword); keyword != "" { pattern := "%" + escapeLike(keyword) + "%" query = query.Where("LOWER(name) LIKE LOWER(?) ESCAPE '\\'", pattern) } if request.Kind != "" { query = query.Where("kind = ?", request.Kind) } switch request.RecalibrationState { case "", "all": case "needed": query = query.Where("needs_recalibration = ?", true) case "ready": query = query.Where("needs_recalibration = ?", false) default: return nil, 0, ErrInvalidRequest } var total int64 if err := query.Count(&total).Error; err != nil { return nil, 0, err } var definitions []Definition if err := query.Order("updated_at DESC, name ASC").Offset((request.PageIndex - 1) * request.PageSize).Limit(request.PageSize).Find(&definitions).Error; err != nil { return nil, 0, err } items := make([]Response, 0, len(definitions)) for i := range definitions { item, err := s.response(ctx, &definitions[i]) if err != nil { return nil, 0, err } items = append(items, item) } return items, total, nil } func (s *Service) Create(ctx context.Context, request UpsertRequest) (Response, error) { if request.ExpectedVersion != 0 { return Response{}, ErrInvalidRequest } name, err := validateRequest(request) if err != nil { return Response{}, err } now := time.Now().UTC() definitionID := uuid.NewString() versionID := uuid.NewString() err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { route, routeErr := loadRoute(tx, request.RouteID) if routeErr != nil { return routeErr } geometry, marshalErr := json.Marshal(request.Points) if marshalErr != nil { return ErrInvalidGeometry } definition := Definition{ID: definitionID, Name: name, Kind: request.Kind, DeviceID: route.DeviceID, ProfileToken: route.ProfileToken, ProfileWidth: route.Width, ProfileHeight: route.Height, ProfileEncoding: route.Encoding, CurrentVersion: 1, CurrentVersionID: versionID, Enabled: request.Enabled, CreatedBy: request.UpdateBy, UpdatedBy: request.UpdateBy, CreatedAt: now, UpdatedAt: now} version := Version{ID: versionID, DefinitionID: definitionID, Version: 1, Name: name, Kind: request.Kind, DeviceID: route.DeviceID, ProfileToken: route.ProfileToken, ProfileWidth: route.Width, ProfileHeight: route.Height, ProfileEncoding: route.Encoding, GeometryJSON: string(geometry), Direction: request.Direction, Enabled: request.Enabled, CreatedBy: request.UpdateBy, CreatedAt: now} if err = tx.Create(&definition).Error; err != nil { return err } return tx.Create(&version).Error }) if err != nil { return Response{}, err } return s.Get(ctx, definitionID) } func (s *Service) Update(ctx context.Context, id string, request UpsertRequest) (Response, error) { if strings.TrimSpace(id) == "" || request.ExpectedVersion < 1 { return Response{}, ErrInvalidRequest } name, err := validateRequest(request) if err != nil { return Response{}, err } now := time.Now().UTC() err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { var current Definition if readErr := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(¤t, "id = ?", id).Error; readErr != nil { if errors.Is(readErr, gorm.ErrRecordNotFound) { return ErrNotFound } return readErr } if current.CurrentVersion != request.ExpectedVersion { return ErrConflict } route, routeErr := loadRoute(tx, request.RouteID) if routeErr != nil { return routeErr } geometry, marshalErr := json.Marshal(request.Points) if marshalErr != nil { return ErrInvalidGeometry } versionID := uuid.NewString() version := Version{ID: versionID, DefinitionID: current.ID, Version: current.CurrentVersion + 1, Name: name, Kind: request.Kind, DeviceID: route.DeviceID, ProfileToken: route.ProfileToken, ProfileWidth: route.Width, ProfileHeight: route.Height, ProfileEncoding: route.Encoding, GeometryJSON: string(geometry), Direction: request.Direction, Enabled: request.Enabled, SupersedesID: current.CurrentVersionID, CreatedBy: request.UpdateBy, CreatedAt: now} if createErr := tx.Create(&version).Error; createErr != nil { return createErr } updates := map[string]any{"name": name, "kind": request.Kind, "device_id": route.DeviceID, "profile_token": route.ProfileToken, "profile_width": route.Width, "profile_height": route.Height, "profile_encoding": route.Encoding, "current_version": version.Version, "current_version_id": versionID, "enabled": request.Enabled, "needs_recalibration": false, "updated_by": request.UpdateBy, "updated_at": now} result := tx.Model(&Definition{}).Where("id = ? AND current_version = ?", current.ID, request.ExpectedVersion).Updates(updates) if result.Error != nil { return result.Error } if result.RowsAffected != 1 { return ErrConflict } return nil }) if err != nil { return Response{}, err } return s.Get(ctx, id) } func (s *Service) Get(ctx context.Context, id string) (Response, error) { var definition Definition if err := s.db.WithContext(ctx).First(&definition, "id = ?", id).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return Response{}, ErrNotFound } return Response{}, err } return s.response(ctx, &definition) } func (s *Service) Versions(ctx context.Context, id string) ([]VersionResponse, error) { var count int64 if err := s.db.WithContext(ctx).Model(&Definition{}).Where("id = ?", id).Count(&count).Error; err != nil { return nil, err } if count == 0 { return nil, ErrNotFound } var versions []Version if err := s.db.WithContext(ctx).Where("definition_id = ?", id).Order("version DESC").Find(&versions).Error; err != nil { return nil, err } out := make([]VersionResponse, 0, len(versions)) for _, version := range versions { points, err := decodePoints(version.GeometryJSON) if err != nil { return nil, fmt.Errorf("decode area version %d: %w", version.Version, err) } out = append(out, VersionResponse{ID: version.ID, Version: version.Version, Name: version.Name, Kind: version.Kind, DeviceID: version.DeviceID, ProfileToken: version.ProfileToken, ProfileWidth: version.ProfileWidth, ProfileHeight: version.ProfileHeight, ProfileEncoding: version.ProfileEncoding, Points: points, Direction: version.Direction, Enabled: version.Enabled, SupersedesID: version.SupersedesID, CreatedBy: version.CreatedBy, CreatedAt: version.CreatedAt}) } return out, nil } func (s *Service) response(ctx context.Context, definition *Definition) (Response, error) { var version Version if err := s.db.WithContext(ctx).First(&version, "id = ?", definition.CurrentVersionID).Error; err != nil { return Response{}, err } points, err := decodePoints(version.GeometryJSON) if err != nil { return Response{}, err } var route routeSnapshot query := s.db.WithContext(ctx).Table("sense_devices AS d"). Select("COALESCE(r.id, '') AS route_id, d.id AS device_id, d.name AS device_name, d.location AS device_location, COALESCE(p.token, '') AS profile_token, COALESCE(p.name, '') AS profile_name, COALESCE(p.width, 0) AS width, COALESCE(p.height, 0) AS height, COALESCE(p.encoding, '') AS encoding, COALESCE(p.verification_status, '') AS verification_status"). Joins("LEFT JOIN sense_admission_profiles AS p ON p.device_id = d.id AND p.token = ?", definition.ProfileToken). Joins("LEFT JOIN sense_media_routes AS r ON r.device_id = d.id AND r.profile_token = ?", definition.ProfileToken). Where("d.id = ?", definition.DeviceID).Limit(1).Scan(&route) if query.Error != nil { return Response{}, query.Error } recalibration := definition.NeedsRecalibration || route.ProfileToken == "" || route.Verification != "ready" || route.Width != definition.ProfileWidth || route.Height != definition.ProfileHeight || !strings.EqualFold(route.Encoding, definition.ProfileEncoding) if recalibration && !definition.NeedsRecalibration { if err = s.db.WithContext(ctx).Model(&Definition{}).Where("id = ? AND needs_recalibration = ?", definition.ID, false).Update("needs_recalibration", true).Error; err != nil { return Response{}, err } definition.NeedsRecalibration = true } return Response{ID: definition.ID, Name: definition.Name, Kind: definition.Kind, DeviceID: definition.DeviceID, DeviceName: route.DeviceName, DeviceLocation: route.DeviceLocation, ProfileToken: definition.ProfileToken, ProfileName: route.ProfileName, ProfileWidth: definition.ProfileWidth, ProfileHeight: definition.ProfileHeight, ProfileEncoding: definition.ProfileEncoding, RouteID: route.RouteID, Version: definition.CurrentVersion, Points: points, Direction: version.Direction, Enabled: definition.Enabled, NeedsRecalibration: recalibration, UpdatedBy: definition.UpdatedBy, UpdatedAt: definition.UpdatedAt}, nil } func MarkProfilesReplaced(tx *gorm.DB, deviceID string, profiles []ProfileSnapshot) error { if tx == nil || !tx.Migrator().HasTable(&Definition{}) { return nil } available := make(map[string]ProfileSnapshot, len(profiles)) for _, profile := range profiles { available[profile.Token] = profile } var definitions []Definition if err := tx.Where("device_id = ? AND needs_recalibration = ?", deviceID, false).Find(&definitions).Error; err != nil { return err } for _, definition := range definitions { profile, ok := available[definition.ProfileToken] if !ok || profile.Width != definition.ProfileWidth || profile.Height != definition.ProfileHeight || !strings.EqualFold(profile.Encoding, definition.ProfileEncoding) { if err := tx.Model(&Definition{}).Where("id = ?", definition.ID).Update("needs_recalibration", true).Error; err != nil { return err } } } return nil } func loadRoute(db *gorm.DB, routeID string) (routeSnapshot, error) { if strings.TrimSpace(routeID) == "" { return routeSnapshot{}, ErrProfileMissing } var route routeSnapshot err := db.Table("sense_media_routes AS r"). Select("r.id AS route_id, r.device_id, d.name AS device_name, d.location AS device_location, r.profile_token, p.name AS profile_name, p.width, p.height, p.encoding, p.verification_status"). Joins("JOIN sense_devices AS d ON d.id = r.device_id"). Joins("JOIN sense_admission_profiles AS p ON p.device_id = r.device_id AND p.token = r.profile_token"). Where("r.id = ? AND p.verification_status = ?", routeID, "ready").Limit(1).Scan(&route).Error if err != nil { return routeSnapshot{}, err } if route.RouteID == "" || route.Width < 1 || route.Height < 1 { return routeSnapshot{}, ErrProfileMissing } return route, nil } func validateRequest(request UpsertRequest) (string, error) { name := strings.TrimSpace(request.Name) if name == "" || utf8.RuneCountInString(name) > 128 || request.UpdateBy < 1 || strings.TrimSpace(request.RouteID) == "" { return "", ErrInvalidRequest } if err := validateGeometry(request.Kind, request.Direction, request.Points); err != nil { return "", err } return name, nil } func decodePoints(value string) ([]Point, error) { var points []Point if err := json.Unmarshal([]byte(value), &points); err != nil { return nil, err } return points, nil } func normalizePage(index, size int) (int, int) { if index < 1 { index = 1 } if size < 1 { size = 10 } if size > 50 { size = 50 } return index, size } func escapeLike(value string) string { value = strings.ReplaceAll(value, `\`, `\\`) value = strings.ReplaceAll(value, `%`, `\%`) return strings.ReplaceAll(value, `_`, `\_`) }