434 lines
14 KiB
Go
434 lines
14 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"log/slog"
|
|
"time"
|
|
|
|
"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/internal/redisdao"
|
|
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/redismodel"
|
|
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/errcode"
|
|
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/upload"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// OutfitService 穿搭业务服务。
|
|
type OutfitService struct {
|
|
outfitDao *mysqldao.OutfitDao
|
|
outfitItemDao *mysqldao.OutfitItemDao
|
|
clothesDao *mysqldao.ClothesDao
|
|
auditLogDao *mysqldao.OutfitAuditLogDao
|
|
}
|
|
|
|
// NewOutfitService 创建穿搭服务。
|
|
func NewOutfitService() *OutfitService {
|
|
return &OutfitService{
|
|
outfitDao: mysqldao.NewOutfitDao(),
|
|
outfitItemDao: mysqldao.NewOutfitItemDao(),
|
|
clothesDao: mysqldao.NewClothesDao(),
|
|
auditLogDao: mysqldao.NewOutfitAuditLogDao(),
|
|
}
|
|
}
|
|
|
|
// Create 创建穿搭。
|
|
func (s *OutfitService) Create(ctx context.Context, userID int64, request *dto.OutfitCreateRequest) (*dto.OutfitCreateResponse, *errcode.Error) {
|
|
ctx, span := global.Tracer.Start(ctx, "svc.OutfitCreate")
|
|
defer span.End()
|
|
|
|
clothesIDs, items, cerr := s.resolveOwnedClothes(ctx, userID, request.Items)
|
|
if cerr != nil {
|
|
return nil, cerr
|
|
}
|
|
|
|
source := request.Source
|
|
if source != OutfitSourceUser && source != OutfitSourceAI {
|
|
source = OutfitSourceUser
|
|
}
|
|
|
|
outfit := &mysqlmodel.Outfit{
|
|
OutfitUUID: genUUID(),
|
|
UserID: userID,
|
|
OutfitName: request.OutfitName,
|
|
OutfitDesc: request.OutfitDesc,
|
|
Scene: request.Scene,
|
|
Source: source,
|
|
IsPublic: OutfitPrivate,
|
|
AuditStatus: OutfitAuditPending,
|
|
Status: OutfitStatusNormal,
|
|
}
|
|
if err := s.outfitDao.OutfitCreate(ctx, outfit); err != nil {
|
|
return nil, errcode.OutfitCreateFailed
|
|
}
|
|
|
|
if err := s.createItems(ctx, outfit.ID, clothesIDs); err != nil {
|
|
return nil, errcode.OutfitCreateFailed
|
|
}
|
|
|
|
response := buildOutfitCreateResponse(outfit, items)
|
|
return &response, nil
|
|
}
|
|
|
|
// GetByUUID 获取穿搭详情。私有穿搭仅本人可见。
|
|
func (s *OutfitService) GetByUUID(ctx context.Context, userID int64, request *dto.OutfitGetByUUIDRequest) (*dto.OutfitGetByUUIDResponse, *errcode.Error) {
|
|
ctx, span := global.Tracer.Start(ctx, "svc.OutfitGetByUUID")
|
|
defer span.End()
|
|
|
|
outfit, err := s.outfitDao.OutfitGetByUUID(ctx, request.OutfitUUID)
|
|
if err != nil {
|
|
if isNotFound(err) {
|
|
return nil, errcode.OutfitNotFound
|
|
}
|
|
return nil, errcode.DatabaseError
|
|
}
|
|
|
|
// 可见性:本人,或已公开且审核通过且正常。
|
|
if outfit.UserID != userID && !isOutfitPublicVisible(outfit) {
|
|
return nil, errcode.OutfitNoPermission
|
|
}
|
|
|
|
items, lerr := s.itemResponses(ctx, outfit.ID)
|
|
if lerr != nil {
|
|
return nil, errcode.DatabaseError
|
|
}
|
|
|
|
return &dto.OutfitGetByUUIDResponse{
|
|
OutfitCreateResponse: buildOutfitCreateResponse(outfit, items),
|
|
}, nil
|
|
}
|
|
|
|
// UpdateByUUID 修改穿搭基础信息与明细。
|
|
func (s *OutfitService) UpdateByUUID(ctx context.Context, userID int64, request *dto.OutfitUpdateByUUIDRequest) (*dto.OutfitUpdateByUUIDResponse, *errcode.Error) {
|
|
ctx, span := global.Tracer.Start(ctx, "svc.OutfitUpdateByUUID")
|
|
defer span.End()
|
|
|
|
outfit, cerr := s.getOwnedOutfit(ctx, request.OutfitUUID, userID)
|
|
if cerr != nil {
|
|
return nil, cerr
|
|
}
|
|
|
|
clothesIDs, _, verr := s.resolveOwnedClothes(ctx, userID, request.Items)
|
|
if verr != nil {
|
|
return nil, verr
|
|
}
|
|
|
|
updates := map[string]any{}
|
|
setIfNotEmpty(updates, "outfit_name", request.OutfitName)
|
|
setIfNotEmpty(updates, "outfit_desc", request.OutfitDesc)
|
|
setIfNotEmpty(updates, "scene", request.Scene)
|
|
if len(updates) > 0 {
|
|
if err := s.outfitDao.OutfitUpdateByUUID(ctx, request.OutfitUUID, updates); err != nil {
|
|
return nil, errcode.OutfitUpdateFailed
|
|
}
|
|
}
|
|
|
|
// 重建明细(事务内先删后建,避免中途失败留下空明细)。
|
|
rebuildErr := global.MysqlEngine.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
if err := tx.Where("outfit_id = ?", outfit.ID).Delete(&mysqlmodel.OutfitItem{}).Error; err != nil {
|
|
return err
|
|
}
|
|
items := make([]mysqlmodel.OutfitItem, 0, len(clothesIDs))
|
|
for _, cid := range clothesIDs {
|
|
items = append(items, mysqlmodel.OutfitItem{OutfitID: outfit.ID, ClothesID: cid})
|
|
}
|
|
if len(items) == 0 {
|
|
return nil
|
|
}
|
|
return tx.Create(&items).Error
|
|
})
|
|
if rebuildErr != nil {
|
|
return nil, errcode.OutfitUpdateFailed
|
|
}
|
|
|
|
return &dto.OutfitUpdateByUUIDResponse{}, nil
|
|
}
|
|
|
|
// List 分页查询我的穿搭。
|
|
func (s *OutfitService) List(ctx context.Context, userID int64, request *dto.OutfitListRequest) (*dto.OutfitListResponse, *errcode.Error) {
|
|
ctx, span := global.Tracer.Start(ctx, "svc.OutfitList")
|
|
defer span.End()
|
|
|
|
page, pageSize := normalizePage(request.Page, request.PageSize)
|
|
|
|
list, total, err := s.outfitDao.OutfitListByUserID(ctx, userID, request.IsPublic, request.Source, page, pageSize)
|
|
if err != nil {
|
|
return nil, errcode.DatabaseError
|
|
}
|
|
|
|
items, berr := s.buildOutfitListResponses(ctx, list)
|
|
if berr != nil {
|
|
return nil, errcode.DatabaseError
|
|
}
|
|
|
|
return &dto.OutfitListResponse{
|
|
PageResponse: dto.PageResponse{Total: total, Page: page, PageSize: pageSize},
|
|
List: items,
|
|
}, nil
|
|
}
|
|
|
|
// Delete 删除穿搭(软删除穿搭 + 清理明细)。
|
|
func (s *OutfitService) Delete(ctx context.Context, userID int64, request *dto.OutfitDeleteRequest) (*dto.OutfitDeleteResponse, *errcode.Error) {
|
|
ctx, span := global.Tracer.Start(ctx, "svc.OutfitDelete")
|
|
defer span.End()
|
|
|
|
outfit, cerr := s.getOwnedOutfit(ctx, request.OutfitUUID, userID)
|
|
if cerr != nil {
|
|
return nil, cerr
|
|
}
|
|
if err := s.outfitDao.OutfitDeleteByUUID(ctx, request.OutfitUUID); err != nil {
|
|
return nil, errcode.OutfitDeleteFailed
|
|
}
|
|
_ = s.outfitItemDao.OutfitItemDeleteByOutfitID(ctx, outfit.ID)
|
|
return &dto.OutfitDeleteResponse{}, nil
|
|
}
|
|
|
|
// Public 设置穿搭公开状态。公开时自动审核通过并写审核日志、消耗社区分享额度。
|
|
func (s *OutfitService) Public(ctx context.Context, userID int64, request *dto.OutfitPublicRequest) (*dto.OutfitPublicResponse, *errcode.Error) {
|
|
ctx, span := global.Tracer.Start(ctx, "svc.OutfitPublic")
|
|
defer span.End()
|
|
|
|
outfit, cerr := s.getOwnedOutfit(ctx, request.OutfitUUID, userID)
|
|
if cerr != nil {
|
|
return nil, cerr
|
|
}
|
|
|
|
// 公开/取消公开流程加锁串行化:消除并发下「A 扣额失败回退公开态、B 幂等返回成功但最终未公开」
|
|
// 的语义冲突;锁失败返回请求过于频繁,Redis 异常时降级放行(与项目 fail-open 策略一致)。
|
|
lockKey := fmtLockKey(redismodel.KeyLockOutfitPublic, request.OutfitUUID)
|
|
lockVal := genUUID()
|
|
if ok, lerr := redisdao.TryLock(ctx, lockKey, lockVal, 10*time.Second); lerr != nil {
|
|
slog.WarnContext(ctx, "acquire outfit public lock failed, proceed without lock",
|
|
"outfit_uuid", request.OutfitUUID, "error", lerr)
|
|
} else if !ok {
|
|
return nil, errcode.TooManyRequests
|
|
} else {
|
|
defer func() { _ = redisdao.Unlock(ctx, lockKey, lockVal) }()
|
|
}
|
|
|
|
// 取消公开:直接置为不公开。
|
|
if request.IsPublic == OutfitPrivate {
|
|
if outfit.IsPublic == OutfitPrivate {
|
|
return &dto.OutfitPublicResponse{}, nil
|
|
}
|
|
if err := s.outfitDao.OutfitUpdateIsPublic(ctx, request.OutfitUUID, OutfitPrivate); err != nil {
|
|
return nil, errcode.OutfitUpdateFailed
|
|
}
|
|
return &dto.OutfitPublicResponse{}, nil
|
|
}
|
|
|
|
// 已公开则无需重复处理。
|
|
if outfit.IsPublic == OutfitPublic {
|
|
return &dto.OutfitPublicResponse{}, nil
|
|
}
|
|
|
|
// 首次公开:先条件更新(私密 -> 公开+审核通过),成功才消耗额度,防并发重复扣减。
|
|
ok, perr := s.outfitDao.OutfitPublish(ctx, request.OutfitUUID)
|
|
if perr != nil {
|
|
return nil, errcode.OutfitUpdateFailed
|
|
}
|
|
if !ok {
|
|
// 已被并发公开,幂等返回。
|
|
return &dto.OutfitPublicResponse{}, nil
|
|
}
|
|
|
|
if qerr := consumeDailyQuota(ctx, userID, QuotaTypeOutfitShare, 1, "穿搭社区分享消耗"); qerr != nil {
|
|
// 扣额度失败,回退公开状态。
|
|
_ = s.outfitDao.OutfitUpdateIsPublic(ctx, request.OutfitUUID, OutfitPrivate)
|
|
return nil, qerr
|
|
}
|
|
|
|
// 写审核日志(自动通过,best-effort:失败仅告警不阻断公开)。
|
|
if err := s.auditLogDao.OutfitAuditLogCreate(ctx, &mysqlmodel.OutfitAuditLog{
|
|
TargetID: outfit.ID,
|
|
AuditResult: AuditResultPass,
|
|
AuditTime: nowUnix(),
|
|
}); err != nil {
|
|
slog.WarnContext(ctx, "write outfit audit log failed", "outfit_id", outfit.ID, "error", err)
|
|
}
|
|
|
|
// 记录当日社区分享计数(统计)。
|
|
recordDailyUsage(ctx, userID, QuotaTypeOutfitShare)
|
|
|
|
return &dto.OutfitPublicResponse{}, nil
|
|
}
|
|
|
|
// ---- 内部辅助 ----
|
|
|
|
// getOwnedOutfit 查询穿搭并校验归属。
|
|
func (s *OutfitService) getOwnedOutfit(ctx context.Context, outfitUUID string, userID int64) (*mysqlmodel.Outfit, *errcode.Error) {
|
|
outfit, err := s.outfitDao.OutfitGetByUUID(ctx, outfitUUID)
|
|
if err != nil {
|
|
if isNotFound(err) {
|
|
return nil, errcode.OutfitNotFound
|
|
}
|
|
return nil, errcode.DatabaseError
|
|
}
|
|
if outfit.UserID != userID {
|
|
return nil, errcode.OutfitNoPermission
|
|
}
|
|
return outfit, nil
|
|
}
|
|
|
|
// isOutfitPublicVisible 判断穿搭是否对他人公开可见(公开 + 审核通过 + 正常)。
|
|
func isOutfitPublicVisible(o *mysqlmodel.Outfit) bool {
|
|
return o.IsPublic == OutfitPublic && o.AuditStatus == OutfitAuditPass && o.Status == OutfitStatusNormal
|
|
}
|
|
|
|
// resolveOwnedClothes 校验请求中的衣物均属于该用户,返回有序去重的 clothesID 与 uuid 列表。
|
|
func (s *OutfitService) resolveOwnedClothes(ctx context.Context, userID int64, items []dto.OutfitItemRequest) ([]int64, []dto.OutfitItemResponse, *errcode.Error) {
|
|
uuids := make([]string, 0, len(items))
|
|
seen := make(map[string]struct{}, len(items))
|
|
for _, it := range items {
|
|
if _, ok := seen[it.ClothesUUID]; ok {
|
|
continue
|
|
}
|
|
seen[it.ClothesUUID] = struct{}{}
|
|
uuids = append(uuids, it.ClothesUUID)
|
|
}
|
|
|
|
clothesList, err := s.clothesDao.ClothesGetByUUIDs(ctx, uuids)
|
|
if err != nil {
|
|
return nil, nil, errcode.DatabaseError
|
|
}
|
|
byUUID := make(map[string]mysqlmodel.Clothes, len(clothesList))
|
|
for _, c := range clothesList {
|
|
byUUID[c.ClothesUUID] = c
|
|
}
|
|
|
|
ids := make([]int64, 0, len(uuids))
|
|
respItems := make([]dto.OutfitItemResponse, 0, len(uuids))
|
|
for _, u := range uuids {
|
|
c, ok := byUUID[u]
|
|
if !ok {
|
|
return nil, nil, errcode.ClothesNotFound
|
|
}
|
|
if c.UserID != userID {
|
|
return nil, nil, errcode.ClothesNoPermission
|
|
}
|
|
ids = append(ids, c.ID)
|
|
respItems = append(respItems, buildOutfitItemResponse(&c))
|
|
}
|
|
return ids, respItems, nil
|
|
}
|
|
|
|
// createItems 批量创建穿搭明细。
|
|
func (s *OutfitService) createItems(ctx context.Context, outfitID int64, clothesIDs []int64) error {
|
|
items := make([]mysqlmodel.OutfitItem, 0, len(clothesIDs))
|
|
for _, cid := range clothesIDs {
|
|
items = append(items, mysqlmodel.OutfitItem{OutfitID: outfitID, ClothesID: cid})
|
|
}
|
|
return s.outfitItemDao.OutfitItemCreateBatch(ctx, items)
|
|
}
|
|
|
|
// itemResponses 获取某穿搭的衣物明细(含图片 URL)。
|
|
func (s *OutfitService) itemResponses(ctx context.Context, outfitID int64) ([]dto.OutfitItemResponse, error) {
|
|
return loadOutfitItemResponses(ctx, s.outfitItemDao, s.clothesDao, outfitID)
|
|
}
|
|
|
|
// loadOutfitItemResponses 获取某穿搭的衣物明细(含图片 URL),供穿搭详情与穿搭分享复用。
|
|
func loadOutfitItemResponses(ctx context.Context, outfitItemDao *mysqldao.OutfitItemDao, clothesDao *mysqldao.ClothesDao, outfitID int64) ([]dto.OutfitItemResponse, error) {
|
|
items, err := outfitItemDao.OutfitItemListByOutfitID(ctx, outfitID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
ids := make([]int64, 0, len(items))
|
|
for _, it := range items {
|
|
ids = append(ids, it.ClothesID)
|
|
}
|
|
clothesList, err := clothesDao.ClothesGetByIDs(ctx, ids)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
clothesByID := make(map[int64]mysqlmodel.Clothes, len(clothesList))
|
|
for i := range clothesList {
|
|
clothesByID[clothesList[i].ID] = clothesList[i]
|
|
}
|
|
resps := make([]dto.OutfitItemResponse, 0, len(items))
|
|
for _, it := range items {
|
|
if c, ok := clothesByID[it.ClothesID]; ok {
|
|
resps = append(resps, buildOutfitItemResponse(&c))
|
|
}
|
|
}
|
|
return resps, nil
|
|
}
|
|
|
|
// buildOutfitListResponses 批量组装穿搭列表(一次性加载全部明细与衣物,避免 N+1)。
|
|
func (s *OutfitService) buildOutfitListResponses(ctx context.Context, list []mysqlmodel.Outfit) ([]dto.OutfitCreateResponse, error) {
|
|
if len(list) == 0 {
|
|
return []dto.OutfitCreateResponse{}, nil
|
|
}
|
|
|
|
outfitIDs := make([]int64, 0, len(list))
|
|
for _, o := range list {
|
|
outfitIDs = append(outfitIDs, o.ID)
|
|
}
|
|
|
|
allItems, err := s.outfitItemDao.OutfitItemListByOutfitIDs(ctx, outfitIDs)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
clothesIDSet := make(map[int64]struct{}, len(allItems))
|
|
itemsByOutfit := make(map[int64][]int64, len(list))
|
|
for _, it := range allItems {
|
|
itemsByOutfit[it.OutfitID] = append(itemsByOutfit[it.OutfitID], it.ClothesID)
|
|
clothesIDSet[it.ClothesID] = struct{}{}
|
|
}
|
|
|
|
clothesIDs := make([]int64, 0, len(clothesIDSet))
|
|
for id := range clothesIDSet {
|
|
clothesIDs = append(clothesIDs, id)
|
|
}
|
|
clothesList, err := s.clothesDao.ClothesGetByIDs(ctx, clothesIDs)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
clothesByID := make(map[int64]mysqlmodel.Clothes, len(clothesList))
|
|
for _, c := range clothesList {
|
|
clothesByID[c.ID] = c
|
|
}
|
|
|
|
resps := make([]dto.OutfitCreateResponse, 0, len(list))
|
|
for i := range list {
|
|
o := &list[i]
|
|
items := make([]dto.OutfitItemResponse, 0, len(itemsByOutfit[o.ID]))
|
|
for _, cid := range itemsByOutfit[o.ID] {
|
|
if c, ok := clothesByID[cid]; ok {
|
|
items = append(items, buildOutfitItemResponse(&c))
|
|
}
|
|
}
|
|
resps = append(resps, buildOutfitCreateResponse(o, items))
|
|
}
|
|
return resps, nil
|
|
}
|
|
|
|
// buildOutfitItemResponse 组装穿搭明细(processed_image 转为可访问 URL)。
|
|
func buildOutfitItemResponse(c *mysqlmodel.Clothes) dto.OutfitItemResponse {
|
|
return dto.OutfitItemResponse{ClothesUUID: c.ClothesUUID, ProcessedImage: clothesImageURL(c.ProcessedImage)}
|
|
}
|
|
|
|
// clothesImageURL 将衣物抠图相对路径转为可访问 URL(空则返回空)。
|
|
func clothesImageURL(processed string) string {
|
|
if processed == "" {
|
|
return ""
|
|
}
|
|
return upload.GetCosUrl(processed)
|
|
}
|
|
|
|
// buildOutfitCreateResponse 组装穿搭响应。
|
|
func buildOutfitCreateResponse(o *mysqlmodel.Outfit, items []dto.OutfitItemResponse) dto.OutfitCreateResponse {
|
|
return dto.OutfitCreateResponse{
|
|
OutfitUUID: o.OutfitUUID,
|
|
OutfitName: o.OutfitName,
|
|
OutfitDesc: o.OutfitDesc,
|
|
Scene: o.Scene,
|
|
Source: o.Source,
|
|
IsPublic: o.IsPublic,
|
|
AuditStatus: o.AuditStatus,
|
|
Items: items,
|
|
}
|
|
}
|