补齐相关日志日志
This commit is contained in:
+78
-18
@@ -53,36 +53,47 @@ func (s *ClothesService) Upload(ctx context.Context, userID int64, fileHeader *m
|
||||
defer span.End()
|
||||
|
||||
if fileHeader == nil || fileHeader.Size == 0 {
|
||||
slog.DebugContext(ctx, "clothes upload: empty file", "user_id", userID)
|
||||
return nil, errcode.UploadFileEmpty
|
||||
}
|
||||
|
||||
// 1. 校验扩展名与大小。
|
||||
ext := upload.FileGetExt(fileHeader.Filename)
|
||||
if !upload.FileCheckAllowExt(ext, global.UploadSetting.AllowedExtensions) {
|
||||
slog.DebugContext(ctx, "clothes upload: extension not allowed", "user_id", userID, "ext", ext)
|
||||
return nil, errcode.UploadFileExtNotAllow
|
||||
}
|
||||
if !upload.FileCheckMaxSize(fileHeader.Size, int64(global.UploadSetting.MaxSizeMB)) {
|
||||
slog.DebugContext(ctx, "clothes upload: file too large", "user_id", userID, "size", fileHeader.Size)
|
||||
return nil, errcode.UploadFileTooLarge
|
||||
}
|
||||
|
||||
// 2. 读取原图字节(必须同步读取,请求返回后 multipart 临时文件会被清理)。
|
||||
src, oerr := fileHeader.Open()
|
||||
if oerr != nil {
|
||||
slog.ErrorContext(ctx, "clothes upload: open file failed", "user_id", userID, "error", oerr)
|
||||
return nil, errcode.UploadFileSaveFailed
|
||||
}
|
||||
imageData, rerr := io.ReadAll(src)
|
||||
_ = src.Close()
|
||||
if rerr != nil {
|
||||
slog.ErrorContext(ctx, "clothes upload: read file failed", "user_id", userID, "error", rerr)
|
||||
return nil, errcode.UploadFileSaveFailed
|
||||
}
|
||||
|
||||
// 3. 校验文件内容为真实图片(magic bytes),防止伪造扩展名上传恶意文件。
|
||||
if !upload.FileCheckImageMagic(imageData) {
|
||||
slog.WarnContext(ctx, "clothes upload: magic bytes check failed", "user_id", userID, "size", fileHeader.Size)
|
||||
return nil, errcode.UploadFileExtNotAllow
|
||||
}
|
||||
|
||||
// 4. 扣减 COS 上传额度(权威扣减,先到期凭证先用,再扣免费额度)。
|
||||
if qerr := consumeCosQuota(ctx, userID, 1, 0, "上传衣物消耗"); qerr != nil {
|
||||
if qerr == errcode.QuotaInsufficient {
|
||||
slog.InfoContext(ctx, "clothes upload: quota insufficient", "user_id", userID)
|
||||
} else {
|
||||
slog.ErrorContext(ctx, "clothes upload: consume quota failed", "user_id", userID, "error", qerr)
|
||||
}
|
||||
return nil, qerr
|
||||
}
|
||||
|
||||
@@ -91,6 +102,7 @@ func (s *ClothesService) Upload(ctx context.Context, userID int64, fileHeader *m
|
||||
originalName := clothesUUID + ext
|
||||
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, "原图持久化失败回退")
|
||||
return nil, errcode.UploadFileSaveFailed
|
||||
}
|
||||
@@ -102,6 +114,8 @@ func (s *ClothesService) Upload(ctx context.Context, userID int64, fileHeader *m
|
||||
OriginalImagePath: originalPath,
|
||||
}
|
||||
if err := s.clothesDao.ClothesCreate(ctx, clothes); err != nil {
|
||||
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, "上传失败回退")
|
||||
@@ -111,6 +125,8 @@ func (s *ClothesService) Upload(ctx context.Context, userID int64, fileHeader *m
|
||||
// 6. 异步抠图 + 分类。
|
||||
go s.processImageAsync(ctx, userID, clothesUUID, imageData, originalPath)
|
||||
|
||||
slog.InfoContext(ctx, "clothes uploaded", "user_id", userID, "clothes_uuid", clothesUUID)
|
||||
|
||||
return &dto.ClothesUploadResponse{
|
||||
ClothesUUID: clothesUUID,
|
||||
ImageProcessStatus: clothes.ImageProcessStatus,
|
||||
@@ -140,11 +156,13 @@ func (s *ClothesService) processImageAsync(parentCtx context.Context, userID int
|
||||
dao := s.clothesDao
|
||||
|
||||
// 1. 抠图。
|
||||
_ = dao.ClothesUpdateByUUID(ctx, clothesUUID, map[string]any{"image_process_status": ImageProcessMatting})
|
||||
if uerr := dao.ClothesUpdateByUUID(ctx, clothesUUID, map[string]any{"image_process_status": ImageProcessMatting}); uerr != nil {
|
||||
slog.DebugContext(ctx, "clothes process: mark matting status failed", "clothes_uuid", clothesUUID, "error", uerr)
|
||||
}
|
||||
mattingClient := matting.NewClient()
|
||||
processedData, err := retryCall(ctx, global.MattingModelSetting.MaxRetry, "matting", func() ([]byte, error) {
|
||||
return mattingClient.Matting(ctx, imageData)
|
||||
})
|
||||
}, "clothes_uuid", clothesUUID)
|
||||
if err != nil {
|
||||
slog.ErrorContext(ctx, "matting failed", "clothes_uuid", clothesUUID, "error", err)
|
||||
s.markProcessFailed(ctx, userID, clothesUUID, "图片抠图失败")
|
||||
@@ -156,7 +174,7 @@ func (s *ClothesService) processImageAsync(parentCtx context.Context, userID int
|
||||
// 获取用户 UUID,作为 COS 对象键第一层(uuid/userid/图片)。
|
||||
user, gerr := s.userDao.UserGetByID(ctx, userID)
|
||||
if gerr != nil {
|
||||
slog.ErrorContext(ctx, "get user for cos key failed", "clothes_uuid", clothesUUID, "error", gerr)
|
||||
slog.ErrorContext(ctx, "get user for cos key failed", "clothes_uuid", clothesUUID, "user_id", userID, "error", gerr)
|
||||
s.markProcessFailed(ctx, userID, clothesUUID, "获取用户信息失败")
|
||||
return
|
||||
}
|
||||
@@ -186,15 +204,19 @@ func (s *ClothesService) processImageAsync(parentCtx context.Context, userID int
|
||||
}
|
||||
|
||||
// 3. 分类。
|
||||
_ = dao.ClothesUpdateByUUID(ctx, clothesUUID, map[string]any{"image_process_status": ImageProcessClassifying})
|
||||
if uerr := dao.ClothesUpdateByUUID(ctx, clothesUUID, map[string]any{"image_process_status": ImageProcessClassifying}); uerr != nil {
|
||||
slog.DebugContext(ctx, "clothes process: mark classifying status failed", "clothes_uuid", clothesUUID, "error", uerr)
|
||||
}
|
||||
classifyClient := classification.NewClient()
|
||||
result, cerr := retryCall(ctx, global.ClassificationModelSetting.MaxRetry, "classify", func() (*classification.ClassifyResult, error) {
|
||||
return classifyClient.Classify(ctx, processedData)
|
||||
})
|
||||
}, "clothes_uuid", clothesUUID)
|
||||
if cerr != nil {
|
||||
slog.ErrorContext(ctx, "classify failed", "clothes_uuid", clothesUUID, "error", cerr)
|
||||
// 分类失败仍保留已抠图结果,但条件标记失败并回退以便用户手动补全。
|
||||
_ = dao.ClothesUpdateByUUID(ctx, clothesUUID, map[string]any{"processed_image": cosKey})
|
||||
if uerr := dao.ClothesUpdateByUUID(ctx, clothesUUID, map[string]any{"processed_image": cosKey}); uerr != nil {
|
||||
slog.WarnContext(ctx, "clothes process: keep processed image failed", "clothes_uuid", clothesUUID, "error", uerr)
|
||||
}
|
||||
s.markProcessFailed(ctx, userID, clothesUUID, "图片分类失败")
|
||||
return
|
||||
}
|
||||
@@ -260,7 +282,7 @@ func buildDefaultClothesName(result *classification.ClassifyResult) string {
|
||||
|
||||
// retryCall 对 fn 进行最多 maxRetry 次额外重试(总尝试 maxRetry+1 次)。
|
||||
// 重试间隔 1s;ctx 到期则提前终止并返回 ctx.Err()。
|
||||
func retryCall[T any](ctx context.Context, maxRetry int, name string, fn func() (T, error)) (T, error) {
|
||||
func retryCall[T any](ctx context.Context, maxRetry int, name string, fn func() (T, error), attrs ...any) (T, error) {
|
||||
var zero T
|
||||
var err error
|
||||
for attempt := 0; attempt <= maxRetry; attempt++ {
|
||||
@@ -272,7 +294,8 @@ func retryCall[T any](ctx context.Context, maxRetry int, name string, fn func()
|
||||
if attempt >= maxRetry {
|
||||
break
|
||||
}
|
||||
slog.WarnContext(ctx, "model call failed, retrying", "name", name, "attempt", attempt+1, "error", e)
|
||||
args := append([]any{"name", name, "attempt", attempt + 1, "error", e}, attrs...)
|
||||
slog.WarnContext(ctx, "model call failed, retrying", args...)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return zero, ctx.Err()
|
||||
@@ -287,10 +310,12 @@ func retryCall[T any](ctx context.Context, maxRetry int, name string, fn func()
|
||||
// 两路径串行化保证「处理失败回退」与「删除回退」恰好执行一次,消除漏退/双退窗口。
|
||||
func (s *ClothesService) markProcessFailed(ctx context.Context, userID int64, clothesUUID, reason string) {
|
||||
// 调用方的三分钟处理 context 可能已超时;清理与额度回退必须使用独立短 context。
|
||||
cleanupCtx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
// 用 WithoutCancel 派生:既不受调用方取消影响,又保留 ctx 中已有的 span/trace 信息。
|
||||
cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 20*time.Second)
|
||||
defer cancel()
|
||||
vipExpire := getUserVIPExpire(cleanupCtx, userID)
|
||||
var originalPath string
|
||||
failPhase := ""
|
||||
err := global.MysqlEngine.WithContext(cleanupCtx).Transaction(func(tx *gorm.DB) error {
|
||||
var c mysqlmodel.Clothes
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
@@ -312,16 +337,24 @@ func (s *ClothesService) markProcessFailed(ctx context.Context, userID int64, cl
|
||||
"fail_reason": reason,
|
||||
"original_image_path": "",
|
||||
}).Error; err != nil {
|
||||
failPhase = "mark_failed"
|
||||
return err
|
||||
}
|
||||
return refundCosQuotaTx(tx, userID, vipExpire, 1, 0, "衣物处理失败回退")
|
||||
if err := refundCosQuotaTx(cleanupCtx, tx, userID, vipExpire, 1, 0, "衣物处理失败回退"); err != nil {
|
||||
failPhase = "refund_quota"
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
slog.ErrorContext(ctx, "mark process failed transaction error", "clothes_uuid", clothesUUID, "error", err)
|
||||
slog.ErrorContext(ctx, "mark process failed transaction error",
|
||||
"clothes_uuid", clothesUUID, "user_id", userID, "phase", failPhase, "error", err)
|
||||
return
|
||||
}
|
||||
if originalPath != "" {
|
||||
_ = os.Remove(originalPath)
|
||||
if rerr := os.Remove(originalPath); rerr != nil {
|
||||
slog.DebugContext(ctx, "clothes process: remove original image failed", "clothes_uuid", clothesUUID, "error", rerr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -452,9 +485,13 @@ func (s *ClothesService) DeleteByUUID(ctx context.Context, userID int64, request
|
||||
// 也与接口文档约定的 50001「衣服已被搭配使用,无法删除」保持一致。
|
||||
refCount, rerr := s.clothesDao.ClothesReferencedCount(ctx, request.ClothesUUID)
|
||||
if rerr != nil {
|
||||
slog.ErrorContext(ctx, "delete clothes: count references failed",
|
||||
"clothes_uuid", request.ClothesUUID, "user_id", userID, "error", rerr)
|
||||
return nil, errcode.DatabaseError
|
||||
}
|
||||
if refCount > 0 {
|
||||
slog.InfoContext(ctx, "delete clothes: clothes in use",
|
||||
"clothes_uuid", request.ClothesUUID, "user_id", userID)
|
||||
return nil, errcode.ClothesInUse
|
||||
}
|
||||
|
||||
@@ -480,18 +517,23 @@ func (s *ClothesService) DeleteByUUID(ctx context.Context, userID int64, request
|
||||
return nil
|
||||
}
|
||||
// 同事务回退额度(优先免费额度,不足回退凭证)。
|
||||
return refundCosQuotaTx(tx, userID, vipExpire, 1, 0, "删除衣物回退")
|
||||
return refundCosQuotaTx(ctx, tx, userID, vipExpire, 1, 0, "删除衣物回退")
|
||||
})
|
||||
if err != nil {
|
||||
if isNotFound(err) {
|
||||
return nil, errcode.ClothesNotFound
|
||||
}
|
||||
slog.ErrorContext(ctx, "delete clothes: transaction failed",
|
||||
"clothes_uuid", request.ClothesUUID, "user_id", userID, "error", err)
|
||||
return nil, errcode.ClothesDeleteFailed
|
||||
}
|
||||
|
||||
// 事务提交成功后清理 COS 图片(尽力而为,失败仅记录日志,不影响删除结果)。
|
||||
if originalImagePath != "" {
|
||||
_ = os.Remove(originalImagePath)
|
||||
if rerr := os.Remove(originalImagePath); rerr != nil {
|
||||
slog.DebugContext(ctx, "delete clothes: remove original image failed",
|
||||
"clothes_uuid", request.ClothesUUID, "error", rerr)
|
||||
}
|
||||
}
|
||||
if processedImage != "" {
|
||||
if derr := upload.FileDeleteCos(ctx, processedImage); derr != nil {
|
||||
@@ -499,6 +541,8 @@ func (s *ClothesService) DeleteByUUID(ctx context.Context, userID int64, request
|
||||
}
|
||||
}
|
||||
|
||||
slog.InfoContext(ctx, "clothes deleted", "clothes_uuid", request.ClothesUUID, "user_id", userID)
|
||||
|
||||
return &dto.ClothesDeleteByUUIDResponse{}, nil
|
||||
}
|
||||
|
||||
@@ -604,17 +648,33 @@ func runClothesProcessingRecovery() {
|
||||
slog.ErrorContext(ctx, "list stale clothes processing tasks failed", "error", err)
|
||||
return
|
||||
}
|
||||
if len(list) == 0 {
|
||||
slog.DebugContext(ctx, "clothes recovery: no stale processing tasks")
|
||||
return
|
||||
}
|
||||
service := NewClothesService()
|
||||
recovered := 0
|
||||
for i := range list {
|
||||
claimed, err := dao.ClothesClaimStaleProcessing(ctx, list[i].ClothesUUID, before)
|
||||
if err != nil || !claimed {
|
||||
claimed, cerr := dao.ClothesClaimStaleProcessing(ctx, list[i].ClothesUUID, before)
|
||||
if cerr != nil {
|
||||
slog.ErrorContext(ctx, "clothes recovery: claim stale task failed",
|
||||
"clothes_uuid", list[i].ClothesUUID, "error", cerr)
|
||||
continue
|
||||
}
|
||||
imageData, err := os.ReadFile(list[i].OriginalImagePath)
|
||||
if err != nil || !upload.FileCheckImageMagic(imageData) {
|
||||
if !claimed {
|
||||
slog.DebugContext(ctx, "clothes recovery: task already claimed by another instance",
|
||||
"clothes_uuid", list[i].ClothesUUID)
|
||||
continue
|
||||
}
|
||||
imageData, rerr := os.ReadFile(list[i].OriginalImagePath)
|
||||
if rerr != nil || !upload.FileCheckImageMagic(imageData) {
|
||||
slog.WarnContext(ctx, "clothes recovery: original image missing or corrupted",
|
||||
"clothes_uuid", list[i].ClothesUUID, "user_id", list[i].UserID, "error", rerr)
|
||||
service.markProcessFailed(ctx, list[i].UserID, list[i].ClothesUUID, "原图丢失或损坏,任务无法恢复")
|
||||
continue
|
||||
}
|
||||
recovered++
|
||||
go service.processImageAsync(context.Background(), list[i].UserID, list[i].ClothesUUID, imageData, list[i].OriginalImagePath)
|
||||
}
|
||||
slog.InfoContext(ctx, "clothes recovery: stale tasks recovered", "scanned", len(list), "recovered", recovered)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user