305 lines
13 KiB
Go
305 lines
13 KiB
Go
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, `_`, `\_`)
|
|
}
|