package user import ( "context" "strings" "hyapp/pkg/appcode" "hyapp/pkg/xerr" hostdomain "hyapp/services/user-service/internal/domain/host" invitedomain "hyapp/services/user-service/internal/domain/invite" userdomain "hyapp/services/user-service/internal/domain/user" ) // UpdateUserProfile 修改用户名、头像、性别和生日;国家修改走独立接口和冷却期。 func (s *Service) UpdateUserProfile(ctx context.Context, userID int64, username *string, avatar *string, gender *string, birth *string) (userdomain.User, error) { if userID <= 0 { // user_id 必须来自 gateway 鉴权上下文,service 层仍兜底校验。 return userdomain.User{}, xerr.New(xerr.InvalidArgument, "user_id is required") } if s.userRepository == nil { return userdomain.User{}, xerr.New(xerr.Unavailable, "user repository is not configured") } if err := s.requireCompletedProfile(ctx, userID); err != nil { // 注册页唯一入口是 CompleteOnboarding;未完成资料的用户不能通过资料编辑拼出半完成状态。 return userdomain.User{}, err } username = trimmedStringPtr(username) avatar = trimmedStringPtr(avatar) gender = trimmedStringPtr(gender) birth = trimmedStringPtr(birth) if username == nil && avatar == nil && gender == nil && birth == nil { // PATCH 语义要求至少有一个字段,否则客户端通常是传参错误。 return userdomain.User{}, xerr.New(xerr.InvalidArgument, "profile update field is required") } if username != nil && *username == "" { // 用户名是展示资料,禁止写成空字符串;头像和生日允许清空。 return userdomain.User{}, xerr.New(xerr.InvalidArgument, "username is required") } if username != nil && userdomain.ProfileStringTooLong(*username, userdomain.ProfileUsernameMaxRunes) { // 提前按 users.username 的 VARCHAR 边界拒绝,避免把客户端错误暴露成 MySQL 错误。 return userdomain.User{}, xerr.New(xerr.InvalidArgument, "username is too long") } if avatar != nil && *avatar != "" { if userdomain.ProfileStringTooLong(*avatar, userdomain.ProfileAvatarMaxRunes) { // 头像 URL 直接进入展示资料,长度错误必须稳定返回 INVALID_ARGUMENT。 return userdomain.User{}, xerr.New(xerr.InvalidArgument, "avatar is too long") } if !userdomain.ValidProfileAvatarURL(*avatar) { // 非 HTTP(S) 或相对地址不应写入展示资料,避免客户端渲染和安全策略不一致。 return userdomain.User{}, xerr.New(xerr.InvalidArgument, "avatar must be an absolute http or https URL") } } if gender != nil { if *gender == "" { return userdomain.User{}, xerr.New(xerr.InvalidArgument, "gender is required") } if userdomain.ProfileStringTooLong(*gender, userdomain.ProfileGenderMaxRunes) { return userdomain.User{}, xerr.New(xerr.InvalidArgument, "gender is too long") } } if birth != nil && *birth != "" { if !userdomain.ValidProfileBirthDate(*birth) { // 生日只保存日期,不接受时区或斜杠格式,避免客户端跨时区偏移。 return userdomain.User{}, xerr.New(xerr.InvalidArgument, "birth must use yyyy-mm-dd") } } return s.userRepository.UpdateUserProfile(ctx, userdomain.ProfileUpdateCommand{ AppCode: appcode.FromContext(ctx), UserID: userID, Username: username, Avatar: avatar, Gender: gender, BirthDate: birth, UpdatedAtMs: s.now().UnixMilli(), }) } // UpdateUserProfileBackground 只修改个人信息页背景图;客户端传入已上传完成的图片 URL。 func (s *Service) UpdateUserProfileBackground(ctx context.Context, userID int64, profileBgImg string) (userdomain.User, error) { if userID <= 0 { // 背景图只能改当前登录用户,gateway 传入 token 中的 user_id,service 层继续做兜底校验。 return userdomain.User{}, xerr.New(xerr.InvalidArgument, "user_id is required") } if s.userRepository == nil { return userdomain.User{}, xerr.New(xerr.Unavailable, "user repository is not configured") } if err := s.requireCompletedProfile(ctx, userID); err != nil { // 个人信息页背景图属于已完成资料后的用户资料维护,不能绕过注册资料完成流程。 return userdomain.User{}, err } profileBgImg = strings.TrimSpace(profileBgImg) if profileBgImg == "" { // 上传接口必须写入真实对象 URL;清空背景需要另行设计明确的删除入口。 return userdomain.User{}, xerr.New(xerr.InvalidArgument, "profile_bg_img is required") } if userdomain.ProfileStringTooLong(profileBgImg, userdomain.ProfileAvatarMaxRunes) { // users.profile_bg_img 与头像同为 512 字符 URL 字段,超过数据库边界必须在业务层拦截。 return userdomain.User{}, xerr.New(xerr.InvalidArgument, "profile_bg_img is too long") } if !userdomain.ValidProfileAvatarURL(profileBgImg) { // 背景图是客户端直接渲染的 URL,不接受资源 code、相对路径或非 HTTP(S) scheme。 return userdomain.User{}, xerr.New(xerr.InvalidArgument, "profile_bg_img must be an absolute http or https URL") } return s.userRepository.UpdateUserProfileBackground(ctx, userdomain.ProfileBackgroundUpdateCommand{ AppCode: appcode.FromContext(ctx), UserID: userID, ProfileBgImg: profileBgImg, UpdatedAtMs: s.now().UnixMilli(), }) } // UpdateUserContactInfo 修改用户级联系方式;Host、BD、Agency owner 和币商中心共用这一个字段展示。 func (s *Service) UpdateUserContactInfo(ctx context.Context, userID int64, contactInfo string) (userdomain.User, error) { if userID <= 0 { // 联系方式只能改当前登录用户或后台已解析出的真实用户,非法 user_id 直接拒绝,避免落到空用户。 return userdomain.User{}, xerr.New(xerr.InvalidArgument, "user_id is required") } if s.userRepository == nil { return userdomain.User{}, xerr.New(xerr.Unavailable, "user repository is not configured") } if err := s.requireCompletedProfile(ctx, userID); err != nil { // 未完成注册资料的账号不能先写经营联系方式,否则后台会看到一个仍不可进入业务的半成品用户。 return userdomain.User{}, err } contactInfo = strings.TrimSpace(contactInfo) if userdomain.ProfileStringTooLong(contactInfo, userdomain.ProfileContactInfoMaxRunes) { // users.contact_info 是短展示字段;提前拦截超长输入,避免 MySQL 截断造成 H5 显示值和用户输入不一致。 return userdomain.User{}, xerr.New(xerr.InvalidArgument, "contact_info is too long") } return s.userRepository.UpdateUserContactInfo(ctx, userdomain.ProfileContactUpdateCommand{ AppCode: appcode.FromContext(ctx), UserID: userID, ContactInfo: contactInfo, UpdatedAtMs: s.now().UnixMilli(), }) } // UpdateUserWithdrawAddress 修改用户默认提现 USDT-TRC20 地址;钱包提现单创建时再读取并快照该值。 func (s *Service) UpdateUserWithdrawAddress(ctx context.Context, userID int64, usdtTRC20Address string) (userdomain.User, error) { if userID <= 0 { // 提现地址是用户资产相关资料,只能写明确用户;非法 user_id 直接拒绝,避免落到空用户。 return userdomain.User{}, xerr.New(xerr.InvalidArgument, "user_id is required") } if s.userRepository == nil { return userdomain.User{}, xerr.New(xerr.Unavailable, "user repository is not configured") } if err := s.requireCompletedProfile(ctx, userID); err != nil { // 未完成注册资料的账号不能提前写提现资料,避免半成品账号进入资产链路。 return userdomain.User{}, err } usdtTRC20Address = strings.TrimSpace(usdtTRC20Address) if usdtTRC20Address == "" { return userdomain.User{}, xerr.New(xerr.InvalidArgument, "withdraw_usdt_trc20_address is required") } if userdomain.ProfileStringTooLong(usdtTRC20Address, userdomain.WithdrawUSDTTRC20AddressMaxRunes) { // users.withdraw_usdt_trc20_address 是短地址字段;先按数据库边界拦截,避免 MySQL 截断。 return userdomain.User{}, xerr.New(xerr.InvalidArgument, "withdraw_usdt_trc20_address is too long") } if !userdomain.ValidUSDTTRC20Address(usdtTRC20Address) { // H5 只做展示和提交,服务端必须兜底拦截明显不是 TRC20 的地址,防止后续人工打款拿到脏数据。 return userdomain.User{}, xerr.New(xerr.InvalidArgument, "withdraw_usdt_trc20_address is invalid") } return s.userRepository.UpdateUserWithdrawAddress(ctx, userdomain.WithdrawAddressUpdateCommand{ AppCode: appcode.FromContext(ctx), UserID: userID, USDTTRC20Address: usdtTRC20Address, UpdatedAtMs: s.now().UnixMilli(), }) } // CompleteOnboarding 是注册页唯一提交入口。 // 它必须一次性校验并写入 username/avatar/gender/country/region_id/profile_completed,避免客户端拆接口拼出半完成状态。 func (s *Service) CompleteOnboarding(ctx context.Context, userID int64, username string, avatar string, gender string, country string, inviteCode string, requestID string) (userdomain.User, error) { username = strings.TrimSpace(username) avatar = strings.TrimSpace(avatar) gender = strings.TrimSpace(gender) country = userdomain.NormalizeCountryCode(country) inviteCode = invitedomain.NormalizeCode(inviteCode) if userID <= 0 { return userdomain.User{}, xerr.New(xerr.InvalidArgument, "user_id is required") } if username == "" { return userdomain.User{}, xerr.New(xerr.InvalidArgument, "username is required") } if userdomain.ProfileStringTooLong(username, userdomain.ProfileUsernameMaxRunes) { return userdomain.User{}, xerr.New(xerr.InvalidArgument, "username is too long") } if avatar == "" { return userdomain.User{}, xerr.New(xerr.InvalidArgument, "avatar is required") } if userdomain.ProfileStringTooLong(avatar, userdomain.ProfileAvatarMaxRunes) { return userdomain.User{}, xerr.New(xerr.InvalidArgument, "avatar is too long") } if !userdomain.ValidProfileAvatarURL(avatar) { return userdomain.User{}, xerr.New(xerr.InvalidArgument, "avatar must be an absolute http or https URL") } if gender == "" { return userdomain.User{}, xerr.New(xerr.InvalidArgument, "gender is required") } if userdomain.ProfileStringTooLong(gender, userdomain.ProfileGenderMaxRunes) { return userdomain.User{}, xerr.New(xerr.InvalidArgument, "gender is too long") } if country == "" { return userdomain.User{}, xerr.New(xerr.InvalidArgument, "country is required") } if !userdomain.ValidCountryCode(country) { return userdomain.User{}, xerr.New(xerr.InvalidArgument, "country must use 2 to 3 uppercase letters") } if inviteCode != "" && !invitedomain.ValidCode(inviteCode) { // 邀请码不存在、格式错误或不可用都统一落到 INVALID_INVITE_CODE,避免邀请码枚举。 return userdomain.User{}, xerr.New(xerr.InvalidInviteCode, "invalid invite code") } if s.userRepository == nil { return userdomain.User{}, xerr.New(xerr.Unavailable, "user repository is not configured") } if s.countryRegionRepository == nil { return userdomain.User{}, xerr.New(xerr.Unavailable, "country repository is not configured") } resolvedCountry, ok, err := s.countryRegionRepository.ResolveEnabledCountryByCode(ctx, country) if err != nil { return userdomain.User{}, err } if !ok { return userdomain.User{}, xerr.New(xerr.InvalidArgument, "country is not supported") } var regionID int64 if region, ok, err := s.countryRegionRepository.ResolveActiveRegionByCountry(ctx, resolvedCountry.CountryCode); err != nil { return userdomain.User{}, err } else if ok { // 国家未命中显式区域时,user-service 会返回 region_id=0 的 GLOBAL 兜底桶。 regionID = region.RegionID } return s.userRepository.CompleteOnboarding(ctx, userdomain.CompleteOnboardingCommand{ AppCode: appcode.FromContext(ctx), UserID: userID, Username: username, Avatar: avatar, Gender: gender, Country: resolvedCountry.CountryCode, RegionID: regionID, InviteCode: inviteCode, RequestID: strings.TrimSpace(requestID), CompletedAtMs: s.now().UnixMilli(), }) } // ChangeUserCountry 是 App 用户自助修改国家入口;普通用户不再受冷却期限制,业务身份用户必须走后台治理流程。 func (s *Service) ChangeUserCountry(ctx context.Context, userID int64, country string, requestID string) (userdomain.User, int64, error) { if userID <= 0 { return userdomain.User{}, 0, xerr.New(xerr.InvalidArgument, "user_id is required") } if s.userRepository == nil { return userdomain.User{}, 0, xerr.New(xerr.Unavailable, "user repository is not configured") } if err := s.requireCompletedProfile(ctx, userID); err != nil { // 未完成 onboarding 时拒绝在这里改国家,避免首次国家选择写入变更日志并消耗冷却期。 return userdomain.User{}, 0, err } if s.roleSummaryRepository != nil { summary, err := s.roleSummaryRepository.GetUserRoleSummary(ctx, userID) if err != nil { return userdomain.User{}, 0, err } if role := blockedCountryChangeRole(summary); role != "" { // App 自助入口不能改变已有经营身份的区域归属,否则房间、组织和币商读模型会在用户侧无审计地漂移。 return userdomain.User{}, 0, xerr.New(xerr.PermissionDenied, "you have "+role+" id, not allow modify country") } } resolvedCountry, regionID, err := s.resolveCountryRegionForChange(ctx, country) if err != nil { return userdomain.User{}, 0, err } nowMs := s.now().UnixMilli() command := userdomain.CountryChangeCommand{ AppCode: appcode.FromContext(ctx), UserID: userID, NewCountry: resolvedCountry.CountryCode, NewRegionID: regionID, Source: "app", RequestID: strings.TrimSpace(requestID), ChangedAtMs: nowMs, CooldownMs: 0, } updated, nextAllowedAt, revokedSessionIDs, err := s.userRepository.ChangeUserCountry(ctx, command) if err != nil { return userdomain.User{}, nextAllowedAt, err } s.writeCountryChangeSessionDenylist(ctx, command, revokedSessionIDs) return updated, nextAllowedAt, nil } // AdminChangeUserCountry 是后台修改用户国家入口;后台操作已在 admin-server 做 RBAC 和审计,这里只保证用户主数据和 outbox 原子一致。 func (s *Service) AdminChangeUserCountry(ctx context.Context, targetUserID int64, country string, requestID string) (userdomain.User, int64, error) { if targetUserID <= 0 { return userdomain.User{}, 0, xerr.New(xerr.InvalidArgument, "target_user_id is required") } if s.userRepository == nil { return userdomain.User{}, 0, xerr.New(xerr.Unavailable, "user repository is not configured") } resolvedCountry, regionID, err := s.resolveCountryRegionForChange(ctx, country) if err != nil { return userdomain.User{}, 0, err } nowMs := s.now().UnixMilli() command := userdomain.CountryChangeCommand{ AppCode: appcode.FromContext(ctx), UserID: targetUserID, NewCountry: resolvedCountry.CountryCode, NewRegionID: regionID, Source: "admin", RequestID: strings.TrimSpace(requestID), ChangedAtMs: nowMs, CooldownMs: 0, } updated, nextAllowedAt, revokedSessionIDs, err := s.userRepository.ChangeUserCountry(ctx, command) if err != nil { return userdomain.User{}, nextAllowedAt, err } s.writeCountryChangeSessionDenylist(ctx, command, revokedSessionIDs) return updated, nextAllowedAt, nil } func (s *Service) writeCountryChangeSessionDenylist(ctx context.Context, command userdomain.CountryChangeCommand, sessionIDs []string) { if len(sessionIDs) == 0 || s.sessionDenylist == nil { // MySQL auth_sessions 是登录态的持久事实;Redis denylist 只用于让已经签出的 access token 在过期前更快失效。 return } _, _ = s.writeSessionDenylist(ctx, UserStatusCommand{ AppCode: command.AppCode, TargetUserID: command.UserID, RequestID: command.RequestID, NowMs: command.ChangedAtMs, }, sessionIDs, countryChangeRevokeReason) } func (s *Service) resolveCountryRegionForChange(ctx context.Context, country string) (userdomain.Country, int64, error) { country = userdomain.NormalizeCountryCode(country) if country == "" { return userdomain.Country{}, 0, xerr.New(xerr.InvalidArgument, "country is required") } if !userdomain.ValidCountryCode(country) { return userdomain.Country{}, 0, xerr.New(xerr.InvalidArgument, "country must use 2 to 3 uppercase letters") } if s.countryRegionRepository == nil { return userdomain.Country{}, 0, xerr.New(xerr.Unavailable, "country repository is not configured") } resolvedCountry, ok, err := s.countryRegionRepository.ResolveEnabledCountryByCode(ctx, country) if err != nil { return userdomain.Country{}, 0, err } if !ok { return userdomain.Country{}, 0, xerr.New(xerr.InvalidArgument, "country is not supported") } var regionID int64 if region, ok, err := s.countryRegionRepository.ResolveActiveRegionByCountry(ctx, resolvedCountry.CountryCode); err != nil { return userdomain.Country{}, 0, err } else if ok { regionID = region.RegionID } return resolvedCountry, regionID, nil } func blockedCountryChangeRole(summary hostdomain.UserRoleSummary) string { switch { case summary.IsHost: return "host" case summary.IsAgency: return "agency" case summary.IsBDLeader: return "bd leader" case summary.IsBD: return "bd" case summary.IsManager: return "manager" case summary.IsCoinSeller: return "coin seller" default: return "" } } func (s *Service) requireCompletedProfile(ctx context.Context, userID int64) error { if s.userRepository == nil { return xerr.New(xerr.Unavailable, "user repository is not configured") } user, err := s.userRepository.GetUser(ctx, userID) if err != nil { // GetUser 已经把缺失用户映射成稳定 NOT_FOUND,这里不改写错误语义。 return err } if !user.ProfileCompleted { // service 层必须基于数据库事实兜底,而不是只信 gateway token 里的 profile_completed 快照。 return xerr.New(xerr.ProfileRequired, "profile required") } return nil } func trimmedStringPtr(value *string) *string { if value == nil { return nil } trimmed := strings.TrimSpace(*value) return &trimmed }