--- name: 衣柜分享-分享给我的 overview: 新增"分享给我的"能力:通过 closet_share_receivers 关联表记录登录用户访问分享详情的接收关系(一对多、幂等),并新增 GET /closet-share/received 列表接口,返回含分享者昵称/头像的分享列表。 todos: - id: add-receiver-migration-model content: 新增 closet_share_receivers 迁移、ClosetShareReceiver 模型与 DAO(幂等 upsert、联表分页查询) status: completed - id: add-received-service content: 扩展 DTO 与 UserGetByIDs,详情记录接收人并实现 ListReceived 服务 status: completed dependencies: - add-receiver-migration-model - id: add-received-handler-route content: 新增 ReceivedList handler 并注册 /closet-share/received 路由 status: completed dependencies: - add-received-service - id: update-docs content: 更新接口文档与 README 的衣柜分享模块说明 status: completed dependencies: - add-received-handler-route --- ## 产品概述 为后端"衣柜分享"补齐"分享给我的"数据能力。当前 `closet_shares` 只记录分享发起者(`user_id`),没有接收人概念,`GET /closet-share` 仅返回"我发起的分享"。本次改造采用"访问即视为收到"的语义:登录用户(非分享属主)成功访问某个分享详情时,自动建立该用户与分享的接收关系,并提供"我收到的分享"分页列表接口。 ## 核心功能 - **接收关系自动记录**:保持现有链接分享方式不变;登录用户首次访问他人分享详情且分享状态正常时,自动幂等记录为接收人,重复访问不产生重复记录,分享属主访问不记录。 - **一对多接收人**:同一分享可被多个登录用户访问,每个接收人各生成一条关联记录,通过 `share_uuid + receiver_user_id` 联合唯一索引去重。 - **"分享给我的"列表接口**:新增 `GET /closet-share/received`,按当前登录用户分页查询其收到的分享,返回分享基本信息、衣物数量、分享状态,并额外返回分享者昵称与头像(`owner_name` / `owner_avatar`),支持按状态筛选。 - **接口兼容**:现有"我的分享"列表响应结构仅新增字段,不破坏既有字段;既有创建、详情、取消分享接口行为保持不变。 ## 技术选型 - 语言/框架:Go + Gin(沿用现有项目) - ORM:GORM(含 `soft_delete.DeletedAt` 软删除) - 数据库:MySQL,迁移使用 `golang-migrate`(`go:embed *.sql` 自动打包,服务不自动执行迁移) - 缓存:Redis(访问计数沿用现状,接收关系落 MySQL,保证可查询与持久化) ## 实现方案 ### 数据模型 新增关联表 `closet_share_receivers`,与现有表结构规范保持一致(含 `id`、`created_at`、`updated_at`、`deleted_at`、`created_by`、`updated_by`、`deleted_by`): - `share_uuid VARCHAR(64)`:分享 UUID - `receiver_user_id BIGINT`:接收人用户 ID - `UNIQUE KEY uk_closet_share_receiver (share_uuid, receiver_user_id)`:保证一个用户对同一分享只有一条记录 - `KEY idx_closet_share_receivers_user (receiver_user_id)`:支撑"按接收人分页查询" 接收关系写入采用幂等 upsert(唯一索引冲突时 `DO NOTHING`),避免并发访问重复插入。 ### 接收关系记录时机 在 `ClosetShareService.Detail` 中,仅当以下条件全部满足时记录接收关系: 1. 分享状态校验通过(未被取消/封禁/过期); 2. 衣物明细加载成功,详情能够正常返回; 3. 当前 `userID > 0` 且非分享属主。 记录为 best-effort:失败仅告警,不阻断详情返回,避免因关联表故障影响详情可用性。 ### "分享给我的"列表查询 新增 `ClosetShareService.ListReceived`,通过 DAO 联表 `closet_share_receivers` 与 `closet_shares` 分页查询,避免 N+1: 1. 联表分页取出分享记录(按接收记录 `id DESC` 排序,支持 `status` 过滤); 2. 复用 `ClosetShareItemListByShareUUIDs` 批量统计各分享衣物数量; 3. 新增 `UserDao.UserGetByIDs` 批量查询分享者昵称/头像,头像按现有 `upload.GetCosUrl` 规则转换; 4. 组装列表项返回。 ### 路由兼容 在 `GET /closet-share/:uuid` 之前注册静态路由 `GET /closet-share/received`。gin 路由树对静态段优先于参数段,此顺序可确保 `/received` 不被 `/:uuid` 误匹配。 ## 实施要点 - **性能**:列表查询一次联表取分享、两次批量查询(衣物明细 + 用户信息),时间复杂度与页大小线性相关;联合唯一索引 + `receiver_user_id` 普通索引保证写入去重与列表检索效率。 - **幂等与并发**:接收关系写入用 `clause.OnConflict{DoNothing: true}`,并发首次访问不会报错或产生重复数据。 - **日志**:复用 `slog.WarnContext` 记录接收关系写入失败,仅含 `share_uuid`、`user_id`、`error`,不记录衣物或用户敏感大字段;避免高频详情访问造成日志刷屏,仅记录异常。 - **安全**:接收关系写入、列表查询均使用 GORM 参数绑定,防 SQL 注入;列表仅返回当前登录用户自身的接收记录,无越权读取面。 - **爆炸半径控制**:不改动既有 `closet_shares` 表结构,不修改创建/详情/取消接口既有返回契约,仅新增字段与新接口;迁移通过独立命令执行,服务启动不受影响。 - **兼容性**:`ClosetShareItemResponse` 新增 `OwnerName`、`OwnerAvatar` 字段,"我的分享"列表暂不填充(空字符串),"分享给我的"列表填充分享者信息。 ## 架构设计 ```mermaid flowchart LR A[登录用户访问 GET /closet-share/:uuid] --> B{状态校验通过且非属主} B -->|是| C[UPSERT closet_share_receivers
唯一键幂等] B -->|否| D[返回详情] C --> D E[GET /closet-share/received] --> F[JOIN receivers + shares 分页] F --> G[批量统计衣物数量] F --> H[批量查询分享者信息] G --> I[组装列表返回] H --> I ``` ## 目录结构 ``` /root/zt-closet-backend/ ├── pkg/migration/ │ ├── 000018_create_closet_share_receivers.up.sql # [NEW] 创建 closet_share_receivers 表:标准字段 + share_uuid + receiver_user_id,联合唯一索引与 receiver_user_id 普通索引 │ └── 000018_create_closet_share_receivers.down.sql # [NEW] DROP TABLE IF EXISTS closet_share_receivers ├── internal/mysqlmodel/ │ └── closet.go # [MODIFY] 新增 ClosetShareReceiver 模型及 TableName,唯一索引标签与迁移一致 ├── internal/mysqldao/ │ ├── closet.go # [MODIFY] 新增 ClosetShareReceiverDao:NewClosetShareReceiverDao、ClosetShareReceiverUpsert(DO NOTHING 幂等)、ClosetShareReceivedList(联表分页查询 shares) │ └── user.go # [MODIFY] 新增 UserGetByIDs 批量查询,返回 []mysqlmodel.User,空 ids 直接返回空 ├── internal/dto/ │ └── closet.go # [MODIFY] ClosetShareItemResponse 新增 OwnerName/OwnerAvatar;接收列表复用 ClosetShareListRequest 与 ShareListResponse ├── internal/service/ │ └── closet.go # [MODIFY] ClosetShareService 新增 shareReceiverDao;构造器初始化;Detail 状态校验通过后 best-effort 记录接收人;新增 ListReceived 分页查询并批量填充分享者信息 ├── internal/routers/api/v1/ │ └── closet.go # [MODIFY] ClosetShareHandler 新增 ReceivedList 方法,绑定 ClosetShareListRequest 并调用 svc.ListReceived ├── internal/routers/ │ └── router.go # [MODIFY] 在 GET /closet-share/:uuid 前注册 auth.GET("/closet-share/received", closetHandler.ReceivedList) ├── 接口文档.md # [MODIFY] 衣柜分享模块新增 GET /closet-share/received 接口说明(请求参数、响应字段、错误码) └── README.md # [MODIFY] 更新衣柜分享业务流程与接口列表,补充"访问即视为收到"与"分享给我的"说明 ``` ## 关键代码结构 ``` // internal/mysqlmodel/closet.go type ClosetShareReceiver struct { Model ShareUUID string `gorm:"column:share_uuid;uniqueIndex:uk_closet_share_receiver,priority:1"` ReceiverUserID int64 `gorm:"column:receiver_user_id;uniqueIndex:uk_closet_share_receiver,priority:2"` } // internal/mysqldao/closet.go func (d *ClosetShareReceiverDao) ClosetShareReceiverUpsert(ctx context.Context, shareUUID string, receiverUserID int64) error func (d *ClosetShareReceiverDao) ClosetShareReceivedList(ctx context.Context, receiverUserID int64, status int8, page, pageSize int) ([]mysqlmodel.ClosetShare, int64, error) // internal/mysqldao/user.go func (d *UserDao) UserGetByIDs(ctx context.Context, ids []int64) ([]mysqlmodel.User, error) // internal/service/closet.go func (s *ClosetShareService) ListReceived(ctx context.Context, userID int64, request *dto.ClosetShareListRequest) (*dto.ShareListResponse, *errcode.Error) ```