first commit

This commit is contained in:
2026-09-11 01:41:52 +08:00
commit 9f22e6ccae
263 changed files with 28935 additions and 0 deletions
+417
View File
@@ -0,0 +1,417 @@
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> 作为幂等键 —— 必须携带 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,
}
}