笔记站 notes · 检索、白皮书、表格与版本
一句话:notes 是站群最复杂的一站,Next.js 14 App Router + Prisma + SQLite,跑在
3102,basePath 为/notes,公网入口https://cag.quest/notes/;它同时承担 Markdown 笔记库、双路检索、白皮书体系、结构化表格、贴纸、附件、页面说明书与版本管理。
一、定位与入口
| 项 | 实测值 |
|---|---|
| 公网地址 | https://cag.quest/notes/(经 portal 3000 反代) |
| 监听端口 | 3102(实测 :: 3102,PID 35316) |
| 进程命令 | node node_modules\next\dist\bin\next start -p 3102 |
| basePath | /notes,trailingSlash: true |
| 计划任务 | KaoyanNotes,Running;动作 ops\launch.ps1 -Name notes |
| 框架 | Next.js 14.2.15 App Router、React 18、Prisma 5.20、SQLite |
| 数据库 | notes/prisma/notes.db(实测 20 张业务表,4.3MB) |
| 数据目录 | notes/data/assets、data/stickers、data/files |
| 构建产物 | .next,可用 NOTES_DIST_DIR 改名规避文件锁 |
实测 http://127.0.0.1:3000/notes/ 返回 200;直连 3102 也返回 200,但因为没有 x-portal-secret,所有身份都会降级为匿名。
二、技术栈与目录结构
notes/
app/
page.tsx 检索首页(含「说明书检索」入口)
note/[id]/ 笔记详情;page.tsx、settings/、版本切换
whitebook/ 白皮书总览、[id] 阅读板、manage/ 管理、note/ 笔记型
import/ 导入(带图 md、目录批量导入)
panes/ 同屏工作台
tree/ 层级编辑台(拖拽 + 撤回 + 快照)
sheets/ 表格库、[id] 详情、upgrade/ 管道表升级
keywords/ 关键字库
favorites/ 我的收藏
versions/ 版本清理
api/ 54 个 route.ts
components/ 全部客户端组件(Navbar、SideNav、NoteFeatureRail、FolderTree、StickerBoard、SheetEditor…)
lib/ 领域层
db.ts Prisma 单例,从 @/generated/db3 导入
identity.ts 解析 X-User-* 与 x-portal-secret
visibility.ts 可见性唯一判定入口
grants.ts 授权读写(canReadDb / canWriteDb)
search.ts 双路检索 + 范围 + 表反查 + 手册排除
manuals.ts 14 个 pageKey ↔ 手册笔记
sheets.ts / sheet-*.ts 表格模型、编辑内核、导出、检索、AI、xlsx
whitebooks.ts 白皮书服务
version-admin.ts 版本清理
prisma/
schema.prisma 20 个模型
notes.db
scripts/ trace-guard.js、files-gc.ts、各类 verify-*.js
manuals/ 14 篇手册的 Markdown 源文
三、数据模型
notes.db 实测 20 张表。按职责分组:
| 表 | 用途 | 关键字段 | 关系 |
|---|---|---|---|
Note | 笔记主表 | title、contentMd、summary、evaluation、category、visibility、ownerId/ownerName、parentId、sortIndex、depth、isWhitebook、readToken | 自关联 NoteTree;一对多版本、关键字、贴纸、授权、附件、手册映射 |
NoteVersion | 笔记版本,永不覆盖 | version、contentMd、isPrimary、direction、keywordHints、whitebookRefs、editedBy | 唯一 (noteId, version);页面正文以 isPrimary 版本为准 |
Keyword | 关键字字典 | name 唯一、normalized 唯一、aliases、category、weight | 多对多 Note |
NoteKeyword | 笔记↔关键字 | weight(0-1)、source(ai/manual) | 唯一 (noteId, keywordId) |
NoteGrant | 单篇授权 | username、access(read/write)、grantedBy | 唯一 (noteId, username);不继承子笔记 |
Whitebook | 白皮书主表 | slug 唯一、kind(meta/guide/note)、category、score、isMeta、parentId、parentNoteId、sourceNoteId | 自关联树;一对多版本 |
WhitebookVersion | 白皮书版本 | version、contentMd、score、isPrimary、isUsable、reviewJson | 唯一 (whitebookId, version) |
SheetDoc | 结构化表格 | shortId 唯一、dataJson(唯一真相)、textDump、version、folder、visibility | 一对多版本与引用 |
SheetFolder | 表格文件夹 | name 唯一、sortIndex | — |
SheetRef | 表↔笔记反向索引 | 复合主键 (noteId, sheetId) | 删除保护 |
SheetVersion | 表格版本 | version、dataJson、note、editedBy | 唯一 (sheetId, version) |
Sticker | 贴纸 | kind(preset/upload/url/text)、坐标 x/y/w/h、anchorKind、rotation、z | 多对一 Note |
PageManual | 页面说明书映射 | pageKey 主键、noteId | 多对一 Note,级联删除 |
UserFavorite | 按账号收藏 | userId、noteId | 唯一 (userId, noteId) |
TreeSnapshot | 目录结构快照 | data JSON(只存 parentId/sortIndex) | 不含正文 |
NoteFile | 笔记附件 | name、storedPath、mime、size、sha256 | 多对一 Note |
TaskJob | 后台生成任务 | kind、status、title、noteId、read | 消息中心持久化 |
SearchHistory | 检索历史 | query、mode、resultCount | — |
TreeEditBatch | 层级调整批次 | ops JSON、undone | 支持撤回 |
AppSetting | 键值配置 | key 主键、value | — |
实测当前:Note 91 篇(public 82、private 9)、NoteVersion 150、Keyword 398、NoteKeyword 413、Whitebook 18(meta 9、guide 9、note 0)、WhitebookVersion 18、SheetDoc 34、SheetVersion 131、SheetRef 24、PageManual 14、NoteFile 88、Sticker 10、TaskJob 10。
四、请求链路
https://cag.quest/notes/...→ Cloudflare → cloudflared →127.0.0.1:3000。- portal 命中
/notes前缀,proxy(req,res,3102,u=>u),不改路径。 - portal 删除客户端自带的
x-user-*与x-portal-secret,再注入自己的密钥与会话身份;x-user-name用 URL 编码。 - notes 的
lib/identity.ts读x-portal-secret,与../portal/data/portal-secret.txt比对;不一致直接返回null(匿名),不报错。 - 服务端组件或 route handler 调
lib/grants.ts/visibility.ts做可见性过滤,再查 Prisma。 - 客户端组件发请求时一律走
/notes/api/.../(带尾斜杠),因为trailingSlash下缺尾斜杠会 308,res.json()会拿到 HTML 而报错。 - 返回 HTML 或 JSON,经 portal 原样回写给浏览器。
五、关键机制
1)双路检索:关键字优先,全文兜底,表内容单独反查。
lib/search.ts 的 searchNotes() 有四种模式:auto、keyword、fulltext,以及内部的 manualsOnly。auto 的流程是:
- 先
suggestKeywords()在本地Keyword字典里做contains匹配,最多取 6 个; - 命中不足 2 个时才调 DeepSeek 做查询扩展(
aiExpandQuery),AI 不可用时静默降级; - 用关键字走
searchByKeywords()做元数据检索; - 关键字结果为空时,兜底
searchFulltext(),对title、summary、contentMd、关联关键字名做contains; - 无论哪条路,都并上
searchSheetNotes()的表格内容命中,结果里带sheet标记; - 普通检索会排除 14 篇使用说明书,避免搜一个概念回来一堆说明书;
manualsOnly则反过来只查手册。
检索范围支持 note(只这一篇)、subtree(这一篇及子笔记)、all(整库)。buildScope() 一次性取全库 id/parentId/title 建图,BFS 求子树,同时算出每篇的路径,所以结果能显示「逻辑推理 / 命题」这样的目录线。
2)可见性与授权:一个入口,四层规则。
lib/visibility.ts 是唯一判定入口,API 不允许自己写 if:
public:任何人可读;写操作要求登录(匿名只读,防止公网随意篡改)。private:只有所有者、管理员、或被NoteGrant授权的用户可读;其他人要在检索词里写@用户名才能命中该用户的私人笔记(parseMention只认 3-20 位合法登录名)。NoteGrant:read可读,write可改内容/贴图/切版本;授权不继承到子笔记,每篇独立。readToken:私密笔记的只读分享令牌,?t=<token>放行,链接即凭证,不进列表、不写授权表;轮换或清空即撤销。
管理员在列表里能看到全部笔记,但详情页仍标明归属。这套规则同时被笔记、贴纸、表格、附件复用;表格还有一条额外规则:笔记公开不等于表公开,检索命中表内容后要先按 viewer 过一遍表的可见性,再决定是否把「表命中」标记带给笔记。
3)白皮书三类与版本正典。
Whitebook.kind 分三类:
meta:元白皮书,最高级、无父白皮书,固定 7 个 slug(whitebook-system、whitebook-gen、whitebook-md、whitebook-judge、whitebook-review、notes-analyze、notes-regen),改版即同步网站提示词;guide:引导型,按category分notes / vocab / workbuddy / portal / general,用作「格式引导」;note:笔记型,由笔记打isWhitebook标记提升而来,用作「风格引导」;sourceNoteId指回笔记。
WhitebookVersion 永不覆盖,只有一个 isPrimary。元白皮书新版若被 AI 判为「过于错误」,会置 isUsable=false 并回滚,该版本留档但不进提示词;reviewJson 保存对比评判结论。白皮书还有「双挂靠」:parentId 是白皮书文件夹,parentNoteId 是笔记型白皮书的父级,两者互斥,置一个清另一个。
4)表格:dataJson 是唯一真相,版本永不覆盖。
SheetDoc.dataJson 存结构化 grid(稀疏 cells + rs/cs 合并 + 列宽行高 + 表头区),不是 Markdown 管道表,也不是图片。所有出口都从它派生:DOM 渲染、复制到 WPS 的剪贴板 text/html、导出 xlsx、导出 md、导出 PNG。正文用围栏引用:```` sheet id=k3m9x2 caption="表 1" ``。每次保存追加一条 SheetVersion;删除表前查 SheetRef 和正文兜底,有引用就 409 拦下。SheetRef 在「建笔记 / 存正文 / 切主版本 / 删笔记」四处同步,少一处就会出现「明明引用了却显示 0 篇」。
5)14 篇使用说明书与 pageKey 映射。
lib/manuals.ts 固定 14 个入口页:home、note、note-settings、import、panes、tree、sheets、sheet-detail、keywords、favorites、whitebook、whitebook-reader、whitebook-manage、versions。页面代码只写 pageKey,manualHref(pageKey) 查 PageManual 得到笔记 id,拼成 /note/<id>/?from=manual:<pageKey>;换绑手册不用改页面代码。手册本身是普通笔记,可改、可版本、可鉴权、可检索;手册笔记删除时 PageManual 级联删除,按钮自然消失,不留死链。实测 PageManual 正好 14 行,与 manuals/ 下 14 个 md 文件一一对应。
6)API 轮廓(实测 54 个 route.ts)。
重点包括:/api/notes(列表/新建)、/api/notes/[id](详情/改/删)、/api/notes/[id]/content(存正文并生成新版本)、/api/notes/[id]/versions/[version](切主版本)、/api/notes/[id]/grants(授权)、/api/notes/[id]/share(只读令牌)、/api/notes/[id]/regenerate(AI 重写)、/api/search(双路检索)、/api/tree + /api/tree/undo、/api/sheets 及 versions/rollback/xlsx/ai/folders/from-xlsx/md-tables、/api/whitebooks 及 children/rejudge/sync/versions/generate/picker、/api/import/folder(目录批量导入)、/api/assets/upload、/api/files、/api/stickers、/api/analyze、/api/generate、/api/jobs、/api/favorites、/api/folders、/api/snapshots、/api/versions。
六、发布与运维
构建:
cd C:\Users\cag\WorkBuddy\2026-10-11-06-00-16\notes
npm run build # = node --require ./scripts/trace-guard.js next build
scripts/trace-guard.js 是构建兜底:Next 的 .next/trace 追加写入在本机偶发 EPERM,未捕获的流错误会直接终止构建,所以把 trace 写入替换成内存 PassThrough。npm run build:plain 保留原始构建,用于确认问题是否真的来自 trace。
部署必须遵守四条铁律:
- Prisma client 输出目录是
generated/db3。schema.prisma的generator.output写死../generated/db3,lib/db.ts从@/generated/db3导入。历史上从generated/prisma换到db、db2、db3,每次都是旧目录的 query engine DLL 被残留 node 进程锁住,prisma generate报 EPERM。遇到锁不要死磕,按约定换目录并同步lib/db.ts。 - 启动前自动 generate。
ops/launch.ps1 -Name notes先杀掉 3102 的监听进程,再执行prisma generate,最后next start。原因是运行中的进程会独占引擎 DLL,只有旧进程刚被杀掉的窗口才能安全生成;这也是「改 schema 只需重启服务」的实现方式。 - 任务必须 Stop 再 Start。
Start-ScheduledTask对已运行任务不生效,只 Start 会出现旧进程还在、新构建没加载,表现为ChunkLoadError或 400。 - BOM 与文件锁。
ops/launch.ps1实测带 UTF-8 BOM(文件头EF BB BF),kaoyannotes.task.xml是 UTF-16;这是 PowerShell 5.1 正确解析中文注释与中文路径的前提,重写启动脚本时不要存成「UTF-8 无 BOM」。public/在生产构建时会被冻结,运行期新增图片/附件必须放data/assets、data/stickers、data/files,由 API 路由读盘,否则构建后访问不到。
常见故障:
- 页面正文空白但标题正常:脚本直接
note.create()时没建NoteVersion。页面渲染的是versions[0],不是Note.contentMd。 - 保存表格报 409:
SheetRef显示还有笔记引用;先改引用或删引用,不能强删。 - 直连 3102 时
/assets/guide.js404:该脚本由门户提供,属于预期降级。 - 上传大文件 524:Cloudflare 等待超时,改用「目录批量导入」让服务端直接读盘。
- Prisma EPERM:按第 1 条换输出目录,或管理员权限清理孤儿 node 进程。
七、现状与改进点
结构债一:单库单体,领域全塞在一起。 20 张表覆盖笔记、白皮书、表格、贴纸、附件、任务、快照、收藏、手册,lib/ 下 50 多个模块。任何一个功能改动都可能触碰共享的 visibility / grants / search,回归面很大。
结构债二:Prisma 输出目录靠「换名字」续命。 db3 不是设计,是 Windows 文件锁的妥协。长期应把 generate 放到独立构建容器/临时目录,或在启动前确保没有孤儿进程,而不是无限 db4、db5。
结构债三:检索是 contains 全表扫描。 91 篇笔记时无感,上千篇后 contentMd contains 会明显变慢;SQLite 没有启用 FTS5。当前靠 take 30 和范围过滤控制成本。
风险:手册与普通笔记共用一张表。 好处是复用版本/授权/检索,坏处是每次普通检索都要先查 PageManual 排除手册,漏一处就会把说明书混进结果。这个「隐式排除」应做成检索层的显式参数,而不是每个调用点自己记得。
建议:优先把「正文版本」与「Note.contentMd 缓存」的关系收敛成一个写入口,避免脚本造数据时再出现空正文;其次给检索加 FTS5 虚表与触发器;最后把 Prisma 生成流程从「换目录」改成「构建期固定目录 + 启动前健康检查」。