feat: expand companion platform, device controls, and memoir workflows
ci / verify (push) Canceled after 0s

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.
This commit is contained in:
Codex Local
2026-10-08 10:06:44 +08:00
parent 98fb2965d0
commit bf1233d5df
861 changed files with 94614 additions and 3209 deletions
+151
View File
@@ -0,0 +1,151 @@
package onboarding
import (
"context"
"errors"
"time"
"data-terminal-cloud/internal/deviceactivation"
"data-terminal-cloud/internal/identity"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
func bleClaimPermission(ctx context.Context, tx pgx.Tx, c bleClaim) error {
if c.SubjectID != "" {
return claimPermission(ctx, tx, c.claimRecord)
}
var active bool
err := tx.QueryRow(ctx, `SELECT true FROM user_account ua JOIN tenant t ON t.id=ua.tenant_id AND t.status='active' WHERE ua.id=$1::uuid AND ua.tenant_id=$2::uuid AND ua.status='active' AND ua.account_kind='customer' FOR SHARE OF ua,t`, c.UserID, c.TenantID).Scan(&active)
if errors.Is(err, pgx.ErrNoRows) {
return errAccess
}
return err
}
// This runs only after the authenticated device submits the physically confirmed token.
func (s *Service) commitBLEAccount(ctx context.Context, tx pgx.Tx, d Device, c bleClaim, requestID string) (map[string]any, error) {
var owned bool
if err := tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM device_account_binding WHERE device_id=$1::uuid AND released_at IS NULL)`, d.ID).Scan(&owned); err != nil {
return nil, err
}
if owned {
return nil, fail(409, "ALREADY_BOUND", "设备已添加到账号")
}
var ownerID string
err := tx.QueryRow(ctx, `INSERT INTO device_account_binding(device_id,tenant_id,user_id,generation) VALUES($1::uuid,$2::uuid,$3::uuid,$4) RETURNING id::text`, d.ID, d.TenantID, c.UserID, d.Generation+1).Scan(&ownerID)
if err != nil {
return nil, err
}
if _, err = tx.Exec(ctx, `UPDATE device SET binding_generation=binding_generation+1,onboarding_revision=onboarding_revision+1 WHERE id=$1::uuid`, d.ID); err != nil {
return nil, err
}
if _, err = tx.Exec(ctx, `UPDATE device_ble_binding SET status='COMMITTED',account_binding_id=$2::uuid,completed_at=now() WHERE id=$1::uuid`, c.ID, ownerID); err != nil {
return nil, err
}
if _, err = tx.Exec(ctx, `UPDATE device_ble_binding SET status='CANCELLED',completed_at=now() WHERE device_id=$1::uuid AND status='PENDING'`, d.ID); err != nil {
return nil, err
}
if _, err = deviceactivation.Apply(ctx, tx, deviceactivation.Input{DeviceID: d.ID, TenantID: d.TenantID, Target: deviceactivation.Bound, Reason: "蓝牙实体确认添加到账号", Source: "onboarding", ActorType: "device", ActorPublicID: &d.PublicID, RequestID: requestID}); err != nil {
return nil, err
}
// Preserve the existing device response (including the five provider fields).
// Chat still requires a real subject binding; account ownership alone cannot start it.
if err = s.enqueueActivation(ctx, tx, d, d.Generation+1, requestID); err != nil {
return nil, err
}
if err = s.auditEvent(ctx, tx, d, "device", d.PublicID, "device.account.ble", requestID, map[string]any{"account_binding_id": ownerID, "binding_generation": d.Generation + 1}); err != nil {
return nil, err
}
return map[string]any{"status": "success"}, tx.Commit(ctx)
}
type AssignDeviceSubjectInput struct {
DeviceID string `json:"device_id"`
ElderID string `json:"elder_id"`
ExpectedAccountBindingID string `json:"expected_account_binding_id"`
}
// Account ownership already proves physical possession; later subject selection
// requires both that same owner and management access to the selected family.
func (s *Service) AssignDeviceSubject(ctx context.Context, p identity.Principal, requestID string, in AssignDeviceSubjectInput) (map[string]any, error) {
if !uuidValid(in.DeviceID) || !uuidValid(in.ElderID) || !uuidValid(in.ExpectedAccountBindingID) || !uuidV4(requestID) {
return nil, fail(400, "VALIDATION_FAILED", "设备、老人或归属版本不正确")
}
if !s.Enabled() || s.options.PauseClaims {
return nil, fail(503, "ENROLLMENT_PAUSED", "设备绑定暂时暂停")
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
var deviceID string
err = tx.QueryRow(ctx, `SELECT id::text FROM device WHERE public_id=$1::uuid`, in.DeviceID).Scan(&deviceID)
if errors.Is(err, pgx.ErrNoRows) {
return nil, errAccess
}
if err != nil {
return nil, err
}
d, err := readDevice(ctx, tx, deviceID, true)
if err != nil {
return nil, err
}
if !deviceAllowed(d) {
return nil, errAccess
}
var userID, userPublicID, assignedBindingID string
err = tx.QueryRow(ctx, `SELECT ua.id::text,ua.public_id::text,COALESCE(ab.assigned_binding_id::text,'') FROM device_account_binding ab JOIN user_account ua ON ua.id=ab.user_id AND ua.tenant_id=ab.tenant_id JOIN tenant t ON t.id=ua.tenant_id AND t.status='active' WHERE ab.id=$1::uuid AND ab.device_id=$2::uuid AND ((ab.released_at IS NULL AND ab.generation=$3) OR EXISTS(SELECT 1 FROM device_binding b JOIN subject_profile sp ON sp.id=b.subject_id WHERE b.id=ab.assigned_binding_id AND b.unbound_at IS NULL AND b.generation=$3 AND sp.public_id=$6::uuid)) AND ua.oidc_issuer=$4 AND ua.oidc_subject=$5 AND ua.status='active' AND ua.account_kind='customer' FOR SHARE OF ua,t`, in.ExpectedAccountBindingID, d.ID, d.Generation, p.Issuer, p.Subject, in.ElderID).Scan(&userID, &userPublicID, &assignedBindingID)
if errors.Is(err, pgx.ErrNoRows) {
return nil, errAccess
}
if err != nil {
return nil, err
}
c := bleClaim{claimRecord: claimRecord{ID: uuid.NewString(), DeviceID: d.ID, TenantID: d.TenantID, UserID: userID, Generation: d.Generation, Expires: s.now().Add(5 * time.Minute)}, BindingStatus: "PENDING"}
err = tx.QueryRow(ctx, `SELECT id::text,household_id::text FROM subject_profile WHERE public_id=$1::uuid AND tenant_id=$2::uuid`, in.ElderID, d.TenantID).Scan(&c.SubjectID, &c.HouseholdID)
if errors.Is(err, pgx.ErrNoRows) {
return nil, errAccess
}
if err != nil {
return nil, err
}
if err = claimPermission(ctx, tx, c.claimRecord); err != nil {
return nil, err
}
if assignedBindingID != "" {
return map[string]any{"status": "success", "device_id": in.DeviceID, "elder_id": in.ElderID}, tx.Commit(ctx)
}
// Expire stale legacy setup claims before inserting the assignment claim.
if err = s.expireClaims(ctx, tx, d.ID); err != nil {
return nil, err
}
var busy bool
err = tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM device_binding_claim WHERE device_id=$1::uuid AND status='PENDING') OR EXISTS(SELECT 1 FROM fleet_ota WHERE device_id=$1::uuid AND status IN ('pending','accepted'))`, d.ID).Scan(&busy)
if err != nil {
return nil, err
}
if busy {
return nil, fail(409, "DEVICE_BUSY", "设备有待确认操作或升级,请完成后重试")
}
token := randomToken(32)
if _, err = tx.Exec(ctx, `UPDATE device_ble_binding SET status='CANCELLED',completed_at=now() WHERE device_id=$1::uuid AND user_id=$2::uuid AND status='PENDING'`, d.ID, userID); err != nil {
return nil, err
}
_, err = tx.Exec(ctx, `INSERT INTO device_ble_binding(id,token_hash,device_id,tenant_id,user_id,household_id,subject_id,expected_generation,expires_at) VALUES($1::uuid,$2,$3::uuid,$4::uuid,$5::uuid,$6::uuid,$7::uuid,$8,$9)`, c.ID, digest([]byte(token)), d.ID, d.TenantID, userID, c.HouseholdID, c.SubjectID, d.Generation, c.Expires)
if err != nil {
return nil, err
}
if _, err = tx.Exec(ctx, `SELECT set_config('app.device_account_binding',$1,true)`, in.ExpectedAccountBindingID); err != nil {
return nil, err
}
c.AccountBindingID = in.ExpectedAccountBindingID
if _, err = s.commitBLESubject(ctx, tx, d, c, requestID, token); err != nil {
return nil, err
}
if err = s.auditEvent(ctx, tx, d, "user", userPublicID, "device.account.assign_subject", requestID, map[string]any{"elder_id": in.ElderID, "account_binding_id": in.ExpectedAccountBindingID}); err != nil {
return nil, err
}
return map[string]any{"status": "success", "device_id": in.DeviceID, "elder_id": in.ElderID}, tx.Commit(ctx)
}