604 lines
24 KiB
Go
604 lines
24 KiB
Go
package service
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"io"
|
||
"log/slog"
|
||
"mime/multipart"
|
||
"os"
|
||
"strings"
|
||
"time"
|
||
|
||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/global"
|
||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/dto"
|
||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqldao"
|
||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqlmodel"
|
||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/redisdao"
|
||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/errcode"
|
||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/lzcwyjwt"
|
||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/upload"
|
||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/wechat"
|
||
)
|
||
|
||
// UserService 用户业务服务。
|
||
type UserService struct {
|
||
userDao *mysqldao.UserDao
|
||
}
|
||
|
||
// NewUserService 创建用户服务。
|
||
func NewUserService() *UserService {
|
||
return &UserService{
|
||
userDao: mysqldao.NewUserDao(),
|
||
}
|
||
}
|
||
|
||
// WxLogin 微信小程序登录:code2session 换取 openid,首次登录自动注册并初始化默认额度。
|
||
func (s *UserService) WxLogin(ctx context.Context, request *dto.UserWxLoginRequest) (*dto.UserWxLoginResponse, *errcode.Error) {
|
||
ctx, span := global.Tracer.Start(ctx, "svc.UserWxLogin")
|
||
defer span.End()
|
||
|
||
sess, err := wechat.Code2Session(ctx, request.Code)
|
||
if err != nil {
|
||
var codeErr *wechat.Code2SessionError
|
||
if errors.As(err, &codeErr) && codeErr.IsInvalidCode() {
|
||
slog.WarnContext(ctx, "wx login: invalid code", "error", err)
|
||
return nil, errcode.UserWxCodeInvalid
|
||
}
|
||
slog.ErrorContext(ctx, "wx login: code2session failed", "error", err)
|
||
return nil, errcode.UserWxLoginFailed
|
||
}
|
||
|
||
isNew := false
|
||
// userCacheStale 标记内存中的 user 对象已落后于数据库(如赠送会员后回读用户失败)。
|
||
// 为 true 时跳过用户信息缓存回写,避免用过期的非会员对象覆盖缓存(下次读取会走 DB 回填)。
|
||
userCacheStale := false
|
||
user, err := s.userDao.UserGetByOpenID(ctx, sess.OpenID)
|
||
if err != nil {
|
||
if !isNotFound(err) {
|
||
slog.ErrorContext(ctx, "wx login: query user failed", "error", err)
|
||
return nil, errcode.DatabaseError
|
||
}
|
||
// 首次登录:注册新用户。
|
||
user = &mysqlmodel.User{
|
||
UserUUID: genUUID(),
|
||
WxOpenID: sess.OpenID,
|
||
Username: "微信用户",
|
||
IsNewUser: true,
|
||
Status: UserStatusNormal,
|
||
VIPType: VIPTypeNone,
|
||
LastLoginTime: nowUnix(),
|
||
}
|
||
if cerr := s.userDao.UserCreate(ctx, user); cerr != nil {
|
||
if isDuplicateKey(cerr) {
|
||
// 并发注册冲突:重查已存在的用户继续登录。
|
||
slog.InfoContext(ctx, "wx login: concurrent register conflict, reload user")
|
||
user, err = s.userDao.UserGetByOpenID(ctx, sess.OpenID)
|
||
if err != nil {
|
||
slog.ErrorContext(ctx, "wx login: reload user after register conflict failed", "error", err)
|
||
return nil, errcode.DatabaseError
|
||
}
|
||
} else {
|
||
slog.ErrorContext(ctx, "wx login: create user failed", "error", cerr)
|
||
return nil, errcode.UserCreateFailed
|
||
}
|
||
} else {
|
||
isNew = true
|
||
// 初始化默认免费额度(best-effort,内部失败已告警,不阻断登录)。
|
||
ensureDefaultQuota(ctx, user.ID)
|
||
// 赠送体验会员(best-effort);赠送成功后回读用户,
|
||
// 保证后续写入缓存与登录响应的是会员态,避免被内存中的非会员对象覆盖。
|
||
trialVIPExpire := s.ensureTrialVIP(ctx, user.ID)
|
||
if trialVIPExpire > 0 {
|
||
if fresh, ferr := s.userDao.UserGetByID(ctx, user.ID); ferr == nil {
|
||
user = fresh
|
||
} else {
|
||
// 库中已是会员态但回读失败:不回写缓存,交由下次读取走 DB 回填。
|
||
slog.WarnContext(ctx, "reload user after trial vip failed", "user_id", user.ID, "error", ferr)
|
||
userCacheStale = true
|
||
}
|
||
}
|
||
// 赠送体验会员额度(best-effort):有效期固定为体验期结束时刻(trialVIPExpire),
|
||
// 不跟随 VIP 延长 —— 否则用户后续充值会把体验额度一直「继承」下去。
|
||
s.ensureTrialVIPQuota(ctx, user.ID, trialVIPExpire)
|
||
}
|
||
}
|
||
|
||
// 非正常状态账号(临时/永久封禁、已注销)禁止登录。
|
||
if user.Status != UserStatusNormal {
|
||
slog.WarnContext(ctx, "wx login: user disabled", "user_id", user.ID, "status", user.Status)
|
||
return nil, errcode.UserDisabled
|
||
}
|
||
|
||
if !isNew {
|
||
if uerr := s.userDao.UserUpdateByID(ctx, user.ID, map[string]any{"last_login_time": nowUnix()}); uerr != nil {
|
||
slog.WarnContext(ctx, "wx login: update last login time failed", "user_id", user.ID, "error", uerr)
|
||
}
|
||
}
|
||
|
||
accessToken, refreshToken, terr := lzcwyjwt.GenerateTokens(user.ID)
|
||
if terr != nil {
|
||
slog.ErrorContext(ctx, "wx login: generate tokens failed", "user_id", user.ID, "error", terr)
|
||
return nil, errcode.UnauthorizedTokenGenerate
|
||
}
|
||
if aerr := redisdao.AuthTokenSet(ctx, user.ID, accessToken, refreshToken); aerr != nil {
|
||
// Redis 故障降级放行(与鉴权中间件 fail-open 策略一致),避免 Redis 抖动导致全员无法登录。
|
||
slog.WarnContext(ctx, "set auth token failed, allow by fallback", "user_id", user.ID, "error", aerr)
|
||
}
|
||
|
||
// 缓存 session_key(虚拟支付 signature 计算依赖;best-effort,失败不阻断登录,
|
||
// 虚拟支付下单时若缺失会提示重新登录)。
|
||
if sess.SessionKey != "" {
|
||
if serr := redisdao.AuthSessionKeySet(ctx, user.ID, sess.SessionKey); serr != nil {
|
||
slog.WarnContext(ctx, "set session key failed", "user_id", user.ID, "error", serr)
|
||
}
|
||
}
|
||
|
||
// 刷新缓存中的用户信息(best-effort);对象已落后于数据库时不回写。
|
||
if !userCacheStale {
|
||
if cerr := redisdao.UserInfoSet(ctx, user, 0); cerr != nil {
|
||
slog.WarnContext(ctx, "wx login: cache user info failed", "user_id", user.ID, "error", cerr)
|
||
}
|
||
}
|
||
|
||
info := buildUserInfoResponse(user)
|
||
info.IsNewUser = isNew
|
||
|
||
slog.InfoContext(ctx, "user login", "user_id", user.ID, "is_new_user", isNew)
|
||
|
||
return &dto.UserWxLoginResponse{
|
||
AccessToken: accessToken,
|
||
RefreshToken: refreshToken,
|
||
ExpireTime: nowUnix() + int64(global.JWTSetting.AccessTokenExpire),
|
||
UserInfo: info,
|
||
}, nil
|
||
}
|
||
|
||
// InfoGetBySelf 获取当前用户信息,优先读缓存。
|
||
func (s *UserService) InfoGetBySelf(ctx context.Context, userID int64) (*dto.UserInfoGetBySelfResponse, *errcode.Error) {
|
||
ctx, span := global.Tracer.Start(ctx, "svc.UserInfoGetBySelf")
|
||
defer span.End()
|
||
|
||
if user, err := redisdao.UserInfoGet(ctx, userID); err == nil && user != nil {
|
||
info := buildUserInfoResponse(user)
|
||
s.consumeNewUserFlag(userID, &info)
|
||
return &info, nil
|
||
} else if err != nil && !isCacheMiss(err) {
|
||
// 缓存异常不阻断,继续回落 DB。
|
||
slog.WarnContext(ctx, "get user info: cache unavailable, fallback to db", "user_id", userID, "error", err)
|
||
}
|
||
|
||
user, err := s.userDao.UserGetByID(ctx, userID)
|
||
if err != nil {
|
||
if isNotFound(err) {
|
||
slog.WarnContext(ctx, "get user info: user not found", "user_id", userID)
|
||
return nil, errcode.UserNotFound
|
||
}
|
||
slog.ErrorContext(ctx, "get user info: query user failed", "user_id", userID, "error", err)
|
||
return nil, errcode.DatabaseError
|
||
}
|
||
if cerr := redisdao.UserInfoSet(ctx, user, 0); cerr != nil {
|
||
slog.WarnContext(ctx, "get user info: backfill cache failed", "user_id", userID, "error", cerr)
|
||
}
|
||
|
||
info := buildUserInfoResponse(user)
|
||
s.consumeNewUserFlag(userID, &info)
|
||
return &info, nil
|
||
}
|
||
|
||
// consumeNewUserFlag 消费「新用户」标记。
|
||
// is_new_user 只在注册时写 true,此前没有任何复位路径,会让客户端「新用户引导」反复触发。
|
||
// 首次读取用户信息即视为引导已消费:本次响应返回 false,并异步复位 DB(best-effort,失败只告警)。
|
||
// 只在「获取用户信息」路径调用——WxLogin 的 is_new_user 表示「本次是否新注册」,语义保持不变。
|
||
func (s *UserService) consumeNewUserFlag(userID int64, info *dto.UserInfoGetBySelfResponse) {
|
||
if !info.IsNewUser {
|
||
return
|
||
}
|
||
info.IsNewUser = false
|
||
go s.resetNewUserFlag(userID)
|
||
}
|
||
|
||
// ProfileUpdate 修改当前用户资料。
|
||
func (s *UserService) ProfileUpdate(ctx context.Context, userID int64, request *dto.UserProfileUpdateBySelfRequest) (*dto.UserProfileUpdateBySelfResponse, *errcode.Error) {
|
||
ctx, span := global.Tracer.Start(ctx, "svc.UserProfileUpdate")
|
||
defer span.End()
|
||
|
||
// 字段校验:昵称长度、头像长度、性别枚举、生日范围。
|
||
if u := strings.TrimSpace(request.Username); u != "" {
|
||
if len([]rune(u)) > 32 {
|
||
return nil, errcode.InvalidParams.WithDetails("昵称长度不能超过 32 个字符")
|
||
}
|
||
request.Username = u
|
||
}
|
||
if request.Avatar != "" && len([]rune(request.Avatar)) > 500 {
|
||
return nil, errcode.InvalidParams.WithDetails("头像地址过长")
|
||
}
|
||
if request.Sex != 0 && request.Sex != SexMale && request.Sex != SexFemale {
|
||
return nil, errcode.InvalidParams.WithDetails("性别参数不合法")
|
||
}
|
||
if request.BirthDay != 0 && request.BirthDay > nowUnix() {
|
||
return nil, errcode.InvalidParams.WithDetails("生日不能晚于当前时间")
|
||
}
|
||
|
||
updates := map[string]any{}
|
||
if request.Username != "" {
|
||
updates["username"] = request.Username
|
||
}
|
||
if request.Avatar != "" {
|
||
updates["avatar"] = request.Avatar
|
||
}
|
||
if request.Sex != 0 {
|
||
updates["sex"] = request.Sex
|
||
}
|
||
if request.BirthDay != 0 {
|
||
updates["birth_day"] = request.BirthDay
|
||
}
|
||
if len(updates) == 0 {
|
||
return &dto.UserProfileUpdateBySelfResponse{}, nil
|
||
}
|
||
|
||
if err := s.userDao.UserUpdateByID(ctx, userID, updates); err != nil {
|
||
slog.ErrorContext(ctx, "profile update: update user failed", "user_id", userID, "error", err)
|
||
return nil, errcode.UserUpdateFailed
|
||
}
|
||
// 失效缓存,下次读取回填最新数据。
|
||
if derr := redisdao.UserInfoDel(ctx, userID); derr != nil {
|
||
slog.DebugContext(ctx, "profile update: invalidate user cache failed", "user_id", userID, "error", derr)
|
||
}
|
||
|
||
return &dto.UserProfileUpdateBySelfResponse{}, nil
|
||
}
|
||
|
||
// AvatarUpload 上传用户头像:校验图片 → 落临时目录 → 上传 COS → 更新 users.avatar(存 COS key)→ 失效缓存,
|
||
// 返回可直接访问的头像 URL。头像属基础资料,不消耗衣物 COS 上传额度。
|
||
func (s *UserService) AvatarUpload(ctx context.Context, userID int64, fileHeader *multipart.FileHeader) (*dto.UserAvatarUploadResponse, *errcode.Error) {
|
||
ctx, span := global.Tracer.Start(ctx, "svc.UserAvatarUpload")
|
||
defer span.End()
|
||
|
||
if fileHeader == nil || fileHeader.Size == 0 {
|
||
slog.DebugContext(ctx, "avatar upload: empty file", "user_id", userID)
|
||
return nil, errcode.UploadFileEmpty
|
||
}
|
||
|
||
// 1. 校验扩展名与大小。
|
||
ext := upload.FileGetExt(fileHeader.Filename)
|
||
if !upload.FileCheckAllowExt(ext, global.UploadSetting.AllowedExtensions) {
|
||
slog.DebugContext(ctx, "avatar upload: extension not allowed", "user_id", userID, "ext", ext)
|
||
return nil, errcode.UploadFileExtNotAllow
|
||
}
|
||
if !upload.FileCheckMaxSize(fileHeader.Size, int64(global.UploadSetting.MaxSizeMB)) {
|
||
slog.DebugContext(ctx, "avatar upload: file too large", "user_id", userID, "size", fileHeader.Size)
|
||
return nil, errcode.UploadFileTooLarge
|
||
}
|
||
|
||
// 2. 读取原图字节(必须同步读取,请求返回后 multipart 临时文件会被清理)。
|
||
src, oerr := fileHeader.Open()
|
||
if oerr != nil {
|
||
slog.ErrorContext(ctx, "avatar upload: open file failed", "user_id", userID, "error", oerr)
|
||
return nil, errcode.UploadFileSaveFailed
|
||
}
|
||
imageData, rerr := io.ReadAll(src)
|
||
_ = src.Close()
|
||
if rerr != nil {
|
||
slog.ErrorContext(ctx, "avatar upload: read file failed", "user_id", userID, "error", rerr)
|
||
return nil, errcode.UploadFileSaveFailed
|
||
}
|
||
|
||
// 3. 校验文件内容为真实图片(magic bytes),防止伪造扩展名上传恶意文件。
|
||
if !upload.FileCheckImageMagic(imageData) {
|
||
slog.WarnContext(ctx, "avatar upload: magic bytes check failed", "user_id", userID, "size", fileHeader.Size)
|
||
return nil, errcode.UploadFileExtNotAllow
|
||
}
|
||
|
||
// 4. 获取用户 UUID,构造 COS key:{userUUID}/{userID}/avatar/{随机名+扩展名}。
|
||
user, uerr := s.userDao.UserGetByID(ctx, userID)
|
||
if uerr != nil {
|
||
if isNotFound(uerr) {
|
||
slog.WarnContext(ctx, "avatar upload: user not found", "user_id", userID)
|
||
return nil, errcode.UserNotFound
|
||
}
|
||
slog.ErrorContext(ctx, "avatar upload: query user failed", "user_id", userID, "error", uerr)
|
||
return nil, errcode.DatabaseError
|
||
}
|
||
fileName, nerr := upload.FileGenName(fileHeader.Filename)
|
||
if nerr != nil {
|
||
slog.ErrorContext(ctx, "avatar upload: generate file name failed", "user_id", userID, "error", nerr)
|
||
return nil, errcode.UploadFileSaveFailed
|
||
}
|
||
subDir := fmt.Sprintf("%s/%d/avatar", user.UserUUID, userID)
|
||
tmpPath, serr := upload.FileSaveTmpBytes(ctx, imageData, fileName, subDir)
|
||
if serr != nil {
|
||
slog.ErrorContext(ctx, "avatar upload: save temp file failed", "user_id", userID, "error", serr)
|
||
return nil, errcode.UploadFileSaveFailed
|
||
}
|
||
// 头像不消耗额度、上传接口也无限流,临时文件必须清理,
|
||
// 否则单个认证用户循环上传即可把磁盘写满(此前返回值被丢弃,文件从不删除)。
|
||
defer func() { _ = os.Remove(tmpPath) }()
|
||
cosKey := fmt.Sprintf("%s/%s", subDir, fileName)
|
||
if cerr := upload.FileUploadCos(ctx, cosKey); cerr != nil {
|
||
slog.ErrorContext(ctx, "avatar upload: upload to cos failed", "user_id", userID, "cos_key", cosKey, "error", cerr)
|
||
return nil, errcode.UploadFileSaveFailed
|
||
}
|
||
|
||
// 5. 更新 avatar(存 COS key,读取时统一转 URL)。
|
||
if uerr := s.userDao.UserUpdateByID(ctx, userID, map[string]any{"avatar": cosKey}); uerr != nil {
|
||
slog.ErrorContext(ctx, "avatar upload: update user avatar failed", "user_id", userID, "error", uerr)
|
||
return nil, errcode.UserUpdateFailed
|
||
}
|
||
|
||
// 6. 失效缓存,下次读取回填最新头像。
|
||
if derr := redisdao.UserInfoDel(ctx, userID); derr != nil {
|
||
slog.DebugContext(ctx, "avatar upload: invalidate user cache failed", "user_id", userID, "error", derr)
|
||
}
|
||
|
||
return &dto.UserAvatarUploadResponse{Avatar: upload.GetCosUrl(cosKey)}, nil
|
||
}
|
||
|
||
// RefreshToken 使用 refresh token 刷新访问令牌。
|
||
func (s *UserService) RefreshToken(ctx context.Context, request *dto.UserRefreshTokenRequest) (*dto.UserRefreshTokenResponse, *errcode.Error) {
|
||
ctx, span := global.Tracer.Start(ctx, "svc.UserRefreshToken")
|
||
defer span.End()
|
||
|
||
claims, err := lzcwyjwt.ParseRefreshToken(request.RefreshToken)
|
||
if err != nil {
|
||
if errors.Is(err, lzcwyjwt.ErrTokenExpired) {
|
||
slog.InfoContext(ctx, "refresh token: token expired")
|
||
return nil, errcode.UnauthorizedTokenTimeout
|
||
}
|
||
slog.WarnContext(ctx, "refresh token: parse token failed", "error", err)
|
||
return nil, errcode.UnauthorizedTokenError
|
||
}
|
||
|
||
// 校验 refresh token 仍在有效会话集合中。
|
||
uid, aerr := redisdao.AuthUserIDByRefresh(ctx, request.RefreshToken)
|
||
if aerr != nil {
|
||
if isCacheMiss(aerr) {
|
||
slog.WarnContext(ctx, "refresh token: session not found", "user_id", claims.UserID)
|
||
return nil, errcode.UnauthorizedTokenError
|
||
}
|
||
slog.ErrorContext(ctx, "refresh token: get session failed", "user_id", claims.UserID, "error", aerr)
|
||
return nil, errcode.CacheError
|
||
}
|
||
if uid != claims.UserID {
|
||
slog.ErrorContext(ctx, "refresh token: session user mismatch", "user_id", claims.UserID, "session_user_id", uid)
|
||
return nil, errcode.UnauthorizedTokenError
|
||
}
|
||
|
||
accessToken, refreshToken, terr := lzcwyjwt.GenerateTokens(claims.UserID)
|
||
if terr != nil {
|
||
slog.ErrorContext(ctx, "refresh token: generate tokens failed", "user_id", claims.UserID, "error", terr)
|
||
return nil, errcode.UnauthorizedTokenGenerate
|
||
}
|
||
|
||
// 失效旧会话:强制携带旧 access token,精确吊销旧 access + 旧 refresh,防止旧 token 泄露后被继续使用。
|
||
if request.AccessToken == "" {
|
||
slog.InfoContext(ctx, "refresh token: old access token missing", "user_id", claims.UserID)
|
||
return nil, errcode.InvalidParams.WithDetails("缺少旧 access token")
|
||
}
|
||
oldUID, oerr := redisdao.AuthUserIDByAccess(ctx, request.AccessToken)
|
||
if oerr != nil && !isCacheMiss(oerr) {
|
||
slog.ErrorContext(ctx, "refresh token: get old access session failed", "user_id", claims.UserID, "error", oerr)
|
||
return nil, errcode.CacheError
|
||
}
|
||
if oerr == nil && oldUID != claims.UserID {
|
||
slog.ErrorContext(ctx, "refresh token: old access token user mismatch", "user_id", claims.UserID, "old_user_id", oldUID)
|
||
return nil, errcode.UnauthorizedTokenError
|
||
}
|
||
if derr := redisdao.AuthTokenDel(ctx, claims.UserID, request.AccessToken, request.RefreshToken); derr != nil {
|
||
slog.ErrorContext(ctx, "refresh token: revoke old tokens failed", "user_id", claims.UserID, "error", derr)
|
||
}
|
||
if serr := redisdao.AuthTokenSet(ctx, claims.UserID, accessToken, refreshToken); serr != nil {
|
||
slog.ErrorContext(ctx, "refresh token: save new tokens failed", "user_id", claims.UserID, "error", serr)
|
||
return nil, errcode.CacheError
|
||
}
|
||
|
||
slog.DebugContext(ctx, "refresh token: tokens rotated", "user_id", claims.UserID)
|
||
|
||
return &dto.UserRefreshTokenResponse{
|
||
AccessToken: accessToken,
|
||
RefreshToken: refreshToken,
|
||
ExpireTime: nowUnix() + int64(global.JWTSetting.AccessTokenExpire),
|
||
}, nil
|
||
}
|
||
|
||
// defaultVipDays 返回新用户注册赠送的体验会员天数:
|
||
// 优先取 NewUserGift.GiftVIPDay(新位置);未配置该项时回落 UserDefaultQuota.GiftVIPDay(旧位置,兼容既有配置);
|
||
// 两者都不可用时回落内置常量 DefaultVipDays。显式配置为 0 表示不赠送;负数视为非法配置,继续往后回落。
|
||
func defaultVipDays() int64 {
|
||
if cfg := global.NewUserGiftSetting; cfg != nil && cfg.GiftVIPDay != nil && *cfg.GiftVIPDay >= 0 {
|
||
return *cfg.GiftVIPDay
|
||
}
|
||
if cfg := global.UserDefaultQuotaSetting; cfg != nil && cfg.GiftVIPDay != nil && *cfg.GiftVIPDay >= 0 {
|
||
return *cfg.GiftVIPDay
|
||
}
|
||
return DefaultVipDays
|
||
}
|
||
|
||
// ensureTrialVIP 新用户注册赠送体验会员(best-effort:任何失败仅告警,不阻断注册)。
|
||
//
|
||
// 幂等:依赖 vip_flow_logs 的 uk_vip_flow_idem(order_no, change_type) 唯一索引;
|
||
// 注册场景没有订单号,使用 register_gift_<userID> 作为幂等键 —— 必须携带 userID,
|
||
// 若写成固定常量则全站只能发放一次(第二个用户注册即冲突)。
|
||
//
|
||
// 返回赠送后的会员到期时间(秒);未赠送或已发放过返回 0。
|
||
func (s *UserService) ensureTrialVIP(ctx context.Context, userID int64) int64 {
|
||
days := defaultVipDays()
|
||
if days <= 0 {
|
||
return 0
|
||
}
|
||
|
||
ctx, span := global.Tracer.Start(ctx, "svc.EnsureTrialVIP")
|
||
defer span.End()
|
||
|
||
flow := &mysqlmodel.VIPFlowLog{
|
||
UserID: userID,
|
||
ChangeType: VIPChangeTypeActivity,
|
||
OldVIPType: VIPTypeNone,
|
||
NewVIPType: VIPTypeTrial,
|
||
OldVIPExpire: 0,
|
||
OrderNo: fmt.Sprintf("register_gift_%d", userID),
|
||
Source: VIPSourceSystem,
|
||
Reason: "新用户注册赠送体验会员",
|
||
}
|
||
|
||
if err := s.userDao.VIPGrantIdempotent(ctx, userID, nowUnix(), days, VIPTypeTrial, flow); err != nil {
|
||
if isDuplicateKey(err) {
|
||
// 唯一索引冲突:该用户已赠送过,按已发放处理。
|
||
return 0
|
||
}
|
||
span.RecordError(err)
|
||
slog.WarnContext(ctx, "grant trial vip failed", "user_id", userID, "days", days, "error", err)
|
||
return 0
|
||
}
|
||
|
||
// 会员信息已变更,失效缓存(注册流程随后会用回读后的用户重新写入)。
|
||
if derr := redisdao.UserInfoDel(ctx, userID); derr != nil {
|
||
slog.DebugContext(ctx, "grant trial vip: invalidate user cache failed", "user_id", userID, "error", derr)
|
||
}
|
||
slog.InfoContext(ctx, "trial vip granted", "user_id", userID, "days", days, "vip_expire", flow.NewVIPExpire)
|
||
|
||
sendNotification(ctx, userID, 0, NotificationTypeSystem, "欢迎礼包已到账",
|
||
fmt.Sprintf("已为你开通 %d 天体验会员,有效期至 %s",
|
||
days, time.Unix(flow.NewVIPExpire, 0).Format("2006-01-02")))
|
||
|
||
return flow.NewVIPExpire
|
||
}
|
||
|
||
// trialVIPQuotaValue 返回体验会员赠送的某类额度数量(NewUserGift 段配置)。
|
||
// 段缺失、该项未配置(指针为 nil)或非正数一律返回 0,表示不赠送该类额度。
|
||
func trialVIPQuotaValue(quotaType int8) int64 {
|
||
cfg := global.NewUserGiftSetting
|
||
if cfg == nil {
|
||
return 0
|
||
}
|
||
|
||
switch quotaType {
|
||
case QuotaTypeCos:
|
||
if cfg.Cos != nil && *cfg.Cos > 0 {
|
||
return *cfg.Cos
|
||
}
|
||
case QuotaTypeAI:
|
||
if cfg.AI != nil && *cfg.AI > 0 {
|
||
return *cfg.AI
|
||
}
|
||
case QuotaTypeOutfitShare:
|
||
if cfg.OutfitShare != nil && *cfg.OutfitShare > 0 {
|
||
return *cfg.OutfitShare
|
||
}
|
||
case QuotaTypeClosetShare:
|
||
if cfg.ClosetShare != nil && *cfg.ClosetShare > 0 {
|
||
return *cfg.ClosetShare
|
||
}
|
||
}
|
||
return 0
|
||
}
|
||
|
||
// ensureTrialVIPQuota 新用户注册赠送体验会员额度(best-effort:任何失败仅告警,不阻断注册)。
|
||
//
|
||
// expireAt 为体验期结束时刻(取自 ensureTrialVIP 的实际发放结果)。
|
||
//
|
||
// 这里刻意使用「固定到期时间」而不是 expire_time=0(跟随 VIP):
|
||
// 跟随 VIP 时 grantExpired 判断的是用户「当前」的 vip_expire,用户后续一充值就会把
|
||
// vip_expire 推后,体验额度被续费「继承」下去、永远不失效,失去「体验」的语义。
|
||
//
|
||
// 幂等:ref_id 固定为 register_gift_<userID>,依赖 quota_grants 的 (ref_id, quota_type) 唯一索引,
|
||
// 重复注册或重试不会重复发放,也不会重复写发放流水。
|
||
func (s *UserService) ensureTrialVIPQuota(ctx context.Context, userID, expireAt int64) {
|
||
if expireAt <= 0 {
|
||
// 未赠送体验会员(GiftVIPDay<=0),或本次未实际发放(幂等命中):
|
||
// 没有明确的体验期就不发额度,避免写入语义不清的凭证。
|
||
return
|
||
}
|
||
|
||
ctx, span := global.Tracer.Start(ctx, "svc.EnsureTrialVIPQuota")
|
||
defer span.End()
|
||
|
||
refID := fmt.Sprintf("register_gift_%d", userID)
|
||
grantDao := mysqldao.NewQuotaGrantDao()
|
||
|
||
for _, quotaType := range []int8{QuotaTypeCos, QuotaTypeAI, QuotaTypeOutfitShare, QuotaTypeClosetShare} {
|
||
amount := trialVIPQuotaValue(quotaType)
|
||
if amount <= 0 {
|
||
continue // 未配置或显式配 0:不赠送该类额度
|
||
}
|
||
|
||
grant := &mysqlmodel.QuotaGrant{
|
||
UserID: userID,
|
||
QuotaType: quotaType,
|
||
Amount: amount,
|
||
Source: QuotaGrantSourceTrial, // 体验会员赠送(与购买会员包 source=1 区分)
|
||
ExpireTime: expireAt, // 固定到期时间,不随后续续费延长
|
||
RefID: refID,
|
||
}
|
||
// 变更前后取「有效剩余」,仅用于额度明细展示(权威数值以凭证表为准),与购买发放路径保持一致。
|
||
before := int64(0)
|
||
if eff, qerr := getEffectiveQuota(ctx, userID, quotaType); qerr == nil {
|
||
before = eff.Remain
|
||
}
|
||
flow := &mysqlmodel.QuotaFlowLog{
|
||
QuotaFlowUUID: genUUID(),
|
||
UserID: userID,
|
||
ChangeTarget: QuotaChangeTargetAdjust,
|
||
ChangeType: QuotaChangeTypeActivity,
|
||
ChangeNum: amount,
|
||
OldValue: before,
|
||
NewValue: before + amount,
|
||
QuotaType: quotaType,
|
||
Reason: "新用户注册赠送体验会员额度",
|
||
}
|
||
|
||
if _, err := grantDao.QuotaGrantCreateWithFlow(ctx, grant, flow); err != nil {
|
||
slog.WarnContext(ctx, "grant trial vip quota failed",
|
||
"user_id", userID, "quota_type", quotaType, "amount", amount, "error", err)
|
||
}
|
||
}
|
||
}
|
||
|
||
// buildUserInfoResponse 将 User 模型映射为用户信息响应。
|
||
// 会员已过期(vip_expire <= now)时把 vip_type 归一为 1:项目没有会员到期归位任务,
|
||
// 过期会员的 vip_type 仍保留原值,归一可避免调用方仅凭 vip_type 误判为有效会员(只影响响应,不写库)。
|
||
func buildUserInfoResponse(user *mysqlmodel.User) dto.UserInfoGetBySelfResponse {
|
||
vipType := user.VIPType
|
||
if vipType != VIPTypeNone && user.VIPExpire <= nowUnix() {
|
||
vipType = VIPTypeNone
|
||
}
|
||
|
||
// 头像兼容转换:历史通过修改资料接口传入的 http(s) URL 直接透传;
|
||
// 头像上传接口写入的 COS key 统一转为可访问 URL。
|
||
avatar := upload.AvatarURL(user.Avatar)
|
||
|
||
return dto.UserInfoGetBySelfResponse{
|
||
Username: user.Username,
|
||
Avatar: avatar,
|
||
Sex: user.Sex,
|
||
BirthDay: user.BirthDay,
|
||
Phone: user.Phone,
|
||
IsNewUser: user.IsNewUser,
|
||
Status: user.Status,
|
||
BanReason: user.BanReason,
|
||
BanStart: user.BanStart,
|
||
BanExpire: user.BanExpire,
|
||
VIPType: vipType,
|
||
VIPExpire: user.VIPExpire,
|
||
}
|
||
}
|
||
|
||
// resetNewUserFlag 异步复位 is_new_user(best-effort,失败仅告警)。
|
||
// 使用独立 context,避免请求结束后 ctx 被取消导致复位失败。
|
||
func (s *UserService) resetNewUserFlag(userID int64) {
|
||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||
defer cancel()
|
||
// 异步任务无请求 ctx,派生独立 span 保证该路径日志携带 trace_id。
|
||
ctx, span := global.Tracer.Start(ctx, "svc.ResetNewUserFlag")
|
||
defer span.End()
|
||
|
||
if err := s.userDao.UserUpdateByID(ctx, userID, map[string]any{"is_new_user": false}); err != nil {
|
||
slog.WarnContext(ctx, "reset is_new_user failed", "user_id", userID, "error", err)
|
||
return
|
||
}
|
||
if derr := redisdao.UserInfoDel(ctx, userID); derr != nil {
|
||
slog.DebugContext(ctx, "reset is_new_user: invalidate user cache failed", "user_id", userID, "error", derr)
|
||
}
|
||
}
|