147 lines
9.2 KiB
Markdown
147 lines
9.2 KiB
Markdown
---
|
||
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<br/>唯一键幂等]
|
||
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)
|
||
``` |