补齐相关日志日志

This commit is contained in:
2026-09-16 00:23:49 +08:00
parent 8c2ea84939
commit b2e5eef3c8
39 changed files with 1576 additions and 176 deletions
+170 -16
View File
@@ -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)
}
}