509 lines
23 KiB
Go
509 lines
23 KiB
Go
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
|
||
}
|
||
if err := s.ensureDeviceCanRegisterThirdParty(ctx, registration, meta, provider); 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)
|
||
}
|
||
|
||
func (s *Service) ensureDeviceCanRegisterThirdParty(ctx context.Context, registration authdomain.ThirdPartyRegistration, meta Meta, provider string) error {
|
||
// 设备号限制只约束“创建新账号”路径;已绑定三方身份登录不经过这里,避免把正常跨设备登录误拦。
|
||
userID, err := s.authRepository.FindUserIDByRegisterDeviceID(ctx, registration.AppCode, registration.DeviceID)
|
||
if xerr.IsCode(err, xerr.NotFound) {
|
||
return nil
|
||
}
|
||
if err != nil {
|
||
// 设备查重失败不能退化为注册,否则并发和依赖异常都会突破一机一号约束。
|
||
s.audit(ctx, meta, 0, loginThird, provider, resultFailed, string(xerr.CodeOf(err)))
|
||
return err
|
||
}
|
||
|
||
// 已有任意用户占用该注册设备时拒绝创建新账号;不返回 user_id,避免客户端枚举设备归属。
|
||
s.audit(ctx, meta, userID, loginThird, provider, resultFailed, string(xerr.DeviceAlreadyRegistered))
|
||
return 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)
|
||
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)
|
||
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) 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,
|
||
}
|
||
}
|