spark-server/docs/square.md
DESKTOP-L8OUL9F\gfl0034 c26d61fd26 feat(media): 镜像装 ffmpeg,并补齐压缩配置与文档
机器人视频压缩依赖 ffmpeg;补 MediaCompressOptions、广场机器人说明与当日变动清单。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-24 23:53:56 +08:00

412 lines
15 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`
> 状态:**已实现**(`internal/module/square`、`GET/POST /v1/square/...`)
> 硬约束:遵守 `docs/architecture-v3.md` C1–C7。对照已落地朋友圈见 `docs/moments.md`。
广场是独立于朋友圈的**内容频道**:默认发帖经审核通过后进入**全站广场 Feed**;发帖可选 **仅自己**、**部分好友可看**(白名单)、**不给谁看**(黑名单,其余登录用户可看)。私密/白名单帖不进全站 Feed。与好友 **拉黑** 关系任一侧存在时,**默认双方互相看不到对方广场内容**(无需写进 `audience_uids`)。载体与朋友圈一致。分表、分 REST、前缀 `perm/square/`。
---
# 0. 需求摘要
| # | 需求 | 设计要点 |
| --- | --- | --- |
| 1 | 发送方式与朋友圈类似 | 同一套 **content JSON**;`square_image` / `square_video` |
| 2 | 默认审核通过;公开类帖进全站 Feed | `audit_status`;v1 即 `approved`;全站 Feed 拉 `visibility∈{公开,不给谁看}`,再按读者过滤 |
| 3 | 仅自己 / 部分好友 / **不给谁看** | `visibility` 0/1/2/3 + `square_audience`(2=白名单能看,3=黑名单不能看);名单须双向好友,最多 200 |
| 3b | **拉黑好友默认不给看** | 任意可见性下,`friend.BlockedEither(作者,读者)` → 不可见、不可赞评 |
| 4 | 广场列表分页 | `cursor` + `limit`,**默认 20**、上限 50(与朋友圈一致,**不改**朋友圈默认) |
| 6 | **v1 就要赞/评通知** | `square_notices` + 列表/已读/未读角标 API(对齐朋友圈,不进会话 inbox) |
---
# 0.1 做 / 不做
## 做(v1)
| 能力 | 说明 |
| --- | --- |
| 发帖 | 公开 / 仅自己 / 部分好友 / **不给谁看(黑名单)** |
| 全站广场 Feed | `visibility∈{0,3}` + 审核通过 + 未删;**读时**剔除拉黑对与黑名单命中 |
| 作者主页流 | `GET /v1/square/users/{uid}/posts`:按可见性对当前浏览者过滤 |
| 单帖详情 | 有权读者可读(见 §3.5) |
| 点赞 / 取消赞 | 幂等;写 `square_notices`(赞) |
| 评论 / 回复 / 删评 | 全量展示评论(无共同好友过滤);写通知(评/回复) |
| 互动通知 | 列表、已读、未读红点(**不进** `messages` / mailbox) |
| 作者删帖 | 软删 + 删对象 |
| 审核字段 | 默认通过 |
## 不做(v1)
| 能力 | 原因 |
| --- | --- |
| 朋友圈式「不看他 / 可见天数」 | 属 `moment_*`(广场无 hide 边) |
| 广场帖写进 `messages` / 会话 inbox | C1/C7 |
| 全站 TCP 推送每条新帖 | 非聊天;赞评可选二期 `deliver` Push |
| 推荐算法、话题、@ | 后续 |
| 未登录刷广场 | 需 Bearer |
### 机器人海外媒体(已实现)
| 能力 | 说明 |
| --- | --- |
| 定时发帖 | `square_bot.enabled`;进程内 ticker,默认 **5 分钟**;随机 `botNNN@spark.bot` |
| 内容 | 海外主流媒体 **公开 RSS** 标题+短摘要+原文链接;URL 去重(`t_square_bot_links`) |
| TTL | 机器人帖 `expires_at` 默认 **15 天**;到期软删 + Feed/详情不可见 |
| 配图 | `media.bot_compress`:enclosure 图下载后压到 ≤1080 再 `Put` |
详见 `docs/media.md`。
---
# 0.2 与朋友圈的差异
| 维度 | 朋友圈 | 广场 |
| --- | --- | --- |
| 主 Feed | 好友时间线 | **全站流**(`visibility` 公开 + 不给谁看,读时过滤) |
| 可见性默认 | 好友公开 | **全站公开**(审核通过后) |
| 不给谁看 | 黑名单 visibility=3 | 同枚举;仍进 Feed 拉取,名单内不可见 |
| 拉黑 | Feed 过滤 | **默认**双方互不可见(无需写 audience) |
| 私密/白名单 | 不进好友 Feed | 不进全站 Feed;主页流按权限 |
| 评论规则 | 共同好友 | **全部展示** |
| 通知 | `moment_notices` | `square_notices`(v1 同等能力) |
| 表 / API | `moment_*`、`/v1/moments` | `square_*`、`/v1/square` |
**内容 JSON**:与 `moment/entity` 的 `Content` 字段兼容;logic 不跨模块 import `model`。
---
# 0.3 与 V3 的关系
| 约束 | 广场 |
| --- | --- |
| C1 | Snowflake `square_posts.id` |
| C2 | HTTP → `square/logic` |
| C5 | 发帖 HTTP 200;非 `SEND_ACK` |
| C6 | 限流用 `reqctx.RealIP` |
| C7 | 通知**不进**会话 Redis ZSET |
---
# 1. 总览
```text
客户端
│ HTTPS /v1/square
│ POST /v1/files (square_image / square_video)
▼
Gin → square/logic
│
├─ MySQL square_posts / square_audience / square_likes
│ square_comments / square_notices
├─ friend 校验 audience、读权限(双向好友)
├─ RustFS perm/square/...
└─ 可选二期 deliver:queue(赞评在线 Push,朋友圈亦未强制)
```
---
# 2. 模块与目录
```text
internal/module/square/
├── entity/ Post、Visibility、AuditStatus、Notice、Content…
├── model/ 一表一文件 + repo
└── logic/ publish、feed、album、get、like、comment、notice、delete、visible
internal/transport/http/handler/square/
migrations/00xx_square.sql
```
依赖:
| 依赖 | 用途 |
| --- | --- |
| `friend` | `audience_uids` 须双向好友;读帖时校验关系(拉黑则不可见/不可互动) |
| `user` | 作者卡片 |
| `upload` / `objectstore` / `id.Generator` | 同前 |
---
# 3. 数据模型
## 3.1 `square_posts`
| 列 | 类型 | 说明 |
| --- | --- | --- |
| `id` | BIGINT PK | Snowflake;游标 |
| `user_id` | BIGINT | 作者 |
| `kind` | SMALLINT | 1 文 2 图 3 视频 |
| `text` | VARCHAR(1000) | 可空;与媒体至少一项 |
| `content` | **VARBINARY(16384) NOT NULL** | **序列化 JSON 字节**(`json.Marshal` → `[]byte` 入库);图/视频只存 object_key,**禁止** MySQL `BLOB` 类型、禁止 `VARCHAR`/`TEXT` 存 JSON 字符串、禁止媒体二进制 |
| `poi_*` | | 可选地点 |
| `visibility` | SMALLINT | **0 全站公开 1 仅自己 2 白名单 3 黑名单(不给谁看)** |
| `audit_status` | SMALLINT | 0 待审 1 通过 2 拒绝;v1 发帖 **1** |
| `audit_note` | VARCHAR(256) | |
| `audited_at` | DATETIME(3) NULL | |
| `status` | SMALLINT | 0 正常 1 软删 |
| `like_count` / `comment_count` | INT | |
| `created_at` | DATETIME(3) | |
索引:
- `PRIMARY KEY (id)`
- `KEY idx_public_feed (audit_status, visibility, id)` — 全站 Feed:`audit_status=1 AND visibility IN (0,3) AND status=0`
- `KEY idx_user (user_id, id)` — 作者主页流
### `visibility`(枚举与朋友圈 `moment_posts.visibility` **同值同义**,广场语义见下表)
| 值 | 名称 | 进全站 Feed 拉取 | 读者(在未拉黑前提下) |
| --- | --- | --- | --- |
| 0 | **public** | 是 | 任意登录用户 |
| 1 | **private** | 否 | 仅作者 |
| 2 | **include** | 否 | 作者 + 白名单 `square_audience` |
| 3 | **exclude** | 是 | 除黑名单 `square_audience` 外的登录用户 |
发帖默认:`visibility=0`。
### `content` 列(与 `messages.content` 语义一致,物理类型不同)
| 层 | 约定 |
| --- | --- |
| 应用层 | `entity.Content` → `Marshal()` 得到 `[]byte`,写入 `Post.Content` |
| GORM | `Content []byte` + `gorm:"column:content;type:varbinary(16384);..."` |
| MySQL | `VARBINARY(16384)`,不用 `BLOB`/`MEDIUMBLOB` |
| 上限 | 9 图 + 视频元数据 JSON 远小于 16KB;超限返回 `40001` |
朋友圈 `moment_posts.content` 历史为 `BLOB`;广场新表按上表实现,后续若要统一 moment 可单独 migration。
**拉黑(全局,默认)**:不论上表哪一行,若 `BlockedEither(作者, viewer)`,则 viewer **不可见、不可赞评**;实现于 `visible` 逻辑最前,不写入 `square_audience`。
## 3.2 `square_audience`
`visibility=2`(白名单)或 `visibility=3`(黑名单)时有行。语义与 `moment_audience` 一致:
| visibility | `peer_id` 含义 |
| --- | --- |
| 2 include | **能看** 的好友 uid |
| 3 exclude | **不能看** 的好友 uid(其余登录用户能看,仍受拉黑规则约束) |
| 列 | 说明 |
| --- | --- |
| `post_id` | 帖 id |
| `peer_id` | 好友 uid |
| PK | `(post_id, peer_id)` |
单帖最多 **200** 人;`audience_uids` 去重;每人须与作者 **双向好友**(`friend` 校验);不含作者自己。
## 3.3 `square_likes` / `square_comments`
与 `moment_likes` / `moment_comments` 同构。评论列表**不做**共同好友过滤。
## 3.4 `square_notices`(v1 必做)
结构对齐 `moment_notices`:
| 列 | 说明 |
| --- | --- |
| `id` | Snowflake PK,列表游标 |
| `to_user_id` | 接收人(帖作者或被回复者) |
| `from_user_id` | 点赞人/评论人 |
| `kind` | 1 赞 2 评论 3 回复 |
| `post_id` | 帖 id |
| `comment_id` | 赞为 0 |
| `read_at` | NULL=未读 |
| `created_at` | |
索引:`KEY idx_to_user (to_user_id, id)`。
**写入时机**(与朋友圈一致,同事务或紧接 COMMIT):
| 动作 | 通知 |
| --- | --- |
| 点赞 | `to`=帖作者,`kind=赞`;自己赞自己不写 |
| 直接评论 | `to`=帖作者,`kind=评论` |
| 回复评论 | `to`=被回复用户,`kind=回复`;若与帖作者不同可各写一条(同 moment) |
取消赞**不**删历史通知(同朋友圈)。
### 未读水位(v1)
独立表 **`square_notice_state`**(或单列进用户扩展,推荐独立表与 moment 解耦):
| 列 | 说明 |
| --- | --- |
| `user_id` | PK |
| `last_read_notice_id` | 广场互动通知已读水位,只增不减 |
| `updated_at` | |
`GET /v1/square/notices/unread`:`latest_id > last_read_notice_id` 则 `has_new=true`(实现可抄 `moment` `UnreadFeed`)。
---
# 3.5 读权限(单帖 / 赞 / 评 / 通知跳转)
对浏览者 `viewer`、帖 `post`(`status=正常`,`audit_status=通过`):
**第一步(默认)**:若 `viewer != post.user_id` 且 `BlockedEither(post.user_id, viewer)` → **拒绝**(拉黑好友不给看)。
**第二步**:`visibilityOK`(与 `moment/logic/visible.go` 同构,公开帖在广场表示「除黑名单外全员」):
| visibility | 允许读 |
| --- | --- |
| public (0) | 任意登录用户 |
| private (1) | 仅作者 |
| include (2) | 作者,或 viewer 在白名单 |
| exclude (3) | 作者,或 viewer **不在** 黑名单 |
全站 Feed:`visibility IN (0,3)` 分页拉取后,对每条执行上述过滤;不足 `limit` 时 **overfetch**(策略同朋友圈 Feed,`Overfetch`/`OverfetchRounds` 可复用常量)。
---
# 4. 分页约定
`cursor` = 上一页最后 `post_id`,`limit` 默认 **20**、max **50**(常量与朋友圈 `entity.ListDefault` / `ListMax` 对齐);响应 `items`、`next_cursor`、`has_more`。
## 4.1 全站 Feed SQL(示意)
```sql
SELECT ... FROM square_posts
WHERE audit_status = 1 AND visibility IN (0, 3) AND status = 0
AND (:cursor = 0 OR id < :cursor)
ORDER BY id DESC
LIMIT :batch; -- batch > limit,供拉黑/黑名单过滤
```
## 4.2 作者主页流
`GET /v1/square/users/{uid}/posts?cursor=&limit=`
- 拉取 `user_id = uid` 的帖(`id` 游标),在 logic 层对 `viewer` 做 §3.5 过滤(overfetch 策略可抄朋友圈 `album`)。
- 用于:看自己的私密/部分好友帖;好友看对方允许自己看的广场帖。
## 4.3 与朋友圈分页
朋友圈 **保持** `GET /v1/moments?cursor=&limit=` 默认 **20**(`internal/module/moment/entity/kind.go` 的 `ListDefault`、OpenAPI 不变)。广场复用同一套默认/上限常量或复制相同数值(20/50)。
---
# 5. HTTP API(v1)
均需 Bearer。前缀 `/v1/square`。
### 帖子
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| GET | `/v1/square/posts` | **全站公开** Feed;`limit` 默认 20 |
| POST | `/v1/square/posts` | 发帖(见下) |
| GET | `/v1/square/posts/{id}` | 单帖(权限 §3.5) |
| DELETE | `/v1/square/posts/{id}` | 作者删帖 |
| GET | `/v1/square/users/{uid}/posts` | 某用户广场帖(按可见性过滤) |
### 互动
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| PUT/DELETE | `/v1/square/posts/{id}/like` | 赞 / 取消 |
| GET/POST/DELETE | `/v1/square/posts/{id}/comments`… | 评论列表/发评/删评 |
### 通知(v1)
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| GET | `/v1/square/notices` | `cursor`、`limit`(默认 20) |
| GET | `/v1/square/notices/unread` | 角标 |
| POST | `/v1/square/notices/read` | body `{ "cursor": 0 }` 标记已读到该水位 |
### 发帖 Body
```json
{
"text": "今天天气不错",
"images": [{ "key": "perm/square/image/20260922/a.jpg", "w": 1080, "h": 720 }],
"visibility": 0,
"audience_uids": [2101923649870106624],
"poi": { "name": "外滩", "lat": 31.24, "lng": 121.49 }
}
```
| 字段 | 说明 |
| --- | --- |
| `visibility` | `0` 公开 / `1` 仅自己 / `2` 白名单 / `3` 不给谁看 |
| `audience_uids` | `visibility=2` 或 `3` 时必填,最多 200,双向好友 |
校验:媒体规则同朋友圈;`visibility` 为 2/3 时 `audience_uids` 非空(`entity.Visibility.NeedsAudience()`)。
---
# 6. 上传与对象存储
不变:`square_image` / `square_video` → `perm/square/...`。
---
# 7. 权限与安全
| 动作 | 规则 |
| --- | --- |
| 全站 Feed | 公开+不给谁看类帖;按读者过滤拉黑/黑名单 |
| 发帖 | 登录 + 限流 |
| 读单帖/主页流 | §3.5 + 拉黑 |
| 赞评 | 须能读该帖;限流;写 `square_notices` |
| 删帖 | 作者 |
---
# 8. 实现复用
| 项 | 策略 |
| --- | --- |
| 客户端 | 可见范围:公开 / 仅自己 / 选好友可看 / **选好友不给看** |
| `visibility` 校验 | 与 moment **同枚举 0–3**;`validate.square.*` |
| `visible` | 复用 `visibilityOK` 语义 + 优先 `BlockedEither` |
| `notice` | 抄 `moment/logic/notice.go`、`like.go`、`comment.go` 的 `notify` 模式 |
| GORM | 嵌入结构体须 **导出**(`MessageRow` 教训) |
---
# 9. 实施阶段
## Phase 1(本期)
- [ ] migration:`square_posts`、`square_audience`、`square_likes`、`square_comments`、`square_notices`、`square_notice_state`
- [ ] module + HTTP + OpenAPI + i18n
- [ ] upload purpose
- [ ] 发帖:公开 / 仅自己 / 白名单 / **黑名单(不给谁看)** + 拉黑默认过滤
- [ ] Feed + 用户主页流 + 分页默认 20
- [ ] 赞评 + **通知列表/未读/已读**
- [ ] 测试:可见性、Feed 不含私密帖、赞评通知、拉黑不可互动
## Phase 2
- [ ] 审核 `pending` + 运营后台
- [ ] `deliver:queue` 赞评 Push(在线设备)
- [ ] 机审 / 敏感词
---
# 10. 测试要点
| 场景 | 期望 |
| --- | --- |
| 发 `visibility=0` | 出现在 `GET /square/posts` |
| 发 `visibility=1` | 不出现在全站 Feed;作者主页可见 |
| 发 `visibility=2` + audience | 仅作者与名单好友可读;全站 Feed 无 |
| 发 `visibility=3` + audience | 全站 Feed 有;名单内好友不可见 |
| A 拉黑 B | B 刷 Feed 不见 A 的帖;B 单帖 GET 403/404 |
| A 公开帖 + B 在黑名单 | 仅 B 不可见 |
| B 赞 A 的公开帖 | A 的 `square_notices` 增一条;`unread` 为 true |
| B 评 A | 同;自己评自己不通知 |
| 回复 C 的评论 | C 收到回复通知;A 收到评论通知(若规则同 moment) |
| 非好友不在 audience | `GET` 单帖 404 或 403 |
| GORM INSERT | 全列写入 |
---
# 11. 参考
- `docs/moments.md`、`internal/module/moment/logic/notice.go`
- `docs/architecture-v3.md`