bugfix
This commit is contained in:
@@ -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, "大模型调用失败")
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 将通知模型映射为响应。
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
// 条件流转:待支付 -> 已支付,防重复回调。
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user