diff --git a/internal/dto/clothes.go b/internal/dto/clothes.go index 37301bf..274b4e2 100644 --- a/internal/dto/clothes.go +++ b/internal/dto/clothes.go @@ -53,6 +53,7 @@ type ClothesUpdateByUUIDResponse struct{} type ClothesGetByUUIDRequest struct { ClothesUUID string `json:"clothes_uuid" binding:"required"` // 衣物UUID ShareUUID string `json:"share_uuid"` // 可选:通过穿搭分享只读查看时携带(非属主) + OutfitUUID string `json:"outfit_uuid"` // 可选:通过社区公开穿搭只读查看时携带(非属主) } // ClothesGetByUUIDResponse 衣物信息 diff --git a/internal/routers/api/v1/clothes.go b/internal/routers/api/v1/clothes.go index a239319..3786323 100644 --- a/internal/routers/api/v1/clothes.go +++ b/internal/routers/api/v1/clothes.go @@ -76,7 +76,12 @@ func (h *ClothesHandler) Get(c *gin.Context) { response.ToErrorResponse(errcode.Unauthorized) return } - request := dto.ClothesGetByUUIDRequest{ClothesUUID: c.Param("uuid"), ShareUUID: c.Query("share_uuid")} + request := dto.ClothesGetByUUIDRequest{ + ClothesUUID: c.Param("uuid"), + // 非属主只读查看的两个入口:穿搭分享(share_uuid)与社区公开穿搭(outfit_uuid) + ShareUUID: c.Query("share_uuid"), + OutfitUUID: c.Query("outfit_uuid"), + } if request.ClothesUUID == "" { response.ToErrorResponse(errcode.InvalidParams) return diff --git a/internal/service/clothes.go b/internal/service/clothes.go index 4624e47..54dc107 100644 --- a/internal/service/clothes.go +++ b/internal/service/clothes.go @@ -391,7 +391,8 @@ func (s *ClothesService) ProcessStatusGetByUUID(ctx context.Context, userID int6 } // GetByUUID 获取衣物详情。 -// 属主始终可见;非属主仅允许携带有效穿搭分享(share_uuid)时,只读查看该分享穿搭内的衣物。 +// 属主始终可见;非属主只有两个只读入口: +// 携带有效穿搭分享(share_uuid)查看该分享穿搭内的衣物,或携带社区公开穿搭(outfit_uuid)查看已公开可见穿搭内的衣物。 func (s *ClothesService) GetByUUID(ctx context.Context, userID int64, request *dto.ClothesGetByUUIDRequest) (*dto.ClothesGetByUUIDResponse, *errcode.Error) { ctx, span := global.Tracer.Start(ctx, "svc.ClothesGetByUUID") defer span.End() @@ -404,18 +405,61 @@ func (s *ClothesService) GetByUUID(ctx context.Context, userID int64, request *d return nil, errcode.DatabaseError } if clothes.UserID != userID { - if cerr := s.assertClothesVisibleViaOutfitShare(ctx, userID, request.ShareUUID, clothes.ID); cerr != nil { + if cerr := s.assertClothesVisibleToVisitor(ctx, userID, request, clothes.ID); cerr != nil { return nil, cerr } } response := buildClothesResponse(clothes) - // 非属主只读查看(穿搭分享):不暴露内部失败原因 + // 非属主只读查看:不暴露内部失败原因;社区(未携带 share_uuid 的公开穿搭入口) + // 是陌生人可见场景,属主私有备注同样不下发。 if clothes.UserID != userID { response.FailReason = "" + if request.ShareUUID == "" { + response.Remark = "" + } } return &response, nil } +// assertClothesVisibleToVisitor 校验非属主可以只读查看该衣物。 +// 两个入口互斥:优先穿搭分享,其次社区公开穿搭;都未携带则直接拒绝。 +func (s *ClothesService) assertClothesVisibleToVisitor(ctx context.Context, userID int64, request *dto.ClothesGetByUUIDRequest, clothesID int64) *errcode.Error { + if request.ShareUUID != "" { + return s.assertClothesVisibleViaOutfitShare(ctx, userID, request.ShareUUID, clothesID) + } + if request.OutfitUUID != "" { + return s.assertClothesVisibleViaPublicOutfit(ctx, request.OutfitUUID, clothesID) + } + return errcode.ClothesNoPermission +} + +// assertClothesVisibleViaPublicOutfit 校验非属主可通过社区公开穿搭只读查看该衣物。 +// 可见性口径与 OutfitService.GetByUUID 的非属主分支保持一致(公开 + 审核通过 + 正常)。 +func (s *ClothesService) assertClothesVisibleViaPublicOutfit(ctx context.Context, outfitUUID string, clothesID int64) *errcode.Error { + outfit, err := s.outfitDao.OutfitGetByUUID(ctx, outfitUUID) + if err != nil { + if isNotFound(err) { + // 不区分「穿搭不存在」与「穿搭不可见」,统一按无权限返回,避免探测他人穿搭 + return errcode.ClothesNoPermission + } + return errcode.DatabaseError + } + if !isOutfitPublicVisible(outfit) { + return errcode.ClothesNoPermission + } + // 该衣物必须属于该公开穿搭的明细。 + items, ierr := s.outfitItemDao.OutfitItemListByOutfitID(ctx, outfit.ID) + if ierr != nil { + return errcode.DatabaseError + } + for _, it := range items { + if it.ClothesID == clothesID { + return nil + } + } + return errcode.ClothesNoPermission +} + // assertClothesVisibleViaOutfitShare 校验非属主可通过某穿搭分享只读查看该衣物。 func (s *ClothesService) assertClothesVisibleViaOutfitShare(ctx context.Context, userID int64, shareUUID string, clothesID int64) *errcode.Error { if shareUUID == "" { diff --git a/internal/service/like.go b/internal/service/like.go index 15c7755..15b737a 100644 --- a/internal/service/like.go +++ b/internal/service/like.go @@ -64,9 +64,10 @@ func (s *LikeService) LikeCreate(ctx context.Context, userID int64, request *dto "target_id", targetID, "user_id", userID, "error", rerr) } - // 仅在「从取消/无记录变为点赞」时发通知,避免并发/重复点赞重复通知。 - wasLiked := !notExist && existing.Status == InteractStatusOn - if !wasLiked && outfit.UserID != userID { + // 仅在「首次点赞」时发通知(likes 表此前无记录)。 + // 取消点赞只把 status 置为已取消、不删行,因此 notExist 可稳定表达 + // 「此前从未赞过该穿搭」;取消后再点赞不再重复通知,避免频繁点赞/取消把拥有者刷屏。 + if notExist && outfit.UserID != userID { sendNotification(ctx, outfit.UserID, userID, NotificationTypeLike, "收到新的点赞", "你的穿搭「"+outfit.OutfitName+"」收到了一个赞") } diff --git a/接口文档.md b/接口文档.md index 3ce1835..101a149 100644 --- a/接口文档.md +++ b/接口文档.md @@ -817,15 +817,19 @@ Authorization: Bearer | --- | --- | --- | --- | | uuid | string | ✅ | 衣物 UUID | | share_uuid | string | 否 | 穿搭分享 UUID:非属主通过穿搭分享只读查看该分享穿搭内的衣物时必传 | +| outfit_uuid | string | 否 | 穿搭 UUID:非属主通过**社区公开穿搭**只读查看该穿搭内的衣物时必传 | **响应** `data` = [衣物对象](#42-衣物对象-clothesitem) **业务逻辑(可见性)** - 属主:始终可见。 -- 非属主:**仅当携带有效 `share_uuid`**(该分享正常、穿搭未被删除 / 封禁、且该衣物属于该分享的穿搭明细)时返回**只读**衣物信息;否则 `50005`。 +- 非属主:两个只读入口,**二选一携带即可**,都不携带则 `50005`。 + 1. `share_uuid`:该分享正常、穿搭未被删除 / 封禁、且该衣物属于该分享的穿搭明细 → 返回**只读**衣物信息。 + 2. `outfit_uuid`:该穿搭**公开可见**(`is_public=2` 且 `audit_status=2` 且 `status=1`,与 `GET /outfit/:uuid` 非属主口径一致)且该衣物属于该穿搭明细 → 返回**只读**衣物信息。 +- 只读场景下响应不含失败原因(`fail_reason` 恒为空);通过 `outfit_uuid`(社区公开穿搭)进入时,属主**私有备注 `remark` 也不下发**,避免暴露给陌生人(`share_uuid` 好友分享场景保持原样)。 -**错误码**:`10000` 参数错误(uuid 为空)· `50000` 衣物不存在 · `50005` 无权操作该衣物 · `90103` 穿搭分享不存在(含穿搭已删除 / 已封禁)· `90104` 穿搭分享已取消 +**错误码**:`10000` 参数错误(uuid 为空)· `50000` 衣物不存在 · `50005` 无权操作该衣物(含未携带凭证 / 穿搭不可见 / 衣物不属于该穿搭)· `90103` 穿搭分享不存在(含穿搭已删除 / 已封禁)· `90104` 穿搭分享已取消 ---