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
+110 -31
View File
@@ -24,6 +24,10 @@ import (
type SMSProvider interface {
Send(context.Context, string, string, string) error
}
type purposeSMSProvider interface {
SendForPurpose(context.Context, string, string, string, string) error
}
type HTTPSMSProvider struct {
endpoint, token string
client *http.Client
@@ -62,24 +66,47 @@ func (p *HTTPSMSProvider) Send(ctx context.Context, phone, code, id string) erro
var phonePattern = regexp.MustCompile(`^\+[1-9][0-9]{7,14}$`)
var smsCodePattern = regexp.MustCompile(`^[0-9]{6}$`)
const smsCodeLifetime = 5 * time.Minute
type SMSSendInput struct {
Phone string `json:"phone_e164"`
Purpose string `json:"purpose"`
CaptchaTicket string `json:"captcha_ticket,omitempty"`
CaptchaRandstr string `json:"captcha_randstr,omitempty"`
Phone string `json:"phone_e164"`
Purpose string `json:"purpose"`
CaptchaID string `json:"captcha_id"`
CaptchaCode string `json:"captcha_code"`
}
func (s *Service) SendSMS(ctx context.Context, w WebSession, requestID string, in SMSSendInput) (map[string]any, error) {
return s.sendSMS(ctx, w.Hash, requestID, in, "onboarding_sms_challenge", func(tx pgx.Tx) error {
d, err := readDevice(ctx, tx, w.Device.ID, true)
if err != nil {
return err
}
return s.checkWebActor(ctx, tx, w.Hash, d)
})
}
// table is an internal constant, never supplied by a request. Both login entry
// points share phone limits, code hashing, delivery idempotency and attempt caps.
func (s *Service) sendSMS(ctx context.Context, sessionHash, requestID string, in SMSSendInput, table string, check func(pgx.Tx) error) (map[string]any, error) {
if !s.options.SMSLoginEnabled {
return nil, fail(503, "SMS_UNAVAILABLE", "短信登录暂未开放,请使用账号登录")
return nil, fail(503, "SMS_UNAVAILABLE", "短信登录暂未开放,请稍后重试")
}
if !uuidV4(requestID) || !phonePattern.MatchString(in.Phone) || in.Purpose != "LOGIN" {
if !uuidV4(requestID) || !phonePattern.MatchString(in.Phone) {
return nil, fail(400, "VALIDATION_FAILED", "请使用包含国家区号的手机号码")
}
// These routes authenticate an account (including the existing password
// recovery flow). Phone changes require their own authenticated workflow.
if in.Purpose != SMSPurposeLogin && in.Purpose != SMSPurposeChangePassword {
return nil, fail(400, "VALIDATION_FAILED", "当前接口不支持此短信验证码用途")
}
phoneHash := domainMAC(s.crypt.hashKey, "PHONE1\n"+in.Phone)
// Keyed phone hashes prevent offline enumeration of stored phone numbers.
if err := s.limit(ctx, "sms:global", 1000, time.Hour); err != nil {
return nil, err
}
if err := s.limit(ctx, "sms:session:"+w.Hash, 10, time.Hour); err != nil {
if err := s.limit(ctx, "sms:session:"+sessionHash, 10, time.Hour); err != nil {
return nil, err
}
tx, err := s.pool.Begin(ctx)
@@ -87,22 +114,18 @@ func (s *Service) SendSMS(ctx context.Context, w WebSession, requestID string, i
return nil, err
}
defer tx.Rollback(ctx)
d, err := readDevice(ctx, tx, w.Device.ID, true)
if err != nil {
return nil, err
}
if err = s.checkWebActor(ctx, tx, w.Hash, d); err != nil {
if err = check(tx); err != nil {
return nil, err
}
if _, err = tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1,0))`, "sms:"+phoneHash); err != nil {
return nil, err
}
var id, status, stored string
var id, status, stored, storedPurpose string
var expires time.Time
err = tx.QueryRow(ctx, `SELECT id::text,status,phone_digest,expires_at FROM onboarding_sms_challenge WHERE session_hash=$1 AND request_id=$2::uuid`, w.Hash, requestID).Scan(&id, &status, &stored, &expires)
err = tx.QueryRow(ctx, `SELECT id::text,status,phone_digest,expires_at,purpose FROM `+table+` WHERE session_hash=$1 AND request_id=$2::uuid`, sessionHash, requestID).Scan(&id, &status, &stored, &expires, &storedPurpose)
if err == nil {
if stored != phoneHash {
return nil, fail(409, "REQUEST_BODY_CONFLICT", "请求号已用于其他手机号码")
if stored != phoneHash || storedPurpose != in.Purpose {
return nil, fail(409, "REQUEST_BODY_CONFLICT", "请求号已用于其他手机号码或短信用途")
}
if status != "SENT" || !expires.After(s.now()) {
return nil, fail(409, "SMS_SEND_UNCERTAIN", "请等待重发间隔后重新申请验证码")
@@ -112,35 +135,59 @@ func (s *Service) SendSMS(ctx context.Context, w WebSession, requestID string, i
if !errors.Is(err, pgx.ErrNoRows) {
return nil, err
}
// Exact delivery retries above return only the previous result. Every new
// delivery must consume a fresh challenge bound to this browser session.
if err = s.verifyLoginCaptcha(ctx, sessionHash, in.CaptchaID, in.CaptchaCode, in.CaptchaTicket, in.CaptchaRandstr); err != nil {
return nil, err
}
var recent, hourCount int
err = tx.QueryRow(ctx, `SELECT count(*) FILTER(WHERE created_at>now()-interval '60 seconds'),count(*) FROM onboarding_sms_challenge WHERE phone_digest=$1 AND created_at>now()-interval '1 hour'`, phoneHash).Scan(&recent, &hourCount)
err = tx.QueryRow(ctx, `SELECT count(*) FILTER(WHERE created_at>now()-interval '60 seconds'),count(*) FROM (
SELECT created_at FROM onboarding_sms_challenge WHERE phone_digest=$1 AND created_at>now()-interval '1 hour'
UNION ALL SELECT created_at FROM customer_sms_challenge WHERE phone_digest=$1 AND created_at>now()-interval '1 hour'
UNION ALL SELECT created_at FROM operator_sms_challenge WHERE phone_digest=$1 AND created_at>now()-interval '1 hour'
) sends`, phoneHash).Scan(&recent, &hourCount)
if err != nil {
return nil, err
}
if recent > 0 || hourCount >= 5 {
return nil, fail(429, "SMS_RATE_LIMITED", "发送过于频繁,请稍后再试")
}
templatePurpose := in.Purpose
if in.Purpose == SMSPurposeLogin && !s.operatorLogin {
var registered bool
if err = tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM `+s.phoneCredentialTable()+` WHERE phone_digest=$1)`, phoneHash).Scan(&registered); err != nil {
return nil, err
}
if !registered {
templatePurpose = SMSPurposeRegister
}
}
n, err := rand.Int(rand.Reader, big.NewInt(1000000))
if err != nil {
return nil, err
}
code := fmt.Sprintf("%06d", n.Int64())
id = uuid.NewString()
expires = s.now().Add(5 * time.Minute)
expires = s.now().Add(smsCodeLifetime)
codeHash := domainMAC(s.smsHashKey(), id+"\n"+code)
_, err = tx.Exec(ctx, `INSERT INTO onboarding_sms_challenge(id,session_hash,phone_digest,phone_ciphertext,code_hash,request_id,expires_at) VALUES($1::uuid,$2,$3,$4,$5,$6::uuid,$7)`, id, w.Hash, phoneHash, s.crypt.seal([]byte(in.Phone), "sms-phone:"+id), codeHash, requestID, expires)
_, err = tx.Exec(ctx, `INSERT INTO `+table+`(id,session_hash,phone_digest,phone_ciphertext,code_hash,request_id,expires_at,purpose) VALUES($1::uuid,$2,$3,$4,$5,$6::uuid,$7,$8)`, id, sessionHash, phoneHash, s.crypt.seal([]byte(in.Phone), "sms-phone:"+id), codeHash, requestID, expires, in.Purpose)
if err != nil {
return nil, err
}
if err = tx.Commit(ctx); err != nil {
return nil, err
}
sendErr := s.options.SMS.Send(ctx, in.Phone, code, id)
var sendErr error
if sender, ok := s.options.SMS.(purposeSMSProvider); ok {
sendErr = sender.SendForPurpose(ctx, in.Phone, code, id, templatePurpose)
} else {
sendErr = s.options.SMS.Send(ctx, in.Phone, code, id)
}
status = "SENT"
if sendErr != nil {
status = "FAILED"
}
if _, err = s.pool.Exec(ctx, `UPDATE onboarding_sms_challenge SET status=$2 WHERE id=$1::uuid AND status='SENDING'`, id, status); err != nil {
if _, err = s.pool.Exec(ctx, `UPDATE `+table+` SET status=$2 WHERE id=$1::uuid AND status='SENDING'`, id, status); err != nil {
return nil, err
}
if sendErr != nil {
@@ -159,6 +206,20 @@ type SMSVerifyInput struct {
}
func (s *Service) VerifySMS(ctx context.Context, w WebSession, in SMSVerifyInput) (BrowserCredentials, error) {
var d Device
return s.verifySMS(ctx, w.Hash, in, "onboarding_sms_challenge", func(tx pgx.Tx) error {
var err error
d, err = readDevice(ctx, tx, w.Device.ID, true)
if err != nil {
return err
}
return s.checkWebActor(ctx, tx, w.Hash, d)
}, func(tx pgx.Tx, p identity.Principal) (BrowserCredentials, error) {
return s.attachPrincipal(ctx, tx, w, d, p, true)
})
}
func (s *Service) verifySMS(ctx context.Context, sessionHash string, in SMSVerifyInput, table string, check func(pgx.Tx) error, attach func(pgx.Tx, identity.Principal) (BrowserCredentials, error)) (BrowserCredentials, error) {
var out BrowserCredentials
if !s.options.SMSLoginEnabled {
return out, fail(503, "SMS_UNAVAILABLE", "短信登录暂未开放")
@@ -171,18 +232,14 @@ func (s *Service) VerifySMS(ctx context.Context, w WebSession, in SMSVerifyInput
return out, err
}
defer tx.Rollback(ctx)
d, err := readDevice(ctx, tx, w.Device.ID, true)
if err != nil {
return out, err
}
if err = s.checkWebActor(ctx, tx, w.Hash, d); err != nil {
if err = check(tx); err != nil {
return out, err
}
var expected, phoneHash, status string
var encrypted []byte
var attempts int
var expires time.Time
err = tx.QueryRow(ctx, `SELECT code_hash,phone_digest,phone_ciphertext,status,attempts,expires_at FROM onboarding_sms_challenge WHERE id=$1::uuid AND session_hash=$2 FOR UPDATE`, in.ChallengeID, w.Hash).Scan(&expected, &phoneHash, &encrypted, &status, &attempts, &expires)
err = tx.QueryRow(ctx, `SELECT code_hash,phone_digest,phone_ciphertext,status,attempts,expires_at FROM `+table+` WHERE id=$1::uuid AND session_hash=$2 FOR UPDATE`, in.ChallengeID, sessionHash).Scan(&expected, &phoneHash, &encrypted, &status, &attempts, &expires)
if errors.Is(err, pgx.ErrNoRows) {
return out, fail(401, "SMS_CODE_INVALID", "验证码错误或已失效")
}
@@ -193,7 +250,7 @@ func (s *Service) VerifySMS(ctx context.Context, w WebSession, in SMSVerifyInput
return out, fail(401, "SMS_CODE_INVALID", "验证码错误或已失效")
}
if !hmac.Equal([]byte(expected), []byte(domainMAC(s.smsHashKey(), in.ChallengeID+"\n"+in.Code))) {
if _, err = tx.Exec(ctx, `UPDATE onboarding_sms_challenge SET attempts=attempts+1 WHERE id=$1::uuid`, in.ChallengeID); err != nil {
if _, err = tx.Exec(ctx, `UPDATE `+table+` SET attempts=attempts+1 WHERE id=$1::uuid`, in.ChallengeID); err != nil {
return out, err
}
if err = tx.Commit(ctx); err != nil {
@@ -207,11 +264,33 @@ func (s *Service) VerifySMS(ctx context.Context, w WebSession, in SMSVerifyInput
}
masked := "用户" + string(phone[len(phone)-4:])
p := identity.Principal{Issuer: "urn:platform:onboarding:sms", Subject: phoneHash, DisplayName: masked}
out, err = s.attachPrincipal(ctx, tx, w, d, p, true)
// Resolve both login methods to the same verified phone identity.
if !s.operatorLogin {
_, err = tx.Exec(ctx, `INSERT INTO `+s.phoneCredentialTable()+`(phone_digest,phone_masked,issuer,subject) VALUES($1,$2,$3,$4) ON CONFLICT(phone_digest) DO NOTHING`, phoneHash, string(phone[:len(phone)-8])+"****"+string(phone[len(phone)-4:]), p.Issuer, p.Subject)
if err != nil {
return out, err
}
}
err = tx.QueryRow(ctx, `SELECT issuer,subject FROM `+s.phoneCredentialTable()+` WHERE phone_digest=$1 FOR SHARE`, phoneHash).Scan(&p.Issuer, &p.Subject)
if errors.Is(err, pgx.ErrNoRows) && s.operatorLogin {
return out, fail(403, "OPERATOR_ACCOUNT_UNAVAILABLE", "该手机号尚未开通管理权限,请联系管理员")
}
if err != nil {
return out, err
}
_, err = tx.Exec(ctx, `UPDATE onboarding_sms_challenge SET status='CONSUMED',consumed_at=now(),attempts=attempts+1 WHERE id=$1::uuid`, in.ChallengeID)
out, err = attach(tx, p)
if err != nil {
return out, err
}
_, err = tx.Exec(ctx, `UPDATE `+s.accountSessionTable()+` SET phone_digest=$2,phone_verified_at=$3 WHERE token_hash=$1`, digest([]byte(out.AccountToken)), phoneHash, s.now())
if err != nil {
return out, err
}
_, err = tx.Exec(ctx, `UPDATE `+table+` SET status='CONSUMED',consumed_at=now(),attempts=attempts+1 WHERE id=$1::uuid`, in.ChallengeID)
if err != nil {
return out, err
}
out.TrustedDeviceToken, err = s.rememberLoginDevice(ctx, tx, phoneHash)
if err != nil {
return out, err
}
@@ -223,11 +302,11 @@ func (s *Service) AccountPrincipal(ctx context.Context, token, csrf string, writ
var p identity.Principal
var expected string
if !validB64(token, 32) {
return p, errAccess
return p, fail(401, "LOGIN_REQUIRED", "登录已失效,请重新登录")
}
err := s.pool.QueryRow(ctx, `SELECT ua.oidc_issuer,ua.oidc_subject,COALESCE(ua.display_name,''),ss.csrf_hash FROM onboarding_account_session ss JOIN user_account ua ON ua.id=ss.user_id AND ua.status='active' JOIN tenant t ON t.id=ua.tenant_id AND t.status='active' WHERE ss.token_hash=$1 AND ss.expires_at>now() AND ss.revoked_at IS NULL`, digest([]byte(token))).Scan(&p.Issuer, &p.Subject, &p.DisplayName, &expected)
err := s.pool.QueryRow(ctx, `SELECT ua.oidc_issuer,ua.oidc_subject,COALESCE(ua.display_name,''),ss.csrf_hash FROM `+s.accountSessionTable()+` ss JOIN user_account ua ON ua.id=ss.user_id AND ua.status='active' AND `+s.accountPredicate()+` JOIN tenant t ON t.id=ua.tenant_id AND t.status='active' WHERE ss.token_hash=$1 AND ss.expires_at>now() AND ss.revoked_at IS NULL`, digest([]byte(token))).Scan(&p.Issuer, &p.Subject, &p.DisplayName, &expected)
if errors.Is(err, pgx.ErrNoRows) {
return p, errAccess
return p, fail(401, "LOGIN_REQUIRED", "登录已失效,请重新登录")
}
if err != nil {
return p, err