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 session,platform 是注册来源必填快照;两者缺失都不能创建用户主记录。 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 } registration, err = s.resolveRegistrationCountry(ctx, registration) if err != nil { return authdomain.Token{}, false, err } return s.createThirdPartyUser(ctx, provider, profile, registration, meta) } 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) 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) createThirdPartyUser(ctx context.Context, provider string, profile authdomain.ThirdPartyProfile, registration authdomain.ThirdPartyRegistration, meta Meta) (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) if err == nil { // repository 事务成功后,用户、默认短号、三方绑定和首个 session 同时存在。 s.audit(ctx, meta, user.UserID, loginThird, provider, resultSuccess, "") token, tokenErr := s.issueToken(user, session.SessionID, refreshToken) 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) 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) 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 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 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, } }