Files
zt-closet-backend/internal/service/user.go
T
2026-09-15 16:02:45 +08:00

450 lines
16 KiB
Go
Raw 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() {
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)
s.consumeNewUserFlag(userID, &info)
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)
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 {
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)
tmpPath, serr := upload.FileSaveTmpBytes(ctx, imageData, fileName, subDir)
if serr != nil {
return nil, errcode.UploadFileSaveFailed
}
// 头像不消耗额度、上传接口也无限流,临时文件必须清理,
// 否则单个认证用户循环上传即可把磁盘写满(此前返回值被丢弃,文件从不删除)。
defer func() { _ = os.Remove(tmpPath) }()
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> 作为幂等键 —— 必须携带 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,
}
}
// 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()
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)
}