fix:分享

This commit is contained in:
2026-09-13 23:32:22 +08:00
parent 646fd99887
commit 77eec37af1
17 changed files with 903 additions and 28 deletions
+1
View File
@@ -32,6 +32,7 @@ type ClosetShareItemResponse struct {
Status int8 `json:"status"` // 1=正常 2=已取消 3=已过期 4=已封禁
OwnerName string `json:"owner_name"` // 分享者昵称("分享给我的"列表返回)
OwnerAvatar string `json:"owner_avatar"` // 分享者头像("分享给我的"列表返回)
CreatedAt int64 `json:"created_at"` // 分享创建时间(秒,前端合并排序用)
}
// ShareListRequest 我的分享列表
+1
View File
@@ -52,6 +52,7 @@ type ClothesUpdateByUUIDResponse struct{}
type ClothesGetByUUIDRequest struct {
ClothesUUID string `json:"clothes_uuid" binding:"required"` // 衣物UUID
ShareUUID string `json:"share_uuid"` // 可选:通过穿搭分享只读查看时携带(非属主)
}
// ClothesGetByUUIDResponse 衣物信息
+54
View File
@@ -0,0 +1,54 @@
package dto
// OutfitShareCreateRequest 创建穿搭分享
type OutfitShareCreateRequest struct {
OutfitUUID string `json:"outfit_uuid" binding:"required"` // 穿搭UUID
}
// OutfitShareCreateResponse 创建穿搭分享响应
type OutfitShareCreateResponse struct {
ShareUUID string `json:"share_uuid"` // 分享UUID,供他人访问分享详情
}
// OutfitShareDetailResponse 穿搭分享详情(访客 / 属主视角)
type OutfitShareDetailResponse struct {
ShareUUID string `json:"share_uuid"`
OwnerName string `json:"owner_name"`
OwnerAvatar string `json:"owner_avatar"`
Status int8 `json:"status"` // 1=正常 2=已取消
IsOwner bool `json:"is_owner"` // 是否本人分享
Outfit OutfitCreateResponse `json:"outfit"` // 穿搭内容(含 items 的 processed_image)
}
// OutfitShareItemResponse 穿搭分享列表项
type OutfitShareItemResponse struct {
ShareUUID string `json:"share_uuid"`
OutfitUUID string `json:"outfit_uuid"`
OutfitName string `json:"outfit_name"`
CoverImage string `json:"cover_image"` // 封面(首件衣物抠图 URL,无则空)
ItemsCount int `json:"items_count"` // 包含衣物数量
Status int8 `json:"status"` // 1=正常 2=已取消
OwnerName string `json:"owner_name"` // 分享者昵称("分享给我的"列表返回)
OwnerAvatar string `json:"owner_avatar"`
CreatedAt int64 `json:"created_at"` // 分享创建时间(秒)
}
// OutfitShareListRequest 穿搭分享列表请求
type OutfitShareListRequest struct {
PageRequest
Status int8 `form:"status" json:"status"` // 状态 1=正常 2=已取消(0 不筛选)
}
// OutfitShareListResponse 穿搭分享列表响应
type OutfitShareListResponse struct {
PageResponse
List []OutfitShareItemResponse `json:"list"`
}
// OutfitShareCancelRequest 取消穿搭分享
type OutfitShareCancelRequest struct {
ShareUUID string `json:"share_uuid" binding:"required"`
}
// OutfitShareCancelResponse 取消穿搭分享响应
type OutfitShareCancelResponse struct{}
+16
View File
@@ -108,6 +108,22 @@ func (d *OutfitDao) OutfitGetByID(ctx context.Context, id int64) (*mysqlmodel.Ou
return &outfit, nil
}
// OutfitGetByIDs 按主键 ID 批量查询穿搭
func (d *OutfitDao) OutfitGetByIDs(ctx context.Context, ids []int64) ([]mysqlmodel.Outfit, error) {
ctx, span := global.Tracer.Start(ctx, "mysqldao.OutfitGetByIDs")
defer span.End()
var list []mysqlmodel.Outfit
if len(ids) == 0 {
return list, nil
}
if err := d.db.WithContext(ctx).Where("id IN ?", ids).Find(&list).Error; err != nil {
span.RecordError(err)
return nil, err
}
return list, nil
}
// OutfitGetByUUIDs 按 UUID 批量查询穿搭
func (d *OutfitDao) OutfitGetByUUIDs(ctx context.Context, uuids []string) ([]mysqlmodel.Outfit, error) {
ctx, span := global.Tracer.Start(ctx, "mysqldao.OutfitGetByUUIDs")
+137
View File
@@ -0,0 +1,137 @@
package mysqldao
import (
"context"
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/global"
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqlmodel"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// OutfitShareDao 穿搭分享数据访问
type OutfitShareDao struct {
db *gorm.DB
}
// NewOutfitShareDao 创建穿搭分享 DAO
func NewOutfitShareDao() *OutfitShareDao {
return &OutfitShareDao{db: global.MysqlEngine}
}
// OutfitShareCreate 创建穿搭分享
func (d *OutfitShareDao) OutfitShareCreate(ctx context.Context, share *mysqlmodel.OutfitShare) error {
ctx, span := global.Tracer.Start(ctx, "mysqldao.OutfitShareCreate")
defer span.End()
if err := d.db.WithContext(ctx).Create(share).Error; err != nil {
span.RecordError(err)
return err
}
return nil
}
// OutfitShareGetByUUID 按 UUID 查询穿搭分享
func (d *OutfitShareDao) OutfitShareGetByUUID(ctx context.Context, shareUUID string) (*mysqlmodel.OutfitShare, error) {
ctx, span := global.Tracer.Start(ctx, "mysqldao.OutfitShareGetByUUID")
defer span.End()
var share mysqlmodel.OutfitShare
if err := d.db.WithContext(ctx).Where("share_uuid = ?", shareUUID).First(&share).Error; err != nil {
span.RecordError(err)
return nil, err
}
return &share, nil
}
// OutfitShareUpdateStatus 更新穿搭分享状态(取消)
func (d *OutfitShareDao) OutfitShareUpdateStatus(ctx context.Context, shareUUID string, status int8) error {
ctx, span := global.Tracer.Start(ctx, "mysqldao.OutfitShareUpdateStatus")
defer span.End()
if err := d.db.WithContext(ctx).Model(&mysqlmodel.OutfitShare{}).
Where("share_uuid = ?", shareUUID).
UpdateColumn("status", status).Error; err != nil {
span.RecordError(err)
return err
}
return nil
}
// OutfitShareListByUserID 分页查询我的穿搭分享(status 为 0 不筛选)
func (d *OutfitShareDao) OutfitShareListByUserID(ctx context.Context, userID int64, status int8, page, pageSize int) ([]mysqlmodel.OutfitShare, int64, error) {
ctx, span := global.Tracer.Start(ctx, "mysqldao.OutfitShareListByUserID")
defer span.End()
var list []mysqlmodel.OutfitShare
var total int64
q := d.db.WithContext(ctx).Model(&mysqlmodel.OutfitShare{}).Where("user_id = ?", userID)
if status != 0 {
q = q.Where("status = ?", status)
}
if err := q.Count(&total).Error; err != nil {
span.RecordError(err)
return nil, 0, err
}
if err := q.Offset((page - 1) * pageSize).Limit(pageSize).Order("id DESC").Find(&list).Error; err != nil {
span.RecordError(err)
return nil, 0, err
}
return list, total, nil
}
// OutfitShareReceiverDao 穿搭分享接收人数据访问
type OutfitShareReceiverDao struct {
db *gorm.DB
}
// NewOutfitShareReceiverDao 创建穿搭分享接收人 DAO
func NewOutfitShareReceiverDao() *OutfitShareReceiverDao {
return &OutfitShareReceiverDao{db: global.MysqlEngine}
}
// OutfitShareReceiverUpsert 幂等记录接收关系:唯一索引冲突时不做任何事,避免重复记录。
func (d *OutfitShareReceiverDao) OutfitShareReceiverUpsert(ctx context.Context, shareUUID string, receiverUserID int64) error {
ctx, span := global.Tracer.Start(ctx, "mysqldao.OutfitShareReceiverUpsert")
defer span.End()
receiver := &mysqlmodel.OutfitShareReceiver{
ShareUUID: shareUUID,
ReceiverUserID: receiverUserID,
}
if err := d.db.WithContext(ctx).Clauses(clause.OnConflict{DoNothing: true}).Create(receiver).Error; err != nil {
span.RecordError(err)
return err
}
return nil
}
// OutfitShareReceivedList 联表分页查询某接收人收到的穿搭分享(status 为 0 不筛选)。
func (d *OutfitShareReceiverDao) OutfitShareReceivedList(ctx context.Context, receiverUserID int64, status int8, page, pageSize int) ([]mysqlmodel.OutfitShare, int64, error) {
ctx, span := global.Tracer.Start(ctx, "mysqldao.OutfitShareReceivedList")
defer span.End()
var list []mysqlmodel.OutfitShare
var total int64
q := d.db.WithContext(ctx).
Model(&mysqlmodel.OutfitShare{}).
Joins("JOIN outfit_share_receivers r ON r.share_uuid = outfit_shares.share_uuid").
Where("r.receiver_user_id = ?", receiverUserID)
if status != 0 {
q = q.Where("outfit_shares.status = ?", status)
}
if err := q.Count(&total).Error; err != nil {
span.RecordError(err)
return nil, 0, err
}
if err := q.Select("outfit_shares.*").
Order("r.id DESC").
Offset((page - 1) * pageSize).
Limit(pageSize).
Find(&list).Error; err != nil {
span.RecordError(err)
return nil, 0, err
}
return list, total, nil
}
+25
View File
@@ -0,0 +1,25 @@
package mysqlmodel
// OutfitShare 穿搭分享。
type OutfitShare struct {
Model
ShareUUID string `gorm:"column:share_uuid"` // 分享UUID
UserID int64 `gorm:"column:user_id"` // 所属用户ID
OutfitID int64 `gorm:"column:outfit_id"` // 穿搭ID
Status int8 `gorm:"column:status"` // 分享状态 1=正常 2=用户取消
}
func (OutfitShare) TableName() string {
return "outfit_shares"
}
// OutfitShareReceiver 穿搭分享接收人关联(访问即视为收到,幂等)。
type OutfitShareReceiver struct {
Model
ShareUUID string `gorm:"column:share_uuid;uniqueIndex:uk_outfit_share_receiver,priority:1"` // 分享UUID
ReceiverUserID int64 `gorm:"column:receiver_user_id;uniqueIndex:uk_outfit_share_receiver,priority:2"` // 接收人用户ID
}
func (OutfitShareReceiver) TableName() string {
return "outfit_share_receivers"
}
+1 -1
View File
@@ -76,7 +76,7 @@ func (h *ClothesHandler) Get(c *gin.Context) {
response.ToErrorResponse(errcode.Unauthorized)
return
}
request := dto.ClothesGetByUUIDRequest{ClothesUUID: c.Param("uuid")}
request := dto.ClothesGetByUUIDRequest{ClothesUUID: c.Param("uuid"), ShareUUID: c.Query("share_uuid")}
if request.ClothesUUID == "" {
response.ToErrorResponse(errcode.InvalidParams)
return
+125
View File
@@ -0,0 +1,125 @@
package v1
import (
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/dto"
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/middleware"
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/service"
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/app"
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/errcode"
"github.com/gin-gonic/gin"
)
// OutfitShareHandler 穿搭分享相关接口。
type OutfitShareHandler struct {
svc *service.OutfitShareService
}
// NewOutfitShareHandler 创建穿搭分享 handler。
func NewOutfitShareHandler() *OutfitShareHandler {
return &OutfitShareHandler{svc: service.NewOutfitShareService()}
}
// Create 创建穿搭分享。
func (h *OutfitShareHandler) Create(c *gin.Context) {
response := app.NewResponse(c)
userID, ok := middleware.GetUserID(c.Request.Context())
if !ok {
response.ToErrorResponse(errcode.Unauthorized)
return
}
var request dto.OutfitShareCreateRequest
if err := c.ShouldBindJSON(&request); err != nil {
response.ToErrorResponse(errcode.InvalidParams.WithDetails(err.Error()))
return
}
out, e := h.svc.Create(c.Request.Context(), userID, &request)
if e != nil {
response.ToErrorResponse(e)
return
}
response.ToResponse(out)
}
// List 分页查询我的穿搭分享。
func (h *OutfitShareHandler) List(c *gin.Context) {
response := app.NewResponse(c)
userID, ok := middleware.GetUserID(c.Request.Context())
if !ok {
response.ToErrorResponse(errcode.Unauthorized)
return
}
var request dto.OutfitShareListRequest
if err := c.ShouldBindQuery(&request); err != nil {
response.ToErrorResponse(errcode.InvalidParams.WithDetails(err.Error()))
return
}
out, e := h.svc.List(c.Request.Context(), userID, &request)
if e != nil {
response.ToErrorResponse(e)
return
}
response.ToResponse(out)
}
// ReceivedList 分页查询分享给我的穿搭分享。
func (h *OutfitShareHandler) ReceivedList(c *gin.Context) {
response := app.NewResponse(c)
userID, ok := middleware.GetUserID(c.Request.Context())
if !ok {
response.ToErrorResponse(errcode.Unauthorized)
return
}
var request dto.OutfitShareListRequest
if err := c.ShouldBindQuery(&request); err != nil {
response.ToErrorResponse(errcode.InvalidParams.WithDetails(err.Error()))
return
}
out, e := h.svc.ListReceived(c.Request.Context(), userID, &request)
if e != nil {
response.ToErrorResponse(e)
return
}
response.ToResponse(out)
}
// Detail 查看穿搭分享详情(uuid 来自路径参数)。
func (h *OutfitShareHandler) Detail(c *gin.Context) {
response := app.NewResponse(c)
userID, ok := middleware.GetUserID(c.Request.Context())
if !ok {
response.ToErrorResponse(errcode.Unauthorized)
return
}
shareUUID := c.Param("uuid")
if shareUUID == "" {
response.ToErrorResponse(errcode.InvalidParams)
return
}
out, e := h.svc.Detail(c.Request.Context(), userID, shareUUID)
if e != nil {
response.ToErrorResponse(e)
return
}
response.ToResponse(out)
}
// Cancel 取消穿搭分享(uuid 来自路径参数)。
func (h *OutfitShareHandler) Cancel(c *gin.Context) {
response := app.NewResponse(c)
userID, ok := middleware.GetUserID(c.Request.Context())
if !ok {
response.ToErrorResponse(errcode.Unauthorized)
return
}
request := dto.OutfitShareCancelRequest{ShareUUID: c.Param("uuid")}
if request.ShareUUID == "" {
response.ToErrorResponse(errcode.InvalidParams)
return
}
out, e := h.svc.Cancel(c.Request.Context(), userID, &request)
if e != nil {
response.ToErrorResponse(e)
return
}
response.ToResponse(out)
}
+8
View File
@@ -33,6 +33,7 @@ func NewRouter() *gin.Engine {
clothesHandler := v1.NewClothesHandler()
outfitHandler := v1.NewOutfitHandler()
closetHandler := v1.NewClosetShareHandler()
outfitShareHandler := v1.NewOutfitShareHandler()
collectHandler := v1.NewCollectHandler()
likeHandler := v1.NewLikeHandler()
notificationHandler := v1.NewNotificationHandler()
@@ -99,6 +100,13 @@ func NewRouter() *gin.Engine {
auth.GET("/closet-share/:uuid", closetHandler.Detail)
auth.POST("/closet-share/:uuid/cancel", closetHandler.Cancel)
// 穿搭分享
auth.POST("/outfit-share", outfitShareHandler.Create)
auth.GET("/outfit-share", outfitShareHandler.List)
auth.GET("/outfit-share/received", outfitShareHandler.ReceivedList)
auth.GET("/outfit-share/:uuid", outfitShareHandler.Detail)
auth.POST("/outfit-share/:uuid/cancel", outfitShareHandler.Cancel)
// 收藏
auth.POST("/collect", collectHandler.Collect)
auth.GET("/collect", collectHandler.List)
+1
View File
@@ -278,6 +278,7 @@ func (s *ClosetShareService) ListReceived(ctx context.Context, userID int64, req
Status: sh.Status,
OwnerName: ownerName,
OwnerAvatar: ownerAvatar,
CreatedAt: sh.CreatedAt,
})
}
+61 -7
View File
@@ -27,15 +27,21 @@ var imageProcessSem = make(chan struct{}, 10)
// ClothesService 衣物业务服务。
type ClothesService struct {
clothesDao *mysqldao.ClothesDao
userDao *mysqldao.UserDao
clothesDao *mysqldao.ClothesDao
userDao *mysqldao.UserDao
outfitShareDao *mysqldao.OutfitShareDao
outfitDao *mysqldao.OutfitDao
outfitItemDao *mysqldao.OutfitItemDao
}
// NewClothesService 创建衣物服务。
func NewClothesService() *ClothesService {
return &ClothesService{
clothesDao: mysqldao.NewClothesDao(),
userDao: mysqldao.NewUserDao(),
clothesDao: mysqldao.NewClothesDao(),
userDao: mysqldao.NewUserDao(),
outfitShareDao: mysqldao.NewOutfitShareDao(),
outfitDao: mysqldao.NewOutfitDao(),
outfitItemDao: mysqldao.NewOutfitItemDao(),
}
}
@@ -300,18 +306,66 @@ func (s *ClothesService) ProcessStatusGetByUUID(ctx context.Context, userID int6
}
// GetByUUID 获取衣物详情。
// 属主始终可见;非属主仅允许携带有效穿搭分享(share_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()
clothes, cerr := s.getOwnedClothes(ctx, request.ClothesUUID, userID)
if cerr != nil {
return nil, cerr
clothes, err := s.clothesDao.ClothesGetByUUID(ctx, request.ClothesUUID)
if err != nil {
if isNotFound(err) {
return nil, errcode.ClothesNotFound
}
return nil, errcode.DatabaseError
}
if clothes.UserID != userID {
if cerr := s.assertClothesVisibleViaOutfitShare(ctx, userID, request.ShareUUID, clothes.ID); cerr != nil {
return nil, cerr
}
}
response := buildClothesResponse(clothes)
return &response, nil
}
// assertClothesVisibleViaOutfitShare 校验非属主可通过某穿搭分享只读查看该衣物。
func (s *ClothesService) assertClothesVisibleViaOutfitShare(ctx context.Context, userID int64, shareUUID string, clothesID int64) *errcode.Error {
if shareUUID == "" {
return errcode.ClothesNoPermission
}
share, err := s.outfitShareDao.OutfitShareGetByUUID(ctx, shareUUID)
if err != nil {
if isNotFound(err) {
return errcode.OutfitShareNotFound
}
return errcode.DatabaseError
}
if share.Status == ShareStatusCancelled {
return errcode.OutfitShareCancelled
}
outfit, oerr := s.outfitDao.OutfitGetByID(ctx, share.OutfitID)
if oerr != nil {
if isNotFound(oerr) {
return errcode.OutfitShareNotFound
}
return errcode.DatabaseError
}
// 访客查看要求穿搭仍公开可见(属主访问自己的分享始终可查看)。
if share.UserID != userID && !isOutfitPublicVisible(outfit) {
return errcode.OutfitShareNotFound
}
// 该衣物必须属于该分享的穿搭明细。
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
}
// UpdateByUUID 修改衣物属性(仅更新非空字段)。
func (s *ClothesService) UpdateByUUID(ctx context.Context, userID int64, request *dto.ClothesUpdateByUUIDRequest) (*dto.ClothesUpdateByUUIDResponse, *errcode.Error) {
ctx, span := global.Tracer.Start(ctx, "svc.ClothesUpdateByUUID")
+17 -8
View File
@@ -325,7 +325,12 @@ func (s *OutfitService) createItems(ctx context.Context, outfitID int64, clothes
// itemResponses 获取某穿搭的衣物明细(含图片 URL)。
func (s *OutfitService) itemResponses(ctx context.Context, outfitID int64) ([]dto.OutfitItemResponse, error) {
items, err := s.outfitItemDao.OutfitItemListByOutfitID(ctx, outfitID)
return loadOutfitItemResponses(ctx, s.outfitItemDao, s.clothesDao, outfitID)
}
// loadOutfitItemResponses 获取某穿搭的衣物明细(含图片 URL),供穿搭详情与穿搭分享复用。
func loadOutfitItemResponses(ctx context.Context, outfitItemDao *mysqldao.OutfitItemDao, clothesDao *mysqldao.ClothesDao, outfitID int64) ([]dto.OutfitItemResponse, error) {
items, err := outfitItemDao.OutfitItemListByOutfitID(ctx, outfitID)
if err != nil {
return nil, err
}
@@ -333,13 +338,13 @@ func (s *OutfitService) itemResponses(ctx context.Context, outfitID int64) ([]dt
for _, it := range items {
ids = append(ids, it.ClothesID)
}
clothesList, err := s.clothesDao.ClothesGetByIDs(ctx, ids)
clothesList, err := 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
for i := range clothesList {
clothesByID[clothesList[i].ID] = clothesList[i]
}
resps := make([]dto.OutfitItemResponse, 0, len(items))
for _, it := range items {
@@ -402,11 +407,15 @@ func (s *OutfitService) buildOutfitListResponses(ctx context.Context, list []mys
// buildOutfitItemResponse 组装穿搭明细(processed_image 转为可访问 URL)。
func buildOutfitItemResponse(c *mysqlmodel.Clothes) dto.OutfitItemResponse {
img := c.ProcessedImage
if img != "" {
img = upload.GetCosUrl(img)
return dto.OutfitItemResponse{ClothesUUID: c.ClothesUUID, ProcessedImage: clothesImageURL(c.ProcessedImage)}
}
// clothesImageURL 将衣物抠图相对路径转为可访问 URL(空则返回空)。
func clothesImageURL(processed string) string {
if processed == "" {
return ""
}
return dto.OutfitItemResponse{ClothesUUID: c.ClothesUUID, ProcessedImage: img}
return upload.GetCosUrl(processed)
}
// buildOutfitCreateResponse 组装穿搭响应。
+272
View File
@@ -0,0 +1,272 @@
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
}