fix(clothes): 保护失败原因并可靠回退额度
仅属主可查看失败原因,分享只读场景置空;额度回退改用独立 ctx,避免客户端断开导致回退失败;新增 ImageProcessStatus 与 FailReason 响应字段,并抽取 detachedCtx 复用后台任务上下文。
This commit is contained in:
+21
-17
@@ -29,23 +29,23 @@ type ClothesDeleteByUUIDResponse struct{}
|
||||
// ClothesUpdateByUUIDRequest 修改衣物
|
||||
type ClothesUpdateByUUIDRequest struct {
|
||||
ClothesUUID string `json:"clothes_uuid" binding:"required"`
|
||||
CategoryMain string `json:"category_main" binding:"max=64"` // 衣物大类 上装/下装/连衣裙/外套/鞋/配饰
|
||||
CategorySub string `json:"category_sub" binding:"max=64"` // 衣物小类 T恤/牛仔裤/运动鞋/发带/戒指等
|
||||
Style string `json:"style" binding:"max=64"` // 风格
|
||||
ColorMain string `json:"color" binding:"max=64"` // 主颜色
|
||||
ColorSub string `json:"color_sub" binding:"max=64"` // 副颜色
|
||||
Thickness string `json:"thickness" binding:"max=64"` // 厚度
|
||||
Version string `json:"version" binding:"max=64"` // 版型 宽松/修身/阔腿/紧身/常规合身
|
||||
Material string `json:"material" binding:"max=64"` // 材质 牛仔布/针织/皮质/纯棉/羊毛等
|
||||
Pattern string `json:"pattern" binding:"max=64"` // 花纹
|
||||
SleeveType string `json:"sleeve_type" binding:"max=64"` // 袖型
|
||||
ClothesType string `json:"clothes_type" binding:"max=64"` // 衣长类型 短款/常规/中长/长款
|
||||
NecklineType string `json:"neckline_type" binding:"max=64"` // 领型
|
||||
Formality string `json:"formality" binding:"max=16"` // 正式程度 1-5
|
||||
Occasions string `json:"occasions" binding:"max=255"` // 适用场合
|
||||
VisualImpact string `json:"visual_impact" binding:"max=16"` // 视觉冲击力 1-5
|
||||
Name string `json:"name" binding:"max=64"` // 名称
|
||||
Remark string `json:"remark" binding:"max=500"` // 备注
|
||||
CategoryMain string `json:"category_main" binding:"max=64"` // 衣物大类 上装/下装/连衣裙/外套/鞋/配饰
|
||||
CategorySub string `json:"category_sub" binding:"max=64"` // 衣物小类 T恤/牛仔裤/运动鞋/发带/戒指等
|
||||
Style string `json:"style" binding:"max=64"` // 风格
|
||||
ColorMain string `json:"color" binding:"max=64"` // 主颜色
|
||||
ColorSub string `json:"color_sub" binding:"max=64"` // 副颜色
|
||||
Thickness string `json:"thickness" binding:"max=64"` // 厚度
|
||||
Version string `json:"version" binding:"max=64"` // 版型 宽松/修身/阔腿/紧身/常规合身
|
||||
Material string `json:"material" binding:"max=64"` // 材质 牛仔布/针织/皮质/纯棉/羊毛等
|
||||
Pattern string `json:"pattern" binding:"max=64"` // 花纹
|
||||
SleeveType string `json:"sleeve_type" binding:"max=64"` // 袖型
|
||||
ClothesType string `json:"clothes_type" binding:"max=64"` // 衣长类型 短款/常规/中长/长款
|
||||
NecklineType string `json:"neckline_type" binding:"max=64"` // 领型
|
||||
Formality string `json:"formality" binding:"max=16"` // 正式程度 1-5
|
||||
Occasions string `json:"occasions" binding:"max=255"` // 适用场合
|
||||
VisualImpact string `json:"visual_impact" binding:"max=16"` // 视觉冲击力 1-5
|
||||
Name string `json:"name" binding:"max=64"` // 名称
|
||||
Remark string `json:"remark" binding:"max=500"` // 备注
|
||||
}
|
||||
|
||||
type ClothesUpdateByUUIDResponse struct{}
|
||||
@@ -76,6 +76,10 @@ type ClothesGetByUUIDResponse struct {
|
||||
Name string `json:"name"`
|
||||
Remark string `json:"remark"`
|
||||
ProcessedImage string `json:"processed_image"`
|
||||
// ImageProcessStatus 处理状态:1=抠图中 2=分类中 3=排队中 4=成功 5=失败
|
||||
ImageProcessStatus int8 `json:"image_process_status"`
|
||||
// FailReason 处理失败原因:仅属主可见,非属主(分享只读)场景为空
|
||||
FailReason string `json:"fail_reason"`
|
||||
}
|
||||
|
||||
// ClothesListRequest 衣物列表
|
||||
|
||||
@@ -172,7 +172,12 @@ func (s *ClosetShareService) Detail(ctx context.Context, userID int64, shareUUID
|
||||
}
|
||||
clothesResp := make([]dto.ClothesGetByUUIDResponse, 0, len(clothesList))
|
||||
for i := range clothesList {
|
||||
clothesResp = append(clothesResp, buildClothesResponse(&clothesList[i]))
|
||||
item := buildClothesResponse(&clothesList[i])
|
||||
// 非属主只读查看:不暴露内部失败原因
|
||||
if !isOwner {
|
||||
item.FailReason = ""
|
||||
}
|
||||
clothesResp = append(clothesResp, item)
|
||||
}
|
||||
|
||||
// 记录接收关系:登录且非属主用户成功访问即视为收到,幂等,失败不阻断详情返回。
|
||||
|
||||
@@ -103,7 +103,7 @@ func (s *ClothesService) Upload(ctx context.Context, userID int64, fileHeader *m
|
||||
originalPath, serr := upload.FileSaveTmpBytes(ctx, imageData, originalName, fmt.Sprintf("pending/%d", userID))
|
||||
if serr != nil {
|
||||
slog.ErrorContext(ctx, "clothes upload: save original image failed", "user_id", userID, "error", serr)
|
||||
refundCosQuota(ctx, userID, 1, 0, "原图持久化失败回退")
|
||||
refundUploadQuotaOnFail(ctx, userID, "原图持久化失败回退")
|
||||
return nil, errcode.UploadFileSaveFailed
|
||||
}
|
||||
clothes := &mysqlmodel.Clothes{
|
||||
@@ -117,8 +117,8 @@ func (s *ClothesService) Upload(ctx context.Context, userID int64, fileHeader *m
|
||||
slog.ErrorContext(ctx, "clothes upload: create clothes record failed",
|
||||
"user_id", userID, "clothes_uuid", clothesUUID, "error", err)
|
||||
_ = os.Remove(originalPath)
|
||||
// 建记录失败,回退已扣额度。
|
||||
refundCosQuota(ctx, userID, 1, 0, "上传失败回退")
|
||||
// 建记录失败,回退已扣额度(独立 ctx:客户端中途断开也要保证退回)。
|
||||
refundUploadQuotaOnFail(ctx, userID, "上传失败回退")
|
||||
return nil, errcode.ClothesCreateFailed
|
||||
}
|
||||
|
||||
@@ -133,6 +133,23 @@ func (s *ClothesService) Upload(ctx context.Context, userID int64, fileHeader *m
|
||||
}, nil
|
||||
}
|
||||
|
||||
// detachedCtx 基于 Background 派生独立 ctx:继承 trace 链路但切断请求取消信号,
|
||||
// 供「与客户端连接解耦」的后台任务与「必须完成」的收尾操作使用。
|
||||
func detachedCtx(parent context.Context, timeout time.Duration) (context.Context, context.CancelFunc) {
|
||||
return context.WithTimeout(
|
||||
trace.ContextWithSpanContext(context.Background(), trace.SpanContextFromContext(parent)),
|
||||
timeout,
|
||||
)
|
||||
}
|
||||
|
||||
// refundUploadQuotaOnFail 用不受请求取消影响的 ctx 回退上传额度:
|
||||
// 客户端中途断开导致请求 ctx 已取消时,仍要保证额度退回成功。
|
||||
func refundUploadQuotaOnFail(parent context.Context, userID int64, reason string) {
|
||||
ctx, cancel := detachedCtx(parent, 30*time.Second)
|
||||
defer cancel()
|
||||
refundCosQuota(ctx, userID, 1, 0, reason)
|
||||
}
|
||||
|
||||
// processImageAsync 后台执行抠图、上传 COS、分类,并回填衣物记录。
|
||||
func (s *ClothesService) processImageAsync(parentCtx context.Context, userID int64, clothesUUID string, imageData []byte, originalPath string) {
|
||||
// 限流:获取信号量,超出并发上限时阻塞等待。
|
||||
@@ -140,7 +157,7 @@ func (s *ClothesService) processImageAsync(parentCtx context.Context, userID int
|
||||
defer func() { <-imageProcessSem }()
|
||||
|
||||
// 派生独立 ctx:继承父 trace 信息,但切断取消信号,避免复用已结束的请求 ctx。
|
||||
ctx, cancel := context.WithTimeout(trace.ContextWithSpanContext(context.Background(), trace.SpanContextFromContext(parentCtx)), 3*time.Minute)
|
||||
ctx, cancel := detachedCtx(parentCtx, 3*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
ctx, span := global.Tracer.Start(ctx, "svc.ClothesProcessImageAsync")
|
||||
@@ -392,6 +409,10 @@ func (s *ClothesService) GetByUUID(ctx context.Context, userID int64, request *d
|
||||
}
|
||||
}
|
||||
response := buildClothesResponse(clothes)
|
||||
// 非属主只读查看(穿搭分享):不暴露内部失败原因
|
||||
if clothes.UserID != userID {
|
||||
response.FailReason = ""
|
||||
}
|
||||
return &response, nil
|
||||
}
|
||||
|
||||
@@ -610,6 +631,9 @@ func buildClothesResponse(c *mysqlmodel.Clothes) dto.ClothesGetByUUIDResponse {
|
||||
Name: c.Name,
|
||||
Remark: c.Remark,
|
||||
ProcessedImage: processed,
|
||||
|
||||
ImageProcessStatus: c.ImageProcessStatus,
|
||||
FailReason: c.FailReason,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user