Files
Codex Local bf1233d5df
ci / verify (push) Canceled after 0s
feat: expand companion platform, device controls, and memoir workflows
Add customer and operator login, BLE provisioning, device fleet controls, call memory, memoir generation, and refreshed family and maintenance portals. Include API contracts, migrations, firmware sources, tests, assets, and deployment documentation.
2026-10-08 10:06:44 +08:00

94 lines
4.1 KiB
Go

package onboarding
import (
"context"
"errors"
"github.com/jackc/pgx/v5"
)
// Join the direct-call transaction fence before taking the same twin/device
// lock order as commands and OTA. A new prepare cannot race the final idle check.
func lockBLEControl(ctx context.Context, tx pgx.Tx, deviceID, factoryID string) error {
if deviceID == "" {
err := tx.QueryRow(ctx, `SELECT id::text FROM device WHERE factory_device_id=$1`, factoryID).Scan(&deviceID)
if errors.Is(err, pgx.ErrNoRows) {
return nil
} // Normal access validation supplies the precise error.
if err != nil {
return err
}
}
if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1,11))`, deviceID); err != nil {
return err
}
_, err := tx.Exec(ctx, `SELECT device_id FROM device_twin WHERE device_id=$1::uuid FOR UPDATE`, deviceID)
return err
}
func (s *Service) checkBLETransfer(ctx context.Context, tx pgx.Tx, d Device, target claimRecord, sourceID string) error {
source := claimRecord{TenantID: d.TenantID, UserID: target.UserID}
err := tx.QueryRow(ctx, `SELECT household_id::text,subject_id::text FROM device_binding WHERE id=$1::uuid AND device_id=$2::uuid AND tenant_id=$3::uuid AND unbound_at IS NULL AND generation=$4 FOR UPDATE`, sourceID, d.ID, d.TenantID, d.Generation).Scan(&source.HouseholdID, &source.SubjectID)
if errors.Is(err, pgx.ErrNoRows) {
return fail(409, "BINDING_CHANGED", "设备归属已变化,请刷新后重新选择")
}
if err != nil {
return err
}
if err = claimPermission(ctx, tx, source); err != nil {
return err
}
if source.SubjectID == target.SubjectID {
return fail(400, "ELDER_UNCHANGED", "请选择另一位使用老人")
}
if err = s.expireClaims(ctx, tx, d.ID); err != nil {
return err
}
var busy bool
err = tx.QueryRow(ctx, `SELECT
EXISTS(SELECT 1 FROM call_session WHERE device_id=$1::uuid AND call_state IN ('PREPARING','STARTING','TALKING','STOPPING')) OR
EXISTS(SELECT 1 FROM conversation WHERE device_id=$1::uuid AND status NOT IN ('COMPLETED','FAILED','EXPIRED')) OR
EXISTS(SELECT 1 FROM device_command WHERE device_id=$1::uuid AND status IN ('QUEUED','DELIVERED','ACKNOWLEDGED') AND expires_at>now()) OR
EXISTS(SELECT 1 FROM firmware_assignment WHERE device_id=$1::uuid AND status IN ('PENDING','ASSIGNED','COMMAND_QUEUED','DOWNLOADING','INSTALLING')) OR
EXISTS(SELECT 1 FROM fleet_ota WHERE device_id=$1::uuid AND status IN ('pending','accepted')) OR
EXISTS(SELECT 1 FROM device_binding_claim WHERE device_id=$1::uuid AND status='PENDING') OR
EXISTS(SELECT 1 FROM device_account_binding WHERE device_id=$1::uuid AND released_at IS NULL)`, d.ID).Scan(&busy)
if err != nil {
return err
}
if busy {
return fail(409, "DEVICE_BUSY", "设备有通话、命令、升级或待确认操作,请完成后重试")
}
return nil
}
// Only commitBLEBinding, after DEVREQ1 device authentication, can reach this.
// The old assignment stays active while a token waits for the physical tap.
func (s *Service) commitBLETransfer(ctx context.Context, tx pgx.Tx, d Device, c bleClaim, requestID, token string) (map[string]any, error) {
if err := s.checkBLETransfer(ctx, tx, d, c.claimRecord, c.BindingID); err != nil {
return nil, err
}
sourceID := c.BindingID
if _, err := tx.Exec(ctx, `UPDATE device_binding SET unbound_at=now(),unbound_by=$2::uuid,reason='ble_physical_transfer' WHERE id=$1::uuid`, sourceID, c.UserID); err != nil {
return nil, err
}
// Closing the old binding advances the generation. The new claim must be
// fenced against that value, and its receipt must survive response retries.
d, err := readDevice(ctx, tx, d.ID, true)
if err != nil {
return nil, err
}
c.Generation = d.Generation
if _, err = tx.Exec(ctx, `UPDATE device_ble_binding SET expected_generation=$2 WHERE id=$1::uuid`, c.ID, d.Generation); err != nil {
return nil, err
}
result, err := s.commitBLESubject(ctx, tx, d, c, requestID, token)
if err != nil {
return nil, err
}
if err = s.auditEvent(ctx, tx, d, "device", d.PublicID, "device.binding.ble_transfer", requestID, map[string]any{"previous_binding_id": sourceID, "binding_generation": d.Generation + 1}); err != nil {
return nil, err
}
return result, tx.Commit(ctx)
}