bugfix
This commit is contained in:
@@ -67,6 +67,32 @@ type OutfitListResponse struct {
|
||||
List []OutfitCreateResponse `json:"list"`
|
||||
}
|
||||
|
||||
// OutfitCommunityListRequest 社区穿搭列表请求
|
||||
type OutfitCommunityListRequest struct {
|
||||
PageRequest
|
||||
}
|
||||
|
||||
// OutfitCommunityListResponse 社区穿搭列表响应
|
||||
type OutfitCommunityListResponse struct {
|
||||
PageResponse
|
||||
List []OutfitCommunityItem `json:"list"`
|
||||
}
|
||||
|
||||
// OutfitCommunityItem 社区穿搭列表项(含作者信息与互动计数)
|
||||
type OutfitCommunityItem struct {
|
||||
OutfitUUID string `json:"outfit_uuid"`
|
||||
OutfitName string `json:"outfit_name"`
|
||||
OutfitDesc string `json:"outfit_desc"`
|
||||
Scene string `json:"scene"`
|
||||
Source int8 `json:"source"` // 1=用户创建 2=AI推荐
|
||||
CreatedAt int64 `json:"created_at"` // 秒级时间戳
|
||||
Items []OutfitItemResponse `json:"items"`
|
||||
OwnerName string `json:"owner_name"`
|
||||
OwnerAvatar string `json:"owner_avatar"`
|
||||
LikeCount int64 `json:"like_count"`
|
||||
CollectCount int64 `json:"collect_count"`
|
||||
}
|
||||
|
||||
// OutfitDeleteRequest 删除穿搭
|
||||
type OutfitDeleteRequest struct {
|
||||
OutfitUUID string `json:"outfit_uuid" binding:"required"`
|
||||
|
||||
@@ -55,6 +55,48 @@ func (d *ClothesDao) ClothesUpdateByUUID(ctx context.Context, clothesUUID string
|
||||
return nil
|
||||
}
|
||||
|
||||
// ClothesUpdateByUUIDIfStatusIn 条件更新衣物:仅当当前图片处理状态在 statuses 内时才更新,返回是否更新成功。
|
||||
// 用于「处理成功回填」这类必须与状态守卫绑定的写操作:恢复任务可能已把该记录置为失败并回退额度,
|
||||
// 无条件写回会把状态改回成功,造成「额度已退但衣物成功」的不一致。
|
||||
func (d *ClothesDao) ClothesUpdateByUUIDIfStatusIn(ctx context.Context, clothesUUID string, statuses []int8, updates map[string]any) (bool, error) {
|
||||
ctx, span := global.Tracer.Start(ctx, "mysqldao.ClothesUpdateByUUIDIfStatusIn")
|
||||
defer span.End()
|
||||
|
||||
res := d.db.WithContext(ctx).Model(&mysqlmodel.Clothes{}).
|
||||
Where("clothes_uuid = ? AND image_process_status IN ?", clothesUUID, statuses).
|
||||
Updates(updates)
|
||||
if res.Error != nil {
|
||||
span.RecordError(res.Error)
|
||||
return false, res.Error
|
||||
}
|
||||
return res.RowsAffected > 0, nil
|
||||
}
|
||||
|
||||
// ClothesReferencedCount 统计衣物被「穿搭明细 / 衣柜分享明细」引用的数量(删除前校验,>0 表示不可删除)。
|
||||
// 删除被引用的衣物会留下悬挂引用,公开穿搭与分享会变成空壳(明细里查不到衣物,前端静默跳过)。
|
||||
func (d *ClothesDao) ClothesReferencedCount(ctx context.Context, clothesUUID string) (int64, error) {
|
||||
ctx, span := global.Tracer.Start(ctx, "mysqldao.ClothesReferencedCount")
|
||||
defer span.End()
|
||||
|
||||
subQuery := "clothes_id IN (SELECT id FROM clothes WHERE clothes_uuid = ? AND deleted_at = 0)"
|
||||
|
||||
var total int64
|
||||
if err := d.db.WithContext(ctx).Model(&mysqlmodel.OutfitItem{}).
|
||||
Where(subQuery, clothesUUID).Count(&total).Error; err != nil {
|
||||
span.RecordError(err)
|
||||
return 0, err
|
||||
}
|
||||
if total > 0 {
|
||||
return total, nil
|
||||
}
|
||||
if err := d.db.WithContext(ctx).Model(&mysqlmodel.ClosetShareItem{}).
|
||||
Where(subQuery, clothesUUID).Count(&total).Error; err != nil {
|
||||
span.RecordError(err)
|
||||
return 0, err
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
|
||||
// ClothesGetByUUID 按 UUID 查询衣物
|
||||
func (d *ClothesDao) ClothesGetByUUID(ctx context.Context, clothesUUID string) (*mysqlmodel.Clothes, error) {
|
||||
ctx, span := global.Tracer.Start(ctx, "mysqldao.ClothesGetByUUID")
|
||||
|
||||
@@ -86,13 +86,48 @@ func (d *CollectDao) CollectCountByTarget(ctx context.Context, collectType int8,
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// CollectListTargetIDsByUserPage 分页查询用户收藏的目标ID列表(仅 status=1),返回 ids 与总数。
|
||||
// CollectCountByTargets 批量统计多个目标的收藏数(仅 status=1),返回 targetID -> count;空入参短路。
|
||||
func (d *CollectDao) CollectCountByTargets(ctx context.Context, collectType int8, targetIDs []int64) (map[int64]int64, error) {
|
||||
ctx, span := global.Tracer.Start(ctx, "mysqldao.CollectCountByTargets")
|
||||
defer span.End()
|
||||
|
||||
counts := make(map[int64]int64, len(targetIDs))
|
||||
if len(targetIDs) == 0 {
|
||||
return counts, nil
|
||||
}
|
||||
|
||||
var rows []struct {
|
||||
TargetID int64 `gorm:"column:target_id"`
|
||||
Cnt int64 `gorm:"column:cnt"`
|
||||
}
|
||||
if err := d.db.WithContext(ctx).Model(&mysqlmodel.Collect{}).
|
||||
Select("target_id, COUNT(*) AS cnt").
|
||||
Where("collect_type = ? AND target_id IN ? AND status = ?", collectType, targetIDs, int8(1)).
|
||||
Group("target_id").
|
||||
Scan(&rows).Error; err != nil {
|
||||
span.RecordError(err)
|
||||
return nil, err
|
||||
}
|
||||
for _, r := range rows {
|
||||
counts[r.TargetID] = r.Cnt
|
||||
}
|
||||
return counts, nil
|
||||
}
|
||||
|
||||
// CollectListTargetIDsByUserPage 分页查询用户收藏的目标ID列表(仅 status=1,且目标穿搭对本人可见),返回 ids 与总数。
|
||||
//
|
||||
// 可见性口径与 service.isOutfitPublicVisible 保持一致:本人收藏自己的穿搭始终可见;
|
||||
// 收藏他人穿搭要求「公开(2) + 审核通过(2) + 状态正常(1)」。
|
||||
// 联表过滤而非服务层过滤,是为了让 total 与实际返回条数口径一致
|
||||
//(此前只按 collects 计数,穿搭转私/删除后会返回 total 虚高、页内条数偏少的空洞页)。
|
||||
func (d *CollectDao) CollectListTargetIDsByUserPage(ctx context.Context, collectType int8, userID int64, page, pageSize int) ([]int64, int64, error) {
|
||||
ctx, span := global.Tracer.Start(ctx, "mysqldao.CollectListTargetIDsByUserPage")
|
||||
defer span.End()
|
||||
|
||||
q := d.db.WithContext(ctx).Model(&mysqlmodel.Collect{}).
|
||||
Where("collect_type = ? AND user_id = ? AND status = ?", collectType, userID, int8(1))
|
||||
Joins("JOIN outfits o ON o.id = collects.target_id AND o.deleted_at = 0").
|
||||
Where("collects.collect_type = ? AND collects.user_id = ? AND collects.status = ?", collectType, userID, int8(1)).
|
||||
Where("o.user_id = ? OR (o.is_public = ? AND o.audit_status = ? AND o.status = ?)", userID, 2, 2, 1)
|
||||
|
||||
var total int64
|
||||
if err := q.Count(&total).Error; err != nil {
|
||||
@@ -101,7 +136,8 @@ func (d *CollectDao) CollectListTargetIDsByUserPage(ctx context.Context, collect
|
||||
}
|
||||
|
||||
var ids []int64
|
||||
if err := q.Order("id DESC").Offset((page-1)*pageSize).Limit(pageSize).Pluck("target_id", &ids).Error; err != nil {
|
||||
if err := q.Order("collects.id DESC").Offset((page-1)*pageSize).Limit(pageSize).
|
||||
Pluck("collects.target_id", &ids).Error; err != nil {
|
||||
span.RecordError(err)
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
@@ -86,6 +86,34 @@ func (d *LikeDao) LikeCountByTarget(ctx context.Context, likeType int8, targetID
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// LikeCountByTargets 批量统计多个目标的点赞数(仅 status=1),返回 targetID -> count;空入参短路。
|
||||
func (d *LikeDao) LikeCountByTargets(ctx context.Context, likeType int8, targetIDs []int64) (map[int64]int64, error) {
|
||||
ctx, span := global.Tracer.Start(ctx, "mysqldao.LikeCountByTargets")
|
||||
defer span.End()
|
||||
|
||||
counts := make(map[int64]int64, len(targetIDs))
|
||||
if len(targetIDs) == 0 {
|
||||
return counts, nil
|
||||
}
|
||||
|
||||
var rows []struct {
|
||||
TargetID int64 `gorm:"column:target_id"`
|
||||
Cnt int64 `gorm:"column:cnt"`
|
||||
}
|
||||
if err := d.db.WithContext(ctx).Model(&mysqlmodel.Like{}).
|
||||
Select("target_id, COUNT(*) AS cnt").
|
||||
Where("like_type = ? AND target_id IN ? AND status = ?", likeType, targetIDs, int8(1)).
|
||||
Group("target_id").
|
||||
Scan(&rows).Error; err != nil {
|
||||
span.RecordError(err)
|
||||
return nil, err
|
||||
}
|
||||
for _, r := range rows {
|
||||
counts[r.TargetID] = r.Cnt
|
||||
}
|
||||
return counts, nil
|
||||
}
|
||||
|
||||
// LikeListUserIDsByTarget 查询点赞用户ID列表(仅 status=1)
|
||||
func (d *LikeDao) LikeListUserIDsByTarget(ctx context.Context, likeType int8, targetID int64) ([]int64, error) {
|
||||
ctx, span := global.Tracer.Start(ctx, "mysqldao.LikeListUserIDsByTarget")
|
||||
|
||||
@@ -165,6 +165,28 @@ func (d *OutfitDao) OutfitListByUserID(ctx context.Context, userID int64, isPubl
|
||||
return list, total, nil
|
||||
}
|
||||
|
||||
// OutfitListCommunity 分页查询社区穿搭(其他用户「公开 + 审核通过 + 状态正常」的穿搭)。
|
||||
// 状态字面量与 service.isOutfitPublicVisible 保持一致:is_public 2=公开;audit_status 2=审核通过;status 1=正常。
|
||||
func (d *OutfitDao) OutfitListCommunity(ctx context.Context, excludeUserID int64, page, pageSize int) ([]mysqlmodel.Outfit, int64, error) {
|
||||
ctx, span := global.Tracer.Start(ctx, "mysqldao.OutfitListCommunity")
|
||||
defer span.End()
|
||||
|
||||
var list []mysqlmodel.Outfit
|
||||
var total int64
|
||||
q := d.db.WithContext(ctx).Model(&mysqlmodel.Outfit{}).
|
||||
Where("is_public = ? AND audit_status = ? AND status = ?", int8(2), int8(2), int8(1)).
|
||||
Where("user_id != ?", excludeUserID)
|
||||
if err := q.Count(&total).Error; err != nil {
|
||||
span.RecordError(err)
|
||||
return nil, 0, err
|
||||
}
|
||||
if err := q.Offset((page - 1) * pageSize).Limit(pageSize).Order("id DESC").Find(&list).Error; err != nil {
|
||||
span.RecordError(err)
|
||||
return nil, 0, err
|
||||
}
|
||||
return list, total, nil
|
||||
}
|
||||
|
||||
// OutfitItemDao 穿搭明细数据访问
|
||||
type OutfitItemDao struct {
|
||||
db *gorm.DB
|
||||
|
||||
@@ -119,10 +119,18 @@ func (d *OutfitShareReceiverDao) OutfitShareReceivedList(ctx context.Context, re
|
||||
Joins("JOIN outfit_share_receivers r ON r.share_uuid = outfit_shares.share_uuid").
|
||||
Joins("JOIN outfits o ON o.id = outfit_shares.outfit_id").
|
||||
Where("r.receiver_user_id = ?", receiverUserID).
|
||||
Where("outfit_shares.status = ?", 1).
|
||||
Where("o.is_public = ? AND o.audit_status = ? AND o.status = ? AND o.deleted_at = 0", 2, 2, 1)
|
||||
// 可见性:仅排除「已删除」与「已封禁」的穿搭。
|
||||
// 分享与公开状态解耦(仅自己可见的穿搭同样可分享),故不再按 is_public / audit_status 过滤;
|
||||
// 穿搭删除或封禁后不再下发内容。
|
||||
Where("o.status <> ? AND o.deleted_at = 0", 2)
|
||||
if status != 0 {
|
||||
// 显式筛选分享状态:1=正常 2=已取消。
|
||||
// 修复:此前这里无条件叠加 `outfit_shares.status = 1`,与下面的参数条件形成
|
||||
// `status = 1 AND status = 2`,导致传 status=2 恒返回空列表。
|
||||
q = q.Where("outfit_shares.status = ?", status)
|
||||
} else {
|
||||
// 默认只返回正常分享:已取消的分享不应再出现在接收列表(保留「取消分享后不再下发」语义)。
|
||||
q = q.Where("outfit_shares.status = ?", 1)
|
||||
}
|
||||
if err := q.Count(&total).Error; err != nil {
|
||||
span.RecordError(err)
|
||||
|
||||
@@ -22,15 +22,6 @@ end
|
||||
return v
|
||||
`)
|
||||
|
||||
// notificationUnreadDecrScript 未读数 -1,保证结果不低于 0
|
||||
var notificationUnreadDecrScript = redis.NewScript(`
|
||||
local v = redis.call('GET', KEYS[1])
|
||||
if v and tonumber(v) > 0 then
|
||||
return redis.call('DECRBY', KEYS[1], 1)
|
||||
end
|
||||
return 0
|
||||
`)
|
||||
|
||||
// NotificationUnreadIncr 用户未读消息数 +1,返回最新值
|
||||
func NotificationUnreadIncr(ctx context.Context, userID int64) (int64, error) {
|
||||
ctx, span := global.Tracer.Start(ctx, "redisdao.NotificationUnreadIncr")
|
||||
@@ -45,20 +36,6 @@ func NotificationUnreadIncr(ctx context.Context, userID int64) (int64, error) {
|
||||
return val, nil
|
||||
}
|
||||
|
||||
// NotificationUnreadDecr 用户未读消息数 -1,返回扣减后的值(不低于 0)
|
||||
func NotificationUnreadDecr(ctx context.Context, userID int64) (int64, error) {
|
||||
ctx, span := global.Tracer.Start(ctx, "redisdao.NotificationUnreadDecr")
|
||||
defer span.End()
|
||||
|
||||
key := fmt.Sprintf(redismodel.KeyNotificationUnread, userID)
|
||||
val, err := notificationUnreadDecrScript.Run(ctx, global.RedisEngine, []string{key}).Int64()
|
||||
if err != nil {
|
||||
span.RecordError(err)
|
||||
return 0, err
|
||||
}
|
||||
return val, nil
|
||||
}
|
||||
|
||||
// NotificationUnreadGet 获取用户未读消息数,不存在时返回 0
|
||||
func NotificationUnreadGet(ctx context.Context, userID int64) (int64, error) {
|
||||
ctx, span := global.Tracer.Start(ctx, "redisdao.NotificationUnreadGet")
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
package redisdao
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/global"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/redismodel"
|
||||
)
|
||||
|
||||
// VirtualPayUnpaidSet 标记某虚拟支付订单「最近一次查单确认未支付」,TTL 内不再重复查询。
|
||||
func VirtualPayUnpaidSet(ctx context.Context, orderNo string, ttl time.Duration) error {
|
||||
ctx, span := global.Tracer.Start(ctx, "redisdao.VirtualPayUnpaidSet")
|
||||
defer span.End()
|
||||
|
||||
key := fmt.Sprintf(redismodel.KeyVirtualPayUnpaid, orderNo)
|
||||
if err := global.RedisEngine.Set(ctx, key, 1, ttl).Err(); err != nil {
|
||||
span.RecordError(err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// VirtualPayUnpaidDel 清除负缓存(订单已支付/已处理后调用,让后续流程不再跳过它)。
|
||||
func VirtualPayUnpaidDel(ctx context.Context, orderNo string) error {
|
||||
ctx, span := global.Tracer.Start(ctx, "redisdao.VirtualPayUnpaidDel")
|
||||
defer span.End()
|
||||
|
||||
key := fmt.Sprintf(redismodel.KeyVirtualPayUnpaid, orderNo)
|
||||
if err := global.RedisEngine.Del(ctx, key).Err(); err != nil {
|
||||
span.RecordError(err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// VirtualPayUnpaidBatchGet 批量判断哪些订单命中负缓存。
|
||||
// 用一次 MGET 代替 N 次 GET,避免逐单往返 Redis 放大单轮耗时。
|
||||
func VirtualPayUnpaidBatchGet(ctx context.Context, orderNos []string) (map[string]bool, error) {
|
||||
ctx, span := global.Tracer.Start(ctx, "redisdao.VirtualPayUnpaidBatchGet")
|
||||
defer span.End()
|
||||
|
||||
result := make(map[string]bool, len(orderNos))
|
||||
if len(orderNos) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
keys := make([]string, 0, len(orderNos))
|
||||
for _, no := range orderNos {
|
||||
keys = append(keys, fmt.Sprintf(redismodel.KeyVirtualPayUnpaid, no))
|
||||
}
|
||||
vals, err := global.RedisEngine.MGet(ctx, keys...).Result()
|
||||
if err != nil {
|
||||
span.RecordError(err)
|
||||
return nil, err
|
||||
}
|
||||
for i, v := range vals {
|
||||
if i >= len(orderNos) {
|
||||
break
|
||||
}
|
||||
if v != nil {
|
||||
result[orderNos[i]] = true
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package redismodel
|
||||
|
||||
// 虚拟支付相关 Redis key。
|
||||
const (
|
||||
// KeyVirtualPayUnpaid 查单补偿的「已确认未支付」负缓存:order_no。
|
||||
// 命中表示最近一次 query_order 反查结果为未支付,本轮跳过该订单,
|
||||
// 避免大量「下单后不付款」的积压订单长期占满扫描名额,把真正已支付的订单挤出去。
|
||||
KeyVirtualPayUnpaid = RedisPrefix + "xpay:unpaid:%s"
|
||||
)
|
||||
@@ -61,6 +61,27 @@ func (h *OutfitHandler) List(c *gin.Context) {
|
||||
response.ToResponse(out)
|
||||
}
|
||||
|
||||
// Community 分页查询社区穿搭(其他用户公开的穿搭)。
|
||||
func (h *OutfitHandler) Community(c *gin.Context) {
|
||||
response := app.NewResponse(c)
|
||||
userID, ok := middleware.GetUserID(c.Request.Context())
|
||||
if !ok {
|
||||
response.ToErrorResponse(errcode.Unauthorized)
|
||||
return
|
||||
}
|
||||
var request dto.OutfitCommunityListRequest
|
||||
if err := c.ShouldBindQuery(&request); err != nil {
|
||||
response.ToErrorResponse(errcode.InvalidParams.WithDetails(err.Error()))
|
||||
return
|
||||
}
|
||||
out, e := h.svc.CommunityList(c.Request.Context(), userID, &request)
|
||||
if e != nil {
|
||||
response.ToErrorResponse(e)
|
||||
return
|
||||
}
|
||||
response.ToResponse(out)
|
||||
}
|
||||
|
||||
// Get 获取穿搭详情(uuid 来自路径参数)。
|
||||
func (h *OutfitHandler) Get(c *gin.Context) {
|
||||
response := app.NewResponse(c)
|
||||
|
||||
@@ -88,6 +88,8 @@ func NewRouter() *gin.Engine {
|
||||
// 穿搭
|
||||
auth.POST("/outfit", outfitHandler.Create)
|
||||
auth.GET("/outfit", outfitHandler.List)
|
||||
// 注意:静态路径 /outfit/community 必须先于 /outfit/:uuid 注册,避免被通配路由抢占
|
||||
auth.GET("/outfit/community", outfitHandler.Community)
|
||||
auth.GET("/outfit/:uuid", outfitHandler.Get)
|
||||
auth.PUT("/outfit/:uuid", outfitHandler.Update)
|
||||
auth.DELETE("/outfit/:uuid", outfitHandler.Delete)
|
||||
|
||||
@@ -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