This commit is contained in:
2026-09-15 16:02:45 +08:00
parent 112c5f8e46
commit ade2453083
70 changed files with 1441 additions and 707 deletions
+2 -1
View File
@@ -88,7 +88,8 @@ func (s *AIRecommendService) Create(ctx context.Context, userID int64, request *
// 5. 调用大模型生成推荐。
requestTime := nowUnix()
client := tokenhub.NewClient()
result, gerr := client.GenerateOutfitRecommendation(ctx, zodiac, fortune, clothes)
// 用户自定义场景(scene_type=2)透传给模型;为空时由模型自行判断。
result, gerr := client.GenerateOutfitRecommendation(ctx, zodiac, fortune, clothes, request.Scene)
if gerr != nil {
return nil, fail(errcode.AIRecommendFailed, "大模型调用失败")
}
+1
View File
@@ -215,6 +215,7 @@ func (s *ClosetShareService) List(ctx context.Context, userID int64, request *dt
ClothesCount: countByShare[sh.ShareUUID],
ExpireTime: sh.ExpireTime,
Status: sh.Status,
CreatedAt: sh.CreatedAt,
})
}
+28 -3
View File
@@ -222,10 +222,24 @@ func (s *ClothesService) processImageAsync(parentCtx context.Context, userID int
"fail_reason": "",
"original_image_path": "",
}
if uerr := dao.ClothesUpdateByUUID(ctx, clothesUUID, updates); uerr != nil {
// 带状态守卫回填:恢复任务可能已把该记录置为失败并回退了额度,
// 此时若无条件写回成功,会出现「额度已退但衣物成功」(白送额度)并残留 COS 对象。
ok, uerr := dao.ClothesUpdateByUUIDIfStatusIn(ctx, clothesUUID,
[]int8{ImageProcessMatting, ImageProcessClassifying, ImageProcessQueuing}, updates)
if uerr != nil {
slog.ErrorContext(ctx, "update clothes after classify failed", "clothes_uuid", clothesUUID, "error", uerr)
return
}
if !ok {
// 记录已被置失败或已删除:本次结果作废,清理刚上传的 COS 对象避免孤儿。
slog.WarnContext(ctx, "clothes no longer processing, discard result",
"clothes_uuid", clothesUUID, "cos_key", cosKey)
if derr := upload.FileDeleteCos(ctx, cosKey); derr != nil {
slog.ErrorContext(ctx, "delete discarded cos image failed",
"clothes_uuid", clothesUUID, "cos_key", cosKey, "error", derr)
}
return
}
_ = os.Remove(originalPath)
}
@@ -370,8 +384,8 @@ func (s *ClothesService) assertClothesVisibleViaOutfitShare(ctx context.Context,
}
return errcode.DatabaseError
}
// 访客查看要求穿搭仍公开可见(属主访问自己的分享始终可查看)。
if share.UserID != userID && !isOutfitPublicVisible(outfit) {
// 访客只读查看:分享正常 + 穿搭未被封禁即可(分享与公开状态解耦,属主访问自己的分享始终可查看)。
if share.UserID != userID && outfit.Status == OutfitStatusBanned {
return errcode.OutfitShareNotFound
}
// 该衣物必须属于该分享的穿搭明细。
@@ -433,6 +447,17 @@ func (s *ClothesService) DeleteByUUID(ctx context.Context, userID int64, request
return nil, cerr
}
// 引用校验:已被穿搭明细 / 衣柜分享明细引用的衣物不允许删除,否则会留下悬挂引用,
// 公开穿搭与分享会变成「空壳」(明细里查不到衣物,前端静默跳过),
// 也与接口文档约定的 50001「衣服已被搭配使用,无法删除」保持一致。
refCount, rerr := s.clothesDao.ClothesReferencedCount(ctx, request.ClothesUUID)
if rerr != nil {
return nil, errcode.DatabaseError
}
if refCount > 0 {
return nil, errcode.ClothesInUse
}
// 事务内锁定衣物行 -> 读最新状态 -> 软删除 -> 条件回退额度 -> 写回退流水,
// 数值变更与流水同事务强一致,并避免与异步处理失败回退并发导致漏退/双退。
vipExpire := getUserVIPExpire(ctx, userID)
+24 -10
View File
@@ -2,6 +2,7 @@ package service
import (
"context"
"log/slog"
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/global"
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/dto"
@@ -100,6 +101,11 @@ func (s *CollectService) List(ctx context.Context, userID int64, request *dto.Co
// 穿搭可能已删除,跳过。
continue
}
// 越权防护(纵深防御):DAO 已按可见性联表过滤,此处再挡一次查询与读取之间
// 发生的「转私密 / 被封禁 / 审核驳回」变更,避免下发他人非公开穿搭内容。
if outfit.UserID != userID && !isOutfitPublicVisible(outfit) {
continue
}
outfitItems, uerr := s.itemResponses(ctx, outfit.ID)
if uerr != nil {
continue
@@ -133,19 +139,27 @@ func (s *CollectService) GetByTarget(ctx context.Context, userID int64, request
}
targetID := outfit.ID
// 是否已收藏:Redis 优先,回落 MySQL。
isCollected, err := redisdao.CollectIsMember(ctx, CollectTypeOutfit, targetID, userID)
if err != nil {
if rec, derr := s.collectDao.CollectGetByTargetAndUser(ctx, CollectTypeOutfit, targetID, userID); derr == nil {
isCollected = rec.Status == InteractStatusOn
// 是否已收藏:以 MySQL 为权威。
// Redis 集合带 TTL,过期后会被后续写入重建为「仅含新成员」,导致 SIsMember/SCard 长期低估,
// 因此只在 DB 异常时用 Redis 兜底(降级可用性优先于精确性)。
isCollected := false
if rec, derr := s.collectDao.CollectGetByTargetAndUser(ctx, CollectTypeOutfit, targetID, userID); derr == nil {
isCollected = rec.Status == InteractStatusOn
} else if !isNotFound(derr) {
slog.WarnContext(ctx, "query collect record failed, fallback to redis",
"target_id", targetID, "user_id", userID, "error", derr)
if v, rerr := redisdao.CollectIsMember(ctx, CollectTypeOutfit, targetID, userID); rerr == nil {
isCollected = v
}
}
// 收藏总数:Redis 优先,回落 MySQL。
count, err := redisdao.CollectCount(ctx, CollectTypeOutfit, targetID)
if err != nil || count == 0 {
if dbCnt, derr := s.collectDao.CollectCountByTarget(ctx, CollectTypeOutfit, targetID); derr == nil {
count = dbCnt
// 收藏总数:以 MySQL 为权威(同上,Redis 仅在 DB 异常时兜底)。
count, derr := s.collectDao.CollectCountByTarget(ctx, CollectTypeOutfit, targetID)
if derr != nil {
slog.WarnContext(ctx, "count collect failed, fallback to redis",
"target_id", targetID, "error", derr)
if rcnt, rerr := redisdao.CollectCount(ctx, CollectTypeOutfit, targetID); rerr == nil {
count = rcnt
}
}
+19 -10
View File
@@ -2,6 +2,7 @@ package service
import (
"context"
"log/slog"
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/global"
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/dto"
@@ -87,19 +88,27 @@ func (s *LikeService) GetByTargetID(ctx context.Context, userID int64, request *
}
targetID := outfit.ID
// 是否已点赞:Redis 优先,回落 MySQL。
isLiked, err := redisdao.LikeIsMember(ctx, LikeTypeOutfit, targetID, userID)
if err != nil {
if rec, derr := s.likeDao.LikeGetByTargetAndUser(ctx, LikeTypeOutfit, targetID, userID); derr == nil {
isLiked = rec.Status == InteractStatusOn
// 是否已点赞:以 MySQL 为权威。
// Redis 集合带 TTL,过期后会被后续写入重建为「仅含新成员」,导致 SIsMember/SCard 长期低估,
// 因此只在 DB 异常时用 Redis 兜底(降级可用性优先于精确性)。
isLiked := false
if rec, derr := s.likeDao.LikeGetByTargetAndUser(ctx, LikeTypeOutfit, targetID, userID); derr == nil {
isLiked = rec.Status == InteractStatusOn
} else if !isNotFound(derr) {
slog.WarnContext(ctx, "query like record failed, fallback to redis",
"target_id", targetID, "user_id", userID, "error", derr)
if v, rerr := redisdao.LikeIsMember(ctx, LikeTypeOutfit, targetID, userID); rerr == nil {
isLiked = v
}
}
// 点赞总数:Redis 优先,回落 MySQL。
count, err := redisdao.LikeCount(ctx, LikeTypeOutfit, targetID)
if err != nil || count == 0 {
if dbCnt, derr := s.likeDao.LikeCountByTarget(ctx, LikeTypeOutfit, targetID); derr == nil {
count = dbCnt
// 点赞总数:以 MySQL 为权威(同上,Redis 仅在 DB 异常时兜底)。
count, derr := s.likeDao.LikeCountByTarget(ctx, LikeTypeOutfit, targetID)
if derr != nil {
slog.WarnContext(ctx, "count like failed, fallback to redis",
"target_id", targetID, "error", derr)
if rcnt, rerr := redisdao.LikeCount(ctx, LikeTypeOutfit, targetID); rerr == nil {
count = rcnt
}
}
+35 -21
View File
@@ -84,43 +84,57 @@ func (s *NotificationService) Read(ctx context.Context, userID int64, request *d
// 已读则直接返回当前未读数。
if n.IsRead == NotificationRead {
cnt, _ := redisdao.NotificationUnreadGet(ctx, userID)
cnt, cerr := s.unreadCount(ctx, userID)
if cerr != nil {
return nil, cerr
}
return &dto.NotificationUnreadResponse{UnreadCount: cnt}, nil
}
// 条件标记已读(仅未读 -> 已读),防并发重复扣减未读数。
ok, err := s.notificationDao.NotificationUpdateReadByUUIDIfUnread(ctx, request.NotificationUUID)
if err != nil {
// 条件标记已读(仅未读 -> 已读),防并发重复标记。
if _, err := s.notificationDao.NotificationUpdateReadByUUIDIfUnread(ctx, request.NotificationUUID); err != nil {
return nil, errcode.DatabaseError
}
if !ok {
// 已被并发标记已读,直接返回当前未读数。
cnt, _ := redisdao.NotificationUnreadGet(ctx, userID)
return &dto.NotificationUnreadResponse{UnreadCount: cnt}, nil
// 无论本次是否真的标记成功,都按 DB 重新统计并回填缓存:
// Redis 计数在 key 过期后会被 INCR 从 1 重建,直接返回 Redis 的 decr 结果会长期少报。
cnt, cerr := s.unreadCount(ctx, userID)
if cerr != nil {
return nil, cerr
}
cnt, _ := redisdao.NotificationUnreadDecr(ctx, userID)
return &dto.NotificationUnreadResponse{UnreadCount: cnt}, nil
}
// Unread 获取当前用户未读数,Redis 优先、回落 MySQL。
// Unread 获取当前用户未读数(以 MySQL 为权威)。
func (s *NotificationService) Unread(ctx context.Context, userID int64) (*dto.NotificationUnreadResponse, *errcode.Error) {
ctx, span := global.Tracer.Start(ctx, "svc.NotificationUnread")
defer span.End()
cnt, err := redisdao.NotificationUnreadGet(ctx, userID)
if err == nil && cnt > 0 {
return &dto.NotificationUnreadResponse{UnreadCount: cnt}, nil
cnt, cerr := s.unreadCount(ctx, userID)
if cerr != nil {
return nil, cerr
}
return &dto.NotificationUnreadResponse{UnreadCount: cnt}, nil
}
// Redis 为 0 或异常,回落 MySQL 统计。
dbCnt, derr := s.notificationDao.NotificationCountUnread(ctx, userID)
if derr != nil {
return nil, errcode.DatabaseError
// unreadCount 以 MySQL 为权威统计未读数并回填 Redis 缓存。
// 此前「Redis 优先、只有 0 或异常才回源」的策略在 key 过期后会长期少报
//(key 过期 → 新通知 INCR 从 1 开始 → Unread 读到正数就不再回源),改为 DB 权威。
// DB 异常时才用 Redis 兜底,保证降级可用性。
func (s *NotificationService) unreadCount(ctx context.Context, userID int64) (int64, *errcode.Error) {
cnt, err := s.notificationDao.NotificationCountUnread(ctx, userID)
if err != nil {
if rcnt, rerr := redisdao.NotificationUnreadGet(ctx, userID); rerr == nil {
slog.WarnContext(ctx, "count unread from db failed, fallback to redis",
"user_id", userID, "error", err)
return rcnt, nil
}
return 0, errcode.DatabaseError
}
// 回填 Redis 未读数缓存,避免 key 过期导致后续标记已读时扣减被吞。
_ = redisdao.NotificationUnreadSet(ctx, userID, dbCnt)
return &dto.NotificationUnreadResponse{UnreadCount: dbCnt}, nil
if serr := redisdao.NotificationUnreadSet(ctx, userID, cnt); serr != nil {
slog.WarnContext(ctx, "backfill notification unread cache failed",
"user_id", userID, "error", serr)
}
return cnt, nil
}
// buildNotificationResponse 将通知模型映射为响应。
+10 -1
View File
@@ -146,12 +146,21 @@ func (s *OrderService) PayNotify(ctx context.Context, request *http.Request) *er
return errcode.DatabaseError
}
if data.Amount.Total != order.PayAmount {
// 金额不一致:不流转、不发放权益,人工介入。
// 这里回的是成功应答(微信不再重试),所以必须把原始报文落库留痕,
// 否则这笔钱在系统里将无任何可追溯记录(callback_raw 是唯一能事后对账的字段)。
slog.ErrorContext(ctx, "pay notify: amount mismatch",
"out_trade_no", data.OutTradeNo,
"notify_amount", data.Amount.Total,
"order_amount", order.PayAmount,
)
return nil // 金额不一致,不流转、不发放权益,人工介入。
if uerr := s.orderDao.OrderUpdateByOrderNo(ctx, order.OrderNo, map[string]any{
"callback_raw": data.Raw,
}); uerr != nil {
slog.ErrorContext(ctx, "pay notify: record mismatch callback raw failed",
"out_trade_no", data.OutTradeNo, "error", uerr)
}
return nil
}
// 条件流转:待支付 -> 已支付,防重复回调。
+117
View File
@@ -22,6 +22,9 @@ type OutfitService struct {
outfitItemDao *mysqldao.OutfitItemDao
clothesDao *mysqldao.ClothesDao
auditLogDao *mysqldao.OutfitAuditLogDao
userDao *mysqldao.UserDao
likeDao *mysqldao.LikeDao
collectDao *mysqldao.CollectDao
}
// NewOutfitService 创建穿搭服务。
@@ -31,6 +34,9 @@ func NewOutfitService() *OutfitService {
outfitItemDao: mysqldao.NewOutfitItemDao(),
clothesDao: mysqldao.NewClothesDao(),
auditLogDao: mysqldao.NewOutfitAuditLogDao(),
userDao: mysqldao.NewUserDao(),
likeDao: mysqldao.NewLikeDao(),
collectDao: mysqldao.NewCollectDao(),
}
}
@@ -175,6 +181,30 @@ func (s *OutfitService) List(ctx context.Context, userID int64, request *dto.Out
}, nil
}
// CommunityList 分页查询社区穿搭:其他用户「公开 + 审核通过 + 状态正常」的穿搭,
// 聚合作者信息与点赞/收藏计数后一次性下发(批量查询,避免 N+1)。
func (s *OutfitService) CommunityList(ctx context.Context, userID int64, request *dto.OutfitCommunityListRequest) (*dto.OutfitCommunityListResponse, *errcode.Error) {
ctx, span := global.Tracer.Start(ctx, "svc.OutfitCommunityList")
defer span.End()
page, pageSize := normalizePage(request.Page, request.PageSize)
list, total, err := s.outfitDao.OutfitListCommunity(ctx, userID, page, pageSize)
if err != nil {
return nil, errcode.DatabaseError
}
items, berr := s.buildCommunityItems(ctx, list)
if berr != nil {
return nil, errcode.DatabaseError
}
return &dto.OutfitCommunityListResponse{
PageResponse: dto.PageResponse{Total: total, Page: page, PageSize: pageSize},
List: items,
}, nil
}
// Delete 删除穿搭(软删除穿搭 + 清理明细)。
func (s *OutfitService) Delete(ctx context.Context, userID int64, request *dto.OutfitDeleteRequest) (*dto.OutfitDeleteResponse, *errcode.Error) {
ctx, span := global.Tracer.Start(ctx, "svc.OutfitDelete")
@@ -414,6 +444,93 @@ func (s *OutfitService) buildOutfitListResponses(ctx context.Context, list []mys
return resps, nil
}
// buildCommunityItems 批量组装社区穿搭列表项(明细/衣物/作者/互动计数均批量查询,避免 N+1)。
func (s *OutfitService) buildCommunityItems(ctx context.Context, list []mysqlmodel.Outfit) ([]dto.OutfitCommunityItem, error) {
items := make([]dto.OutfitCommunityItem, 0, len(list))
if len(list) == 0 {
return items, nil
}
outfitIDs := make([]int64, 0, len(list))
ownerIDs := make([]int64, 0, len(list))
for _, o := range list {
outfitIDs = append(outfitIDs, o.ID)
ownerIDs = append(ownerIDs, o.UserID)
}
// 1. 穿搭明细 + 衣物(批量)。
allItems, err := s.outfitItemDao.OutfitItemListByOutfitIDs(ctx, outfitIDs)
if err != nil {
return nil, err
}
itemsByOutfit := make(map[int64][]int64, len(list))
clothesIDSet := make(map[int64]struct{}, len(allItems))
for _, it := range allItems {
itemsByOutfit[it.OutfitID] = append(itemsByOutfit[it.OutfitID], it.ClothesID)
clothesIDSet[it.ClothesID] = struct{}{}
}
clothesIDs := make([]int64, 0, len(clothesIDSet))
for id := range clothesIDSet {
clothesIDs = append(clothesIDs, id)
}
clothesByID := make(map[int64]mysqlmodel.Clothes, len(clothesIDs))
if len(clothesIDs) > 0 {
clothesList, cerr := s.clothesDao.ClothesGetByIDs(ctx, clothesIDs)
if cerr != nil {
return nil, cerr
}
for i := range clothesList {
clothesByID[clothesList[i].ID] = clothesList[i]
}
}
// 2. 作者信息(批量;头像 COS key 统一转可访问 URL)。
ownerByID := make(map[int64]mysqlmodel.User, len(ownerIDs))
if owners, uerr := s.userDao.UserGetByIDs(ctx, ownerIDs); uerr == nil {
for i := range owners {
ownerByID[owners[i].ID] = owners[i]
}
}
// 3. 点赞 / 收藏计数(批量)。
likeCounts, lerr := s.likeDao.LikeCountByTargets(ctx, LikeTypeOutfit, outfitIDs)
if lerr != nil {
return nil, lerr
}
collectCounts, cerr := s.collectDao.CollectCountByTargets(ctx, CollectTypeOutfit, outfitIDs)
if cerr != nil {
return nil, cerr
}
for i := range list {
o := &list[i]
oItems := make([]dto.OutfitItemResponse, 0, len(itemsByOutfit[o.ID]))
for _, cid := range itemsByOutfit[o.ID] {
if c, ok := clothesByID[cid]; ok {
oItems = append(oItems, buildOutfitItemResponse(&c))
}
}
item := dto.OutfitCommunityItem{
OutfitUUID: o.OutfitUUID,
OutfitName: o.OutfitName,
OutfitDesc: o.OutfitDesc,
Scene: o.Scene,
Source: o.Source,
CreatedAt: o.CreatedAt,
Items: oItems,
LikeCount: likeCounts[o.ID],
CollectCount: collectCounts[o.ID],
}
if owner, ok := ownerByID[o.UserID]; ok {
item.OwnerName = owner.Username
item.OwnerAvatar = upload.AvatarURL(owner.Avatar)
}
items = append(items, item)
}
return items, nil
}
// buildOutfitItemResponse 组装穿搭明细(processed_image 转为可访问 URL)。
func buildOutfitItemResponse(c *mysqlmodel.Clothes) dto.OutfitItemResponse {
return dto.OutfitItemResponse{ClothesUUID: c.ClothesUUID, ProcessedImage: clothesImageURL(c.ProcessedImage)}
+22 -6
View File
@@ -34,7 +34,9 @@ func NewOutfitShareService() *OutfitShareService {
}
}
// Create 创建穿搭分享。仅能分享本人且已公开的穿搭;不消耗额度。
// Create 创建穿搭分享。仅能分享本人的穿搭;分享与「公开到社区」解耦,
// 仅自己可见的穿搭同样可生成分享链接(好友凭 share_uuid 只读查看)。
// 额度:私密穿搭分享扣 1 次衣柜分享额度(与衣柜分享共用额度池),已公开穿搭分享不扣。
func (s *OutfitShareService) Create(ctx context.Context, userID int64, request *dto.OutfitShareCreateRequest) (*dto.OutfitShareCreateResponse, *errcode.Error) {
ctx, span := global.Tracer.Start(ctx, "svc.OutfitShareCreate")
defer span.End()
@@ -49,9 +51,18 @@ func (s *OutfitShareService) Create(ctx context.Context, userID int64, request *
if outfit.UserID != userID {
return nil, errcode.OutfitNoPermission
}
// 分享前提:穿搭已公开可见(否则对方打开后无法查看)。
if !isOutfitPublicVisible(outfit) {
return nil, errcode.InvalidParams.WithDetails("请先公开该穿搭再分享")
// 封禁穿搭不可分享;公开状态 / 审核状态不再作为前置条件(分享与公开到社区解耦)。
if outfit.Status == OutfitStatusBanned {
return nil, errcode.OutfitNoPermission
}
// 额度:仅「仅自己可见」的穿搭分享扣 1 次衣柜分享额度(与衣柜分享共用额度池);
// 已公开穿搭在「公开到社区」时已扣过社区分享额度,分享给好友不再重复扣。
charged := outfit.IsPublic == OutfitPrivate
if charged {
if qerr := consumeDailyQuota(ctx, userID, QuotaTypeClosetShare, 1, "私密穿搭分享消耗"); qerr != nil {
return nil, qerr
}
}
share := &mysqlmodel.OutfitShare{
@@ -61,6 +72,10 @@ func (s *OutfitShareService) Create(ctx context.Context, userID int64, request *
Status: ShareStatusNormal,
}
if err := s.shareDao.OutfitShareCreate(ctx, share); err != nil {
// 分享落库失败:回退刚扣的额度,避免用户额度被白扣。
if charged {
refundDailyQuota(ctx, userID, QuotaTypeClosetShare, 1)
}
return nil, errcode.DatabaseError
}
return &dto.OutfitShareCreateResponse{ShareUUID: share.ShareUUID}, nil
@@ -92,8 +107,9 @@ func (s *OutfitShareService) Detail(ctx context.Context, userID int64, shareUUID
}
isOwner := share.UserID == userID
// 访客视角:穿搭必须仍处于公开可见状态,否则分享失效
if !isOwner && !isOutfitPublicVisible(outfit) {
// 访客视角:分享正常即可只读查看(分享独立于公开状态,取消公开不影响已发出的分享);
// 仅当穿搭已删除(上方判为 90103)或已封禁时视为分享失效。
if !isOwner && outfit.Status == OutfitStatusBanned {
return nil, errcode.OutfitShareNotFound
}
+23 -11
View File
@@ -300,10 +300,16 @@ func consumeCosQuota(ctx context.Context, userID int64, n int64, relatedID int64
if delta > remain {
delta = remain
}
if err := tx.Model(&mysqlmodel.QuotaGrant{}).
res := tx.Model(&mysqlmodel.QuotaGrant{}).
Where("id = ? AND used + ? <= amount", grants[i].ID, delta).
UpdateColumn("used", gorm.Expr("used + ?", delta)).Error; err != nil {
return err
UpdateColumn("used", gorm.Expr("used + ?", delta))
if res.Error != nil {
return res.Error
}
if res.RowsAffected == 0 {
// 凭证在行锁下被并发改写(理论上不应发生)。此时若继续累加会少扣额度,
// 直接回滚整个事务并报额度不足,宁可让用户重试也不超额放行。
return errcode.QuotaInsufficient
}
remain -= delta
}
@@ -415,25 +421,31 @@ func refundCosQuotaTx(tx *gorm.DB, userID int64, vipExpire int64, n int64, relat
}
remain -= delta
}
if remain > 0 {
// 无足够已用额度可回退(数据异常),跳过写流水。
return nil
}
}
// 3. 写回退流水。
// 3. 写回退流水:变更量取「实际回退量」。
// 此前 `remain > 0` 时直接 return,导致额度已被部分改回却没有流水(账实不一致);
// 同时 ChangeNum 写的是请求量 n,退不满时数字虚高。现统一按实际回退量记账,
// 退不满/退不动时在原因里标注并告警(已用额度不足通常意味着重复回退或数据异常,必须留痕)。
refunded := n - remain
flowReason := reason
if refunded < n {
flowReason = fmt.Sprintf("%s(实际回退 %d/%d,可用已退额度不足)", reason, refunded, n)
slog.WarnContext(context.Background(), "refund cos quota partially applied",
"user_id", userID, "want", n, "refunded", refunded, "related_id", relatedID)
}
after := cosRemainTx(tx, userID, vipExpire, now)
flow := &mysqlmodel.QuotaFlowLog{
QuotaFlowUUID: genUUID(),
UserID: userID,
ChangeTarget: QuotaChangeTargetUse,
ChangeType: QuotaChangeTypeRefund,
ChangeNum: n,
OldValue: after - n,
ChangeNum: refunded,
OldValue: after - refunded,
NewValue: after,
QuotaType: QuotaTypeCos,
RelatedID: relatedID,
Reason: reason,
Reason: flowReason,
}
// 回退类流水无业务订单号,用流水 UUID 填充 order_no 避免唯一索引空串冲突。
flow.OrderNo = flow.QuotaFlowUUID
+33 -1
View File
@@ -7,6 +7,7 @@ import (
"io"
"log/slog"
"mime/multipart"
"os"
"strings"
"time"
@@ -143,6 +144,7 @@ func (s *UserService) InfoGetBySelf(ctx context.Context, userID int64) (*dto.Use
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。
@@ -158,9 +160,22 @@ func (s *UserService) InfoGetBySelf(ctx context.Context, userID int64) (*dto.Use
_ = 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")
@@ -257,9 +272,13 @@ func (s *UserService) AvatarUpload(ctx context.Context, userID int64, fileHeader
return nil, errcode.UploadFileSaveFailed
}
subDir := fmt.Sprintf("%s/%d/avatar", user.UserUUID, userID)
if _, serr := upload.FileSaveTmpBytes(ctx, imageData, fileName, subDir); serr != nil {
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
@@ -415,3 +434,16 @@ func buildUserInfoResponse(user *mysqlmodel.User) dto.UserInfoGetBySelfResponse
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)
}
+89 -11
View File
@@ -3,6 +3,7 @@ package service
import (
"context"
"log/slog"
"sync"
"time"
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/global"
@@ -17,8 +18,20 @@ const (
// virtualConfirmLookbackSeconds 查单补偿的回看窗口:只处理近 2 小时内的待支付/已过期虚拟订单。
// 该窗口覆盖推送重试耗尽的时间(官方最多重试 15 次、间隔 2/4/8/16… 秒),又避免全表扫描。
virtualConfirmLookbackSeconds int64 = 2 * 60 * 60
// virtualConfirmBatchSize 单轮最多处理的订单数,控制单轮耗时与微信接口调用量。
virtualConfirmBatchSize = 50
// virtualConfirmBatchSize 单轮最多取回的候选订单数。
virtualConfirmBatchSize = 200
// virtualConfirmWorkers 单轮并发查询的 worker 数。
// 必须并发:串行查询在积压时会撞上 ctx 超时,导致尾部订单被静默跳过(看似处理了全部,实则只查了前一部分)。
virtualConfirmWorkers = 5
// virtualConfirmUnpaidTTL 「已确认未支付」负缓存的存活时间。
// 命中即跳过本轮查询,使积压订单让出扫描名额——这是修复「积压订单饿死已支付订单」的关键;
// 同时把单个订单的查询频率从每分钟一次降到每 TTL 一次,控制微信接口调用量。
// 代价:用户支付后最坏要等一个 TTL 才被补偿发现,需要更快收敛就把该值调小。
virtualConfirmUnpaidTTL = 10 * time.Minute
// virtualConfirmLockTTL 单轮任务的分布式锁 TTL,必须大于单轮最大耗时,否则锁提前过期会让多实例并发执行。
virtualConfirmLockTTL = 5 * time.Minute
// virtualConfirmTimeout 单轮任务的上下文超时,需给「批量查询 + 并发反查」留足余量。
virtualConfirmTimeout = 10 * time.Minute
)
// StartVirtualPayConfirmScheduler 启动虚拟支付查单补偿定时任务(官方「道具直购流程」的发货轮询分支)。
@@ -45,14 +58,20 @@ func StartVirtualPayConfirmScheduler() {
}()
}
// runVirtualPayConfirm 扫描待确认的虚拟支付订单并逐个向微信反查,单实例通过 Redis 分布式锁防重。
// runVirtualPayConfirm 扫描待确认的虚拟支付订单并并发向微信反查,单实例通过 Redis 分布式锁防重。
//
// 扫描策略(避免积压饿死):
// 1. 按 id 升序取窗口内候选订单,用「已确认未支付」负缓存批量过滤掉最近查过的订单,
// 被过滤的订单不再占名额,后续新订单自然会被覆盖到;
// 2. 剩余候选用固定 worker 并发反查,避免串行耗时撞上 ctx 超时;
// 3. 候选数打满上限时打 warning,提示可能存在积压。
func runVirtualPayConfirm() {
// 虚拟支付未开通时直接返回,避免无谓的数据库扫描。
if !wechat.IsVirtualPayEnabled() {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
ctx, cancel := context.WithTimeout(context.Background(), virtualConfirmTimeout)
defer cancel()
ctx, span := global.Tracer.Start(ctx, "scheduler.VirtualPayConfirm")
@@ -60,7 +79,7 @@ func runVirtualPayConfirm() {
lockKey := redismodel.KeyLockVirtualConfirm
lockVal := genUUID()
ok, lerr := redisdao.TryLock(ctx, lockKey, lockVal, time.Minute)
ok, lerr := redisdao.TryLock(ctx, lockKey, lockVal, virtualConfirmLockTTL)
if lerr != nil || !ok {
return
}
@@ -76,17 +95,65 @@ func runVirtualPayConfirm() {
if len(orders) == 0 {
return
}
if len(orders) >= virtualConfirmBatchSize {
slog.WarnContext(ctx, "virtual confirm backlog detected, candidates reached limit",
"candidates", len(orders), "limit", virtualConfirmBatchSize,
"lookback_seconds", virtualConfirmLookbackSeconds)
}
// 批量过滤负缓存:一次 MGET,避免逐单往返 Redis。
orderNos := make([]string, 0, len(orders))
for i := range orders {
orderNos = append(orderNos, orders[i].OrderNo)
}
unpaid, merr := redisdao.VirtualPayUnpaidBatchGet(ctx, orderNos)
if merr != nil {
// 负缓存不可用不阻断补偿:本轮退化为「逐单都查」(最坏情况等价于旧行为)。
slog.WarnContext(ctx, "get virtual pay unpaid cache failed, fallback to query all", "error", merr)
unpaid = map[string]bool{}
}
targets := make([]*mysqlmodel.Order, 0, len(orders))
for i := range orders {
if unpaid[orders[i].OrderNo] {
continue // 最近已确认未支付,让出名额给其它订单。
}
targets = append(targets, &orders[i])
}
if len(targets) == 0 {
return
}
s := NewOrderService()
confirmed := 0
for i := range orders {
if s.confirmVirtualOrder(ctx, &orders[i]) {
confirmed++
}
var (
mu sync.Mutex
wg sync.WaitGroup
confirmed int
)
sem := make(chan struct{}, virtualConfirmWorkers)
for _, order := range targets {
wg.Add(1)
sem <- struct{}{}
go func(o *mysqlmodel.Order) {
defer wg.Done()
defer func() { <-sem }()
defer func() {
if r := recover(); r != nil {
slog.Error("virtual confirm worker panic", "order_no", o.OrderNo, "recover", r)
}
}()
if s.confirmVirtualOrder(ctx, o) {
mu.Lock()
confirmed++
mu.Unlock()
}
}(order)
}
wg.Wait()
if confirmed > 0 {
slog.InfoContext(ctx, "virtual pay confirm compensated orders",
"scanned", len(orders), "confirmed", confirmed)
"scanned", len(orders), "queried", len(targets), "confirmed", confirmed)
}
}
@@ -104,12 +171,18 @@ func (s *OrderService) confirmVirtualOrder(ctx context.Context, order *mysqlmode
info, qerr := wechat.QueryOrder(ctx, user.WxOpenID, order.OrderNo, "")
if qerr != nil {
// 查询失败不等于「未支付」,不写负缓存,下一轮继续重试。
slog.ErrorContext(ctx, "virtual confirm: query order failed",
"order_no", order.OrderNo, "error", qerr)
return false
}
if !wechat.IsOrderPaidStatus(info.Order.Status) {
// 未支付:用户放弃或仍在支付中,保持原状态等待过期。
// 写入负缓存让该订单在 TTL 内让出扫描名额,避免大量未支付订单把已支付订单挤出扫描窗口。
if serr := redisdao.VirtualPayUnpaidSet(ctx, order.OrderNo, virtualConfirmUnpaidTTL); serr != nil {
slog.WarnContext(ctx, "virtual confirm: set unpaid cache failed",
"order_no", order.OrderNo, "error", serr)
}
return false
}
// 金额必须与下单快照一致,防止订单号错配导致错发。
@@ -138,6 +211,11 @@ func (s *OrderService) confirmVirtualOrder(ctx context.Context, order *mysqlmode
slog.ErrorContext(ctx, "virtual confirm: notify provide goods failed",
"order_no", order.OrderNo, "error", nerr)
}
// 订单已补发,清掉可能残留的负缓存。
if derr := redisdao.VirtualPayUnpaidDel(ctx, order.OrderNo); derr != nil {
slog.WarnContext(ctx, "virtual confirm: clear unpaid cache failed",
"order_no", order.OrderNo, "error", derr)
}
return true
}
}