2026-05-05 03:05:06 +08:00

221 lines
8.9 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"
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
}