fix(clothes): 保护失败原因并可靠回退额度

仅属主可查看失败原因,分享只读场景置空;额度回退改用独立
ctx,避免客户端断开导致回退失败;新增 ImageProcessStatus 与
FailReason 响应字段,并抽取 detachedCtx 复用后台任务上下文。
This commit is contained in:
2026-09-16 02:01:19 +08:00
parent b2e5eef3c8
commit e9b9754b57
5 changed files with 299 additions and 22 deletions
+28 -4
View File
@@ -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,
}
}