From 760155f8fdcfc98e9f3b793c6b5f9b6ac105dd1e Mon Sep 17 00:00:00 2001
From: ax_rd <ax_rd@aisim.cn>
Date: Thu, 03 Sep 2026 08:52:12 +0800
Subject: [PATCH] feat: schema v2 迁移(aliases/status/文件指纹/provenance/unresolved_links + FTS external-content)+ Makefile 固化 FTS5 构建标志

---
 internal/index/migrations_test.go |  131 +++++++++++++++++++++
 Makefile                          |   14 +
 internal/index/fts.go             |   47 +++++--
 cmd/review.go                     |    2 
 internal/index/migrations.go      |  125 ++++++++++++++++++++
 internal/index/sqlite.go          |    9 +
 6 files changed, 307 insertions(+), 21 deletions(-)

diff --git a/Makefile b/Makefile
index c772d23..f0daa47 100644
--- a/Makefile
+++ b/Makefile
@@ -1,20 +1,24 @@
-.PHONY: build test clean install
+# Makefile
+CGO_CFLAGS := -DSQLITE_ENABLE_FTS5
+CGO_LDFLAGS := -lm
+
+.PHONY: build test clean install test-v run-search run-index run-status
 
 # 构建
 build:
-	CGO_ENABLED=1 go build -tags fts5 -o bin/kb-cli .
+	CGO_CFLAGS="$(CGO_CFLAGS)" CGO_LDFLAGS="$(CGO_LDFLAGS)" go build -o bin/kb-cli .
 
 # 安装到 ~/go/bin
 install:
-	CGO_ENABLED=1 go install -tags fts5 .
+	CGO_CFLAGS="$(CGO_CFLAGS)" CGO_LDFLAGS="$(CGO_LDFLAGS)" go install .
 
 # 测试
 test:
-	CGO_ENABLED=1 go test -tags fts5 ./...
+	CGO_CFLAGS="$(CGO_CFLAGS)" CGO_LDFLAGS="$(CGO_LDFLAGS)" go test ./... 2>&1 | grep -v "no test files"
 
 # 详细测试
 test-v:
-	CGO_ENABLED=1 go test -tags fts5 -v ./...
+	CGO_CFLAGS="$(CGO_CFLAGS)" CGO_LDFLAGS="$(CGO_LDFLAGS)" go test -v ./... 2>&1 | grep -v "no test files"
 
 # 清理
 clean:
diff --git a/cmd/review.go b/cmd/review.go
index a84025b..bdc0561 100644
--- a/cmd/review.go
+++ b/cmd/review.go
@@ -74,7 +74,7 @@
 		if err == nil {
 			content := extractMainContent(string(draftContent))
 			if content != "" {
-				fmt.Println("### 草稿主要内容\n")
+				fmt.Println("### 草稿主要内容")
 				fmt.Println(content)
 			}
 		}
diff --git a/internal/index/fts.go b/internal/index/fts.go
index 07bc115..4323985 100644
--- a/internal/index/fts.go
+++ b/internal/index/fts.go
@@ -14,25 +14,46 @@
 	Rank    float64
 }
 
-// CreateFTS 创建 FTS5 虚拟表
+// CreateFTS 创建 FTS5 虚拟表(external-content 模式,由触发器增量维护)
+// 注意:external-content 模式下 FTS 列名必须与 content 表列名一致,
+// 故 content 列对应 nodes 表的 content_fts 列
 func (s *Store) CreateFTS() error {
 	_, err := s.db.Exec(`
 		CREATE VIRTUAL TABLE IF NOT EXISTS nodes_fts USING fts5(
-			title,
-			content,
-			tags,
-			entities
-		)
-	`)
-	return err
+			title, content_fts, tags, aliases,
+			content='nodes', content_rowid='id'
+		)`)
+	if err != nil {
+		return err
+	}
+	// 触发器:nodes 的增删改同步维护 FTS
+	triggers := []string{
+		`CREATE TRIGGER IF NOT EXISTS nodes_fts_ai AFTER INSERT ON nodes BEGIN
+			INSERT INTO nodes_fts(rowid, title, content_fts, tags, aliases)
+			VALUES (new.id, new.title, new.content_fts, new.tags, new.aliases);
+		END`,
+		`CREATE TRIGGER IF NOT EXISTS nodes_fts_ad AFTER DELETE ON nodes BEGIN
+			INSERT INTO nodes_fts(nodes_fts, rowid, title, content_fts, tags, aliases)
+			VALUES ('delete', old.id, old.title, old.content_fts, old.tags, old.aliases);
+		END`,
+		`CREATE TRIGGER IF NOT EXISTS nodes_fts_au AFTER UPDATE ON nodes BEGIN
+			INSERT INTO nodes_fts(nodes_fts, rowid, title, content_fts, tags, aliases)
+			VALUES ('delete', old.id, old.title, old.content_fts, old.tags, old.aliases);
+			INSERT INTO nodes_fts(rowid, title, content_fts, tags, aliases)
+			VALUES (new.id, new.title, new.content_fts, new.tags, new.aliases);
+		END`,
+	}
+	for _, t := range triggers {
+		if _, err := s.db.Exec(t); err != nil {
+			return err
+		}
+	}
+	return nil
 }
 
-// PopulateFTS 填充 FTS 索引
+// PopulateFTS 全量重灌 FTS(external-content 模式专用语法)
 func (s *Store) PopulateFTS() error {
-	_, err := s.db.Exec(`
-		INSERT INTO nodes_fts(rowid, title, content, tags, entities)
-		SELECT id, title, content_fts, tags, entities FROM nodes
-	`)
+	_, err := s.db.Exec(`INSERT INTO nodes_fts(nodes_fts) VALUES('rebuild')`)
 	return err
 }
 
diff --git a/internal/index/migrations.go b/internal/index/migrations.go
new file mode 100644
index 0000000..25955b0
--- /dev/null
+++ b/internal/index/migrations.go
@@ -0,0 +1,125 @@
+package index
+
+import (
+	"fmt"
+	"os"
+)
+
+const currentSchemaVersion = 2
+
+// migrate 幂等的 schema 升级:v1 → v2。
+// v2 变更:nodes 加 aliases/status/size/mtime/content_hash 列;
+// edges 加 provenance 列;新建 unresolved_links、schema_versions 表;
+// nodes_fts 改 external-content + 触发器。迁移前自动备份 .db 文件。
+func (s *Store) migrate() error {
+	// 读当前版本
+	var version int
+	err := s.db.QueryRow(`SELECT value FROM schema_versions WHERE key='version'`).Scan(&version)
+	if err != nil && version == 0 {
+		// 首次:schema_versions 表可能不存在
+		version = 1
+	}
+	if version >= currentSchemaVersion {
+		return nil
+	}
+
+	// 备份
+	if err := s.backupDB(); err != nil {
+		return fmt.Errorf("备份失败: %w", err)
+	}
+
+	// v1 → v2
+	if err := s.migrateV1toV2(); err != nil {
+		return fmt.Errorf("v1→v2 迁移失败: %w", err)
+	}
+	return nil
+}
+
+// backupDB 迁移前备份 .db 文件(已有备份则跳过)
+func (s *Store) backupDB() error {
+	if s.dbPath == "" {
+		return nil // 无路径信息时跳过备份(测试场景)
+	}
+	dst := s.dbPath + ".bak"
+	if _, err := os.Stat(dst); err == nil {
+		return nil // 已有备份
+	}
+	data, err := os.ReadFile(s.dbPath)
+	if err != nil {
+		return err
+	}
+	return os.WriteFile(dst, data, 0644)
+}
+
+// migrateV1toV2 执行 v1 → v2 的全部变更
+func (s *Store) migrateV1toV2() error {
+	// 1. schema_versions 表
+	if _, err := s.db.Exec(`CREATE TABLE IF NOT EXISTS schema_versions (
+		key TEXT PRIMARY KEY, value TEXT NOT NULL)`); err != nil {
+		return err
+	}
+	if _, err := s.db.Exec(`INSERT OR REPLACE INTO schema_versions VALUES ('version', '2')`); err != nil {
+		return err
+	}
+
+	// 2. nodes 加列(逐列检查,幂等)
+	for _, col := range []struct{ name, def string }{
+		{"aliases", "ALTER TABLE nodes ADD COLUMN aliases TEXT"},
+		{"status", "ALTER TABLE nodes ADD COLUMN status TEXT"},
+		{"size", "ALTER TABLE nodes ADD COLUMN size INTEGER"},
+		{"mtime", "ALTER TABLE nodes ADD COLUMN mtime INTEGER"},
+		{"content_hash", "ALTER TABLE nodes ADD COLUMN content_hash TEXT"},
+	} {
+		if !s.nodeColumnExists(col.name) {
+			if _, err := s.db.Exec(col.def); err != nil {
+				return fmt.Errorf("加列 %s 失败: %w", col.name, err)
+			}
+		}
+	}
+
+	// 3. edges 加 provenance 列
+	if !s.edgeColumnExists("provenance") {
+		if _, err := s.db.Exec(`ALTER TABLE edges ADD COLUMN provenance TEXT`); err != nil {
+			return fmt.Errorf("edges 加 provenance 失败: %w", err)
+		}
+	}
+
+	// 4. unresolved_links 表
+	if _, err := s.db.Exec(`CREATE TABLE IF NOT EXISTS unresolved_links (
+		id INTEGER PRIMARY KEY AUTOINCREMENT,
+		from_node INTEGER NOT NULL,
+		link_text TEXT NOT NULL,
+		name_tail TEXT NOT NULL,
+		status TEXT NOT NULL DEFAULT 'pending',
+		created_at TEXT DEFAULT (datetime('now')),
+		last_attempt TEXT)`); err != nil {
+		return err
+	}
+	if _, err := s.db.Exec(`CREATE INDEX IF NOT EXISTS idx_unresolved_tail
+		ON unresolved_links(name_tail) WHERE status='failed'`); err != nil {
+		return err
+	}
+
+	// 5. FTS 改 external-content:删旧表重建 + 触发器 + 重灌
+	if _, err := s.db.Exec(`DROP TABLE IF EXISTS nodes_fts`); err != nil {
+		return err
+	}
+	if err := s.CreateFTS(); err != nil {
+		return err
+	}
+	return s.PopulateFTS()
+}
+
+// nodeColumnExists 检查 nodes 表是否已有指定列
+func (s *Store) nodeColumnExists(name string) bool {
+	var n int
+	s.db.QueryRow(`SELECT COUNT(*) FROM pragma_table_info('nodes') WHERE name=?`, name).Scan(&n)
+	return n > 0
+}
+
+// edgeColumnExists 检查 edges 表是否已有指定列
+func (s *Store) edgeColumnExists(name string) bool {
+	var n int
+	s.db.QueryRow(`SELECT COUNT(*) FROM pragma_table_info('edges') WHERE name=?`, name).Scan(&n)
+	return n > 0
+}
diff --git a/internal/index/migrations_test.go b/internal/index/migrations_test.go
new file mode 100644
index 0000000..2dd4328
--- /dev/null
+++ b/internal/index/migrations_test.go
@@ -0,0 +1,131 @@
+package index
+
+import (
+	"database/sql"
+	"os"
+	"path/filepath"
+	"testing"
+)
+
+// newV1Store 手工构建一个 v1 schema 的老库(模拟迁移前状态)
+func newV1Store(t *testing.T) (*Store, string) {
+	t.Helper()
+	dbPath := filepath.Join(t.TempDir(), "kb-v1.db")
+	db, err := sql.Open("sqlite3", dbPath+"?_journal_mode=WAL")
+	if err != nil {
+		t.Fatal(err)
+	}
+	schema := `
+	CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);
+	CREATE TABLE nodes (
+		id INTEGER PRIMARY KEY AUTOINCREMENT,
+		path TEXT NOT NULL UNIQUE,
+		title TEXT, section TEXT, tags TEXT, entities TEXT, wikilinks TEXT,
+		content_fts TEXT,
+		created_at TEXT DEFAULT (datetime('now')),
+		updated_at TEXT DEFAULT (datetime('now')));
+	CREATE TABLE edges (
+		id INTEGER PRIMARY KEY AUTOINCREMENT,
+		from_node INTEGER NOT NULL, to_node INTEGER NOT NULL,
+		relation TEXT NOT NULL, label TEXT,
+		UNIQUE(from_node, to_node, relation, label));
+	`
+	if _, err := db.Exec(schema); err != nil {
+		t.Fatal(err)
+	}
+	if _, err := db.Exec(`INSERT INTO nodes (path, title, tags, entities, wikilinks, content_fts)
+		VALUES ('FAQ/001-测试.md', '测试', '["a"]', '[]', '[]', '内容')`); err != nil {
+		t.Fatal(err)
+	}
+	return &Store{db: db, dbPath: dbPath}, dbPath
+}
+
+func TestMigrateV1ToV2(t *testing.T) {
+	store, dbPath := newV1Store(t)
+	defer store.Close()
+
+	// 迁移前备份文件必须存在
+	if err := store.migrate(); err != nil {
+		t.Fatalf("migrate: %v", err)
+	}
+	if _, err := os.Stat(dbPath + ".bak"); err != nil {
+		t.Fatalf("备份文件不存在: %v", err)
+	}
+
+	// v2 列必须存在
+	cols := store.nodeColumns(t)
+	for _, want := range []string{"aliases", "status", "size", "mtime", "content_hash"} {
+		if !cols[want] {
+			t.Errorf("nodes 缺列 %s", want)
+		}
+	}
+	if !store.edgeHasProvenance(t) {
+		t.Error("edges 缺 provenance 列")
+	}
+	if !store.tableExists(t, "unresolved_links") {
+		t.Error("缺 unresolved_links 表")
+	}
+
+	// 数据保留
+	var title string
+	if err := store.db.QueryRow(`SELECT title FROM nodes WHERE path='FAQ/001-测试.md'`).Scan(&title); err != nil {
+		t.Fatal(err)
+	}
+	if title != "测试" {
+		t.Errorf("迁移丢数据: %s", title)
+	}
+}
+
+func TestMigrateIdempotent(t *testing.T) {
+	store, _ := newV1Store(t)
+	defer store.Close()
+	if err := store.migrate(); err != nil {
+		t.Fatalf("第一次: %v", err)
+	}
+	if err := store.migrate(); err != nil {
+		t.Fatalf("第二次(幂等): %v", err)
+	}
+}
+
+func TestNewDBIsV2(t *testing.T) {
+	dbPath := filepath.Join(t.TempDir(), "kb-v2.db")
+	store, err := Open(dbPath)
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer store.Close()
+	if !store.edgeHasProvenance(t) {
+		t.Error("新建库应为 v2 schema")
+	}
+}
+
+// 测试辅助:列检查
+func (s *Store) nodeColumns(t *testing.T) map[string]bool {
+	t.Helper()
+	rows, err := s.db.Query(`SELECT name FROM pragma_table_info('nodes')`)
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer rows.Close()
+	m := map[string]bool{}
+	for rows.Next() {
+		var name string
+		rows.Scan(&name)
+		m[name] = true
+	}
+	return m
+}
+
+func (s *Store) edgeHasProvenance(t *testing.T) bool {
+	t.Helper()
+	var n int
+	s.db.QueryRow(`SELECT COUNT(*) FROM pragma_table_info('edges') WHERE name='provenance'`).Scan(&n)
+	return n > 0
+}
+
+func (s *Store) tableExists(t *testing.T, name string) bool {
+	t.Helper()
+	var n int
+	s.db.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?`, name).Scan(&n)
+	return n > 0
+}
diff --git a/internal/index/sqlite.go b/internal/index/sqlite.go
index 1501020..a70b7d1 100644
--- a/internal/index/sqlite.go
+++ b/internal/index/sqlite.go
@@ -15,7 +15,8 @@
 
 // Store SQLite 存储层
 type Store struct {
-	db *sql.DB
+	db     *sql.DB
+	dbPath string
 }
 
 // Open 打开或创建数据库
@@ -31,11 +32,15 @@
 		return nil, fmt.Errorf("打开数据库失败: %w", err)
 	}
 
-	s := &Store{db: db}
+	s := &Store{db: db, dbPath: dbPath}
 	if err := s.initTables(); err != nil {
 		db.Close()
 		return nil, err
 	}
+	if err := s.migrate(); err != nil {
+		db.Close()
+		return nil, err
+	}
 	return s, nil
 }
 

--
Gitblit v1.10.0