Browse Source

docs: add global search design spec (me + public, FTS5)

Made-with: Cursor
main
npmrun 17 hours ago
parent
commit
3696c26e1a
  1. 113
      docs/superpowers/specs/2026-04-19-global-search-design.md

113
docs/superpowers/specs/2026-04-19-global-search-design.md

@ -0,0 +1,113 @@
# 设计:全局搜索(后台 A + 公开 B)
**日期**:2026-04-19
**状态**:已定稿(与产品对话一致)
## 1. 背景与目标
person-panel 为 Nuxt 4 + Nitro + SQLite + Drizzle 多租户个人站;内容按 `userId` 隔离,文章/时光机/RSS 等具备 `visibility` 与公开主页规则。当前无全文检索能力。
**目标**:
- **A(后台 `/me`)**:登录用户搜索**本人**的 **文章 + 时光机事件 + RSS 收件箱条目**(含非公开内容;权限与「仅本人可见」一致)。
- **B(公开站)**:任意访问者搜索 **公开文章 + 公开时光机 + 公开 RSS 条目 + 可发现用户资料**(与现有公开 profile、发现页资格对齐)。
- **U1**:**两套 UI 入口**(`/me` 一处、公开站一处),**不要求**同一路由或同一套前端组件;后端共享索引与查询内核,**API 分开展示与鉴权**。
- **L2**:公开搜索 API **轻度 IP 限流**(复用进程内 `assertUnderRateLimit` 模式,参数独立、宽松于登录/验证码)。
## 2. 产品范围(已确认)
| 维度 | 规则 |
|------|------|
| A 实体类型 | `posts`、`timeline_events`、`rss_items`(仅 `owner_user_id = 当前用户`) |
| B 实体类型 | 上述三类中 **`visibility === public`** 且满足公开主页链路;另加 **`user_profile` 检索「人」** |
| B 用户资料可搜文本 | `username`;展示名规则与公开页一致(`nickname` 非空则用否则 `username`);**`bio_markdown` 仅当 `bio_visibility` 允许对访客展示时** 纳入索引文本(与公开资料 API 行为对齐,避免隐私回漏) |
| B 用户是否进入公开索引 | 与发现列表 **同一组必要条件**:`status = 'active'`、`discover_visible = true`、`public_slug` 非空(非空字符串) |
| 非目标(第一版) | 评论全文;unlisted / `shareToken` 半公开链路;拼写纠错;独立搜索引擎(Meilisearch 等);复杂高亮 |
## 3. 架构:统一窄表 + FTS5
**推荐实现**:新增业务表 **`search_documents`**(实现时可微调表名),由 FTS5 虚拟表索引其文本列。
**`search_documents` 建议字段**:
| 字段 | 说明 |
|------|------|
| `id` | 主键(自增) |
| `entity_kind` | `post` \| `timeline` \| `rss_item` \| `user_profile` |
| `entity_id` | 业务主键;`user_profile` 时为 `users.id` |
| `owner_user_id` | 内容归属用户;`user_profile` 时与 `entity_id` 相同 |
| `public_indexable` | 布尔语义:为 **true** 时该行可被 **B** 类查询命中;A 类查询**不依赖**此位(仅按 `owner_user_id` + `MATCH`) |
| `search_text` | 拼接后的可检索纯文本(见 §4) |
| `updated_at` | 同步时间,用于排序打破平局 |
**FTS5**:采用 SQLite 支持的 **external content****contentless** 方案之一(实现计划在迁移中选定并写明同步契约);分词器以 **`unicode61`** 为默认(中文连续字串的召回在 §6 说明)。
**同步**:在 `server/service` 内对 posts / timeline / rss / profile 的创建、更新、删除路径调用 **`syncSearchDocument`**(或等价模块);失败写日志。提供 **管理用重建索引** 入口或脚本(可选但建议),用于修复漏同步。
## 4. 拼接与 `public_indexable` 规则
### 4.1 文章 `post`
- **search_text**:`title` + 空白 + `excerpt` + 空白 + `body_markdown` + 空白 + 从 `tags_json` 解析的标签(空格或逗号分隔均可,实现统一即可)。
- **A**:始终为该用户写入一行(含 private / unlisted)。
- **B**:`public_indexable = true` 当且仅当:`visibility === 'public'` 且该帖所属用户满足公开主页条件(`public_slug` 非空、`status === 'active'` 等与现有 `listPublicPosts` 一致)。实现上复用或与现有 `where` 辅助函数对齐,禁止分叉规则。
### 4.2 时光机 `timeline`
- **search_text**:`title` + 空白 + `body_markdown`(可空)+ 空白 + `link_url`(可空)。
- **B**:`public_indexable` 条件与现有公开时光机列表一致(`visibility === 'public'` + 用户公开链路)。
### 4.3 RSS 条目 `rss_item`
- **search_text**:`title` + `summary` + `content_snippet` + `author`(均以空白拼接,字段可空则跳过)。
- **B**:`visibility === 'public'` + 用户公开链路。
### 4.4 用户资料 `user_profile`
- **一行对应一个用户**(`entity_kind = user_profile`,`entity_id = user.id`)。
- **search_text**:`username` + 展示名(nickname 或回退 username)+ 条件性 `bio_markdown`(仅当公开资料会展示 bio 时并入)。
- **public_indexable**:§2 表中 B 用户条件为真时 **true**,否则 **false**(可不删行,但 B 查询必须过滤 `public_indexable`;或删除行,实现计划二选一并文档化)。
**去重键**:`(entity_kind, entity_id)` 唯一,同步时 **upsert**
## 5. API 契约
### 5.1 后台 `GET /api/me/search`(路径以实现为准,保持 `me` + 鉴权)
- **鉴权**:必须登录;未登录 **401**
- **Query**:`q`(必填,trim;**最短长度**在实现计划中约定,如 1~2 字符以上防扫库)、`page`(与项目分页风格一致)、可选 `type`(`post` \| `timeline` \| `rss_item`,多选或单选以实现为准)。
- **逻辑**:`MATCH` + `owner_user_id = session.userId`;**忽略** `public_indexable` 对可见性的裁剪(本人应看到自己所有已索引类型)。
- **响应**:列表项含 `entity_kind`、`entity_id`、跳转所需字段(如 `slug`、`public_slug`、RSS `id` 等)、可选 `snippet`(见 §6)、相关度或排序辅助字段(可选)。
### 5.2 公开 `GET /api/public/search`
- **鉴权**:无。
- **Query**:同 `q`、`page`、可选 `type`;可选按类型过滤(含 `user_profile`)。
- **逻辑**:`MATCH` + **`public_indexable = true`**;必要时 join 或冗余列校验用户仍满足公开条件(防止索引滞后;规则与列表 API 一致)。
- **限流**:`assertUnderRateLimit('public-search:' + clientIp, max, windowMs)`;**max / windowMs** 用 env 或常量,默认「每分钟数十次」量级(实现计划写明确数值)。
**两接口共用**:同一查询构建函数,差异仅为 `where` 子句与是否限流。
## 6. 排序、分页与片段
- **默认排序**:FTS **`bm25`** 相关度为主;同分用实体可用时间字段打破(post / rss 用 `published_at`,timeline 用 `occurred_on`,user 用 `users.updated_at` 或文档 `updated_at`)。
- **分页**:与现有 `PUBLIC_LIST_PAGE_SIZE` 风格对齐;固定 `pageSize`、**忽略**客户端任意大页。
- **snippet**:第一版 **可选**;若实现,服务端对 `search_text` 或原文字段做**短片段截断**(不要求关键词高亮)。
**中文说明**:`unicode61` 对连续汉字无细粒度分词;用户多词查询用 FTS **AND** 组合;单段连续中文依赖 **短语匹配** 或后续迭代(trigram / 插件)不在本版范围。
## 7. 错误与安全
- 公开搜索 **429**:与 `simple-rate-limit` 一致的中文 `statusMessage`
- **禁止**在 B 响应中暴露 email、password、仅管理员字段。
- **A** 接口可不限流或极宽松(实现计划可选)。
## 8. 测试与验收
- Service 层:写入后 **MATCH** 命中;A 仅本人;B **不**返回他人 private 内容。
- B:用户 `public_indexable` 变 false 后不应再出现于公开结果(或下一索引同步后)。
- 可选:集成测试公开 API + 连续请求触发 429。
## 9. 后续流程
实现前在单独会话中依据本 spec 编写 **implementation plan**(`docs/superpowers/plans/…`),并按计划开发与验证。
Loading…
Cancel
Save