package area import ( "context" "crypto/rand" "encoding/hex" "fmt" "strings" "time" "yovision.local/sense/app/sense/admission" "yovision.local/sense/app/sense/identity" ) type Service struct { store Store profile func(string, string) (admission.Profile, error) now func() time.Time } func NewService(store Store) *Service { return &Service{store: store, profile: admission.VerifiedProfile, now: time.Now} } func (s *Service) Create(ctx context.Context, actor identity.Principal, deviceID, profileToken, name, kind, direction string, points []Point) (Area, error) { profile, err := s.profile(deviceID, profileToken) if err != nil { return Area{}, err } if err := ValidateGeometry(kind, direction, points); err != nil { return Area{}, err } name = strings.TrimSpace(name) if name == "" { return Area{}, fmt.Errorf("名称不能为空") } now := s.now().UTC() item := Area{ID: newID(), DeviceID: deviceID, ProfileToken: profileToken, Width: profile.Width, Height: profile.Height, Name: name, Kind: kind, Direction: direction, Points: points, Enabled: true, Version: 1, CreatedAt: now, UpdatedAt: now} if err := s.store.Save(ctx, item, 0); err != nil { return Area{}, err } identity.RecordAudit(ctx, actor.UserID, "area.create", item.ID, "success", map[string]any{"version": 1, "kind": kind}) return item, nil } func (s *Service) Update(ctx context.Context, actor identity.Principal, id, name, direction string, points []Point, expected int64, recalibrate bool) (Area, error) { current, err := s.store.Get(ctx, id) if err != nil { return Area{}, err } if current.Version != expected { return Area{}, ErrConflict } profile, err := s.profile(current.DeviceID, current.ProfileToken) if err != nil { return Area{}, err } resolutionChanged := profile.Width != current.Width || profile.Height != current.Height if resolutionChanged && !recalibrate { current.RecalibrationRequired = true current.Version++ current.UpdatedAt = s.now().UTC() if err := s.store.Save(ctx, current, expected); err != nil { return Area{}, err } return current, nil } if err := ValidateGeometry(current.Kind, direction, points); err != nil { return Area{}, err } current.Name = strings.TrimSpace(name) current.Direction = direction current.Points = points current.Width = profile.Width current.Height = profile.Height current.RecalibrationRequired = false current.Version++ current.UpdatedAt = s.now().UTC() if err := s.store.Save(ctx, current, expected); err != nil { return Area{}, err } identity.RecordAudit(ctx, actor.UserID, "area.publish", id, "success", map[string]any{"version": current.Version, "recalibrated": recalibrate}) return current, nil } func (s *Service) Disable(ctx context.Context, actor identity.Principal, id string, expected int64) (Area, error) { item, err := s.store.Get(ctx, id) if err != nil { return Area{}, err } if item.Version != expected { return Area{}, ErrConflict } item.Enabled = false item.Version++ item.UpdatedAt = s.now().UTC() if err := s.store.Save(ctx, item, expected); err != nil { return Area{}, err } identity.RecordAudit(ctx, actor.UserID, "area.disable", id, "success", map[string]any{"version": item.Version}) return item, nil } func (s *Service) List(ctx context.Context, deviceID string) ([]Area, error) { items, err := s.store.List(ctx, deviceID) if err != nil { return nil, err } for i := range items { if profile, profileErr := s.profile(items[i].DeviceID, items[i].ProfileToken); profileErr == nil && (profile.Width != items[i].Width || profile.Height != items[i].Height) { items[i].RecalibrationRequired = true } } return items, nil } func newID() string { b := make([]byte, 16) if _, err := rand.Read(b); err != nil { panic(err) } return "area_" + hex.EncodeToString(b) }