Files

519 lines
23 KiB
Go
Raw Permalink Normal View History

package onboarding
import (
"context"
"crypto/hmac"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"data-terminal-cloud/internal/deviceactivation"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
type RegisterInput struct {
ChallengeID string `json:"challenge_id"`
BootID string `json:"boot_id"`
Model string `json:"hardware_model"`
HardwareRevision string `json:"hardware_revision"`
FirmwareVersion string `json:"firmware_version"`
SDKVersion string `json:"sdk_version"`
}
func (s *Service) Register(ctx context.Context, d Device, requestID string, raw []byte, in RegisterInput) (map[string]any, error) {
if !uuidV4(in.ChallengeID) || !uuidV4(in.BootID) || in.Model != d.Model || in.HardwareRevision != d.HardwareRevision || len(in.FirmwareVersion) < 1 || len(in.FirmwareVersion) > 32 || len(in.SDKVersion) < 1 || len(in.SDKVersion) > 64 {
return nil, fail(400, "VALIDATION_FAILED", "注册字段与生产记录不一致")
}
return s.mutate(ctx, d, "device:"+d.ID, "register", requestID, raw, 24*time.Hour, func(tx pgx.Tx, current Device) (map[string]any, error) {
tag, err := tx.Exec(ctx, `UPDATE device_time_challenge SET consumed_by=$1::uuid WHERE id=$2::uuid AND device_id=$3::uuid AND key_version=$4 AND boot_id=$5::uuid AND expires_at>now() AND consumed_by IS NULL`, requestID, in.ChallengeID, d.ID, d.KeyVersion, in.BootID)
if err != nil {
return nil, err
}
if tag.RowsAffected() != 1 {
return nil, fail(409, "CHALLENGE_INVALID", "请重新获取校时挑战")
}
if current.BootID != "" && current.BootID != in.BootID {
if _, err = tx.Exec(ctx, `UPDATE device_setup_session SET closed_at=COALESCE(closed_at,now()),close_reason='DEVICE_REBOOT' WHERE device_id=$1::uuid`, d.ID); err != nil {
return nil, err
}
if _, err = tx.Exec(ctx, `UPDATE device_binding_claim SET status='CANCELLED',completed_at=now() WHERE device_id=$1::uuid AND status='PENDING'`, d.ID); err != nil {
return nil, err
}
// A new boot cannot reuse a still-live pre-reboot execution permit:
// the previous SDK call may have reached the supplier before power loss.
pending, e := tx.Exec(ctx, `UPDATE device_activation_attempt a SET status='UNKNOWN' FROM device_activation_job j WHERE a.activation_id=j.id AND j.device_id=$1::uuid AND a.executor='DEVICE' AND a.status='CONTACT_POSSIBLE'`, d.ID)
if e != nil {
return nil, e
}
if pending.RowsAffected() > 0 {
if _, err = tx.Exec(ctx, `UPDATE device_activation_job SET status='RECONCILE_REQUIRED',last_error_code='DEVICE_REBOOT_DURING_ATTEMPT',version=version+1,updated_at=now() WHERE device_id=$1::uuid`, d.ID); err != nil {
return nil, err
}
}
}
_, err = tx.Exec(ctx, `UPDATE device SET onboarding_boot_id=$2::uuid,onboarding_last_seen_at=now(),onboarding_revision=onboarding_revision+1 WHERE id=$1::uuid`, d.ID, in.BootID)
if err != nil {
return nil, err
}
_, err = tx.Exec(ctx, `UPDATE device_twin SET firmware_version=$2,board_revision=$3,rtc_sdk_version=$4,last_reported_at=now(),updated_at=now() WHERE device_id=$1::uuid`, d.ID, in.FirmwareVersion, in.HardwareRevision, in.SDKVersion)
if err != nil {
return nil, err
}
st, err := s.state(ctx, tx, d)
return map[string]any{"state": st, "contract_version": ContractVersion}, err
})
}
type SetupInput struct {
BootID string `json:"boot_id"`
LocalSetupID string `json:"local_setup_id"`
Transport string `json:"transport_profile"`
}
type setup struct {
ID, DeviceID, BootID, LocalID, Mode string
Generation int64
Expires time.Time
Closed *time.Time
}
func readSetup(ctx context.Context, tx pgx.Tx, id string, d Device, open bool) (setup, error) {
var v setup
if !uuidV4(id) {
return v, fail(400, "VALIDATION_FAILED", "设置会话编号不正确")
}
err := tx.QueryRow(ctx, `SELECT id::text,device_id::text,boot_id::text,local_setup_id::text,mode,binding_generation,expires_at,closed_at FROM device_setup_session WHERE id=$1::uuid AND device_id=$2::uuid AND tenant_id=$3::uuid FOR UPDATE`, id, d.ID, d.TenantID).Scan(&v.ID, &v.DeviceID, &v.BootID, &v.LocalID, &v.Mode, &v.Generation, &v.Expires, &v.Closed)
if errors.Is(err, pgx.ErrNoRows) {
return v, errAccess
}
if err != nil {
return v, err
}
if v.BootID != d.BootID {
return v, fail(409, "BOOT_CHANGED", "设备已重启,请恢复新的设置会话")
}
if v.Generation != d.Generation {
return v, errState
}
if open && (v.Closed != nil || !v.Expires.After(time.Now())) {
return v, fail(410, "SETUP_EXPIRED", "设置入口已关闭,请从设备恢复")
}
return v, nil
}
func (s *Service) Setup(ctx context.Context, d Device, requestID string, raw []byte, in SetupInput) (map[string]any, error) {
if !uuidV4(in.BootID) || !uuidV4(in.LocalSetupID) || in.Transport != "LOCAL_WEB" {
return nil, fail(400, "VALIDATION_FAILED", "设置会话参数不正确")
}
return s.mutate(ctx, d, "device:"+d.ID, "setup", requestID, raw, 15*time.Minute, func(tx pgx.Tx, current Device) (map[string]any, error) {
if current.BootID != in.BootID {
return nil, fail(409, "BOOT_CHANGED", "请先完成本次启动注册")
}
st, err := s.state(ctx, tx, current)
if err != nil {
return nil, err
}
mode := "ENROLL"
if st.BindingState == "BOUND" {
mode = "RECOVERY"
}
if mode == "ENROLL" && !s.Enabled() {
return nil, fail(503, "ENROLLMENT_PAUSED", "首次绑定入口暂停")
}
var id string
var expires time.Time
err = tx.QueryRow(ctx, `SELECT id::text,expires_at FROM device_setup_session WHERE device_id=$1::uuid AND boot_id=$2::uuid AND local_setup_id=$3::uuid AND binding_generation=$4 AND mode=$5 AND closed_at IS NULL AND expires_at>now() ORDER BY created_at DESC LIMIT 1`, d.ID, in.BootID, in.LocalSetupID, current.Generation, mode).Scan(&id, &expires)
if errors.Is(err, pgx.ErrNoRows) {
expires = s.now().Add(15 * time.Minute)
err = tx.QueryRow(ctx, `INSERT INTO device_setup_session(device_id,tenant_id,boot_id,local_setup_id,binding_generation,mode,expires_at) VALUES($1::uuid,$2::uuid,$3::uuid,$4::uuid,$5,$6,$7) RETURNING id::text`, d.ID, d.TenantID, in.BootID, in.LocalSetupID, current.Generation, mode, expires).Scan(&id)
}
return map[string]any{"setup_id": id, "expires_at_ms": expires.UnixMilli(), "poll_after_ms": 2000, "state": st}, err
})
}
type HandoffInput struct {
SetupID string `json:"setup_id"`
LocalID string `json:"local_setup_id"`
Challenge string `json:"code_challenge"`
}
func (s *Service) Handoff(ctx context.Context, d Device, requestID string, raw []byte, in HandoffInput) (map[string]any, error) {
if !uuidV4(in.LocalID) || !validB64(in.Challenge, 32) {
return nil, fail(400, "VALIDATION_FAILED", "入口挑战不正确")
}
return s.mutate(ctx, d, "device:"+d.ID, "handoff", requestID, raw, 90*time.Second, func(tx pgx.Tx, current Device) (map[string]any, error) {
setup, err := readSetup(ctx, tx, in.SetupID, current, true)
if err != nil {
return nil, err
}
if setup.Mode != "ENROLL" || setup.LocalID != in.LocalID {
return nil, errAccess
}
var bound bool
if err = tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM device_binding WHERE device_id=$1::uuid AND unbound_at IS NULL)`, d.ID).Scan(&bound); err != nil {
return nil, err
}
if bound {
return nil, errAccess
}
token := randomToken(32)
expires := s.now().Add(90 * time.Second)
if setup.Expires.Before(expires) {
expires = setup.Expires
}
_, err = tx.Exec(ctx, `INSERT INTO device_handoff(token_hash,setup_id,code_challenge,expires_at) VALUES($1,$2::uuid,$3,$4)`, digest([]byte(token)), setup.ID, in.Challenge, expires)
return map[string]any{"handoff_token": token, "expires_at_ms": expires.UnixMilli(), "entry_url": s.options.EntryURL}, err
})
}
type PollInput struct {
SetupID string `json:"setup_id"`
BootID string `json:"boot_id"`
KnownRevision int64 `json:"known_revision"`
}
func (s *Service) Poll(ctx context.Context, d Device, in PollInput) (map[string]any, error) {
if !uuidV4(in.BootID) || in.KnownRevision < 0 {
return nil, fail(400, "VALIDATION_FAILED", "轮询参数不正确")
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
d, err = readDevice(ctx, tx, d.ID, true)
if err != nil {
return nil, err
}
if !deviceAllowed(d) {
return nil, errAccess
}
setup, err := readSetup(ctx, tx, in.SetupID, d, false)
if err != nil {
return nil, err
}
if in.BootID != d.BootID {
return nil, errState
}
if err = s.expireClaims(ctx, tx, d.ID); err != nil {
return nil, err
}
st, err := s.state(ctx, tx, d)
if err != nil {
return nil, err
}
var command any
if setup.Closed == nil && setup.Expires.After(s.now()) && setup.Mode == "ENROLL" {
var id, hash, masked string
var encrypted []byte
var expires time.Time
var gen int64
err = tx.QueryRow(ctx, `SELECT c.id::text,c.challenge_ciphertext,c.binding_digest,c.expected_generation,c.expires_at,COALESCE(ua.display_name,'') FROM device_binding_claim c JOIN user_account ua ON ua.id=c.user_id WHERE c.device_id=$1::uuid AND c.setup_id=$2::uuid AND c.status='PENDING' AND c.expires_at>now()`, d.ID, setup.ID).Scan(&id, &encrypted, &hash, &gen, &expires, &masked)
if err == nil {
plain, e := s.crypt.open(encrypted, "claim:"+id)
if e != nil {
return nil, e
}
runes := []rune(masked)
if len(runes) > 4 {
masked = "尾号" + string(runes[len(runes)-4:])
} else {
masked = "当前登录账号"
}
command = map[string]any{"command_id": id, "type": "CONFIRM_CLAIM", "expires_at_ms": expires.UnixMilli(), "payload": map[string]any{"claim_id": id, "challenge": string(plain), "binding_digest": hash, "expected_binding_generation": gen, "masked_account": masked}}
} else if !errors.Is(err, pgx.ErrNoRows) {
return nil, err
}
}
if command == nil && st.BindingState == "BOUND" {
command, err = s.activationCommand(ctx, tx, d)
if err != nil {
return nil, err
}
}
st, err = s.state(ctx, tx, d)
if err != nil {
return nil, err
}
interval := 2000
if st.BindingState == "BOUND" {
interval = 10000
}
if st.ActivationState == "READY" {
interval = 60000
}
return map[string]any{"state": st, "command": command, "poll_after_ms": interval}, tx.Commit(ctx)
}
func (s *Service) expireClaims(ctx context.Context, tx pgx.Tx, id string) error {
tag, err := tx.Exec(ctx, `UPDATE device_binding_claim SET status='EXPIRED',completed_at=now() WHERE device_id=$1::uuid AND status='PENDING' AND expires_at<=now()`, id)
if err == nil && tag.RowsAffected() > 0 {
err = bump(ctx, tx, id)
}
return err
}
type ConfirmationInput struct {
ClaimID string `json:"claim_id"`
Challenge string `json:"challenge"`
Digest string `json:"binding_digest"`
ExpectedGeneration int64 `json:"expected_binding_generation"`
PressEventID string `json:"press_event_id"`
}
func (s *Service) Confirm(ctx context.Context, d Device, requestID string, raw []byte, in ConfirmationInput) (map[string]any, error) {
if !uuidV4(in.ClaimID) || !uuidV4(in.PressEventID) || !validB64(in.Challenge, 32) || !validDigest(in.Digest) || in.ExpectedGeneration < 0 {
return nil, fail(400, "VALIDATION_FAILED", "设备确认字段不正确")
}
return s.mutate(ctx, d, "device:"+d.ID, "confirm", requestID, raw, 24*time.Hour, func(tx pgx.Tx, current Device) (map[string]any, error) {
var claim claimRecord
var err error
claim, err = readClaim(ctx, tx, in.ClaimID, current.ID)
if err != nil {
return nil, err
}
if err = claimPermission(ctx, tx, claim); err != nil {
return nil, err
}
if !hmac.Equal([]byte(claim.ChallengeHash), []byte(digest([]byte(in.Challenge)))) || !hmac.Equal([]byte(claim.Digest), []byte(in.Digest)) || claim.Generation != in.ExpectedGeneration {
return nil, errState
}
if claim.Status == "CONFIRMED" {
if claim.PressID != in.PressEventID || current.Generation != claim.Generation+1 {
return nil, errState
}
var active bool
err = tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM device_binding WHERE id=$1::uuid AND unbound_at IS NULL AND generation=$2)`, claim.BindingID, current.Generation).Scan(&active)
if err != nil {
return nil, err
}
if !active {
return nil, errState
}
st, e := s.state(ctx, tx, current)
return map[string]any{"claim_id": claim.ID, "state": st, "already_applied": true}, e
}
if current.Generation != in.ExpectedGeneration {
return nil, errState
}
setup, err := readSetup(ctx, tx, claim.SetupID, current, true)
if err != nil {
return nil, err
}
if setup.Mode != "ENROLL" {
return nil, errAccess
}
if claim.Status != "PENDING" || !claim.Expires.After(s.now()) {
return nil, fail(410, "CLAIM_EXPIRED", "绑定申请已过期或取消")
}
var active bool
err = tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM device_binding WHERE device_id=$1::uuid AND unbound_at IS NULL)`, d.ID).Scan(&active)
if err != nil {
return nil, err
}
if active {
return nil, errState
}
if _, err = tx.Exec(ctx, `SELECT set_config('app.onboarding_claim',$1,true)`, claim.ID); err != nil {
return nil, err
}
var bindingID string
err = tx.QueryRow(ctx, `INSERT INTO device_binding(tenant_id,device_id,household_id,subject_id,bound_by,reason) VALUES($1::uuid,$2::uuid,$3::uuid,$4::uuid,$5::uuid,'physical_confirm') RETURNING id::text`, d.TenantID, d.ID, claim.HouseholdID, claim.SubjectID, claim.UserID).Scan(&bindingID)
if err != nil {
return nil, err
}
_, err = tx.Exec(ctx, `UPDATE device_binding_claim SET status='CONFIRMED',press_event_id=$2::uuid,binding_id=$3::uuid,completed_at=now() WHERE id=$1::uuid`, claim.ID, in.PressEventID, bindingID)
if err != nil {
return nil, err
}
_, err = tx.Exec(ctx, `UPDATE device_setup_session SET binding_generation=$2 WHERE id=$1::uuid`, setup.ID, current.Generation+1)
if 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
}
if err = s.enqueueActivation(ctx, tx, current, current.Generation+1, requestID); err != nil {
return nil, err
}
// Match the platform's basic voice mode. Optional storage, extraction and
// personalization stay off until their existing consent flows are used.
_, err = tx.Exec(ctx, `INSERT INTO consent_snapshot(tenant_id,subject_id,snapshot_version,effective_consents,source_consent_ids) SELECT $1::uuid,$2::uuid,1,'{"voice_chat":true,"transcript_storage":false,"raw_audio_storage":false,"story_extraction":false,"personalized_memory":false,"family_sharing":false}'::jsonb,'[]'::jsonb WHERE NOT EXISTS(SELECT 1 FROM consent_snapshot WHERE tenant_id=$1::uuid AND subject_id=$2::uuid)`, current.TenantID, claim.SubjectID)
if err != nil {
return nil, err
}
if err = s.auditEvent(ctx, tx, d, "device", d.PublicID, "device.binding.confirm", requestID, map[string]any{"claim_id": claim.ID, "binding_generation": current.Generation + 1}); err != nil {
return nil, err
}
st, err := s.state(ctx, tx, current)
return map[string]any{"claim_id": claim.ID, "state": st, "already_applied": false}, err
})
}
type claimRecord struct {
ID, SetupID, DeviceID, TenantID, UserID, HouseholdID, SubjectID, ChallengeHash, Digest, Status, BindingID, PressID string
Generation int64
Expires time.Time
}
func readClaim(ctx context.Context, tx pgx.Tx, id, device string) (claimRecord, error) {
var c claimRecord
err := tx.QueryRow(ctx, `SELECT id::text,setup_id::text,device_id::text,tenant_id::text,user_id::text,household_id::text,subject_id::text,challenge_hash,binding_digest,status,COALESCE(binding_id::text,''),COALESCE(press_event_id::text,''),expected_generation,expires_at FROM device_binding_claim WHERE id=$1::uuid AND device_id=$2::uuid FOR UPDATE`, id, device).Scan(&c.ID, &c.SetupID, &c.DeviceID, &c.TenantID, &c.UserID, &c.HouseholdID, &c.SubjectID, &c.ChallengeHash, &c.Digest, &c.Status, &c.BindingID, &c.PressID, &c.Generation, &c.Expires)
if errors.Is(err, pgx.ErrNoRows) {
return c, errAccess
}
return c, err
}
func claimPermission(ctx context.Context, tx pgx.Tx, c claimRecord) error {
var allowed bool
err := tx.QueryRow(ctx, `SELECT true FROM household_member hm JOIN user_account ua ON ua.id=hm.user_id AND ua.status='active' JOIN household h ON h.id=hm.household_id AND h.status='active' JOIN subject_profile sp ON sp.household_id=h.id AND sp.tenant_id=h.tenant_id WHERE ua.id=$1::uuid AND hm.household_id=$2::uuid AND sp.id=$3::uuid AND hm.tenant_id=$4::uuid AND hm.status='active' AND hm.role IN ('owner','admin','editor') AND sp.status='active' FOR SHARE OF ua,hm,h,sp`, c.UserID, c.HouseholdID, c.SubjectID, c.TenantID).Scan(&allowed)
if errors.Is(err, pgx.ErrNoRows) {
return errAccess
}
if err != nil {
return err
}
if !allowed {
return errAccess
}
return nil
}
type StateAckInput struct {
Revision int64 `json:"device_revision"`
Generation int64 `json:"binding_generation"`
LocalGeneration int64 `json:"local_generation"`
Hash string `json:"state_hash"`
}
func (s *Service) StateAck(ctx context.Context, d Device, requestID string, raw []byte, in StateAckInput) (map[string]any, error) {
if in.Revision < 1 || in.Generation < 0 || in.LocalGeneration < 0 || !validDigest(in.Hash) {
return nil, fail(400, "VALIDATION_FAILED", "状态回执不正确")
}
return s.mutate(ctx, d, "device:"+d.ID, "state-ack", requestID, raw, 24*time.Hour, func(tx pgx.Tx, current Device) (map[string]any, error) {
st, err := s.state(ctx, tx, current)
if err != nil {
return nil, err
}
if st.Revision != in.Revision || st.Generation != in.Generation || st.Hash != in.Hash {
return nil, errState
}
var local int64
var boot string
err = tx.QueryRow(ctx, `SELECT local_generation,boot_id::text FROM device_state_ack WHERE device_id=$1::uuid`, d.ID).Scan(&local, &boot)
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return nil, err
}
if err == nil && boot == current.BootID && local > in.LocalGeneration {
return nil, errState
}
_, err = tx.Exec(ctx, `INSERT INTO device_state_ack(device_id,revision,binding_generation,state_hash,boot_id,local_generation) VALUES($1::uuid,$2,$3,$4,$5::uuid,$6) ON CONFLICT(device_id) DO UPDATE SET revision=EXCLUDED.revision,binding_generation=EXCLUDED.binding_generation,state_hash=EXCLUDED.state_hash,boot_id=EXCLUDED.boot_id,local_generation=EXCLUDED.local_generation,accepted_at=CASE WHEN device_state_ack.binding_generation=EXCLUDED.binding_generation AND device_state_ack.boot_id=EXCLUDED.boot_id THEN device_state_ack.accepted_at ELSE now() END`, d.ID, in.Revision, in.Generation, in.Hash, current.BootID, in.LocalGeneration)
if err != nil {
return nil, err
}
canClose, err := s.stateAckCanClose(ctx, tx, current)
return map[string]any{"accepted_revision": in.Revision, "setup_can_close": canClose}, err
})
}
type CloseInput struct {
SetupID string `json:"setup_id"`
Reason string `json:"reason"`
Revision int64 `json:"final_revision"`
}
func (s *Service) Close(ctx context.Context, d Device, requestID string, raw []byte, in CloseInput) (map[string]any, error) {
if !uuidV4(in.SetupID) || in.Revision < 1 || !oneOf(in.Reason, "SUCCESS", "TIMEOUT", "CANCELLED", "NETWORK_SWITCH") {
return nil, fail(400, "VALIDATION_FAILED", "收尾参数不正确")
}
return s.mutate(ctx, d, "device:"+d.ID, "close", requestID, raw, 24*time.Hour, func(tx pgx.Tx, current Device) (map[string]any, error) {
setup, err := readSetup(ctx, tx, in.SetupID, current, false)
if err != nil {
return nil, err
}
if in.Revision > current.Revision {
return nil, errState
}
if in.Reason == "SUCCESS" {
_, allowed, e := s.closeDelay(ctx, tx, current, setup.ID)
if e != nil {
return nil, e
}
if !allowed {
return nil, fail(409, "SETUP_CLOSE_WAIT", "请等待设备和页面回执的收尾时间")
}
var saved bool
err = tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM device_state_ack a JOIN device_binding b ON b.device_id=a.device_id AND b.unbound_at IS NULL WHERE a.device_id=$1::uuid AND a.binding_generation=$2 AND a.boot_id=$3::uuid)`, d.ID, current.Generation, current.BootID).Scan(&saved)
if err != nil {
return nil, err
}
if !saved {
return nil, fail(409, "STATE_ACK_REQUIRED", "设备保存后才能确认绑定收尾")
}
}
if _, err = tx.Exec(ctx, `UPDATE device_setup_session SET closed_at=COALESCE(closed_at,now()),close_reason=COALESCE(close_reason,$2) WHERE id=$1::uuid`, setup.ID, in.Reason); err != nil {
return nil, err
}
tag, err := tx.Exec(ctx, `UPDATE device_binding_claim SET status='CANCELLED',completed_at=now() WHERE setup_id=$1::uuid AND status='PENDING'`, setup.ID)
if err != nil {
return nil, err
}
if tag.RowsAffected() > 0 {
if err = bump(ctx, tx, d.ID); err != nil {
return nil, err
}
}
st, err := s.state(ctx, tx, current)
return map[string]any{"close_allowed": true, "state": st}, err
})
}
func oneOf(s string, values ...string) bool {
for _, v := range values {
if s == v {
return true
}
}
return false
}
func bindingDigest(d Device, user, household, subject, claim string) string {
return digest([]byte(fmt.Sprintf("CLAIM1\n%s\n%s\n%s\n%s\n%s\n%d", d.FactoryID, user, household, subject, claim, d.Generation)))
}
func newClaim(ctx context.Context, s *Service, tx pgx.Tx, d Device, setupID, user, household, subject string) (map[string]any, error) {
if err := s.expireClaims(ctx, tx, d.ID); err != nil {
return nil, err
}
var pending bool
err := tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM device_binding_claim WHERE device_id=$1::uuid AND status='PENDING')`, d.ID).Scan(&pending)
if err != nil {
return nil, err
}
if pending {
return nil, fail(409, "CLAIM_BUSY", "设备正在等待另一笔确认,请稍后再试")
}
id := uuid.NewString()
challenge := randomToken(32)
hash := bindingDigest(d, user, household, subject, id)
expires := s.now().Add(120 * time.Second)
setup, err := readSetup(ctx, tx, setupID, d, true)
if err != nil {
return nil, err
}
if setup.Expires.Before(expires) {
expires = setup.Expires
}
_, err = tx.Exec(ctx, `INSERT INTO device_binding_claim(id,device_id,tenant_id,setup_id,user_id,household_id,subject_id,expected_generation,challenge_hash,challenge_ciphertext,binding_digest,expires_at) VALUES($1::uuid,$2::uuid,$3::uuid,$4::uuid,$5::uuid,$6::uuid,$7::uuid,$8,$9,$10,$11,$12)`, id, d.ID, d.TenantID, setupID, user, household, subject, d.Generation, digest([]byte(challenge)), s.crypt.seal([]byte(challenge), "claim:"+id), hash, expires)
if err != nil {
return nil, err
}
if err = bump(ctx, tx, d.ID); err != nil {
return nil, err
}
st, err := s.state(ctx, tx, d)
return map[string]any{"claim_id": id, "expires_at_ms": expires.UnixMilli(), "state": st}, err
}
// Used by protocol tests without leaking transient challenges into logs.
func requestJSON(v any) []byte { b, _ := json.Marshal(v); return b }
func nonempty(v string, max int) bool { return len(strings.TrimSpace(v)) > 0 && len(v) <= max }