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
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 实体类型 | 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、RSSid等)、可选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/…),并按计划开发与验证。