补齐相关日志日志

This commit is contained in:
2026-09-16 00:23:49 +08:00
parent 8c2ea84939
commit b2e5eef3c8
39 changed files with 1576 additions and 176 deletions
+78 -18
View File
@@ -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)
}