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

18 KiB
Raw Blame History

朋友圈设计

对照:微信「发现 → 朋友圈」
形态: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. 总览

客户端
  │  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,三层拆分与现有业务一致。

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 查询

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 客户端顺序

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 建议同一视图:

{
  "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 请求例:

{
  "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 发现页红点

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:

{ "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,产品要时再开。