# 朋友圈设计 > 对照:微信「发现 → 朋友圈」 > 形态: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`,产品要时再开。