221 lines
8.9 KiB
Go
221 lines
8.9 KiB
Go
package user
|
||
|
||
import (
|
||
"context"
|
||
"strings"
|
||
|
||
"hyapp/pkg/appcode"
|
||
"hyapp/pkg/xerr"
|
||
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(),
|
||
})
|
||
}
|
||
|
||
// 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) (userdomain.User, error) {
|
||
username = strings.TrimSpace(username)
|
||
avatar = strings.TrimSpace(avatar)
|
||
gender = strings.TrimSpace(gender)
|
||
country = userdomain.NormalizeCountryCode(country)
|
||
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 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,
|
||
CompletedAtMs: s.now().UnixMilli(),
|
||
})
|
||
}
|
||
|
||
// ChangeUserCountry 修改用户国家并写审计日志;同一用户滚动 30 天内只能成功修改一次。
|
||
func (s *Service) ChangeUserCountry(ctx context.Context, userID int64, country string, requestID string) (userdomain.User, int64, error) {
|
||
country = userdomain.NormalizeCountryCode(country)
|
||
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 country == "" {
|
||
return userdomain.User{}, 0, xerr.New(xerr.InvalidArgument, "country is required")
|
||
}
|
||
if !userdomain.ValidCountryCode(country) {
|
||
return userdomain.User{}, 0, xerr.New(xerr.InvalidArgument, "country must use 2 to 3 uppercase letters")
|
||
}
|
||
if s.countryRegionRepository == nil {
|
||
return userdomain.User{}, 0, xerr.New(xerr.Unavailable, "country repository is not configured")
|
||
}
|
||
resolvedCountry, ok, err := s.countryRegionRepository.ResolveEnabledCountryByCode(ctx, country)
|
||
if err != nil {
|
||
return userdomain.User{}, 0, err
|
||
}
|
||
if !ok {
|
||
return userdomain.User{}, 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.User{}, 0, err
|
||
} else if ok {
|
||
regionID = region.RegionID
|
||
}
|
||
|
||
nowMs := s.now().UnixMilli()
|
||
return s.userRepository.ChangeUserCountry(ctx, userdomain.CountryChangeCommand{
|
||
AppCode: appcode.FromContext(ctx),
|
||
UserID: userID,
|
||
NewCountry: resolvedCountry.CountryCode,
|
||
NewRegionID: regionID,
|
||
RequestID: strings.TrimSpace(requestID),
|
||
ChangedAtMs: nowMs,
|
||
CooldownMs: s.countryChangeCooldown.Milliseconds(),
|
||
})
|
||
}
|
||
|
||
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
|
||
}
|