This commit is contained in:
zhengchiliu
2026-09-15 00:53:04 +08:00
parent 32a0b87e9e
commit 112c5f8e46
42 changed files with 1121 additions and 304 deletions
+81 -12
View File
@@ -6,6 +6,7 @@ import (
"io"
"log/slog"
"mime/multipart"
"os"
"time"
"go.opentelemetry.io/otel/trace"
@@ -87,20 +88,28 @@ func (s *ClothesService) Upload(ctx context.Context, userID int64, fileHeader *m
// 5. 创建衣物记录,初始状态为排队中。
clothesUUID := genUUID()
originalName := clothesUUID + ext
originalPath, serr := upload.FileSaveTmpBytes(ctx, imageData, originalName, fmt.Sprintf("pending/%d", userID))
if serr != nil {
refundCosQuota(ctx, userID, 1, 0, "原图持久化失败回退")
return nil, errcode.UploadFileSaveFailed
}
clothes := &mysqlmodel.Clothes{
ClothesUUID: clothesUUID,
UserID: userID,
ImageSize: fileHeader.Size / 1024,
ImageProcessStatus: ImageProcessQueuing,
OriginalImagePath: originalPath,
}
if err := s.clothesDao.ClothesCreate(ctx, clothes); err != nil {
_ = os.Remove(originalPath)
// 建记录失败,回退已扣额度。
refundCosQuota(ctx, userID, 1, 0, "上传失败回退")
return nil, errcode.ClothesCreateFailed
}
// 6. 异步抠图 + 分类。
go s.processImageAsync(ctx, userID, clothesUUID, imageData)
go s.processImageAsync(ctx, userID, clothesUUID, imageData, originalPath)
return &dto.ClothesUploadResponse{
ClothesUUID: clothesUUID,
@@ -109,7 +118,7 @@ func (s *ClothesService) Upload(ctx context.Context, userID int64, fileHeader *m
}
// processImageAsync 后台执行抠图、上传 COS、分类,并回填衣物记录。
func (s *ClothesService) processImageAsync(parentCtx context.Context, userID int64, clothesUUID string, imageData []byte) {
func (s *ClothesService) processImageAsync(parentCtx context.Context, userID int64, clothesUUID string, imageData []byte, originalPath string) {
// 限流:获取信号量,超出并发上限时阻塞等待。
imageProcessSem <- struct{}{}
defer func() { <-imageProcessSem }()
@@ -152,11 +161,13 @@ func (s *ClothesService) processImageAsync(parentCtx context.Context, userID int
return
}
subDir := fmt.Sprintf("%s/%d", user.UserUUID, userID)
if _, serr := upload.FileSaveTmpBytes(ctx, processedData, processedName, subDir); serr != nil {
processedPath, serr := upload.FileSaveTmpBytes(ctx, processedData, processedName, subDir)
if serr != nil {
slog.ErrorContext(ctx, "save processed image failed", "clothes_uuid", clothesUUID, "error", serr)
s.markProcessFailed(ctx, userID, clothesUUID, "图片保存失败")
return
}
defer os.Remove(processedPath)
cosKey := fmt.Sprintf("%s/%s", subDir, processedName)
if uerr := upload.FileUploadCos(ctx, cosKey); uerr != nil {
slog.ErrorContext(ctx, "upload processed image to cos failed", "clothes_uuid", clothesUUID, "error", uerr)
@@ -209,10 +220,13 @@ func (s *ClothesService) processImageAsync(parentCtx context.Context, userID int
"processed_image": cosKey,
"image_process_status": ImageProcessSuccess,
"fail_reason": "",
"original_image_path": "",
}
if uerr := dao.ClothesUpdateByUUID(ctx, clothesUUID, updates); uerr != nil {
slog.ErrorContext(ctx, "update clothes after classify failed", "clothes_uuid", clothesUUID, "error", uerr)
return
}
_ = os.Remove(originalPath)
}
// buildDefaultClothesName 根据分类结果生成衣服默认名称(风格+小类+大类,空字段跳过)。
@@ -258,35 +272,42 @@ func retryCall[T any](ctx context.Context, maxRetry int, name string, fn func()
// 单事务(行锁 -> 置失败 -> 回退额度 + 写流水),与删除事务(FOR UPDATE)通过行锁互斥:
// 两路径串行化保证「处理失败回退」与「删除回退」恰好执行一次,消除漏退/双退窗口。
func (s *ClothesService) markProcessFailed(ctx context.Context, userID int64, clothesUUID, reason string) {
vipExpire := getUserVIPExpire(ctx, userID)
err := global.MysqlEngine.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// 调用方的三分钟处理 context 可能已超时;清理与额度回退必须使用独立短 context。
cleanupCtx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
vipExpire := getUserVIPExpire(cleanupCtx, userID)
var originalPath string
err := global.MysqlEngine.WithContext(cleanupCtx).Transaction(func(tx *gorm.DB) error {
var c mysqlmodel.Clothes
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("clothes_uuid = ?", clothesUUID).
First(&c).Error; err != nil {
if isNotFound(err) {
// 衣物已删除:删除事务已回退额度,跳过。
return nil
}
return err
}
// 已是失败态:说明失败回退已处理过,跳过。
if c.ImageProcessStatus == ImageProcessFailed {
return nil
}
// 置失败。
originalPath = c.OriginalImagePath
if err := tx.Model(&mysqlmodel.Clothes{}).
Where("clothes_uuid = ?", clothesUUID).
Updates(map[string]any{"image_process_status": ImageProcessFailed, "fail_reason": reason}).Error; err != nil {
Updates(map[string]any{
"image_process_status": ImageProcessFailed,
"fail_reason": reason,
"original_image_path": "",
}).Error; err != nil {
return err
}
// 同事务回退额度(优先免费额度,不足回退凭证)。
return refundCosQuotaTx(tx, userID, vipExpire, 1, 0, "衣物处理失败回退")
})
if err != nil {
slog.ErrorContext(ctx, "mark process failed transaction error", "clothes_uuid", clothesUUID, "error", err)
return
}
if originalPath != "" {
_ = os.Remove(originalPath)
}
}
@@ -416,6 +437,7 @@ func (s *ClothesService) DeleteByUUID(ctx context.Context, userID int64, request
// 数值变更与流水同事务强一致,并避免与异步处理失败回退并发导致漏退/双退。
vipExpire := getUserVIPExpire(ctx, userID)
var processedImage string
var originalImagePath string
err := global.MysqlEngine.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var c mysqlmodel.Clothes
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
@@ -424,6 +446,7 @@ func (s *ClothesService) DeleteByUUID(ctx context.Context, userID int64, request
return err
}
processedImage = c.ProcessedImage
originalImagePath = c.OriginalImagePath
if err := tx.Where("clothes_uuid = ?", request.ClothesUUID).Delete(&mysqlmodel.Clothes{}).Error; err != nil {
return err
}
@@ -442,6 +465,9 @@ func (s *ClothesService) DeleteByUUID(ctx context.Context, userID int64, request
}
// 事务提交成功后清理 COS 图片(尽力而为,失败仅记录日志,不影响删除结果)。
if originalImagePath != "" {
_ = os.Remove(originalImagePath)
}
if processedImage != "" {
if derr := upload.FileDeleteCos(ctx, processedImage); derr != nil {
slog.WarnContext(ctx, "delete clothes cos image failed", "clothes_uuid", request.ClothesUUID, "cos_key", processedImage, "error", derr)
@@ -524,3 +550,46 @@ func setIfNotEmpty(m map[string]any, key, val string) {
m[key] = val
}
}
// StartClothesProcessingRecoveryScheduler 周期恢复因进程退出或超时遗留的图片任务。
func StartClothesProcessingRecoveryScheduler() {
go func() {
slog.Info("clothes processing recovery scheduler started")
for {
func() {
defer func() {
if r := recover(); r != nil {
slog.Error("clothes processing recovery scheduler panic", "recover", r)
}
}()
runClothesProcessingRecovery()
}()
time.Sleep(time.Minute)
}
}()
}
func runClothesProcessingRecovery() {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
before := time.Now().Add(-5 * time.Minute).Unix()
dao := mysqldao.NewClothesDao()
list, err := dao.ClothesListStaleProcessing(ctx, before, 50)
if err != nil {
slog.ErrorContext(ctx, "list stale clothes processing tasks failed", "error", err)
return
}
service := NewClothesService()
for i := range list {
claimed, err := dao.ClothesClaimStaleProcessing(ctx, list[i].ClothesUUID, before)
if err != nil || !claimed {
continue
}
imageData, err := os.ReadFile(list[i].OriginalImagePath)
if err != nil || !upload.FileCheckImageMagic(imageData) {
service.markProcessFailed(ctx, list[i].UserID, list[i].ClothesUUID, "原图丢失或损坏,任务无法恢复")
continue
}
go service.processImageAsync(context.Background(), list[i].UserID, list[i].ClothesUUID, imageData, list[i].OriginalImagePath)
}
}