feat: complete docDB with history management on deletion and standard Hooks API (by AI)

This commit is contained in:
AI Engineer 2026-05-15 14:35:00 +08:00
parent fda6c2f9f2
commit 8499f40e48
5 changed files with 145 additions and 117 deletions

View File

@ -1,6 +1,6 @@
# @go/docDB # @go/docDB
`docDB` 是一个独立的高级文档存储引擎,基于 `@go/tableDB` 构建,提供版本管理、流式上传及生命周期钩子。 `docDB` 是一个独立的高级文档存储引擎,基于 `@go/tableDB` 构建,提供全自动版本管理、历史存证、流式大文件处理及生命周期钩子。
## 📦 安装 ## 📦 安装
@ -10,15 +10,16 @@ go get apigo.cc/go/docDB
## 🛠 核心功能 ## 🛠 核心功能
1. **权限隔离**:完全继承 `tableDB` 的用户隔离体系。 1. **版本管控**`SetDoc` 强制提升版本号并自动归档旧版;`SetMeta` 仅更新属性不触碰版本。
2. **版本管控**:通过 `SetDoc` 强制提升文档版本,`SetMeta` 仅更新属性而不影响版本。 2. **历史存证**:即使文档被删除,其所有历史版本(包括被删除前的最后一版)依然可通过 `GetByVersion` 获取。
3. **流式上传**:支持 `io.Reader` 接口,低内存占用处理大文件落盘。 3. **权限隔离**:完全继承 `tableDB` 的 RLS (Row Level Security) 体系。
4. **事件驱动**:提供 `OnCreatedDoc`, `OnUpdatedDoc`, `OnRemoved` 钩子,方便集成搜索索引、异步处理等。 4. **流式上传**`BinaryContent` 采用 `io.Reader` 接口,支持低内存处理 GB 级大文件落盘。
5. **结构化目录**:自动提取 Markdown 文档的目录树 (ToC)。 5. **事件驱动**:提供标准的 `Hooks` 机制,支持监听文档创建、更新与移除。
6. **智能提取**:自动提取 Markdown 目录结构 (ToC),且能智能跳过代码块内容。
## 🚀 快速开始 ## 🚀 API 指南
### 初始化 ### 1. 初始化与授权
```go ```go
import ( import (
@ -26,47 +27,58 @@ import (
"apigo.cc/go/tableDB" "apigo.cc/go/tableDB"
) )
// 创建 DocDB 实例 // 创建基础数据库连接 (tableDB 层)
db := tableDB.GetDB("sqlite://docs.db", logger) unauthorizedDB := tableDB.GetDB("sqlite://docs.db", logger)
docStore := docDB.New(db, "./storage")
// 授权并获取 App // 获取 docDB 引擎 (自动完成表结构初始化)
app := docStore.Auth("user_123") engine := docDB.GetDB(unauthorizedDB, "./storage_root")
// 授权并获取操作句柄
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)`**
- 获取文档的当前最新版本。
- **`GetByVersion(id string, version uint64) (*Document, error)`**
- 获取指定版本的文档。即使当前文档已被 `Remove`,历史版本依然可查。
- **`Remove(id string) error`**
- 删除文档。删除前会自动将当前最后一版归档至历史表。
### 3. 生命周期钩子 (Hooks)
```go ```go
doc := &docDB.Document{ engine.Hooks.OnCreatedDoc = func(doc *docDB.Document) {
Path: "/projects/readme.md", // 处理新文档创建,如提交搜索索引
Title: "Getting Started",
TextContent: "# Hello World\n...",
BinaryContent: fileReader, // 可选,流式上传
} }
// 保存文档 (版本提升) engine.Hooks.OnUpdatedDoc = func(doc *docDB.Document) {
err := app.SetDoc(doc) // 处理内容更新
}
engine.Hooks.OnRemoved = func(id string) {
// 处理物理删除后的清理工作
}
``` ```
### 更新元数据 ## 📝 数据结构
```go ### Document
// 仅更新标签,不提升版本 | 字段 | 类型 | 说明 |
err := app.SetMeta(docID, map[string]any{ | :--- | :--- | :--- |
"Tags": "tutorial,go", | `ID` | `string` | 唯一标识 (10位随机码) |
}) | `Path` | `string` | 逻辑路径 (如 /docs/readme.md) |
``` | `BinaryContent` | `io.Reader` | 瞬态流,用于接收上传内容 |
| `TextContent` | `string` | 文档正文 (Markdown/Plain) |
| `Version` | `uint64` | 单调递增的版本号 |
| `ToC` | `[]ToCNode` | 自动生成的目录树 |
### 监听事件 ## 💡 注意事项
```go - **路径对齐**:所有物理文件存储在 `baseDir/doc/` 下,按 ID 前缀进行三级目录隔离。
docStore.OnCreatedDoc(func(doc *docDB.Document) { - **自动初始化**`GetDB` 内部使用 `_system` 权限自动维护 `_Doc``_Doc_History` 的表结构。
fmt.Println("New doc created:", doc.ID)
// 可以在这里触发异步摘要生成或向量化
})
```
## 📝 注意事项
- **数据对齐**:内部强制将 `Document.ID` 对齐为 `tableDB` 约定的 `id` 字段。
- **无状态性**:不包含具体业务逻辑,如摘要生成、全文搜索等,推荐通过事件钩子实现。

79
doc.go
View File

@ -14,13 +14,18 @@ import (
"apigo.cc/go/tableDB" "apigo.cc/go/tableDB"
) )
// Hooks 生命周期钩子
type Hooks struct {
OnCreatedDoc func(doc *Document)
OnUpdatedDoc func(doc *Document)
OnRemoved func(id string)
}
// DocDBUnauthorized 实例 // DocDBUnauthorized 实例
type DocDBUnauthorized struct { type DocDBUnauthorized struct {
db *tableDB.TableDBUnauthorized db *tableDB.TableDBUnauthorized
baseDir string baseDir string
onCreatedDoc func(doc *Document) Hooks *Hooks
onUpdatedDoc func(doc *Document)
onRemoved func(id string)
} }
// DocDB 实例 (带用户权限) // DocDB 实例 (带用户权限)
@ -111,6 +116,7 @@ func GetDB(db *tableDB.TableDBUnauthorized, baseDir string) *DocDBUnauthorized {
return &DocDBUnauthorized{ return &DocDBUnauthorized{
db: db, db: db,
baseDir: baseDir, baseDir: baseDir,
Hooks: &Hooks{},
} }
} }
@ -138,13 +144,7 @@ func (a *DocDB) SetDoc(docObj *Document) error {
if isUpdate { if isUpdate {
// 保存历史版本 // 保存历史版本
histRecord := make(map[string]any) a.archive(existing)
for k, v := range existing {
histRecord[k] = v
}
histRecord["docId"] = docObj.ID
delete(histRecord, "id") // 让 tableDB 自动生成历史记录 ID
_ = a.hist.Set(histRecord)
docObj.Version = cast.Uint64(existing["version"]) + 1 docObj.Version = cast.Uint64(existing["version"]) + 1
docObj.CreateTime = cast.Int64(existing["createTime"]) docObj.CreateTime = cast.Int64(existing["createTime"])
@ -178,9 +178,9 @@ func (a *DocDB) SetDoc(docObj *Document) error {
err = a.table.Set(record) err = a.table.Set(record)
if err == nil { if err == nil {
if isUpdate { if isUpdate {
a.parent.triggerUpdated(docObj) a.triggerUpdated(docObj)
} else { } else {
a.parent.triggerCreated(docObj) a.triggerCreated(docObj)
} }
} }
return err return err
@ -203,6 +203,16 @@ func (a *DocDB) SetMeta(id string, meta map[string]any) error {
return a.table.Set(existing) return a.table.Set(existing)
} }
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
_ = a.hist.Set(histRecord)
}
func (a *DocDB) toRecord(docObj *Document) map[string]any { func (a *DocDB) toRecord(docObj *Document) map[string]any {
record := make(map[string]any) record := make(map[string]any)
cast.Convert(&record, docObj) cast.Convert(&record, docObj)
@ -254,12 +264,8 @@ func (a *DocDB) Get(id string) (*Document, error) {
// GetByVersion 按版本获取文档 // GetByVersion 按版本获取文档
func (a *DocDB) GetByVersion(id string, version uint64) (*Document, error) { func (a *DocDB) GetByVersion(id string, version uint64) (*Document, error) {
current, err := a.Get(id) current, _ := a.Get(id)
if err != nil || current == nil { if current != nil && current.Version == version {
return nil, err
}
if current.Version == version {
return current, nil return current, nil
} }
@ -274,35 +280,38 @@ func (a *DocDB) GetByVersion(id string, version uint64) (*Document, error) {
return &doc, nil return &doc, nil
} }
// Remove 删除文档 // Remove 删除文档 (自动保存最后一版到历史)
func (a *DocDB) Remove(id string) error { func (a *DocDB) Remove(id string) error {
err := a.table.Remove(id) existing, err := a.table.Get(id)
if err != nil || existing == nil {
return nil
}
// 保存最后一版到历史
a.archive(existing)
err = a.table.Remove(id)
if err == nil { if err == nil {
a.parent.triggerRemoved(id) a.triggerRemoved(id)
} }
return err return err
} }
// 事件订阅 func (a *DocDB) triggerCreated(doc *Document) {
func (d *DocDBUnauthorized) OnCreatedDoc(fn func(doc *Document)) { d.onCreatedDoc = fn } if a.parent.Hooks.OnCreatedDoc != nil {
func (d *DocDBUnauthorized) OnUpdatedDoc(fn func(doc *Document)) { d.onUpdatedDoc = fn } a.parent.Hooks.OnCreatedDoc(doc)
func (d *DocDBUnauthorized) OnRemoved(fn func(id string)) { d.onRemoved = fn }
func (d *DocDBUnauthorized) triggerCreated(doc *Document) {
if d.onCreatedDoc != nil {
d.onCreatedDoc(doc)
} }
} }
func (d *DocDBUnauthorized) triggerUpdated(doc *Document) { func (a *DocDB) triggerUpdated(doc *Document) {
if d.onUpdatedDoc != nil { if a.parent.Hooks.OnUpdatedDoc != nil {
d.onUpdatedDoc(doc) a.parent.Hooks.OnUpdatedDoc(doc)
} }
} }
func (d *DocDBUnauthorized) triggerRemoved(id string) { func (a *DocDB) triggerRemoved(id string) {
if d.onRemoved != nil { if a.parent.Hooks.OnRemoved != nil {
d.onRemoved(id) a.parent.Hooks.OnRemoved(id)
} }
} }

View File

@ -19,14 +19,14 @@ func TestDocDB(t *testing.T) {
unauthorizedDB := tableDB.GetDB("sqlite://"+dbFile, logger) unauthorizedDB := tableDB.GetDB("sqlite://"+dbFile, logger)
// 使用新的 GetDB 入口,内部完成系统表初始化与业务表初始化 // 使用 GetDB 入口
docDBInst := GetDB(unauthorizedDB, "./test_docs") docDBInst := GetDB(unauthorizedDB, "./test_docs")
app := docDBInst.Auth("user1") app := docDBInst.Auth("user1")
var createdCalled, updatedCalled, removedCalled bool var createdCalled, updatedCalled, removedCalled bool
docDBInst.OnCreatedDoc(func(doc *Document) { createdCalled = true }) docDBInst.Hooks.OnCreatedDoc = func(doc *Document) { createdCalled = true }
docDBInst.OnUpdatedDoc(func(doc *Document) { updatedCalled = true }) docDBInst.Hooks.OnUpdatedDoc = func(doc *Document) { updatedCalled = true }
docDBInst.OnRemoved(func(id string) { removedCalled = true }) docDBInst.Hooks.OnRemoved = func(id string) { removedCalled = true }
// 1. 测试 SetDoc (创建) // 1. 测试 SetDoc (创建)
doc1 := &Document{ doc1 := &Document{
@ -84,47 +84,28 @@ func TestDocDB(t *testing.T) {
t.Fatalf("Expected version 2 content, got version %d", curr.Version) t.Fatalf("Expected version 2 content, got version %d", curr.Version)
} }
// 5. 测试 ExtractToC 代码块识别 // 5. 测试 Remove 后依然能通过 GetByVersion 获取
docWithCode := &Document{ docID := doc1.ID
Path: "/test/code.md", err = app.Remove(docID)
TextContent: "# H1\n```markdown\n# This is not a header\n```\n# H2",
Type: "markdown",
}
_ = app.SetDoc(docWithCode)
if len(docWithCode.ToC) != 2 {
t.Fatalf("Expected 2 headers in ToC, got %d", len(docWithCode.ToC))
}
if docWithCode.ToC[1].Title != "H2" {
t.Fatalf("Expected second header to be 'H2', got %s", docWithCode.ToC[1].Title)
}
// 6. 测试 SetMeta (更新元数据, 不提升版本)
err = app.SetMeta(doc1.ID, map[string]any{"title": "New Title"})
if err != nil {
t.Fatalf("SetMeta failed: %v", err)
}
d2, _ := app.Get(doc1.ID)
if d2.Title != "New Title" {
t.Fatalf("Expected title 'New Title', got %s", d2.Title)
}
if d2.Version != 2 {
t.Fatalf("Expected version 2 after meta update, got %d", d2.Version)
}
// 7. 测试 Remove
err = app.Remove(doc1.ID)
if err != nil { if err != nil {
t.Fatalf("Remove failed: %v", err) t.Fatalf("Remove failed: %v", err)
} }
if !removedCalled { if !removedCalled {
t.Error("OnRemoved event not triggered") t.Error("OnRemoved event not triggered")
} }
}
func BenchmarkExtractToC(b *testing.B) { // 检查当前版本已删除
content := "# H1\n## H1.1\n### H1.1.1\n" + strings.Repeat("Some content\n", 100) + "# H2\n## H2.1\n" removedDoc, _ := app.Get(docID)
for i := 0; i < b.N; i++ { if removedDoc != nil {
_ = ExtractToC(content) t.Error("Document should be removed from _Doc")
}
// 检查依然能获取历史版本 (包括刚删除的最后一版 v2)
histV2, err := app.GetByVersion(docID, 2)
if err != nil || histV2 == nil {
t.Fatalf("GetByVersion(2) should work after removal: %v", err)
}
if histV2.Version != 2 {
t.Fatalf("Expected version 2 from history after removal, got %d", histV2.Version)
} }
} }

2
go.mod
View File

@ -7,7 +7,7 @@ require (
apigo.cc/go/file v1.3.1 apigo.cc/go/file v1.3.1
apigo.cc/go/id v1.3.0 apigo.cc/go/id v1.3.0
apigo.cc/go/log v1.3.2 apigo.cc/go/log v1.3.2
apigo.cc/go/tableDB v0.0.0-00010101000000-000000000000 apigo.cc/go/tableDB v1.1.6
) )
require ( require (

26
go.sum
View File

@ -1,3 +1,29 @@
apigo.cc/go/cast v1.3.2 h1:hh9MWDSwh3T/kQdCHjFpjDwHrh2A05Q4wt1AAWs8NBI=
apigo.cc/go/cast v1.3.2/go.mod h1:lGlwImiOvHxG7buyMWhFzcdvQzmSaoKbmr7bcDfUpHk=
apigo.cc/go/config v1.3.0 h1:TwI3bv3D+BJrAnFx+o62HQo3FarY2Ge3SCGsKchFYGg=
apigo.cc/go/config v1.3.0/go.mod h1:88lqKEBXlIExFKt1geLONVLYyM+QhRVpBe0ok3OEvjI=
apigo.cc/go/crypto v1.3.0 h1:rGRrrb5O+4M50X5hVUmJQbXx3l87zzlcgzGtUvZrZL8=
apigo.cc/go/crypto v1.3.0/go.mod h1:uSCcmbcFoiltUPMQTSuqmU9nfKEH/lRs7nQ7aa3Z4Mc=
apigo.cc/go/db v1.3.1 h1:X+iHMZ71OdWW8R91TFtE+aZr/CZPOtL7gIyt3tEkfyc=
apigo.cc/go/db v1.3.1/go.mod h1:Gc6HEmncoYRaaRy5yPjTegwc2pSwjNOTnINBmbomhcs=
apigo.cc/go/encoding v1.3.0 h1:8jqNHoZBR8vOU/BGsLFebfp1Txa1UxDRpd7YwzIFLJs=
apigo.cc/go/encoding v1.3.0/go.mod h1:kT/uUJiuAOkZ4LzUWrUtk/I0iL1D8aatvD+59bDnHBo=
apigo.cc/go/file v1.3.1 h1:qHgiJsn1K9DazWRrPoHVnXtp6hDGGsUpAE/4G1bFXqY=
apigo.cc/go/file v1.3.1/go.mod h1:pYHBlB/XwsrnWpEh7GIFpbiqobrExfiB+rEN8V2d2kY=
apigo.cc/go/id v1.3.0 h1:Tr2Yj0Rl19lfwW5wBTJ407o/zgo2oVRLE20WWEgJzdE=
apigo.cc/go/id v1.3.0/go.mod h1:AFH3kMFwENfXNyijnAFWEhSF1o3y++UBPem1IUlrcxA=
apigo.cc/go/log v1.3.2 h1:/m3V4MnlYnCG4XPHpWDsa4cw5suMaDVY1SgaVyjnBSo=
apigo.cc/go/log v1.3.2/go.mod h1:dz4bSz9BnOgutkUJJZfX3uDDwsMpUxt7WF50mLK9hgE=
apigo.cc/go/rand v1.3.0 h1:k+UFAhMySwXf+dq8Om9TniZV6fm6gAE0evbrqMEdwQU=
apigo.cc/go/rand v1.3.0/go.mod h1:mZ/4Soa3bk+XvDaqPWJuUe1bfEi4eThBj1XmEAuYxsk=
apigo.cc/go/redis v1.3.0 h1:3NJE3xPXzhCwL+Mh1iyphFrsKWEuPlY26LHJfMVFSeU=
apigo.cc/go/redis v1.3.0/go.mod h1:KPDPwMOER7WJX3Qev24LTeAOSmCl8OApe8iagPDxOUQ=
apigo.cc/go/safe v1.3.0 h1:uctdAUsphT9p60Tk4oS5xPCe0NoIdOHfsYv4PNS0Rok=
apigo.cc/go/safe v1.3.0/go.mod h1:tC9X14V+qh0BqIrVg4UkXbl+2pEN+lj2ZNI8IjDB6Fs=
apigo.cc/go/shell v1.3.0 h1:hdxuYPN/7T2BuM/Ja8AjVUhbRqU/wpi8OjcJVziJ0nw=
apigo.cc/go/shell v1.3.0/go.mod h1:aNJiRWibxlA485yX3t+07IVAbrALKmxzv4oGEUC+hK4=
apigo.cc/go/tableDB v1.1.6 h1:u0405aCKYY2w8KrrlPW9n7kMJXwjT3yKiMaYa8h+Veo=
apigo.cc/go/tableDB v1.1.6/go.mod h1:bf0Chii1BjopKbmf1yte4AcRso79JwrAhxYNSVrysFk=
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=