418 lines
15 KiB
Go
418 lines
15 KiB
Go
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,
|
||
}
|
||
}
|