Files
2026-09-16 00:23:49 +08:00

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
}