You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

7.6 KiB

设计:全局搜索(后台 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 实体类型 poststimeline_eventsrss_items(仅 owner_user_id = 当前用户
B 实体类型 上述三类中 visibility === public 且满足公开主页链路;另加 user_profile 检索「人」
B 用户资料可搜文本 username;展示名规则与公开页一致(nickname 非空则用否则 username);bio_markdown 仅当 bio_visibility 允许对访客展示时 纳入索引文本(与公开资料 API 行为对齐,避免隐私回漏)
B 用户是否进入公开索引 与发现列表 同一组必要条件status = 'active'discover_visible = truepublic_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 contentcontentless 方案之一(实现计划在迁移中选定并写明同步契约);分词器以 unicode61 为默认(中文连续字串的召回在 §6 说明)。

同步:在 server/service 内对 posts / timeline / rss / profile 的创建、更新、删除路径调用 syncSearchDocument(或等价模块);失败写日志。提供 管理用重建索引 入口或脚本(可选但建议),用于修复漏同步。

4. 拼接与 public_indexable 规则

4.1 文章 post

  • search_texttitle + 空白 + excerpt + 空白 + body_markdown + 空白 + 从 tags_json 解析的标签(空格或逗号分隔均可,实现统一即可)。
  • A:始终为该用户写入一行(含 private / unlisted)。
  • Bpublic_indexable = true 当且仅当:visibility === 'public' 且该帖所属用户满足公开主页条件(public_slug 非空、status === 'active' 等与现有 listPublicPosts 一致)。实现上复用或与现有 where 辅助函数对齐,禁止分叉规则。

4.2 时光机 timeline

  • search_texttitle + 空白 + body_markdown(可空)+ 空白 + link_url(可空)。
  • Bpublic_indexable 条件与现有公开时光机列表一致(visibility === 'public' + 用户公开链路)。

4.3 RSS 条目 rss_item

  • search_texttitle + summary + content_snippet + author(均以空白拼接,字段可空则跳过)。
  • Bvisibility === 'public' + 用户公开链路。

4.4 用户资料 user_profile

  • 一行对应一个用户entity_kind = user_profileentity_id = user.id)。
  • search_textusername + 展示名(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
  • Queryq(必填,trim;最短长度在实现计划中约定,如 1~2 字符以上防扫库)、page(与项目分页风格一致)、可选 typepost | timeline | rss_item,多选或单选以实现为准)。
  • 逻辑MATCH + owner_user_id = session.userId忽略 public_indexable 对可见性的裁剪(本人应看到自己所有已索引类型)。
  • 响应:列表项含 entity_kindentity_id、跳转所需字段(如 slugpublic_slug、RSS id 等)、可选 snippet(见 §6)、相关度或排序辅助字段(可选)。
  • 鉴权:无。
  • Query:同 qpage、可选 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 plandocs/superpowers/plans/…),并按计划开发与验证。