# 广场设计 > 对照:公开广场流 + 可选「仅自己 / 部分好友」;发帖形态对齐微信朋友圈 > 形态: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 | --- # 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`