Files
zt-closet-backend/internal/service/clothes.go
T
zhengchiliu c2519c4b6d feat(clothes): 支持通过社区公开穿搭只读查看衣物
新增 outfit_uuid 查询参数,非属主可通过社区公开穿搭查看衣物详情。
同时调整点赞通知逻辑,仅在首次点赞时发送通知。
2026-09-16 02:28:59 +08:00

749 lines
29 KiB
Go

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 {
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
}
// 5. 创建衣物记录,初始状态为排队中。
clothesUUID := genUUID()
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)
refundUploadQuotaOnFail(ctx, userID, "原图持久化失败回退")
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 {
slog.ErrorContext(ctx, "clothes upload: create clothes record failed",
"user_id", userID, "clothes_uuid", clothesUUID, "error", err)
_ = os.Remove(originalPath)
// 建记录失败,回退已扣额度(独立 ctx:客户端中途断开也要保证退回)。
refundUploadQuotaOnFail(ctx, userID, "上传失败回退")
return nil, errcode.ClothesCreateFailed
}
// 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,
}, nil
}
// detachedCtx 基于 Background 派生独立 ctx:继承 trace 链路但切断请求取消信号,
// 供「与客户端连接解耦」的后台任务与「必须完成」的收尾操作使用。
func detachedCtx(parent context.Context, timeout time.Duration) (context.Context, context.CancelFunc) {
return context.WithTimeout(
trace.ContextWithSpanContext(context.Background(), trace.SpanContextFromContext(parent)),
timeout,
)
}
// refundUploadQuotaOnFail 用不受请求取消影响的 ctx 回退上传额度:
// 客户端中途断开导致请求 ctx 已取消时,仍要保证额度退回成功。
func refundUploadQuotaOnFail(parent context.Context, userID int64, reason string) {
ctx, cancel := detachedCtx(parent, 30*time.Second)
defer cancel()
refundCosQuota(ctx, userID, 1, 0, reason)
}
// 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 := detachedCtx(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. 抠图。
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, "图片抠图失败")
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, "user_id", userID, "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. 分类。
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)
// 分类失败仍保留已抠图结果,但条件标记失败并回退以便用户手动补全。
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
}
// 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": "",
}
// 带状态守卫回填:恢复任务可能已把该记录置为失败并回退了额度,
// 此时若无条件写回成功,会出现「额度已退但衣物成功」(白送额度)并残留 COS 对象。
ok, uerr := dao.ClothesUpdateByUUIDIfStatusIn(ctx, clothesUUID,
[]int8{ImageProcessMatting, ImageProcessClassifying, ImageProcessQueuing}, updates)
if uerr != nil {
slog.ErrorContext(ctx, "update clothes after classify failed", "clothes_uuid", clothesUUID, "error", uerr)
return
}
if !ok {
// 记录已被置失败或已删除:本次结果作废,清理刚上传的 COS 对象避免孤儿。
slog.WarnContext(ctx, "clothes no longer processing, discard result",
"clothes_uuid", clothesUUID, "cos_key", cosKey)
if derr := upload.FileDeleteCos(ctx, cosKey); derr != nil {
slog.ErrorContext(ctx, "delete discarded cos image failed",
"clothes_uuid", clothesUUID, "cos_key", cosKey, "error", derr)
}
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), attrs ...any) (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
}
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()
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。
// 用 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"}).
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 {
failPhase = "mark_failed"
return err
}
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, "user_id", userID, "phase", failPhase, "error", err)
return
}
if originalPath != "" {
if rerr := os.Remove(originalPath); rerr != nil {
slog.DebugContext(ctx, "clothes process: remove original image failed", "clothes_uuid", clothesUUID, "error", rerr)
}
}
}
// 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)查看该分享穿搭内的衣物,或携带社区公开穿搭(outfit_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.assertClothesVisibleToVisitor(ctx, userID, request, clothes.ID); cerr != nil {
return nil, cerr
}
}
response := buildClothesResponse(clothes)
// 非属主只读查看:不暴露内部失败原因;社区(未携带 share_uuid 的公开穿搭入口)
// 是陌生人可见场景,属主私有备注同样不下发。
if clothes.UserID != userID {
response.FailReason = ""
if request.ShareUUID == "" {
response.Remark = ""
}
}
return &response, nil
}
// assertClothesVisibleToVisitor 校验非属主可以只读查看该衣物。
// 两个入口互斥:优先穿搭分享,其次社区公开穿搭;都未携带则直接拒绝。
func (s *ClothesService) assertClothesVisibleToVisitor(ctx context.Context, userID int64, request *dto.ClothesGetByUUIDRequest, clothesID int64) *errcode.Error {
if request.ShareUUID != "" {
return s.assertClothesVisibleViaOutfitShare(ctx, userID, request.ShareUUID, clothesID)
}
if request.OutfitUUID != "" {
return s.assertClothesVisibleViaPublicOutfit(ctx, request.OutfitUUID, clothesID)
}
return errcode.ClothesNoPermission
}
// assertClothesVisibleViaPublicOutfit 校验非属主可通过社区公开穿搭只读查看该衣物。
// 可见性口径与 OutfitService.GetByUUID 的非属主分支保持一致(公开 + 审核通过 + 正常)。
func (s *ClothesService) assertClothesVisibleViaPublicOutfit(ctx context.Context, outfitUUID string, clothesID int64) *errcode.Error {
outfit, err := s.outfitDao.OutfitGetByUUID(ctx, outfitUUID)
if err != nil {
if isNotFound(err) {
// 不区分「穿搭不存在」与「穿搭不可见」,统一按无权限返回,避免探测他人穿搭
return errcode.ClothesNoPermission
}
return errcode.DatabaseError
}
if !isOutfitPublicVisible(outfit) {
return errcode.ClothesNoPermission
}
// 该衣物必须属于该公开穿搭的明细。
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
}
// 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 && outfit.Status == OutfitStatusBanned {
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
}
// 引用校验:已被穿搭明细 / 衣柜分享明细引用的衣物不允许删除,否则会留下悬挂引用,
// 公开穿搭与分享会变成「空壳」(明细里查不到衣物,前端静默跳过),
// 也与接口文档约定的 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
}
// 事务内锁定衣物行 -> 读最新状态 -> 软删除 -> 条件回退额度 -> 写回退流水,
// 数值变更与流水同事务强一致,并避免与异步处理失败回退并发导致漏退/双退。
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(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 != "" {
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 {
slog.WarnContext(ctx, "delete clothes cos image failed", "clothes_uuid", request.ClothesUUID, "cos_key", processedImage, "error", derr)
}
}
slog.InfoContext(ctx, "clothes deleted", "clothes_uuid", request.ClothesUUID, "user_id", userID)
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,
ImageProcessStatus: c.ImageProcessStatus,
FailReason: c.FailReason,
}
}
// 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
}
if len(list) == 0 {
slog.DebugContext(ctx, "clothes recovery: no stale processing tasks")
return
}
service := NewClothesService()
recovered := 0
for i := range list {
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
}
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)
}