# notebook **Repository Path**: sean_project/notebook ## Basic Information - **Project Name**: notebook - **Description**: 笔记本项目 - **Primary Language**: Java - **License**: Apache-2.0 - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 0 - **Forks**: 0 - **Created**: 2026-09-08 - **Last Updated**: 2026-09-08 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # 笔记应用 Notebook 参考「有道云笔记」风格的个人笔记应用:左侧**分类 / 标签树**、中间**笔记列表**、右侧 **Markdown 编辑器 + 实时预览**。支持多用户注册登录,每个用户的数据完全隔离;笔记**正文以 AES-256-GCM 加密**存储到数据库,标题明文以便检索。 **技术栈**:Spring Boot 3.3.4 · Java 17 · Spring Data JPA · PostgreSQL 16 · Thymeleaf · Spring Security · JWT (jjwt 0.12.6) · BCrypt 个人觉得当前各种安全问题,笔记最好自己存起来,我的应用场景: Windows11 安装一个Docker Desktop,运行下面命令即可跑起来。 ``` xxx\notebook> .\build.bat xxx\notebook> docker compose up -d --build xxx\notebook> docker compose ps ``` --- ## 目录 1. [功能总览](#1-功能总览) 2. [数据模型与加密方案](#2-数据模型与加密方案) 3. [认证与会话(令牌模型)](#3-认证与会话令牌模型) 4. [标签多级与分类树](#4-标签多级与分类树) 5. [搜索机制](#5-搜索机制) 6. [文件导入能力](#6-文件导入能力) 7. [目录结构](#7-目录结构) 8. [构建与运行](#8-构建与运行) 9. [配置项(application.yml / 环境变量)](#9-配置项applicationyml--环境变量) 10. [REST API 速览](#10-rest-api-速览) 11. [数据库迁移与启动引导(DataInitializer)](#11-数据库迁移与启动引导datainitializer) 12. [默认账号与种子数据](#12-默认账号与种子数据) 13. [辅助 SQL 脚本(scripts/)](#13-辅助-sql-脚本scripts) 14. [环境坑与注意事项](#14-环境坑与注意事项) 15. [已知限制 / 后续可扩展](#15-已知限制--后续可扩展) --- ## 1. 功能总览 ### 账号与会话 - 🔐 **多租户注册 / 登录** —— 短效 access token(默认 30 分钟)+ 可撤销 refresh token(默认 30 天,轮换式) - 🔑 **改密 / 改用户名 / 改邮箱** —— 改密可一键把其他设备踢下线 - 💻 **多设备会话管理** —— 查看所有在线设备(系统 / 浏览器 / IP / 最近活跃),可单台下线或全部下线 - 👤 **数据完全隔离** —— 每个用户只能看 / 改 / 删自己的笔记、分类、标签、附件(所有查询都带 `user_id` 作用域) ### 笔记组织 - 📁 **分类(笔记本)**:支持多级树状结构,可新建 / 改 / 删子分类;侧栏树形展示,可折叠;显示分类内有效笔记数 - 🏷 **标签(多级)**:支持多级树(`parent` 自关联),同一显示名可出现在不同父级下;**同级判重**、防成环;删除标签时子标签自动上提、清掉关联 - ⭐ **收藏** 与 🗑 **回收站**(软删除,可恢复 / 彻底删除) - 🔎 **列表侧标签 / 分类过滤**:可同时按多个标签(含其全部子孙)过滤 ### 编辑器与渲染 - 📝 **Markdown 编辑器**:工具栏快捷插入语法,右侧**实时预览**(后端 commonmark + GFM 表格渲染) - 🎨 **预览排版优化**:`.markdown-body` 全面样式升级(行高、h1–h6、嵌套列表、引用、代码块、表格斑马纹+横向滚动、图片圆角、GFM 任务列表去项目符等) - 🔦 **搜索命中高亮**:列表标题 / 摘要(snippet) / 标签名中的关键词用 `` 高亮 - 🖱 **顶栏登录名 chip**:登录用户名做成可点击的醒目 chip(带 SVG 图标),hover 高亮提示可点,点击展开账户菜单 ### 安全确认 - ⚠️ **删除二次确认**:删除笔记(移入回收站)、彻底删除、删分类、删标签、退出登录等危险操作统一走 `ConfirmModal`(支持 ESC 取消 / Enter 确认 / danger 红色按钮) ### 导入 - 📥 **PDF / Word(.docx/.doc) → 可编辑 Markdown 笔记**(结构化抽取,保留标题 / 列表层级) - 📦 **批量 ZIP 导入**(如有道笔记导出 / 目录式备份):按目录自动建分类 / 笔记 / 附件,**幂等** upsert - 🗜 其他文件(图片、Excel、压缩包…)作为**附件**保存(附件字节加密) - ⬆ 导入支持最大文件 1GB / 请求 10GB --- ## 2. 数据模型与加密方案 ### 当前加密模型(重点!) > 这是本项目迭代后确定的模型,与「标题正文都加密」或「正文保留明文做全文检索」的旧版本都不同。 | 字段 | 存储方式 | 说明 | | --- | --- | --- | | **标题 `title`** | **明文** | 主检索键,可被 `ILIKE` 搜索、排序、展示,无需解密 | | **正文 `content`** | **密文** | AES-256-GCM(owner 的 DEK),存于 `content_ciphertext` | | 历史列 `title_ciphertext` | 仅兼容旧行 | 新写入一律清空,不再使用 | | 历史列 `content` / `content_text` | **恒为空串** | 任何地方都不持久化明文正文 | **推论**:正文在数据库里没有明文 → **正文不可全文搜索**。搜索只按「标题 + 标签名」执行。这是刻意取舍(为隐私换取可见度),见 §5。 **统一加解密入口**:`security/NoteCipher.java` —— 所有读写笔记正文/标题的代码都必须经它,否则明文会泄漏回数据库。 - `writeTitle(note, title)`:写明文标题,清 `title_ciphertext` - `writeContent(note, md, dek)`:加密正文到 `content_ciphertext`,清 `content` / `content_text` - `readTitle / readContent`:读时兼容未迁移的旧明文/旧密文行;DEK 错误时返回可读提示(如「解密失败:主密钥可能已更换」),不会因一行坏数据打挂整个列表 - `wipe(dek)`:用后把 DEK 字节清零 ### 密钥体系(信封加密) | 密钥 | 生成 | 存储 | 用途 | | --- | --- | --- | --- | | **Master Key (KEK)** | 启动时从 `app.crypto.master-key` 读取 | `application.yml` / 环境变量 / 密钥管理 | 包装每个用户的 DEK | | **User DEK** | 注册时随机生成 32 字节 | 数据库 `users.encryption_key_encrypted`(用 master key 包裹) | 加密该用户的正文 / 附件 | | **每条记录 IV** | AES-GCM 加密时随机 12 字节 | 与密文一同存(`content_ciphertext` / `data_ciphertext` 格式 = `IV \|\| ciphertext+tag`) | GCM 必备 | **威胁模型**: - 仅数据库泄露(无 master key)→ 攻击者拿到的是包裹后的 DEK + 密文,无法解密 - 数据库 + master key 同时泄露 → 所有数据可解 > ⚠️ 换 master key 会让**已加密的正文/附件无法解密**(DEK 是用旧 key 包裹的),所以第一次部署前就该定好,不要随便轮换。 ### 模型文件一览(`model/`) - `Note` —— userId、明文 title、密文正文、category、多对多 tags、starred/deleted/deletedAt/createdAt/updatedAt、importPath - `Category` —— 多级(parent 自关联)、sortOrder、noteCount(聚合) - `Tag` —— 多级(`@ManyToOne parent`)、sortOrder、color;**去掉全局唯一约束**(名称改为 app 层同级判重) - `Attachment` —— 密文字节 `data_ciphertext`、note 归属、文件名/MIME/大小 - `User` / `RefreshToken` —— 账号与可撤销会话(refresh 只存 SHA-256) --- ## 3. 认证与会话(令牌模型) | 令牌 | 有效期(可配) | 存放 | 说明 | | --- | --- | --- | --- | | **access token** | 30 分钟 `app.jwt.access-ttl-seconds` | 浏览器 `localStorage.notebook_token` | 每次 API 的 Bearer token;含 `sid`(会话 id)与 `ver`(令牌版本)声明 | | **refresh token** | 30 天 `app.jwt.refresh-ttl-seconds` | 浏览器 `localStorage.notebook_refresh` | 只用于 `/api/auth/refresh`;服务端只存其 **SHA-256**,**每次使用都轮换**(防重放) | **撤销生效方式**(即时,无需等 token 过期): 1. **会话级**:每行 `refresh_token` = 一个登录会话。撤销该行 → 该设备的 access token 因 `sid` 失效。 2. **账号级**:改密 / 「下线其他设备」会自增 `users.token_version`,所有已签发 access token 因 `ver` 不匹配立即失效(当前设备会拿到新的)。 > 改密**不会**重新加密笔记:DEK 由 master key 包裹,与登录密码无关,改密只是一次 BCrypt 哈希替换。 **前端静默续期**:收到 401 → 用 refresh token 调 `/api/auth/refresh` 换新 token 并重试一次,失败才跳登录页(见 `app.js` 的 `authFetch` / `doRefresh`)。 `security/` 包:`SecurityConfig`(安全配置)、`JwtAuthFilter`(Bearer 校验)、`JwtService`(签发/解析)、`CurrentUser`(从 token 取当前 userId)、`UserCryptoService`(DEK 包裹/解包)、`AesGcmCrypto`(加解密原语)、`Pbkdf2Kdf`、`NoteCipher`(统一入口,见 §2)。 --- ## 4. 标签多级与分类树 ### 标签(Tag) - **数据模型**:`parent` 自关联 + `sort_order`;名称**非全局唯一**(同一名字可存在于不同父级下),已删掉 `(user_id, name)` 唯一约束与旧 `tag_name_key` - **同级判重**:app 层按「父级 + 名称」忽略大小写判重(`existsByUserIdAndParentIsNullAndNameIgnoreCase` / `existsByUserIdAndParentIdAndNameIgnoreCase`) - **防成环**:改父级时若 `findDescendantIds(id)` 里已含目标父级 → 拒绝(不能设自身或子孙为父) - **删除**:先把所有直接子标签的 `parent` 上提到被删标签的父级(或根),再清 `note_tags` 关联,最后删标签;子标签下的笔记不受影响 - **子孙展开**:点父标签过滤时会递归展开所有子孙标签的笔记(`findDescendantIds` CTE);侧栏计数 `countActiveByAnyTagId` 按「任意命中」统计(DISTINCT note) - **API**: - `GET /api/tags/tree` —— 返回根树 `TagNodeResponse{id,name,color,parentId,parentName,sortOrder,noteCount,children}`,侧栏用 - `GET /api/tags` —— 扁平列表(同结构,含 depth 感知),编辑器标签选择器用 - `POST/PUT/DELETE /api/tags[/{id}]` —— 增改删(`TagRequest{name,color,parentId}`) - **前端**:侧栏标签树可折叠 / 显示计数 / 加子标签 / 删除;`flattenTags` 摊平供编辑器选择 ### 分类(Category) - 同样是多级树(seed 里「我的笔记本 → 工作 / 生活」);`GET /api/categories` 返回树;增改删走 `/api/categories[/{id}]` - 删除分类会把该分类(含子孙)下的笔记 `category = null` 解绑,避免外键冲突 --- ## 5. 搜索机制 **搜索范围 = 标题 + 标签名**(正文已加密,永不参与搜索)。 `NoteRepository.search`(原生 SQL): ```sql SELECT n.* FROM note n WHERE n.user_id = :userId AND coalesce(n.deleted, false) = false AND ( lower(coalesce(n.title, '')) LIKE lower(:likeKw) OR EXISTS ( SELECT 1 FROM note_tags nt JOIN tag t ON t.id = nt.tag_id WHERE nt.note_id = n.id AND lower(t.name) LIKE lower(:likeKw) ) ) ORDER BY n.updated_at DESC ``` - 已**移除**正文 FTS(旧版 `note_fts_idx` + `to_tsvector` / `plainto_tsquery` 已删除,见 DataInitializer) - 关键词用 `%...%` 包裹做子串匹配(`lower() ILIKE` 等价),对英文大小写不敏感 - 因为不再对正文做 FTS,因此不再需要中文分词扩展才能搜到正文(正文本来也搜不到) --- ## 6. 文件导入能力 统一入口 `ImportApiController`(`/api/import`),路由到 `NoteService` / `ZipImportService`: ### 单文件导入 `/api/import`(multipart `file`) 按 MIME / 扩展名分流: - **Word `.docx` / `.doc`** → `WordImportService` → **结构化 Markdown** - **PDF** → `PdfImportService` → **结构化 Markdown** - 其他文件(图片 / Excel / 压缩包…)→ 保存为**加密附件**,挂到一条笔记 > 导入后把原文转成**可编辑 Markdown 笔记**(有独立标题 / 正文),标题走明文、正文走 `NoteCipher.writeContent` 加密落库。 ### Word 抽取要点(WordImportService) - 用 `XWPFDocument.getParagraphs()` **逐段遍历**(每段间补 `\n\n`),**不用** `XWPFWordExtractor.getText()`(会把整篇压成单行) - 标题识别:按段落 `getStyle()`(Title / Heading 1–6,兼容中英文)映射为 `#..######` - 列表识别:`getNumID()!=null && numFmt.signum()>0` + style 关键词 → 前缀 `-` / 编号;**刻意不调 `getNumFmt()`**(POI 5.x 在引用不存在的 abstractNumId 时内部 NPE) - 每段 `try/catch RuntimeException` 降级为纯文本,某段解析失败不影响整篇 ### PDF 抽取要点(PdfImportService) - `PdfBox 3.0.3` 抽取文本后接 `rebuildAsMarkdown()` **启发式重建**: - 原文单个换行 → 升级为 `\n\n`(恢复段落) - 识别列表起首:`1.` / `1.1` / `•` / `-` / `(1)` / `一、` - 保留 `标签:值` 结构;2+ 空格分隔的多列独立成段;3+ 空行折叠;首尾去空行 - 特殊排版若仍「糊成一行」,把片段贴回来继续调启发式 ### 批量 ZIP `/api/import/zip` - `ZipImportService` + `YoudaoNoteParser` 解析目录/压缩结构(如有道笔记导出) - 按路径自动建分类 / 笔记 / 附件,**幂等**:`findFirstByUserIdAndImportPathAndDeletedFalse` —— 重复导入同一 ZIP 只更新不重复;**回收站里的同名笔记不会被覆盖**(只看未删除) ### 导入错误处理 - `NoteService.importWord` / 单文件导入包 `try/catch RuntimeException` → 转成 `IllegalArgumentException`(400 + 真实中文原因),避免前端拿到笼统的 500 兜底文案 --- ## 7. 目录结构 ``` notebook/ ├── build.bat 一键打包脚本(Windows,见 §8) ├── build.sh 一键打包脚本(Git Bash / Linux) ├── Dockerfile 容器构建(COPY 本地 fat jar,不在容器内编译) ├── docker-compose.yml 本地一条命令起 PostgreSQL16 + app(见 §8) ├── settings.xml 项目级 Maven settings(绕开本机损坏代理,直连中央仓库) ├── pom.xml Spring Boot 3.3.4 parent ├── scripts/ │ ├── auth_schema.sql 幂等建登录/会话表(refresh_token, token_version) │ └── fix_multitenant_schema.sql 存量库一次性修复 user_id 等列(见 §13) └── src/main/ ├── java/com/example/notebook/ │ ├── NotebookApplication.java │ ├── DataInitializer.java schema 迁移 + 旧数据迁移 + 默认 admin + 种子(见 §11) │ ├── model/ Note / Category / Tag / Attachment / User / RefreshToken │ ├── repository/ NoteRepository / CategoryRepository / TagRepository │ │ AttachmentRepository / UserRepository / RefreshTokenRepository │ ├── security/ SecurityConfig / JwtAuthFilter / JwtService / CurrentUser │ │ UserCryptoService / AesGcmCrypto / Pbkdf2Kdf / NoteCipher │ ├── service/ NoteService / CategoryService / TagService / AuthService │ │ PdfImportService / WordImportService / ZipImportService │ │ YoudaoNoteParser / MarkdownService │ ├── controller/ AuthController / PageController │ │ Note / Category / Tag / Markdown / Import / Attachment ApiController │ ├── dto/ NoteRequest/Response / CategoryRequest/Response / TagRequest │ │ TagResponse / TagNodeResponse / DeviceInfo │ └── exception/ GlobalExceptionHandler / ResourceNotFoundException └── resources/ ├── application.yml ├── templates/index.html 三栏主页面(Thymeleaf) ├── static/login.html / register.html ├── static/css/style.css └── static/js/app.js 主前端逻辑(token / 401 自动续期 / 树 / 编辑器 / ConfirmModal) account.js 账户设置弹窗(资料 / 改密 / 设备会话) ``` --- ## 8. 构建与运行 ### ⚠️ 协作约定(本机) 构建 / 启动 / 部署 / 测试一律由**用户自己执行**;本仓库只改代码与配置,不给用户代跑 `mvn` / `docker` / 应用。 ### 一键打包 项目根目录已内置 `build.bat`(纯 ASCII 防 cmd/GBK 乱码),内部已写好: - `JAVA_HOME=C:\Program Files\Java\jdk-17`(可用环境变量覆盖) - `MAVEN_HOME=D:\tools\apache-maven-3.9.9` - `mvn.cmd -gs settings.xml -s settings.xml -DskipTests -B clean package`(用项目内 `settings.xml` 绕开本机 `~/.m2/settings.xml` 里损坏的代理配置) **直接跑 `build.bat`**(别再手写这些命令)。产物:`target/notebook.jar`。Linux / Git Bash 用等价 `build.sh`。 ### 本地运行(JDK17 + Maven + PostgreSQL) 1. 准备库: ```sql CREATE DATABASE notebook; CREATE USER notebook WITH PASSWORD 'notebook'; GRANT ALL PRIVILEGES ON DATABASE notebook TO notebook; ``` 2. 跑 `build.bat` 打包。 3. 启动:`java -jar target/notebook.jar` 4. 访问 **http://localhost:8080/login.html**,用 `admin / admin123` 登录。 ### Docker 运行(一条命令) 先起 Docker Desktop,然后: ```bash docker compose up -d --build ``` 自动拉起 **PostgreSQL 16** + **app**。访问 **http://localhost:18080/login.html**(宿主 18080 → 容器 8080;Windows 上 8080 常被 Hyper-V/WSL 保留端口占用,故绕到 18080)。容器名:`notebook-db` / `notebook-app`。 > 想改回 8080:先释放占用,把 `docker-compose.yml` 里 app 的 `127.0.0.1:18080:8080` 改回 `127.0.0.1:8080:8080`,再 `docker compose up -d`。 ### 覆盖默认密钥(生产推荐) ```bash # 32 字节 master key(base64) export CRYPTO_MASTER_KEY="$(openssl rand -base64 32)" # ≥32 字节 JWT 签名密钥(base64) export JWT_SECRET="$(openssl rand -base64 48)" java -jar target/notebook.jar ``` --- ## 9. 配置项(application.yml / 环境变量) | 配置键 | 环境变量 | 默认 | 说明 | | --- | --- | --- | --- | | `spring.datasource.url` | `DB_URL` | `jdbc:postgresql://localhost:5432/notebook` | 数据源 | | `spring.datasource.username` | `DB_USER` | `notebook` | 用户 | | `spring.datasource.password` | `DB_PASSWORD` | `notebook` | 密码 | | `server.port` | `SERVER_PORT` | `8080` | 端口 | | `spring.jpa.hibernate.ddl-auto` | `DDL_AUTO` | `update` | 建表策略 | | `app.jwt.secret` | `JWT_SECRET` | (本地默认值) | **≥32 字节**签名密钥(base64) | | `app.jwt.access-ttl-seconds` | `JWT_ACCESS_TTL` | `1800` | access 有效期(秒) | | `app.jwt.refresh-ttl-seconds` | `JWT_REFRESH_TTL` | `2592000` | refresh 有效期(秒) | | `app.crypto.master-key` | `CRYPTO_MASTER_KEY` | (本地默认值) | **必须解出恰好 32 字节**的 AES-256 主密钥(base64) | | `spring.servlet.multipart.max-file-size` | — | `1GB` | 单文件上限 | | `spring.servlet.multipart.max-request-size` | — | `10GB` | 单请求上限 | > ⚠️ 生产环境把两个密钥放到**密钥管理服务 / 环境变量**,不要写死在 `application.yml`。 > > ⚠️ master key 用 `openssl rand -base64 32` 生成;手写英文句子(如 `master-key-32-bytes-for-local-development`)是 41 字节会启动失败:`IllegalStateException: app.crypto.master-key must decode to exactly 32 bytes`。 --- ## 10. REST API 速览 | 方法 | 路径 | 说明 | | --- | --- | --- | | POST | `/api/auth/register` | 注册:`{username,password,email?}` → `{accessToken,refreshToken,user,accessTtlSeconds,refreshTtlSeconds}` | | POST | `/api/auth/login` | 登录 → 同上 | | POST | `/api/auth/refresh` | 用 refresh 换新 access(旧 refresh 作废,轮换) | | POST | `/api/auth/logout` | 退出:撤销会话 | | GET | `/api/auth/me` | 当前用户 | | POST | `/api/auth/password` | 改密:`{oldPassword,newPassword,revokeOthers?}` | | PUT | `/api/auth/profile` | 改用户名/邮箱(改用户名返回新 access token) | | GET | `/api/auth/sessions` | 在线设备列表(含 `current` 标记) | | DELETE | `/api/auth/sessions/{id}` | 下线指定设备 | | POST | `/api/auth/sessions/revoke-others` | 下线其他所有设备 | | GET | `/api/notes?categoryId=&tagId=&q=&starred=&deleted=` | 笔记列表;`tagId` 可多值 `List`,`categoryId` 含子孙 | | GET | `/api/notes/{id}` | 详情(自动解密正文/标题) | | POST | `/api/notes` | 新建 | | PUT | `/api/notes/{id}` | 更新 | | DELETE | `/api/notes/{id}` | 移入回收站(软删) | | POST | `/api/notes/{id}/restore` | 恢复 | | DELETE | `/api/notes/{id}/permanent` | 彻底删除 | | POST | `/api/notes/{id}/star` | 切换收藏 | | POST | `/api/import` | 单文件导入(multipart `file`) | | POST | `/api/import/zip` | ZIP 批量幂等导入 | | GET | `/api/attachments/{id}` | 下载附件(解密后输出) | | GET | `/api/categories` | 分类树 | | POST/PUT/DELETE | `/api/categories[/{id}]` | 分类增改删 | | GET | `/api/tags/tree` | 标签根树(侧栏) | | GET | `/api/tags` | 标签扁平列表(编辑器选择器) | | POST/PUT/DELETE | `/api/tags[/{id}]` | 标签增改删(支持 `parentId`,同级判重、防环) | | POST | `/api/preview` | Markdown → HTML(commonmark + GFM) | > 除 `register / login / refresh / logout`、`login.html` / `register.html` 外,所有 API 都要 `Authorization: Bearer `,否则 401;前端自动续期一次,失败才跳登录页。 --- ## 11. 数据库迁移与启动引导(DataInitializer) `DataInitializer` 是 `ApplicationRunner`,在 EntityManagerFactory **之后**运行,负责幂等地把库迁移到「多租户 + 正文加密」的当前模型,**跑在 JPA 建表之后,因此不可能导致 EMF 创建失败**。顺序: 1. 把 `user_id` 为 NULL/0 的历史行**指派给 admin**(老单用户数据的归属迁移) 2. 给 `tag` 补 `parent_id` / `sort_order`,`sort_order` 空值补 0 3. 放宽 `attachment.data` 的 NOT NULL(Hibernate `ddl-auto=update` 不会 drop NOT NULL,需自删) 4. 清理过期 refresh_token 5. 重建唯一约束:drop `uk_note_import_path` → 建 `uk_note_import_path_active`(只约束未删除行,保证回收站不挡重复导入) 6. 删除废弃索引 `note_fts_idx` 7. 建多租户 / 标签父级索引(`idx_note_user` / `idx_category_user` / `idx_tag_user` / `idx_tag_parent` / `idx_attachment_user` / `idx_attachment_note`)与 `refresh_token` 表(`ensureAuthSchema`) 8. 若存在 admin 则 `migrateLegacyRowsTo(admin)`,把每条旧笔记塑形为当前模型(幂等,只处理 `content_ciphertext IS NULL` 的行): - 旧 `title_ciphertext` 解密回明文 `title`;旧明文 `title` 保留 - 旧 `content` 明文 → 加密进 `content_ciphertext`,清 `content` / `content_text` - 旧附件 `data` 明文 → 加密进 `data_ciphertext`,清 `data` - 删除 `tag` 上的旧全局唯一约束(`tag_name_key` / `uk_tag_user_name`) 9. `seedIfEmpty(admin)`:若 admin 还没有任何笔记 → 造「我的笔记本 → 工作 / 生活」分类、默认「重要 / 待办」标签、欢迎笔记 > Hibernate `halt_on_error=false`:某条 DDL 失败只记日志不致命,DataInitializer 会幂等地补建。 --- ## 12. 默认账号与种子数据 首次启动自动创建 admin(`users` 表无该用户时): | 用户名 | 密码 | | --- | --- | | `admin` | `admin123` | > ⚠️ 登录后立即改密;生产务必先改再上线。 种子(仅当 admin 无任何笔记时):分类「我的笔记本→工作/生活」、标签「重要(#ef4444)」「待办(#f59e0b)」、一篇说明当前模型/技巧的欢迎笔记。 --- ## 13. 辅助 SQL 脚本(scripts/) **导入方式**:PowerShell 不支持 `<` 重定向,用 `docker cp scripts/xxx.sql notebook-db:/tmp/x.sql` + `docker exec notebook-db psql -U notebook -d notebook -f /tmp/x.sql`; Git Bash / Linux 可直接 `docker exec -i notebook-db psql -U notebook -d notebook < scripts/xxx.sql`。 - `auth_schema.sql`:幂等建登录/会话表(`refresh_token` + `idx_refresh_token_user` + `users.token_version`) - `fix_multitenant_schema.sql`:**存量库一次性修复**。为什么需要:`ddl-auto=update` 对已有行的表 `alter ... add column user_id bigint not null` 会被 Postgres 以「含 null 值」拒绝;DataInitializer 虽在启动后修列,但依赖的索引不会建。在**启动前**跑一次让 Hibernate 无事可做;同时放开 `attachment.data` 的 NOT NULL。 --- ## 14. 环境坑与注意事项 - **Git Bash 下 `mvn` 走 POSIX 路径**会让 java.exe 认不到 classpath → 必须用 `mvn.cmd` + Windows 风格路径(`build.bat` 已处理) - **PowerShell 不支持 `<` 输入重定向**(`RedirectionNotSupported`),往容器灌 SQL 见 §13 - **Hibernate `BeanCreationException`**:根因看**最后一个 `Caused by`**,前面 `SessionFactoryObserverChain` 等只是调用链 - **`DataInitializer` 是 `ApplicationRunner`**,跑在 EMF 之后 → 不会造成 EMF 创建失败;排错时别怀疑它导致启动崩溃 - **密钥必须机器生成**(`openssl rand -base64 32/48`);手抄英文串会因长度不对启动失败。换 master key 会让已加密数据解不开 - **8080 被 Hyper-V/WSL 保留** → Docker 用 18080(见 §8) - **POI 5.x 陷阱**:`XWPFNumbering.getAbstractNum(...)` 引用不存在的 abstractNumId 会 NPE → 列表判断改用 `getNumID()`;`XWPFWordExtractor.getText()` 会把整篇压成单行 → 逐段遍历 --- ## 15. 已知限制 / 后续可扩展 - **正文不可全文搜索** —— 当前为「正文加密、搜索仅标题+标签」的刻意取舍。若想恢复正文检索,需在**加密前**另建明文索引/摘要(会削弱加密收益),需权衡 - **数据迁移** —— 自动迁移只覆盖「旧标题/附件明文 + user_id=0」的情况;有自定义存量数据请先备份再升级 - **多设备登录上限** —— 不限制同时在线数,只提供手动下线 - **XSS** —— Markdown 渲染结果直接注入预览,当前为本地可信场景未清洗;上公网请接 OWASP Java HTML Sanitizer - **密码重置** —— 暂未实现忘记密码 / 邮件验证码重置 - **导入启发式** —— PDF/Word 的 `rebuildAsMarkdown` 对特殊排版可能仍不完美;遇到糊版可回贴样例继续调