feat: add versioned Sense areas and directional lines (#27)

This commit is contained in:
QiuSW
2026-08-12 17:29:09 +08:00
parent 8509699fff
commit e2e18b0380
17 changed files with 593 additions and 0 deletions
@@ -0,0 +1,19 @@
package area
import (
"context"
"fmt"
"sync/atomic"
)
var activeService atomic.Pointer[Service]
// ExportCurrent is a project-internal adapter port for a future versioned
// contract mapper. The internal Area type is not a shared Brain contract.
func ExportCurrent(ctx context.Context, deviceID string) ([]Area, error) {
service := activeService.Load()
if service == nil {
return nil, fmt.Errorf("area service is not ready")
}
return service.List(ctx, deviceID)
}
+77
View File
@@ -0,0 +1,77 @@
package area
import (
"fmt"
"math"
)
const epsilon = 1e-9
func ValidateGeometry(kind, direction string, points []Point) error {
for _, point := range points {
if point.X < 0 || point.X > 1 || point.Y < 0 || point.Y > 1 {
return fmt.Errorf("坐标必须位于画面范围内")
}
}
switch kind {
case KindPolygon:
if len(points) < 3 {
return fmt.Errorf("多边形至少需要 3 个点")
}
if math.Abs(polygonArea(points)) < epsilon {
return fmt.Errorf("多边形不能退化为直线")
}
if selfIntersects(points, true) {
return fmt.Errorf("多边形不能自交")
}
case KindLine:
if len(points) != 2 {
return fmt.Errorf("警戒线必须恰好包含 2 个点")
}
if distance(points[0], points[1]) < epsilon {
return fmt.Errorf("警戒线两点不能重合")
}
if direction != DirectionAToB && direction != DirectionBToA && direction != DirectionBoth {
return fmt.Errorf("警戒线方向不正确")
}
default:
return fmt.Errorf("区域类型不正确")
}
return nil
}
func polygonArea(points []Point) float64 {
sum := 0.0
for i := range points {
next := (i + 1) % len(points)
sum += points[i].X*points[next].Y - points[next].X*points[i].Y
}
return sum / 2
}
func distance(a, b Point) float64 { dx, dy := a.X-b.X, a.Y-b.Y; return dx*dx + dy*dy }
func selfIntersects(points []Point, closed bool) bool {
segments := len(points) - 1
if closed {
segments = len(points)
}
for i := 0; i < segments; i++ {
a1, a2 := points[i], points[(i+1)%len(points)]
if distance(a1, a2) < epsilon {
return true
}
for j := i + 1; j < segments; j++ {
if j == i+1 || closed && i == 0 && j == segments-1 {
continue
}
b1, b2 := points[j], points[(j+1)%len(points)]
if segmentsIntersect(a1, a2, b1, b2) {
return true
}
}
}
return false
}
func segmentsIntersect(a, b, c, d Point) bool {
o1, o2, o3, o4 := orientation(a, b, c), orientation(a, b, d), orientation(c, d, a), orientation(c, d, b)
return o1*o2 < 0 && o3*o4 < 0
}
func orientation(a, b, c Point) float64 { return (b.X-a.X)*(c.Y-a.Y) - (b.Y-a.Y)*(c.X-a.X) }
@@ -0,0 +1,19 @@
package area
import "testing"
func TestValidateGeometry(t *testing.T) {
tests := []struct {
name, kind, direction string
points []Point
valid bool
}{{"polygon", KindPolygon, "", []Point{{.1, .1}, {.9, .1}, {.5, .8}}, true}, {"self intersection", KindPolygon, "", []Point{{.1, .1}, {.9, .9}, {.9, .1}, {.1, .9}}, false}, {"outside", KindPolygon, "", []Point{{-.1, .1}, {.9, .1}, {.5, .8}}, false}, {"line", KindLine, DirectionAToB, []Point{{.1, .1}, {.9, .9}}, true}, {"line direction", KindLine, "", []Point{{.1, .1}, {.9, .9}}, false}, {"line degenerate", KindLine, DirectionBoth, []Point{{.1, .1}, {.1, .1}}, false}}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
err := ValidateGeometry(test.kind, test.direction, test.points)
if (err == nil) != test.valid {
t.Fatalf("err=%v valid=%v", err, test.valid)
}
})
}
}
+89
View File
@@ -0,0 +1,89 @@
package area
import (
"net/http"
"yovision.local/sense/app/sense/identity"
"yovision.local/sense/internal/platform"
)
type Module struct{ service *Service }
func NewModule(service *Service) *Module { return &Module{service: service} }
func (m *Module) Register(app *platform.App) {
activeService.Store(m.service)
app.Handle("GET /api/v1/areas", identity.Require(identity.PermissionAreaRead, http.HandlerFunc(m.list)))
app.Handle("POST /api/v1/areas", identity.Require(identity.PermissionAreaWrite, http.HandlerFunc(m.create)))
app.Handle("PATCH /api/v1/areas/{id}", identity.Require(identity.PermissionAreaWrite, http.HandlerFunc(m.update)))
app.Handle("POST /api/v1/areas/{id}/recalibrate", identity.Require(identity.PermissionAreaWrite, http.HandlerFunc(m.recalibrate)))
app.Handle("POST /api/v1/areas/{id}/disable", identity.Require(identity.PermissionAreaWrite, http.HandlerFunc(m.disable)))
}
type writeRequest struct {
DeviceID string `json:"device_id"`
ProfileToken string `json:"profile_token"`
Name string `json:"name"`
Kind string `json:"kind"`
Direction string `json:"direction"`
Points []Point `json:"points"`
Version int64 `json:"version"`
}
func (m *Module) list(w http.ResponseWriter, r *http.Request) {
items, err := m.service.List(r.Context(), r.URL.Query().Get("device_id"))
if err != nil {
platform.WriteError(w, err)
return
}
platform.WriteJSON(w, http.StatusOK, map[string]any{"items": items, "total": len(items)})
}
func (m *Module) create(w http.ResponseWriter, r *http.Request) {
var req writeRequest
if err := platform.DecodeJSON(r, &req); err != nil {
platform.WriteError(w, err)
return
}
actor, _ := identity.PrincipalFromContext(r.Context())
item, err := m.service.Create(r.Context(), actor, req.DeviceID, req.ProfileToken, req.Name, req.Kind, req.Direction, req.Points)
writeResult(w, item, err, http.StatusCreated)
}
func (m *Module) update(w http.ResponseWriter, r *http.Request) { m.writeExisting(w, r, false) }
func (m *Module) recalibrate(w http.ResponseWriter, r *http.Request) { m.writeExisting(w, r, true) }
func (m *Module) writeExisting(w http.ResponseWriter, r *http.Request, recalibrate bool) {
var req writeRequest
if err := platform.DecodeJSON(r, &req); err != nil {
platform.WriteError(w, err)
return
}
actor, _ := identity.PrincipalFromContext(r.Context())
item, err := m.service.Update(r.Context(), actor, r.PathValue("id"), req.Name, req.Direction, req.Points, req.Version, recalibrate)
writeResult(w, item, err, http.StatusOK)
}
func (m *Module) disable(w http.ResponseWriter, r *http.Request) {
var req struct {
Version int64 `json:"version"`
}
if err := platform.DecodeJSON(r, &req); err != nil {
platform.WriteError(w, err)
return
}
actor, _ := identity.PrincipalFromContext(r.Context())
item, err := m.service.Disable(r.Context(), actor, r.PathValue("id"), req.Version)
writeResult(w, item, err, http.StatusOK)
}
func writeResult(w http.ResponseWriter, item Area, err error, status int) {
if err != nil {
code := "area_invalid"
httpStatus := http.StatusBadRequest
if err == ErrConflict {
code = "version_conflict"
httpStatus = http.StatusConflict
}
if err == ErrNotFound {
code = "area_not_found"
httpStatus = http.StatusNotFound
}
platform.WriteError(w, &platform.APIError{Status: httpStatus, Code: code, Message: err.Error()})
return
}
platform.WriteJSON(w, status, item)
}
+3
View File
@@ -0,0 +1,3 @@
package area
const MigrationSQL = `CREATE TABLE IF NOT EXISTS sense_area_versions(id TEXT NOT NULL,version BIGINT NOT NULL,device_id TEXT NOT NULL,profile_token TEXT NOT NULL,width INTEGER NOT NULL,height INTEGER NOT NULL,name TEXT NOT NULL,kind TEXT NOT NULL,direction TEXT NOT NULL DEFAULT '',points JSONB NOT NULL,enabled BOOLEAN NOT NULL,recalibration_required BOOLEAN NOT NULL DEFAULT FALSE,created_at TIMESTAMPTZ NOT NULL,updated_at TIMESTAMPTZ NOT NULL,PRIMARY KEY(id,version));CREATE INDEX IF NOT EXISTS sense_area_device_idx ON sense_area_versions(device_id,id,version DESC);`
+117
View File
@@ -0,0 +1,117 @@
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)
}
@@ -0,0 +1,36 @@
package area
import (
"context"
"testing"
"yovision.local/sense/app/sense/admission"
"yovision.local/sense/app/sense/identity"
)
func TestVersionsConflictAndRecalibration(t *testing.T) {
service := NewService(NewMemoryStore())
width := 1920
service.profile = func(string, string) (admission.Profile, error) {
return admission.Profile{Width: width, Height: 1080}, nil
}
item, err := service.Create(context.Background(), identity.Principal{}, "device", "main", "区域", KindPolygon, "", []Point{{.1, .1}, {.9, .1}, {.5, .8}})
if err != nil {
t.Fatal(err)
}
updated, err := service.Update(context.Background(), identity.Principal{}, item.ID, "区域2", "", item.Points, item.Version, false)
if err != nil || updated.Version != 2 {
t.Fatalf("updated=%#v err=%v", updated, err)
}
if _, err := service.Update(context.Background(), identity.Principal{}, item.ID, "冲突", "", item.Points, item.Version, false); err != ErrConflict {
t.Fatalf("conflict err=%v", err)
}
width = 1280
flagged, err := service.Update(context.Background(), identity.Principal{}, item.ID, updated.Name, "", updated.Points, updated.Version, false)
if err != nil || !flagged.RecalibrationRequired {
t.Fatalf("flagged=%#v err=%v", flagged, err)
}
recalibrated, err := service.Update(context.Background(), identity.Principal{}, item.ID, updated.Name, "", updated.Points, flagged.Version, true)
if err != nil || recalibrated.RecalibrationRequired || recalibrated.Width != 1280 {
t.Fatalf("recalibrated=%#v err=%v", recalibrated, err)
}
}
+120
View File
@@ -0,0 +1,120 @@
package area
import (
"context"
"database/sql"
"encoding/json"
"errors"
"sync"
)
var (
ErrNotFound = errors.New("area not found")
ErrConflict = errors.New("area version conflict")
)
type Store interface {
Save(context.Context, Area, int64) error
Get(context.Context, string) (Area, error)
List(context.Context, string) ([]Area, error)
}
type MemoryStore struct {
mu sync.RWMutex
versions map[string][]Area
}
func NewMemoryStore() *MemoryStore { return &MemoryStore{versions: map[string][]Area{}} }
func (s *MemoryStore) Save(_ context.Context, item Area, expected int64) error {
s.mu.Lock()
defer s.mu.Unlock()
values := s.versions[item.ID]
if len(values) == 0 {
if expected != 0 {
return ErrConflict
}
} else if values[len(values)-1].Version != expected {
return ErrConflict
}
s.versions[item.ID] = append(values, item)
return nil
}
func (s *MemoryStore) Get(_ context.Context, id string) (Area, error) {
s.mu.RLock()
defer s.mu.RUnlock()
values := s.versions[id]
if len(values) == 0 {
return Area{}, ErrNotFound
}
return values[len(values)-1], nil
}
func (s *MemoryStore) List(_ context.Context, deviceID string) ([]Area, error) {
s.mu.RLock()
defer s.mu.RUnlock()
items := []Area{}
for _, values := range s.versions {
item := values[len(values)-1]
if deviceID == "" || item.DeviceID == deviceID {
items = append(items, item)
}
}
return items, nil
}
type PostgresStore struct{ database *sql.DB }
func NewPostgresStore(db *sql.DB) *PostgresStore { return &PostgresStore{database: db} }
func (s *PostgresStore) Save(ctx context.Context, item Area, expected int64) error {
tx, err := s.database.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelSerializable})
if err != nil {
return err
}
defer tx.Rollback()
var current int64
err = tx.QueryRowContext(ctx, `SELECT COALESCE(max(version),0) FROM sense_area_versions WHERE id=$1`, item.ID).Scan(&current)
if err != nil {
return err
}
if current != expected {
return ErrConflict
}
points, _ := json.Marshal(item.Points)
_, err = tx.ExecContext(ctx, `INSERT INTO sense_area_versions(id,version,device_id,profile_token,width,height,name,kind,direction,points,enabled,recalibration_required,created_at,updated_at) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)`, item.ID, item.Version, item.DeviceID, item.ProfileToken, item.Width, item.Height, item.Name, item.Kind, item.Direction, points, item.Enabled, item.RecalibrationRequired, item.CreatedAt, item.UpdatedAt)
if err != nil {
return err
}
return tx.Commit()
}
func (s *PostgresStore) Get(ctx context.Context, id string) (Area, error) {
return scan(s.database.QueryRowContext(ctx, `SELECT id,version,device_id,profile_token,width,height,name,kind,direction,points,enabled,recalibration_required,created_at,updated_at FROM sense_area_versions WHERE id=$1 ORDER BY version DESC LIMIT 1`, id))
}
type scanner interface{ Scan(...any) error }
func scan(row scanner) (Area, error) {
var item Area
var points []byte
err := row.Scan(&item.ID, &item.Version, &item.DeviceID, &item.ProfileToken, &item.Width, &item.Height, &item.Name, &item.Kind, &item.Direction, &points, &item.Enabled, &item.RecalibrationRequired, &item.CreatedAt, &item.UpdatedAt)
if errors.Is(err, sql.ErrNoRows) {
return Area{}, ErrNotFound
}
if err == nil {
err = json.Unmarshal(points, &item.Points)
}
return item, err
}
func (s *PostgresStore) List(ctx context.Context, deviceID string) ([]Area, error) {
rows, err := s.database.QueryContext(ctx, `SELECT DISTINCT ON(id) id,version,device_id,profile_token,width,height,name,kind,direction,points,enabled,recalibration_required,created_at,updated_at FROM sense_area_versions WHERE ($1='' OR device_id=$1) ORDER BY id,version DESC`, deviceID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []Area{}
for rows.Next() {
item, err := scan(rows)
if err != nil {
return nil, err
}
items = append(items, item)
}
return items, rows.Err()
}
+32
View File
@@ -0,0 +1,32 @@
package area
import "time"
const (
KindPolygon = "polygon"
KindLine = "line"
DirectionAToB = "a_to_b"
DirectionBToA = "b_to_a"
DirectionBoth = "both"
)
type Point struct {
X float64 `json:"x"`
Y float64 `json:"y"`
}
type Area struct {
ID string `json:"id"`
DeviceID string `json:"device_id"`
ProfileToken string `json:"profile_token"`
Width int `json:"width"`
Height int `json:"height"`
Name string `json:"name"`
Kind string `json:"kind"`
Direction string `json:"direction,omitempty"`
Points []Point `json:"points"`
Enabled bool `json:"enabled"`
Version int64 `json:"version"`
RecalibrationRequired bool `json:"recalibration_required"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
+20
View File
@@ -0,0 +1,20 @@
package sense
import (
"yovision.local/sense/app/sense/area"
"yovision.local/sense/internal/platform"
)
func init() {
registerModule(func(app *platform.App) error {
var store area.Store
if app.Config().DatabaseMode == platform.DatabaseModeMemory {
store = area.NewMemoryStore()
} else {
store = area.NewPostgresStore(app.Database())
}
app.RegisterMigration(platform.Migration{Version: 2026081204, Name: "sense_area", SQL: area.MigrationSQL})
area.NewModule(area.NewService(store)).Register(app)
return nil
})
}
@@ -0,0 +1,3 @@
CREATE TABLE IF NOT EXISTS sense_area_versions(id TEXT NOT NULL,version BIGINT NOT NULL,device_id TEXT NOT NULL,profile_token TEXT NOT NULL,width INTEGER NOT NULL,height INTEGER NOT NULL,name TEXT NOT NULL,kind TEXT NOT NULL,direction TEXT NOT NULL DEFAULT '',points JSONB NOT NULL,enabled BOOLEAN NOT NULL,recalibration_required BOOLEAN NOT NULL DEFAULT FALSE,created_at TIMESTAMPTZ NOT NULL,updated_at TIMESTAMPTZ NOT NULL,PRIMARY KEY(id,version));
CREATE INDEX IF NOT EXISTS sense_area_device_idx ON sense_area_versions(device_id,id,version DESC);