补齐相关日志日志
This commit is contained in:
+170
-16
@@ -43,8 +43,10 @@ func (s *UserService) WxLogin(ctx context.Context, request *dto.UserWxLoginReque
|
||||
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
|
||||
}
|
||||
|
||||
@@ -55,6 +57,7 @@ func (s *UserService) WxLogin(ctx context.Context, request *dto.UserWxLoginReque
|
||||
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
|
||||
}
|
||||
// 首次登录:注册新用户。
|
||||
@@ -70,11 +73,14 @@ func (s *UserService) WxLogin(ctx context.Context, request *dto.UserWxLoginReque
|
||||
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 {
|
||||
@@ -83,7 +89,8 @@ func (s *UserService) WxLogin(ctx context.Context, request *dto.UserWxLoginReque
|
||||
ensureDefaultQuota(ctx, user.ID)
|
||||
// 赠送体验会员(best-effort);赠送成功后回读用户,
|
||||
// 保证后续写入缓存与登录响应的是会员态,避免被内存中的非会员对象覆盖。
|
||||
if s.ensureTrialVIP(ctx, user.ID) > 0 {
|
||||
trialVIPExpire := s.ensureTrialVIP(ctx, user.ID)
|
||||
if trialVIPExpire > 0 {
|
||||
if fresh, ferr := s.userDao.UserGetByID(ctx, user.ID); ferr == nil {
|
||||
user = fresh
|
||||
} else {
|
||||
@@ -92,25 +99,32 @@ func (s *UserService) WxLogin(ctx context.Context, request *dto.UserWxLoginReque
|
||||
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 {
|
||||
_ = s.userDao.UserUpdateByID(ctx, user.ID, map[string]any{"last_login_time": nowUnix()})
|
||||
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", "error", aerr)
|
||||
slog.WarnContext(ctx, "set auth token failed, allow by fallback", "user_id", user.ID, "error", aerr)
|
||||
}
|
||||
|
||||
// 缓存 session_key(虚拟支付 signature 计算依赖;best-effort,失败不阻断登录,
|
||||
@@ -123,12 +137,16 @@ func (s *UserService) WxLogin(ctx context.Context, request *dto.UserWxLoginReque
|
||||
|
||||
// 刷新缓存中的用户信息(best-effort);对象已落后于数据库时不回写。
|
||||
if !userCacheStale {
|
||||
_ = redisdao.UserInfoSet(ctx, user, 0)
|
||||
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,
|
||||
@@ -148,16 +166,21 @@ func (s *UserService) InfoGetBySelf(ctx context.Context, userID int64) (*dto.Use
|
||||
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
|
||||
}
|
||||
_ = redisdao.UserInfoSet(ctx, user, 0)
|
||||
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)
|
||||
@@ -216,10 +239,13 @@ func (s *UserService) ProfileUpdate(ctx context.Context, userID int64, request *
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
// 失效缓存,下次读取回填最新数据。
|
||||
_ = redisdao.UserInfoDel(ctx, userID)
|
||||
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
|
||||
}
|
||||
@@ -231,31 +257,37 @@ func (s *UserService) AvatarUpload(ctx context.Context, userID int64, fileHeader
|
||||
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
|
||||
}
|
||||
|
||||
@@ -263,17 +295,21 @@ func (s *UserService) AvatarUpload(ctx context.Context, userID int64, fileHeader
|
||||
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
|
||||
}
|
||||
// 头像不消耗额度、上传接口也无限流,临时文件必须清理,
|
||||
@@ -281,16 +317,20 @@ func (s *UserService) AvatarUpload(ctx context.Context, userID int64, fileHeader
|
||||
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. 失效缓存,下次读取回填最新头像。
|
||||
_ = redisdao.UserInfoDel(ctx, userID)
|
||||
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
|
||||
}
|
||||
@@ -303,8 +343,10 @@ func (s *UserService) RefreshToken(ctx context.Context, request *dto.UserRefresh
|
||||
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
|
||||
}
|
||||
|
||||
@@ -312,35 +354,47 @@ func (s *UserService) RefreshToken(ctx context.Context, request *dto.UserRefresh
|
||||
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
|
||||
}
|
||||
_ = redisdao.AuthTokenDel(ctx, claims.UserID, request.AccessToken, request.RefreshToken)
|
||||
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,
|
||||
@@ -349,14 +403,16 @@ func (s *UserService) RefreshToken(ctx context.Context, request *dto.UserRefresh
|
||||
}
|
||||
|
||||
// defaultVipDays 返回新用户注册赠送的体验会员天数:
|
||||
// 优先取 UserDefaultQuota.GiftVIPDay 的配置值,配置为 0 时生效(表示不赠送);
|
||||
// 未配置(整段缺失或该项缺失,指针为 nil)或配置为负数时,回落内置常量 DefaultVipDays。
|
||||
// 优先取 NewUserGift.GiftVIPDay(新位置);未配置该项时回落 UserDefaultQuota.GiftVIPDay(旧位置,兼容既有配置);
|
||||
// 两者都不可用时回落内置常量 DefaultVipDays。显式配置为 0 表示不赠送;负数视为非法配置,继续往后回落。
|
||||
func defaultVipDays() int64 {
|
||||
cfg := global.UserDefaultQuotaSetting
|
||||
if cfg == nil || cfg.GiftVIPDay == nil || *cfg.GiftVIPDay < 0 {
|
||||
return DefaultVipDays
|
||||
if cfg := global.NewUserGiftSetting; cfg != nil && cfg.GiftVIPDay != nil && *cfg.GiftVIPDay >= 0 {
|
||||
return *cfg.GiftVIPDay
|
||||
}
|
||||
return *cfg.GiftVIPDay
|
||||
if cfg := global.UserDefaultQuotaSetting; cfg != nil && cfg.GiftVIPDay != nil && *cfg.GiftVIPDay >= 0 {
|
||||
return *cfg.GiftVIPDay
|
||||
}
|
||||
return DefaultVipDays
|
||||
}
|
||||
|
||||
// ensureTrialVIP 新用户注册赠送体验会员(best-effort:任何失败仅告警,不阻断注册)。
|
||||
@@ -397,7 +453,10 @@ func (s *UserService) ensureTrialVIP(ctx context.Context, userID int64) int64 {
|
||||
}
|
||||
|
||||
// 会员信息已变更,失效缓存(注册流程随后会用回读后的用户重新写入)。
|
||||
_ = redisdao.UserInfoDel(ctx, userID)
|
||||
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",
|
||||
@@ -406,6 +465,96 @@ func (s *UserService) ensureTrialVIP(ctx context.Context, userID int64) int64 {
|
||||
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 误判为有效会员(只影响响应,不写库)。
|
||||
@@ -440,10 +589,15 @@ func buildUserInfoResponse(user *mysqlmodel.User) dto.UserInfoGetBySelfResponse
|
||||
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
|
||||
}
|
||||
_ = redisdao.UserInfoDel(ctx, userID)
|
||||
if derr := redisdao.UserInfoDel(ctx, userID); derr != nil {
|
||||
slog.DebugContext(ctx, "reset is_new_user: invalidate user cache failed", "user_id", userID, "error", derr)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user