Browse Source
- Added a custom JSON error handler to improve error responses in Nitro. - Introduced utility functions to determine JSON request types and normalize error messages. - Updated Nuxt configuration to utilize the new error handler. Made-with: Cursormain
5 changed files with 141 additions and 113 deletions
@ -1,113 +0,0 @@ |
|||||
# 设计:全局搜索(后台 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/…`),并按计划开发与验证。 |
|
||||
Binary file not shown.
@ -0,0 +1,59 @@ |
|||||
|
import log4js from "logger"; |
||||
|
import { |
||||
|
getRequestURL, |
||||
|
send, |
||||
|
setResponseHeader, |
||||
|
setResponseHeaders, |
||||
|
setResponseStatus, |
||||
|
} from "h3"; |
||||
|
import { defineNitroErrorHandler } from "nitropack/runtime"; |
||||
|
import { isJsonRequest, normalizeError } from "#server/utils/nitro-error-json"; |
||||
|
|
||||
|
const logger = log4js.getLogger("ERROR"); |
||||
|
const isDev = process.env.NODE_ENV === "development"; |
||||
|
|
||||
|
/** 优先于 Nitro 内置 JSON 错误体:不向客户端返回 stack(开发/生产一致) */ |
||||
|
export default defineNitroErrorHandler(function safeJsonErrorHandler(error, event) { |
||||
|
if (!isJsonRequest(event)) { |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
const { statusCode, statusMessage, message } = normalizeError(error, isDev); |
||||
|
|
||||
|
if (error.unhandled || error.fatal || statusCode >= 500) { |
||||
|
logger.error( |
||||
|
"[request]", |
||||
|
event.method, |
||||
|
event.path, |
||||
|
error.message, |
||||
|
error instanceof Error ? (error.stack ?? "") : "", |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
const url = getRequestURL(event, { xForwardedHost: true, xForwardedProto: true }); |
||||
|
|
||||
|
setResponseHeaders(event, { |
||||
|
"Content-Security-Policy": "script-src 'none'; frame-ancestors 'none';", |
||||
|
"X-Content-Type-Options": "nosniff", |
||||
|
"X-Frame-Options": "DENY", |
||||
|
"Referrer-Policy": "no-referrer", |
||||
|
}); |
||||
|
if (statusCode === 404) { |
||||
|
setResponseHeader(event, "Cache-Control", "no-cache"); |
||||
|
} |
||||
|
setResponseStatus(event, statusCode, statusMessage); |
||||
|
setResponseHeader(event, "Content-Type", "application/json"); |
||||
|
|
||||
|
const body: Record<string, unknown> = { |
||||
|
error: true, |
||||
|
url: url.href, |
||||
|
statusCode, |
||||
|
statusMessage, |
||||
|
message, |
||||
|
}; |
||||
|
if (error.data !== undefined) { |
||||
|
body.data = error.data; |
||||
|
} |
||||
|
|
||||
|
return send(event, JSON.stringify(body)); |
||||
|
}); |
||||
@ -0,0 +1,56 @@ |
|||||
|
import type { H3Event } from "h3"; |
||||
|
import { getRequestHeader } from "h3"; |
||||
|
|
||||
|
/** 与 Nitro `runtime/utils` 一致,避免依赖 `nitropack/.../utils` 的 TS 子路径解析 */ |
||||
|
function hasReqHeader(event: H3Event, name: string, includes: string): boolean { |
||||
|
const value = getRequestHeader(event, name); |
||||
|
return Boolean(value && typeof value === "string" && value.toLowerCase().includes(includes)); |
||||
|
} |
||||
|
|
||||
|
export function isJsonRequest(event: H3Event): boolean { |
||||
|
if (hasReqHeader(event, "accept", "text/html")) { |
||||
|
return false; |
||||
|
} |
||||
|
return ( |
||||
|
hasReqHeader(event, "accept", "application/json") || |
||||
|
hasReqHeader(event, "user-agent", "curl/") || |
||||
|
hasReqHeader(event, "user-agent", "httpie/") || |
||||
|
hasReqHeader(event, "sec-fetch-mode", "cors") || |
||||
|
event.path.startsWith("/api/") || |
||||
|
event.path.endsWith(".json") |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
export function normalizeError(error: any, isDev?: boolean) { |
||||
|
const cwd = typeof process.cwd === "function" ? process.cwd() : "/"; |
||||
|
const stack = |
||||
|
!isDev && !import.meta.prerender && (error.unhandled || error.fatal) |
||||
|
? [] |
||||
|
: (error.stack || "") |
||||
|
.split("\n") |
||||
|
.splice(1) |
||||
|
.filter((line: string) => line.includes("at ")) |
||||
|
.map((line: string) => { |
||||
|
const text = line |
||||
|
.replace(cwd + "/", "./") |
||||
|
.replace("webpack:/", "") |
||||
|
.replace("file://", "") |
||||
|
.trim(); |
||||
|
return { |
||||
|
text, |
||||
|
internal: |
||||
|
(line.includes("node_modules") && !line.includes(".cache")) || |
||||
|
line.includes("internal") || |
||||
|
line.includes("new Promise"), |
||||
|
}; |
||||
|
}); |
||||
|
const statusCode = error.statusCode || 500; |
||||
|
const statusMessage = error.statusMessage ?? (statusCode === 404 ? "Not Found" : ""); |
||||
|
const message = !isDev && error.unhandled ? "internal server error" : error.message || error.toString(); |
||||
|
return { |
||||
|
stack, |
||||
|
statusCode, |
||||
|
statusMessage, |
||||
|
message, |
||||
|
}; |
||||
|
} |
||||
Loading…
Reference in new issue