package docDB import ( "fmt" "io" "os" "path/filepath" "strings" "time" "apigo.cc/go/cast" "apigo.cc/go/file" "apigo.cc/go/id" "apigo.cc/go/tableDB" ) // Hooks 生命周期钩子 type Hooks struct { OnCreatedDoc func(doc *Document) OnUpdatedDoc func(doc *Document) OnRemoved func(path string) OnMoved func(oldPath, newPath string) } // DocDBUnauthorized 实例 type DocDBUnauthorized struct { db *tableDB.TableDBUnauthorized baseDir string Hooks *Hooks } // DocDB 实例 (带用户权限) type DocDB struct { parent *DocDBUnauthorized userID string table *tableDB.Table hist *tableDB.Table } // Document 结构体 (用户不感知 ID) type Document struct { Path string // 层级路径 (唯一标识) Title string // 标题 TextContent string // 纯文本 BinaryContent io.Reader `json:"-"` // 仅为内存瞬态,流式读取 FilePath string // 原文件落盘绝对路径 (只读) Thumbnail string // 缩略图路径 (只读) Summary string // 摘要 Metadata map[string]any // 元数据 ToC []ToCNode // 目录树 Tags string // 标签 Language string // 语言 Type string // 类型 StoreType string // 存储方式 (file/db) AsFile bool // 是否以文件存储 Version uint64 // 版本号 IsSecret bool // 是否加密 CreateTime int64 // 创建时间 UpdateTime int64 // 更新时间 Creator string // 创建人 ID Updater string // 更新人 ID } // ToCNode 目录树节点 type ToCNode struct { Title string Level int Children []ToCNode } // 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) } initTable := func(tableName string) { res, _ := sys.Table("_Table").Get(tableName) if res == nil { _ = sys.SetTable(tableDB.TableSchema{ID: tableName, Name: tableName, Memo: "Document Storage", EnableRLS: true}) fields := []tableDB.FieldSchema{ {Name: "path", Type: "v256", IsIndex: true}, {Name: "title", Type: "v255"}, {Name: "textContent", Type: "t"}, {Name: "filePath", Type: "v256"}, {Name: "thumbnail", Type: "v256"}, {Name: "summary", Type: "t"}, {Name: "metadata", Type: "o"}, {Name: "toc", Type: "o"}, {Name: "tags", Type: "v1024"}, {Name: "language", Type: "v32"}, {Name: "type", Type: "v32"}, {Name: "storeType", Type: "v16"}, {Name: "asFile", Type: "b"}, {Name: "version", Type: "bi"}, {Name: "isSecret", Type: "b"}, {Name: "updateTime", Type: "bi"}, {Name: "updater", Type: "v64"}, {Name: "createTime", Type: "bi"}, {Name: "creator", Type: "v64"}, } if tableName == "_Doc_History" { fields = append(fields, tableDB.FieldSchema{Name: "docId", Type: "c10", IsIndex: true}) } _ = sys.Table(tableName).SetField(fields...) } } initTable("_Doc") initTable("_Doc_History") return &DocDBUnauthorized{ db: db, baseDir: baseDir, Hooks: &Hooks{}, } } // Auth 授权并获取 DocDB func (d *DocDBUnauthorized) Auth(userID string) *DocDB { appTableDB := d.db.Auth(userID) return &DocDB{ parent: d, userID: userID, table: appTableDB.Table("_Doc"), hist: appTableDB.Table("_Doc_History"), } } // SetDoc 更新内容 (强制提升版本号,自动保存历史) func (a *DocDB) SetDoc(docObj *Document) error { now := time.Now().UnixMilli() existing, _ := a.getRaw(docObj.Path) 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 } docObj.UpdateTime = now docObj.Updater = a.userID // 处理文件落盘 if docObj.BinaryContent != nil { if err := a.saveFile(internalID, docObj); err != nil { return err } docObj.AsFile = true 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) } } return err } // SetMeta 更新元数据 (不提升版本号) 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", path) } now := time.Now().UnixMilli() for k, v := range meta { existing[k] = v } existing["updateTime"] = now existing["updater"] = a.userID 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") _ = a.hist.Set(histRecord) } func (a *DocDB) toRecord(internalID string, docObj *Document) map[string]any { record := make(map[string]any) cast.Convert(&record, docObj) record["id"] = internalID delete(record, "binaryContent") return record } 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) filePath := filepath.Join(a.parent.baseDir, "doc", subDir1, subDir2, subDir3, fileName) file.EnsureParentDir(filePath) out, err := os.Create(filePath) if err != nil { return err } defer out.Close() if _, err := io.Copy(out, docObj.BinaryContent); err != nil { return err } docObj.FilePath = filePath return nil } // Get 获取文档 (当前版本) func (a *DocDB) Get(path string) (*Document, error) { res, err := a.getRaw(path) if err != nil || res == nil { return nil, err } var doc Document cast.Convert(&doc, res) return &doc, nil } // GetByVersion 按版本获取文档 func (a *DocDB) GetByVersion(path string, version uint64) (*Document, error) { current, _ := a.Get(path) if current != nil && current.Version == version { 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"]) } } 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, histRes[0]) return &doc, nil } // Remove 删除文档 (自动保存最后一版到历史) 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(cast.String(existing["id"])) if err == nil { a.triggerRemoved(path) } return err } func (a *DocDB) triggerCreated(doc *Document) { if a.parent.Hooks.OnCreatedDoc != nil { a.parent.Hooks.OnCreatedDoc(doc) } } func (a *DocDB) triggerUpdated(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) } } // ExtractToC 提取 Markdown 目录结构 (带代码块识别) func ExtractToC(content string) []ToCNode { lines := strings.Split(content, "\n") var root []ToCNode stack := []*[]ToCNode{&root} inCodeBlock := false for _, line := range lines { trimmed := strings.TrimSpace(line) if strings.HasPrefix(trimmed, "```") { inCodeBlock = !inCodeBlock continue } if inCodeBlock { continue } if !strings.HasPrefix(trimmed, "#") { continue } level := 0 for level < len(trimmed) && trimmed[level] == '#' { level++ } if level > 0 && level < len(trimmed) && trimmed[level] == ' ' { title := strings.TrimSpace(trimmed[level:]) for len(stack) > level { stack = stack[:len(stack)-1] } for len(stack) < level { parentSlice := stack[len(stack)-1] if len(*parentSlice) == 0 { *parentSlice = append(*parentSlice, ToCNode{ Title: "...", Level: len(stack), }) } lastNode := &((*parentSlice)[len(*parentSlice)-1]) stack = append(stack, &(lastNode.Children)) } parentSlice := stack[len(stack)-1] *parentSlice = append(*parentSlice, ToCNode{ Title: title, Level: level, }) lastNode := &((*parentSlice)[len(*parentSlice)-1]) stack = append(stack, &(lastNode.Children)) } } return root }