214 lines
7.7 KiB
Go
214 lines
7.7 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/internal/redisdao"
|
|
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/errcode"
|
|
)
|
|
|
|
// CollectService 收藏业务服务。
|
|
type CollectService struct {
|
|
collectDao *mysqldao.CollectDao
|
|
outfitDao *mysqldao.OutfitDao
|
|
outfitItemDao *mysqldao.OutfitItemDao
|
|
clothesDao *mysqldao.ClothesDao
|
|
}
|
|
|
|
// NewCollectService 创建收藏服务。
|
|
func NewCollectService() *CollectService {
|
|
return &CollectService{
|
|
collectDao: mysqldao.NewCollectDao(),
|
|
outfitDao: mysqldao.NewOutfitDao(),
|
|
outfitItemDao: mysqldao.NewOutfitItemDao(),
|
|
clothesDao: mysqldao.NewClothesDao(),
|
|
}
|
|
}
|
|
|
|
// Collect 收藏或取消收藏社区穿搭。
|
|
func (s *CollectService) Collect(ctx context.Context, userID int64, request *dto.CollectRequest) (*dto.CollectResponse, *errcode.Error) {
|
|
ctx, span := global.Tracer.Start(ctx, "svc.CollectCollect")
|
|
defer span.End()
|
|
|
|
outfit, err := s.outfitDao.OutfitGetByUUID(ctx, request.TargetUUID)
|
|
if err != nil {
|
|
if isNotFound(err) {
|
|
return nil, errcode.TargetNotFound
|
|
}
|
|
slog.ErrorContext(ctx, "collect: query outfit failed", "outfit_uuid", request.TargetUUID, "user_id", userID, "error", err)
|
|
return nil, errcode.DatabaseError
|
|
}
|
|
// 非本人仅可收藏公开可见的穿搭。
|
|
if outfit.UserID != userID && !isOutfitPublicVisible(outfit) {
|
|
return nil, errcode.OutfitNoPermission
|
|
}
|
|
targetID := outfit.ID
|
|
|
|
existing, err := s.collectDao.CollectGetByTargetAndUser(ctx, CollectTypeOutfit, targetID, userID)
|
|
notExist := false
|
|
if err != nil {
|
|
if isNotFound(err) {
|
|
notExist = true
|
|
} else {
|
|
slog.ErrorContext(ctx, "collect: query record failed", "target_id", targetID, "user_id", userID, "error", err)
|
|
return nil, errcode.DatabaseError
|
|
}
|
|
}
|
|
|
|
switch request.Action {
|
|
case ActionDo: // 收藏
|
|
if cerr := s.collectDao.CollectUpsertOn(ctx, &mysqlmodel.Collect{
|
|
CollectType: CollectTypeOutfit,
|
|
TargetID: targetID,
|
|
UserID: userID,
|
|
Status: InteractStatusOn,
|
|
}); cerr != nil {
|
|
slog.ErrorContext(ctx, "collect: upsert record failed", "target_id", targetID, "user_id", userID, "error", cerr)
|
|
return nil, errcode.CollectFailed
|
|
}
|
|
if rerr := redisdao.CollectAdd(ctx, CollectTypeOutfit, targetID, userID); rerr != nil {
|
|
slog.WarnContext(ctx, "collect: redis add failed, counter may undercount",
|
|
"target_id", targetID, "user_id", userID, "error", rerr)
|
|
}
|
|
|
|
case ActionCancel: // 取消收藏
|
|
if !notExist && existing.Status == InteractStatusOn {
|
|
if uerr := s.collectDao.CollectUpdateStatus(ctx, existing.ID, InteractStatusOff); uerr != nil {
|
|
slog.ErrorContext(ctx, "collect: update status failed", "target_id", targetID, "user_id", userID, "error", uerr)
|
|
return nil, errcode.CollectFailed
|
|
}
|
|
if rerr := redisdao.CollectRemove(ctx, CollectTypeOutfit, targetID, userID); rerr != nil {
|
|
slog.WarnContext(ctx, "collect: redis remove failed, counter may overcount",
|
|
"target_id", targetID, "user_id", userID, "error", rerr)
|
|
}
|
|
}
|
|
}
|
|
|
|
return &dto.CollectResponse{}, nil
|
|
}
|
|
|
|
// List 分页查询我收藏的穿搭。
|
|
func (s *CollectService) List(ctx context.Context, userID int64, request *dto.CollectListRequest) (*dto.CollectListResponse, *errcode.Error) {
|
|
ctx, span := global.Tracer.Start(ctx, "svc.CollectList")
|
|
defer span.End()
|
|
|
|
page, pageSize := normalizePage(request.Page, request.PageSize)
|
|
|
|
pageIDs, total, err := s.collectDao.CollectListTargetIDsByUserPage(ctx, CollectTypeOutfit, userID, page, pageSize)
|
|
if err != nil {
|
|
return nil, errcode.DatabaseError
|
|
}
|
|
|
|
items := make([]dto.OutfitGetByUUIDResponse, 0, len(pageIDs))
|
|
for _, oid := range pageIDs {
|
|
outfit, gerr := s.outfitDao.OutfitGetByID(ctx, oid)
|
|
if gerr != nil {
|
|
// 穿搭可能已删除,跳过。
|
|
continue
|
|
}
|
|
// 越权防护(纵深防御):DAO 已按可见性联表过滤,此处再挡一次查询与读取之间
|
|
// 发生的「转私密 / 被封禁 / 审核驳回」变更,避免下发他人非公开穿搭内容。
|
|
if outfit.UserID != userID && !isOutfitPublicVisible(outfit) {
|
|
continue
|
|
}
|
|
outfitItems, uerr := s.itemResponses(ctx, outfit.ID)
|
|
if uerr != nil {
|
|
continue
|
|
}
|
|
items = append(items, dto.OutfitGetByUUIDResponse{
|
|
OutfitCreateResponse: buildOutfitCreateResponse(outfit, outfitItems),
|
|
})
|
|
}
|
|
|
|
return &dto.CollectListResponse{
|
|
PageResponse: dto.PageResponse{Total: total, Page: page, PageSize: pageSize},
|
|
List: items,
|
|
}, nil
|
|
}
|
|
|
|
// GetByTarget 查询穿搭收藏状态与收藏数(Redis 优先,回落 MySQL)。
|
|
func (s *CollectService) GetByTarget(ctx context.Context, userID int64, request *dto.CollectGetByTargetRequest) (*dto.CollectGetByTargetResponse, *errcode.Error) {
|
|
ctx, span := global.Tracer.Start(ctx, "svc.CollectGetByTarget")
|
|
defer span.End()
|
|
|
|
outfit, err := s.outfitDao.OutfitGetByUUID(ctx, request.TargetUUID)
|
|
if err != nil {
|
|
if isNotFound(err) {
|
|
return nil, errcode.TargetNotFound
|
|
}
|
|
slog.ErrorContext(ctx, "collect: query outfit for status failed", "outfit_uuid", request.TargetUUID, "user_id", userID, "error", err)
|
|
return nil, errcode.DatabaseError
|
|
}
|
|
// 非本人仅可查询公开可见穿搭的收藏状态。
|
|
if outfit.UserID != userID && !isOutfitPublicVisible(outfit) {
|
|
return nil, errcode.OutfitNoPermission
|
|
}
|
|
targetID := outfit.ID
|
|
|
|
// 是否已收藏:以 MySQL 为权威。
|
|
// Redis 集合带 TTL,过期后会被后续写入重建为「仅含新成员」,导致 SIsMember/SCard 长期低估,
|
|
// 因此只在 DB 异常时用 Redis 兜底(降级可用性优先于精确性)。
|
|
isCollected := false
|
|
if rec, derr := s.collectDao.CollectGetByTargetAndUser(ctx, CollectTypeOutfit, targetID, userID); derr == nil {
|
|
isCollected = rec.Status == InteractStatusOn
|
|
} else if !isNotFound(derr) {
|
|
slog.WarnContext(ctx, "query collect record failed, fallback to redis",
|
|
"target_id", targetID, "user_id", userID, "error", derr)
|
|
if v, rerr := redisdao.CollectIsMember(ctx, CollectTypeOutfit, targetID, userID); rerr == nil {
|
|
isCollected = v
|
|
} else {
|
|
slog.DebugContext(ctx, "collect: redis fallback read failed",
|
|
"target_id", targetID, "user_id", userID, "error", rerr)
|
|
}
|
|
}
|
|
|
|
// 收藏总数:以 MySQL 为权威(同上,Redis 仅在 DB 异常时兜底)。
|
|
count, derr := s.collectDao.CollectCountByTarget(ctx, CollectTypeOutfit, targetID)
|
|
if derr != nil {
|
|
slog.WarnContext(ctx, "count collect failed, fallback to redis",
|
|
"target_id", targetID, "error", derr)
|
|
if rcnt, rerr := redisdao.CollectCount(ctx, CollectTypeOutfit, targetID); rerr == nil {
|
|
count = rcnt
|
|
} else {
|
|
slog.DebugContext(ctx, "collect: redis fallback count failed", "target_id", targetID, "error", rerr)
|
|
}
|
|
}
|
|
|
|
return &dto.CollectGetByTargetResponse{
|
|
UserIsCollected: isCollected,
|
|
CollectCount: count,
|
|
}, nil
|
|
}
|
|
|
|
// itemResponses 获取某穿搭的衣物明细(含图片 URL)。
|
|
func (s *CollectService) itemResponses(ctx context.Context, outfitID int64) ([]dto.OutfitItemResponse, error) {
|
|
its, err := s.outfitItemDao.OutfitItemListByOutfitID(ctx, outfitID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
ids := make([]int64, 0, len(its))
|
|
for _, it := range its {
|
|
ids = append(ids, it.ClothesID)
|
|
}
|
|
clothesList, err := s.clothesDao.ClothesGetByIDs(ctx, ids)
|
|
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.OutfitItemResponse, 0, len(its))
|
|
for _, it := range its {
|
|
if c, ok := clothesByID[it.ClothesID]; ok {
|
|
resps = append(resps, buildOutfitItemResponse(&c))
|
|
}
|
|
}
|
|
return resps, nil
|
|
}
|