package service import ( "context" "errors" "fmt" "io" "log/slog" "mime/multipart" "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() { return nil, errcode.UserWxCodeInvalid } 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) { 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) { // 并发注册冲突:重查已存在的用户继续登录。 user, err = s.userDao.UserGetByOpenID(ctx, sess.OpenID) if err != nil { return nil, errcode.DatabaseError } } else { return nil, errcode.UserCreateFailed } } else { isNew = true // 初始化默认免费额度(best-effort,内部失败已告警,不阻断登录)。 ensureDefaultQuota(ctx, user.ID) // 赠送体验会员(best-effort);赠送成功后回读用户, // 保证后续写入缓存与登录响应的是会员态,避免被内存中的非会员对象覆盖。 if s.ensureTrialVIP(ctx, user.ID) > 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 } } } } // 非正常状态账号(临时/永久封禁、已注销)禁止登录。 if user.Status != UserStatusNormal { return nil, errcode.UserDisabled } if !isNew { _ = s.userDao.UserUpdateByID(ctx, user.ID, map[string]any{"last_login_time": nowUnix()}) } accessToken, refreshToken, terr := lzcwyjwt.GenerateTokens(user.ID) if terr != nil { 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) } // 缓存 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 { _ = redisdao.UserInfoSet(ctx, user, 0) } info := buildUserInfoResponse(user) info.IsNewUser = 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) return &info, nil } else if err != nil && !isCacheMiss(err) { // 缓存异常不阻断,继续回落 DB。 } user, err := s.userDao.UserGetByID(ctx, userID) if err != nil { if isNotFound(err) { return nil, errcode.UserNotFound } return nil, errcode.DatabaseError } _ = redisdao.UserInfoSet(ctx, user, 0) info := buildUserInfoResponse(user) return &info, nil } // 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 { return nil, errcode.UserUpdateFailed } // 失效缓存,下次读取回填最新数据。 _ = redisdao.UserInfoDel(ctx, userID) 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 { return nil, errcode.UploadFileEmpty } // 1. 校验扩展名与大小。 ext := upload.FileGetExt(fileHeader.Filename) if !upload.FileCheckAllowExt(ext, global.UploadSetting.AllowedExtensions) { return nil, errcode.UploadFileExtNotAllow } if !upload.FileCheckMaxSize(fileHeader.Size, int64(global.UploadSetting.MaxSizeMB)) { return nil, errcode.UploadFileTooLarge } // 2. 读取原图字节(必须同步读取,请求返回后 multipart 临时文件会被清理)。 src, oerr := fileHeader.Open() if oerr != nil { return nil, errcode.UploadFileSaveFailed } imageData, rerr := io.ReadAll(src) _ = src.Close() if rerr != nil { return nil, errcode.UploadFileSaveFailed } // 3. 校验文件内容为真实图片(magic bytes),防止伪造扩展名上传恶意文件。 if !upload.FileCheckImageMagic(imageData) { return nil, errcode.UploadFileExtNotAllow } // 4. 获取用户 UUID,构造 COS key:{userUUID}/{userID}/avatar/{随机名+扩展名}。 user, uerr := s.userDao.UserGetByID(ctx, userID) if uerr != nil { if isNotFound(uerr) { return nil, errcode.UserNotFound } return nil, errcode.DatabaseError } fileName, nerr := upload.FileGenName(fileHeader.Filename) if nerr != nil { return nil, errcode.UploadFileSaveFailed } subDir := fmt.Sprintf("%s/%d/avatar", user.UserUUID, userID) if _, serr := upload.FileSaveTmpBytes(ctx, imageData, fileName, subDir); serr != nil { return nil, errcode.UploadFileSaveFailed } cosKey := fmt.Sprintf("%s/%s", subDir, fileName) if cerr := upload.FileUploadCos(ctx, cosKey); cerr != nil { return nil, errcode.UploadFileSaveFailed } // 5. 更新 avatar(存 COS key,读取时统一转 URL)。 if uerr := s.userDao.UserUpdateByID(ctx, userID, map[string]any{"avatar": cosKey}); uerr != nil { return nil, errcode.UserUpdateFailed } // 6. 失效缓存,下次读取回填最新头像。 _ = redisdao.UserInfoDel(ctx, userID) 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) { return nil, errcode.UnauthorizedTokenTimeout } return nil, errcode.UnauthorizedTokenError } // 校验 refresh token 仍在有效会话集合中。 uid, aerr := redisdao.AuthUserIDByRefresh(ctx, request.RefreshToken) if aerr != nil { if isCacheMiss(aerr) { return nil, errcode.UnauthorizedTokenError } return nil, errcode.CacheError } if uid != claims.UserID { return nil, errcode.UnauthorizedTokenError } accessToken, refreshToken, terr := lzcwyjwt.GenerateTokens(claims.UserID) if terr != nil { return nil, errcode.UnauthorizedTokenGenerate } // 失效旧会话:强制携带旧 access token,精确吊销旧 access + 旧 refresh,防止旧 token 泄露后被继续使用。 if request.AccessToken == "" { return nil, errcode.InvalidParams.WithDetails("缺少旧 access token") } oldUID, oerr := redisdao.AuthUserIDByAccess(ctx, request.AccessToken) if oerr != nil && !isCacheMiss(oerr) { return nil, errcode.CacheError } if oerr == nil && oldUID != claims.UserID { return nil, errcode.UnauthorizedTokenError } _ = redisdao.AuthTokenDel(ctx, claims.UserID, request.AccessToken, request.RefreshToken) if serr := redisdao.AuthTokenSet(ctx, claims.UserID, accessToken, refreshToken); serr != nil { return nil, errcode.CacheError } return &dto.UserRefreshTokenResponse{ AccessToken: accessToken, RefreshToken: refreshToken, ExpireTime: nowUnix() + int64(global.JWTSetting.AccessTokenExpire), }, nil } // defaultVipDays 返回新用户注册赠送的体验会员天数: // 优先取 UserDefaultQuota.GiftVIPDay 的配置值,配置为 0 时生效(表示不赠送); // 未配置(整段缺失或该项缺失,指针为 nil)或配置为负数时,回落内置常量 DefaultVipDays。 func defaultVipDays() int64 { cfg := global.UserDefaultQuotaSetting if cfg == nil || cfg.GiftVIPDay == nil || *cfg.GiftVIPDay < 0 { return DefaultVipDays } return *cfg.GiftVIPDay } // ensureTrialVIP 新用户注册赠送体验会员(best-effort:任何失败仅告警,不阻断注册)。 // // 幂等:依赖 vip_flow_logs 的 uk_vip_flow_idem(order_no, change_type) 唯一索引; // 注册场景没有订单号,使用 register_gift_ 作为幂等键 —— 必须携带 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 } // 会员信息已变更,失效缓存(注册流程随后会用回读后的用户重新写入)。 _ = redisdao.UserInfoDel(ctx, userID) sendNotification(ctx, userID, 0, NotificationTypeSystem, "欢迎礼包已到账", fmt.Sprintf("已为你开通 %d 天体验会员,有效期至 %s", days, time.Unix(flow.NewVIPExpire, 0).Format("2006-01-02"))) return flow.NewVIPExpire } // 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, } }