Ver código fonte

doc(docs): 文档v1.0完成

kairo 1 dia atrás
pai
commit
c50d6a8631

+ 74 - 0
README.md

@@ -0,0 +1,74 @@
+下面是 `README.md` 初稿。根目录文件,定位是**索引 + 工作流入口**,不重复任何文档的内容。
+
+---
+
+# Haven
+
+自托管的个人综合知识中台。PostgreSQL 为唯一真值源,Go + Gin 后端,Vue 3 前端,公开域与私有域分离部署。本期只做 Web 端,TUI 与 Neovim 插件在第二版。
+
+## 文档即事实来源
+
+本项目所有代码由 opencode + DeepSeek 依据 `docs/` 生成,**文档是唯一事实来源**:
+
+- 代码与文档冲突时,以文档为准;
+- 改需求先改文档,再重新生成代码;
+- `docs/` 只能由人类修改,AI 只读不写。
+
+完整规则见 `docs/conventions.md`。
+
+## 文档索引
+
+### 项目级(`docs/`)
+
+| 文件 | 回答什么问题 |
+|---|---|
+| `overview.md` | Haven 是什么、面向什么场景、由哪几部分组成 |
+| `architecture.md` | 技术栈、目录结构、组件关系、配置项、部署形态 |
+| `conventions.md` | 文档与代码的组织规则、主从关系、硬性禁令清单 |
+| `api.md` | 每个 HTTP 接口的路径、请求、响应、鉴权、错误码 |
+
+### 后端(`docs/backend/`)
+
+| 文件 | 回答什么问题 |
+|---|---|
+| `data-model.md` | 数据库有哪些表、字段、约束、索引、业务规则 |
+| `auth.md` | 鉴权分层、会话、授信设备、API Token、授权中间件 |
+| `audit.md` | 审计记录哪些事件、表结构、写入时机 |
+| `llm-extension.md` | LLM 抽象层接口定义(本期只定义不实现) |
+
+### 前端(`docs/frontend/`)
+
+| 文件 | 回答什么问题 |
+|---|---|
+| `frontend.md` | 公开域与私有域的页面清单、路由、登录流程 |
+| `clients.md` | TUI 与 Neovim 插件(第二版,本期占位) |
+
+## 仓库结构
+
+```
+Haven/
+├── docs/           # 唯一事实来源
+├── backend/        # Go 后端(Gin + database/sql + pgx)
+├── frontend/       # Vue 3 + Vite 前端
+├── deploy/         # 部署脚本(如需要)
+└── README.md
+```
+
+后端代码只写 `backend/`,前端代码只写 `frontend/`,不得交叉。
+
+## 工作流
+
+生成代码时,按任务类型喂上下文:
+
+- **生成后端代码**:`docs/` 根下项目级文件 + `docs/backend/` + `docs/api.md`;
+- **生成前端代码**:`docs/` 根下项目级文件 + `docs/frontend/` + `docs/api.md`。
+
+生成结果与文档不符时,是代码错,修代码或重新生成,不改文档去迁就代码。
+
+## 快速开始
+
+(占位,部署方式待 Gogs 备案通过后确定,届时补入。已知约束:不使用 Docker / K8s,宿主机直接运行。)
+
+## 技术栈
+
+Go ≥ 1.22 / Gin / PostgreSQL ≥ 15(`pg_trgm`)/ Vue 3 / Vite / TypeScript。详见 `docs/architecture.md`。

+ 895 - 0
docs/api.md

@@ -0,0 +1,895 @@
+# api — HTTP 接口契约
+
+> 本文件回答:每个 HTTP 接口的路径、方法、请求、响应、鉴权要求、错误码。
+> 依赖:data-model.md、auth.md
+
+## 一、通用约定
+
+### 1.1 路径规则
+
+- 公开只读接口统一前缀 `/api/public/`,匿名可访问;
+- 其余接口一律要求登录,数据按 `user_id = current_user_id` 过滤;
+- 路径中不使用版本号。
+
+### 1.2 请求与响应格式
+
+- 请求体与响应体统一为 JSON,`Content-Type: application/json`;
+- 时间字段统一用 RFC 3339 字符串,如 `"2026-10-26T12:00:00Z"`;
+- id 字段统一为 uuid 字符串;
+- 字段命名统一 `snake_case`(前后端 JSON 不转 camelCase)。
+
+### 1.3 成功响应
+
+- 直接返回数据本体,不包 `{code, data}` 外壳;
+- 列表接口返回 `{ "items": [...], "total": <int> }`,空列表为 `{"items": [], "total": 0}`,不返回 null;
+- 创建成功返回 `201` + 创建后的完整对象;
+- 更新成功返回 `200` + 更新后的完整对象;
+- 删除成功返回 `204`,无响应体。
+
+### 1.4 错误响应
+
+统一格式:
+
+```json
+{ "error": "unauthorized", "message": "登录已过期" }
+```
+
+| HTTP | error | 使用场景 |
+|---|---|---|
+| 400 | `bad_request` | 请求体格式错误、JSON 解析失败 |
+| 401 | `unauthorized` | 未登录、会话过期、Token 无效 |
+| 403 | `forbidden` | 已登录但权限不足(如 read Token 做写操作) |
+| 404 | `not_found` | 资源不存在,或跨用户访问 |
+| 409 | `conflict` | 唯一性冲突(如标签名重复) |
+| 422 | `validation_failed` | 字段校验失败,额外带 `fields` |
+| 500 | `internal_error` | 服务端错误 |
+
+`422` 响应额外字段:
+
+```json
+{ "error": "validation_failed", "message": "字段校验失败", "fields": { "title": "不能为空" } }
+```
+
+### 1.5 分页
+
+- 列表接口统一用 `?limit=&offset=`;
+- `limit` 默认 20,最大 100;`offset` 默认 0;
+- 响应体带 `total` 表示过滤后的总数。
+
+### 1.6 鉴权标注含义
+
+- **匿名**:任何人可访问;
+- **登录**:需会话 cookie 或有效 Token(`read` / `read_write` 均可);
+- **登录 + write**:需会话 cookie,或 `scope = read_write` 的 Token;
+- **登录 + TOTP**:仅会话 cookie,且会话创建时通过过 TOTP。Token 不可用。
+
+`Authorization: Bearer <token>` 优先于会话 cookie。
+
+### 1.7 更新语义
+
+- `PATCH` 为部分更新:只更新请求体中出现的字段,未出现的字段保持不变;
+- 数组类字段(如笔记的 `tag_ids`)如出现,为**全量替换**,不是追加。
+
+---
+
+## 二、认证 `/api/auth`
+
+### 2.1 POST `/api/auth/login`
+
+**鉴权**:匿名
+
+**请求**:
+
+```json
+{ "username": "kairo", "password": "xxx", "trust_device": false }
+```
+
+- `trust_device`:可选,默认 false。仅在本次登录不需要第二步时立即生效;若返回 `totp_required: true`,此参数暂存于 `pending_logins`,第二步成功后才创建授信设备。
+
+**响应** `200`:
+
+情况一,登录完成(未启用 TOTP 或授信设备有效):
+
+```json
+{ "user": { "id": "...", "username": "kairo" }, "totp_required": false }
+```
+
+同时设置会话 cookie(`haven_session`);若 `trust_device=true`,同时设置授信设备 cookie。
+
+情况二,需要 TOTP:
+
+```json
+{ "user": { "id": "...", "username": "kairo" }, "totp_required": true, "temp_token": "..." }
+```
+
+不设置会话 cookie。
+
+**错误**:
+
+- `401 unauthorized` — 用户名或密码错误,`message` 统一为“用户名或密码错误”(不区分两者,防枚举)。
+
+**备注**:情况一成功后写 `login_success` 审计事件(`via` 为 `password+totp` 或 `password+trusted_device`);情况二不算登录成功,不写 `login_success`,第二步成功时才写;密码错误写 `login_failure` 审计事件。
+
+### 2.2 POST `/api/auth/login/totp`
+
+**鉴权**:匿名(凭 `temp_token`)
+
+**请求**:
+
+```json
+{ "temp_token": "...", "code": "123456" }
+```
+
+**响应** `200`:
+
+```json
+{ "user": { "id": "...", "username": "kairo" } }
+```
+
+设置会话 cookie;若第一步请求了 `trust_device=true`,同时设置授信设备 cookie。
+
+**错误**:
+
+- `401 unauthorized` — `temp_token` 无效或已过期,`message` 为“登录已超时,请重新登录”;
+- `401 unauthorized` — code 错误,`message` 为“动态码错误”。
+
+**备注**:成功后删除 `pending_logins` 记录;写 `login_success` 审计事件(`via` 为 `password+totp`);失败写 `login_failure` 审计事件(`reason` 为 `bad_totp`)。
+
+### 2.3 POST `/api/auth/logout`
+
+**鉴权**:登录
+
+**请求**:无 body
+
+**响应** `204`
+
+删除当前会话记录,清除会话 cookie;授信设备 cookie 保留。
+
+**备注**:写 `logout` 审计事件。Token 调用此接口无效(Token 无会话可登出),返回 204 但无实际操作。
+
+### 2.4 POST `/api/auth/totp/setup`
+
+**鉴权**:登录
+
+**请求**:无 body
+
+**响应** `200`:
+
+```json
+{ "secret": "JBSWY3DPEHPK3PXP", "otpauth_uri": "otpauth://totp/Haven:kairo?secret=...&issuer=Haven" }
+```
+
+**错误**:
+
+- `409 conflict` — 用户已启用 TOTP,不能重复 setup。
+
+**备注**:secret 此时**未落库**,仅返回给前端。用户扫码后调 2.5 提交 code 确认,校验通过才落库并置 `totp_enabled=true`。
+
+### 2.5 POST `/api/auth/totp/verify`
+
+**鉴权**:登录
+
+**请求**:
+
+```json
+{ "secret": "JBSWY3DPEHPK3PXP", "code": "123456" }
+```
+
+- `secret`:2.4 返回的密钥,原样回传;
+- `code`:验证器生成的 6 位码。
+
+**响应** `200`:
+
+```json
+{ "totp_enabled": true }
+```
+
+**错误**:
+
+- `422 validation_failed` — code 格式错误或校验不通过,`fields.code` 说明原因;
+- `409 conflict` — 已启用 TOTP。
+
+**备注**:校验通过后 secret 落库到 `users.totp_secret`,`totp_enabled` 置 true;写 `totp_bound` 审计事件。
+
+### 2.6 POST `/api/auth/totp/disable`
+
+**鉴权**:登录 + TOTP
+
+**请求**:
+
+```json
+{ "code": "123456" }
+```
+
+**响应** `200`:
+
+```json
+{ "totp_enabled": false }
+```
+
+**错误**:
+
+- `401 unauthorized` — code 校验不通过;
+- `422 validation_failed` — 用户尚未启用 TOTP。
+
+**备注**:清空 `users.totp_secret`,`totp_enabled` 置 false,**删除该用户所有授信设备**(否则授信设备失去意义);写 `totp_disabled` 审计事件。
+
+### 2.7 POST `/api/auth/password`
+
+**鉴权**:登录 + TOTP
+
+**请求**:
+
+```json
+{ "old_password": "xxx", "new_password": "yyy", "totp_code": "123456" }
+```
+
+**响应** `204`
+
+**错误**:
+
+- `401 unauthorized` — 原密码错误或 TOTP 错误;
+- `422 validation_failed` — 新密码不符合要求(≥12 位,且不能与旧密码相同)。
+
+**备注**:成功后在同一事务内删除该用户所有 `sessions` 和 `trusted_devices`,**不删除** `tokens`;写 `password_changed` 审计事件;当前会话也被删除,用户需重新登录。
+
+---
+
+## 三、会话 `/api/sessions`
+
+### 3.1 GET `/api/sessions`
+
+**鉴权**:登录
+
+**响应** `200`:
+
+```json
+{
+  "items": [
+    {
+      "id": "...",
+      "user_agent": "Mozilla/5.0 ...",
+      "created_at": "2026-10-26T12:00:00Z",
+      "last_used_at": "2026-10-26T12:30:00Z",
+      "is_current": true
+    }
+  ],
+  "total": 1
+}
+```
+
+- `is_current`:是否当前请求所用的会话,前端据此标注“当前设备”,不可撤销。
+
+**备注**:Token 调用返回 403(Token 无法查看会话)。
+
+### 3.2 DELETE `/api/sessions/:id`
+
+**鉴权**:登录
+
+**响应** `204`
+
+**错误**:
+
+- `404 not_found` — 会话不存在或不属于当前用户;
+- `422 validation_failed` — 试图撤销当前会话(请用 `POST /api/auth/logout`)。
+
+**备注**:写 `session_revoked` 审计事件。
+
+---
+
+## 四、授信设备 `/api/trusted-devices`
+
+### 4.1 GET `/api/trusted-devices`
+
+**鉴权**:登录
+
+**响应** `200`:
+
+```json
+{
+  "items": [
+    { "id": "...", "device_name": "家里台式机", "created_at": "...", "last_used_at": "...", "expires_at": "..." }
+  ],
+  "total": 1
+}
+```
+
+**备注**:Token 调用返回 403。
+
+### 4.2 DELETE `/api/trusted-devices/:id`
+
+**鉴权**:登录
+
+**响应** `204`
+
+**错误**:
+
+- `404 not_found` — 设备不存在或不属于当前用户。
+
+**备注**:撤销即删除记录;写 `trusted_device_revoked` 审计事件。
+
+**说明**:**不提供 `POST /api/trusted-devices`**。授信设备只能在登录时通过 `trust_device=true` 在那台设备上创建——授信的本质是“这台浏览器可信”,脱离那台浏览器创建设备没有意义。手动添加接口会产生“创建的设备凭证怎么传给目标设备”的难题,本期不做。
+
+---
+
+## 五、API Token `/api/tokens`
+
+### 5.1 GET `/api/tokens`
+
+**鉴权**:登录 + TOTP
+
+**响应** `200`:
+
+```json
+{
+  "items": [
+    {
+      "id": "...",
+      "name": "TUI @ 家里",
+      "token_prefix": "haven_ro_",
+      "scope": "read",
+      "expires_at": null,
+      "last_used_at": "2026-10-26T12:30:00Z",
+      "revoked_at": null,
+      "created_at": "..."
+    }
+  ],
+  "total": 1
+}
+```
+
+**备注**:只返回前缀,不返回明文;已撤销的 Token 也列出(`revoked_at` 非 null)。
+
+### 5.2 POST `/api/tokens`
+
+**鉴权**:登录 + TOTP
+
+**请求**:
+
+```json
+{ "name": "TUI @ 家里", "scope": "read", "expires_at": null }
+```
+
+- `scope`:必填,`read` 或 `read_write`;
+- `expires_at`:可选,RFC 3339 时间;null 表示永不过期。
+
+**响应** `201`:
+
+```json
+{
+  "id": "...",
+  "name": "TUI @ 家里",
+  "token": "haven_ro_AbCdEfGh...",
+  "token_prefix": "haven_ro_",
+  "scope": "read",
+  "expires_at": null,
+  "created_at": "..."
+}
+```
+
+- `token` 为明文,**仅此次返回,之后无法再获取**。
+
+**错误**:
+
+- `422 validation_failed` — name 为空、scope 非法等。
+
+**备注**:写 `token_created` 审计事件(metadata 含 `name`、`scope`、`token_prefix`,**不含明文**)。
+
+### 5.3 DELETE `/api/tokens/:id`
+
+**鉴权**:登录 + TOTP
+
+**响应** `204`
+
+**错误**:
+
+- `404 not_found` — Token 不存在或不属于当前用户;
+- `422 validation_failed` — Token 已撤销。
+
+**备注**:撤销 = 写 `revoked_at`,不删除记录;写 `token_revoked` 审计事件。
+
+---
+
+## 六、笔记(私有侧)`/api/notes`
+
+### 6.1 GET `/api/notes`
+
+**鉴权**:登录
+
+**查询参数**:
+
+- `limit`、`offset`:分页;
+- `visibility`:可选,`private` / `public`,按可见性过滤;
+- `tag_id`:可选,按标签过滤。
+
+**响应** `200`:
+
+```json
+{
+  "items": [
+    {
+      "id": "...",
+      "title": "...",
+      "visibility": "private",
+      "published_at": null,
+      "created_at": "...",
+      "updated_at": "...",
+      "tags": [{ "id": "...", "name": "编程" }]
+    }
+  ],
+  "total": 1
+}
+```
+
+**备注**:列表不返回 `content`(正文),正文只在详情接口返回。按 `updated_at DESC` 排序。
+
+### 6.2 GET `/api/notes/:id`
+
+**鉴权**:登录
+
+**响应** `200`:
+
+```json
+{
+  "id": "...",
+  "title": "...",
+  "content": "...",
+  "visibility": "private",
+  "published_at": null,
+  "created_at": "...",
+  "updated_at": "...",
+  "tags": [{ "id": "...", "name": "编程" }]
+}
+```
+
+**错误**:
+
+- `404 not_found` — 笔记不存在或不属于当前用户。
+
+### 6.3 POST `/api/notes`
+
+**鉴权**:登录 + write
+
+**请求**:
+
+```json
+{ "title": "...", "content": "...", "tag_ids": ["...", "..."] }
+```
+
+- `title`:必填;
+- `content`:可选,默认空串;
+- `tag_ids`:可选,标签 id 数组;后端校验每个 tag 属于当前用户。
+
+**响应** `201`:同 6.2 的笔记对象。
+
+**错误**:
+
+- `422 validation_failed` — title 为空、tag_ids 含无效 id。
+
+**备注**:**不接受 `visibility` 参数**,新建一律 private;写操作成功不写审计事件(笔记 CRUD 不审计)。
+
+### 6.4 PATCH `/api/notes/:id`
+
+**鉴权**:登录 + write
+
+**请求**(部分更新):
+
+```json
+{ "title": "...", "content": "...", "tag_ids": ["..."] }
+```
+
+- 只更新出现的字段;
+- `tag_ids` 如出现,为**全量替换**。
+
+**响应** `200`:同 6.2 的笔记对象。
+
+**错误**:
+
+- `404 not_found` — 笔记不存在或不属于当前用户;
+- `422 validation_failed` — 字段校验失败。
+
+**备注**:`updated_at` 由应用层更新。
+
+### 6.5 DELETE `/api/notes/:id`
+
+**鉴权**:登录 + write
+
+**响应** `204`
+
+**错误**:
+
+- `404 not_found` — 笔记不存在或不属于当前用户。
+
+### 6.6 POST `/api/notes/:id/publish`
+
+**鉴权**:登录 + write
+
+**请求**:无 body
+
+**响应** `200`:同 6.2 的笔记对象,`visibility` 为 `public`,`published_at` 非空。
+
+**错误**:
+
+- `404 not_found` — 笔记不存在或不属于当前用户;
+- `409 conflict` — 笔记已是 public。
+
+**备注**:若 `published_at` 为 NULL,写入当前时间;否则保留首次发布时间;写 `note_published` 审计事件。
+
+### 6.7 POST `/api/notes/:id/unpublish`
+
+**鉴权**:登录 + write
+
+**请求**:无 body
+
+**响应** `200`:同 6.2 的笔记对象,`visibility` 为 `private`,`published_at` 保留不变。
+
+**错误**:
+
+- `404 not_found` — 笔记不存在或不属于当前用户;
+- `409 conflict` — 笔记已是 private。
+
+**备注**:写 `note_unpublished` 审计事件。
+
+---
+
+## 七、笔记(公开侧)`/api/public/notes`
+
+### 7.1 GET `/api/public/notes`
+
+**鉴权**:匿名
+
+**查询参数**:`limit`、`offset`
+
+**响应** `200`:
+
+```json
+{
+  "items": [
+    {
+      "id": "...",
+      "title": "...",
+      "published_at": "...",
+      "updated_at": "...",
+      "tags": [{ "id": "...", "name": "编程" }]
+    }
+  ],
+  "total": 1
+}
+```
+
+**备注**:只返回 `visibility = public AND published_at IS NOT NULL` 的笔记,跨用户。按 `published_at DESC` 排序。列表不返回正文。
+
+### 7.2 GET `/api/public/notes/:id`
+
+**鉴权**:匿名
+
+**响应** `200`:
+
+```json
+{
+  "id": "...",
+  "title": "...",
+  "content": "...",
+  "published_at": "...",
+  "updated_at": "...",
+  "tags": [{ "id": "...", "name": "编程" }]
+}
+```
+
+**错误**:
+
+- `404 not_found` — 笔记不存在、非 public、或 `published_at` 为空。
+
+---
+
+## 八、标签 `/api/tags`
+
+### 8.1 GET `/api/tags`
+
+**鉴权**:登录
+
+**响应** `200`:
+
+```json
+{
+  "items": [
+    { "id": "...", "name": "编程", "note_count": 5, "created_at": "..." }
+  ],
+  "total": 1
+}
+```
+
+- `note_count`:该标签关联的笔记数(含私有)。
+
+### 8.2 POST `/api/tags`
+
+**鉴权**:登录 + write
+
+**请求**:
+
+```json
+{ "name": "编程" }
+```
+
+**响应** `201`:
+
+```json
+{ "id": "...", "name": "编程", "note_count": 0, "created_at": "..." }
+```
+
+**错误**:
+
+- `409 conflict` — 当前用户下已有同名标签(`lower(name)` 相同);
+- `422 validation_failed` — name 为空或过长。
+
+### 8.3 DELETE `/api/tags/:id`
+
+**鉴权**:登录 + write
+
+**响应** `204`
+
+**错误**:
+
+- `404 not_found` — 标签不存在或不属于当前用户。
+
+**备注**:关联由外键 `ON DELETE CASCADE` 自动清理。
+
+### 8.4 GET `/api/tags/:id/notes`
+
+**鉴权**:登录
+
+**查询参数**:`limit`、`offset`
+
+**响应** `200`:结构同 6.1(含私有和公开)。
+
+**错误**:
+
+- `404 not_found` — 标签不存在或不属于当前用户。
+
+---
+
+## 九、搜索 `/api/search`
+
+### 9.1 GET `/api/search`
+
+**鉴权**:登录
+
+**查询参数**:
+
+- `q`:必填,关键词;
+- `limit`、`offset`:分页,只作用于笔记结果。
+
+**响应** `200`:
+
+```json
+{
+  "notes": {
+    "items": [
+      {
+        "id": "...",
+        "title": "...",
+        "visibility": "private",
+        "published_at": null,
+        "updated_at": "...",
+        "tags": [{ "id": "...", "name": "编程" }]
+      }
+    ],
+    "total": 1
+  },
+  "tags": {
+    "items": [{ "id": "...", "name": "编程", "note_count": 3 }],
+    "total": 1
+  }
+}
+```
+
+**备注**:
+
+- 笔记按 `search_text ILIKE '%q%'` 匹配,范围限当前用户;
+- 标签按 `name ILIKE '%q%'` 匹配,范围限当前用户;
+- 不返回正文,只返回标题与元信息。
+
+### 9.2 GET `/api/public/search`
+
+**鉴权**:匿名
+
+**查询参数**:`q`、`limit`、`offset`
+
+**响应** `200`:
+
+```json
+{
+  "items": [
+    {
+      "id": "...",
+      "title": "...",
+      "published_at": "...",
+      "updated_at": "...",
+      "tags": [{ "id": "...", "name": "编程" }]
+    }
+  ],
+  "total": 1
+}
+```
+
+**备注**:只搜 `visibility = public AND published_at IS NOT NULL` 的笔记,跨用户。**不返回标签列表**——公开页的标签只作为笔记属性出现,点标签跳转到本接口用标签名搜索。本期不提供 `/api/public/tags`。
+
+---
+
+## 十、作品集 `/api/portfolio`
+
+### 10.1 GET `/api/public/portfolio`
+
+**鉴权**:匿名
+
+**响应** `200`:
+
+```json
+{
+  "items": [
+    {
+      "id": "...",
+      "title": "Haven",
+      "description": "...",
+      "sort_order": 0,
+      "repos": [
+        {
+          "id": "...",
+          "platform": "gogs",
+          "repo_path": "kairo/haven",
+          "url": "https://gogs.example.com/kairo/haven",
+          "is_primary": true
+        },
+        {
+          "id": "...",
+          "platform": "github",
+          "repo_path": "kairo/haven",
+          "url": "https://github.com/kairo/haven",
+          "is_primary": false
+        }
+      ]
+    }
+  ],
+  "total": 1
+}
+```
+
+- `url` 由后端按 `platform` + 配置的 base URL 拼接,前端直接用;
+- 按 `sort_order` 升序排序。
+
+### 10.2 POST `/api/portfolio`
+
+**鉴权**:登录 + write
+
+**请求**:
+
+```json
+{ "title": "Haven", "description": "...", "sort_order": 0 }
+```
+
+**响应** `201`:
+
+```json
+{ "id": "...", "title": "Haven", "description": "...", "sort_order": 0, "repos": [] }
+```
+
+**错误**:
+
+- `422 validation_failed` — title 为空。
+
+### 10.3 PATCH `/api/portfolio/:id`
+
+**鉴权**:登录 + write
+
+**请求**(部分更新):
+
+```json
+{ "title": "...", "description": "...", "sort_order": 1 }
+```
+
+**响应** `200`:同 10.2,含当前 `repos`。
+
+**错误**:
+
+- `404 not_found` — 作品不存在或不属于当前用户。
+
+### 10.4 DELETE `/api/portfolio/:id`
+
+**鉴权**:登录 + write
+
+**响应** `204`
+
+**错误**:
+
+- `404 not_found` — 作品不存在或不属于当前用户。
+
+**备注**:关联仓库由外键 `ON DELETE CASCADE` 自动清理。
+
+### 10.5 POST `/api/portfolio/:id/repos`
+
+**鉴权**:登录 + write
+
+**请求**:
+
+```json
+{ "platform": "gogs", "repo_path": "kairo/haven", "is_primary": true }
+```
+
+**响应** `201`:
+
+```json
+{ "id": "...", "platform": "gogs", "repo_path": "kairo/haven", "url": "https://gogs.example.com/kairo/haven", "is_primary": true }
+```
+
+**错误**:
+
+- `404 not_found` — 作品不存在或不属于当前用户;
+- `409 conflict` — 该作品下已有相同 `platform + repo_path`;
+- `409 conflict` — 该作品已有主链接且本次 `is_primary=true`;
+- `422 validation_failed` — platform 非法、repo_path 为空。
+
+### 10.6 PATCH `/api/portfolio/:id/repos/:repoId`
+
+**鉴权**:登录 + write
+
+**请求**(部分更新):
+
+```json
+{ "repo_path": "...", "is_primary": true }
+```
+
+**响应** `200`:同 10.5 的仓库对象。
+
+**错误**:
+
+- `404 not_found` — 作品或仓库不存在、不属于当前用户;
+- `409 conflict` — 唯一性或主链接冲突。
+
+### 10.7 DELETE `/api/portfolio/:id/repos/:repoId`
+
+**鉴权**:登录 + write
+
+**响应** `204`
+
+**错误**:
+
+- `404 not_found` — 作品或仓库不存在、不属于当前用户。
+
+---
+
+## 十一、其他 `/api/me`
+
+### 11.1 GET `/api/me`
+
+**鉴权**:匿名
+
+**响应** `200`:
+
+未登录:
+
+```json
+{ "anonymous": true }
+```
+
+登录:
+
+```json
+{
+  "anonymous": false,
+  "auth_type": "session",
+  "user": { "id": "...", "username": "kairo", "totp_enabled": true }
+}
+```
+
+Token 调用:
+
+```json
+{
+  "anonymous": false,
+  "auth_type": "token",
+  "scope": "read",
+  "user": { "id": "...", "username": "kairo", "totp_enabled": true }
+}
+```
+
+**备注**:供前端判断登录态与权限范围,不做其他事。

+ 237 - 0
docs/architecture.md

@@ -0,0 +1,237 @@
+# architecture — 技术架构与目录结构
+
+> 本文件回答:技术栈、目录结构、组件关系、配置项清单、部署形态(占位)。
+> 依赖:overview.md
+
+## 一、技术栈(锁定)
+
+| 层 | 选型 | 说明 |
+|---|---|---|
+| 后端语言 | Go | 版本 ≥ 1.22 |
+| 后端 HTTP 框架 | **Gin** | 版本 ≥ 1.10,统一使用,不混用 chi / Echo |
+| 数据库 | PostgreSQL | ≥ 15,启用 `pg_trgm` 扩展 |
+| 数据库访问 | `database/sql` + `pgx` 驱动,手写 SQL | 不引入 ORM / sqlc |
+| 前端框架 | Vue 3 + TypeScript | Composition API |
+| 前端构建 | Vite | 不使用 Webpack / Nuxt |
+| 前端路由 | Vue Router | History 模式 |
+| 前端状态 | Pinia(仅需时用) | 简单状态优先用 composable |
+| 前端 UI | 待定 | 不做组件库锁定,用原生 CSS 或轻量库 |
+
+**锁定含义**:opencode 生成代码时不得替换上述选型,需要新增依赖时先改本节。
+
+**框架选择理由**:本项目代码由 opencode + DeepSeek 生成,Gin 的训练语料丰富、写法惯例统一,生成质量比 chi 更稳定。代价是 Gin 自带 `*gin.Context`,需要通过约束把它限制在 handler 层。
+
+## 二、目录结构
+
+```
+Haven/
+├── docs/                    # 唯一事实来源(文档)
+├── backend/                 # Go 后端
+│   ├── cmd/
+│   │   └── haven/
+│   │       └── main.go      # 入口
+│   ├── internal/
+│   │   ├── config/          # 配置加载
+│   │   ├── db/              # 数据库连接
+│   │   ├── auth/            # 鉴权中间件、会话、Token
+│   │   ├── notes/           # 笔记领域
+│   │   ├── tags/            # 标签领域
+│   │   ├── search/          # 搜索
+│   │   ├── portfolio/       # 作品集
+│   │   ├── audit/           # 审计
+│   │   ├── llm/             # LLM 抽象层(本期空接口)
+│   │   └── httpx/           # 通用 HTTP 辅助(错误响应、中间件注册)
+│   ├── migrations/          # SQL migration 文件
+│   ├── go.mod
+│   └── go.sum
+├── frontend/                # Vue 前端
+│   ├── src/
+│   │   ├── api/             # API 客户端封装
+│   │   ├── views/           # 页面
+│   │   ├── components/      # 组件
+│   │   ├── router/          # 路由
+│   │   ├── stores/          # Pinia(仅需时)
+│   │   └── main.ts
+│   ├── index.html
+│   ├── package.json
+│   ├── tsconfig.json
+│   └── vite.config.ts
+├── deploy/                  # 部署脚本(systemd unit、构建脚本),如需要
+└── README.md
+```
+
+**约定**:
+
+- 后端按**领域**分包(`notes`、`tags`、`portfolio`…),不按技术层分包(不建 `handlers/`、`services/`、`repositories/` 这种横切目录)。
+- 每个领域包内部自包含:handler、业务逻辑、SQL 查询都在该包内。
+- 跨领域的通用代码放 `internal/httpx`、`internal/auth`、`internal/config`。
+- 前端按**页面**组织 `views`,公共组件放 `components`,API 调用统一封装在 `api/` 下。
+
+## 三、组件关系与数据流
+
+```
+浏览器 (Vue SPA)
+    │  HTTPS / HTTP
+    ▼
+Go 后端 (Gin)
+    │
+    ├── 鉴权中间件 ──→ sessions / pending_logins / tokens / trusted_devices
+    │
+    ├── 领域 handler ──→ notes / tags / search / portfolio / audit
+    │
+    └── database/sql ──→ PostgreSQL(唯一真值源)
+
+TUI / Neovim ──→ Go 后端(Authorization: Bearer <token>)
+```
+
+**关键点**:
+
+- 后端是**唯一 API 出口**,所有客户端(Web / TUI / Neovim)都走同一套接口。
+- 前端是纯静态 SPA,构建产物由后端托管(见第七节)。
+- 没有任何组件直连 PostgreSQL,除了后端本身。
+
+## 四、配置项清单
+
+所有配置通过**环境变量**提供(自用场景,不引入配置文件)。后端启动时读取并校验,缺失必填项则拒绝启动。
+
+| 变量名 | 必填 | 说明 | 示例 |
+|---|---|---|---|
+| `HAVEN_LISTEN_ADDR` | 是 | 后端监听地址 | `127.0.0.1:8080` |
+| `HAVEN_DB_DSN` | 是 | PostgreSQL 连接串 | `postgres://haven:xxx@127.0.0.1:5432/haven?sslmode=disable` |
+| `HAVEN_FRONTEND_DIR` | 是 | 前端静态文件目录 | `/opt/haven/frontend/dist` |
+| `HAVEN_COOKIE_SECURE` | 否 | 会话 cookie 是否加 `Secure` 属性,默认 `true`;HTTP 部署时设 `false` | `true` |
+| `HAVEN_GOGS_BASE_URL` | 是 | Gogs 站点 base URL,用于拼作品集跳转链接 | `https://gogs.example.com` |
+| `HAVEN_TOTP_ISSUER` | 否 | TOTP 验证器里显示的发行者名,默认 `Haven` | `Haven` |
+| `HAVEN_LOG_LEVEL` | 否 | 日志级别,默认 `info` | `info` / `debug` |
+| `HAVEN_COOKIE_DOMAIN` | 是 | 会话与授信设备 cookie 的 Domain | `app.example.com` |
+
+**常量(不进配置)**:
+
+以下值在 `auth.md` 中已定,直接写在代码里,不通过环境变量暴露,避免误配。
+
+| 常量 | 值 | 来源 |
+|---|---|---|
+| 会话 cookie 名 | `haven_session` | auth.md 第三节 |
+| 授信设备 cookie 名 | `haven_trusted_device` | auth.md 第四节 |
+| 会话有效期 | 7 天 | auth.md 第三节 |
+| 授信设备有效期 | 30 天 | auth.md 第四节 |
+| `pending_logins` 有效期 | 5 分钟 | auth.md 3.1 |
+| bcrypt cost | 12 | auth.md 8.1 |
+
+**未来可能新增**(本期不实现):
+
+- `LLM_PROVIDER` / `LLM_API_KEY` / `LLM_BASE_URL` / `LLM_MODEL` — 见 `llm-extension.md` 4.1
+- `TOTP_SECRET_ENCRYPTION_KEY` — 见 `auth.md` 8.2 后期升级路径
+
+## 五、后端项目结构约定
+
+### 5.1 数据库访问方式
+
+采用 **`database/sql` + `pgx` 驱动**,手写 SQL。理由:
+
+- 自用项目,查询数量有限,手写 SQL 最直观;
+- 避免 ORM 的隐式行为(懒加载、N+1、意外的级联),SQL 写出来是什么就是什么;
+- `pgx` 提供 PostgreSQL 完整支持(`jsonb`、`uuid`、`timestamptz`)。
+
+**不引入**:GORM、Ent、sqlc、sqlboiler。
+
+### 5.2 Gin 的使用边界
+
+**`*gin.Context` 只允许出现在 handler 层**,不得传入业务逻辑层或数据访问层。
+
+- handler 函数签名:`func(c *gin.Context)`;
+- handler 内部解析请求、调用业务函数、写响应;
+- 业务函数签名只接受标准 `context.Context` 和领域参数,不接受 `*gin.Context`;
+- 需要标准 context 时用 `c.Request.Context()`。
+
+**原因**:把 Gin 限制在 HTTP 层,业务层不感知框架,将来如需换框架只改 handler。
+
+### 5.3 中间件
+
+- 鉴权中间件在 `internal/auth` 下实现,返回 `gin.HandlerFunc`;
+- 中间件通过 `c.Set("user", ...)` 注入身份,handler 用 `c.MustGet("user")` 取出;
+- 中间件顺序:日志 → 鉴权 → 领域 handler。具体注册在 `internal/httpx` 或 `main.go`。
+
+### 5.4 错误处理
+
+- 领域层返回 `error`,handler 层统一映射为 `api.md` 1.4 定义的错误响应;
+- 不暴露内部错误细节给客户端(`500` 只返回 `internal_error`,细节进日志);
+- 统一的错误响应构造函数放在 `internal/httpx`,所有 handler 调用同一套。
+
+### 5.5 请求校验
+
+- 用 Gin 的 `ShouldBindJSON` 绑定请求体;
+- 校验规则用 struct tag(`binding:"required"`)做基础校验,复杂校验在业务层;
+- 校验失败统一返回 `422 validation_failed`,`fields` 从绑定错误中提取。
+
+### 5.6 migration
+
+- 使用 **golang-migrate**(或等效工具),migration 文件放在 `backend/migrations/`;
+- 文件名格式:`0001_init.up.sql` / `0001_init.down.sql`;
+- 按 `data-model.md` 第九节的依赖顺序编写;
+- migration 由部署脚本执行,**后端启动时不自动跑 migration**(避免并发启动冲突,也避免误执行)。
+
+### 5.7 日志
+
+- 结构化日志,标准库 `log/slog`;
+- 不记录请求体、不记录笔记内容、不记录密码或 Token;
+- 审计事件走 `audit_events` 表,不走日志。
+
+## 六、前端项目结构约定
+
+### 6.1 API 客户端
+
+`frontend/src/api/` 下按后端领域分文件(`auth.ts`、`notes.ts`、`tags.ts`…),每个文件导出函数,函数内部处理:
+
+- 请求路径拼接(前缀 `/api`);
+- 错误响应转异常(统一 `ApiError` 类型,含 `error` / `message` / `fields`);
+- 不在此层做状态管理。
+
+### 6.2 认证状态
+
+- 前端启动时调 `GET /api/me` 判断登录态;
+- 登录态存 Pinia store;
+- 未登录访问受保护路由时,router guard 重定向到登录页。
+
+### 6.3 构建产物
+
+- `npm run build` 输出到 `frontend/dist/`;
+- 该目录即 `HAVEN_FRONTEND_DIR` 指向的位置(部署时由运维决定是否拷贝)。
+
+## 七、静态文件托管与路由
+
+**后端同时托管前端静态文件**,不单独部署 nginx。
+
+- `GET /api/*` 走 API handler;
+- 其余路径(`/`、`/notes/xxx` 等)一律返回 `index.html`,由 Vue Router 接管;
+- 静态资源(`/assets/*.js`、`*.css`)由后端直接返回文件,带长缓存头(文件名含 hash)。
+
+**实现方式**:Gin 的 `r.NoRoute(...)` 兜底到 `index.html`,`r.Static("/assets", ...)` 托管静态资源。
+
+**理由**:自用项目、单机部署,多一个 nginx 就多一个组件要运维。Go 的静态文件服务足够。
+
+## 八、部署形态(占位)
+
+**当前状态:待定**。已知约束:
+
+- 不使用 Docker / Docker Compose;
+- 不使用 Kubernetes;
+- 使用宿主机直接运行(Go 二进制 + 前端静态文件 + 本机 PostgreSQL);
+- Gogs 也在同机部署(备案通过后),使用 Gogs 内置 SSH server,与宿主机 sshd 端口不冲突;
+- Haven 本身不提供 SSH 服务。
+
+**待 Gogs 部署完成后补充**:
+
+- PG / 后端 / 前端的运行方式(systemd unit 或手动);
+- 端口分配(Haven 后端端口、Gogs HTTP 端口、Gogs SSH 端口);
+- 反向代理(如需要 HTTPS);
+- 备份策略(`pg_dump` 定时任务)。
+
+## 九、不做的事(与 conventions.md 呼应)
+
+`conventions.md` 第四节是完整禁令清单,本文件不重复。这里只强调与架构相关的四条:
+
+- 不用 Docker / K8s 部署;
+- 不引入 PG 之外的存储作为真值源;
+- 不做 SSR / 预渲染;
+- 后端 HTTP 层统一用 Gin,不混用其他 Web 框架,`*gin.Context` 不得越出 handler 层。

+ 113 - 0
docs/backend/audit.md

@@ -0,0 +1,113 @@
+# audit — 审计日志
+
+> 本文件回答:审计记录哪些事件、表结构、写入时机、写入失败时怎么处理。
+> 依赖:data-model.md、auth.md
+
+## 一、定位与范围
+
+审计日志的用途是**事后追溯**:谁、什么时候、对什么做了什么。它不是业务日志,不记录请求细节、不记录笔记内容、不用于排查性能问题。
+
+本期范围限定为**安全与状态事件**。笔记 CRUD 等高频业务事件**不记录**——理由见 `conventions.md` 第四节。
+
+## 二、audit_events 表
+
+**用途**:存储审计事件,只追加,不修改,不删除。
+
+| 字段 | 类型 | 约束 | 说明 |
+|---|---|---|---|
+| `id` | uuid | PRIMARY KEY, DEFAULT `gen_random_uuid()` | |
+| `actor_user_id` | uuid | NULL, FK → users(id) ON DELETE SET NULL | 事件发起者;未登录事件(如登录失败但用户名不存在)为 NULL |
+| `actor_username` | text | NOT NULL DEFAULT '' | 事件发起者用户名快照;用户被删除后仍保留 |
+| `event_type` | text | NOT NULL, CHECK (见第三节清单) | 事件类型 |
+| `target_type` | text | NULL, CHECK (见第三节清单) | 被操作对象类型,无对象时为 NULL |
+| `target_id` | uuid | NULL | 被操作对象 id,无对象时为 NULL |
+| `metadata` | jsonb | NOT NULL DEFAULT `'{}'` | 事件附加信息,结构由 event_type 决定 |
+| `ip` | text | NOT NULL DEFAULT '' | 请求来源 IP,未取到时为空串 |
+| `user_agent` | text | NOT NULL DEFAULT '' | 请求 UA |
+| `created_at` | timestamptz | NOT NULL DEFAULT `now()` | |
+
+**索引**:
+
+```sql
+CREATE INDEX idx_audit_events_created_at ON audit_events (created_at DESC);
+CREATE INDEX idx_audit_events_event_type ON audit_events (event_type, created_at DESC);
+CREATE INDEX idx_audit_events_actor ON audit_events (actor_user_id, created_at DESC)
+  WHERE actor_user_id IS NOT NULL;
+```
+
+**规则**:
+
+1. **只追加**:不提供 UPDATE / DELETE 接口,不在应用层修改记录。
+2. **`actor_user_id` 可为 NULL**:登录失败但用户名不存在时,不知道是谁,记为 NULL;`actor_username` 存用户提交的用户名原文,便于排查暴力破解。
+3. **`actor_user_id` 与 `actor_username` 并存**:前者是外键(用户被删后置 NULL),后者是快照(永不为 NULL)。这样用户被删除后,历史审计记录仍能显示“谁”做的。
+4. **`metadata` 是 jsonb**:不同 event_type 有不同的附加字段,不固定 schema。每个 event_type 的 metadata 结构在本文件第三节定义。
+5. **不记录笔记内容、密码、Token 明文、TOTP secret**。metadata 中只记 id、名字、状态变化等元信息。
+
+## 三、事件类型清单
+
+本期记录以下事件。**新增事件类型必须同步更新本节和 CHECK 约束**。
+
+| event_type | target_type | 触发时机 | metadata 结构 |
+|---|---|---|---|
+| `login_success` | `null` | 登录成功(含授信设备跳过 TOTP) | `{"via":"password+totp"}` 或 `{"via":"password+trusted_device"}` |
+| `login_failure` | `null` | 密码错误、TOTP 错误、用户名不存在 | `{"reason":"bad_password"\|"bad_totp"\|"no_such_user"}` |
+| `logout` | `null` | 主动登出 | `{}` |
+| `totp_bound` | `user` | 首次绑定 TOTP 成功 | `{}` |
+| `totp_disabled` | `user` | 关闭 TOTP | `{}` |
+| `password_changed` | `user` | 修改密码成功 | `{}` |
+| `session_revoked` | `session` | 手动撤销会话 | `{"session_created_at":"..."}` |
+| `trusted_device_added` | `trusted_device` | 授信设备添加 | `{"device_name":"..."}` |
+| `trusted_device_revoked` | `trusted_device` | 授信设备撤销 | `{"device_name":"..."}` |
+| `token_created` | `token` | API Token 创建 | `{"name":"...","scope":"read"\|"read_write","token_prefix":"..."}` |
+| `token_revoked` | `token` | API Token 撤销 | `{"name":"...","token_prefix":"..."}` |
+| `note_published` | `note` | 笔记 private → public | `{"title":"..."}` |
+| `note_unpublished` | `note` | 笔记 public → private | `{"title":"..."}` |
+
+**说明**:
+
+- `target_type` 取值为 `null`(字符串字面量,非 SQL NULL)或 `user` / `session` / `trusted_device` / `token` / `note`。CHECK 约束里用 `'null'` 字符串表示“无对象”,避免和 SQL NULL 混淆。
+- `login_failure` 的 `actor_user_id` 通常为 NULL(尤其 `no_such_user`),但密码错误时如果能确定用户,可以为该用户 id。这一条在实现时决定,本文件不强制。
+- **笔记 CRUD(创建、编辑、删除)不在清单内**。`note_published` / `note_unpublished` 只记录“公开状态变化”这一状态事件,不记录内容变化。
+
+## 四、写入时机
+
+审计写入发生在**业务操作成功之后、响应返回之前**。顺序:
+
+1. 执行业务操作(如登录校验、Token 创建);
+2. 业务操作成功;
+3. 写审计事件;
+4. 返回响应。
+
+**审计写入不在业务事务内**(除非业务操作本身就是数据库事务且审计失败必须回滚,见第五节)。理由:审计表挂了不应该让登录之类的操作失败。
+
+## 五、写入失败的处理
+
+### 5.1 业务成功、审计写入失败
+
+**本期决策:业务操作不回滚,审计失败只打错误日志。**
+
+理由:审计是辅助记录,不是业务前置条件。审计表挂了(磁盘满、表被误删)不应该让用户登录不进去。错误日志中记录完整的审计事件内容,便于事后人工补录。
+
+代价:极端情况下(审计表挂了 + 同时发生安全事件),会丢失审计记录。自用场景接受此代价。
+
+### 5.2 业务失败、需要记录失败事件(如登录失败)
+
+**本期决策:同样只打错误日志,不影响登录接口的响应。**
+
+登录失败本身就是业务结果,审计表挂了不该让“登录失败”变成“服务错误”。响应照常返回“密码错误”,审计写入失败只打日志。
+
+**例外**:如果将来要做“连续登录失败 N 次锁定账号”,锁定逻辑依赖审计表或独立的失败计数器,那时审计写入失败会影响锁定判断。本期不做锁定,所以无此问题。**如后期加锁定,需重新评估本节。**
+
+## 六、查询与展示
+
+本期**不提供审计日志的 Web 查询界面**。理由:自用、单人,需要排查时直接 `psql` 查表即可,做界面收益低。
+
+`audit_events` 表结构本身就是查询入口,索引已覆盖常见查询(按时间倒序、按事件类型、按用户)。
+
+如果后期需要界面,另开文档,不在本期范围。
+
+## 七、对 data-model.md 的回写
+
+贴完本文件后,回头修改 `docs/backend/data-model.md` 第九节,将 `audit_events` 那一行改为指向本文件:
+
+> - `audit_events` — 审计事件(见 audit.md)

+ 51 - 20
docs/backend/auth.md

@@ -15,27 +15,34 @@ Haven 的鉴权分三个独立维度,共同决定一次请求能否通过:
 
 ## 二、登录流程
 
-### 2.1 普通登录(未授信设备)
+登录分两步。第一步校验账密,第二步校验 TOTP。只有两步都通过(或该用户未启用 TOTP、或授信设备有效)才下发正式会话。
 
-1. 用户提交 `username + password`;
-2. 服务端校验密码哈希;
-3. 若该用户已启用 TOTP,要求输入 6 位动态码,校验 TOTP;
-4. 校验通过,创建会话(写 `sessions` 表),下发会话 cookie;
-5. 若用户在登录时勾选“信任此设备”,额外创建一条 `trusted_devices` 记录,下发授信设备 cookie。
-
-### 2.2 授信设备登录(跳过 TOTP)
+### 2.1 第一步:账密校验
 
 1. 用户提交 `username + password`;
-2. 服务端校验密码哈希;
-3. 服务端检查请求携带的授信设备 cookie:存在、未过期、哈希匹配、属于该用户;
-4. 匹配成功则跳过 TOTP,直接创建会话;
-5. 匹配失败则回退到普通登录流程(要求 TOTP)。
+2. 服务端校验密码哈希,失败返回 401;
+3. 若用户未启用 TOTP,直接创建正式会话,下发会话 cookie,登录完成;
+4. 若用户启用了 TOTP:
+    - 若请求携带的授信设备 cookie 有效(未过期、哈希匹配、属于该用户),跳过 TOTP,直接创建正式会话;
+    - 否则,不创建会话,创建一条 `pending_logins` 记录,返回临时 token,要求进入第二步。
+
+### 2.2 第二步:TOTP 校验
 
-**关键点**:授信设备**不免除密码**,只免除 TOTP。密码始终要输。授信设备 cookie 单独存放,与会话 cookie 独立。
+1. 用户提交 `temp_token + code`;
+2. 服务端按 `token_hash` 查 `pending_logins`,校验未过期、属于该用户;
+3. 校验 code 对 `users.totp_secret` 是否正确;
+4. 校验通过:删除 `pending_logins` 记录,创建正式会话,下发会话 cookie;若第一步请求了 `trust_device`,同时创建授信设备;
+5. 校验失败:返回 401,`pending_logins` 记录保留至过期(5 分钟),期间可重试。
 
 ### 2.3 TOTP 未启用
 
-`users.totp_enabled = false` 时,登录只校验密码,不要求 TOTP。这是首次登录的默认状态——用户登录后在设置页绑定 TOTP,绑定成功后 `totp_enabled` 置为 true。
+`users.totp_enabled = false` 时,登录只走第一步,不要求 TOTP。首次登录即此状态,用户在设置页绑定 TOTP 后才启用。
+
+**关键点**:
+- 正式会话只在第一步(未启用 TOTP 或授信设备有效)或第二步(TOTP 通过)成功后创建;
+- `pending_logins` 记录不是会话,授权中间件不认,只能用于第二步;
+- 授信设备**不免除密码**,只免除 TOTP。
+
 
 ## 三、会话(sessions)
 
@@ -54,11 +61,33 @@ Haven 的鉴权分三个独立维度,共同决定一次请求能否通过:
 **规则**:
 
 1. **会话 token 为随机 32 字节**,base64url 编码后下发到 cookie。数据库只存哈希,明文不落库。
-2. **cookie 属性**:`HttpOnly`、`Secure`(HTTPS 下)、`SameSite=Lax`。生产环境若为 HTTP(自用局域网),`Secure` 可配为 false,但要在 `architecture.md` 里注明风险。
-3. **有效期 7 天**,不做滑动续期(避免会话无限延长)。过期后需重新登录。
-4. **每次请求更新 `last_used_at`**,但不延长 `expires_at`。
-5. **用户可在设置页列出自己的所有会话并单独撤销**——这是网吧环境离开后的兜底手段。
-6. **撤销会话 = 删除记录**,硬删除。
+2. **cookie 属性**:`HttpOnly`、`Secure`(HTTPS 下)、`SameSite=Lax`、`Domain=app.example.com`。生产环境若为 HTTP(自用局域网),`Secure` 可配为 false,但要在 `architecture.md` 里注明风险。
+3. **cookie `Domain` 固定为私有域**(`app.example.com`),不设 `.example.com`。公开域(`example.com`)收不到任何 cookie,公开域的请求天然无凭证,减少 CSRF 面。详见 `frontend.md` 第二节。
+4. **有效期 7 天**,不做滑动续期(避免会话无限延长)。过期后需重新登录。
+5. **每次请求更新 `last_used_at`**,但不延长 `expires_at`。
+6. **用户可在设置页列出自己的所有会话并单独撤销**——这是网吧环境离开后的兜底手段。
+7. **撤销会话 = 删除记录**,硬删除。
+8. **本表只存正式会话**。待验证的登录请求不在本表,见 3.1 节 `pending_logins`。授权中间件查询会话时,本表所有记录均视为有效会话(仍要校验 `expires_at`)。
+
+### 3.1 pending_logins — 待验证登录
+
+**用途**:暂存“账密已通过、TOTP 待验证”的登录请求。生命周期极短,验证完成或过期即删除。
+
+| 字段 | 类型 | 约束 | 说明 |
+|---|---|---|---|
+| `id` | uuid | PK | |
+| `user_id` | uuid | NOT NULL, FK → users(id) ON DELETE CASCADE | |
+| `token_hash` | text | NOT NULL, UNIQUE | 临时 token 的 SHA-256 哈希 |
+| `expires_at` | timestamptz | NOT NULL | 创建后 5 分钟 |
+| `created_at` | timestamptz | NOT NULL | |
+
+**规则**:
+
+1. 临时 token 为随机 32 字节,base64url 编码后返回给前端,数据库只存哈希。
+2. 有效期固定 5 分钟,不续期。
+3. 验证成功即删除记录;过期后由定时清理任务或下次登录时顺手删除。
+4. **授权中间件不认 `pending_logins`**。它只能用于 `POST /api/auth/login/totp`。
+5. 不做失败计数。5 分钟内 code 错几次都允许重试,5 分钟后记录过期,需重新登录。
 
 ## 四、授信设备(trusted_devices)
 
@@ -77,7 +106,7 @@ Haven 的鉴权分三个独立维度,共同决定一次请求能否通过:
 **规则**:
 
 1. **授信设备 token 为随机 32 字节**,存哈希,明文只在 cookie 里。
-2. **cookie 独立于会话 cookie**,名如 `haven_trusted_device`。
+2. **cookie 独立于会话 cookie**,名如 `haven_trusted_device`,`Domain` 与会话 cookie 一致(`app.example.com`)。
 3. **有效期 30 天**,到期不自动续期。
 4. **不做设备指纹**:不用 UA + IP 做绑定校验,避免网吧环境不稳定导致误判。安全性靠“token 随机 + HttpOnly + 可撤销”,不靠指纹。
 5. **用户可在设置页列出所有授信设备并单独撤销**,撤销即删除记录。
@@ -120,6 +149,8 @@ Haven 的鉴权分三个独立维度,共同决定一次请求能否通过:
 
 ### 6.1 身份识别
 
+身份识别只认 `sessions` 表与 `tokens` 表。`pending_logins` 记录不参与身份识别。
+
 1. 若请求携带 `Authorization: Bearer <token>`:
    - 查 `tokens` 表,按 `token_hash` 匹配;
    - 校验未撤销(`revoked_at IS NULL`)、未过期(`expires_at IS NULL OR expires_at > now()`);

+ 10 - 11
docs/backend/data-model.md

@@ -17,7 +17,7 @@
 - 搜索依赖 `pg_trgm` 扩展,数据库初始化时必须执行 `CREATE EXTENSION IF NOT EXISTS pg_trgm;`。
 - 不安装任何中文分词扩展(`zhparser` / `pg_jieba` / `pg_bigm` 等)。搜索走 PostgreSQL 自带的模糊匹配,理由见第八节。
 - **所有业务数据表都带 `user_id` 归属字段**,即使本期单人自用。这是技术预留:注册功能本期不实现,`users` 表只放唯一账号,但数据模型从第一天就按“未来可能有第二个账号”设计,避免以后改表。
-- **数据表之间的依赖顺序**:`users` → `notes` / `tags` / `portfolio_items`,migration 必须按此顺序创建。
+- **数据表之间的依赖顺序**:`users` → `sessions` / `pending_logins` / `trusted_devices` / `tokens` / `audit_events` → `notes` / `tags` / `portfolio_items` → `note_tags` / `portfolio_repos`。migration 必须按此顺序创建。
 
 ## 二、枚举值汇总
 
@@ -193,16 +193,15 @@ CREATE UNIQUE INDEX idx_portfolio_repos_primary ON portfolio_repos (portfolio_it
 - 标签搜索结果返回标签列表,点进去看该标签下的笔记。
 - 搜索本身是一个接口还是两个接口,由 `api.md` 定义,本文件不规定。
 
-## 九、待补:鉴权与审计相关表
+## 九、鉴权与审计相关表
 
-以下表的完整字段与规则见 docs/backend/auth.md 与 docs/backend/audit.md:
-
-- users — 用户账号(见 auth.md 第九节)
-- sessions — 登录会话(见 auth.md 第三节)
-- trusted_devices — 可授信浏览器设备(见 auth.md 第四节)
-- tokens — 长期 API Token(见 auth.md 第五节)
-- audit_events — 审计事件(见 audit.md)
-
-migration 顺序:users 必须最先创建;sessions、trusted_devices、tokens 依赖 users;notes、tags、portfolio_items 也依赖 users。
+以下表的完整字段与规则见 `docs/backend/auth.md` 与 `docs/backend/audit.md`:
 
+- `users` — 用户账号(见 auth.md 第九节)
+- `sessions` — 正式登录会话(见 auth.md 第三节)
+- `pending_logins` — 待验证登录(账密已过、TOTP 待验,见 auth.md 第 3.1 节)
+- `trusted_devices` — 可授信浏览器设备(见 auth.md 第四节)
+- `tokens` — 长期 API Token(见 auth.md 第五节)
+- `audit_events` — 审计事件(见 audit.md 第二节)
 
+**migration 顺序:**users 必须最先创建;sessions、pending_logins、trusted_devices、tokens、audit_events 依赖 users;notes、tags、portfolio_items 也依赖 users。

+ 86 - 0
docs/backend/llm-extension.md

@@ -0,0 +1,86 @@
+# llm-extension — LLM 抽象层预留
+
+> 本文件回答:LLM 抽象层的接口定义、本期实现边界、未来扩展时的落点。
+> 依赖:无
+
+## 一、定位
+
+本期**不实现任何 LLM 调用**。只定义抽象接口,为后续 RAG 检索、笔记静态翻译等扩展预留位置。接口先放在这里,防止业务代码把“以后要接 LLM”这件事忘掉,也防止届时改动侵入现有模块。
+
+## 二、本期实现边界
+
+- 只定义 Go interface,放在 `backend/internal/llm` 包下;
+- **不实现任何 provider**(不接 OpenAI、不接 DeepSeek、不接本地模型);
+- **不引入任何 LLM SDK 依赖**;
+- 不定义配置项(API key、base URL、模型名等一概不出现);
+- 不写 HTTP 接口,前端无感知。
+
+## 三、接口定义
+
+接口按“能力”划分,不按“模型”划分。业务层只依赖能力接口,不依赖具体 provider。
+
+```go
+package llm
+
+import "context"
+
+// Provider 是所有 LLM 能力的根接口。
+// 本期不实现,仅定义。
+type Provider interface {
+    // Translate 将文本从源语言翻译为目标语言。
+    // 同步调用,适用于短文本(单篇笔记正文以内)。
+    Translate(ctx context.Context, req TranslateRequest) (TranslateResponse, error)
+
+    // Embed 生成文本的向量表示,供 RAG 检索使用。
+    // 单条文本生成一个向量。
+    Embed(ctx context.Context, text string) ([]float32, error)
+}
+
+type TranslateRequest struct {
+    Text           string // 待翻译文本
+    SourceLanguage string // 源语言代码,如 "zh"、"en";空表示自动检测
+    TargetLanguage string // 目标语言代码
+}
+
+type TranslateResponse struct {
+    Text string // 翻译结果
+}
+```
+
+**说明**:
+
+- `Translate` 和 `Embed` 是两个独立能力,未来可以由不同 provider 实现;
+- 接口**只覆盖“输入文本 → 输出结果”**,不涉及流式、不涉及对话、不涉及 tool use——这些不是本项目当前场景需要的;
+- `Provider` 是根接口,未来如需拆分(如 `Translator` / `Embedder` 两个接口),在实现第一个 provider 时再拆,现在不预先拆。
+
+## 四、未来扩展的落点
+
+以下内容**本期不实现**,但写在这里,标明未来改动应该落在哪,避免届时重新设计。
+
+### 4.1 配置项
+
+未来接入 provider 时,`architecture.md` 的配置项一节需要新增:
+
+- `LLM_PROVIDER`:provider 名(如 `openai` / `deepseek` / `local`);
+- `LLM_API_KEY`:API 密钥(如适用);
+- `LLM_BASE_URL`:API 地址(如适用);
+- `LLM_MODEL`:模型名。
+
+### 4.2 触发方式
+
+- **翻译**:同步调用。用户在笔记详情页点“翻译”,后端调 `Translate`,返回结果直接展示,不落库。
+- **RAG 检索**:异步任务。`Embed` 在笔记保存后异步执行,向量存储方案届时决定(pgvector 或外部存储),**本期不引入**。
+
+### 4.3 数据出境规则
+
+未来接入外部 LLM 时,必须明确哪些内容允许发送出去:
+
+- 只有 **public 笔记** 允许送往外部 provider;
+- private 笔记的翻译 / 嵌入只能走本地模型,或经用户显式确认后临时发送;
+- 这条规则在实现第一个 provider 时写入 `conventions.md` 第四节,本文件不预先写死。
+
+## 五、硬性约束
+
+- 业务层**不得直接 import 任何 LLM SDK**,只能 import `backend/internal/llm`;
+- 抽象层**不泄漏具体 provider 的 SDK 类型**到业务层(如 OpenAI 的 `ChatCompletionRequest` 不得出现在接口签名里);
+- 本期**不写任何实现代码**,不建 `backend/internal/llm/openai` 之类的子包。

+ 4 - 1
docs/conventions.md

@@ -66,7 +66,8 @@ Haven/
 - 不使用 Kubernetes。
 - 原因备注:同机 Gogs 若跑在容器中会导致 SSH 失败;为避免同类问题,Haven 不使用容器。
 - 不引入 PostgreSQL 之外的存储作为真值源;Redis / 对象存储等如后续需要,只作为辅助,不存真值。
-- 不做多租户、不做多用户协作,本项目是单人自用。数据模型保留 user_id 归属字段,是为将来可能的第二个账号预留技术位;不代表本期要实现注册、邀请、账号管理等多人功能。所有接口仍按“当前登录用户”过滤,不提供账号创建入口。
+- 不做多租户、不做多用户协作,本项目是单人自用。数据模型保留 `user_id` 归属字段,是为将来可能的第二个账号预留技术位;**不代表本期要实现注册、邀请、账号管理等多人功能**。所有接口仍按“当前登录用户”过滤,不提供账号创建入口。
+
 
 ### 前端
 
@@ -79,11 +80,13 @@ Haven/
 - `visibility` 的默认值在数据库层固化(DEFAULT 'private' + NOT NULL),不依赖应用层传值。
 - API Token 只做 `read` / `read_write` 两档 scope,不做细粒度权限。
 - 所有读接口统一走授权中间件做 visibility 收敛,不在各 handler 里各自判断。
+- 后端 HTTP 层统一用 Gin,不引入 chi / Echo 等其他 Web 框架;`*gin.Context` 只允许出现在 handler 层,不得传入业务层与数据访问层。
 
 ### 客户端
 
 - TUI 与 Neovim 插件只覆盖“读写笔记 + 检索”主路径,不做功能对等,其余交给 Web。
 - 次级客户端以服务端为准,不做离线编辑与冲突合并(如后续要做,先改本文档再实现)。
+- 本期(第一版)不实现 TUI 和 Neovim 插件,不创建任何相关代码。第二版再动工,设计另写。
 
 ### LLM
 

+ 23 - 0
docs/frontend/clients.md

@@ -0,0 +1,23 @@
+# clients — TUI 与 Neovim 插件(第二版)
+
+> 本文件回答:次级客户端的本期状态与未来边界。
+> 依赖:api.md
+
+## 一、本期状态
+
+**本期(第一版)不实现 TUI 和 Neovim 插件。** 本文件仅作占位,标明未来边界,防止 opencode 在本期生成任何相关代码。
+
+## 二、未来范围(第二版)
+
+- TUI 与 Neovim 插件作为次级客户端,只覆盖**读写笔记 + 检索**主路径;
+- 不做功能对等,其余功能交给 Web;
+- 通过 API Token 鉴权,不引入新的鉴权方式;
+- 以服务端为准,不做离线编辑与冲突合并。
+
+## 三、本期不做的事
+
+- 不在 `backend/` 或 `frontend/` 下创建任何 TUI / Neovim 相关代码;
+- 不在 `api.md` 中为次级客户端新增接口(现有接口已足够覆盖读写 + 检索);
+- 不在 `architecture.md` 的目录结构中预留 TUI / Neovim 目录。
+
+具体设计(Token 配置方式、覆盖哪些接口、错误处理)等第二版动工时另写,不在本文件预先展开。

+ 106 - 0
docs/frontend/frontend.md

@@ -0,0 +1,106 @@
+# frontend — Web 前端页面与交互
+
+> 本文件回答:公开域与私有域各有哪些页面、每页调哪些接口、登录态与路由规则。
+> 依赖:api.md、auth.md、architecture.md
+
+## 一、部署形态
+
+前端为单一 Vue 3 + Vite SPA 构建,通过**两个域名**区分公开与私有:
+
+| 域名 | 角色 | 说明 |
+|---|---|---|
+| `example.com` | 公开域 | 匿名可访问,展示公开内容,无登录入口 |
+| `app.example.com` | 私有域 | 登录后使用,笔记管理、设置、Token 等 |
+
+- `www.example.com` 301 重定向到 `example.com`;
+- 两个域名指向**同一份前端构建**,前端启动时按 `window.location.hostname` 判断当前域,加载对应的路由表;
+- API 请求一律发往当前域名下的 `/api/*`(后端同源托管,不跨域)。
+
+## 二、cookie 域配置
+
+- 会话 cookie、授信设备 cookie 的 `Domain` 设为 `app.example.com`,**不设 `.example.com`**;
+- 效果:公开域收不到任何 cookie,公开域的请求天然无凭证,减少 CSRF 面;
+- 私有域内的所有请求自带 cookie。
+
+该配置由环境变量 `HAVEN_COOKIE_DOMAIN` 提供,见 `architecture.md` 第四节。cookie 属性细节见 `auth.md` 第三节规则 2。
+
+## 三、公开域路由
+
+| 路径 | 页面 | 调用的接口 |
+|---|---|---|
+| `/` | 公开笔记列表(首页) | `GET /api/public/notes` |
+| `/notes/:id` | 公开笔记详情 | `GET /api/public/notes/:id` |
+| `/search?q=` | 公开搜索 | `GET /api/public/search` |
+| `/portfolio` | 作品集展示 | `GET /api/public/portfolio` |
+
+**规则**:
+
+- 公开域**没有登录页、没有设置页、没有任何写操作入口**;
+- 公开域**不暴露任何后台入口**。后台地址由使用者手动输入,不在页面上放链接;
+- 所有页面匿名可访问,不需要登录态判断;
+- 公开笔记详情页展示笔记的 tag,点击 tag 跳转到 `/search?q=<tag 名>`。
+
+## 四、私有域路由
+
+| 路径 | 页面 | 调用的接口 |
+|---|---|---|
+| `/login` | 登录页(含 TOTP 第二步) | `POST /api/auth/login`、`POST /api/auth/login/totp` |
+| `/` | 笔记列表(工作台首页) | `GET /api/notes` |
+| `/notes/:id` | 笔记详情 / 编辑 | `GET /api/notes/:id`、`PATCH /api/notes/:id`、`POST /api/notes/:id/publish`、`POST /api/notes/:id/unpublish`、`DELETE /api/notes/:id` |
+| `/notes/new` | 新建笔记 | `POST /api/notes` |
+| `/search?q=` | 私有搜索 | `GET /api/search` |
+| `/tags` | 标签管理 | `GET /api/tags`、`POST /api/tags`、`DELETE /api/tags/:id` |
+| `/portfolio` | 作品集管理 | `GET /api/public/portfolio`、`POST /api/portfolio`、`PATCH /api/portfolio/:id`、`DELETE /api/portfolio/:id`、仓库链接的增删改 |
+| `/settings` | 设置入口 | `GET /api/me` |
+| `/settings/security` | 密码、TOTP | `POST /api/auth/password`、`POST /api/auth/totp/*` |
+| `/settings/sessions` | 会话管理 | `GET /api/sessions`、`DELETE /api/sessions/:id` |
+| `/settings/devices` | 授信设备管理 | `GET /api/trusted-devices`、`DELETE /api/trusted-devices/:id` |
+| `/settings/tokens` | API Token 管理 | `GET /api/tokens`、`POST /api/tokens`、`DELETE /api/tokens/:id` |
+| `/preview/notes/:id` | 公开笔记预览(可选) | `GET /api/public/notes/:id` |
+
+**规则**:
+
+- 除 `/login` 外,所有页面需要登录,router guard 统一处理;
+- 未登录访问任意受保护路径 → 重定向到 `/login`;
+- 登录成功后回到原本要访问的路径(`?redirect=` 参数);
+- 私有域的工作台首页就是笔记列表,不再有“三合一主页”;
+- `/settings/tokens` 页面若收到 403(当前会话未通过 TOTP),页面内提示“管理 Token 需要完成 TOTP 验证”,并提供跳转到 `/settings/security` 完成验证的按钮,不强制登出。
+
+## 五、登录流程(前端视角)
+
+1. `/login` 页面显示账号密码表单;
+2. 提交 → `POST /api/auth/login`;
+3. 若响应 `totp_required: false` → 登录完成,跳转目标页;
+4. 若响应 `totp_required: true` → 表单切换为 TOTP 输入框,暂存 `temp_token`(内存变量,不落 localStorage);
+5. 用户输入 6 位码 → `POST /api/auth/login/totp`;
+6. 成功 → 跳转目标页;失败 → 提示错误,可重试(5 分钟内有效);
+7. `temp_token` 过期 → 回到账号密码步骤,提示“登录已超时,请重新登录”。
+
+## 六、全局状态
+
+- `auth` store(Pinia):当前用户、`auth_type`、`totp_enabled`、Token 的 `scope`;
+- 启动时调 `GET /api/me` 初始化;
+- 401 响应统一处理:清空 store、跳转 `/login`;
+- 403 响应:提示“权限不足”,不跳转(页面自行处理特殊情况,如 `/settings/tokens` 的 TOTP 引导)。
+
+## 七、API 客户端约定
+
+- `frontend/src/api/` 下按领域分文件:`auth.ts`、`notes.ts`、`tags.ts`、`search.ts`、`portfolio.ts`、`sessions.ts`、`devices.ts`、`tokens.ts`、`me.ts`;
+- 统一封装 `ApiError`,含 `error`、`message`、`fields`;
+- 请求路径一律相对路径(`/api/...`),不写死域名,靠当前域自动路由;
+- 公开域与私有域共用同一套 API 客户端代码,页面决定调哪个函数。
+
+## 八、样式与组件
+
+- 不做组件库锁定,用原生 CSS 或轻量库,优先可读性;
+- 公共组件:`NoteCard`、`TagBadge`、`SearchBar`、`LoginForm`、`TotpForm`、`SettingsNav`;
+- 不做深色模式切换(如需要,后期加,不在本期)。
+
+## 九、不做的事
+
+- 不做 SSR / SEO;
+- 不做移动端专门适配(响应式布局即可,不做独立 App);
+- 不做 PWA;
+- 公开域不做登录入口以外的任何写操作;
+- 公开域不放后台链接;
+- 不做多语言(中文界面固定)。

+ 4 - 3
docs/overview.md

@@ -24,8 +24,9 @@ Haven 是一套自托管的个人综合知识中台:以 PostgreSQL 为唯一
 | Web 前端 | 一等主入口 | 登录、写作、公开浏览、作品集、设置 |
 | Go 后端 | 唯一 API 出口 | 所有客户端经由它读写 PostgreSQL |
 | PostgreSQL | 唯一真值源 | 不引入其他存储作为真值源 |
-| TUI 客户端 | 次级客户端 | 只覆盖读写笔记 + 检索主路径 |
-| Neovim 插件 | 次级客户端 | 同上,走 API Token 鉴权 |
+| TUI 客户端 | 次级客户端(第二版) | 只覆盖读写笔记 + 检索主路径 |
+| Neovim 插件 | 次级客户端(第二版) | 同上,走 API Token 鉴权 |
+
 
 三类客户端的分工原则:**Web 是功能全集,TUI 与 Neovim 只覆盖主路径,不做功能对等。**
 
@@ -35,7 +36,7 @@ Haven 是一套自托管的个人综合知识中台:以 PostgreSQL 为唯一
 - 新建笔记一律 private,公开是显式动作;
 - 未登录只能访问 public 内容;
 - 鉴权分层:账号密码 → TOTP → 可授信浏览器设备;
-- 长期 API Token 供 TUI / Neovim / 脚本使用,可单独撤销,与登录会话解耦。
+- 长期 API Token 供脚本、TUI / Neovim(第二版)使用,可单独撤销,与登录会话解耦。本期 Token 机制完整实现,只是消费方暂时只有脚本。
 
 细节见 `docs/backend/auth.md` 与 `docs/backend/data-model.md`。
 

苏ICP备2026082406号
苏公网安备 待审核