111 lines
4.4 KiB
Go
111 lines
4.4 KiB
Go
package user
|
||
|
||
import (
|
||
"context"
|
||
"strings"
|
||
|
||
"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, birth *string) (userdomain.User, error) {
|
||
if userID <= 0 {
|
||
// user_id 必须来自 gateway 鉴权上下文,service 层仍兜底校验。
|
||
return userdomain.User{}, xerr.New(xerr.InvalidArgument, "user_id is required")
|
||
}
|
||
username = trimmedStringPtr(username)
|
||
avatar = trimmedStringPtr(avatar)
|
||
birth = trimmedStringPtr(birth)
|
||
if username == nil && avatar == 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 birth != nil && *birth != "" {
|
||
if !userdomain.ValidProfileBirthDate(*birth) {
|
||
// 生日只保存日期,不接受时区或斜杠格式,避免客户端跨时区偏移。
|
||
return userdomain.User{}, xerr.New(xerr.InvalidArgument, "birth must use yyyy-mm-dd")
|
||
}
|
||
}
|
||
if s.userRepository == nil {
|
||
return userdomain.User{}, xerr.New(xerr.Unavailable, "user repository is not configured")
|
||
}
|
||
|
||
return s.userRepository.UpdateUserProfile(ctx, userdomain.ProfileUpdateCommand{
|
||
UserID: userID,
|
||
Username: username,
|
||
Avatar: avatar,
|
||
BirthDate: birth,
|
||
UpdatedAtMs: 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 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.userRepository == nil {
|
||
return userdomain.User{}, 0, xerr.New(xerr.Unavailable, "user repository is not configured")
|
||
}
|
||
if s.countryRegionRepository == nil {
|
||
return userdomain.User{}, 0, xerr.New(xerr.Unavailable, "country repository is not configured")
|
||
}
|
||
resolvedCountry, ok, err := s.countryRegionRepository.ResolveActiveCountryByCode(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{
|
||
UserID: userID,
|
||
NewCountry: resolvedCountry.CountryCode,
|
||
NewRegionID: regionID,
|
||
RequestID: strings.TrimSpace(requestID),
|
||
ChangedAtMs: nowMs,
|
||
CooldownMs: s.countryChangeCooldown.Milliseconds(),
|
||
})
|
||
}
|
||
|
||
func trimmedStringPtr(value *string) *string {
|
||
if value == nil {
|
||
return nil
|
||
}
|
||
trimmed := strings.TrimSpace(*value)
|
||
return &trimmed
|
||
}
|