From 081a362b97c75a8c7ff845f31366fae0ac7b3302 Mon Sep 17 00:00:00 2001 From: AI Engineer Date: Fri, 15 May 2026 14:51:36 +0800 Subject: [PATCH] feat: refactor to path-based API, add Move support, and encapsulate internal ID (by AI) --- README.md | 54 ++++++++++----------- doc.go | 137 +++++++++++++++++++++++++++++++--------------------- doc_test.go | 65 ++++++++++++++++--------- 3 files changed, 149 insertions(+), 107 deletions(-) diff --git a/README.md b/README.md index 73df480..d598d34 100644 --- a/README.md +++ b/README.md @@ -10,12 +10,14 @@ go get apigo.cc/go/docDB ## 🛠 核心功能 -1. **版本管控**:`SetDoc` 强制提升版本号并自动归档旧版;`SetMeta` 仅更新属性不触碰版本。 -2. **历史存证**:即使文档被删除,其所有历史版本(包括被删除前的最后一版)依然可通过 `GetByVersion` 获取。 -3. **权限隔离**:完全继承 `tableDB` 的 RLS (Row Level Security) 体系。 -4. **流式上传**:`BinaryContent` 采用 `io.Reader` 接口,支持低内存处理 GB 级大文件落盘。 -5. **事件驱动**:提供标准的 `Hooks` 机制,支持监听文档创建、更新与移除。 -6. **智能提取**:自动提取 Markdown 目录结构 (ToC),且能智能跳过代码块内容。 +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),且能智能跳过代码块内容。 ## 🚀 API 指南 @@ -40,30 +42,25 @@ db := engine.Auth("user_123") ### 2. 文档操作 (DocDB API) - **`SetDoc(doc *Document) error`** - - 保存文档。如果是更新,会自动将旧版本移入 `_Doc_History`,并递增 `version`。 -- **`SetMeta(id string, meta map[string]any) error`** - - 更新文档元数据(如 Title, Tags)。**不提升版本号**。 -- **`Get(id string) (*Document, error)`** + - 保存文档。通过 `doc.Path` 识别。如果是更新,会自动归档旧版本并递增 `version`。 +- **`SetMeta(path string, meta map[string]any) error`** + - 通过路径更新文档元数据。**不提升版本号**。 +- **`Move(oldPath, newPath string) error`** + - 修改文档路径(重命名)。历史记录将关联至新路径。 +- **`Get(path string) (*Document, error)`** - 获取文档的当前最新版本。 -- **`GetByVersion(id string, version uint64) (*Document, error)`** - - 获取指定版本的文档。即使当前文档已被 `Remove`,历史版本依然可查。 -- **`Remove(id string) error`** +- **`GetByVersion(path string, version uint64) (*Document, error)`** + - 获取指定版本的文档。支持已删除文档的历史回溯。 +- **`Remove(path string) error`** - 删除文档。删除前会自动将当前最后一版归档至历史表。 ### 3. 生命周期钩子 (Hooks) ```go -engine.Hooks.OnCreatedDoc = func(doc *docDB.Document) { - // 处理新文档创建,如提交搜索索引 -} - -engine.Hooks.OnUpdatedDoc = func(doc *docDB.Document) { - // 处理内容更新 -} - -engine.Hooks.OnRemoved = func(id string) { - // 处理物理删除后的清理工作 -} +engine.Hooks.OnCreatedDoc = func(doc *docDB.Document) { /* ... */ } +engine.Hooks.OnUpdatedDoc = func(doc *docDB.Document) { /* ... */ } +engine.Hooks.OnRemoved = func(path string) { /* ... */ } +engine.Hooks.OnMoved = func(oldPath, newPath string) { /* ... */ } ``` ## 📝 数据结构 @@ -71,14 +68,13 @@ engine.Hooks.OnRemoved = func(id string) { ### Document | 字段 | 类型 | 说明 | | :--- | :--- | :--- | -| `ID` | `string` | 唯一标识 (10位随机码) | -| `Path` | `string` | 逻辑路径 (如 /docs/readme.md) | +| `Path` | `string` | **核心标识** (如 /docs/readme.md) | | `BinaryContent` | `io.Reader` | 瞬态流,用于接收上传内容 | -| `TextContent` | `string` | 文档正文 (Markdown/Plain) | +| `TextContent` | `string` | 文档正文 | | `Version` | `uint64` | 单调递增的版本号 | | `ToC` | `[]ToCNode` | 自动生成的目录树 | ## 💡 注意事项 -- **路径对齐**:所有物理文件存储在 `baseDir/doc/` 下,按 ID 前缀进行三级目录隔离。 -- **自动初始化**:`GetDB` 内部使用 `_system` 权限自动维护 `_Doc` 和 `_Doc_History` 的表结构。 +- **ID 封装**:底层依然使用唯一 ID 维护物理存储,但对 API 调用者完全隐藏。 +- **自动初始化**:`GetDB` 会自动维护 `_Doc` 和 `_Doc_History` 的表结构。 diff --git a/doc.go b/doc.go index 483f233..3ba8084 100644 --- a/doc.go +++ b/doc.go @@ -18,7 +18,8 @@ import ( type Hooks struct { OnCreatedDoc func(doc *Document) OnUpdatedDoc func(doc *Document) - OnRemoved func(id string) + OnRemoved func(path string) + OnMoved func(oldPath, newPath string) } // DocDBUnauthorized 实例 @@ -36,15 +37,14 @@ type DocDB struct { hist *tableDB.Table } -// Document 结构体 +// Document 结构体 (用户不感知 ID) type Document struct { - ID string // 唯一标识符 - Path string // 层级路径 + 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 // 目录树 @@ -70,10 +70,8 @@ type ToCNode struct { // GetDB 获取 DocDBUnauthorized 实例并初始化表结构 func GetDB(db *tableDB.TableDBUnauthorized, baseDir string) *DocDBUnauthorized { - // 使用系统管理员身份初始化 sys := db.Auth(tableDB.SystemUserID) - // 确保系统基础表存在 if raw, err := sys.GetRawDB(); err == nil { _ = raw.Sync(tableDB.SystemSchema) } @@ -133,23 +131,20 @@ func (d *DocDBUnauthorized) Auth(userID string) *DocDB { // SetDoc 更新内容 (强制提升版本号,自动保存历史) func (a *DocDB) SetDoc(docObj *Document) error { - if docObj.ID == "" { - docObj.ID = id.MakeID(10) - } - now := time.Now().UnixMilli() - existing, err := a.table.Get(docObj.ID) + existing, _ := a.getRaw(docObj.Path) - isUpdate := err == nil && existing != nil + isUpdate := existing != nil + var internalID string if isUpdate { - // 保存历史版本 a.archive(existing) - + internalID = cast.String(existing["id"]) docObj.Version = cast.Uint64(existing["version"]) + 1 docObj.CreateTime = cast.Int64(existing["createTime"]) docObj.Creator = cast.String(existing["creator"]) } else { + internalID = id.MakeID(10) docObj.Version = 1 docObj.CreateTime = now docObj.Creator = a.userID @@ -160,7 +155,7 @@ func (a *DocDB) SetDoc(docObj *Document) error { // 处理文件落盘 if docObj.BinaryContent != nil { - if err := a.saveFile(docObj); err != nil { + if err := a.saveFile(internalID, docObj); err != nil { return err } docObj.AsFile = true @@ -172,10 +167,9 @@ func (a *DocDB) SetDoc(docObj *Document) error { docObj.ToC = ExtractToC(docObj.TextContent) } - // 转换为 map 并清理瞬态/冲突字段 - record := a.toRecord(docObj) + record := a.toRecord(internalID, docObj) - err = a.table.Set(record) + err := a.table.Set(record) if err == nil { if isUpdate { a.triggerUpdated(docObj) @@ -187,10 +181,10 @@ func (a *DocDB) SetDoc(docObj *Document) error { } // SetMeta 更新元数据 (不提升版本号) -func (a *DocDB) SetMeta(id string, meta map[string]any) error { - existing, err := a.table.Get(id) +func (a *DocDB) SetMeta(path string, meta map[string]any) error { + existing, err := a.getRaw(path) if err != nil || existing == nil { - return fmt.Errorf("document not found: %s", id) + return fmt.Errorf("document not found: %s", path) } now := time.Now().UnixMilli() @@ -203,34 +197,56 @@ func (a *DocDB) SetMeta(id string, meta map[string]any) error { return a.table.Set(existing) } +// Move 修改路径 (重命名) +func (a *DocDB) Move(oldPath, newPath string) error { + existing, err := a.getRaw(oldPath) + if err != nil || existing == nil { + return fmt.Errorf("document not found: %s", oldPath) + } + + existing["path"] = newPath + existing["updateTime"] = time.Now().UnixMilli() + existing["updater"] = a.userID + + err = a.table.Set(existing) + if err == nil { + if a.parent.Hooks.OnMoved != nil { + a.parent.Hooks.OnMoved(oldPath, newPath) + } + } + return err +} + +func (a *DocDB) getRaw(path string) (map[string]any, error) { + res, err := a.table.List(map[string]any{"path": path}) + if err != nil || len(res) == 0 { + return nil, err + } + return res[0], nil +} + func (a *DocDB) archive(existing map[string]any) { histRecord := make(map[string]any) for k, v := range existing { histRecord[k] = v } histRecord["docId"] = cast.String(existing["id"]) - delete(histRecord, "id") // 让 tableDB 自动生成历史记录 ID + delete(histRecord, "id") _ = a.hist.Set(histRecord) } -func (a *DocDB) toRecord(docObj *Document) map[string]any { +func (a *DocDB) toRecord(internalID string, docObj *Document) map[string]any { record := make(map[string]any) cast.Convert(&record, docObj) - - if v, ok := record["ID"]; ok { - record["id"] = v - delete(record, "ID") - } - + record["id"] = internalID delete(record, "binaryContent") - return record } -func (a *DocDB) saveFile(docObj *Document) error { - subDir1 := docObj.ID[0:2] - subDir2 := docObj.ID[2:6] - subDir3 := docObj.ID[6:10] +func (a *DocDB) saveFile(internalID string, docObj *Document) error { + subDir1 := internalID[0:2] + subDir2 := internalID[2:6] + subDir3 := internalID[6:10] ext := filepath.Ext(docObj.Path) fileName := fmt.Sprintf("v%d%s", docObj.Version, ext) @@ -252,8 +268,8 @@ func (a *DocDB) saveFile(docObj *Document) error { } // Get 获取文档 (当前版本) -func (a *DocDB) Get(id string) (*Document, error) { - res, err := a.table.Get(id) +func (a *DocDB) Get(path string) (*Document, error) { + res, err := a.getRaw(path) if err != nil || res == nil { return nil, err } @@ -263,36 +279,51 @@ func (a *DocDB) Get(id string) (*Document, error) { } // GetByVersion 按版本获取文档 -func (a *DocDB) GetByVersion(id string, version uint64) (*Document, error) { - current, _ := a.Get(id) +func (a *DocDB) GetByVersion(path string, version uint64) (*Document, error) { + current, _ := a.Get(path) if current != nil && current.Version == version { return current, nil } - res, err := a.hist.List(map[string]any{"docId": id, "version": version}) - if err != nil || len(res) == 0 { + // 查找原始 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"]) + } + } + + if docID == "" { + return nil, fmt.Errorf("document not found: %s", path) + } + + histRes, err := a.hist.List(map[string]any{"docId": docID, "version": version}) + if err != nil || len(histRes) == 0 { return nil, err } var doc Document - cast.Convert(&doc, res[0]) - doc.ID = id // 确保 ID 是原始文档 ID 而非历史记录 ID + cast.Convert(&doc, histRes[0]) return &doc, nil } // Remove 删除文档 (自动保存最后一版到历史) -func (a *DocDB) Remove(id string) error { - existing, err := a.table.Get(id) +func (a *DocDB) Remove(path string) error { + existing, err := a.getRaw(path) if err != nil || existing == nil { return nil } - // 保存最后一版到历史 a.archive(existing) - err = a.table.Remove(id) + err = a.table.Remove(cast.String(existing["id"])) if err == nil { - a.triggerRemoved(id) + a.triggerRemoved(path) } return err } @@ -309,9 +340,9 @@ func (a *DocDB) triggerUpdated(doc *Document) { } } -func (a *DocDB) triggerRemoved(id string) { +func (a *DocDB) triggerRemoved(path string) { if a.parent.Hooks.OnRemoved != nil { - a.parent.Hooks.OnRemoved(id) + a.parent.Hooks.OnRemoved(path) } } @@ -321,17 +352,15 @@ func ExtractToC(content string) []ToCNode { var root []ToCNode stack := []*[]ToCNode{&root} - inCodeBlock := false // 状态机:是否在代码块内 + inCodeBlock := false for _, line := range lines { trimmed := strings.TrimSpace(line) - // 切换代码块状态 if strings.HasPrefix(trimmed, "```") { inCodeBlock = !inCodeBlock continue } - // 如果在代码块内部,直接跳过解析 if inCodeBlock { continue } diff --git a/doc_test.go b/doc_test.go index e0ddde5..6488916 100644 --- a/doc_test.go +++ b/doc_test.go @@ -19,18 +19,19 @@ func TestDocDB(t *testing.T) { unauthorizedDB := tableDB.GetDB("sqlite://"+dbFile, logger) - // 使用 GetDB 入口 docDBInst := GetDB(unauthorizedDB, "./test_docs") app := docDBInst.Auth("user1") - var createdCalled, updatedCalled, removedCalled bool + var createdCalled, updatedCalled, removedCalled, movedCalled bool docDBInst.Hooks.OnCreatedDoc = func(doc *Document) { createdCalled = true } docDBInst.Hooks.OnUpdatedDoc = func(doc *Document) { updatedCalled = true } - docDBInst.Hooks.OnRemoved = func(id string) { removedCalled = true } + docDBInst.Hooks.OnRemoved = func(path string) { removedCalled = true } + docDBInst.Hooks.OnMoved = func(oldPath, newPath string) { movedCalled = true } // 1. 测试 SetDoc (创建) + docPath := "/test/doc1.md" doc1 := &Document{ - Path: "/test/doc1.md", + Path: docPath, Title: "Test Doc", TextContent: "# Header 1\nContent 1", Type: "markdown", @@ -49,7 +50,7 @@ func TestDocDB(t *testing.T) { } // 2. 测试 Get - d, err := app.Get(doc1.ID) + d, err := app.Get(docPath) if err != nil || d == nil { t.Fatalf("Get failed: %v", err) } @@ -57,36 +58,53 @@ func TestDocDB(t *testing.T) { t.Fatalf("Expected title 'Test Doc', got %s", d.Title) } - // 3. 测试 SetDoc (更新内容, 提升版本并存入历史) + // 3. 测试 SetDoc (更新内容) doc1.TextContent = "# Header 1\nUpdated Content" err = app.SetDoc(doc1) if err != nil { t.Fatalf("Update SetDoc failed: %v", err) } if doc1.Version != 2 { - t.Fatalf("Expected version 2 after content update, got %d", doc1.Version) + t.Fatalf("Expected version 2, got %d", doc1.Version) } if !updatedCalled { t.Error("OnUpdatedDoc event not triggered") } // 4. 测试 GetByVersion - hist, err := app.GetByVersion(doc1.ID, 1) + 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 version 1, got %s", hist.TextContent) + t.Fatalf("Expected 'Content 1' in history, got %s", hist.TextContent) } - curr, _ := app.GetByVersion(doc1.ID, 2) - if curr.Version != 2 || !strings.Contains(curr.TextContent, "Updated Content") { - t.Fatalf("Expected version 2 content, got version %d", curr.Version) + // 5. 测试 Move (重命名) + newPath := "/test/renamed.md" + err = app.Move(docPath, newPath) + if err != nil { + t.Fatalf("Move failed: %v", err) + } + if !movedCalled { + t.Error("OnMoved event not triggered") } - // 5. 测试 Remove 后依然能通过 GetByVersion 获取 - docID := doc1.ID - err = app.Remove(docID) + // 检查旧路径失效,新路径生效 + 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 + err = app.Remove(newPath) if err != nil { t.Fatalf("Remove failed: %v", err) } @@ -94,18 +112,17 @@ func TestDocDB(t *testing.T) { t.Error("OnRemoved event not triggered") } - // 检查当前版本已删除 - removedDoc, _ := app.Get(docID) + removedDoc, _ := app.Get(newPath) if removedDoc != nil { - t.Error("Document should be removed from _Doc") + t.Error("Document should be removed") } - // 检查依然能获取历史版本 (包括刚删除的最后一版 v2) - histV2, err := app.GetByVersion(docID, 2) - if err != nil || histV2 == nil { - t.Fatalf("GetByVersion(2) should work after removal: %v", err) + // 7. 测试删除后依然能获取历史 + lastHist, err := app.GetByVersion(newPath, 2) + if err != nil || lastHist == nil { + t.Fatalf("Should get history after removal: %v", err) } - if histV2.Version != 2 { - t.Fatalf("Expected version 2 from history after removal, got %d", histV2.Version) + if lastHist.Version != 2 { + t.Fatalf("Expected version 2, got %d", lastHist.Version) } }