2026-06-16 17:00:06 +08:00

356 lines
14 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 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(),
})
}
// 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
}