package service import ( "context" "log/slog" "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/errcode" "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/upload" ) // OutfitShareService 穿搭分享业务服务。 type OutfitShareService struct { shareDao *mysqldao.OutfitShareDao receiverDao *mysqldao.OutfitShareReceiverDao outfitDao *mysqldao.OutfitDao outfitItemDao *mysqldao.OutfitItemDao clothesDao *mysqldao.ClothesDao userDao *mysqldao.UserDao } // NewOutfitShareService 创建穿搭分享服务。 func NewOutfitShareService() *OutfitShareService { return &OutfitShareService{ shareDao: mysqldao.NewOutfitShareDao(), receiverDao: mysqldao.NewOutfitShareReceiverDao(), outfitDao: mysqldao.NewOutfitDao(), outfitItemDao: mysqldao.NewOutfitItemDao(), clothesDao: mysqldao.NewClothesDao(), userDao: mysqldao.NewUserDao(), } } // Create 创建穿搭分享。仅能分享本人且已公开的穿搭;不消耗额度。 func (s *OutfitShareService) Create(ctx context.Context, userID int64, request *dto.OutfitShareCreateRequest) (*dto.OutfitShareCreateResponse, *errcode.Error) { ctx, span := global.Tracer.Start(ctx, "svc.OutfitShareCreate") 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 { return nil, errcode.OutfitNoPermission } // 分享前提:穿搭已公开可见(否则对方打开后无法查看)。 if !isOutfitPublicVisible(outfit) { return nil, errcode.InvalidParams.WithDetails("请先公开该穿搭再分享") } share := &mysqlmodel.OutfitShare{ ShareUUID: genUUID(), UserID: userID, OutfitID: outfit.ID, Status: ShareStatusNormal, } if err := s.shareDao.OutfitShareCreate(ctx, share); err != nil { return nil, errcode.DatabaseError } return &dto.OutfitShareCreateResponse{ShareUUID: share.ShareUUID}, nil } // Detail 查看穿搭分享详情(访客 / 属主视角)。访客访问成功即记录接收关系。 func (s *OutfitShareService) Detail(ctx context.Context, userID int64, shareUUID string) (*dto.OutfitShareDetailResponse, *errcode.Error) { ctx, span := global.Tracer.Start(ctx, "svc.OutfitShareDetail") defer span.End() share, err := s.shareDao.OutfitShareGetByUUID(ctx, shareUUID) if err != nil { if isNotFound(err) { return nil, errcode.OutfitShareNotFound } return nil, errcode.DatabaseError } if share.Status == ShareStatusCancelled { return nil, errcode.OutfitShareCancelled } outfit, oerr := s.outfitDao.OutfitGetByID(ctx, share.OutfitID) if oerr != nil { // 穿搭已删除:分享视为失效 if isNotFound(oerr) { return nil, errcode.OutfitShareNotFound } return nil, errcode.DatabaseError } isOwner := share.UserID == userID // 访客视角:穿搭必须仍处于公开可见状态,否则分享失效 if !isOwner && !isOutfitPublicVisible(outfit) { return nil, errcode.OutfitShareNotFound } items, lerr := loadOutfitItemResponses(ctx, s.outfitItemDao, s.clothesDao, outfit.ID) if lerr != nil { return nil, errcode.DatabaseError } // 记录接收关系:登录且非属主用户成功访问即视为收到,幂等,失败不阻断详情返回。 if userID > 0 && !isOwner { if rerr := s.receiverDao.OutfitShareReceiverUpsert(ctx, shareUUID, userID); rerr != nil { slog.WarnContext(ctx, "record outfit share receiver failed", "share_uuid", shareUUID, "user_id", userID, "error", rerr) } } ownerName, ownerAvatar := "", "" if owner, uerr := s.userDao.UserGetByID(ctx, share.UserID); uerr == nil { ownerName = owner.Username ownerAvatar = upload.AvatarURL(owner.Avatar) } return &dto.OutfitShareDetailResponse{ ShareUUID: shareUUID, OwnerName: ownerName, OwnerAvatar: ownerAvatar, Status: share.Status, IsOwner: isOwner, Outfit: buildOutfitCreateResponse(outfit, items), }, nil } // List 分页查询我的穿搭分享。 func (s *OutfitShareService) List(ctx context.Context, userID int64, request *dto.OutfitShareListRequest) (*dto.OutfitShareListResponse, *errcode.Error) { ctx, span := global.Tracer.Start(ctx, "svc.OutfitShareList") defer span.End() page, pageSize := normalizePage(request.Page, request.PageSize) list, total, err := s.shareDao.OutfitShareListByUserID(ctx, userID, request.Status, page, pageSize) if err != nil { return nil, errcode.DatabaseError } items, berr := s.buildShareItems(ctx, list) if berr != nil { return nil, errcode.DatabaseError } return &dto.OutfitShareListResponse{ PageResponse: dto.PageResponse{Total: total, Page: page, PageSize: pageSize}, List: items, }, nil } // ListReceived 分页查询"分享给我的"穿搭分享。 func (s *OutfitShareService) ListReceived(ctx context.Context, userID int64, request *dto.OutfitShareListRequest) (*dto.OutfitShareListResponse, *errcode.Error) { ctx, span := global.Tracer.Start(ctx, "svc.OutfitShareReceivedList") defer span.End() page, pageSize := normalizePage(request.Page, request.PageSize) list, total, err := s.receiverDao.OutfitShareReceivedList(ctx, userID, request.Status, page, pageSize) if err != nil { return nil, errcode.DatabaseError } items, berr := s.buildShareItems(ctx, list) if berr != nil { return nil, errcode.DatabaseError } // 批量填充分享者信息。 ownerIDs := make([]int64, 0, len(list)) for _, sh := range list { ownerIDs = append(ownerIDs, sh.UserID) } ownerByID := make(map[int64]mysqlmodel.User, len(ownerIDs)) if owners, oerr := s.userDao.UserGetByIDs(ctx, ownerIDs); oerr == nil { for _, u := range owners { ownerByID[u.ID] = u } } for i := range items { if owner, ok := ownerByID[list[i].UserID]; ok { items[i].OwnerName = owner.Username items[i].OwnerAvatar = upload.AvatarURL(owner.Avatar) } } return &dto.OutfitShareListResponse{ PageResponse: dto.PageResponse{Total: total, Page: page, PageSize: pageSize}, List: items, }, nil } // Cancel 取消穿搭分享(校验归属)。 func (s *OutfitShareService) Cancel(ctx context.Context, userID int64, request *dto.OutfitShareCancelRequest) (*dto.OutfitShareCancelResponse, *errcode.Error) { ctx, span := global.Tracer.Start(ctx, "svc.OutfitShareCancel") defer span.End() share, err := s.shareDao.OutfitShareGetByUUID(ctx, request.ShareUUID) if err != nil { if isNotFound(err) { return nil, errcode.OutfitShareNotFound } return nil, errcode.DatabaseError } if share.UserID != userID { return nil, errcode.Forbidden } if err := s.shareDao.OutfitShareUpdateStatus(ctx, request.ShareUUID, ShareStatusCancelled); err != nil { return nil, errcode.DatabaseError } return &dto.OutfitShareCancelResponse{}, nil } // ---- 内部辅助 ---- // buildShareItems 批量组装分享列表项(一次性加载穿搭与首件衣物,避免 N+1)。 func (s *OutfitShareService) buildShareItems(ctx context.Context, list []mysqlmodel.OutfitShare) ([]dto.OutfitShareItemResponse, error) { items := make([]dto.OutfitShareItemResponse, 0, len(list)) if len(list) == 0 { return items, nil } outfitIDs := make([]int64, 0, len(list)) for _, sh := range list { outfitIDs = append(outfitIDs, sh.OutfitID) } outfits, err := s.outfitDao.OutfitGetByIDs(ctx, outfitIDs) if err != nil { return nil, err } outfitByID := make(map[int64]mysqlmodel.Outfit, len(outfits)) for i := range outfits { outfitByID[outfits[i].ID] = outfits[i] } allItems, err := s.outfitItemDao.OutfitItemListByOutfitIDs(ctx, outfitIDs) if err != nil { return nil, err } countByOutfit := make(map[int64]int, len(outfitIDs)) firstClothesByOutfit := make(map[int64]int64, len(outfitIDs)) for _, it := range allItems { countByOutfit[it.OutfitID]++ if _, ok := firstClothesByOutfit[it.OutfitID]; !ok { firstClothesByOutfit[it.OutfitID] = it.ClothesID } } clothesIDs := make([]int64, 0, len(firstClothesByOutfit)) for _, cid := range firstClothesByOutfit { clothesIDs = append(clothesIDs, cid) } coverByClothesID := make(map[int64]string, len(clothesIDs)) if clothesList, cerr := s.clothesDao.ClothesGetByIDs(ctx, clothesIDs); cerr == nil { for i := range clothesList { coverByClothesID[clothesList[i].ID] = clothesImageURL(clothesList[i].ProcessedImage) } } for _, sh := range list { o := outfitByID[sh.OutfitID] cover := "" if cid, ok := firstClothesByOutfit[sh.OutfitID]; ok { cover = coverByClothesID[cid] } items = append(items, dto.OutfitShareItemResponse{ ShareUUID: sh.ShareUUID, OutfitUUID: o.OutfitUUID, OutfitName: o.OutfitName, CoverImage: cover, ItemsCount: countByOutfit[sh.OutfitID], Status: sh.Status, CreatedAt: sh.CreatedAt, }) } return items, nil }