spark-server/docs/moments.md
2026-09-18 17:51:42 +08:00

518 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 朋友圈设计
> 对照:微信「发现 → 朋友圈」
> 形态:Spark IM 模块化单体内的独立业务,不进会话、不走 `messages`
> 状态:v1 HTTP 已落地(发帖/Feed/相册/赞评/通知/设置)。未做 TCP `MOMENT_PUSH`。
> 硬约束:遵守 `docs/architecture-v3.md` C1–C7。不上 Kafka、K8s、微服务、ConversationSeq。
朋友圈是好友之间的时间线,不是群动态,也不是聊天消息。帖子用独立表、独立 REST、独立对象前缀。Snowflake `post_id` 是主键、排序键和翻页游标,与聊天的 C1 同构,互不混用。
---
# 0. 范围
## 0.1 做(对齐微信核心)
| 微信 | Spark |
| --- | --- |
| 只对好友,不对群 | 双向好友可见;拉黑任一侧不可见 |
| 文字 / 最多 9 图 / **或** 1 条视频 | 同样互斥;图允许 jpeg / png / gif |
| 可见范围:公开好友、私密、部分可见、不给谁看 | 同四个;v1 用 uid 列表,**不做好友标签** |
| 不看他 / 不让他看 | 独立有向边,和拉黑分开 |
| 允许朋友查看范围:最近 3 天 / 1 个月 / 半年 / 全部 | 作者侧设置;Feed 与相册都裁 |
| 点赞、评论、回复 | 有;评论按「共同好友」过滤 |
| 封面、发现页红点、赞评消息列表 | 封面 + 未读红点 + 互动通知 |
| 随时删除自己的动态 | 作者软删;删帖后删对象 |
## 0.2 不做
- 好友标签选人、转载公众号/链接、@好友、视频号、直播
- 「仅聊天」好友类型(Spark 尚无此关系)
- 把动态写进 `messages` / 会话 inbox / `SYNC_INBOX`
- 写扩散:给每个好友插一行 MySQL inbox(违反 C7 精神,也无 Kafka 可扛)
- 新动态推送给全部好友(微信也不把发帖当聊天 `MESSAGE`)
- 收藏朋友圈(`favorites` 只收藏聊天;媒体不从 `chat/` 拷过来)
## 0.3 和 V3 的关系
| 约束 | 朋友圈怎么守 |
| --- | --- |
| C1 | 聊天仍是 `messages` + `MsgId`。朋友圈是 `moment_posts.id`,**没有 ConversationSeq** |
| C2 | 发帖/赞评在 HTTP → Application。gnet EventLoop 不碰朋友圈 |
| C3 | 无新握手。可选 TCP `MOMENT_PUSH` 仍是薄 Frame + JSON 信封 |
| C4 | 仍走 `sparkapi` HTTPS。不新开公网端口 |
| C5 | 发帖不是 `SEND_ACK`。落库成功即 HTTP 200。赞评若要实时,COMMIT 后再 `deliver:queue` Push |
| C6 | 发帖/赞评限流用 `reqctx.RealIP` |
| C7 | 朋友圈红点**不**进会话 ZSET。未读用 `moment_settings.last_read_post_id` |
HTTP 与(若加)TCP 共用 `internal/module/moment/logic`。Gin handler 只绑参、写信封。`logic` 不 import `model`。
---
# 1. 总览
```text
客户端
│ HTTPS /v1/moments
│ POST /v1/files (moment_image / moment_video / moment_cover)
▼
Gin → moment/logic
│
├─ MySQL 帖 / 可见名单 / 赞 / 评 / 设置 / 通知
├─ RustFS perm/moment/{image|video|cover}/...
└─ 可选 RPUSH deliver:queue (仅赞、评、回复)
│
Deliver Worker
│
在线设备 MOMENT_PUSH
不写会话列表、不写 mailbox_events
```
实时靠连接只服务「有人赞/评了我」;时间线正文靠 MySQL;媒体只存 object_key。
---
# 2. 模块与目录
新模块 `internal/module/moment`,三层拆分与现有业务一致。
```text
internal/module/moment/
├── entity/ 帖、可见性枚举、content JSON、设置
├── model/ 一表一文件(GORM + COMMENT)
└── logic/ 一功能一文件:publish、feed、album、like、comment、notice、settings
internal/transport/http/handler/moment/
migrations/00xx_moments.sql
```
REST 分组:`/v1/moments`。OpenAPI tag:`moments`。
上传 purpose 扩在 `upload/entity`,不在 moment 里自己签 URL。
依赖(只通过 interface,bootstrap 注入):
| 依赖 | 用途 |
| --- | --- |
| `friend` | `AreFriends`、`ListFriends`、`BlockedEither` |
| `upload` | 校验 object_key 前缀;不代签 |
| `user` | 展示昵称、头像 |
| `id.Generator` | `post_id` / `comment_id` / `notice_id` |
| `objectstore` | 删帖 / 换封面时 `Delete` |
| `deliver` | 赞评 Push(可后做) |
---
# 3. 数据模型
主键一律 Snowflake,不用 AUTO_INCREMENT 业务 id。表和列必须有 COMMENT。时间展示用 `created_at`,**排序和游标用 `id`**。
## 3.1 `moment_posts`
| 列 | 类型 | 说明 |
| --- | --- | --- |
| `id` | BIGINT PK | Snowflake。Feed / 相册游标 |
| `user_id` | BIGINT | 作者 `users.id` |
| `kind` | SMALLINT | 1 纯文 2 图 3 视频 |
| `text` | VARCHAR(1000) | 可空;与媒体至少有一项 |
| `content` | BLOB | 图/视频 JSON 字节,不存媒体二进制 |
| `poi_lat` / `poi_lng` | DOUBLE | 可选 |
| `poi_name` / `poi_address` | VARCHAR | 可选地点文案 |
| `visibility` | SMALLINT | 0 好友公开 1 私密 2 白名单 3 黑名单 |
| `status` | SMALLINT | 0 正常 1 已删 |
| `like_count` | INT | 事务内维护 |
| `comment_count` | INT | 事务内维护(只计未删评论) |
| `created_at` | DATETIME(3) | 展示与 `visible_days` 裁剪 |
索引:
- `PRIMARY KEY (id)`
- `KEY idx_user_id (user_id, id)` — 相册
不建「全表时间线」二级索引。Feed 用 `id < cursor AND user_id IN (好友列表)`。
## 3.2 `moment_audience`
仅 `visibility` 为 2 或 3 时有行。
| 列 | 说明 |
| --- | --- |
| `post_id` | 帖 id |
| `peer_id` | 好友 uid |
| PK | `(post_id, peer_id)` |
白名单:列出**能看**的人。黑名单:列出**不能看**的人(其余好友能看)。单帖最多 **200** 人,且必须是发帖时的双向好友。
## 3.3 `moment_likes`
| 列 | 说明 |
| --- | --- |
| `post_id` | 帖 id |
| `user_id` | 点赞人 |
| `created_at` | DATETIME(3) |
| PK | `(post_id, user_id)` |
重复 PUT 幂等。取消赞删行并减 `like_count`。
## 3.4 `moment_comments`
| 列 | 说明 |
| --- | --- |
| `id` | Snowflake PK |
| `post_id` | 帖 id |
| `user_id` | 评论人 |
| `reply_to_id` | 父评论 id,0 表示直接评帖 |
| `reply_to_uid` | 被回复的用户,方便通知 |
| `text` | VARCHAR(500),必填 |
| `status` | 0 正常 1 已删 |
| `created_at` | DATETIME(3) |
索引:`KEY idx_post (post_id, id)`。
## 3.5 `moment_notices`
互动通知,**不是**聊天 mailbox。
| 列 | 说明 |
| --- | --- |
| `id` | Snowflake PK,列表游标 |
| `to_user_id` | 接收人 |
| `from_user_id` | 动作发起人 |
| `kind` | 1 赞 2 评论 3 回复 |
| `post_id` | 帖 |
| `comment_id` | 评论,赞则为 0 |
| `read_at` | 可空 |
| `created_at` | DATETIME(3) |
索引:`KEY idx_to_user (to_user_id, id)`。
自己赞/评自己的帖不写通知。
## 3.6 `moment_settings`
每用户一行。无行则用默认:无封面、可见范围为全部、`last_read_post_id=0`。
| 列 | 说明 |
| --- | --- |
| `user_id` | PK |
| `cover_key` | `perm/moment/cover/...`,空表示未设 |
| `visible_days` | 0 全部;3 / 30 / 180 |
| `last_read_post_id` | 发现页红点水位 |
| `updated_at` | DATETIME(3) |
## 3.7 `moment_hides`
| 列 | 说明 |
| --- | --- |
| `user_id` | 设置方 |
| `peer_id` | 对方 |
| `hide_him` | 不看他(我的 Feed 去掉他) |
| `hide_me` | 不让他看(他拉我的 Feed/相册时当看不见) |
| PK | `(user_id, peer_id)` |
拉黑仍走 `friend_relations.blocked` / `BlockedEither`,不在本表重复。
## 3.8 枚举(entity + OpenAPI `$ref` + i18n)
| 名 | 值 |
| --- | --- |
| `MomentKind` | 1 text、2 image、3 video |
| `MomentVisibility` | 0 friends、1 private、2 include、3 exclude |
| `MomentStatus` | 0 normal、1 deleted |
| `MomentNoticeKind` | 1 like、2 comment、3 reply |
| `MomentVisibleDays` | 0 / 3 / 30 / 180 |
---
# 4. 可见性
## 4.1 读时过滤(v1 选定)
好友量按几百计。发帖只写作者一行(加可选 `moment_audience`),**不**给每个好友写 inbox。
原因:
- 解绑、改「不看他」、改可见天数、改单帖可见范围,读时立刻生效
- 写扩散要在解绑时扫 inbox,且 V3 禁止 Kafka
- `IN (friend_ids)` + `id` 游标对当前规模足够
观众 V 看作者 A 的帖 P,须同时满足:
1. `P.status = normal`
2. `A == V` → 可见(含私密、含超出 `visible_days` 的旧帖)
3. 否则:双向好友,且 `BlockedEither(A,V) == false`
4. V 没有对 A 设 `hide_him`;A 没有对 V 设 `hide_me`
5. 非本人时:按 A 的 `visible_days` 裁 `P.created_at`(0 表示不裁)
6. `visibility`:
- 0 好友公开 → 过
- 1 私密 → 否
- 2 白名单 → `moment_audience` 含 V
- 3 黑名单 → `moment_audience` 不含 V
非好友访问他人相册:**404**,不返回「有朋友圈但不能看」。
## 4.2 Feed 查询
```text
ids = ListFriends(me) ∪ {me}
ids -= { peer | hide_him(me, peer) }
SELECT * FROM moment_posts
WHERE id < cursor AND user_id IN (ids) AND status = 0
ORDER BY id DESC
LIMIT overfetch -- 建议 50
```
内存套用 4.1。返回 `limit`(默认 20,最大 50)。过滤后不足则再往下翻,**单次请求最多 3 轮 overfetch**,避免大量私密帖拖死接口。下一页 `cursor` 为本页最后一条**已返回**的 `post_id`。
## 4.3 相册
`GET /v1/moments/users/:uid`
- `uid == me`:只按 `status` 过滤,不过可见天数,能看到自己的私密帖
- 否则:必须过 4.1;游标同样是 `post_id`
## 4.4 赞、评对谁可见
V 必须先能看见该帖。
评论对 V 可见,当且仅当:V 是帖作者,或 V 是评论人,或 V 与评论人双向好友。
点赞列表同样过滤:帖作者看见全部赞;其他人只看见自己的赞 + 共同好友的赞。
这是微信「共同好友才看得到这条互动」的简化版,不做「好友的好友」更远的扩散。
---
# 5. 发帖与媒体
## 5.1 客户端顺序
```text
1. POST /v1/files purpose=moment_image|moment_video|moment_cover
2. Presigned PUT(Content-Type / Content-Length / If-None-Match: *)
3. POST /v1/moments
4. 服务端校验 key 前缀、数量、互斥、audience 都是好友
5. Snowflake id → INSERT
6. 信封返回帖视图
```
客户端不自选 object_key。不占用聊天 `client_msg_id`。发帖失败可换新凭证重试;已 PUT 的孤儿对象暂不扫(与当前头像策略相同,删帖路径会删)。
## 5.2 内容规则
| 规则 | 值 |
| --- | --- |
| 文本 | 0–1000 字;可与媒体同时有 |
| 图 | 1–9;不可同时带视频 |
| 视频 | 0 或 1;不可同时带图 |
| 纯文本 | 允许(无媒体) |
| 空正文且无媒体 | 拒绝 |
| 地点 | 可选;有 lat 必须有 lng |
| 白/黑名单 | 仅 visibility 2/3;人数 1–200;必须双向好友且不含自己 |
| 私密 | 禁止带 audience |
`kind` 由服务端根据媒体推断,客户端不必传对。
## 5.3 object_key
路径不含 uid。文件名 16 位 base62(12 字节随机,`reqctx.NewObjectName`)。
| purpose | key | 生命周期 |
| --- | --- | --- |
| `moment_image` | `perm/moment/image/{yyyyMMdd}/{name}.{ext}` | 永久;删帖删除 |
| `moment_video` | `perm/moment/video/{yyyyMMdd}/{name}.{ext}` | 同上 |
| `moment_cover` | `perm/moment/cover/{yyyyMMdd}/{name}.{ext}` | 换封面删除旧对象 |
Lifecycle **不**扫 `perm/`(现有只过期 `chat/`)。朋友圈媒体与头像一样走永久前缀,但**删帖 / 换封面必须 `Delete`**,不把孤儿留给 bucket。
规格(v1):
| purpose | MIME | 大小 | 其它 |
| --- | --- | --- | --- |
| `moment_image` | image/jpeg、image/png、image/gif | ≤ 10MB | 九宫格 |
| `moment_video` | 与 `chat_video` 相同 | 与聊天视频上限相同 | 时长 ≤ 30s(逻辑校验 `duration_ms`) |
| `moment_cover` | jpeg / png | ≤ 2MB | 一张 |
`content` JSON 形状对齐聊天 `Image` / `Video`(`key`、`thumb_key`、`cover_key`、`w`、`h`、`size`、`duration_ms`),可多图为数组。禁止引用 `chat/` 或 `perm/avatar/` 等其它前缀。
发帖不把媒体从 `chat/` 复制到 `perm/moment/`。从聊天「转发到朋友圈」若以后要做,再走 copy,与收藏相同。
## 5.4 限流与审计
`reqctx.RealIP` + uid:建议 **2 条/分钟**、**20 条/天**。赞评另限,例如 60 次/分钟。超限 `errs.ErrRateLimited`。
## 5.5 删除
仅作者。`status=deleted`,Feed/相册/单条均当不存在。
随后异步删 `content` 与封面以外的对象;失败只打日志,不回滚软删。
赞评行可保留但对外不可见。通知仍可列出,点开单条返回 404。
无「2 分钟撤回」窗口,与微信删动态一致。
---
# 6. HTTP API
统一信封 `{code,msg,request_id,data}`。鉴权与其它 `/v1` 资源相同。
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| `POST` | `/v1/moments` | 发帖 |
| `GET` | `/v1/moments` | 好友 Feed。query:`cursor`(post_id,0 表示最新)、`limit` |
| `GET` | `/v1/moments/users/:uid` | 相册 |
| `GET` | `/v1/moments/:id` | 单条;不可见则 404 |
| `DELETE` | `/v1/moments/:id` | 作者删帖 |
| `PUT` | `/v1/moments/:id/like` | 点赞(幂等) |
| `DELETE` | `/v1/moments/:id/like` | 取消赞 |
| `POST` | `/v1/moments/:id/comments` | 评论或回复。body:`text`、`reply_to_id` |
| `DELETE` | `/v1/moments/:id/comments/:cid` | 帖作者或评论人可删 |
| `GET` | `/v1/moments/notices` | 赞评通知。`cursor`=`notice_id` |
| `POST` | `/v1/moments/notices/read` | 标已读;可带 `cursor` 表示已读到该 id |
| `GET` | `/v1/moments/unread` | `{has_new, latest_id}` 发现页红点 |
| `PUT` | `/v1/moments/read` | body:`post_id`,更新 `last_read_post_id`(只增不减) |
| `GET` | `/v1/moments/settings` | 封面、可见天数、不看列表 |
| `PATCH` | `/v1/moments/settings` | 部分更新 |
| `PUT` | `/v1/moments/settings/hides/:uid` | 设置对某人的 hide_him / hide_me |
| `DELETE` | `/v1/moments/settings/hides/:uid` | 清除对该人的两条隐藏 |
Feed / 相册 / 单条的 `data` 建议同一视图:
```json
{
"id": 0,
"user": { "uid": 0, "spark_id": "", "nickname": "", "avatar": "", "remark": "" },
"kind": 2,
"text": "",
"content": {},
"poi": { "lat": 0, "lng": 0, "name": "", "address": "" },
"visibility": 0,
"like_count": 0,
"comment_count": 0,
"liked": false,
"likes": [],
"comments": [],
"created_at": 0
}
```
`likes` / `comments` 已按 4.4 过滤。`remark` 为观众对该作者的好友备注,没有则空串。列表接口可只带赞/评摘要(例如赞 10 人、评 20 条),单条带全量过滤后列表。
`POST /v1/moments` 请求例:
```json
{
"text": "hello",
"images": [{ "key": "perm/moment/image/20260918/xxxx.jpg", "w": 1080, "h": 1080 }],
"video": null,
"visibility": 0,
"audience_uids": [],
"poi": { "lat": 31.2, "lng": 121.5, "name": "外滩" }
}
```
路由注意:静态路径(`/unread`、`/notices`、`/settings`、`/users/:uid`)必须注册在 `/:id` 之前。
---
# 7. 赞、评、通知、红点
## 7.1 赞
能看见帖的人可赞。`PUT` 已赞则原样返回。计数与 `moment_likes` 同一事务。
通知:`to_user_id = 作者`,`kind=like`,作者自己赞跳过。
## 7.2 评论
能看见帖的人可评。`reply_to_id != 0` 时:父评必须同帖、未删。
删评:软删,`comment_count-1`。子评仍在,展示层把父评当「已删除」。
通知:
- 评帖 → 作者(自己评自己跳过)
- 回复 → 被回复人;若被回复人不是作者,作者再收一条 `kind=comment`(与微信「有人评论了我的动态」一致)。同一动作不要给同一人写两行。
v1 **不**把「曾经赞过/评过该帖的其他人」全部再通知一遍。
## 7.3 发现页红点
```text
unread.has_new = Feed 上第一条「别人的」可见 post_id > last_read_post_id
```
打开 Feed 并成功返回后,客户端 `PUT /v1/moments/read` 带本页最大别人 `post_id`。服务端只允许水位前进。
这不是会话未读,禁止写入 `im:dialog:*`。
## 7.4 实时 Push(可后做)
HTTP 赞评 COMMIT 成功后 `DeliverQueue.Push`:
- `Job.MsgID` = `notice_id`(给 `deliver:done:{id}` 幂等用)
- `Cmd` = 新命令 `MOMENT_PUSH`(具体数值接现有 TCP 命令表末尾)
- `ToUserIDs` = 通知接收人
- `NotMailbox = true`、`NotPush` 按设备在线情况
- **不**更新会话 ZSET、**不**写 `mailbox_events`
离线用户下次 `GET /v1/moments/notices`。
**发帖本身不 Push。**
gnet EventLoop 只 `Submit`;解析和查库在 TaskPool(若走 TCP 命令)。首期可以没有 TCP 命令,仅 HTTP 拉通知。
---
# 8. 设置
`PATCH /v1/moments/settings`:
| 字段 | 规则 |
| --- | --- |
| `cover_key` | 必须是本人刚上传的 `perm/moment/cover/` 前缀;空串表示清除封面,并删旧对象 |
| `visible_days` | `oneof=0 3 30 180`;只影响**别人**看我,不影响我看自己 |
`PUT .../hides/:uid`:
```json
{ "hide_him": true, "hide_me": false }
```
对方必须是双向好友。拉黑后 Feed 已不可见,隐藏边可保留,解除拉黑后仍生效。
---
# 9. 和现有模块的边界
| 模块 | 朋友圈 |
| --- | --- |
| `message` / `conversation` / `inbox` / `sync` | 不引用、不写入 |
| `friend` | 好友与拉黑的唯一来源 |
| `upload` | 新 purpose 与 `BuildObjectKey` 分支 |
| `favorite` | 不收藏动态 |
| `user` | 封面不进 `users.avatar` |
| `deliver` / TCP | 仅互动 Push;发现页红点不走 Push |
| 对象存储 lifecycle | 仍只过期 `chat/` |
解绑:不必删帖。读路径 `AreFriends==false` 即不可见。对方相册 404。
---
# 10. 落地顺序
按垂直切片合入,每步可运行、有测试、有 OpenAPI。
| 步 | 内容 |
| --- | --- |
| 1 | migration + entity 枚举 + upload purpose / key |
| 2 | `POST/DELETE /v1/moments`,`GET` 单条(仅自己) |
| 3 | Feed、相册、4.1 可见性、`moment_audience`、hides、拉黑 |
| 4 | 赞、评、通知 HTTP、红点 / `last_read_post_id` |
| 5 | 封面、`visible_days`、删帖删对象、换封面删旧对象 |
| 6 | (可选)`MOMENT_PUSH` + deliver |
每步:`internal/pkg/i18n` 键、`GET /v1/meta/enums`、handler 薄、`make lint`。改了 `migrations/*.sql` 后在本机执行 `go run ./cmd/migrate -config configs/config.yaml`。
---
# 11. 以后才考虑
好友上万导致 `IN (ids)` 变慢时,再对**可见好友**做 Redis ZSET 写扩散(`moment:feed:{uid}` score=`post_id`),在 Application 里同步写,不引入 Kafka。解绑时 `ZREM`。那是压测之后的复杂度,不作为 v1。
举报复用 `friend` 举报或另表 `moment_reports`,产品要时再开。