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(id 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 结构体 type Document struct { ID string // 唯一标识符 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 { if docObj.ID == "" { docObj.ID = id.MakeID(10) } now := time.Now().UnixMilli() existing, err := a.table.Get(docObj.ID) isUpdate := err == nil && existing != nil if isUpdate { // 保存历史版本 a.archive(existing) docObj.Version = cast.Uint64(existing["version"]) + 1 docObj.CreateTime = cast.Int64(existing["createTime"]) docObj.Creator = cast.String(existing["creator"]) } else { 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(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) } // 转换为 map 并清理瞬态/冲突字段 record := a.toRecord(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(id string, meta map[string]any) error { existing, err := a.table.Get(id) if err != nil || existing == nil { return fmt.Errorf("document not found: %s", id) } now := time.Now().UnixMilli() for k, v := range meta { existing[k] = v } existing["updateTime"] = now existing["updater"] = a.userID 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 { record := make(map[string]any) cast.Convert(&record, docObj) if v, ok := record["ID"]; ok { record["id"] = v delete(record, "ID") } 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] 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(id string) (*Document, error) { res, err := a.table.Get(id) if err != nil || res == nil { return nil, err } var doc Document cast.Convert(&doc, res) return &doc, nil } // GetByVersion 按版本获取文档 func (a *DocDB) GetByVersion(id string, version uint64) (*Document, error) { current, _ := a.Get(id) 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 { return nil, err } var doc Document cast.Convert(&doc, res[0]) doc.ID = id // 确保 ID 是原始文档 ID 而非历史记录 ID return &doc, nil } // Remove 删除文档 (自动保存最后一版到历史) func (a *DocDB) Remove(id string) error { existing, err := a.table.Get(id) if err != nil || existing == nil { return nil } // 保存最后一版到历史 a.archive(existing) err = a.table.Remove(id) if err == nil { a.triggerRemoved(id) } 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(id string) { if a.parent.Hooks.OnRemoved != nil { a.parent.Hooks.OnRemoved(id) } } // 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 }