289 lines
9.8 KiB
Go
289 lines
9.8 KiB
Go
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 创建穿搭分享。仅能分享本人的穿搭;分享与「公开到社区」解耦,
|
|
// 仅自己可见的穿搭同样可生成分享链接(好友凭 share_uuid 只读查看)。
|
|
// 额度:私密穿搭分享扣 1 次衣柜分享额度(与衣柜分享共用额度池),已公开穿搭分享不扣。
|
|
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 outfit.Status == OutfitStatusBanned {
|
|
return nil, errcode.OutfitNoPermission
|
|
}
|
|
|
|
// 额度:仅「仅自己可见」的穿搭分享扣 1 次衣柜分享额度(与衣柜分享共用额度池);
|
|
// 已公开穿搭在「公开到社区」时已扣过社区分享额度,分享给好友不再重复扣。
|
|
charged := outfit.IsPublic == OutfitPrivate
|
|
if charged {
|
|
if qerr := consumeDailyQuota(ctx, userID, QuotaTypeClosetShare, 1, "私密穿搭分享消耗"); qerr != nil {
|
|
return nil, qerr
|
|
}
|
|
}
|
|
|
|
share := &mysqlmodel.OutfitShare{
|
|
ShareUUID: genUUID(),
|
|
UserID: userID,
|
|
OutfitID: outfit.ID,
|
|
Status: ShareStatusNormal,
|
|
}
|
|
if err := s.shareDao.OutfitShareCreate(ctx, share); err != nil {
|
|
// 分享落库失败:回退刚扣的额度,避免用户额度被白扣。
|
|
if charged {
|
|
refundDailyQuota(ctx, userID, QuotaTypeClosetShare, 1)
|
|
}
|
|
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
|
|
// 访客视角:分享正常即可只读查看(分享独立于公开状态,取消公开不影响已发出的分享);
|
|
// 仅当穿搭已删除(上方判为 90103)或已封禁时视为分享失效。
|
|
if !isOwner && outfit.Status == OutfitStatusBanned {
|
|
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
|
|
}
|