Преглед на файлове

feat(backend): 完成 TODO 第 1 章配置与数据库基座

- internal/config:配置包落地并补单测,cmd/haven 改为引用
- internal/db:pgx 连接池 + 启动 ping,集成测试以 HAVEN_TEST_DB_DSN 为开关
- migrations/0001_init:按 data-model/auth/audit 建全部表、索引与 CHECK
- cmd/haven-init-user:创建唯一账号(bcrypt cost 12),不暴露注册接口
- TODO 第 1 章 5 项完成、1 项待 deploy 补脚本;AGENTS 更新代码状态与本地初始化命令
kairo преди 5 часа
родител
ревизия
1fde3bf2d8

+ 15 - 4
AGENTS.md

@@ -37,8 +37,10 @@
 
 仓库处于脚手架阶段(TODO.md 第 0 章):
 
-- `backend/cmd/haven/` 已有可启动入口:`main.go` / `config.go` / `logger.go`,读 `HAVEN_*` 环境变量、缺失即拒绝启动,只注册了 `GET /api/health`。**暂未接入 `internal/config`**(第 1 章会迁移)。
-- `backend/internal/*` 全是空目录;`backend/migrations/` 为空;`deploy/` 尚不存在。
+- `backend/cmd/haven/` 已有可启动入口:`main.go` / `logger.go`,读 `HAVEN_*` 环境变量、缺失即拒绝启动,启动时连库并 ping,只注册了 `GET /api/health`。
+- `backend/internal/config` 与 `backend/internal/db` 已可用;`backend/internal/{auth,notes,tags,search,portfolio,audit,httpx,llm}` 仍是空目录。
+- `backend/migrations/0001_init.up.sql` 已建全部表与索引;`cmd/haven-init-user` 用于创建唯一账号。
+- `deploy/` 尚不存在(migration 执行方式待第 7 章补)。
 - `frontend/` 是清理过的 Vite 模板:`main.ts` 已接 `vue-router`(History,路由表暂空)与 `pinia`,`App.vue` 只渲染 `RouterView`;`src/{api,views,stores}` 是空目录。
 - `frontend/src/style.css` 只剩全局基础样式,页面样式未开始。
 - `frontend/dist/` 是构建产物,已被 gitignore,不要手改、不要提交。
@@ -48,10 +50,18 @@
 后端(在 `backend/` 下执行):
 
 ```bash
-go build ./...                  # 编译
+go build ./...
 go vet ./...
 go test ./...                   # 单包:go test ./internal/auth/...
 go test ./internal/auth -run TestLoginTotp -v
+go test ./internal/db -v        # 需要 HAVEN_TEST_DB_DSN,见下
+```
+
+集成测试(目前只有 `internal/db`)在未设置 `HAVEN_TEST_DB_DSN` 时自动 skip。手动初始化本地库:
+
+```bash
+psql "$HAVEN_DB_DSN" -f migrations/0001_init.up.sql        # 建表(启动时不会自动跑)
+HAVEN_DB_DSN=... HAVEN_INIT_PASSWORD='至少12位' go run ./cmd/haven-init-user -username kairo
 ```
 
 前端(在 `frontend/` 下执行):包管理器是 **pnpm**(有 `pnpm-lock.yaml`,不要用 npm/yarn 生成新 lockfile)。
@@ -69,7 +79,8 @@ pnpm build                      # = vue-tsc -b && vite build → frontend/dist/
 ## 容易踩的约定
 
 - **module 路径**是 `github.com/kairo/haven`;`go.mod` 用 `go 1.27.3`,`pgx` 是 v5。
-- **migration** 用 `golang-migrate` 命名 `0001_init.up.sql` / `.down.sql`,放 `backend/migrations/`;顺序为 `users` → `sessions`/`pending_logins`/`trusted_devices`/`tokens`/`audit_events` → `notes`/`tags`/`portfolio_items` → `note_tags`/`portfolio_repos`。**后端启动时不跑 migration**。
+- **migration** 用 `golang-migrate` 命名 `0001_init.up.sql` / `.down.sql`,放 `backend/migrations/`;顺序为 `users` → `sessions`/`pending_logins`/`trusted_devices`/`tokens`/`audit_events` → `notes`/`tags`/`portfolio_items` → `note_tags`/`portfolio_repos`。**后端启动时不跑 migration**。表结构以 `docs/backend/data-model.md` + `auth.md` 第九节 + `audit.md` 为准;`users` 表只在 `auth.md` 里定义。
+- **加 PostgreSQL 权限**:若用 `postgres` 超级用户跑 migration,之后必须 `GRANT ALL ON ALL TABLES IN SCHEMA public TO haven;`,否则应用连接会报 `permission denied for table users`。
 - **后端分包按领域**(`notes`、`tags`、`search`、`portfolio`、`audit`),禁止建 `handlers/`、`services/`、`repositories/`;跨领域公共代码只放 `httpx` / `auth` / `config`。
 - **搜索**只查生成列 `notes.search_text`,应用层不写该字段;`ILIKE '%q%'`,无中文分词。
 - **公开可见条件唯一**:`visibility = 'public' AND published_at IS NOT NULL`;撤回 public→private 时保留 `published_at`。

+ 7 - 7
TODO.md

@@ -3,7 +3,7 @@
 > 执行清单,不是事实来源。所有实现细节以 `docs/` 为准;本文件与 `docs/` 冲突时以 `docs/` 为准。
 > `docs/` 只读,需求变更请先改文档,再勾选这里的条目。
 
-当前阶段:**第 0 章(脚手架清理)已完成**,下一步进入第 1 章(配置与数据库基座)。
+当前阶段:**第 1 章(配置与数据库基座)基本完成**,仅剩 migration 执行脚本(依赖第 7 章的 `deploy/`)。下一步进入第 2 章(鉴权核心)。
 
 图例:`[ ]` 未做 · `[x]` 已完成 · `[~]` 部分完成
 
@@ -23,12 +23,12 @@
 
 ## 1. 配置与数据库基座
 
-- [ ] `internal/config`:读取并校验全部 `HAVEN_*` 变量,缺必填项拒绝启动;常量(cookie 名、有效期、bcrypt cost)写死不走配置
-- [ ] `internal/db`:`database/sql` + `pgx` 连接池,启动时 ping
-- [ ] `migrations/0001_init.up.sql` / `.down.sql`:按 `data-model.md` 依赖顺序建表(`users` → `sessions`/`pending_logins`/`trusted_devices`/`tokens`/`audit_events` → `notes`/`tags`/`portfolio_items` → `note_tags`/`portfolio_repos`),含全部索引与 CHECK
-- [ ] migration 要求 `CREATE EXTENSION IF NOT EXISTS pg_trgm;`;确认 `gen_random_uuid()` 可用
-- [ ] 初始化脚本:创建唯一用户(bcrypt cost 12)——**不暴露注册接口**
-- [ ] 后端启动**不**跑 migration(由脚本执行);在 `deploy/` 或文档待定处补执行方式
+- [x] `internal/config`:读取并校验全部 `HAVEN_*` 变量,缺必填项拒绝启动;常量(cookie 名、有效期、bcrypt cost)写死不走配置
+- [x] `internal/db`:`database/sql` + `pgx` 连接池,启动时 ping
+- [x] `migrations/0001_init.up.sql` / `.down.sql`:按 `data-model.md` 依赖顺序建表(`users` → `sessions`/`pending_logins`/`trusted_devices`/`tokens`/`audit_events` → `notes`/`tags`/`portfolio_items` → `note_tags`/`portfolio_repos`),含全部索引与 CHECK
+- [x] migration 要求 `CREATE EXTENSION IF NOT EXISTS pg_trgm;`;确认 `gen_random_uuid()` 可用
+- [x] 初始化脚本:创建唯一用户(bcrypt cost 12)——**不暴露注册接口**(`backend/cmd/haven-init-user`)
+- [~] 后端启动**不**跑 migration(由脚本执行);在 `deploy/` 或文档待定处补执行方式(已确认启动不迁移;执行方式待 `deploy/` 建立后补,见第 7 章)
 
 ## 2. 鉴权核心(`internal/auth`,按 `auth.md`)
 

+ 117 - 0
backend/cmd/haven-init-user/main.go

@@ -0,0 +1,117 @@
+// haven-init-user 创建 Haven 的初始用户(本期唯一账号)。
+//
+// 依据 docs/backend/auth.md 第九节:本期只有一条 users 记录,由部署时的
+// 初始化脚本创建;不提供注册接口,账号创建只能走命令行。
+//
+// 用法:
+//
+//	HAVEN_DB_DSN='postgres://haven@127.0.0.1:5432/haven?sslmode=disable' \
+//	  go run ./cmd/haven-init-user -username kairo
+//
+// 密码从标准输入读取一次(不回显到命令行历史),或通过环境变量
+// HAVEN_INIT_PASSWORD 提供(便于部署脚本)。密码要求见 docs/api.md 2.7:
+// 至少 12 位。bcrypt cost 固定为 12(auth.md 8.1)。
+
+package main
+
+import (
+	"context"
+	"errors"
+	"flag"
+	"fmt"
+	"os"
+	"strings"
+	"time"
+
+	"golang.org/x/crypto/bcrypt"
+	"golang.org/x/term"
+
+	"github.com/kairo/haven/internal/db"
+)
+
+// bcryptCost 固定 12,见 docs/backend/auth.md 8.1 与 architecture.md 第四节常量表。
+const bcryptCost = 12
+
+// minPasswordLen 见 docs/api.md 2.7:新密码 ≥ 12 位。
+const minPasswordLen = 12
+
+func main() {
+	if err := run(); err != nil {
+		fmt.Fprintf(os.Stderr, "创建用户失败: %v\n", err)
+		os.Exit(1)
+	}
+}
+
+func run() error {
+	username := flag.String("username", "", "登录名(必填)")
+	flag.Parse()
+
+	name := strings.TrimSpace(*username)
+	if name == "" {
+		return errors.New("必须通过 -username 指定登录名")
+	}
+
+	dsn := strings.TrimSpace(os.Getenv("HAVEN_DB_DSN"))
+	if dsn == "" {
+		return errors.New("未设置 HAVEN_DB_DSN")
+	}
+
+	password, err := readPassword()
+	if err != nil {
+		return err
+	}
+	if len([]rune(password)) < minPasswordLen {
+		return fmt.Errorf("密码至少 %d 位", minPasswordLen)
+	}
+
+	hash, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost)
+	if err != nil {
+		return fmt.Errorf("生成密码哈希失败: %w", err)
+	}
+
+	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+	defer cancel()
+
+	conn, err := db.Open(ctx, dsn)
+	if err != nil {
+		return err
+	}
+	defer conn.Close()
+
+	// 不区分大小写判重,与 idx_users_username_lower 一致。
+	var exists bool
+	if err := conn.QueryRowContext(ctx,
+		`SELECT EXISTS (SELECT 1 FROM users WHERE lower(username) = lower($1))`, name,
+	).Scan(&exists); err != nil {
+		return fmt.Errorf("检查用户是否存在失败: %w", err)
+	}
+	if exists {
+		return fmt.Errorf("用户 %q 已存在,本期只允许一个账号", name)
+	}
+
+	var id string
+	if err := conn.QueryRowContext(ctx,
+		`INSERT INTO users (username, password_hash) VALUES ($1, $2) RETURNING id`,
+		name, string(hash),
+	).Scan(&id); err != nil {
+		return fmt.Errorf("写入用户失败: %w", err)
+	}
+
+	fmt.Printf("已创建用户 %s(id=%s,totp_enabled=false)\n", name, id)
+	return nil
+}
+
+// readPassword 优先读 HAVEN_INIT_PASSWORD,否则从终端读取一次不回显的输入。
+func readPassword() (string, error) {
+	if pw := os.Getenv("HAVEN_INIT_PASSWORD"); pw != "" {
+		return pw, nil
+	}
+
+	fmt.Fprint(os.Stderr, "请输入初始密码: ")
+	raw, err := term.ReadPassword(int(os.Stdin.Fd()))
+	fmt.Fprintln(os.Stderr)
+	if err != nil {
+		return "", fmt.Errorf("读取密码失败: %w", err)
+	}
+	return string(raw), nil
+}

+ 12 - 1
backend/cmd/haven/main.go

@@ -1,10 +1,14 @@
 package main
 
 import (
+	"context"
 	"log/slog"
 	"os"
 
 	"github.com/gin-gonic/gin"
+
+	"github.com/kairo/haven/internal/config"
+	"github.com/kairo/haven/internal/db"
 )
 
 // main 是后端入口。
@@ -20,7 +24,7 @@ func main() {
 }
 
 func run() error {
-	cfg, err := loadConfig()
+	cfg, err := config.Load()
 	if err != nil {
 		return err
 	}
@@ -28,6 +32,13 @@ func run() error {
 	logger := newLogger(cfg.LogLevel)
 	slog.SetDefault(logger)
 
+	conn, err := db.Open(context.Background(), cfg.DBDSN)
+	if err != nil {
+		return err
+	}
+	defer conn.Close()
+	logger.Info("数据库连接就绪")
+
 	gin.SetMode(gin.ReleaseMode)
 	r := gin.New()
 	r.Use(gin.Recovery())

+ 11 - 4
backend/go.mod

@@ -2,23 +2,29 @@ module github.com/kairo/haven
 
 go 1.27.3
 
+require (
+	github.com/gin-gonic/gin v1.12.0
+	github.com/jackc/pgx/v5 v5.11.0
+	golang.org/x/crypto v0.53.0
+	golang.org/x/term v0.44.0
+)
+
 require (
 	github.com/bytedance/gopkg v0.1.3 // indirect
 	github.com/bytedance/sonic v1.15.0 // indirect
 	github.com/bytedance/sonic/loader v0.5.0 // indirect
 	github.com/cloudwego/base64x v0.1.6 // indirect
+	github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
 	github.com/gabriel-vasile/mimetype v1.4.12 // indirect
 	github.com/gin-contrib/sse v1.1.0 // indirect
-	github.com/gin-gonic/gin v1.12.0 // indirect
 	github.com/go-playground/locales v0.14.1 // indirect
 	github.com/go-playground/universal-translator v0.18.1 // indirect
 	github.com/go-playground/validator/v10 v10.30.1 // indirect
 	github.com/goccy/go-json v0.10.5 // indirect
 	github.com/goccy/go-yaml v1.19.2 // indirect
-	github.com/golang-migrate/migrate/v4 v4.20.1 // indirect
 	github.com/jackc/pgpassfile v1.0.0 // indirect
 	github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
-	github.com/jackc/pgx/v5 v5.11.0 // indirect
+	github.com/jackc/puddle/v2 v2.2.2 // indirect
 	github.com/json-iterator/go v1.1.12 // indirect
 	github.com/klauspost/cpuid/v2 v2.3.0 // indirect
 	github.com/leodido/go-urn v1.4.0 // indirect
@@ -26,14 +32,15 @@ require (
 	github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
 	github.com/modern-go/reflect2 v1.0.2 // indirect
 	github.com/pelletier/go-toml/v2 v2.2.4 // indirect
+	github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
 	github.com/quic-go/qpack v0.6.0 // indirect
 	github.com/quic-go/quic-go v0.59.0 // indirect
 	github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
 	github.com/ugorji/go/codec v1.3.1 // indirect
 	go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
 	golang.org/x/arch v0.22.0 // indirect
-	golang.org/x/crypto v0.53.0 // indirect
 	golang.org/x/net v0.56.0 // indirect
+	golang.org/x/sync v0.21.0 // indirect
 	golang.org/x/sys v0.46.0 // indirect
 	golang.org/x/text v0.38.0 // indirect
 	google.golang.org/protobuf v1.36.11 // indirect

+ 19 - 12
backend/go.sum

@@ -8,12 +8,16 @@ github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI
 github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
+github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
 github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
 github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
 github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
 github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
 github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
+github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
+github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
 github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
 github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
 github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
@@ -24,8 +28,8 @@ github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
 github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
 github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
 github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
-github.com/golang-migrate/migrate/v4 v4.20.1 h1:2N/ToVTKrKl58ynBpgeVJ4In7VcLCjWTZtm4eP1LxhU=
-github.com/golang-migrate/migrate/v4 v4.20.1/go.mod h1:DDPgKVb4ovSWc4FwSPfV2Uz1160f4XBiTHTrAJtljmM=
+github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
+github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
 github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
 github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
 github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
@@ -33,6 +37,8 @@ github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7Ulw
 github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
 github.com/jackc/pgx/v5 v5.11.0 h1:IzBBtyK9AHqf98cctWFifYSci2hgQR/cd56wB4p+ogg=
 github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
+github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
+github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
 github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
 github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
 github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
@@ -49,6 +55,8 @@ github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjY
 github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
 github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
+github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
 github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
 github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
@@ -63,35 +71,34 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
 github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
 github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
 github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
+github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
 github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
 github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
 github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
 github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
 go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
 go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
+go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
+go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
 golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
 golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
-golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
-golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
 golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
 golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
-golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
-golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
 golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
 golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
+golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
+golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
 golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
-golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
 golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
 golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
-golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
-golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
+golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=
+golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
 golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
 golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
-google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
-google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
 google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
 google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
 gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

+ 15 - 13
backend/cmd/haven/config.go → backend/internal/config/config.go

@@ -1,4 +1,10 @@
-package main
+// Package config 负责从环境变量加载并校验后端配置。
+//
+// 依据 docs/architecture.md 第四节:所有配置通过环境变量提供,
+// 不引入配置文件;缺失必填项时拒绝启动。
+//
+// cookie 名、有效期、bcrypt cost 等常量不在此暴露,直接写在代码里。
+package config
 
 import (
 	"fmt"
@@ -7,12 +13,8 @@ import (
 	"strings"
 )
 
-// config 是后端启动所需的全部配置。
-//
-// 来源见 docs/architecture.md 第四节:全部通过环境变量提供,
-// 缺失必填项则拒绝启动。cookie 名、有效期、bcrypt cost 等常量
-// 不在配置里暴露,直接写在代码中。
-type config struct {
+// Config 是后端启动所需的全部配置。
+type Config struct {
 	// 必填
 	ListenAddr   string // HAVEN_LISTEN_ADDR
 	DBDSN        string // HAVEN_DB_DSN
@@ -26,10 +28,10 @@ type config struct {
 	LogLevel     string // HAVEN_LOG_LEVEL,默认 info
 }
 
-// loadConfig 从环境变量读取并校验配置。
+// Load 从环境变量读取并校验配置。
 // 任一必填项缺失或取值非法都返回错误,由调用方拒绝启动。
-func loadConfig() (config, error) {
-	var cfg config
+func Load() (Config, error) {
+	var cfg Config
 	var missing []string
 
 	cfg.ListenAddr = strings.TrimSpace(os.Getenv("HAVEN_LISTEN_ADDR"))
@@ -58,14 +60,14 @@ func loadConfig() (config, error) {
 	}
 
 	if len(missing) > 0 {
-		return config{}, fmt.Errorf("缺少必填环境变量: %s", strings.Join(missing, ", "))
+		return Config{}, fmt.Errorf("缺少必填环境变量: %s", strings.Join(missing, ", "))
 	}
 
 	cfg.CookieSecure = true
 	if raw := strings.TrimSpace(os.Getenv("HAVEN_COOKIE_SECURE")); raw != "" {
 		v, err := strconv.ParseBool(raw)
 		if err != nil {
-			return config{}, fmt.Errorf("HAVEN_COOKIE_SECURE 取值非法 %q,应为 true 或 false", raw)
+			return Config{}, fmt.Errorf("HAVEN_COOKIE_SECURE 取值非法 %q,应为 true 或 false", raw)
 		}
 		cfg.CookieSecure = v
 	}
@@ -82,7 +84,7 @@ func loadConfig() (config, error) {
 	switch cfg.LogLevel {
 	case "debug", "info", "warn", "error":
 	default:
-		return config{}, fmt.Errorf("HAVEN_LOG_LEVEL 取值非法 %q,应为 debug / info / warn / error", cfg.LogLevel)
+		return Config{}, fmt.Errorf("HAVEN_LOG_LEVEL 取值非法 %q,应为 debug / info / warn / error", cfg.LogLevel)
 	}
 
 	return cfg, nil

+ 123 - 0
backend/internal/config/config_test.go

@@ -0,0 +1,123 @@
+package config
+
+import (
+	"strings"
+	"testing"
+)
+
+// setAll 设置一组完整的必填环境变量,供各用例在此基础上调整。
+func setAll(t *testing.T) {
+	t.Helper()
+	t.Setenv("HAVEN_LISTEN_ADDR", "127.0.0.1:8080")
+	t.Setenv("HAVEN_DB_DSN", "postgres://haven@127.0.0.1:5432/haven?sslmode=disable")
+	t.Setenv("HAVEN_FRONTEND_DIR", "/opt/haven/frontend/dist")
+	t.Setenv("HAVEN_GOGS_BASE_URL", "https://gogs.example.com")
+	t.Setenv("HAVEN_COOKIE_DOMAIN", "app.example.com")
+}
+
+func TestLoadDefaults(t *testing.T) {
+	setAll(t)
+
+	cfg, err := Load()
+	if err != nil {
+		t.Fatalf("Load() 返回错误: %v", err)
+	}
+
+	if !cfg.CookieSecure {
+		t.Error("HAVEN_COOKIE_SECURE 未设置时应默认 true")
+	}
+	if cfg.TotpIssuer != "Haven" {
+		t.Errorf("HAVEN_TOTP_ISSUER 默认为 Haven,实际 %q", cfg.TotpIssuer)
+	}
+	if cfg.LogLevel != "info" {
+		t.Errorf("HAVEN_LOG_LEVEL 默认为 info,实际 %q", cfg.LogLevel)
+	}
+}
+
+func TestLoadMissingRequired(t *testing.T) {
+	setAll(t)
+	t.Setenv("HAVEN_DB_DSN", "")
+	t.Setenv("HAVEN_COOKIE_DOMAIN", "   ")
+
+	_, err := Load()
+	if err == nil {
+		t.Fatal("缺少必填项时应返回错误")
+	}
+	// 应一次性列出全部缺失项,而不是只报第一个
+	for _, name := range []string{"HAVEN_DB_DSN", "HAVEN_COOKIE_DOMAIN"} {
+		if !strings.Contains(err.Error(), name) {
+			t.Errorf("错误信息应包含 %s,实际 %q", name, err.Error())
+		}
+	}
+}
+
+func TestLoadCookieSecureFalse(t *testing.T) {
+	setAll(t)
+	t.Setenv("HAVEN_COOKIE_SECURE", "false")
+
+	cfg, err := Load()
+	if err != nil {
+		t.Fatalf("Load() 返回错误: %v", err)
+	}
+	if cfg.CookieSecure {
+		t.Error("HAVEN_COOKIE_SECURE=false 时应为 false")
+	}
+}
+
+func TestLoadInvalidCookieSecure(t *testing.T) {
+	setAll(t)
+	t.Setenv("HAVEN_COOKIE_SECURE", "yes-please")
+
+	if _, err := Load(); err == nil {
+		t.Fatal("非法的 HAVEN_COOKIE_SECURE 应返回错误")
+	}
+}
+
+func TestLoadLogLevel(t *testing.T) {
+	cases := []struct {
+		raw     string
+		want    string
+		wantErr bool
+	}{
+		{raw: "", want: "info"},
+		{raw: "DEBUG", want: "debug"},
+		{raw: "warn", want: "warn"},
+		{raw: "error", want: "error"},
+		{raw: "verbose", wantErr: true},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.raw, func(t *testing.T) {
+			setAll(t)
+			t.Setenv("HAVEN_LOG_LEVEL", tc.raw)
+
+			cfg, err := Load()
+			if tc.wantErr {
+				if err == nil {
+					t.Fatalf("HAVEN_LOG_LEVEL=%q 应返回错误", tc.raw)
+				}
+				return
+			}
+			if err != nil {
+				t.Fatalf("Load() 返回错误: %v", err)
+			}
+			if cfg.LogLevel != tc.want {
+				t.Errorf("HAVEN_LOG_LEVEL=%q 解析为 %q,期望 %q", tc.raw, cfg.LogLevel, tc.want)
+			}
+		})
+	}
+}
+
+func TestLoadTrimsTrailingSlash(t *testing.T) {
+	setAll(t)
+	t.Setenv("HAVEN_GOGS_BASE_URL", "https://gogs.example.com/")
+
+	cfg, err := Load()
+	if err != nil {
+		t.Fatalf("Load() 返回错误: %v", err)
+	}
+	// 作品集 URL 由 GogsBaseURL + "/" + repo_path 拼接,末尾斜杠必须去掉
+	if cfg.GogsBaseURL != "https://gogs.example.com" {
+		t.Errorf("GogsBaseURL 应去掉末尾斜杠,实际 %q", cfg.GogsBaseURL)
+	}
+}

+ 36 - 0
backend/internal/db/db.go

@@ -0,0 +1,36 @@
+// Package db 负责建立后端到 PostgreSQL 的连接池。
+//
+// 数据库是唯一真值源(docs/overview.md),访问方式为 database/sql 加
+// pgx 驱动、手写 SQL(docs/architecture.md 5.1),不引入 ORM。
+package db
+
+import (
+	"context"
+	"database/sql"
+	"fmt"
+	"time"
+
+	_ "github.com/jackc/pgx/v5/stdlib" // 注册 "pgx" 驱动
+)
+
+// Open 创建连接池并做一次连通性检查,失败即返回错误,由调用方拒绝启动。
+func Open(ctx context.Context, dsn string) (*sql.DB, error) {
+	conn, err := sql.Open("pgx", dsn)
+	if err != nil {
+		return nil, fmt.Errorf("打开数据库连接失败: %w", err)
+	}
+
+	// 连接池与单连接超时:自用单机部署,连接数不宜放大。
+	conn.SetMaxOpenConns(10)
+	conn.SetMaxIdleConns(5)
+	conn.SetConnMaxLifetime(time.Hour)
+
+	pingCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
+	defer cancel()
+	if err := conn.PingContext(pingCtx); err != nil {
+		conn.Close()
+		return nil, fmt.Errorf("数据库连通性检查失败: %w", err)
+	}
+
+	return conn, nil
+}

+ 47 - 0
backend/internal/db/db_test.go

@@ -0,0 +1,47 @@
+package db
+
+import (
+	"context"
+	"os"
+	"testing"
+	"time"
+)
+
+// TestOpen 需要真实的 PostgreSQL,未设置 HAVEN_TEST_DB_DSN 时跳过。
+//
+// 本地跑法:
+//
+//	HAVEN_TEST_DB_DSN='postgres://haven:xxx@127.0.0.1:5432/haven?sslmode=disable' \
+//	  go test ./internal/db/... -v
+func TestOpen(t *testing.T) {
+	dsn := os.Getenv("HAVEN_TEST_DB_DSN")
+	if dsn == "" {
+		t.Skip("未设置 HAVEN_TEST_DB_DSN,跳过数据库集成测试")
+	}
+
+	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+	defer cancel()
+
+	conn, err := Open(ctx, dsn)
+	if err != nil {
+		t.Fatalf("Open() 返回错误: %v", err)
+	}
+	defer conn.Close()
+
+	var one int
+	if err := conn.QueryRowContext(ctx, "SELECT 1").Scan(&one); err != nil {
+		t.Fatalf("查询失败: %v", err)
+	}
+	if one != 1 {
+		t.Errorf("SELECT 1 返回 %d", one)
+	}
+}
+
+func TestOpenInvalidDSN(t *testing.T) {
+	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+	defer cancel()
+
+	if _, err := Open(ctx, "postgres://nouser@127.0.0.1:1/nodb?sslmode=disable&connect_timeout=2"); err == nil {
+		t.Fatal("无法连接的 DSN 应返回错误")
+	}
+}

+ 16 - 0
backend/migrations/0001_init.down.sql

@@ -0,0 +1,16 @@
+-- 0001_init down
+--
+-- 与 0001_init.up.sql 对称:按建表的逆序删除。
+-- 保留 pg_trgm 扩展(可能被同库其他对象使用),不在此删除。
+
+DROP TABLE IF EXISTS portfolio_repos;
+DROP TABLE IF EXISTS note_tags;
+DROP TABLE IF EXISTS portfolio_items;
+DROP TABLE IF EXISTS tags;
+DROP TABLE IF EXISTS notes;
+DROP TABLE IF EXISTS audit_events;
+DROP TABLE IF EXISTS tokens;
+DROP TABLE IF EXISTS trusted_devices;
+DROP TABLE IF EXISTS pending_logins;
+DROP TABLE IF EXISTS sessions;
+DROP TABLE IF EXISTS users;

+ 219 - 0
backend/migrations/0001_init.up.sql

@@ -0,0 +1,219 @@
+-- 0001_init
+--
+-- Haven 初始 schema。依据:
+--   docs/backend/data-model.md(表、索引、枚举)
+--   docs/backend/auth.md 第三节 / 3.1 / 四 / 五 / 九(sessions、pending_logins、
+--     trusted_devices、tokens、users)
+--   docs/backend/audit.md 第二节 / 第三节(audit_events)
+--
+-- 建表顺序遵循 data-model.md 第一节:
+--   users -> sessions / pending_logins / trusted_devices / tokens / audit_events
+--         -> notes / tags / portfolio_items
+--         -> note_tags / portfolio_repos
+--
+-- 本文件由部署脚本执行,后端启动时不自动跑 migration
+-- (docs/architecture.md 5.6)。
+
+-- 搜索依赖 pg_trgm(data-model.md 第一节)。
+-- PostgreSQL >= 15 自带 gen_random_uuid(),无需 pgcrypto。
+CREATE EXTENSION IF NOT EXISTS pg_trgm;
+
+-- ---------------------------------------------------------------------------
+-- users(字段以 docs/backend/auth.md 第九节为准)
+-- ---------------------------------------------------------------------------
+CREATE TABLE users (
+    id            uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+    username      text        NOT NULL,
+    password_hash text        NOT NULL,
+    totp_secret   text        NULL DEFAULT NULL,
+    totp_enabled  boolean     NOT NULL DEFAULT false,
+    created_at    timestamptz NOT NULL DEFAULT now(),
+    updated_at    timestamptz NOT NULL DEFAULT now()
+);
+
+-- username 不区分大小写唯一(auth.md 第九节规则 3)。
+-- 只建小写唯一索引,不再额外加普通 UNIQUE,避免 Admin / admin 同时存在。
+CREATE UNIQUE INDEX idx_users_username_lower ON users (lower(username));
+
+-- ---------------------------------------------------------------------------
+-- sessions(auth.md 第三节)
+-- ---------------------------------------------------------------------------
+CREATE TABLE sessions (
+    id           uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+    user_id      uuid        NOT NULL REFERENCES users (id) ON DELETE CASCADE,
+    token_hash   text        NOT NULL UNIQUE,
+    expires_at   timestamptz NOT NULL,
+    last_used_at timestamptz NOT NULL,
+    user_agent   text        NOT NULL DEFAULT '',
+    created_at   timestamptz NOT NULL DEFAULT now()
+);
+
+-- ---------------------------------------------------------------------------
+-- pending_logins(auth.md 3.1)
+-- ---------------------------------------------------------------------------
+CREATE TABLE pending_logins (
+    id         uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+    user_id    uuid        NOT NULL REFERENCES users (id) ON DELETE CASCADE,
+    token_hash text        NOT NULL UNIQUE,
+    expires_at timestamptz NOT NULL,
+    created_at timestamptz NOT NULL DEFAULT now()
+);
+
+-- ---------------------------------------------------------------------------
+-- trusted_devices(auth.md 第四节)
+-- ---------------------------------------------------------------------------
+CREATE TABLE trusted_devices (
+    id           uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+    user_id      uuid        NOT NULL REFERENCES users (id) ON DELETE CASCADE,
+    token_hash   text        NOT NULL UNIQUE,
+    device_name  text        NOT NULL DEFAULT '',
+    expires_at   timestamptz NOT NULL,
+    last_used_at timestamptz NOT NULL,
+    created_at   timestamptz NOT NULL DEFAULT now()
+);
+
+-- ---------------------------------------------------------------------------
+-- tokens(auth.md 第五节)
+-- ---------------------------------------------------------------------------
+CREATE TABLE tokens (
+    id           uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+    user_id      uuid        NOT NULL REFERENCES users (id) ON DELETE CASCADE,
+    name         text        NOT NULL,
+    token_hash   text        NOT NULL UNIQUE,
+    token_prefix text        NOT NULL,
+    scope        text        NOT NULL CHECK (scope IN ('read', 'read_write')),
+    expires_at   timestamptz NULL,
+    last_used_at timestamptz NULL,
+    revoked_at   timestamptz NULL,
+    created_at   timestamptz NOT NULL DEFAULT now()
+);
+
+-- ---------------------------------------------------------------------------
+-- audit_events(audit.md 第二节、第三节)
+-- 只追加,不修改,不删除。
+-- ---------------------------------------------------------------------------
+CREATE TABLE audit_events (
+    id             uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+    actor_user_id  uuid        NULL REFERENCES users (id) ON DELETE SET NULL,
+    actor_username text        NOT NULL DEFAULT '',
+    event_type     text        NOT NULL CHECK (event_type IN (
+                       'login_success',
+                       'login_failure',
+                       'logout',
+                       'totp_bound',
+                       'totp_disabled',
+                       'password_changed',
+                       'session_revoked',
+                       'trusted_device_added',
+                       'trusted_device_revoked',
+                       'token_created',
+                       'token_revoked',
+                       'note_published',
+                       'note_unpublished'
+                   )),
+    -- 'null' 为字符串字面量,表示“无对象”,与 SQL NULL 区分(audit.md 第三节说明)
+    target_type    text        NULL CHECK (target_type IN (
+                       'null', 'user', 'session', 'trusted_device', 'token', 'note'
+                   )),
+    target_id      uuid        NULL,
+    metadata       jsonb       NOT NULL DEFAULT '{}'::jsonb,
+    ip             text        NOT NULL DEFAULT '',
+    user_agent     text        NOT NULL DEFAULT '',
+    created_at     timestamptz NOT NULL DEFAULT now()
+);
+
+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;
+
+-- ---------------------------------------------------------------------------
+-- notes(data-model.md 第三节)
+-- ---------------------------------------------------------------------------
+CREATE TABLE notes (
+    id           uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+    user_id      uuid        NOT NULL REFERENCES users (id) ON DELETE CASCADE,
+    title        text        NOT NULL,
+    content      text        NOT NULL DEFAULT '',
+    visibility   text        NOT NULL DEFAULT 'private'
+                     CHECK (visibility IN ('private', 'public')),
+    published_at timestamptz NULL DEFAULT NULL,
+    -- 搜索用合并字段,数据库自动维护,应用层只读不写(data-model.md 第三节规则 6)
+    search_text  text        GENERATED ALWAYS AS (title || ' ' || content) STORED,
+    created_at   timestamptz NOT NULL DEFAULT now(),
+    updated_at   timestamptz NOT NULL DEFAULT now()
+);
+
+-- 公开列表:按用户 + 发布时间倒序
+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);
+
+-- ---------------------------------------------------------------------------
+-- tags(data-model.md 第四节)
+-- ---------------------------------------------------------------------------
+CREATE TABLE tags (
+    id         uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+    user_id    uuid        NOT NULL REFERENCES users (id) ON DELETE CASCADE,
+    name       text        NOT NULL,
+    created_at timestamptz NOT NULL DEFAULT now()
+);
+
+-- 同一用户下标签名不区分大小写唯一
+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);
+
+-- ---------------------------------------------------------------------------
+-- portfolio_items(data-model.md 第六节)
+-- ---------------------------------------------------------------------------
+CREATE TABLE portfolio_items (
+    id          uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+    user_id     uuid        NOT NULL REFERENCES users (id) ON DELETE CASCADE,
+    title       text        NOT NULL,
+    description text        NOT NULL DEFAULT '',
+    sort_order  int         NOT NULL DEFAULT 0,
+    created_at  timestamptz NOT NULL DEFAULT now()
+);
+
+CREATE INDEX idx_portfolio_items_sort ON portfolio_items (user_id, sort_order);
+
+-- ---------------------------------------------------------------------------
+-- note_tags(data-model.md 第五节)
+-- 无 user_id:归属由 notes.user_id 与 tags.user_id 在应用层校验。
+-- ---------------------------------------------------------------------------
+CREATE TABLE note_tags (
+    note_id uuid NOT NULL REFERENCES notes (id) ON DELETE CASCADE,
+    tag_id  uuid NOT NULL REFERENCES tags (id) ON DELETE CASCADE,
+    PRIMARY KEY (note_id, tag_id)
+);
+
+-- 复合主键已覆盖 note_id 开头的查询;单独为 tag_id 建索引,支持“按标签查笔记”
+CREATE INDEX idx_note_tags_tag_id ON note_tags (tag_id);
+
+-- ---------------------------------------------------------------------------
+-- portfolio_repos(data-model.md 第七节)
+-- 无 user_id:归属通过 portfolio_items 间接确定。
+-- ---------------------------------------------------------------------------
+CREATE TABLE portfolio_repos (
+    id                uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+    portfolio_item_id uuid    NOT NULL REFERENCES portfolio_items (id) ON DELETE CASCADE,
+    platform          text    NOT NULL CHECK (platform IN ('gogs', 'github')),
+    repo_path         text    NOT NULL,
+    is_primary        boolean NOT NULL DEFAULT false
+);
+
+-- 同一作品下平台 + 路径唯一
+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;

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