package service import ( "context" "fmt" "io" "log/slog" "mime/multipart" "os" "time" "go.opentelemetry.io/otel/trace" "git.lzcwy.cn/zhengchiliu/zt-closet-backend/global" "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/dto" "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqldao" "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqlmodel" "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/classification" "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/errcode" "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/matting" "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/upload" "gorm.io/gorm" "gorm.io/gorm/clause" ) // imageProcessSem 限制异步抠图处理的并发数,避免瞬时大量上传打满下游抠图/分类服务。 var imageProcessSem = make(chan struct{}, 10) // ClothesService 衣物业务服务。 type ClothesService struct { clothesDao *mysqldao.ClothesDao userDao *mysqldao.UserDao outfitShareDao *mysqldao.OutfitShareDao outfitDao *mysqldao.OutfitDao outfitItemDao *mysqldao.OutfitItemDao } // NewClothesService 创建衣物服务。 func NewClothesService() *ClothesService { return &ClothesService{ clothesDao: mysqldao.NewClothesDao(), userDao: mysqldao.NewUserDao(), outfitShareDao: mysqldao.NewOutfitShareDao(), outfitDao: mysqldao.NewOutfitDao(), outfitItemDao: mysqldao.NewOutfitItemDao(), } } // Upload 上传衣物图片:同步完成校验、扣额度、建记录后立即返回, // 抠图与分类在后台 goroutine 异步执行并回填状态。 func (s *ClothesService) Upload(ctx context.Context, userID int64, fileHeader *multipart.FileHeader) (*dto.ClothesUploadResponse, *errcode.Error) { ctx, span := global.Tracer.Start(ctx, "svc.ClothesUpload") defer span.End() if fileHeader == nil || fileHeader.Size == 0 { return nil, errcode.UploadFileEmpty } // 1. 校验扩展名与大小。 ext := upload.FileGetExt(fileHeader.Filename) if !upload.FileCheckAllowExt(ext, global.UploadSetting.AllowedExtensions) { return nil, errcode.UploadFileExtNotAllow } if !upload.FileCheckMaxSize(fileHeader.Size, int64(global.UploadSetting.MaxSizeMB)) { return nil, errcode.UploadFileTooLarge } // 2. 读取原图字节(必须同步读取,请求返回后 multipart 临时文件会被清理)。 src, oerr := fileHeader.Open() if oerr != nil { return nil, errcode.UploadFileSaveFailed } imageData, rerr := io.ReadAll(src) _ = src.Close() if rerr != nil { return nil, errcode.UploadFileSaveFailed } // 3. 校验文件内容为真实图片(magic bytes),防止伪造扩展名上传恶意文件。 if !upload.FileCheckImageMagic(imageData) { return nil, errcode.UploadFileExtNotAllow } // 4. 扣减 COS 上传额度(权威扣减,先到期凭证先用,再扣免费额度)。 if qerr := consumeCosQuota(ctx, userID, 1, 0, "上传衣物消耗"); qerr != nil { return nil, qerr } // 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, originalPath) return &dto.ClothesUploadResponse{ ClothesUUID: clothesUUID, ImageProcessStatus: clothes.ImageProcessStatus, }, nil } // processImageAsync 后台执行抠图、上传 COS、分类,并回填衣物记录。 func (s *ClothesService) processImageAsync(parentCtx context.Context, userID int64, clothesUUID string, imageData []byte, originalPath string) { // 限流:获取信号量,超出并发上限时阻塞等待。 imageProcessSem <- struct{}{} defer func() { <-imageProcessSem }() // 派生独立 ctx:继承父 trace 信息,但切断取消信号,避免复用已结束的请求 ctx。 ctx, cancel := context.WithTimeout(trace.ContextWithSpanContext(context.Background(), trace.SpanContextFromContext(parentCtx)), 3*time.Minute) defer cancel() ctx, span := global.Tracer.Start(ctx, "svc.ClothesProcessImageAsync") defer span.End() defer func() { if r := recover(); r != nil { slog.ErrorContext(ctx, "clothes image process panic", "clothes_uuid", clothesUUID, "recover", r) s.markProcessFailed(ctx, userID, clothesUUID, "内部错误") } }() dao := s.clothesDao // 1. 抠图。 _ = dao.ClothesUpdateByUUID(ctx, clothesUUID, map[string]any{"image_process_status": ImageProcessMatting}) mattingClient := matting.NewClient() processedData, err := retryCall(ctx, global.MattingModelSetting.MaxRetry, "matting", func() ([]byte, error) { return mattingClient.Matting(ctx, imageData) }) if err != nil { slog.ErrorContext(ctx, "matting failed", "clothes_uuid", clothesUUID, "error", err) s.markProcessFailed(ctx, userID, clothesUUID, "图片抠图失败") return } // 2. 处理结果落临时目录并上传 COS。对象键结构:用户UUID/用户ID/图片文件名。 processedName := genUUID() + ".png" // 获取用户 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) s.markProcessFailed(ctx, userID, clothesUUID, "获取用户信息失败") return } subDir := fmt.Sprintf("%s/%d", user.UserUUID, userID) 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) s.markProcessFailed(ctx, userID, clothesUUID, "图片上传失败") return } // 上传成功后回填前检查衣物是否仍存在:用户可能在处理期间删除衣物, // 若已删除则清理刚上传的对象,避免 COS 残留(额度已由删除事务回退,无需再处理)。 if _, gerr := dao.ClothesGetByUUID(ctx, clothesUUID); isNotFound(gerr) { if derr := upload.FileDeleteCos(ctx, cosKey); derr != nil { slog.ErrorContext(ctx, "delete orphan cos image failed", "clothes_uuid", clothesUUID, "cos_key", cosKey, "error", derr) } slog.InfoContext(ctx, "clothes deleted during processing, cleaned uploaded image", "clothes_uuid", clothesUUID) return } // 3. 分类。 _ = dao.ClothesUpdateByUUID(ctx, clothesUUID, map[string]any{"image_process_status": ImageProcessClassifying}) classifyClient := classification.NewClient() result, cerr := retryCall(ctx, global.ClassificationModelSetting.MaxRetry, "classify", func() (*classification.ClassifyResult, error) { return classifyClient.Classify(ctx, processedData) }) if cerr != nil { slog.ErrorContext(ctx, "classify failed", "clothes_uuid", clothesUUID, "error", cerr) // 分类失败仍保留已抠图结果,但条件标记失败并回退以便用户手动补全。 _ = dao.ClothesUpdateByUUID(ctx, clothesUUID, map[string]any{"processed_image": cosKey}) s.markProcessFailed(ctx, userID, clothesUUID, "图片分类失败") return } // 4. 回填分类结果与处理后图片,标记成功。注意主色列名为 color。 updates := map[string]any{ "category_main": result.CategoryMain, "category_sub": result.CategorySub, "style": result.Style, "name": buildDefaultClothesName(result), "color": result.ColorMain, "color_sub": result.ColorSub, "thickness": result.Thickness, "version": result.Version, "material": result.Material, "pattern": result.Pattern, "sleeve_type": result.SleeveType, "clothes_type": result.ClothesType, "neckline_type": result.NecklineType, "formality": result.Formality, "occasions": result.Occasions, "visual_impact": result.VisualImpact, "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 根据分类结果生成衣服默认名称(风格+小类+大类,空字段跳过)。 func buildDefaultClothesName(result *classification.ClassifyResult) string { name := "" if result.Style != "" { name += result.Style } if result.CategorySub != "" { name += result.CategorySub } if result.CategoryMain != "" { name += result.CategoryMain } return name } // 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) { var zero T var err error for attempt := 0; attempt <= maxRetry; attempt++ { result, e := fn() if e == nil { return result, nil } err = e if attempt >= maxRetry { break } slog.WarnContext(ctx, "model call failed, retrying", "name", name, "attempt", attempt+1, "error", e) select { case <-ctx.Done(): return zero, ctx.Err() case <-time.After(time.Second): } } return zero, err } // markProcessFailed 标记处理失败并回退上传额度。 // 单事务(行锁 -> 置失败 -> 回退额度 + 写流水),与删除事务(FOR UPDATE)通过行锁互斥: // 两路径串行化保证「处理失败回退」与「删除回退」恰好执行一次,消除漏退/双退窗口。 func (s *ClothesService) markProcessFailed(ctx context.Context, userID int64, clothesUUID, reason string) { // 调用方的三分钟处理 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, "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) } } // ProcessStatusGetByUUID 查询衣物图片处理状态。 func (s *ClothesService) ProcessStatusGetByUUID(ctx context.Context, userID int64, request *dto.ClothesProcessStatusGetByUUIDRequest) (*dto.ClothesProcessStatusGetByUUIDResponse, *errcode.Error) { ctx, span := global.Tracer.Start(ctx, "svc.ClothesProcessStatusGetByUUID") defer span.End() clothes, cerr := s.getOwnedClothes(ctx, request.ClothesUUID, userID) if cerr != nil { return nil, cerr } return &dto.ClothesProcessStatusGetByUUIDResponse{ ImageProcessStatus: clothes.ImageProcessStatus, FailReason: clothes.FailReason, }, nil } // GetByUUID 获取衣物详情。 // 属主始终可见;非属主仅允许携带有效穿搭分享(share_uuid)时,只读查看该分享穿搭内的衣物。 func (s *ClothesService) GetByUUID(ctx context.Context, userID int64, request *dto.ClothesGetByUUIDRequest) (*dto.ClothesGetByUUIDResponse, *errcode.Error) { ctx, span := global.Tracer.Start(ctx, "svc.ClothesGetByUUID") defer span.End() clothes, err := s.clothesDao.ClothesGetByUUID(ctx, request.ClothesUUID) if err != nil { if isNotFound(err) { return nil, errcode.ClothesNotFound } return nil, errcode.DatabaseError } if clothes.UserID != userID { if cerr := s.assertClothesVisibleViaOutfitShare(ctx, userID, request.ShareUUID, clothes.ID); cerr != nil { return nil, cerr } } response := buildClothesResponse(clothes) return &response, nil } // assertClothesVisibleViaOutfitShare 校验非属主可通过某穿搭分享只读查看该衣物。 func (s *ClothesService) assertClothesVisibleViaOutfitShare(ctx context.Context, userID int64, shareUUID string, clothesID int64) *errcode.Error { if shareUUID == "" { return errcode.ClothesNoPermission } share, err := s.outfitShareDao.OutfitShareGetByUUID(ctx, shareUUID) if err != nil { if isNotFound(err) { return errcode.OutfitShareNotFound } return errcode.DatabaseError } if share.Status == ShareStatusCancelled { return errcode.OutfitShareCancelled } outfit, oerr := s.outfitDao.OutfitGetByID(ctx, share.OutfitID) if oerr != nil { if isNotFound(oerr) { return errcode.OutfitShareNotFound } return errcode.DatabaseError } // 访客查看要求穿搭仍公开可见(属主访问自己的分享始终可查看)。 if share.UserID != userID && !isOutfitPublicVisible(outfit) { return errcode.OutfitShareNotFound } // 该衣物必须属于该分享的穿搭明细。 items, ierr := s.outfitItemDao.OutfitItemListByOutfitID(ctx, outfit.ID) if ierr != nil { return errcode.DatabaseError } for _, it := range items { if it.ClothesID == clothesID { return nil } } return errcode.ClothesNoPermission } // UpdateByUUID 修改衣物属性(仅更新非空字段)。 func (s *ClothesService) UpdateByUUID(ctx context.Context, userID int64, request *dto.ClothesUpdateByUUIDRequest) (*dto.ClothesUpdateByUUIDResponse, *errcode.Error) { ctx, span := global.Tracer.Start(ctx, "svc.ClothesUpdateByUUID") defer span.End() if _, cerr := s.getOwnedClothes(ctx, request.ClothesUUID, userID); cerr != nil { return nil, cerr } updates := map[string]any{} setIfNotEmpty(updates, "category_main", request.CategoryMain) setIfNotEmpty(updates, "category_sub", request.CategorySub) setIfNotEmpty(updates, "style", request.Style) setIfNotEmpty(updates, "color", request.ColorMain) // 主色列名为 color setIfNotEmpty(updates, "color_sub", request.ColorSub) setIfNotEmpty(updates, "thickness", request.Thickness) setIfNotEmpty(updates, "version", request.Version) setIfNotEmpty(updates, "material", request.Material) setIfNotEmpty(updates, "pattern", request.Pattern) setIfNotEmpty(updates, "sleeve_type", request.SleeveType) setIfNotEmpty(updates, "clothes_type", request.ClothesType) setIfNotEmpty(updates, "neckline_type", request.NecklineType) setIfNotEmpty(updates, "formality", request.Formality) setIfNotEmpty(updates, "occasions", request.Occasions) setIfNotEmpty(updates, "visual_impact", request.VisualImpact) setIfNotEmpty(updates, "name", request.Name) setIfNotEmpty(updates, "remark", request.Remark) if len(updates) == 0 { return &dto.ClothesUpdateByUUIDResponse{}, nil } if err := s.clothesDao.ClothesUpdateByUUID(ctx, request.ClothesUUID, updates); err != nil { return nil, errcode.ClothesUpdateFailed } return &dto.ClothesUpdateByUUIDResponse{}, nil } // DeleteByUUID 删除衣物(软删除)。 func (s *ClothesService) DeleteByUUID(ctx context.Context, userID int64, request *dto.ClothesDeleteByUUIDRequest) (*dto.ClothesDeleteByUUIDResponse, *errcode.Error) { ctx, span := global.Tracer.Start(ctx, "svc.ClothesDeleteByUUID") defer span.End() if _, cerr := s.getOwnedClothes(ctx, request.ClothesUUID, userID); cerr != nil { return nil, cerr } // 事务内锁定衣物行 -> 读最新状态 -> 软删除 -> 条件回退额度 -> 写回退流水, // 数值变更与流水同事务强一致,并避免与异步处理失败回退并发导致漏退/双退。 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"}). Where("clothes_uuid = ?", request.ClothesUUID). First(&c).Error; err != nil { return err } processedImage = c.ProcessedImage originalImagePath = c.OriginalImagePath if err := tx.Where("clothes_uuid = ?", request.ClothesUUID).Delete(&mysqlmodel.Clothes{}).Error; err != nil { return err } // 失败状态已在处理失败路径回退,不再回退,避免双重回退。 if c.ImageProcessStatus == ImageProcessFailed { return nil } // 同事务回退额度(优先免费额度,不足回退凭证)。 return refundCosQuotaTx(tx, userID, vipExpire, 1, 0, "删除衣物回退") }) if err != nil { if isNotFound(err) { return nil, errcode.ClothesNotFound } return nil, errcode.ClothesDeleteFailed } // 事务提交成功后清理 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) } } return &dto.ClothesDeleteByUUIDResponse{}, nil } // List 分页查询用户衣物。 func (s *ClothesService) List(ctx context.Context, userID int64, request *dto.ClothesListRequest) (*dto.ClothesListResponse, *errcode.Error) { ctx, span := global.Tracer.Start(ctx, "svc.ClothesList") defer span.End() page, pageSize := normalizePage(request.Page, request.PageSize) list, total, err := s.clothesDao.ClothesListByUserID(ctx, userID, request.CategoryMain, request.CategorySub, page, pageSize) if err != nil { return nil, errcode.DatabaseError } items := make([]dto.ClothesGetByUUIDResponse, 0, len(list)) for i := range list { items = append(items, buildClothesResponse(&list[i])) } return &dto.ClothesListResponse{ PageResponse: dto.PageResponse{Total: total, Page: page, PageSize: pageSize}, List: items, }, nil } // getOwnedClothes 查询衣物并校验归属。 func (s *ClothesService) getOwnedClothes(ctx context.Context, clothesUUID string, userID int64) (*mysqlmodel.Clothes, *errcode.Error) { clothes, err := s.clothesDao.ClothesGetByUUID(ctx, clothesUUID) if err != nil { if isNotFound(err) { return nil, errcode.ClothesNotFound } return nil, errcode.DatabaseError } if clothes.UserID != userID { return nil, errcode.ClothesNoPermission } return clothes, nil } // buildClothesResponse 将衣物模型映射为响应,processed_image 转为可访问 URL。 func buildClothesResponse(c *mysqlmodel.Clothes) dto.ClothesGetByUUIDResponse { processed := c.ProcessedImage if processed != "" { processed = upload.GetCosUrl(processed) } return dto.ClothesGetByUUIDResponse{ ClothesUUID: c.ClothesUUID, CategoryMain: c.CategoryMain, CategorySub: c.CategorySub, Style: c.Style, ColorMain: c.ColorMain, ColorSub: c.ColorSub, Thickness: c.Thickness, Version: c.Version, Material: c.Material, Pattern: c.Pattern, SleeveType: c.SleeveType, ClothesType: c.ClothesType, NecklineType: c.NecklineType, Formality: c.Formality, Occasions: c.Occasions, VisualImpact: c.VisualImpact, Name: c.Name, Remark: c.Remark, ProcessedImage: processed, } } // setIfNotEmpty 当值非空时写入 updates map。 func setIfNotEmpty(m map[string]any, key, val string) { if val != "" { 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) } }