gpt
This commit is contained in:
+81
-12
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user