2026-07-06 11:10:19 +08:00

749 lines
35 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package auth
import (
"context"
"fmt"
"log/slog"
"strings"
"time"
_ "time/tzdata"
"unicode/utf8"
"hyapp/pkg/appcode"
"hyapp/pkg/logx"
"hyapp/pkg/xerr"
authdomain "hyapp/services/user-service/internal/domain/auth"
invitedomain "hyapp/services/user-service/internal/domain/invite"
userdomain "hyapp/services/user-service/internal/domain/user"
)
const (
// 以下长度必须和 services/user-service/deploy/mysql/initdb/001_user_service.sql 的 users 表 VARCHAR 保持一致。
maxRegistrationUsername = userdomain.ProfileUsernameMaxRunes
maxRegistrationGender = 32
maxRegistrationCountry = 64
maxRegistrationInviteCode = 64
maxRegistrationIP = 64
maxRegistrationUserAgent = 512
maxRegistrationCountryByIP = 64
maxRegistrationDeviceID = 128
maxRegistrationDevice = 128
maxRegistrationOSVersion = 64
maxRegistrationAvatar = userdomain.ProfileAvatarMaxRunes
maxRegistrationAppVersion = 64
maxRegistrationBuildNumber = 64
maxRegistrationSource = 64
maxRegistrationInstallChannel = 64
maxRegistrationCampaign = 128
maxRegistrationPlatform = 16
maxRegistrationLanguage = 32
maxRegistrationTimezone = 64
)
// LoginThirdParty 校验三方凭证,存在则登录,不存在则创建用户和三方绑定。
func (s *Service) LoginThirdParty(ctx context.Context, provider string, credential string, registration authdomain.ThirdPartyRegistration, meta Meta) (authdomain.Token, bool, error) {
meta.AppCode = appcode.Normalize(meta.AppCode)
registration.AppCode = meta.AppCode
provider = strings.ToLower(strings.TrimSpace(provider))
credential = strings.TrimSpace(credential)
registration = normalizeThirdPartyRegistration(registration, meta)
meta.Platform = registration.Platform
meta.Language = registration.Language
meta.Timezone = registration.Timezone
if provider == "" || credential == "" {
// provider/credential 是认证材料,缺失时属于请求参数错误,不进入 provider verifier。
return authdomain.Token{}, false, xerr.New(xerr.InvalidArgument, "provider and credential are required")
}
if registration.DeviceID == "" || registration.Platform == "" {
// device_id 绑定 refresh sessionplatform 是注册来源必填快照;两者缺失都不能创建用户主记录。
return authdomain.Token{}, false, xerr.New(xerr.InvalidArgument, "device_id and platform are required")
}
if err := validateThirdPartyRegistration(registration); err != nil {
return authdomain.Token{}, false, err
}
if s.authRepository == nil {
return authdomain.Token{}, false, xerr.New(xerr.Unavailable, "auth repository is not configured")
}
// provider credential 只在 user-service 内校验gateway 不接触 provider secret。
profile, err := s.thirdPartyVerifier.Verify(ctx, provider, credential)
if err != nil {
s.audit(ctx, meta, 0, loginThird, provider, resultFailed, string(xerr.AuthFailed))
return authdomain.Token{}, false, authFailed()
}
// 已存在 provider + subject 绑定时走登录路径,否则走注册事务。
identity, err := s.authRepository.FindThirdPartyIdentity(ctx, provider, profile.ProviderSubject)
if err == nil {
return s.loginExistingThirdParty(ctx, identity, registration, meta)
}
if !xerr.IsCode(err, xerr.NotFound) {
// 非 not found 的存储错误不能退化为注册。
logx.Error(logx.With(ctx, slog.String("request_id", meta.RequestID), slog.String("app_code", meta.AppCode)), "third_party_lookup_failed", err, slog.String("component", "user_auth"), slog.String("provider", provider))
s.audit(ctx, meta, 0, loginThird, provider, resultFailed, string(xerr.CodeOf(err)))
return authdomain.Token{}, false, err
}
maxAccountsPerDevice, err := s.ensureDeviceCanRegisterThirdParty(ctx, registration, meta, provider)
if err != nil {
return authdomain.Token{}, false, err
}
registration, err = s.resolveRegistrationCountry(ctx, registration)
if err != nil {
return authdomain.Token{}, false, err
}
return s.createThirdPartyUser(ctx, provider, profile, registration, meta, maxAccountsPerDevice)
}
// RegisterThirdParty 校验三方凭证并一次性完成 App 自然注册。
// 该入口服务 Flutter 资料页完成动作:资料未齐不建用户,不向客户端暴露 profile_required session。
func (s *Service) RegisterThirdParty(ctx context.Context, provider string, credential string, registration authdomain.ThirdPartyRegistration, meta Meta) (authdomain.Token, bool, error) {
meta.AppCode = appcode.Normalize(meta.AppCode)
registration.AppCode = meta.AppCode
provider = strings.ToLower(strings.TrimSpace(provider))
credential = strings.TrimSpace(credential)
registration = normalizeThirdPartyRegistration(registration, meta)
meta.Platform = registration.Platform
meta.Language = registration.Language
meta.Timezone = registration.Timezone
if provider == "" || credential == "" {
// provider/credential 缺失时不能进入 verifier避免把客户端参数错误伪装成三方认证失败。
return authdomain.Token{}, false, xerr.New(xerr.InvalidArgument, "provider and credential are required")
}
if registration.DeviceID == "" || registration.Platform == "" {
// 注册完成后会立即创建 refresh session缺设备或平台会让 session 和注册来源失去可追踪性。
return authdomain.Token{}, false, xerr.New(xerr.InvalidArgument, "device_id and platform are required")
}
if err := validateThirdPartyRegistrationRequiredForCreate(registration); err != nil {
// 新注册入口承诺不落 pending 用户,因此 username/avatar/gender/country 必须在事务前全部具备。
return authdomain.Token{}, false, err
}
if err := validateThirdPartyRegistration(registration); err != nil {
return authdomain.Token{}, false, err
}
if s.authRepository == nil {
return authdomain.Token{}, false, xerr.New(xerr.Unavailable, "auth repository is not configured")
}
if !s.TokenConfigValid() {
// 资料和三方绑定一旦提交就必须返回可用 token签名配置不完整时提前失败避免写出不可登录用户。
return authdomain.Token{}, false, xerr.New(xerr.Unavailable, "token service is not configured")
}
profile, err := s.thirdPartyVerifier.Verify(ctx, provider, credential)
if err != nil {
s.audit(ctx, meta, 0, loginThird, provider, resultFailed, string(xerr.AuthFailed))
return authdomain.Token{}, false, authFailed()
}
identity, err := s.authRepository.FindThirdPartyIdentity(ctx, provider, profile.ProviderSubject)
if err == nil {
// 兼容旧 Flutter 流程已创建的 profile_required 占位用户;同一 provider subject 不能新建第二个账号。
return s.registerExistingThirdParty(ctx, identity, registration, meta)
}
if !xerr.IsCode(err, xerr.NotFound) {
logx.Error(logx.With(ctx, slog.String("request_id", meta.RequestID), slog.String("app_code", meta.AppCode)), "third_party_register_lookup_failed", err, slog.String("component", "user_auth"), slog.String("provider", provider))
s.audit(ctx, meta, 0, loginThird, provider, resultFailed, string(xerr.CodeOf(err)))
return authdomain.Token{}, false, err
}
maxAccountsPerDevice, err := s.ensureDeviceCanRegisterThirdParty(ctx, registration, meta, provider)
if err != nil {
return authdomain.Token{}, false, err
}
registration, err = s.resolveRegistrationCountry(ctx, registration)
if err != nil {
return authdomain.Token{}, false, err
}
return s.createRegisteredThirdPartyUser(ctx, provider, profile, registration, meta, maxAccountsPerDevice)
}
// CheckThirdPartyRegistered 只校验 provider credential并判断当前 App 下是否已有完整注册用户。
// 它不创建用户、不创建 session专门给 Flutter 决定“进首页登录链路”还是“进资料页注册链路”。
func (s *Service) CheckThirdPartyRegistered(ctx context.Context, provider string, credential string, meta Meta) (bool, error) {
meta.AppCode = appcode.Normalize(meta.AppCode)
provider = strings.ToLower(strings.TrimSpace(provider))
credential = strings.TrimSpace(credential)
if provider == "" || credential == "" {
// 缺认证材料是客户端参数错误;不能返回 registered=false 掩盖真实调用错误。
return false, xerr.New(xerr.InvalidArgument, "provider and credential are required")
}
if s.authRepository == nil {
return false, xerr.New(xerr.Unavailable, "auth repository is not configured")
}
profile, err := s.thirdPartyVerifier.Verify(ctx, provider, credential)
if err != nil {
return false, authFailed()
}
identity, err := s.authRepository.FindThirdPartyIdentity(ctx, provider, profile.ProviderSubject)
if xerr.IsCode(err, xerr.NotFound) {
// 未绑定表示从注册视角还不能进首页;客户端应继续资料页和注册接口。
return false, nil
}
if err != nil {
return false, err
}
user, err := s.freshUser(ctx, identity.UserID, s.now().UnixMilli(), meta.RequestID)
if xerr.IsCode(err, xerr.NotFound) {
// 极少数脏数据只有三方绑定没有用户;按未完成注册处理,避免前端误进首页。
return false, nil
}
if err != nil {
return false, err
}
return user.ProfileCompleted, nil
}
func (s *Service) ensureDeviceCanRegisterThirdParty(ctx context.Context, registration authdomain.ThirdPartyRegistration, meta Meta, provider string) (int32, error) {
// 设备号限制只约束“创建新账号”路径;已绑定三方身份登录不经过这里,避免把正常跨设备登录误拦。
config, err := s.GetRegisterRiskConfig(ctx)
if err != nil {
s.audit(ctx, meta, 0, loginThird, provider, resultFailed, string(xerr.CodeOf(err)))
return 0, err
}
count, err := s.authRepository.CountUsersByRegisterDeviceID(ctx, registration.AppCode, registration.DeviceID)
if err != nil {
// 设备查重失败不能退化为注册,否则并发和依赖异常都会突破一机一号约束。
s.audit(ctx, meta, 0, loginThird, provider, resultFailed, string(xerr.CodeOf(err)))
return 0, err
}
if count < int64(config.MaxAccountsPerDevice) {
return config.MaxAccountsPerDevice, nil
}
// 已达到配置上限时拒绝创建新账号;审计不写已有 user_id避免客户端枚举设备归属。
s.audit(ctx, meta, 0, loginThird, provider, resultFailed, string(xerr.DeviceAlreadyRegistered))
return 0, xerr.New(xerr.DeviceAlreadyRegistered, "device already registered")
}
func (s *Service) loginExistingThirdParty(ctx context.Context, identity authdomain.ThirdPartyIdentity, registration authdomain.ThirdPartyRegistration, meta Meta) (authdomain.Token, bool, error) {
// 已绑定三方用户登录时仍要重新读取用户快照,确保禁用状态和靓号过期生效。
user, err := s.freshUser(ctx, identity.UserID, s.now().UnixMilli(), meta.RequestID)
if err != nil {
s.audit(ctx, meta, identity.UserID, loginThird, identity.Provider, resultFailed, string(xerr.CodeOf(err)))
return authdomain.Token{}, false, err
}
if !user.CanLogin() {
// 禁用用户不能通过三方登录绕过状态限制。
s.audit(ctx, meta, user.UserID, loginThird, identity.Provider, resultFailed, string(xerr.UserDisabled))
return authdomain.Token{}, false, xerr.New(xerr.UserDisabled, "user is disabled")
}
// 每次三方登录创建新的 refresh session。
session, refreshToken, err := s.newSession(meta.AppCode, user.UserID, registration.DeviceID)
if err != nil {
return authdomain.Token{}, false, err
}
if err := s.authRepository.CreateSession(ctx, session); err != nil {
return authdomain.Token{}, false, err
}
s.audit(ctx, meta, user.UserID, loginThird, identity.Provider, resultSuccess, "")
// isNewUser=false 表示本次只创建 session没有创建用户。
token, err := s.issueToken(user, session.SessionID, refreshToken, session.ExpiresAtMs)
if err != nil {
return authdomain.Token{}, false, err
}
s.enqueueLoginIPRiskJob(ctx, meta, token, normalizeThirdPartyChannel(identity.Provider), registration.Platform, registration.Language, registration.Timezone)
return token, false, nil
}
func (s *Service) registerExistingThirdParty(ctx context.Context, identity authdomain.ThirdPartyIdentity, registration authdomain.ThirdPartyRegistration, meta Meta) (authdomain.Token, bool, error) {
// 已完成资料的同一三方身份按登录处理,保持客户端重试和多端登录的幂等语义。
user, err := s.freshUser(ctx, identity.UserID, s.now().UnixMilli(), meta.RequestID)
if err != nil {
s.audit(ctx, meta, identity.UserID, loginThird, identity.Provider, resultFailed, string(xerr.CodeOf(err)))
return authdomain.Token{}, false, err
}
if !user.CanLogin() {
s.audit(ctx, meta, user.UserID, loginThird, identity.Provider, resultFailed, string(xerr.UserDisabled))
return authdomain.Token{}, false, xerr.New(xerr.UserDisabled, "user is disabled")
}
if user.ProfileCompleted {
return s.loginExistingThirdParty(ctx, identity, registration, meta)
}
registration, err = s.resolveRegistrationCountry(ctx, registration)
if err != nil {
s.audit(ctx, meta, identity.UserID, loginThird, identity.Provider, resultFailed, string(xerr.CodeOf(err)))
return authdomain.Token{}, false, err
}
session, refreshToken, err := s.newSession(meta.AppCode, identity.UserID, registration.DeviceID)
if err != nil {
return authdomain.Token{}, false, err
}
updated, err := s.authRepository.CompleteThirdPartyRegistration(ctx, userdomain.CompleteOnboardingCommand{
AppCode: meta.AppCode,
UserID: identity.UserID,
Username: registration.Username,
Avatar: registration.Avatar,
Gender: registration.Gender,
Country: registration.Country,
RegionID: registration.RegionID,
InviteCode: registration.InviteCode,
RequestID: meta.RequestID,
CompletedAtMs: s.now().UnixMilli(),
}, session)
if err != nil {
s.audit(ctx, meta, identity.UserID, loginThird, identity.Provider, resultFailed, string(xerr.CodeOf(err)))
return authdomain.Token{}, false, err
}
s.audit(ctx, meta, updated.UserID, loginThird, identity.Provider, resultSuccess, "")
token, err := s.issueToken(updated, session.SessionID, refreshToken, session.ExpiresAtMs)
if err != nil {
return authdomain.Token{}, false, err
}
s.importIMAccountAfterCommit(ctx, updated, meta)
s.enqueueLoginIPRiskJob(ctx, meta, token, normalizeThirdPartyChannel(identity.Provider), registration.Platform, registration.Language, registration.Timezone)
s.issueRegistrationRewardAfterCommit(ctx, registration.AppCode, updated.UserID, meta)
s.issueRegistrationLevelBadgesAfterCommit(ctx, registration.AppCode, updated.UserID, meta)
return token, false, nil
}
func (s *Service) createThirdPartyUser(ctx context.Context, provider string, profile authdomain.ThirdPartyProfile, registration authdomain.ThirdPartyRegistration, meta Meta, maxAccountsPerDevice int32) (authdomain.Token, bool, error) {
var lastErr error
for attempt := 0; attempt < s.allocateMaxAttempts; attempt++ {
// 三方首次登录要同时生成 user_id 和默认短号,短号冲突时有限重试。
user, displayIdentity := s.newUserWithIdentity(attempt)
user = applyThirdPartyRegistration(user, registration)
displayUserID, err := s.authRepository.AllocateDisplayUserID(ctx, registration.AppCode)
if err != nil {
return authdomain.Token{}, false, err
}
user.DefaultDisplayUserID = displayUserID
user.CurrentDisplayUserID = displayUserID
displayIdentity.DisplayUserID = displayUserID
displayIdentity.DefaultDisplayUserID = displayUserID
displayIdentity.AppCode = registration.AppCode
if !userdomain.ValidDisplayUserID(displayIdentity.DisplayUserID) {
// 非法候选直接跳过,避免进入 repository 唯一性判断。
continue
}
session, refreshToken, err := s.newSession(registration.AppCode, user.UserID, registration.DeviceID)
if err != nil {
return authdomain.Token{}, false, err
}
thirdParty := authdomain.ThirdPartyIdentity{
AppCode: registration.AppCode,
UserID: user.UserID,
Provider: provider,
ProviderSubject: profile.ProviderSubject,
ProviderUnionID: profile.ProviderUnionID,
CreatedAtMs: user.CreatedAtMs,
UpdatedAtMs: user.UpdatedAtMs,
}
inviteBind := invitedomain.BindCommand{
AppCode: registration.AppCode,
InvitedUserID: user.UserID,
InvitedRegionID: registration.RegionID,
InviteCode: registration.InviteCode,
Source: "third_party_registration",
RequestID: meta.RequestID,
BoundAtMs: user.CreatedAtMs,
}
err = s.authRepository.CreateThirdPartyUser(ctx, user, displayIdentity, thirdParty, session, inviteBind, maxAccountsPerDevice)
if err == nil {
// repository 事务成功后,用户、默认短号、三方绑定和首个 session 同时存在。
s.audit(ctx, meta, user.UserID, loginThird, provider, resultSuccess, "")
token, tokenErr := s.issueToken(user, session.SessionID, refreshToken, session.ExpiresAtMs)
if tokenErr != nil {
return authdomain.Token{}, false, tokenErr
}
s.importIMAccountAfterCommit(ctx, user, meta)
s.enqueueLoginIPRiskJob(ctx, meta, token, normalizeThirdPartyChannel(provider), registration.Platform, registration.Language, registration.Timezone)
s.issueRegistrationRewardAfterCommit(ctx, registration.AppCode, user.UserID, meta)
s.issueRegistrationLevelBadgesAfterCommit(ctx, registration.AppCode, user.UserID, meta)
return token, true, nil
}
if xerr.IsCode(err, xerr.DisplayUserIDExists) {
// 默认短号冲突可重试user_id 和 display_user_id 都会重新生成。
lastErr = err
continue
}
if xerr.IsCode(err, xerr.Conflict) {
// 并发注册同一 provider subject 时,尝试转为已有三方登录。
identity, findErr := s.authRepository.FindThirdPartyIdentity(ctx, provider, profile.ProviderSubject)
if findErr == nil {
return s.loginExistingThirdParty(ctx, identity, registration, meta)
}
}
s.audit(ctx, meta, 0, loginThird, provider, resultFailed, string(xerr.CodeOf(err)))
logx.Error(logx.With(ctx, slog.String("request_id", meta.RequestID), slog.String("app_code", meta.AppCode)), "third_party_register_failed", err, slog.String("component", "user_auth"), slog.String("provider", provider))
return authdomain.Token{}, false, err
}
if lastErr != nil {
// 明确记录是短号分配耗尽,而不是 provider credential 失败。
s.audit(ctx, meta, 0, loginThird, provider, resultFailed, string(xerr.DisplayUserIDAllocateFailed))
}
return authdomain.Token{}, false, xerr.New(xerr.DisplayUserIDAllocateFailed, "display_user_id allocation failed")
}
func (s *Service) createRegisteredThirdPartyUser(ctx context.Context, provider string, profile authdomain.ThirdPartyProfile, registration authdomain.ThirdPartyRegistration, meta Meta, maxAccountsPerDevice int32) (authdomain.Token, bool, error) {
var lastErr error
for attempt := 0; attempt < s.allocateMaxAttempts; attempt++ {
// 注册专用入口直接创建 completed 用户所有公开资料、绑定、session 和注册事实必须同事务提交。
user, displayIdentity := s.newUserWithIdentity(attempt)
user = applyThirdPartyRegistration(user, registration)
user.ProfileCompleted = true
user.ProfileCompletedAtMs = user.CreatedAtMs
user.OnboardingStatus = userdomain.OnboardingStatusCompleted
displayUserID, err := s.authRepository.AllocateDisplayUserID(ctx, registration.AppCode)
if err != nil {
return authdomain.Token{}, false, err
}
user.DefaultDisplayUserID = displayUserID
user.CurrentDisplayUserID = displayUserID
displayIdentity.DisplayUserID = displayUserID
displayIdentity.DefaultDisplayUserID = displayUserID
displayIdentity.AppCode = registration.AppCode
if !userdomain.ValidDisplayUserID(displayIdentity.DisplayUserID) {
// 非法候选不写库,继续拿下一组 user_id/display_user_id保持默认短号约束一致。
continue
}
session, refreshToken, err := s.newSession(registration.AppCode, user.UserID, registration.DeviceID)
if err != nil {
return authdomain.Token{}, false, err
}
thirdParty := authdomain.ThirdPartyIdentity{
AppCode: registration.AppCode,
UserID: user.UserID,
Provider: provider,
ProviderSubject: profile.ProviderSubject,
ProviderUnionID: profile.ProviderUnionID,
CreatedAtMs: user.CreatedAtMs,
UpdatedAtMs: user.UpdatedAtMs,
}
inviteBind := invitedomain.BindCommand{
AppCode: registration.AppCode,
InvitedUserID: user.UserID,
InvitedRegionID: registration.RegionID,
InviteCode: registration.InviteCode,
Source: "third_party_register",
RequestID: meta.RequestID,
BoundAtMs: user.CreatedAtMs,
}
err = s.authRepository.CreateThirdPartyUser(ctx, user, displayIdentity, thirdParty, session, inviteBind, maxAccountsPerDevice)
if err == nil {
s.audit(ctx, meta, user.UserID, loginThird, provider, resultSuccess, "")
token, tokenErr := s.issueToken(user, session.SessionID, refreshToken, session.ExpiresAtMs)
if tokenErr != nil {
return authdomain.Token{}, false, tokenErr
}
s.importIMAccountAfterCommit(ctx, user, meta)
s.enqueueLoginIPRiskJob(ctx, meta, token, normalizeThirdPartyChannel(provider), registration.Platform, registration.Language, registration.Timezone)
s.issueRegistrationRewardAfterCommit(ctx, registration.AppCode, user.UserID, meta)
s.issueRegistrationLevelBadgesAfterCommit(ctx, registration.AppCode, user.UserID, meta)
return token, true, nil
}
if xerr.IsCode(err, xerr.DisplayUserIDExists) {
// 默认短号冲突可重试;同一次 Firebase subject 不会因为短号冲突暴露成注册失败。
lastErr = err
continue
}
if xerr.IsCode(err, xerr.Conflict) {
identity, findErr := s.authRepository.FindThirdPartyIdentity(ctx, provider, profile.ProviderSubject)
if findErr == nil {
return s.registerExistingThirdParty(ctx, identity, registration, meta)
}
}
s.audit(ctx, meta, 0, loginThird, provider, resultFailed, string(xerr.CodeOf(err)))
logx.Error(logx.With(ctx, slog.String("request_id", meta.RequestID), slog.String("app_code", meta.AppCode)), "third_party_complete_register_failed", err, slog.String("component", "user_auth"), slog.String("provider", provider))
return authdomain.Token{}, false, err
}
if lastErr != nil {
s.audit(ctx, meta, 0, loginThird, provider, resultFailed, string(xerr.DisplayUserIDAllocateFailed))
}
return authdomain.Token{}, false, xerr.New(xerr.DisplayUserIDAllocateFailed, "display_user_id allocation failed")
}
func (s *Service) issueRegistrationRewardAfterCommit(ctx context.Context, appCode string, userID int64, meta Meta) {
if s.registrationRewardIssuer == nil {
return
}
commandID := fmt.Sprintf("registration_reward:%s:%d", appcode.Normalize(appCode), userID)
if err := s.registrationRewardIssuer.IssueRegistrationReward(ctx, RegistrationRewardCommand{
AppCode: appcode.Normalize(appCode),
RequestID: meta.RequestID,
UserID: userID,
CommandID: commandID,
}); err != nil {
// 注册主事务已经提交,奖励是后置权益发放;失败只能记录并依赖同 command_id 的人工或重试修复,不能让用户注册回滚。
logx.Warn(logx.With(ctx, slog.String("request_id", meta.RequestID), slog.String("app_code", appcode.Normalize(appCode))), "registration_reward_issue_failed",
slog.String("component", "user_auth"),
slog.Int64("user_id", userID),
slog.String("command_id", commandID),
slog.String("error", err.Error()),
)
}
}
func (s *Service) issueRegistrationLevelBadgesAfterCommit(ctx context.Context, appCode string, userID int64, meta Meta) {
if s.registrationLevelBadgeIssuer == nil {
return
}
commandID := fmt.Sprintf("registration_level_badges:%s:%d", appcode.Normalize(appCode), userID)
if err := s.registrationLevelBadgeIssuer.IssueRegistrationLevelBadges(ctx, RegistrationLevelBadgeCommand{
AppCode: appcode.Normalize(appCode),
RequestID: meta.RequestID,
UserID: userID,
CommandID: commandID,
}); err != nil {
// 等级徽章是注册后置权益;用户注册事务已经提交,只能依赖同 command_id 重试或补发修复。
logx.Warn(logx.With(ctx, slog.String("request_id", meta.RequestID), slog.String("app_code", appcode.Normalize(appCode))), "registration_level_badges_issue_failed",
slog.String("component", "user_auth"),
slog.Int64("user_id", userID),
slog.String("command_id", commandID),
slog.String("error", err.Error()),
)
}
}
func normalizeThirdPartyRegistration(registration authdomain.ThirdPartyRegistration, meta Meta) authdomain.ThirdPartyRegistration {
// 注册资料来自公网入口,进入领域前统一裁剪空白,避免数据库里出现不可见差异。
registration.Username = strings.TrimSpace(registration.Username)
registration.Gender = strings.TrimSpace(registration.Gender)
registration.Country = strings.ToUpper(strings.TrimSpace(registration.Country))
registration.InviteCode = invitedomain.NormalizeCode(registration.InviteCode)
// 注册 IP 和 UA 只能来自 gateway 观察到的请求上下文,不能信任公网 JSON body。
registration.IP = strings.TrimSpace(meta.ClientIP)
registration.UserAgent = strings.TrimSpace(meta.UserAgent)
registration.CountryByIP = normalizeCountryByIP(meta.CountryByIP)
registration.DeviceID = strings.TrimSpace(registration.DeviceID)
registration.Device = strings.TrimSpace(registration.Device)
registration.OSVersion = strings.TrimSpace(registration.OSVersion)
registration.Avatar = strings.TrimSpace(registration.Avatar)
registration.BirthDate = strings.TrimSpace(registration.BirthDate)
registration.AppVersion = strings.TrimSpace(registration.AppVersion)
registration.BuildNumber = strings.TrimSpace(registration.BuildNumber)
registration.Source = strings.TrimSpace(registration.Source)
registration.InstallChannel = strings.TrimSpace(registration.InstallChannel)
registration.Campaign = strings.TrimSpace(registration.Campaign)
registration.Platform = strings.ToLower(strings.TrimSpace(registration.Platform))
registration.Language = strings.TrimSpace(registration.Language)
registration.Timezone = strings.TrimSpace(registration.Timezone)
return registration
}
func validateThirdPartyRegistration(registration authdomain.ThirdPartyRegistration) error {
if err := validateRegistrationLengths(registration); err != nil {
return err
}
if registration.Country != "" && !userdomain.ValidCountryCode(registration.Country) {
// 用户选择国家只接受 2 到 3 位大写英文国家码,是否可选由 countries 表决定。
return xerr.New(xerr.InvalidArgument, "country must use 2 to 3 uppercase letters")
}
if registration.InviteCode != "" && !invitedomain.ValidCode(registration.InviteCode) {
// 邀请码错误统一返回 INVALID_INVITE_CODE避免客户端通过错误细节枚举邀请码。
return xerr.New(xerr.InvalidInviteCode, "invalid invite code")
}
if registration.BirthDate != "" {
if !userdomain.ValidProfileBirthDate(registration.BirthDate) {
// 生日只保存日期,不接受带时区或非零填充格式,避免跨端解析不一致。
return xerr.New(xerr.InvalidArgument, "birth must use yyyy-mm-dd")
}
}
if registration.Avatar != "" && !userdomain.ValidProfileAvatarURL(registration.Avatar) {
// avatar 进入可展示资料,不接受相对地址或非 HTTP(S) scheme。
return xerr.New(xerr.InvalidArgument, "avatar must be an absolute http or https URL")
}
if registration.Platform != "android" && registration.Platform != "ios" {
// 平台值进入统计和渠道分析,先收敛到明确枚举。
return xerr.New(xerr.InvalidArgument, "platform must be android or ios")
}
if registration.Language != "" && !validLanguageTag(registration.Language) {
// language 使用轻量 BCP 47 子集,覆盖 en-US、zh-Hant-CN 等常见客户端语言。
return xerr.New(xerr.InvalidArgument, "language must use a BCP 47 language tag")
}
if registration.Timezone != "" {
if _, err := time.LoadLocation(registration.Timezone); err != nil {
// timezone 使用 IANA 名称time/tzdata 保证 Alpine 镜像内也能稳定校验。
return xerr.New(xerr.InvalidArgument, "timezone must use an IANA timezone name")
}
}
return nil
}
func validateThirdPartyRegistrationRequiredForCreate(registration authdomain.ThirdPartyRegistration) error {
if registration.Username == "" || registration.Country == "" || registration.Gender == "" || registration.Avatar == "" {
// 注册即落用户主数据;最小公开资料不完整时拒绝创建,避免后台出现空昵称、空头像或 GLOBAL 国家用户。
return xerr.New(xerr.InvalidArgument, "username, country, gender and avatar are required")
}
return nil
}
func validateRegistrationLengths(registration authdomain.ThirdPartyRegistration) error {
checks := []struct {
name string
value string
max int
}{
{name: "username", value: registration.Username, max: maxRegistrationUsername},
{name: "gender", value: registration.Gender, max: maxRegistrationGender},
{name: "country", value: registration.Country, max: maxRegistrationCountry},
{name: "invite_code", value: registration.InviteCode, max: maxRegistrationInviteCode},
{name: "register_ip", value: registration.IP, max: maxRegistrationIP},
{name: "register_user_agent", value: registration.UserAgent, max: maxRegistrationUserAgent},
{name: "country_by_ip", value: registration.CountryByIP, max: maxRegistrationCountryByIP},
{name: "device_id", value: registration.DeviceID, max: maxRegistrationDeviceID},
{name: "device", value: registration.Device, max: maxRegistrationDevice},
{name: "os_version", value: registration.OSVersion, max: maxRegistrationOSVersion},
{name: "avatar", value: registration.Avatar, max: maxRegistrationAvatar},
{name: "app_version", value: registration.AppVersion, max: maxRegistrationAppVersion},
{name: "build_number", value: registration.BuildNumber, max: maxRegistrationBuildNumber},
{name: "source", value: registration.Source, max: maxRegistrationSource},
{name: "install_channel", value: registration.InstallChannel, max: maxRegistrationInstallChannel},
{name: "campaign", value: registration.Campaign, max: maxRegistrationCampaign},
{name: "platform", value: registration.Platform, max: maxRegistrationPlatform},
{name: "language", value: registration.Language, max: maxRegistrationLanguage},
{name: "timezone", value: registration.Timezone, max: maxRegistrationTimezone},
}
for _, check := range checks {
if utf8.RuneCountInString(check.value) > check.max {
// MySQL VARCHAR 以字符数限制,使用 RuneCount 避免中文昵称被按 UTF-8 字节数误拒。
return xerr.New(xerr.InvalidArgument, check.name+" is too long")
}
}
return nil
}
func (s *Service) resolveRegistrationCountry(ctx context.Context, registration authdomain.ThirdPartyRegistration) (authdomain.ThirdPartyRegistration, error) {
if registration.Country == "" && registration.CountryByIP == "" {
// 没有国家输入时不要求国家主数据依赖,允许极简三方登录测试和匿名地域注册。
return registration, nil
}
if s.countryRegionRepository == nil {
// 只要需要写 country 或 country_by_ip就必须经过国家主数据校验。
return registration, xerr.New(xerr.Unavailable, "country repository is not configured")
}
if registration.Country != "" {
country, ok, err := s.countryRegionRepository.ResolveEnabledCountryByCode(ctx, registration.Country)
if err != nil {
return registration, err
}
if !ok {
return registration, xerr.New(xerr.InvalidArgument, "country is not supported")
}
registration.Country = country.CountryCode
region, ok, err := s.countryRegionRepository.ResolveActiveRegionByCountry(ctx, country.CountryCode)
if err != nil {
return registration, err
}
if ok {
// region_id 是服务端按国家映射计算出的快照,客户端不能直接提交。
registration.RegionID = region.RegionID
}
}
if registration.CountryByIP != "" {
country, ok, err := s.countryRegionRepository.ResolveEnabledCountryByCode(ctx, registration.CountryByIP)
if err != nil {
return registration, err
}
if ok {
// country_by_ip 只保留有效国家快照,不参与区域归属计算。
registration.CountryByIP = country.CountryCode
} else {
registration.CountryByIP = ""
}
}
return registration, nil
}
func normalizeCountryByIP(country string) string {
// 边缘层常用 XX 表示未知国家;未知值不写入用户主表,避免误当真实归属。
country = strings.ToUpper(strings.TrimSpace(country))
if country == "XX" || country == "UNKNOWN" || !userdomain.ValidCountryCode(country) {
return ""
}
return country
}
func validLanguageTag(language string) bool {
parts := strings.Split(language, "-")
if len(parts) == 0 || len(parts[0]) < 2 || len(parts[0]) > 8 {
return false
}
for index, part := range parts {
if part == "" || len(part) > 8 {
return false
}
for _, char := range part {
if !((char >= 'A' && char <= 'Z') || (char >= 'a' && char <= 'z') || (index > 0 && char >= '0' && char <= '9')) {
return false
}
}
}
return true
}
func applyThirdPartyRegistration(user userdomain.User, registration authdomain.ThirdPartyRegistration) userdomain.User {
// 用户资料和注册来源跟随 users 主记录一次性落库,三方 credential 不会进入用户表。
user.Username = registration.Username
user.AppCode = registration.AppCode
user.Gender = registration.Gender
user.Country = registration.Country
user.RegionID = registration.RegionID
user.InviteCode = registration.InviteCode
user.RegisterIP = registration.IP
user.RegisterUserAgent = registration.UserAgent
user.CountryByIP = registration.CountryByIP
user.RegisterDeviceID = registration.DeviceID
user.RegisterDevice = registration.Device
user.RegisterOSVersion = registration.OSVersion
user.Avatar = registration.Avatar
user.BirthDate = registration.BirthDate
user.RegisterAppVersion = registration.AppVersion
user.RegisterBuildNumber = registration.BuildNumber
user.RegisterSource = registration.Source
user.RegisterInstallChannel = registration.InstallChannel
user.RegisterCampaign = registration.Campaign
user.RegisterPlatform = registration.Platform
user.Language = registration.Language
user.Timezone = registration.Timezone
return user
}
func (s *Service) newUserWithIdentity(attempt int) (userdomain.User, userdomain.Identity) {
// user 和 identity 必须成对创建,避免出现没有默认短号的用户。
userID := s.idGenerator.NewInt64()
nowMs := s.now().UnixMilli()
displayUserID := s.displayUserIDAllocator.NextDisplayUserID(userID, attempt)
return userdomain.User{
UserID: userID,
DefaultDisplayUserID: displayUserID,
CurrentDisplayUserID: displayUserID,
CurrentDisplayUserIDKind: userdomain.DisplayUserIDKindDefault,
ProfileCompleted: false,
ProfileCompletedAtMs: 0,
OnboardingStatus: userdomain.OnboardingStatusProfileRequired,
Status: userdomain.StatusActive,
CreatedAtMs: nowMs,
UpdatedAtMs: nowMs,
}, userdomain.Identity{
UserID: userID,
DisplayUserID: displayUserID,
DefaultDisplayUserID: displayUserID,
DisplayUserIDKind: userdomain.DisplayUserIDKindDefault,
Status: userdomain.DisplayUserIDStatusActive,
}
}