From 4eb5c1a93559a27b126034a2471ae19c82998520 Mon Sep 17 00:00:00 2001
From: ax_rd <ax_rd@aisim.cn>
Date: Thu, 03 Sep 2026 09:57:28 +0800
Subject: [PATCH] feat: 增量对账 reconcile——(size,mtime,sha256) 对账替代 commit 判据,index build 默认增量

---
 internal/index/reconcile.go      |  262 ++++++++++++++++++++
 internal/vault/parser.go         |    5 
 internal/vault/scanner.go        |   37 ++
 cmd/search.go                    |   12 
 internal/index/reconcile_test.go |  175 +++++++++++++
 internal/graph/model.go          |   30 +
 cmd/rebuild.go                   |   23 +
 internal/index/cache.go          |   32 --
 cmd/index.go                     |   41 ++-
 internal/index/sqlite.go         |  105 +++++++
 10 files changed, 648 insertions(+), 74 deletions(-)

diff --git a/cmd/index.go b/cmd/index.go
index 63ecbe2..4678803 100644
--- a/cmd/index.go
+++ b/cmd/index.go
@@ -17,10 +17,13 @@
 	Short: "索引管理",
 }
 
+// forceRebuild index build 的 --force flag:全量重建索引
+var forceRebuild bool
+
 var indexBuildCmd = &cobra.Command{
 	Use:   "build",
 	Short: "构建或重建知识库索引",
-	Long:  `kb-cli index build [--vault <路径>] [--db <路径>] # 构建或重建知识库索引`,
+	Long:  `kb-cli index build [--vault <路径>] [--db <路径>] [--force] # 构建或重建知识库索引(默认增量对账,--force 全量重建)`,
 	RunE:  runIndexBuild,
 }
 
@@ -55,6 +58,7 @@
 	indexCmd.AddCommand(indexBuildCmd)
 	indexCmd.AddCommand(indexStatusCmd)
 	indexCmd.AddCommand(indexGcCmd)
+	indexBuildCmd.Flags().BoolVar(&forceRebuild, "force", false, "全量重建索引(默认走增量对账)")
 	
 	rootCmd.AddCommand(gitCmd)
 	gitCmd.AddCommand(gitSyncCmd)
@@ -68,19 +72,26 @@
 	}
 	defer store.Close()
 
-	// 获取当前 commit
-	commit, err := index.GetGitCommit(vaultPath)
-	if err != nil {
-		fmt.Fprintln(os.Stderr, "警告: 无法获取 git commit:", err)
-		commit = ""
+	if forceRebuild {
+		// 全量重建
+		commit, err := index.GetGitCommit(vaultPath)
+		if err != nil {
+			fmt.Fprintln(os.Stderr, "警告: 无法获取 git commit:", err)
+			commit = ""
+		}
+		fmt.Fprintln(os.Stderr, "正在全量重建索引...")
+		if err := rebuildFull(store, commit); err != nil {
+			return fmt.Errorf("重建索引失败: %w", err)
+		}
+		fmt.Fprintln(os.Stderr, "索引重建完成")
+		return nil
 	}
 
-	fmt.Fprintln(os.Stderr, "正在重建索引...")
-	if err := rebuildIndex(store, commit); err != nil {
-		return fmt.Errorf("重建索引失败: %w", err)
+	// 默认走增量对账
+	if err := syncIndex(store); err != nil {
+		return fmt.Errorf("增量同步失败: %w", err)
 	}
-
-	fmt.Fprintln(os.Stderr, "索引重建完成")
+	fmt.Fprintln(os.Stderr, "索引同步完成")
 	return nil
 }
 
@@ -127,14 +138,14 @@
 	fmt.Printf("  当前 commit: %s\n", currentCommit)
 	fmt.Printf("  构建时间: %s\n", builtAt)
 
-	// 检查是否需要更新
-	needsRebuild, _, err := index.NeedsRebuild(store, vaultPath)
+	// 检查是否有变更(stat 快速比对)
+	dirty, err := index.QuickCheck(store, vaultPath)
 	if err != nil {
 		return fmt.Errorf("检查索引状态失败: %w", err)
 	}
 
-	if needsRebuild {
-		fmt.Printf("  状态: 需要更新\n")
+	if dirty {
+		fmt.Printf("  状态: 有变更\n")
 	} else {
 		fmt.Printf("  状态: 最新\n")
 	}
diff --git a/cmd/rebuild.go b/cmd/rebuild.go
index dfff961..a93794f 100644
--- a/cmd/rebuild.go
+++ b/cmd/rebuild.go
@@ -10,8 +10,8 @@
 	"github.com/aisim/kb-cli/internal/vault"
 )
 
-// rebuildIndex 重建索引:扫描 vault → 构建图 → 写入 SQLite
-func rebuildIndex(store *index.Store, commit string) error {
+// rebuildFull 全量重建索引:扫描 vault → 构建图 → 写入 SQLite
+func rebuildFull(store *index.Store, commit string) error {
 	// 清空旧数据
 	if err := store.ClearData(); err != nil {
 		return fmt.Errorf("清空数据失败: %w", err)
@@ -84,3 +84,22 @@
 	fmt.Fprintf(os.Stderr, "已索引 %d 个节点, %d 条边\n", len(g.Nodes), actualEdgeCount)
 	return nil
 }
+
+// syncIndex 增量对账路径(index build 默认、search pre-flight 用)
+func syncIndex(store *index.Store) error {
+	res, err := index.Reconcile(store, vaultPath)
+	if err != nil {
+		return fmt.Errorf("增量同步失败: %w", err)
+	}
+	fmt.Fprintf(os.Stderr, "增量同步: 新增 %d / 修改 %d / 删除 %d / 未变 %d\n",
+		res.Added, res.Modified, res.Deleted, res.Unchanged)
+	if commit, err := index.GetGitCommit(vaultPath); err == nil {
+		if err := store.SetMeta("git_commit", commit); err != nil {
+			return fmt.Errorf("记录 git commit 失败: %w", err)
+		}
+	}
+	if err := store.SetMeta("built_at", time.Now().Format(time.RFC3339)); err != nil {
+		return fmt.Errorf("记录构建时间失败: %w", err)
+	}
+	return nil
+}
diff --git a/cmd/search.go b/cmd/search.go
index e7e31a1..7083750 100644
--- a/cmd/search.go
+++ b/cmd/search.go
@@ -50,16 +50,16 @@
 	}
 	defer store.Close()
 
-	// 检查是否需要重建索引
-	needsRebuild, commit, err := index.NeedsRebuild(store, vaultPath)
+	// pre-flight:快速检查索引是否有变更(只 stat 比对,不读内容)
+	dirty, err := index.QuickCheck(store, vaultPath)
 	if err != nil {
 		return fmt.Errorf("检查索引状态失败: %w", err)
 	}
 
-	if needsRebuild {
-		fmt.Fprintln(os.Stderr, "索引过期,正在重建...")
-		if err := rebuildIndex(store, commit); err != nil {
-			return fmt.Errorf("重建索引失败: %w", err)
+	if dirty {
+		fmt.Fprintln(os.Stderr, "索引有变更,正在增量同步...")
+		if err := syncIndex(store); err != nil {
+			return fmt.Errorf("增量同步失败: %w", err)
 		}
 	}
 
diff --git a/internal/graph/model.go b/internal/graph/model.go
index 0702912..485cc6e 100644
--- a/internal/graph/model.go
+++ b/internal/graph/model.go
@@ -2,22 +2,28 @@
 
 // Node 图节点(对应一个 markdown 文件)
 type Node struct {
-	ID        int64    `json:"id"`
-	Path      string   `json:"path"`
-	Title     string   `json:"title"`
-	Section   string   `json:"section"`
-	Tags      []string `json:"tags"`
-	Entities  []string `json:"entities"`
-	Wikilinks []string `json:"wikilinks"`
-	Content   string   `json:"content"`
+	ID          int64    `json:"id"`
+	Path        string   `json:"path"`
+	Title       string   `json:"title"`
+	Section     string   `json:"section"`
+	Tags        []string `json:"tags"`
+	Entities    []string `json:"entities"`
+	Aliases     []string `json:"aliases"`
+	Status      string   `json:"status"`
+	Wikilinks   []string `json:"wikilinks"`
+	Content     string   `json:"content"`
+	Size        int64    `json:"size"`
+	Mtime       int64    `json:"mtime"`
+	ContentHash string   `json:"content_hash"`
 }
 
 // Edge 图边(实体关系)
 type Edge struct {
-	FromNode int64  `json:"from_node"`
-	ToNode   int64  `json:"to_node"` // 对于 tag/entity 边,ToNode 可以是虚拟节点 ID
-	Relation string `json:"relation"` // "tag" | "entity" | "wikilink"
-	Label    string `json:"label"`    // 具体值
+	FromNode   int64  `json:"from_node"`
+	ToNode     int64  `json:"to_node"` // 对于 tag/entity 边,ToNode 可以是虚拟节点 ID
+	Relation   string `json:"relation"` // "tag" | "entity" | "wikilink"
+	Label      string `json:"label"`    // 具体值
+	Provenance string `json:"provenance"`
 }
 
 // Graph 知识图谱
diff --git a/internal/index/cache.go b/internal/index/cache.go
index b54860c..74b64c7 100644
--- a/internal/index/cache.go
+++ b/internal/index/cache.go
@@ -14,35 +14,3 @@
 	}
 	return strings.TrimSpace(string(out)), nil
 }
-
-// NeedsRebuild 检查是否需要重建索引
-func NeedsRebuild(store *Store, vaultPath string) (bool, string, error) {
-	currentCommit, err := GetGitCommit(vaultPath)
-	if err != nil {
-		// 不是 git 仓库,总是需要重建
-		return true, "", nil
-	}
-
-	storedCommit, err := store.GetMeta("git_commit")
-	if err != nil {
-		return true, currentCommit, nil
-	}
-
-	if storedCommit == "" {
-		// 没有记录,需要重建
-		return true, currentCommit, nil
-	}
-
-	if storedCommit != currentCommit {
-		// commit 变了,需要重建
-		return true, currentCommit, nil
-	}
-
-	// 检查是否有节点
-	count, _ := store.NodeCount()
-	if count == 0 {
-		return true, currentCommit, nil
-	}
-
-	return false, currentCommit, nil
-}
diff --git a/internal/index/reconcile.go b/internal/index/reconcile.go
new file mode 100644
index 0000000..8024eb3
--- /dev/null
+++ b/internal/index/reconcile.go
@@ -0,0 +1,262 @@
+package index
+
+import (
+	"crypto/sha256"
+	"encoding/hex"
+	"fmt"
+	"os"
+	"path/filepath"
+	"strings"
+
+	"github.com/aisim/kb-cli/internal/graph"
+	"github.com/aisim/kb-cli/internal/vault"
+)
+
+// ReconcileResult 对账结果统计
+type ReconcileResult struct {
+	Added     int
+	Modified  int
+	Deleted   int
+	Unchanged int
+}
+
+// Reconcile 增量对账:stat 比对 → 只对变更文件解析和写库
+func Reconcile(store *Store, vaultPath string) (*ReconcileResult, error) {
+	res := &ReconcileResult{}
+
+	// 1. vault 侧指纹
+	vaultStats, err := vault.ScanVaultStat(vaultPath)
+	if err != nil {
+		return nil, fmt.Errorf("扫描失败: %w", err)
+	}
+	dbStats, err := store.GetFileStats()
+	if err != nil {
+		return nil, fmt.Errorf("读取索引指纹失败: %w", err)
+	}
+
+	// 2. 分类
+	type change struct {
+		path  string
+		stat  vault.FileStat
+		known FileStat
+	}
+	var added, modified []change
+	for _, v := range vaultStats {
+		known, ok := dbStats[v.Path]
+		if !ok {
+			added = append(added, change{v.Path, v, FileStat{}})
+			continue
+		}
+		if v.Size == known.Size && v.Mtime == known.Mtime {
+			res.Unchanged++
+			continue
+		}
+		// size/mtime 变化 → sha256 二次确认
+		hash, err := fileHash(filepath.Join(vaultPath, v.Path))
+		if err != nil {
+			return nil, fmt.Errorf("哈希 %s 失败: %w", v.Path, err)
+		}
+		if known.ContentHash != "" && hash == known.ContentHash {
+			res.Unchanged++ // 内容没变(如 touch),只更新指纹
+			store.db.Exec(`UPDATE nodes SET size=?, mtime=? WHERE path=?`, v.Size, v.Mtime, v.Path)
+			continue
+		}
+		modified = append(modified, change{v.Path, v, known})
+	}
+	// 3. 删除
+	for path := range dbStats {
+		found := false
+		for _, v := range vaultStats {
+			if v.Path == path {
+				found = true
+				break
+			}
+		}
+		if !found {
+			if err := store.DeleteNode(path); err != nil {
+				return nil, err
+			}
+			res.Deleted++
+		}
+	}
+
+	// 4. 新增 + 修改:解析 → 写节点 → 重建该节点边
+	for _, c := range append(added, modified...) {
+		isNew := c.known.Path == ""
+		meta, err := vault.ParseFile(filepath.Join(vaultPath, c.path), c.path)
+		if err != nil {
+			return nil, fmt.Errorf("解析 %s 失败: %w", c.path, err)
+		}
+		hash, err := fileHash(filepath.Join(vaultPath, c.path))
+		if err != nil {
+			return nil, err
+		}
+		if isNew {
+			res.Added++
+		} else {
+			res.Modified++
+		}
+		if err := applyNode(store, vaultPath, meta, c.stat.Size, c.stat.Mtime, hash); err != nil {
+			return nil, err
+		}
+	}
+
+	// 5. 悬空链接重试(新增/修改节点可能让 failed 链接变可解析)
+	if res.Added+res.Modified > 0 {
+		store.RetryUnresolved() // 见 Task 4,此处先以空实现占位编译通过
+	}
+	return res, nil
+}
+
+func fileHash(path string) (string, error) {
+	data, err := os.ReadFile(path)
+	if err != nil {
+		return "", err
+	}
+	sum := sha256.Sum256(data)
+	return hex.EncodeToString(sum[:]), nil
+}
+
+// applyNode 写节点 + 重建该节点的出边(tag/entity/wikilink)+ 悬空入表
+func applyNode(store *Store, vaultPath string, meta *vault.FileMeta, size, mtime int64, hash string) error {
+	n := &graph.Node{
+		Path: meta.Path, Title: meta.Title, Section: meta.Section,
+		Tags: meta.Tags, Entities: meta.Entities, Wikilinks: meta.Wikilinks,
+		Aliases: meta.Aliases, Status: meta.Status, Content: meta.Content,
+	}
+	if err := store.UpsertNode(n, size, mtime, hash); err != nil {
+		return err
+	}
+	var nodeID int64
+	if err := store.db.QueryRow(`SELECT id FROM nodes WHERE path=?`, meta.Path).Scan(&nodeID); err != nil {
+		return err
+	}
+	// 删旧边后重建出边
+	if err := store.DeleteNodeEdges(nodeID); err != nil {
+		return err
+	}
+	return store.buildNodeEdges(nodeID, meta)
+}
+
+// buildNodeEdges 为单个节点建出边;wikilink 解析失败入 unresolved_links
+func (s *Store) buildNodeEdges(nodeID int64, meta *vault.FileMeta) error {
+	// tag / entity 边(虚拟节点 ID 沿用 1000000+ 规则,按 label 查现有行避免重复)
+	for _, tag := range meta.Tags {
+		if err := s.insertTagEntityEdge(nodeID, "tag:"+tag, "tag", tag); err != nil {
+			return err
+		}
+	}
+	for _, entity := range meta.Entities {
+		if err := s.insertTagEntityEdge(nodeID, "entity:"+entity, "entity", entity); err != nil {
+			return err
+		}
+	}
+	// wikilink 边 + 悬空
+	for _, link := range meta.Wikilinks {
+		targetID, prov, ok := s.resolveWikilink(link)
+		if !ok {
+			tail := nameTail(link)
+			s.db.Exec(`INSERT INTO unresolved_links (from_node, link_text, name_tail, status)
+				VALUES (?, ?, ?, 'pending') ON CONFLICT DO NOTHING`, nodeID, link, tail)
+			continue
+		}
+		if err := s.InsertEdge(&graph.Edge{
+			FromNode: nodeID, ToNode: targetID,
+			Relation: "wikilink", Label: link, Provenance: prov,
+		}); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+// insertTagEntityEdge tag/entity 边(虚拟节点按 label 复用 ID)
+func (s *Store) insertTagEntityEdge(fromNode int64, key, relation, label string) error {
+	var virtualID int64
+	err := s.db.QueryRow(`SELECT to_node FROM edges WHERE relation=? AND label=? LIMIT 1`, relation, label).Scan(&virtualID)
+	if err != nil {
+		// 新虚拟节点:分配 ID = 1000000 + 行号(稳定:按 label 排序后的行号)
+		var maxID int64
+		s.db.QueryRow(`SELECT COALESCE(MAX(to_node), 1000000) FROM edges WHERE to_node >= 1000000 AND relation=?`, relation).Scan(&maxID)
+		virtualID = maxID + 1
+	}
+	return s.InsertEdge(&graph.Edge{FromNode: fromNode, ToNode: virtualID, Relation: relation, Label: label})
+}
+
+// resolveWikilink 解析 wikilink 目标,返回 (nodeID, provenance, ok)
+// provenance: exact = 标题或文件名精确匹配;fuzzy = 标题包含匹配
+func (s *Store) resolveWikilink(link string) (int64, string, bool) {
+	// 去锚点:[[标题|别名]] 取标题部分
+	if idx := strings.Index(link, "|"); idx >= 0 {
+		link = link[:idx]
+	}
+	var id int64
+	var title, path string
+	// 1. 标题精确
+	err := s.db.QueryRow(`SELECT id, title, path FROM nodes WHERE title = ? LIMIT 1`, link).Scan(&id, &title, &path)
+	if err == nil {
+		return id, "exact", true
+	}
+	// 2. 文件名精确(去 .md 和编号前缀)
+	rows, err := s.db.Query(`SELECT id, title, path FROM nodes`)
+	if err != nil {
+		return 0, "", false
+	}
+	defer rows.Close()
+	var fuzzyID int64
+	for rows.Next() {
+		var nid int64
+		var nTitle, nPath string
+		if err := rows.Scan(&nid, &nTitle, &nPath); err != nil {
+			return 0, "", false
+		}
+		base := filepath.Base(nPath)
+		base = strings.TrimSuffix(base, ".md")
+		if dash := strings.Index(base, "-"); dash >= 0 {
+			base = base[dash+1:]
+		}
+		if base == link || nTitle == link {
+			return nid, "exact", true
+		}
+		if fuzzyID == 0 && strings.Contains(nTitle, link) {
+			fuzzyID = nid
+		}
+	}
+	if fuzzyID != 0 {
+		return fuzzyID, "fuzzy", true
+	}
+	return 0, "", false
+}
+
+// nameTail 取 link 尾部用于重试匹配(去锚点修饰)
+func nameTail(link string) string {
+	if idx := strings.Index(link, "|"); idx >= 0 {
+		link = link[:idx]
+	}
+	return link
+}
+
+// RetryUnresolved 重试解析悬空链接(Task 4 补全完整实现)
+func (s *Store) RetryUnresolved() {}
+
+// QuickCheck 只 stat 比对(不读内容不哈希),返回是否有差异
+func QuickCheck(store *Store, vaultPath string) (bool, error) {
+	vaultStats, err := vault.ScanVaultStat(vaultPath)
+	if err != nil {
+		return false, err
+	}
+	dbStats, err := store.GetFileStats()
+	if err != nil {
+		return false, err
+	}
+	if len(vaultStats) != len(dbStats) {
+		return true, nil
+	}
+	for _, v := range vaultStats {
+		known, ok := dbStats[v.Path]
+		if !ok || v.Size != known.Size || v.Mtime != known.Mtime {
+			return true, nil
+		}
+	}
+	return false, nil
+}
diff --git a/internal/index/reconcile_test.go b/internal/index/reconcile_test.go
new file mode 100644
index 0000000..677015f
--- /dev/null
+++ b/internal/index/reconcile_test.go
@@ -0,0 +1,175 @@
+package index
+
+import (
+	"os"
+	"path/filepath"
+	"testing"
+	"time"
+)
+
+func TestReconcileAdd(t *testing.T) {
+	dir := t.TempDir()
+	if err := os.MkdirAll(filepath.Join(dir, "FAQ"), 0755); err != nil {
+		t.Fatal(err)
+	}
+	content := "---\ntitle: 测试文档\ntags: [t1]\nstatus: 已解决\n---\n正文\n"
+	if err := os.WriteFile(filepath.Join(dir, "FAQ", "001-测试文档.md"), []byte(content), 0644); err != nil {
+		t.Fatal(err)
+	}
+
+	store, err := Open(filepath.Join(t.TempDir(), "kb.db"))
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer store.Close()
+
+	res, err := Reconcile(store, dir)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if res.Added != 1 || res.Unchanged != 0 {
+		t.Errorf("新增场景: %+v", res)
+	}
+	var title, status string
+	if err := store.db.QueryRow(`SELECT title, status FROM nodes WHERE path='FAQ/001-测试文档.md'`).Scan(&title, &status); err != nil {
+		t.Fatal(err)
+	}
+	if title != "测试文档" || status != "已解决" {
+		t.Errorf("节点字段错误: %s / %s", title, status)
+	}
+}
+
+func TestReconcileModify(t *testing.T) {
+	dir := t.TempDir()
+	if err := os.MkdirAll(filepath.Join(dir, "FAQ"), 0755); err != nil {
+		t.Fatal(err)
+	}
+	p := filepath.Join(dir, "FAQ", "001-测试文档.md")
+	if err := os.WriteFile(p, []byte("---\ntitle: 旧标题\n---\n旧内容\n"), 0644); err != nil {
+		t.Fatal(err)
+	}
+
+	store, err := Open(filepath.Join(t.TempDir(), "kb.db"))
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer store.Close()
+	if _, err := Reconcile(store, dir); err != nil {
+		t.Fatal(err)
+	}
+
+	// 修改内容(title 变新标题,内容加"补气")
+	time.Sleep(1100 * time.Millisecond) // mtime 秒级精度,确保 mtime 变化
+	if err := os.WriteFile(p, []byte("---\ntitle: 新标题\n---\n新内容补气\n"), 0644); err != nil {
+		t.Fatal(err)
+	}
+
+	res, err := Reconcile(store, dir)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if res.Modified != 1 || res.Added != 0 {
+		t.Errorf("修改场景: %+v", res)
+	}
+	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 TestReconcileDelete(t *testing.T) {
+	dir := t.TempDir()
+	if err := os.MkdirAll(filepath.Join(dir, "FAQ"), 0755); err != nil {
+		t.Fatal(err)
+	}
+	p := filepath.Join(dir, "FAQ", "001-测试文档.md")
+	if err := os.WriteFile(p, []byte("---\ntitle: 测试文档\n---\n内容\n"), 0644); err != nil {
+		t.Fatal(err)
+	}
+
+	store, err := Open(filepath.Join(t.TempDir(), "kb.db"))
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer store.Close()
+	if _, err := Reconcile(store, dir); err != nil {
+		t.Fatal(err)
+	}
+	if err := os.Remove(p); err != nil {
+		t.Fatal(err)
+	}
+
+	res, err := Reconcile(store, dir)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if res.Deleted != 1 {
+		t.Errorf("删除场景: %+v", res)
+	}
+	var n int
+	if err := store.db.QueryRow(`SELECT COUNT(*) FROM nodes`).Scan(&n); err != nil {
+		t.Fatal(err)
+	}
+	if n != 0 {
+		t.Errorf("节点未删净: %d", n)
+	}
+}
+
+func TestReconcileUnchanged(t *testing.T) {
+	dir := t.TempDir()
+	if err := os.MkdirAll(filepath.Join(dir, "FAQ"), 0755); err != nil {
+		t.Fatal(err)
+	}
+	if err := os.WriteFile(filepath.Join(dir, "FAQ", "001-测试文档.md"),
+		[]byte("---\ntitle: 测试文档\n---\n内容\n"), 0644); err != nil {
+		t.Fatal(err)
+	}
+
+	store, err := Open(filepath.Join(t.TempDir(), "kb.db"))
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer store.Close()
+	if _, err := Reconcile(store, dir); err != nil {
+		t.Fatal(err)
+	}
+
+	res, err := Reconcile(store, dir)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if res.Unchanged != 1 || res.Added != 0 || res.Modified != 0 {
+		t.Errorf("未变场景: %+v", res)
+	}
+}
+
+func TestQuickCheck(t *testing.T) {
+	dir := t.TempDir()
+	if err := os.MkdirAll(filepath.Join(dir, "FAQ"), 0755); err != nil {
+		t.Fatal(err)
+	}
+	if err := os.WriteFile(filepath.Join(dir, "FAQ", "001-测试文档.md"),
+		[]byte("---\ntitle: 测试文档\n---\n内容\n"), 0644); err != nil {
+		t.Fatal(err)
+	}
+
+	store, err := Open(filepath.Join(t.TempDir(), "kb.db"))
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer store.Close()
+	if _, err := Reconcile(store, dir); err != nil {
+		t.Fatal(err)
+	}
+
+	dirty, err := QuickCheck(store, dir)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if dirty {
+		t.Error("无差异时 QuickCheck 应 false")
+	}
+}
diff --git a/internal/index/sqlite.go b/internal/index/sqlite.go
index a70b7d1..06a02b3 100644
--- a/internal/index/sqlite.go
+++ b/internal/index/sqlite.go
@@ -97,17 +97,20 @@
 	return err
 }
 
-// InsertNode 插入节点
+// InsertNode 插入节点(全量重建路径用)
 func (s *Store) InsertNode(n *graph.Node) (int64, error) {
 	tagsJSON, _ := json.Marshal(n.Tags)
 	entitiesJSON, _ := json.Marshal(n.Entities)
 	wikilinksJSON, _ := json.Marshal(n.Wikilinks)
+	aliasesJSON, _ := json.Marshal(n.Aliases)
 
 	result, err := s.db.Exec(`
-		INSERT INTO nodes (path, title, section, tags, entities, wikilinks, content_fts)
-		VALUES (?, ?, ?, ?, ?, ?, ?)
+		INSERT INTO nodes (path, title, section, tags, entities, wikilinks, aliases, status,
+		               content_fts, size, mtime, content_hash)
+		VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
 	`, n.Path, n.Title, n.Section, string(tagsJSON), string(entitiesJSON),
-		string(wikilinksJSON), n.Content)
+		string(wikilinksJSON), string(aliasesJSON), n.Status, n.Content,
+		n.Size, n.Mtime, n.ContentHash)
 	if err != nil {
 		return 0, err
 	}
@@ -117,9 +120,97 @@
 // InsertEdge 插入边
 func (s *Store) InsertEdge(e *graph.Edge) error {
 	_, err := s.db.Exec(`
-		INSERT OR IGNORE INTO edges (from_node, to_node, relation, label)
-		VALUES (?, ?, ?, ?)
-	`, e.FromNode, e.ToNode, e.Relation, e.Label)
+		INSERT OR IGNORE INTO edges (from_node, to_node, relation, label, provenance)
+		VALUES (?, ?, ?, ?, ?)
+	`, e.FromNode, e.ToNode, e.Relation, e.Label, e.Provenance)
+	return err
+}
+
+// FileStat 索引中的文件指纹
+type FileStat struct {
+	Path        string
+	Size        int64
+	Mtime       int64
+	ContentHash string
+}
+
+// GetFileStats 返回所有已索引文件的指纹
+func (s *Store) GetFileStats() (map[string]FileStat, error) {
+	rows, err := s.db.Query(`SELECT path, size, mtime, content_hash FROM nodes`)
+	if err != nil {
+		return nil, err
+	}
+	defer rows.Close()
+	m := make(map[string]FileStat)
+	for rows.Next() {
+		var st FileStat
+		var hash sql.NullString
+		if err := rows.Scan(&st.Path, &st.Size, &st.Mtime, &hash); err != nil {
+			return nil, err
+		}
+		st.ContentHash = hash.String
+		m[st.Path] = st
+	}
+	return m, rows.Err()
+}
+
+// UpsertNode 按 path 插入或更新节点(触发器自动维护 FTS)
+func (s *Store) UpsertNode(n *graph.Node, size, mtime int64, contentHash string) error {
+	tagsJSON, _ := json.Marshal(n.Tags)
+	entitiesJSON, _ := json.Marshal(n.Entities)
+	wikilinksJSON, _ := json.Marshal(n.Wikilinks)
+	aliasesJSON, _ := json.Marshal(n.Aliases)
+	_, err := s.db.Exec(`
+		INSERT INTO nodes (path, title, section, tags, entities, wikilinks, aliases, status,
+		               content_fts, size, mtime, content_hash)
+		VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+		ON CONFLICT(path) DO UPDATE SET
+			title = excluded.title,
+			section = excluded.section,
+			tags = excluded.tags,
+			entities = excluded.entities,
+			wikilinks = excluded.wikilinks,
+			aliases = excluded.aliases,
+			status = excluded.status,
+			content_fts = excluded.content_fts,
+			size = excluded.size,
+			mtime = excluded.mtime,
+			content_hash = excluded.content_hash,
+			updated_at = datetime('now')`,
+		n.Path, n.Title, n.Section, string(tagsJSON), string(entitiesJSON),
+		string(wikilinksJSON), string(aliasesJSON), n.Status, n.Content, size, mtime, contentHash)
+	return err
+}
+
+// DeleteNode 按 path 删节点(级联删边),同时清该节点相关的 unresolved_links
+func (s *Store) DeleteNode(path string) error {
+	tx, err := s.db.Begin()
+	if err != nil {
+		return err
+	}
+	var id int64
+	if err := tx.QueryRow(`SELECT id FROM nodes WHERE path = ?`, path).Scan(&id); err != nil {
+		tx.Rollback()
+		return nil // 不存在,视为成功
+	}
+	if _, err := tx.Exec(`DELETE FROM edges WHERE from_node = ? OR to_node = ?`, id, id); err != nil {
+		tx.Rollback()
+		return err
+	}
+	if _, err := tx.Exec(`DELETE FROM unresolved_links WHERE from_node = ?`, id); err != nil {
+		tx.Rollback()
+		return err
+	}
+	if _, err := tx.Exec(`DELETE FROM nodes WHERE id = ?`, id); err != nil {
+		tx.Rollback()
+		return err
+	}
+	return tx.Commit()
+}
+
+// DeleteNodeEdges 只删某节点的边(保留节点行,用于"修改"场景重建边)
+func (s *Store) DeleteNodeEdges(nodeID int64) error {
+	_, err := s.db.Exec(`DELETE FROM edges WHERE from_node = ? OR to_node = ?`, nodeID, nodeID)
 	return err
 }
 
diff --git a/internal/vault/parser.go b/internal/vault/parser.go
index 6764af4..f4cbb89 100644
--- a/internal/vault/parser.go
+++ b/internal/vault/parser.go
@@ -16,6 +16,8 @@
 	Section   string   // 所属板块
 	Tags      []string // frontmatter tags
 	Entities  []string // frontmatter entities
+	Aliases   []string // frontmatter aliases
+	Status    string   // frontmatter status
 	Wikilinks []string // 正文中的 [[xxx]] 链接
 	Content   string   // 纯文本内容(去 frontmatter)
 }
@@ -26,6 +28,7 @@
 	Tags     []string `yaml:"tags"`
 	Entities []string `yaml:"entities"`
 	Aliases  []string `yaml:"aliases"`
+	Status   string   `yaml:"status"`
 }
 
 var wikilinkRe = regexp.MustCompile(`\[\[([^\]]+)\]\]`)
@@ -75,6 +78,8 @@
 			meta.Title = fm.Title
 			meta.Tags = fm.Tags
 			meta.Entities = fm.Entities
+			meta.Aliases = fm.Aliases
+			meta.Status = fm.Status
 		}
 	}
 
diff --git a/internal/vault/scanner.go b/internal/vault/scanner.go
index 41b37b8..7981aaf 100644
--- a/internal/vault/scanner.go
+++ b/internal/vault/scanner.go
@@ -40,3 +40,40 @@
 
 	return files, err
 }
+
+// FileStat 文件指纹(只 stat,不读内容)
+type FileStat struct {
+	Path  string // 相对路径
+	Size  int64
+	Mtime int64 // Unix 秒
+}
+
+// ScanVaultStat 扫描 .md 文件指纹清单(跳过隐藏目录和待审阅目录,与 ScanVault 一致)
+func ScanVaultStat(vaultPath string) ([]FileStat, error) {
+	var stats []FileStat
+	err := filepath.Walk(vaultPath, func(path string, info os.FileInfo, err error) error {
+		if err != nil {
+			return nil
+		}
+		if info.IsDir() {
+			if strings.HasPrefix(info.Name(), ".") && info.Name() != "." {
+				return filepath.SkipDir
+			}
+			if info.Name() == "待审阅" {
+				return filepath.SkipDir
+			}
+			return nil
+		}
+		if !strings.HasSuffix(path, ".md") {
+			return nil
+		}
+		relPath, _ := filepath.Rel(vaultPath, path)
+		stats = append(stats, FileStat{
+			Path:  relPath,
+			Size:  info.Size(),
+			Mtime: info.ModTime().Unix(),
+		})
+		return nil
+	})
+	return stats, err
+}

--
Gitblit v1.10.0