From a1fec3008350351dc6a891af2d184193501b7115 Mon Sep 17 00:00:00 2001 From: npmrun <1549469775@qq.com> Date: Sun, 19 Apr 2026 01:50:40 +0800 Subject: [PATCH] feat(nitro): implement custom JSON error handler and utility functions - 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: Cursor --- .../specs/2026-04-19-global-search-design.md | 113 --------------------- nuxt.config.ts | 26 +++++ packages/drizzle-pkg/db.sqlite | Bin 147456 -> 147456 bytes server/error-json.ts | 59 +++++++++++ server/utils/nitro-error-json.ts | 56 ++++++++++ 5 files changed, 141 insertions(+), 113 deletions(-) delete mode 100644 docs/superpowers/specs/2026-04-19-global-search-design.md create mode 100644 server/error-json.ts create mode 100644 server/utils/nitro-error-json.ts diff --git a/docs/superpowers/specs/2026-04-19-global-search-design.md b/docs/superpowers/specs/2026-04-19-global-search-design.md deleted file mode 100644 index fb48849..0000000 --- a/docs/superpowers/specs/2026-04-19-global-search-design.md +++ /dev/null @@ -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/…`),并按计划开发与验证。 diff --git a/nuxt.config.ts b/nuxt.config.ts index c4473f5..b11e394 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -1,4 +1,29 @@ // https://nuxt.com/docs/api/configuration/nuxt-config +import { existsSync } from "node:fs"; +import { createRequire } from "node:module"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const configDir = dirname(fileURLToPath(import.meta.url)); +const require = createRequire(import.meta.url); + +function resolveNuxtNitroErrorHandler(): string { + const path = join( + dirname(require.resolve("nuxt/package.json")), + "..", + "@nuxt", + "nitro-server", + "dist", + "runtime", + "handlers", + "error.mjs", + ); + if (!existsSync(path)) { + throw new Error(`Missing Nuxt Nitro error handler: ${path}`); + } + return path; +} + export default defineNuxtConfig({ compatibilityDate: '2025-07-15', modules: ['@nuxt/ui'], @@ -15,6 +40,7 @@ export default defineNuxtConfig({ } }, nitro: { + errorHandler: [join(configDir, "server/error-json.ts"), resolveNuxtNitroErrorHandler()], experimental: { tasks: true, }, diff --git a/packages/drizzle-pkg/db.sqlite b/packages/drizzle-pkg/db.sqlite index f4a963cd58f786fa12326e43f03a3d1b1cf09638..5bbb0add419387eb916b87a4df7d6cd37559a17f 100644 GIT binary patch delta 1106 zcmb7DO>7%Q6rP#&ZoG}X&q-`qZb+NBF%2LGZ@vDBOfgcWs?dtI2pSF$;>4TEBDF2s zQDcx2Zz6#rgtla$E76vpRN+*zZQa&tkwAcY>LDC*$srVItX4=#fFf0(C}A9PC^xF7 zk-mBF&HLtiGp|$2$ovnea}zqkR;*y0};H|EW-(JN#<9uu5PR9#oq8 z@h{-DaxOeHGWaGDxE67=IGRe^p@b~pM6d`~zzGK!g$1Znm$|Xt>-2ygvX1qBj}TH^ z)T6i28NJ!Ji}1T$t$bp5ICJtP^>`{@P;-Uch&qtT46BJWyYg7CNf-4_R$}JzDdLbW zAPZa2Azgq8IL0W>N)36^AjY(vbaT#oD4$mg`KRSbBpeDViV{^6*&hukfp8!c3i#!o zJz-h)%aL$cR)V33yeAP2`v-HGY*tNnpHv63`9dz0KF)}*?jNTe=8pD@Gu0bstDj9& zr`~@kR(#ST;F#UR6N*hYy=96vZK1o16L4h9dE4C4pe^{A+L!4?v(svPc$48LBJ`5k zQeHEg+Xc2o4peUV;j)C!mv8*|0=BU|g1=w|mSMT<_~}n*u6d;U4;xdwB+>gDk6YOJ zyR>c7TLa(HeRXh#rs`mX>YKZ2p%>}3I@n!a+T8BoD*WNf-+;IE*Xb5v8m6TUR(4e~ zHpcMv+8M9$)i{2>p0EzTFAgIkwghd-G8UTG@f+hDAH zimh!dQcFUjUGgEpwt?*5!6E5qY0}YQA(O3nyvAaj KbaGeszy1#+mrL9L delta 821 zcma))O-NKx7>3U|ckYbWnR`Be8%EP+O3NPv8aaS%Jj@{Y2}>{o zAM}Ke-yq3C*JG=XLm$ZC1qWSc?dGVmzO~nsWws(EV|%wLi)T<10}}&3gKUmLczcg2 zn=qJ&0k$H2ZaXc33wUoCzW~QK*f! zR$xg_JTeFBQWy(qJclt)I8G4a*-jj+#fCOu4OWfBAf5+xc?8F$n>d8o5E4Y1jd;= 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 = { + error: true, + url: url.href, + statusCode, + statusMessage, + message, + }; + if (error.data !== undefined) { + body.data = error.data; + } + + return send(event, JSON.stringify(body)); +}); diff --git a/server/utils/nitro-error-json.ts b/server/utils/nitro-error-json.ts new file mode 100644 index 0000000..bd59063 --- /dev/null +++ b/server/utils/nitro-error-json.ts @@ -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, + }; +}