Files
2026-09-16 00:23:49 +08:00

604 lines
24 KiB
Go
Raw Permalink 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 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)
}
}