Răsfoiți Sursa

first commit

kairo 2 zile în urmă
comite
37ac9c7531

+ 0 - 0
README.md


+ 0 - 0
docs/api.md


+ 0 - 0
docs/architecture.md


+ 0 - 0
docs/backend/audit.md


+ 0 - 0
docs/backend/auth.md


+ 203 - 0
docs/backend/data-model.md

@@ -0,0 +1,203 @@
+# data-model — 数据库 schema
+
+> 本文件回答:数据库有哪些表、每张表有哪些字段、约束、索引与业务规则。
+> 依赖:无
+
+## 一、书写约定
+
+每张表统一按四部分书写:用途、字段表、索引、规则。
+
+全局约定:
+
+- 主键统一用 `uuid`,默认 `gen_random_uuid()`。需要 PostgreSQL 13+,或启用 `pgcrypto` 扩展。
+- 时间字段统一用 `timestamptz`。
+- 命名统一 `snake_case`。
+- 枚举值用 `text + CHECK`,不用 PostgreSQL 原生 enum(便于后续加值不改类型)。
+- 本期所有删除为硬删除,不做软删除。如需软删除,先改本文件再实现。
+- 搜索依赖 `pg_trgm` 扩展,数据库初始化时必须执行 `CREATE EXTENSION IF NOT EXISTS pg_trgm;`。
+- 不安装任何中文分词扩展(`zhparser` / `pg_jieba` / `pg_bigm` 等)。搜索走 PostgreSQL 自带的模糊匹配,理由见第八节。
+- **所有业务数据表都带 `user_id` 归属字段**,即使本期单人自用。这是技术预留:注册功能本期不实现,`users` 表只放唯一账号,但数据模型从第一天就按“未来可能有第二个账号”设计,避免以后改表。
+- **数据表之间的依赖顺序**:`users` → `notes` / `tags` / `portfolio_items`,migration 必须按此顺序创建。
+
+## 二、枚举值汇总
+
+各表小节引用此处,不重复定义。
+
+| 枚举名 | 取值 | 说明 |
+|---|---|---|
+| `note_visibility` | `private` / `public` | 笔记可见性 |
+| `repo_platform` | `gogs` / `github` | 作品集仓库托管平台 |
+| `token_scope` | `read` / `read_write` | API Token 权限档位(用于 tokens 表,待补) |
+
+## 三、notes — 笔记
+
+**用途**:存储所有笔记内容,私有与公开共用一张表,靠 `visibility` 区分。
+
+| 字段 | 类型 | 约束 | 默认值 | 说明 |
+|---|---|---|---|---|
+| `id` | uuid | PRIMARY KEY | `gen_random_uuid()` | 主键 |
+| `user_id` | uuid | NOT NULL, FK → users(id) ON DELETE CASCADE | — | 所属用户 |
+| `title` | text | NOT NULL | — | 标题 |
+| `content` | text | NOT NULL | `''` | 正文,允许空串 |
+| `visibility` | text | NOT NULL, CHECK (visibility IN ('private','public')) | `'private'` | 可见性,默认私有 |
+| `published_at` | timestamptz | NULL | NULL | 首次公开发布时间,仅 public 时有意义 |
+| `search_text` | text | GENERATED ALWAYS AS (`title || ' ' || content`) STORED | — | 搜索用合并字段,数据库自动维护,应用层只读不写 |
+| `created_at` | timestamptz | NOT NULL | `now()` | 创建时间 |
+| `updated_at` | timestamptz | NOT NULL | `now()` | 最后更新时间,由应用层在每次写入时更新 |
+
+**索引**:
+
+```sql
+-- 公开列表:按用户 + 发布时间倒序
+CREATE INDEX idx_notes_public ON notes (user_id, published_at DESC)
+  WHERE visibility = 'public' AND published_at IS NOT NULL;
+
+-- 私有工作台:按用户 + 最近更新排序
+CREATE INDEX idx_notes_updated_at ON notes (user_id, updated_at DESC);
+
+-- 模糊搜索:search_text trigram 索引
+CREATE INDEX idx_notes_search_trgm ON notes USING gin (search_text gin_trgm_ops);
+```
+
+**规则**:
+
+1. **新建笔记一律 private**:数据库层 `DEFAULT 'private'` + `NOT NULL` 兜底;创建接口不接受 `visibility` 参数(见 `conventions.md` 第四节)。
+2. **公开是显式动作**:`private → public` 通过独立的 publish 操作完成,该操作在 `published_at IS NULL` 时写入当前时间。
+3. **撤回不清空 `published_at`**:`public → private` 时 `published_at` 保留,语义为“首次公开发布时间”;再次公开不覆盖。
+4. **公开可见的判定条件唯一**:`visibility = 'public' AND published_at IS NOT NULL`。任何公开读路径必须使用此条件,不得只看 `visibility`。
+5. **`published_at` 对 private 笔记无意义**:private 笔记不因 `published_at` 是否存在而改变可见性,该字段只服务于公开列表排序与未来 RSS。
+6. **`search_text` 由数据库自动维护**:应用层不得在 INSERT / UPDATE 时写入该字段,也不得在 Go 代码中手动拼接。搜索查询统一使用 `search_text`,不直接查 `title` 或 `content`。
+7. **搜索结果受可见性约束**:搜索查询同样要经过授权中间件的 visibility 过滤(未登录只能搜到 public 笔记)。具体过滤规则见 `docs/backend/auth.md`,本表不重复定义。
+8. **笔记归属唯一用户**:所有笔记读写操作必须以当前登录用户为过滤条件,即 `WHERE user_id = current_user_id`。授权中间件在 visibility 过滤之外,必须叠加用户归属过滤。跨用户访问视为越权,返回 404 而非 403(不泄露笔记存在性)。
+
+## 四、tags — 标签
+
+**用途**:存储所有标签名,标签本身独立于笔记存在(允许创建后暂不关联任何笔记)。
+
+| 字段 | 类型 | 约束 | 默认值 | 说明 |
+|---|---|---|---|---|
+| `id` | uuid | PRIMARY KEY | `gen_random_uuid()` | 主键 |
+| `user_id` | uuid | NOT NULL, FK → users(id) ON DELETE CASCADE | — | 所属用户 |
+| `name` | text | NOT NULL | — | 标签名 |
+| `created_at` | timestamptz | NOT NULL | `now()` | 创建时间 |
+
+**索引**:
+
+```sql
+-- 同一用户下标签名不区分大小写唯一
+CREATE UNIQUE INDEX idx_tags_user_name_lower ON tags (user_id, lower(name));
+
+-- 标签名模糊搜索
+CREATE INDEX idx_tags_name_trgm ON tags USING gin (name gin_trgm_ops);
+```
+
+**规则**:
+
+1. **标签按用户隔离**:不同用户可拥有同名标签,互不影响。唯一性判断范围是 `(user_id, lower(name))`。
+2. **标签名不区分大小写**:`Go` 和 `go` 在同一用户下视为同一个标签。写入时应用层可保留原样,但唯一性判断以 `lower(name)` 为准。
+3. **不提供重命名接口**:本期标签只能创建和删除。删除标签时,`note_tags` 中的关联由外键 `ON DELETE CASCADE` 自动清理。如需重命名,先改本文件。
+4. **标签查询受用户归属约束**:所有标签查询必须加 `WHERE user_id = current_user_id`。
+
+## 五、note_tags — 笔记与标签关联
+
+**用途**:多对多关联表,一篇笔记可挂多个标签,一个标签可用于多篇笔记。
+
+| 字段 | 类型 | 约束 | 默认值 | 说明 |
+|---|---|---|---|---|
+| `note_id` | uuid | NOT NULL, FK → notes(id) ON DELETE CASCADE | — | 笔记 |
+| `tag_id` | uuid | NOT NULL, FK → tags(id) ON DELETE CASCADE | — | 标签 |
+
+**主键**:`(note_id, tag_id)` 复合主键,天然防止重复关联。
+
+**索引**:
+
+```sql
+-- 复合主键已覆盖 note_id 开头的查询;单独为 tag_id 建索引,支持"按标签查笔记"
+CREATE INDEX idx_note_tags_tag_id ON note_tags (tag_id);
+```
+
+**规则**:
+
+1. 关联无额外字段,保持最简。
+2. 删除笔记或标签时关联自动清理,不在应用层做级联删除。
+3. **跨用户关联防护**:`note_tags` 本身没有 `user_id`,用户归属由 `notes.user_id` 和 `tags.user_id` 分别保证。应用层在创建关联时必须校验 `notes.user_id = tags.user_id = current_user_id`,防止把 A 用户的标签挂到 B 用户的笔记上。这一校验在应用层完成,数据库层不强制(可以用触发器强制,但增加复杂度,本期不做)。
+
+## 六、portfolio_items — 作品集条目
+
+**用途**:主页作品集的条目本体,一个作品可以关联多个仓库链接。
+
+| 字段 | 类型 | 约束 | 默认值 | 说明 |
+|---|---|---|---|---|
+| `id` | uuid | PRIMARY KEY | `gen_random_uuid()` | 主键 |
+| `user_id` | uuid | NOT NULL, FK → users(id) ON DELETE CASCADE | — | 所属用户 |
+| `title` | text | NOT NULL | — | 作品标题 |
+| `description` | text | NOT NULL | `''` | 简介 |
+| `sort_order` | int | NOT NULL | `0` | 主页排序,小的在前 |
+| `created_at` | timestamptz | NOT NULL | `now()` | 创建时间 |
+
+**索引**:
+
+```sql
+CREATE INDEX idx_portfolio_items_sort ON portfolio_items (user_id, sort_order);
+```
+
+**规则**:
+
+1. 作品不直接持有平台信息,平台信息在 `portfolio_repos` 表中。
+2. 一个作品至少关联一个仓库(应用层校验,不在数据库层强制,避免“先建作品后加仓库”的两步操作被卡住)。
+3. **作品归属唯一用户**:本期主页按站点聚合展示(站点只有一个用户),查询时仍建议加 `WHERE user_id = ?` 作为未来扩展的保险。
+
+## 七、portfolio_repos — 作品关联的仓库
+
+**用途**:一个作品可关联多个仓库链接(如同时托管在 Gogs 和 GitHub)。
+
+| 字段 | 类型 | 约束 | 默认值 | 说明 |
+|---|---|---|---|---|
+| `id` | uuid | PRIMARY KEY | `gen_random_uuid()` | 主键 |
+| `portfolio_item_id` | uuid | NOT NULL, FK → portfolio_items(id) ON DELETE CASCADE | — | 所属作品 |
+| `platform` | text | NOT NULL, CHECK (platform IN ('gogs','github')) | — | 托管平台 |
+| `repo_path` | text | NOT NULL | — | 如 `username/repo-name` |
+| `is_primary` | boolean | NOT NULL | `false` | 是否主链接,主页默认跳转这一个 |
+
+**索引**:
+
+```sql
+-- 同一作品下平台 + 路径唯一
+CREATE UNIQUE INDEX idx_portfolio_repos_unique ON portfolio_repos (portfolio_item_id, platform, repo_path);
+
+-- 每个作品最多一个主链接
+CREATE UNIQUE INDEX idx_portfolio_repos_primary ON portfolio_repos (portfolio_item_id)
+  WHERE is_primary = true;
+```
+
+**规则**:
+
+1. **仓库地址只存 `repo_path`,不存完整 URL**:完整 URL 由 `platform` + 配置中的 base URL 拼接。Gogs 的 base URL 放在应用配置里(见 `architecture.md`),GitHub 固定为 `https://github.com`。
+2. **每个作品最多一个主链接**:由部分唯一索引保证。没有主链接时,主页按平台顺序取第一个。
+3. **主链接不强制存在**:作品可以没有 `is_primary = true` 的仓库,主页展示逻辑需处理这种情况。
+4. **仓库归属通过作品间接确定**:`portfolio_repos` 不单独带 `user_id`,归属由所属作品决定。任何仓库操作必须先校验所属作品的 `user_id = current_user_id`。
+
+## 八、搜索设计说明
+
+**范围**:笔记标题、笔记正文、标签名。三处都支持模糊包含查询。
+
+**技术**:
+
+- 笔记标题 + 正文:通过 `notes.search_text` 生成列合并,对合并字段建 `pg_trgm` GIN 索引,查询 `WHERE user_id = ? AND search_text ILIKE '%关键词%'`。
+- 标签名:对 `tags.name` 建 `pg_trgm` GIN 索引,查询 `WHERE user_id = ? AND name ILIKE '%关键词%'`。
+- 不安装中文分词扩展。1–2 个字的查询会退化为全表扫描,接受这一代价(数据量小,且实际使用中很少只搜一两个字)。
+
+**返回**:
+
+- 笔记搜索结果返回笔记列表,同时返回每篇笔记的标签(联查 `note_tags` + `tags`)。
+- 标签搜索结果返回标签列表,点进去看该标签下的笔记。
+- 搜索本身是一个接口还是两个接口,由 `api.md` 定义,本文件不规定。
+
+## 九、待补:鉴权与审计相关表
+
+以下四张表的字段和规则依赖 `auth.md` 与 `audit.md` 定稿,本文件暂不展开,等那两个文件定稿后补入本节:
+
+- `users` — 用户账号。本期只有一条记录(当前使用者),注册功能不实现。
+- `trusted_devices` — 可授信浏览器设备。
+- `tokens` — 长期 API Token。
+- `audit_events` — 审计事件。

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


+ 104 - 0
docs/conventions.md

@@ -0,0 +1,104 @@
+# conventions — 编码约定与硬性约束
+
+> 本文件回答:文档与代码的组织规则、文档与代码的主从关系、以及本项目的硬性禁令清单。
+> 依赖:无
+
+本文件是给 opencode + DeepSeek 生成代码时的护栏。每次生成跑偏,就把对应的禁令追加到本文件,不另起新文件。
+
+## 一、文档归属规则
+
+文档按“约束谁”划分目录,不按“代码在哪”划分:
+
+- `docs/backend/`:只约束后端的文档
+- `docs/frontend/`:只约束前端的文档(含 TUI / Neovim 客户端)
+- `docs/` 根:前后端共享的契约,或项目级文档
+
+当前文件归属:
+
+- `docs/overview.md` — 项目定位、场景、非目标(项目级)
+- `docs/architecture.md` — 整体架构、目录结构、部署形态(项目级)
+- `docs/conventions.md` — 本文件(项目级)
+- `docs/api.md` — HTTP 接口契约,前后端共享,固定放根目录
+- `docs/backend/data-model.md` — 数据库 schema
+- `docs/backend/auth.md` — 鉴权与权限模型
+- `docs/backend/audit.md` — 审计日志
+- `docs/backend/llm-extension.md` — LLM 抽象层预留
+- `docs/frontend/frontend.md` — Web 前端页面与交互
+- `docs/frontend/clients.md` — TUI 与 Neovim 插件
+
+新增文档时,先判断它约束谁,再决定放哪个目录。
+
+## 二、文档与代码的主从关系
+
+1. `docs/` 是唯一事实来源。所有代码由 opencode + DeepSeek 依据文档生成,不以代码反推文档。
+2. 代码与文档冲突时,以文档为准。生成结果不符合文档,是代码错,重新生成或修正代码,不修改文档去迁就代码。
+3. 改需求先改文档。任何功能变更、接口变更、数据模型变更,先落文档,再重新生成相关代码。
+4. 文档回答的问题必须唯一。同一个问题只在一个文件里回答;如果发现两处都在回答同一问题,合并到一处,另一处改为引用。
+5. 文档只能由人类修改。opencode / AI 只读取 `docs/` 生成代码,不得写入或修改 `docs/` 下任何文件。文档的增删改一律由项目维护者手动完成。
+
+每个文档文件开头固定两行:
+
+> 本文件回答:<一句话,这个问题只有本文件回答>
+> 依赖:<前置文件,没有就写“无”>
+
+## 三、目录边界
+
+Haven/
+├── docs/           # 唯一事实来源
+├── backend/        # Go 后端,代码只写在这里
+├── frontend/       # Vue 前端,代码只写在这里
+├── deploy/         # 部署脚本(systemd unit、构建脚本等),如需要
+└── README.md
+
+- 后端代码只写在 `backend/` 下,前端代码只写在 `frontend/` 下,不得交叉。
+- 生成后端代码时,上下文为 `docs/` 根下项目级文件 + `docs/backend/` + `docs/api.md`。
+- 生成前端代码时,上下文为 `docs/` 根下项目级文件 + `docs/frontend/` + `docs/api.md`。
+
+## 四、硬性禁令清单
+
+本清单只锁“不做什么”,不锁“具体怎么做”。具体实现方式以 `architecture.md` 为准。
+
+以下事项本项目明确不做。opencode 生成代码时不得自行添加,发现即视为生成错误。
+
+### 架构与部署
+
+- 不使用 Docker / Docker Compose 部署本项目。
+- 不使用 Kubernetes。
+- 原因备注:同机 Gogs 若跑在容器中会导致 SSH 失败;为避免同类问题,Haven 不使用容器。
+- 不引入 PostgreSQL 之外的存储作为真值源;Redis / 对象存储等如后续需要,只作为辅助,不存真值。
+- 不做多租户、不做多用户协作,本项目是单人自用。数据模型保留 user_id 归属字段,是为将来可能的第二个账号预留技术位;不代表本期要实现注册、邀请、账号管理等多人功能。所有接口仍按“当前登录用户”过滤,不提供账号创建入口。
+
+### 前端
+
+- 不做 SSR / Nuxt / 预渲染,纯 Vue 3 + Vite SPA。
+- 不为 SEO 做任何专门优化。
+
+### 后端 / 接口
+
+- 创建笔记接口不接受 `visibility` 参数,新建笔记一律为 private;公开走独立的 publish 动作。
+- `visibility` 的默认值在数据库层固化(DEFAULT 'private' + NOT NULL),不依赖应用层传值。
+- API Token 只做 `read` / `read_write` 两档 scope,不做细粒度权限。
+- 所有读接口统一走授权中间件做 visibility 收敛,不在各 handler 里各自判断。
+
+### 客户端
+
+- TUI 与 Neovim 插件只覆盖“读写笔记 + 检索”主路径,不做功能对等,其余交给 Web。
+- 次级客户端以服务端为准,不做离线编辑与冲突合并(如后续要做,先改本文档再实现)。
+
+### LLM
+
+- 本期只定义 `LLMProvider` 接口,不实现任何 LLM 调用。
+- 抽象层不泄漏具体模型 SDK 到业务层。
+
+### 审计
+
+- 前期只记录安全与状态事件(登录成功/失败、TOTP 验证、Token 创建/撤销、授信设备添加/撤销、笔记 publish)。
+- 笔记 CRUD 等高频业务事件前期不记录。
+
+## 五、追加规则
+
+- 每次 opencode 生成结果出现“文档没写、但代码自作主张”的行为,由项目维护者(人类)判断是否合理:
+  - 合理:补进对应文档,作为正式需求;
+  - 不合理:追加到本文件第四节禁令清单。
+- 本文件第四节只增不删;确需解除禁令时,写明原因和日期。
+- AI / opencode 不得自行修改 `docs/` 下任何文件,也不得自行向禁令清单追加条目。

+ 0 - 0
docs/frontend/clients.md


+ 0 - 0
docs/frontend/frontend.md


+ 57 - 0
docs/overview.md

@@ -0,0 +1,57 @@
+# overview — 项目定位与边界
+
+> 本文件回答:Haven 是什么、面向什么场景、由哪几部分组成、主页承担什么职责。
+> 依赖:无
+
+## 一、一句话定位
+
+Haven 是一套自托管的个人综合知识中台:以 PostgreSQL 为唯一真值源,Go 后端 + Vue 前端,Web 浏览器为一等主入口,TUI 与 Neovim 插件为次级客户端,服务于个人在多环境下的写作与查阅。
+
+## 二、核心场景
+
+使用者在三类环境之间切换,对鉴权和访问方式的要求不同:
+
+- **家里终端**:可信环境,可授信长期免二次验证;
+- **公司电脑**:半可信环境,需要登录但可授信;
+- **网吧等公共环境**:不可信环境,需要完整鉴权且离开后凭证应可撤销。
+
+核心矛盾是:**同一套数据,要在可信与不可信环境之间安全地读写**。Haven 的鉴权模型围绕这个矛盾设计。
+
+## 三、组成部分
+
+| 部分 | 角色 | 说明 |
+|---|---|---|
+| Web 前端 | 一等主入口 | 登录、写作、公开浏览、作品集、设置 |
+| Go 后端 | 唯一 API 出口 | 所有客户端经由它读写 PostgreSQL |
+| PostgreSQL | 唯一真值源 | 不引入其他存储作为真值源 |
+| TUI 客户端 | 次级客户端 | 只覆盖读写笔记 + 检索主路径 |
+| Neovim 插件 | 次级客户端 | 同上,走 API Token 鉴权 |
+
+三类客户端的分工原则:**Web 是功能全集,TUI 与 Neovim 只覆盖主路径,不做功能对等。**
+
+## 四、权限模型概览
+
+- 笔记分两级:`private`(默认)/ `public`;
+- 新建笔记一律 private,公开是显式动作;
+- 未登录只能访问 public 内容;
+- 鉴权分层:账号密码 → TOTP → 可授信浏览器设备;
+- 长期 API Token 供 TUI / Neovim / 脚本使用,可单独撤销,与登录会话解耦。
+
+细节见 `docs/backend/auth.md` 与 `docs/backend/data-model.md`。
+
+## 五、主页职责
+
+主页同时承担三件事,按登录态区分渲染:
+
+- **私有工作台**:登录后可见的笔记管理与写作入口;
+- **公开内容浏览**:未登录也可访问的 public 笔记列表;
+- **作品集展示**:仓库托管在自建 Gogs 或 GitHub,条目声明平台后跳转。
+
+## 六、扩展预留
+
+- **LLM 抽象层**:为后续 RAG 检索、笔记静态翻译预留接口,本期只定义不实现;
+- **审计日志**:表结构预留,前期只记安全与状态事件。
+
+## 七、边界
+
+本项目的硬性禁令(不做什么)集中在 `docs/conventions.md` 第四节,本文件不重复列举。

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