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