package onboarding import ( "context" "errors" "time" "data-terminal-cloud/internal/identity" "github.com/jackc/pgx/v5" ) type WeChatLoginInput struct { LoginCode string `json:"login_code"` PhoneCode string `json:"phone_code,omitempty"` } type WeChatPasswordInput struct { PasswordLoginInput LoginCode string `json:"login_code"` } func (s *Service) weChatPasswordLogin(ctx context.Context, hash string, in WeChatPasswordInput) (map[string]any, error) { if s.operatorLogin { return nil, fail(403, "CUSTOMER_REQUIRED", "微信登录仅用于家庭账号") } if s.options.WeChat == nil || s.options.WeChatAppID == "" { return nil, fail(503, "WECHAT_NOT_CONFIGURED", "微信登录尚未开通,请使用手机号登录") } if in.LoginCode == "" || len(in.LoginCode) > 512 { return nil, fail(400, "INVALID_WECHAT_CODE", "请重新发起微信登录") } if err := s.limit(ctx, "wechat-login:"+hash, 12, time.Minute); err != nil { return nil, err } openID, err := s.options.WeChat.OpenID(ctx, in.LoginCode) if err != nil { return nil, err } out, err := s.passwordLoginLinked(ctx, hash, in.PasswordLoginInput, openID) if err != nil { return nil, err } return map[string]any{"access_token": "customer." + out.AccountToken, "portal": "customer", "expires_in": 86400}, nil } func (s *Service) linkWeChatPhone(ctx context.Context, tx pgx.Tx, openID, phoneHash string) error { if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1,65))`, s.options.WeChatAppID+":"+openID); err != nil { return err } if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1,65))`, s.options.WeChatAppID+":phone:"+phoneHash); err != nil { return err } var conflict bool if err := tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM customer_wechat_identity WHERE app_id=$1 AND ((open_id=$2 AND phone_digest<>$3) OR (open_id<>$2 AND phone_digest=$3)))`, s.options.WeChatAppID, openID, phoneHash).Scan(&conflict); err != nil { return err } if conflict { return fail(409, "WECHAT_PHONE_CONFLICT", "微信或手机号已关联其他账号,请使用原账号登录") } _, err := tx.Exec(ctx, `INSERT INTO customer_wechat_identity(app_id,open_id,phone_digest) VALUES($1,$2,$3) ON CONFLICT(app_id,open_id) DO NOTHING`, s.options.WeChatAppID, openID, phoneHash) return err } func (s *Service) weChatLogin(ctx context.Context, sessionHash string, in WeChatLoginInput) (map[string]any, error) { if s.operatorLogin { return nil, fail(403, "CUSTOMER_REQUIRED", "微信登录仅用于家庭账号") } if s.options.WeChat == nil || s.options.WeChatAppID == "" { return nil, fail(503, "WECHAT_NOT_CONFIGURED", "微信登录尚未开通,请使用手机号登录") } if in.LoginCode == "" || len(in.LoginCode) > 512 || len(in.PhoneCode) > 512 { return nil, fail(400, "INVALID_WECHAT_CODE", "请重新发起微信登录") } if err := s.limit(ctx, "wechat-login:"+sessionHash, 12, time.Minute); err != nil { return nil, err } ctx, cancel := context.WithTimeout(ctx, 18*time.Second) defer cancel() openID, err := s.options.WeChat.OpenID(ctx, in.LoginCode) if err != nil { return nil, err } // Reuse the existing identity on later logins; a client never supplies an // openid or an unverified phone number to choose the account it receives. verifiedPhone := "" if in.PhoneCode != "" { verifiedPhone, err = s.options.WeChat.Phone(ctx, in.PhoneCode) if err != nil { return nil, err } } tx, err := s.pool.Begin(ctx) if err != nil { return nil, err } defer tx.Rollback(ctx) if err = s.checkLoginSession(ctx, tx, sessionHash); err != nil { return nil, err } if _, err = tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1,65))`, s.options.WeChatAppID+":"+openID); err != nil { return nil, err } var phoneHash string err = tx.QueryRow(ctx, `SELECT phone_digest FROM customer_wechat_identity WHERE app_id=$1 AND open_id=$2`, s.options.WeChatAppID, openID).Scan(&phoneHash) fresh := errors.Is(err, pgx.ErrNoRows) if err != nil && !fresh { return nil, err } if fresh && verifiedPhone == "" { return map[string]any{"phone_required": true}, nil } if verifiedPhone != "" { if !phonePattern.MatchString(verifiedPhone) { return nil, fail(400, "WECHAT_PHONE_INVALID", "手机号授权失败") } verifiedHash := domainMAC(s.crypt.hashKey, "PHONE1\n"+verifiedPhone) if !fresh && phoneHash != verifiedHash { return nil, fail(409, "WECHAT_PHONE_CONFLICT", "微信已关联其他手机号,请使用原账号登录") } phoneHash = verifiedHash } if _, err = tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1,65))`, s.options.WeChatAppID+":phone:"+phoneHash); err != nil { return nil, err } if fresh { var other bool if err = tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM customer_wechat_identity WHERE app_id=$1 AND phone_digest=$2)`, s.options.WeChatAppID, phoneHash).Scan(&other); err != nil { return nil, err } if other { return nil, fail(409, "WECHAT_PHONE_CONFLICT", "该手机号已关联其他微信,请使用手机号密码登录") } p := identity.Principal{Issuer: "urn:platform:onboarding:sms", Subject: phoneHash} masked := verifiedPhone[:len(verifiedPhone)-8] + "****" + verifiedPhone[len(verifiedPhone)-4:] if _, err = tx.Exec(ctx, `INSERT INTO customer_phone_credential(phone_digest,phone_masked,issuer,subject) VALUES($1,$2,$3,$4) ON CONFLICT(phone_digest) DO NOTHING`, phoneHash, masked, p.Issuer, p.Subject); err != nil { return nil, err } } var p identity.Principal err = tx.QueryRow(ctx, `SELECT issuer,subject,phone_masked FROM customer_phone_credential WHERE phone_digest=$1 FOR SHARE`, phoneHash).Scan(&p.Issuer, &p.Subject, &p.DisplayName) if err != nil { return nil, err } out, err := s.attachCustomerLogin(ctx, tx, sessionHash, p, fresh) if err != nil { return nil, err } if fresh { if _, err = tx.Exec(ctx, `INSERT INTO customer_wechat_identity(app_id,open_id,phone_digest) VALUES($1,$2,$3)`, s.options.WeChatAppID, openID, phoneHash); err != nil { return nil, err } } // Merely logging in with an already-linked OpenID is not a fresh SMS/phone // verification and must not grant password-reset privileges. if _, err = tx.Exec(ctx, `UPDATE onboarding_account_session SET phone_digest=$2 WHERE token_hash=$1`, digest([]byte(out.AccountToken)), phoneHash); err != nil { return nil, err } if err = tx.Commit(ctx); err != nil { return nil, err } return map[string]any{"access_token": "customer." + out.AccountToken, "portal": "customer", "expires_in": 86400}, nil }