2026-07-02 10:53:44 +08:00

419 lines
18 KiB
Go
Raw Permalink 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 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_idservice 层继续做兜底校验。
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
}