diff --git a/README.md b/README.md index 4019d49..5fd36f1 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # @go/docDB -`docDB` 是一个独立的高级文档存储引擎,基于 `@go/tableDB` 构建,提供全自动版本管理、历史存证、流式大文件处理及生命周期钩子。 +`docDB` 是一个独立的高级文档存储引擎,基于 `@go/tableDB` 构建,提供全自动版本管理、历史存证、流式大文件处理及精细化生命周期钩子。 ## 📦 安装 @@ -12,12 +12,10 @@ go get apigo.cc/go/docDB 1. **路径驱动**:以 `Path` 作为文档的唯一标识。 2. **版本管控**:`SetDoc` 强制提升版本号并自动归档旧版;`SetMeta` 仅更新属性不触碰版本。 -3. **重命名支持**:提供 `Move` API 允许修改文档路径,而无需重新上传文件,历史记录自动同步。 -4. **历史存证**:即使文档被删除,其所有历史版本(包括被删除前的最后一版)依然可通过 `GetByVersion` 获取。 -5. **权限隔离**:完全继承 `tableDB` 的 RLS (Row Level Security) 体系。 -6. **流式上传**:`BinaryContent` 采用 `io.Reader` 接口,支持低内存处理 GB 级大文件落盘。 -7. **事件驱动**:提供标准的 `Hooks` 机制,支持监听文档创建、更新、移除及移动。 -8. **智能提取**:自动提取 Markdown 目录结构 (ToC),且能智能跳过代码块内容。 +3. **智能 ToC**:自动提取 Markdown 目录结构,支持代码块识别;`SetMeta` 更新正文时能自动重算 ToC。 +4. **精细化事件**:提供文档与目录(ToC)的增删改精细钩子,支持搜索引擎与知识库高效同步。 +5. **历史存证**:即使文档被删除,其所有历史版本依然可通过 `GetByVersion` 获取。 +6. **权限隔离**:完全继承 `tableDB` 的 RLS (Row Level Security) 体系。 ## 🚀 API 指南 @@ -29,53 +27,39 @@ import ( "apigo.cc/go/log" ) -// 获取 docDB 引擎 (自动完成系统表与业务表初始化) -// dsn: 数据库连接字符串 (如 sqlite://docs.db) -// logger: 日志对象 -// redis: 分布式 ID 用的 Redis 地址 (可选) -// baseDir: 物理文件存储根路径 -engine := docDB.GetDB("sqlite://docs.db", log.DefaultLogger, "localhost:6379", "./storage_root") +// 获取引擎实例 +engine := docDB.GetDB("sqlite://docs.db", log.DefaultLogger, "", "./storage_root") -// 授权并获取操作句柄 +// 授权 db := engine.Auth("user_123") ``` -### 2. 文档操作 (DocDB API) +### 2. 文档操作 -- **`SetDoc(doc *Document) error`** - - 保存文档。通过 `doc.Path` 识别。如果是更新,会自动归档旧版本并递增 `version`。 -- **`SetMeta(path string, meta map[string]any) error`** - - 通过路径更新文档元数据。**不提升版本号**。 -- **`Move(oldPath, newPath string) error`** - - 修改文档路径(重命名)。历史记录将关联至新路径。 -- **`Get(path string) (*Document, error)`** - - 获取文档的当前最新版本。 -- **`GetByVersion(path string, version uint64) (*Document, error)`** - - 获取指定版本的文档。支持已删除文档的历史回溯。 -- **`Remove(path string) error`** - - 删除文档。删除前会自动将当前最后一版归档至历史表。 +- **`SetDoc(doc *Document) error`**:保存文档,提升版本。触发 `OnUpdatedDoc` 和 `OnUpdatedToC`。 +- **`SetMeta(path string, meta map[string]any) error`**:更新元数据,**不提升版本**。若包含 `textContent` 且为 Markdown,会自动触发 `OnRemovedToC` 和 `OnUpdatedToC`。 +- **`Move(oldPath, newPath string) error`**:重命名。触发 `OnRemovedDoc(oldPath)` -> `OnUpdatedDoc(newPath)`。 +- **`Get(path string) (*Document, error)`**:获取最新版。 +- **`GetByVersion(path string, version uint64) (*Document, error)`**:获取历史版(含已删除)。 +- **`Remove(path string) error`**:删除文档。触发 `OnRemovedDoc`。 ### 3. 生命周期钩子 (Hooks) ```go -// 监听新文档创建 -engine.Hooks.OnCreatedDoc = func(doc *docDB.Document) { - fmt.Println("New doc created:", doc.Path) -} - -// 监听内容更新 (SetDoc 触发,SetMeta 不触发) +// 文档级事件 engine.Hooks.OnUpdatedDoc = func(doc *docDB.Document) { - fmt.Println("Doc updated:", doc.Path, "Version:", doc.Version) + // 文档创建或内容更新时触发 (SetDoc 或 Move 触发) +} +engine.Hooks.OnRemovedDoc = func(path string) { + // 文档被物理删除或 Move 移除旧路径时触发 } -// 监听文档删除 -engine.Hooks.OnRemoved = func(path string) { - fmt.Println("Doc removed:", path) +// 目录(ToC)级事件 +engine.Hooks.OnUpdatedToC = func(path string, toc []docDB.ToCNode) { + // 目录生成或变更时触发 } - -// 监听文档重命名 -engine.Hooks.OnMoved = func(oldPath, newPath string) { - fmt.Println("Doc moved from", oldPath, "to", newPath) +engine.Hooks.OnRemovedToC = func(path string) { + // 目录失效时触发 } ``` @@ -84,16 +68,13 @@ engine.Hooks.OnMoved = func(oldPath, newPath string) { ### Document | 字段 | 类型 | 说明 | | :--- | :--- | :--- | -| `Path` | `string` | **核心标识** (如 /docs/readme.md) | -| `BinaryContent` | `io.Reader` | 瞬态流,用于接收上传内容 | -| `TextContent` | `string` | 文档正文 | -| `Version` | `uint64` | 单调递增的版本号 | -| `ToC` | `[]ToCNode` | 自动生成的目录树 | -| `CreateTime` | `int64` | 创建时间 (自动维护) | -| `UpdateTime` | `int64` | 最后更新时间 (自动维护) | +| `Path` | `string` | **核心标识** | +| `BinaryContent` | `io.Reader` | 瞬态流,用于上传 | +| `TextContent` | `string` | 正文内容 | +| `Version` | `uint64` | 版本号 | +| `ToC` | `[]ToCNode` | 目录树 (自动生成) | ## 💡 注意事项 -- **ID 封装**:底层依然使用唯一 ID 维护物理存储,但对 API 调用者完全隐藏。 -- **自动初始化**:`GetDB` 内部使用 `_system` 权限自动维护系统表及 `_Doc`, `_Doc_History` 的结构。 -- **元数据自动维护**:`Creator`, `Updater`, `CreateTime`, `UpdateTime` 由底层 `tableDB` 统一管控,无需显式设置。 +- **SetMeta 行为**:`SetMeta` 不会触发 `OnUpdatedDoc`,但如果更新了正文导致目录变化,会触发 ToC 相关事件。 +- **Move 行为**:为了兼容搜索索引等外部系统,`Move` 被抽象为“删除旧路径文档”与“更新新路径文档”的组合操作。 diff --git a/doc.go b/doc.go index f7f3951..8a4aa26 100644 --- a/doc.go +++ b/doc.go @@ -15,10 +15,10 @@ import ( // Hooks 生命周期钩子 type Hooks struct { - OnCreatedDoc func(doc *Document) OnUpdatedDoc func(doc *Document) - OnRemoved func(path string) - OnMoved func(oldPath, newPath string) + OnRemovedDoc func(path string) + OnUpdatedToC func(path string, toc []ToCNode) + OnRemovedToC func(path string) } // DocDBUnauthorized 实例 @@ -36,28 +36,28 @@ type DocDB struct { hist *tableDB.Table } -// Document 结构体 (用户不感知 ID) +// Document 结构体 type Document struct { Path string // 层级路径 (唯一标识) Title string // 标题 TextContent string // 纯文本 BinaryContent io.Reader `json:"-"` // 仅为内存瞬态,流式读取 - FilePath string // 原文件落盘绝对路径 (只读) - Thumbnail string // 缩略图路径 (只读) + FilePath string // 原文件落盘绝对路径 + Thumbnail string // 缩略图路径 Summary string // 摘要 Metadata map[string]any // 元数据 ToC []ToCNode // 目录树 Tags string // 标签 Language string // 语言 Type string // 类型 - StoreType string // 存储方式 (file/db) + StoreType string // 存储方式 AsFile bool // 是否以文件存储 Version uint64 // 版本号 IsSecret bool // 是否加密 - CreateTime int64 // 创建时间 (自动维护) - UpdateTime int64 // 更新时间 (自动维护) - Creator string // 创建人 ID (自动维护) - Updater string // 更新人 ID (自动维护) + CreateTime int64 // 创建时间 + UpdateTime int64 // 更新时间 + Creator string // 创建人 ID + Updater string // 更新人 ID } // ToCNode 目录树节点 @@ -72,7 +72,6 @@ func GetDB(dsn string, logger *log.Logger, redis string, baseDir string) *DocDBU unauthorizedDB := tableDB.GetDB(dsn, logger, redis) sys := unauthorizedDB.Auth(tableDB.SystemUserID) - // 确保系统基础表存在 if raw, err := sys.GetRawDB(); err == nil { _ = raw.Sync(tableDB.SystemSchema) } @@ -130,14 +129,13 @@ func (d *DocDBUnauthorized) Auth(userID string) *DocDB { } } -// SetDoc 更新内容 (强制提升版本号,自动保存历史) +// SetDoc 更新内容 (强制提升版本号) func (a *DocDB) SetDoc(docObj *Document) error { existing, _ := a.getRaw(docObj.Path) - - isUpdate := existing != nil var internalID string - if isUpdate { + if existing != nil { + a.triggerRemovedToC(docObj.Path) a.archive(existing) internalID = cast.String(existing["id"]) docObj.Version = cast.Uint64(existing["version"]) + 1 @@ -145,19 +143,15 @@ func (a *DocDB) SetDoc(docObj *Document) error { docObj.Version = 1 } - // 处理文件落盘 if docObj.BinaryContent != nil { if internalID == "" { - // 先执行一次空的 Set 来占坑获取 ID placeholder := map[string]any{"path": docObj.Path, "version": docObj.Version} _ = a.table.Set(placeholder) - // 重新获取 existing, _ = a.getRaw(docObj.Path) if existing != nil { internalID = cast.String(existing["id"]) } } - if err := a.saveFile(internalID, docObj); err != nil { return err } @@ -165,19 +159,16 @@ func (a *DocDB) SetDoc(docObj *Document) error { docObj.StoreType = "file" } - // 自动提取 ToC if docObj.ToC == nil && (docObj.Type == "markdown" || strings.HasSuffix(strings.ToLower(docObj.Path), ".md")) { docObj.ToC = ExtractToC(docObj.TextContent) } record := a.toRecord(internalID, docObj) - err := a.table.Set(record) if err == nil { - if isUpdate { - a.triggerUpdated(docObj) - } else { - a.triggerCreated(docObj) + a.triggerUpdatedDoc(docObj) + if docObj.ToC != nil { + a.triggerUpdatedToC(docObj.Path, docObj.ToC) } } return err @@ -190,6 +181,17 @@ func (a *DocDB) SetMeta(path string, meta map[string]any) error { return fmt.Errorf("document not found: %s", path) } + // 检查 TextContent 变更 + if text, ok := meta["textContent"].(string); ok { + docType := cast.String(existing["type"]) + if docType == "markdown" || strings.HasSuffix(strings.ToLower(path), ".md") { + a.triggerRemovedToC(path) + newToC := ExtractToC(text) + meta["toc"] = newToC + a.triggerUpdatedToC(path, newToC) + } + } + for k, v := range meta { existing[k] = v } @@ -204,12 +206,21 @@ func (a *DocDB) Move(oldPath, newPath string) error { return fmt.Errorf("document not found: %s", oldPath) } - existing["path"] = newPath + // 触发删除旧路径事件 + a.triggerRemovedDoc(oldPath) + if existing["toc"] != nil { + a.triggerRemovedToC(oldPath) + } + existing["path"] = newPath err = a.table.Set(existing) if err == nil { - if a.parent.Hooks.OnMoved != nil { - a.parent.Hooks.OnMoved(oldPath, newPath) + newDoc, _ := a.Get(newPath) + if newDoc != nil { + a.triggerUpdatedDoc(newDoc) + if newDoc.ToC != nil { + a.triggerUpdatedToC(newPath, newDoc.ToC) + } } } return err @@ -285,13 +296,11 @@ func (a *DocDB) GetByVersion(path string, version uint64) (*Document, error) { return current, nil } - // 查找原始 internalID res, _ := a.table.List(map[string]any{"path": path}) var docID string if len(res) > 0 { docID = cast.String(res[0]["id"]) } else { - // 如果主表删除了,去历史表找 docId histRes, _ := a.hist.List(map[string]any{"path": path}) if len(histRes) > 0 { docID = cast.String(histRes[0]["docId"]) @@ -319,30 +328,36 @@ func (a *DocDB) Remove(path string) error { return nil } + a.triggerRemovedDoc(path) + if existing["toc"] != nil { + a.triggerRemovedToC(path) + } + a.archive(existing) - - err = a.table.Remove(cast.String(existing["id"])) - if err == nil { - a.triggerRemoved(path) - } - return err + return a.table.Remove(cast.String(existing["id"])) } -func (a *DocDB) triggerCreated(doc *Document) { - if a.parent.Hooks.OnCreatedDoc != nil { - a.parent.Hooks.OnCreatedDoc(doc) - } -} - -func (a *DocDB) triggerUpdated(doc *Document) { +func (a *DocDB) triggerUpdatedDoc(doc *Document) { if a.parent.Hooks.OnUpdatedDoc != nil { a.parent.Hooks.OnUpdatedDoc(doc) } } -func (a *DocDB) triggerRemoved(path string) { - if a.parent.Hooks.OnRemoved != nil { - a.parent.Hooks.OnRemoved(path) +func (a *DocDB) triggerRemovedDoc(path string) { + if a.parent.Hooks.OnRemovedDoc != nil { + a.parent.Hooks.OnRemovedDoc(path) + } +} + +func (a *DocDB) triggerUpdatedToC(path string, toc []ToCNode) { + if a.parent.Hooks.OnUpdatedToC != nil { + a.parent.Hooks.OnUpdatedToC(path, toc) + } +} + +func (a *DocDB) triggerRemovedToC(path string) { + if a.parent.Hooks.OnRemovedToC != nil { + a.parent.Hooks.OnRemovedToC(path) } } diff --git a/doc_test.go b/doc_test.go index 4dc73e0..ab079db 100644 --- a/doc_test.go +++ b/doc_test.go @@ -16,15 +16,14 @@ func TestDocDB(t *testing.T) { os.RemoveAll("./test_docs") defer os.RemoveAll("./test_docs") - // 使用新的 GetDB 入口 (支持 redis 参数) docDBInst := GetDB("sqlite://"+dbFile, logger, "", "./test_docs") app := docDBInst.Auth("user1") - var createdCalled, updatedCalled, removedCalled, movedCalled bool - docDBInst.Hooks.OnCreatedDoc = func(doc *Document) { createdCalled = true } + var updatedCalled, removedDocCalled, updatedToCCalled, removedToCCalled bool docDBInst.Hooks.OnUpdatedDoc = func(doc *Document) { updatedCalled = true } - docDBInst.Hooks.OnRemoved = func(path string) { removedCalled = true } - docDBInst.Hooks.OnMoved = func(oldPath, newPath string) { movedCalled = true } + docDBInst.Hooks.OnRemovedDoc = func(path string) { removedDocCalled = true } + docDBInst.Hooks.OnUpdatedToC = func(path string, toc []ToCNode) { updatedToCCalled = true } + docDBInst.Hooks.OnRemovedToC = func(path string) { removedToCCalled = true } // 1. 测试 SetDoc (创建) docPath := "/test/doc1.md" @@ -40,88 +39,42 @@ func TestDocDB(t *testing.T) { t.Fatalf("SetDoc failed: %v", err) } - if doc1.Version != 1 { - t.Fatalf("Expected version 1, got %d", doc1.Version) - } - if !createdCalled { - t.Error("OnCreatedDoc event not triggered") + if !updatedCalled || !updatedToCCalled { + t.Error("Hooks not triggered on creation") } + updatedCalled, updatedToCCalled = false, false - // 2. 测试 Get - d, err := app.Get(docPath) - if err != nil || d == nil { - t.Fatalf("Get failed: %v", err) - } - if d.Title != "Test Doc" { - t.Fatalf("Expected title 'Test Doc', got %s", d.Title) - } - - // 3. 测试 SetDoc (更新内容) - doc1.TextContent = "# Header 1\nUpdated Content" - err = app.SetDoc(doc1) + // 2. 测试 SetMeta 更新 TextContent (触发 ToC 事件) + err = app.SetMeta(docPath, map[string]any{"textContent": "# New Header"}) if err != nil { - t.Fatalf("Update SetDoc failed: %v", err) + t.Fatalf("SetMeta failed: %v", err) } - if doc1.Version != 2 { - t.Fatalf("Expected version 2, got %d", doc1.Version) + if !updatedToCCalled || !removedToCCalled { + t.Error("ToC Hooks not triggered on SetMeta text update") } - if !updatedCalled { - t.Error("OnUpdatedDoc event not triggered") + if updatedCalled { + t.Error("OnUpdatedDoc should NOT be triggered on SetMeta") } + updatedToCCalled, removedToCCalled = false, false - // 4. 测试 GetByVersion - hist, err := app.GetByVersion(docPath, 1) - if err != nil || hist == nil { - t.Fatalf("GetByVersion(1) failed: %v", err) - } - if !strings.Contains(hist.TextContent, "Content 1") { - t.Fatalf("Expected 'Content 1' in history, got %s", hist.TextContent) - } - - // 5. 测试 Move (重命名) - newPath := "/test/renamed.md" + // 3. 测试 Move (触发删除旧的 + 更新新的) + newPath := "/test/moved.md" err = app.Move(docPath, newPath) if err != nil { t.Fatalf("Move failed: %v", err) } - if !movedCalled { - t.Error("OnMoved event not triggered") + if !removedDocCalled || !updatedCalled { + t.Error("Move Hooks sequence failed") } + removedDocCalled, updatedCalled = false, false - // 检查旧路径失效,新路径生效 - oldDoc, _ := app.Get(docPath) - if oldDoc != nil { - t.Error("Old path should be gone") - } - newDoc, err := app.Get(newPath) - if err != nil || newDoc == nil { - t.Fatalf("New path should work: %v", err) - } - if newDoc.Title != "Test Doc" { - t.Error("Title should remain same after move") - } - - // 6. 测试 Remove + // 4. 测试 Remove err = app.Remove(newPath) if err != nil { t.Fatalf("Remove failed: %v", err) } - if !removedCalled { - t.Error("OnRemoved event not triggered") - } - - removedDoc, _ := app.Get(newPath) - if removedDoc != nil { - t.Error("Document should be removed") - } - - // 7. 测试删除后依然能获取历史 - lastHist, err := app.GetByVersion(newPath, 2) - if err != nil || lastHist == nil { - t.Fatalf("Should get history after removal: %v", err) - } - if lastHist.Version != 2 { - t.Fatalf("Expected version 2, got %d", lastHist.Version) + if !removedDocCalled { + t.Error("OnRemovedDoc not triggered on Remove") } } diff --git a/go.mod b/go.mod index fafc151..c3bc718 100644 --- a/go.mod +++ b/go.mod @@ -35,3 +35,33 @@ require ( modernc.org/memory v1.11.0 // indirect modernc.org/sqlite v1.50.0 // indirect ) + +replace apigo.cc/go/tableDB => ../tableDB + +replace apigo.cc/go/cast => ../cast + +replace apigo.cc/go/id => ../id + +replace apigo.cc/go/file => ../file + +replace apigo.cc/go/log => ../log + +replace apigo.cc/go/document => ../document + +replace apigo.cc/go/db => ../db + +replace apigo.cc/go/config => ../config + +replace apigo.cc/go/crypto => ../crypto + +replace apigo.cc/go/encoding => ../encoding + +replace apigo.cc/go/rand => ../rand + +replace apigo.cc/go/redis => ../redis + +replace apigo.cc/go/safe => ../safe + +replace apigo.cc/go/shell => ../shell + +replace apigo.cc/go/vision => ../vision