18 KiB
朋友圈设计
对照:微信「发现 → 朋友圈」
形态:Spark IM 模块化单体内的独立业务,不进会话、不走messages
状态:v1 HTTP 已落地(发帖/Feed/相册/赞评/通知/设置)。未做 TCPMOMENT_PUSH。
硬约束:遵守docs/architecture-v3.mdC1–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,须同时满足:
P.status = normalA == V→ 可见(含私密、含超出visible_days的旧帖)- 否则:双向好友,且
BlockedEither(A,V) == false - V 没有对 A 设
hide_him;A 没有对 V 设hide_me - 非本人时:按 A 的
visible_days裁P.created_at(0 表示不裁) 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,产品要时再开。