diff --git a/internal/dto/closet.go b/internal/dto/closet.go index 46c41ab..39b28a6 100644 --- a/internal/dto/closet.go +++ b/internal/dto/closet.go @@ -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 我的分享列表 diff --git a/internal/dto/clothes.go b/internal/dto/clothes.go index 1da82a4..0f7fa9a 100644 --- a/internal/dto/clothes.go +++ b/internal/dto/clothes.go @@ -52,6 +52,7 @@ type ClothesUpdateByUUIDResponse struct{} type ClothesGetByUUIDRequest struct { ClothesUUID string `json:"clothes_uuid" binding:"required"` // 衣物UUID + ShareUUID string `json:"share_uuid"` // 可选:通过穿搭分享只读查看时携带(非属主) } // ClothesGetByUUIDResponse 衣物信息 diff --git a/internal/dto/outfit_share.go b/internal/dto/outfit_share.go new file mode 100644 index 0000000..e4af5da --- /dev/null +++ b/internal/dto/outfit_share.go @@ -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{} diff --git a/internal/mysqldao/outfit.go b/internal/mysqldao/outfit.go index ea968cd..dc7c34e 100644 --- a/internal/mysqldao/outfit.go +++ b/internal/mysqldao/outfit.go @@ -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") diff --git a/internal/mysqldao/outfit_share.go b/internal/mysqldao/outfit_share.go new file mode 100644 index 0000000..5af36c1 --- /dev/null +++ b/internal/mysqldao/outfit_share.go @@ -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 +} diff --git a/internal/mysqlmodel/outfit_share.go b/internal/mysqlmodel/outfit_share.go new file mode 100644 index 0000000..e00aede --- /dev/null +++ b/internal/mysqlmodel/outfit_share.go @@ -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" +} diff --git a/internal/routers/api/v1/clothes.go b/internal/routers/api/v1/clothes.go index 3f690b8..a239319 100644 --- a/internal/routers/api/v1/clothes.go +++ b/internal/routers/api/v1/clothes.go @@ -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 diff --git a/internal/routers/api/v1/outfit_share.go b/internal/routers/api/v1/outfit_share.go new file mode 100644 index 0000000..596d348 --- /dev/null +++ b/internal/routers/api/v1/outfit_share.go @@ -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) +} diff --git a/internal/routers/router.go b/internal/routers/router.go index e53fa5c..2ba6a5b 100644 --- a/internal/routers/router.go +++ b/internal/routers/router.go @@ -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) diff --git a/internal/service/closet.go b/internal/service/closet.go index 1c80af1..1ad2320 100644 --- a/internal/service/closet.go +++ b/internal/service/closet.go @@ -278,6 +278,7 @@ func (s *ClosetShareService) ListReceived(ctx context.Context, userID int64, req Status: sh.Status, OwnerName: ownerName, OwnerAvatar: ownerAvatar, + CreatedAt: sh.CreatedAt, }) } diff --git a/internal/service/clothes.go b/internal/service/clothes.go index 3152944..f7c13b9 100644 --- a/internal/service/clothes.go +++ b/internal/service/clothes.go @@ -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") diff --git a/internal/service/outfit.go b/internal/service/outfit.go index 945f15f..2c42ba7 100644 --- a/internal/service/outfit.go +++ b/internal/service/outfit.go @@ -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 组装穿搭响应。 diff --git a/internal/service/outfit_share.go b/internal/service/outfit_share.go new file mode 100644 index 0000000..26de9fc --- /dev/null +++ b/internal/service/outfit_share.go @@ -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 +} diff --git a/pkg/errcode/misc_code.go b/pkg/errcode/misc_code.go index cf00bc4..fe222ac 100644 --- a/pkg/errcode/misc_code.go +++ b/pkg/errcode/misc_code.go @@ -10,6 +10,10 @@ var ( ShareExpired = NewError(90101, "分享已过期") ShareCancelled = NewError(90102, "分享已取消") + // 穿搭分享(90103 - 90109) + OutfitShareNotFound = NewError(90103, "穿搭分享不存在") + OutfitShareCancelled = NewError(90104, "穿搭分享已取消") + // 点赞/收藏/社区(90200 - 90299) TargetNotFound = NewError(90200, "目标内容不存在") LikeFailed = NewError(90201, "点赞操作失败") diff --git a/pkg/migration/000019_create_outfit_shares.down.sql b/pkg/migration/000019_create_outfit_shares.down.sql new file mode 100644 index 0000000..498568e --- /dev/null +++ b/pkg/migration/000019_create_outfit_shares.down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS `outfit_share_receivers`; +DROP TABLE IF EXISTS `outfit_shares`; diff --git a/pkg/migration/000019_create_outfit_shares.up.sql b/pkg/migration/000019_create_outfit_shares.up.sql new file mode 100644 index 0000000..e5d97f3 --- /dev/null +++ b/pkg/migration/000019_create_outfit_shares.up.sql @@ -0,0 +1,31 @@ +CREATE TABLE `outfit_shares` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `created_at` BIGINT NOT NULL DEFAULT 0 COMMENT '创建时间', + `updated_at` BIGINT NOT NULL DEFAULT 0 COMMENT '更新时间', + `deleted_at` BIGINT NOT NULL DEFAULT 0 COMMENT '删除时间(0 表示未删除)', + `created_by` BIGINT NOT NULL DEFAULT 0 COMMENT '创建人', + `updated_by` BIGINT NOT NULL DEFAULT 0 COMMENT '更新人', + `deleted_by` BIGINT NOT NULL DEFAULT 0 COMMENT '删除人', + `share_uuid` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '分享UUID', + `user_id` BIGINT NOT NULL DEFAULT 0 COMMENT '所属用户ID', + `outfit_id` BIGINT NOT NULL DEFAULT 0 COMMENT '穿搭ID', + `status` TINYINT NOT NULL DEFAULT 0 COMMENT '分享状态 1=正常 2=用户取消', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_outfit_shares_uuid` (`share_uuid`), + KEY `idx_outfit_shares_user_status` (`user_id`, `status`) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '穿搭分享表'; + +CREATE TABLE `outfit_share_receivers` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `created_at` BIGINT NOT NULL DEFAULT 0 COMMENT '创建时间', + `updated_at` BIGINT NOT NULL DEFAULT 0 COMMENT '更新时间', + `deleted_at` BIGINT NOT NULL DEFAULT 0 COMMENT '删除时间(0 表示未删除)', + `created_by` BIGINT NOT NULL DEFAULT 0 COMMENT '创建人', + `updated_by` BIGINT NOT NULL DEFAULT 0 COMMENT '更新人', + `deleted_by` BIGINT NOT NULL DEFAULT 0 COMMENT '删除人', + `share_uuid` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '分享UUID', + `receiver_user_id` BIGINT NOT NULL DEFAULT 0 COMMENT '接收人用户ID', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_outfit_share_receiver` (`share_uuid`, `receiver_user_id`), + KEY `idx_outfit_share_receivers_user` (`receiver_user_id`) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '穿搭分享接收人关联表'; diff --git a/接口文档.md b/接口文档.md index 038dd02..1b81cd7 100644 --- a/接口文档.md +++ b/接口文档.md @@ -21,7 +21,7 @@ - [2.6 幂等与重试](#26-幂等与重试) - [2.7 需要轮询的场景](#27-需要轮询的场景) - [2.8 CORS 与链路追踪](#28-cors-与链路追踪) -- [3. 接口总表(43 个)](#3-接口总表43-个) +- [3. 接口总表(48 个)](#3-接口总表48-个) - [4. 公共数据结构](#4-公共数据结构) - [5. 接口详解](#5-接口详解) - [5.1 用户模块](#51-用户模块) @@ -31,10 +31,11 @@ - [5.5 穿搭模块](#55-穿搭模块) - [5.6 AI 推荐模块](#56-ai-推荐模块) - [5.7 衣柜分享模块](#57-衣柜分享模块) - - [5.8 收藏与点赞模块](#58-收藏与点赞模块) - - [5.9 通知模块](#59-通知模块) - - [5.10 商品模块](#510-商品模块) - - [5.11 订单与退款模块](#511-订单与退款模块) + - [5.8 穿搭分享模块](#58-穿搭分享模块) + - [5.9 收藏与点赞模块](#59-收藏与点赞模块) + - [5.10 通知模块](#510-通知模块) + - [5.11 商品模块](#511-商品模块) + - [5.12 订单与退款模块](#512-订单与退款模块) - [6. 业务逻辑与状态机](#6-业务逻辑与状态机) - [7. 核心调用时序图](#7-核心调用时序图) - [8. 错误码全表](#8-错误码全表) @@ -239,7 +240,7 @@ Authorization: Bearer --- -## 3. 接口总表(43 个) +## 3. 接口总表(48 个) ### 健康检查(1) @@ -310,6 +311,16 @@ Authorization: Bearer | GET | `/closet-share/:uuid` | 分享详情(属主 / 访客视角) | | POST | `/closet-share/:uuid/cancel` | 取消分享 | +**穿搭分享(5)** + +| 方法 | 路径 | 说明 | +| --- | --- | --- | +| POST | `/outfit-share` | 创建穿搭分享(不扣额度,需穿搭已公开) | +| GET | `/outfit-share` | 我的穿搭分享列表 | +| GET | `/outfit-share/received` | 分享给我的穿搭分享 | +| GET | `/outfit-share/:uuid` | 穿搭分享详情(属主 / 访客视角) | +| POST | `/outfit-share/:uuid/cancel` | 取消穿搭分享 | + **收藏 / 点赞(5)** | 方法 | 路径 | 说明 | @@ -771,15 +782,21 @@ Authorization: Bearer #### GET `/clothes/:uuid` — 衣物详情(鉴权) -**请求**(path) +**请求**(path + query) | 参数 | 类型 | 必填 | 说明 | | --- | --- | --- | --- | | uuid | string | ✅ | 衣物 UUID | +| share_uuid | string | 否 | 穿搭分享 UUID:非属主通过穿搭分享只读查看该分享穿搭内的衣物时必传 | **响应** `data` = [衣物对象](#42-衣物对象-clothesitem) -**错误码**:`10000` 参数错误(uuid 为空)· `50000` 衣物不存在 · `50005` 无权操作该衣物 +**业务逻辑(可见性)** + +- 属主:始终可见。 +- 非属主:**仅当携带有效 `share_uuid`**(该分享正常、穿搭仍公开可见、且该衣物属于该分享的穿搭明细)时返回**只读**衣物信息;否则 `50005`。 + +**错误码**:`10000` 参数错误(uuid 为空)· `50000` 衣物不存在 · `50005` 无权操作该衣物 · `90103` 穿搭分享不存在(含穿搭已删除 / 取消公开)· `90104` 穿搭分享已取消 --- @@ -1125,6 +1142,7 @@ Authorization: Bearer | status | int | `1`=正常 `2`=已取消 `3`=已过期 `4`=已封禁 | | owner_name | string | 分享者昵称(本接口暂不返回,恒为空) | | owner_avatar | string | 分享者头像(本接口暂不返回,恒为空) | +| created_at | int64 | 分享创建时间(秒,前端合并排序用) | > ⚠️ **过期是"惰性"的**:`expire_time` 到了但没人访问时,列表里 `status` 可能仍是 `1`,直到有人访问详情才变成 `3`。**前端应同时判断 `status` 与 `expire_time`** 来展示"已过期"。 @@ -1214,7 +1232,122 @@ Authorization: Bearer --- -### 5.8 收藏与点赞模块 +### 5.8 穿搭分享模块 + +**状态枚举** `status`:`1`=正常 `2`=用户取消 + +> 穿搭分享用于把「自己的公开穿搭」以链接形式分享给好友。与衣柜分享不同,**创建分享不消耗任何额度**,但**要求穿搭已公开可见**(`is_public=2` 且 `audit_status=2` 且 `status=1`);一旦穿搭取消公开或被删除,相关分享即视为失效。 + +#### POST `/outfit-share` — 创建穿搭分享(鉴权) + +**业务逻辑** + +1. 仅能分享**本人**的穿搭(他人 → `40004`)。 +2. 穿搭必须已公开可见,否则 `10000` + `details:["请先公开该穿搭再分享"]`。 +3. **不扣额度**;创建成功后返回 `share_uuid`,分享链接携带该值。 + +**请求**(json) + +| 参数 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| outfit_uuid | string | ✅ | 穿搭 UUID | + +**响应** `data` + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| share_uuid | string | 分享 UUID,供他人访问分享详情 | + +**错误码**:`10000` 参数错误 · `40000` 穿搭不存在 · `40004` 无权操作该穿搭 · `19001` 数据库错误 + +--- + +#### GET `/outfit-share` — 我的穿搭分享列表(鉴权) + +**请求**(query) + +| 参数 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| page | int | 否 | 页码,默认 1 | +| page_size | int | 否 | 每页数量,默认 10,最大 50 | +| status | int | 否 | `1`=正常 `2`=已取消;不传 = 全部 | + +**响应** `data.list[]` + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| share_uuid | string | 分享 UUID | +| outfit_uuid | string | 穿搭 UUID | +| outfit_name | string | 穿搭名称 | +| cover_image | string | 封面(首件衣物抠图 URL,无则空) | +| items_count | int | 包含衣物数量 | +| status | int | `1`=正常 `2`=已取消 | +| owner_name | string | 分享者昵称(本接口不返回,恒为空) | +| owner_avatar | string | 分享者头像(本接口不返回,恒为空) | +| created_at | int64 | 分享创建时间(秒) | + +**错误码**:`10000` 参数错误 · `19001` 数据库错误 + +--- + +#### GET `/outfit-share/received` — 分享给我的穿搭分享(鉴权) + +**业务逻辑** + +- "访问即视为收到":登录用户(非分享属主)成功访问他人穿搭分享详情时,自动记录接收关系;重复访问不重复记录,属主访问不记录。 +- 列表按接收记录倒序分页返回,支持按 `status` 筛选。 +- 字段同「我的穿搭分享列表」,并额外返回 `owner_name` / `owner_avatar`。 + +**错误码**:`10000` 参数错误 · `19001` 数据库错误 + +--- + +#### GET `/outfit-share/:uuid` — 穿搭分享详情(鉴权,属主 / 访客视角) + +**业务逻辑** + +- 属主与访客都可访问(需已登录,`is_owner` 区分视角)。 +- **访客访问成功即记录接收关系**;属主访问不记录。 +- 分享已取消 → `90104`;分享不存在或对应穿搭已删除 / 取消公开 → `90103`。 + +**请求**(path) + +| 参数 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| uuid | string | ✅ | 分享 UUID | + +**响应** `data` + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| share_uuid | string | 分享 UUID | +| owner_name | string | 分享者昵称 | +| owner_avatar | string | 分享者头像 | +| status | int | `1`=正常 `2`=已取消 | +| is_owner | bool | 是否本人分享 | +| outfit | object | [穿搭对象](#43-穿搭对象-outfititem),`items[]` 含 `processed_image`(可直接展示) | + +**错误码**:`10000` 参数错误 · `90103` 穿搭分享不存在(含穿搭已删除 / 取消公开)· `90104` 穿搭分享已取消 + +--- + +#### POST `/outfit-share/:uuid/cancel` — 取消穿搭分享(鉴权) + +**业务逻辑**:**仅属主可操作**,他人 → `12000`(HTTP 403);置 `status=2`,不可逆。 + +**请求**(path) + +| 参数 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| uuid | string | ✅ | 分享 UUID | + +**响应** `data` = `{}` + +**错误码**:`10000` 参数错误 · `12000` 无权限访问(403)· `90103` 穿搭分享不存在 · `19001` 数据库错误 + +--- + +### 5.9 收藏与点赞模块 **动作枚举** `action`:`1`=执行(点赞/收藏)`2`=取消。两个接口都**幂等**,重复提交无副作用。 @@ -1309,7 +1442,7 @@ Authorization: Bearer --- -### 5.9 通知模块 +### 5.10 通知模块 **`is_read` 取值**:⚠️ `1`=**未读** `2`=**已读**(非常规的 0/1,也不是布尔值) **`msg_type` 取值**:`1`=点赞通知 `2`=订单通知 `3`=系统通知(当前代码无发送点,预留) @@ -1387,7 +1520,7 @@ Authorization: Bearer --- -### 5.10 商品模块(公开,无需登录) +### 5.11 商品模块(公开,无需登录) **`goods_type` 枚举**:`1`=会员包 `2`=上传包 `3`=AI 推荐包 `4`=衣柜分享包 `5`=搭配分享包 **`status`**:`1`=上架 `2`=下架(列表只返回上架商品) @@ -1421,7 +1554,7 @@ Authorization: Bearer --- -### 5.11 订单与退款模块 +### 5.12 订单与退款模块 **`order_status` 枚举**:`1`=待支付 `2`=已支付 `3`=已取消 `4`=已过期 `5`=已退款 **退款 `status` 枚举**:`0`=待处理 `1`=退款中 `2`=退款成功 `3`=退款失败 @@ -2039,6 +2172,8 @@ sequenceDiagram | 90100 | 分享不存在 | 200 | | 90101 | 分享已过期 | 200 | | 90102 | 分享已取消 | 200 | +| 90103 | 穿搭分享不存在 | 200 | +| 90104 | 穿搭分享已取消 | 200 | | 90200 | 目标内容不存在 | 200 | | 90201 | 点赞操作失败 | 200 | | 90202 | 收藏操作失败 | 200 |