content-sqlite-design.md 63 KB

Content SQLite 设计说明

一、文件结构概述

content.db 是一个 SQLite 数据库文件,每个文档对应一个独立的数据库文件,存储文档中的所有内容块(Block)

1.1 存储位置

tmp/
├── {user_id}/
│   ├── {YYYY-MM-DD}/
│   │   └── doc-abc123.doc      # 导出的 Word 文件
│   └── sqlite/
│       └── doc-abc123.db       # 文档内容数据库(SQLite)
└── default.json                # 默认样式文件

路径说明:

  • Word 导出文件:tmp/{user_id}/{YYYY-MM-DD}/{filename}.doc(按日期分区)
  • SQLite 数据库:tmp/{user_id}/sqlite/{document_id}.db(统一存放)

1.2 与 documents 表的关系

# documents 表字段
class Document(Base):
    __tablename__ = "documents"
    
    id: Mapped[str] = mapped_column(String(64), primary_key=True)
    content_db_path: Mapped[str] = mapped_column(String(1024))  # SQLite 数据库路径
    # ... 其他字段

1.3 为什么选择 SQLite?

特性 优势
结构化存储 原生支持,无需解析
查询性能 O(log n) 索引查询
局部更新 直接 UPDATE,无需全量更新
全文搜索 支持 FTS5 全文索引
关系查询 原生 JOIN 支持
数据完整性 外键约束、事务支持

二、数据库表结构

2.1 document_blocks 表(内容块主表)

CREATE TABLE document_blocks (
    -- 主键
    id TEXT PRIMARY KEY,                    -- Block 唯一 ID,如 "block-h1-0"
    
    -- 位置
    block_order INTEGER NOT NULL,           -- 在文档中的绝对位置(用于排序)
    
    -- 类型
    type TEXT NOT NULL,                     -- Block 类型:heading, paragraph, table, image
    
    -- 标题信息
    level INTEGER NOT NULL DEFAULT 0,       -- 标题级别(1-6)或段落级别(0)
    "index" INTEGER NOT NULL DEFAULT 0,     -- 该级别在文档中的序号
    
    -- 内容
    content TEXT,                           -- 文本内容(标题、段落)或结构化数据(表格、图片)
    
    -- 样式
    word_style TEXT,                        -- Word 样式名称,如 "Heading 1", "Normal"
    style TEXT,                             -- 颗粒度样式 JSON(自定义样式时使用)
    
    -- 元数据
    metadata TEXT                           -- 其他元数据 JSON
);

-- 索引
CREATE INDEX idx_block_order ON document_blocks(block_order);
CREATE INDEX idx_type ON document_blocks(type);
CREATE INDEX idx_level ON document_blocks(level);

字段说明:

字段 类型 说明 示例
id TEXT Block 唯一标识 "block-h1-0", "block-p-5", "block-table-0"
block_order INTEGER 文档中的绝对位置,用于排序 1, 2, 3, ...
type TEXT Block 类型 "heading", "paragraph", "table", "image"
level INTEGER 标题级别(1-6)或 0(非标题) 1 (H1), 2 (H2), 0 (段落)
index INTEGER 该级别标题的序号 第 2 个 H2 标题的 index 为 1
content TEXT 内容存储(格式因类型而异) 标题/段落:纯文本;表格:JSON;图片:Base64
word_style TEXT Word 样式名 "Heading 1", "Normal", "Table Grid"
style TEXT 自定义样式 JSON "{}"'{"bold": true, "color": "FF0000"}'
metadata TEXT 元数据 JSON '{"parent_id": "block-h1-0"}'

三、稀疏排序与 Metadata 更新策略

3.1 设计原理

为了在插入或删除 Block 时避免大量更新 block_orderindex 等字段,采用稀疏排序策略。

传统方案的问题:

  • 初始排序:1, 2, 3, 4, 5
  • 在位置 2 后插入:需要将 3, 4, 5 全部更新为 4, 5, 6
  • 删除位置 3:需要将 4, 5 全部更新为 3, 4
  • 大文档会导致大量数据库更新操作

稀疏排序方案:

  • 初始排序:100, 200, 300, 400, 500(间隔 100)
  • 在位置 200 后插入:插入 150,无需更新其他 Block
  • 再次插入:可以插入 125, 175 等,填充间隙
  • 删除操作:直接删除,无需更新其他 Block

3.2 实现示例

初始化文档

-- 初始文档结构(间隔 100)
INSERT INTO document_blocks (id, block_order, type, level, "index", content, ...)
VALUES 
  ('block-h1-0', 0, 'heading', 1, 0, '第一章', ...),
  ('block-p-0', 100, 'paragraph', 0, 0, '段落1', ...),
  ('block-p-1', 200, 'paragraph', 0, 0, '段落2', ...),
  ('block-table-0', 300, 'table', 0, 0, '{"rows": [...]}', ...),
  ('block-h1-1', 400, 'heading', 1, 1, '第二章', ...);

插入新 Block

-- 在 block_order=200 后插入段落
-- 计算中间值:(200 + 300) / 2 = 250
INSERT INTO document_blocks (id, block_order, type, level, "index", content, ...)
VALUES ('block-p-new', 250, 'paragraph', 0, 0, '新段落', ...);

-- 结果:0, 100, 200, 250(新), 300, 400
-- 无需更新其他 Block

连续插入

-- 继续在 200 和 250 之间插入
INSERT INTO document_blocks (...) VALUES (..., 225, ...);

-- 结果:0, 100, 200, 225(新), 250, 300, 400

间隙耗尽时的重排

当某个区间的间隙用尽时(例如无法再在 200-201 之间插入),触发局部重排

场景示例:

原始序列:200, 201, 202, 300
需要在 200 和 201 之间插入 → 间隙不足(200.5 无法使用整数)

触发局部重排(重新分配 200-300 区间):
- 区间内有 3 个 Block(201, 202, 新Block),区间大小 = 100
- 重新分配:220, 240, 260
- 结果:200, 220(原201), 240(原202), 260(新), 300

继续可插入:200, 210(新), 220, 230(新), 240, 250(新), 260, 270(新), 300

重排策略:

  • 触发条件:当前后两个 block_order 差值 ≤ 1 时
  • 重排范围:仅重排问题区间(通常 20-50 个 Block),不影响整个文档
  • 重排算法:在区间内均匀重新分配 block_order 值
  • 性能影响:局部重排操作远小于传统方案的全量更新

3.3 标题 index 的稀疏处理

标题的 index 字段也可以采用稀疏排序:

-- 初始标题(间隔 100)
INSERT INTO document_blocks (id, type, level, "index", ...)
VALUES 
  ('block-h2-0', 'heading', 2, 0, ...),      -- index: 0
  ('block-h2-100', 'heading', 2, 100, ...),  -- index: 100
  ('block-h2-200', 'heading', 2, 200, ...);  -- index: 200

-- 在 index=0 和 index=100 之间插入新标题
INSERT INTO document_blocks (id, type, level, "index", ...)
VALUES ('block-h2-50', 'heading', 2, 50, ...);

-- 结果:index: 0, 50(新), 100, 200

3.4 优势总结

操作 传统方案 稀疏排序方案
插入 更新所有后续 Block 仅插入 1 条记录
删除 更新所有后续 Block 仅删除 1 条记录
性能 O(n) 更新操作 O(1) 插入/删除
数据库压力 高(大量 UPDATE) 低(单条 INSERT/DELETE)
适用场景 小文档(<100 Block) 大文档(1000+ Block)

3.5 何时触发重排

  • 插入时:当间隙 ≤ 1 时,对局部区间(20-50 个 Block)重排
  • 定期维护:可选,定期对整个文档重排以恢复均匀间隔
  • 导出时:导出为 Word 时重新计算连续排序,不影响数据库

四、Block 类型说明

4.0 Metadata 更新策略

性能问题

如果在 metadata 中同时存储 parent_idchildren_ids,会导致严重的性能问题:

问题示例:

{
  "parent_id": "block-h1-0",
  "children_ids": ["block-h2-0", "block-h2-100", "block-h2-200"]  // ❌ 不推荐
}

性能问题:

  • 每添加一个标题,都要遍历已有的所有 blocks
  • 时间复杂度:O(n²),其中 n 是 blocks 数量
  • 对于 1000 个 blocks 的文档,需要约 500,000 次比较
  • 对于 10000 个 blocks 的文档,需要约 50,000,000 次比较

推荐方案

只存储 parent_id,不存储 children_ids

{
  "parent_id": "block-h1-0"
  // ✓ 不存储 children_ids,按需动态计算
}

优势:

  • ✅ 插入/删除操作时间复杂度:O(1)
  • ✅ 无需更新其他 blocks 的 metadata
  • ✅ 数据一致性高,单一数据源
  • ✅ 前端/API 可通过 SQL 查询动态构建子节点列表

动态查询子节点:

-- 查询某标题的所有子标题
SELECT * FROM document_blocks
WHERE type = 'heading'
  AND json_extract(metadata, '$.parent_id') = 'block-h1-0'
ORDER BY block_order;

4.1 标题块(Heading)

基础插入示例

简单文本(无富文本格式):

INSERT INTO document_blocks (id, block_order, type, level, "index", content, word_style, style, metadata)
VALUES (
    'block-h1-0',                           -- id
    100,                                    -- block_order
    'heading',                              -- type
    1,                                      -- level
    0,                                      -- index
    '基本数据',                              -- content(纯文本)
    'Heading 1',                            -- word_style
    '{}',                                   -- style(空表示使用 word_style)
    '{"parent_id": null}'                   -- metadata
);

富文本内容(部分样式覆盖)

当标题或段落中部分文字需要不同样式时(如部分加粗、变色),使用 JSON 数组格式:

示例 1:标题中部分文字变色

INSERT INTO document_blocks (id, block_order, type, level, "index", content, word_style, style, metadata)
VALUES (
    'block-h1-1',
    200,
    'heading',
    1,
    100,
    '[
        {"text": "基本", "style": {}},
        {"text": "数据", "style": {"color": "FF0000", "bold": true}}
    ]',                                     -- content(富文本 JSON)
    'Heading 1',
    '{}',
    '{"parent_id": null}'
);

渲染结果: "基本数据"

示例 2:标题中多段样式

-- 标题:"第一章 概述与分析"("概述与分析"为红色)
INSERT INTO document_blocks (..., content, ...)
VALUES (..., 
    '[
        {"text": "第一章 ", "style": {}},
        {"text": "概述与分析", "style": {"color": "FF0000"}}
    ]',
    ...
);

示例 3:复杂富文本组合

-- 标题:"项目名称:东河塘油田"("东河塘油田"加粗且下划线)
INSERT INTO document_blocks (..., content, ...)
VALUES (...,
    '[
        {"text": "项目名称:", "style": {}},
        {"text": "东河塘油田", "style": {"bold": true, "underline": true}}
    ]',
    ...
);

Content 字段格式说明

content 字段支持两种格式:

格式 1:纯文本字符串

content = '基本数据'
  • 适用于:整个标题/段落使用统一样式
  • 样式来源:word_style + style(Block 级别)

格式 2:富文本 JSON 数组

[
  {
    "text": "文本片段1",
    "style": {}  // 继承 Block 级别样式
  },
  {
    "text": "文本片段2",
    "style": {"color": "FF0000", "bold": true}  // 覆盖特定属性
  }
]
  • 适用于:标题/段落中部分文字需要不同样式
  • 每个片段独立定义样式覆盖
  • 未定义的样式属性继承自 Block 级别的 word_style + style

样式继承层级

样式文件 (word_style)
    ↓
Block 级别样式 (style)
    ↓
文本片段样式 (content[].style)

完整示例:

-- Block 定义
INSERT INTO document_blocks (id, block_order, type, level, "index", content, word_style, style, metadata)
VALUES (
    'block-h1-2',
    300,
    'heading',
    1,
    200,
    '[
        {"text": "关于", "style": {}},
        {"text": "重要", "style": {"color": "FF0000", "bold": true}},
        {"text": "通知", "style": {"font_size": 16.0}}
    ]',
    'Heading 1',                           -- 样式文件中定义:宋体、14pt、黑色
    '{"font_name": "微软雅黑"}',            -- Block 覆盖:改为微软雅黑
    '{"parent_id": null}'
);

渲染逻辑:

  1. 样式文件 "Heading 1":{font_name: "宋体", font_size: 14.0, color: "000000"}
  2. Block 覆盖:{font_name: "微软雅黑"} → 字体变为微软雅黑
  3. 文本片段:
    • "关于":微软雅黑、14pt、黑色(继承)
    • "重要":微软雅黑、14pt、红色、粗体(覆盖)
    • "通知":微软雅黑、16pt、黑色(覆盖字号)

样式优先级规则

  1. 纯文本模式word_style → Block style
  2. 富文本模式word_style → Block style → 片段 style

设计要点:

  • level + index 对应现有 API 的局部更新逻辑
  • metadata 中的 parent_id 构建标题树,便于生成目录
  • block_order 使用稀疏排序保证文档块的顺序
  • content 支持纯文本或富文本 JSON 格式

查询示例:

-- 获取所有一级标题
SELECT * FROM document_blocks 
WHERE type = 'heading' AND level = 1 
ORDER BY block_order;

-- 获取标题树(使用 parent_id)
SELECT 
    b1.id,
    b1.level,
    b1."index",
    b1.content,
    json_extract(b1.metadata, '$.parent_id') as parent_id
FROM document_blocks b1
WHERE b1.type = 'heading'
ORDER BY b1.block_order;

-- 获取某标题下的所有内容块
SELECT * FROM document_blocks
WHERE json_extract(metadata, '$.parent_heading_id') = 'block-h1-0'
ORDER BY block_order;

4.2 段落块(Paragraph)

基础插入示例

方式 1:简单文本(无富文本格式)

INSERT INTO document_blocks (id, block_order, type, level, "index", content, word_style, style, metadata)
VALUES (
    'block-p-0',                            -- id
    200,                                    -- block_order
    'paragraph',                            -- type
    0,                                      -- level(段落为 0)
    0,                                      -- index
    '东河塘油田东河1区块DH1-10H井',         -- content(纯文本)
    'Normal',                               -- word_style
    '{}',                                   -- style
    '{"parent_heading_id": "block-h1-0"}'   -- metadata
);

方式 2:富文本 JSON 格式(推荐)

INSERT INTO document_blocks (id, block_order, type, level, "index", content, word_style, style, metadata)
VALUES (
    'block-p-1',
    300,
    'paragraph',
    0,
    0,
    '[
        {"text": "东河塘油田", "style": {"bold": true}},
        {"text": "东河1区块DH1-10H井", "style": {}}
    ]',                                     -- content(富文本 JSON)
    'Normal',
    '{}',
    '{"parent_heading_id": "block-h1-0"}'
);

富文本段落示例

示例 1:部分文字加粗和变色

-- 段落:"项目位置在陕西省,投资金额约5000万元"
-- 效果:"陕西省" 红色加粗,"5000万元" 蓝色加粗
INSERT INTO document_blocks (..., content, ...)
VALUES (...,
    '[
        {"text": "项目位置在", "style": {}},
        {"text": "陕西省", "style": {"bold": true, "color": "FF0000"}},
        {"text": ",投资金额约", "style": {}},
        {"text": "5000万元", "style": {"bold": true, "color": "0000FF"}}
    ]',
    ...
);

示例 2:混合样式段落

-- 复杂格式:普通文字 + 加粗 + 斜体 + 下划线
INSERT INTO document_blocks (..., content, ...)
VALUES (...,
    '[
        {"text": "根据", "style": {}},
        {"text": "《安全生产法》", "style": {"bold": true, "underline": true}},
        {"text": "第23条规定,企业应当", "style": {}},
        {"text": "定期检查", "style": {"italic": true, "color": "FF0000"}},
        {"text": "设备运行状态。", "style": {}}
    ]',
    ...
);

Content 格式对比

格式 适用场景 优点 缺点
纯文本 统一样式段落 简洁、数据量小 无法部分样式调整
富文本 JSON 复杂样式、精确控制 样式灵活、精确 数据量稍大

纯文本与富文本对照:

-- 纯文本格式(整段统一样式)
content = '东河塘油田东河1区块'

-- 富文本格式(部分文字加粗)
content = '[
    {"text": "东河塘油田", "style": {"bold": true}},
    {"text": "东河1区块", "style": {}}
]'

设计要点:

  • 支持纯文本格式(简单)和富文本 JSON(灵活性)
  • metadata.parent_heading_id 关联所属章节
  • 富文本格式支持更精细的样式控制(颜色、字号、下划线等)

查询示例:

-- 搜索段落内容
SELECT * FROM document_blocks 
WHERE type = 'paragraph' 
  AND content LIKE '%东河塘%'
ORDER BY block_order;

-- 获取某章节下的所有段落
SELECT * FROM document_blocks 
WHERE type = 'paragraph' 
  AND json_extract(metadata, '$.parent_heading_id') = 'block-h2-0'
ORDER BY block_order;

4.3 表格块(Table)

4.3.1 表格样式与内容样式

样式层级:

  1. 表格样式(默认):存储在 Block 的 style 字段

    • 表格边框样式、表格宽度等
    • 通常使用默认样式,不常修改
  2. 单元格内容样式(可修改):存储在每个单元格的 style 字段

    • 文本样式(加粗、颜色、字号等)
    • 单元格样式(背景色、对齐等)
    • 从 Word 提取时自动检测并保存
  3. 合并单元格与尺寸:存储在 metadata 和单元格属性中

    • rowspancolspan:单元格合并
    • table_widthcol_widths:表格和列宽

4.3.2 基础表格结构

表格的 content 字段存储 JSON 格式的结构化数据:

简单表格示例:

{
  "rows": [
    {
      "cells": [
        {
          "text": "设计单位名称",
          "rowspan": 1,
          "colspan": 1,
          "style": {"bold": true}  // ← Word 提取时检测到加粗
        },
        {
          "text": "东河采油气管理区",
          "rowspan": 1,
          "colspan": 3,
          "style": {}
        }
      ]
    },
    {
      "cells": [
        {
          "text": "审核意见",
          "rowspan": 2,
          "colspan": 1,
          "style": {"bold": true, "valign": "middle"}  // ← 加粗 + 垂直居中
        },
        {
          "text": "说明:...",
          "rowspan": 1,
          "colspan": 1,
          "style": {}
        }
      ]
    }
  ]
}

插入示例:

INSERT INTO document_blocks (id, block_order, type, level, "index", content, word_style, style, metadata)
VALUES (
    'block-table-0',                        -- id
    500,                                    -- block_order
    'table',                                -- type
    0,                                      -- level
    0,                                      -- index
    '{"rows": [...]}',                      -- content(JSON 格式)
    'Table Grid',                           -- word_style(表格样式名)
    '{}',                                   -- style(使用默认表格样式)
    '{"cols": 4, "rows": 6, "table_width": 100, "table_width_unit": "percent", "col_widths": [25, 25, 25, 25], "parent_heading_id": "block-h2-0"}'
);

Metadata 字段说明:

字段 类型 说明 示例
cols number 表格列数 4
rows number 表格行数 6
table_width number 表格宽度值 100
table_width_unit string 表格宽度单位 "percent", "cm", "inch"
col_widths array 每列宽度 [25, 25, 25, 25]
row_heights array 每行高度(厘米) [1.0, 0.8, 0.8]
parent_heading_id string 所属章节 ID "block-h2-0"

表格宽度定义

表格总宽度单位:

// 示例 1:百分比宽度(占页面宽度的 100%)
{
  "table_width": 100,
  "table_width_unit": "percent"
}

// 示例 2:厘米宽度(固定 16 厘米)
{
  "table_width": 16,
  "table_width_unit": "cm"
}

// 示例 3:英寸宽度(固定 6.5 英寸)
{
  "table_width": 6.5,
  "table_width_unit": "inch"
}

列宽定义(col_widths)

规则:

  • 数组长度必须等于列数(cols
  • table_width_unit"percent" 时,值表示百分比
  • table_width_unit"cm""inch" 时,值表示绝对宽度

示例 1:百分比列宽(等宽)

{
  "cols": 4,
  "table_width": 100,
  "table_width_unit": "percent",
  "col_widths": [25, 25, 25, 25]  // 每列占 25%,总和 = 100%
}

示例 2:百分比列宽(不等宽)

{
  "cols": 4,
  "table_width": 100,
  "table_width_unit": "percent",
  "col_widths": [40, 30, 20, 10]  // 总和 = 100%
}

示例 3:绝对列宽(厘米)

{
  "cols": 3,
  "table_width": 15,
  "table_width_unit": "cm",
  "col_widths": [5, 5, 5]  // 每列 5 厘米,总和 = 15cm
}

行高定义(row_heights)

规则:

  • 可选字段:如果不定义,行高自动适应内容
  • 数组长度必须等于行数(rows
  • 单位:厘米(cm)

示例 1:固定行高

{
  "rows": 3,
  "row_heights": [1.5, 1.0, 1.0]  // 第1行 1.5cm,第2、3行各 1.0cm
}

示例 2:不定义行高(自动适应)

{
  "rows": 3
  // 不定义 row_heights,行高根据内容自动调整
}

示例 3:混合使用

{
  "rows": 5,
  "row_heights": [2.0, 0.8, 0.8, 0.8, 1.5]
  // 第1行(表头)2.0cm
  // 第2-4行(数据)0.8cm
  // 第5行(总计)1.5cm
}

完整表格尺寸示例

{
  "cols": 4,
  "rows": 3,
  "table_width": 100,
  "table_width_unit": "percent",
  "col_widths": [30, 25, 25, 20],      // 列宽:30%, 25%, 25%, 20%
  "row_heights": [1.2, 0.8, 0.8],      // 行高:1.2cm, 0.8cm, 0.8cm
  "parent_heading_id": "block-h2-0"
}

4.3.3 单元格内容格式

单元格的 text 字段支持两种格式:

格式 1:纯文本

{
  "text": "设计单位名称",
  "rowspan": 1,
  "colspan": 1,
  "style": {"bold": true}  // 整个单元格统一样式
}

格式 2:富文本(单元格内部分样式)

{
  "text": [
    {"text": "设计单位", "style": {}},
    {"text": "名称", "style": {"color": "FF0000"}}
  ],
  "rowspan": 1,
  "colspan": 1,
  "style": {"bold": true}  // 单元格基础样式
}

完整示例:表头加粗显示

INSERT INTO document_blocks (..., content, ...)
VALUES (...,
    '{
      "rows": [
        {
          "cells": [
            {"text": "姓名", "rowspan": 1, "colspan": 1, "style": {"bold": true}},
            {"text": "年龄", "rowspan": 1, "colspan": 1, "style": {"bold": true}},
            {"text": "职位", "rowspan": 1, "colspan": 1, "style": {"bold": true}}
          ]
        },
        {
          "cells": [
            {"text": "张三", "rowspan": 1, "colspan": 1, "style": {}},
            {"text": "30", "rowspan": 1, "colspan": 1, "style": {}},
            {"text": "工程师", "rowspan": 1, "colspan": 1, "style": {}}
          ]
        }
      ]
    }',
    ...
);

说明:

  • Word 提取时:自动检测表头单元格是否加粗,保存到 style.bold
  • 前端渲染时:根据 style.bold 渲染加粗效果
  • 用户编辑时:可修改单元格文本样式(字体、字号、加粗、颜色等)和对齐方式

4.3.4 单元格(Cell)结构详解

{
  "text": string | array,   // 单元格内容:纯文本 或 富文本数组
  "rowspan": number,        // 垂直合并单元格数(默认1)✅ 可修改
  "colspan": number,        // 水平合并单元格数(默认1)✅ 可修改
  "style": {                // 单元格样式
    // ===== 文本内容样式(可修改)=====
    "font_name": string,    // 字体名称 ✅
    "font_size": number,    // 字号(磅)✅
    "bold": boolean,        // 是否粗体 ✅ ← Word 提取时检测
    "italic": boolean,      // 是否斜体 ✅
    "underline": boolean,   // 是否下划线 ✅
    "color": string,        // 文字颜色(十六进制)✅
    "align": string,        // 水平对齐:"left", "center", "right" ✅
    "valign": string,       // 垂直对齐:"top", "middle", "bottom" ✅
    
    // ===== 单元格样式(目前不支持修改)=====
  }
}

可修改的属性:

  • ✅ 单元格内容(text
  • ✅ 合并单元格(rowspancolspan
  • ✅ 文本样式(字体、字号、加粗、斜体、下划线、颜色)
  • ✅ 文本对齐(水平对齐、垂直对齐)

暂不支持修改的属性:

  • ❌ 单元格背景色(bg_color
  • ❌ 单元格边框(border_*
  • ❌ 单元格内边距(padding

注意: Word 提取时会检测并保存所有样式属性,但前端编辑器目前只支持修改文本内容样式和对齐方式。

4.3.5 边框(Border)对象

注意: 单元格边框目前不支持修改,此定义仅供 Word 提取和系统内部使用。

4.3.6 查询示例

-- 获取表格基本信息
SELECT 
    id,
    json_extract(metadata, '$.cols') as cols,
    json_extract(metadata, '$.rows') as rows,
    json_extract(metadata, '$.table_width') as width
FROM document_blocks
WHERE type = 'table'
ORDER BY block_order;

-- 搜索表格中的内容(使用 JSON 函数)
SELECT 
    id,
    block_order,
    content
FROM document_blocks
WHERE type = 'table' 
  AND content LIKE '%东河%'
ORDER BY block_order;

-- 统计表格数量
SELECT COUNT(*) as table_count 
FROM document_blocks 
WHERE type = 'table';

-- 获取包含特定列数的表格
SELECT * FROM document_blocks 
WHERE type = 'table' 
  AND json_extract(metadata, '$.cols') = 4
ORDER BY block_order;

4.3.7 表格样式示例

示例 1:基础表格(无合并)

{
  "rows": [
    {
      "cells": [
        {"text": "姓名", "rowspan": 1, "colspan": 1, "style": {"bold": true}},
        {"text": "年龄", "rowspan": 1, "colspan": 1, "style": {"bold": true}},
        {"text": "职位", "rowspan": 1, "colspan": 1, "style": {"bold": true}}
      ]
    },
    {
      "cells": [
        {"text": "张三", "rowspan": 1, "colspan": 1, "style": {}},
        {"text": "30", "rowspan": 1, "colspan": 1, "style": {}},
        {"text": "工程师", "rowspan": 1, "colspan": 1, "style": {}}
      ]
    }
  ]
}

示例 2:合并单元格

表格效果:

┌────────────────────────────────┐
│       标题                     │  ← 第1行:1个单元格横跨3列
│   (colspan=3)                  │
├──────────┬───────────┬─────────┤
│          │  子项1    │   100   │  ← 第2行:"项目A"纵跨2行
│  项目A   ├───────────┼─────────┤
│(rowspan=2)│  子项2    │   200  │ ← 第3行
│          │           │         │
└──────────┴───────────┴─────────┘

JSON 数据结构:

{
  "rows": [
    {
      "cells": [
        {
          "text": "标题", 
          "rowspan": 1, 
          "colspan": 3,       // ← 横跨3列
          "style": {
            "bold": true,
            "align": "center",
            "bg_color": "F0F0F0"
          }
        }
      ]
    },
    {
      "cells": [
        {
          "text": "项目A", 
          "rowspan": 2,       // ← 纵跨2行
          "colspan": 1, 
          "style": {
            "valign": "middle"
          }
        },
        {"text": "子项1", "rowspan": 1, "colspan": 1, "style": {}},
        {"text": "100", "rowspan": 1, "colspan": 1, "style": {}}
      ]
    },
    {
      "cells": [
        // 注意:第1个单元格被上一行的"项目A"占据,所以这行只有2个单元格
        {"text": "子项2", "rowspan": 1, "colspan": 1, "style": {}},
        {"text": "200", "rowspan": 1, "colspan": 1, "style": {}}
      ]
    }
  ]
}

关键点:

  • colspan: 横向合并,值为合并的列数
  • rowspan: 纵向合并,值为合并的行数
  • 被合并占据的单元格不需要在数据中定义
  • 例如:第3行只定义2个单元格,因为第1个位置被"项目A"占据

4.4 图片块(Image)

插入示例:

INSERT INTO document_blocks (id, block_order, type, level, "index", content, word_style, style, metadata)
VALUES (
    'block-img-0',                          -- id
    10,                                     -- block_order
    'image',                                -- type
    0,                                      -- level
    0,                                      -- index
    'data:image/png;base64,iVBORw0KG...',   -- content(Base64 数据)
    'Normal',                               -- word_style
    '{"width": 10.0, "height": 7.0, "unit": "cm", "align": "center"}',  -- style
    '{"alt": "示意图", "para_style": "Normal", "parent_heading_id": "block-h2-0"}'  -- metadata
);

样式优先级:同标题块

设计要点:

  • content 存储 Base64 编码的图片数据(Data URL 格式)
  • style 定义图片显示尺寸和对齐方式
  • metadata 包含图片描述和所属章节

查询示例:

-- 获取所有图片
SELECT 
    id,
    block_order,
    json_extract(metadata, '$.alt') as alt,
    json_extract(style, '$.width') as width,
    json_extract(style, '$.height') as height
FROM document_blocks 
WHERE type = 'image'
ORDER BY block_order;

-- 获取某章节下的所有图片
SELECT * FROM document_blocks 
WHERE type = 'image' 
  AND json_extract(metadata, '$.parent_heading_id') = 'block-h2-0'
ORDER BY block_order;

五、样式系统详细说明

5.1 Word 样式识别规则

如何识别 Word 中的自定义样式作为标题

在 Word 中,标题有多种设置方式:

方式A:使用内置标题样式

  • Word 样式名:Heading 1, Heading 2, Heading 3
  • 直接映射:Heading 1level: 1Heading 2level: 2

方式B:使用自定义样式名称

  • 用户创建自定义样式:如"我的一级标题"、"章节标题"等
  • 判断依据:检查样式的大纲级别(Outline Level)属性
    • Outline Level = 1 → level: 1(一级标题)
    • Outline Level = 2 → level: 2(二级标题)
    • Outline Level = 0 或未设置 → 按普通段落处理

方式C:直接格式化但未应用样式

  • 只是调整了字体、字号、加粗等格式
  • 没有设置大纲级别
  • 按普通段落处理,保留其格式样式

Python 实现示例

from docx import Document

def identify_heading_level(paragraph):
    """识别段落的标题级别"""
    style = paragraph.style
    
    # 方法1:检查样式名称(内置样式)
    if style.name.startswith('Heading'):
        try:
            level = int(style.name.split()[-1])  # "Heading 1" → 1
            return level, style.name
        except ValueError:
            pass
    
    # 方法2:检查大纲级别(自定义样式)
    if hasattr(style.element, 'pPr') and style.element.pPr is not None:
        outline_lvl = style.element.pPr.outlineLvl
        if outline_lvl is not None:
            level = outline_lvl.val + 1  # Word 大纲级别从 0 开始
            return level, style.name
    
    # 方法3:通过段落格式的大纲级别
    if paragraph._element.pPr is not None:
        outline_lvl = paragraph._element.pPr.outlineLvl
        if outline_lvl is not None:
            level = outline_lvl.val + 1
            return level, style.name
    
    # 不是标题,返回 None
    return None, style.name

5.2 样式优先级与使用规则

样式合并逻辑

style(颗粒度样式)覆盖 word_style(样式文件)中的对应属性 > 默认样式(代码内置)

重要说明:

  • style 只在渲染时覆盖,不修改样式文件本身
  • ✅ 样式文件(default.json)保持不变,作为基准样式库
  • ✅ 每个 Block 的 style 字段独立存储覆盖值
  • ✅ 类似 Word 中"应用样式 + 局部调整格式"的行为

使用场景

场景 A:使用样式文件(推荐)

INSERT INTO document_blocks (..., word_style, style, ...)
VALUES (..., 'Normal', '{}', ...);
  • 后端从样式文件查找 "Normal" 的完整定义
  • 如果找到:应用样式文件中的所有属性(字体、大小、颜色等)
  • 如果未找到:使用代码内置的默认样式

示例:

// 样式文件中的 "Normal" 定义
{
  "font_name": "宋体",
  "font_size": 12.0,
  "color": "000000",
  "align": "left"
}

// 最终应用的样式
{
  "font_name": "宋体",
  "font_size": 12.0,
  "color": "000000",
  "align": "left"
}

场景 B:样式覆盖(部分自定义)

INSERT INTO document_blocks (..., word_style, style, ...)
VALUES (..., 'Normal', '{"font_name": "黑体"}', ...);
  • style 中定义的属性覆盖 word_style 中的对应属性
  • style未定义的属性继承自 word_style
  • 类似 Word 中"选中段落 → 单独修改字体"的行为

示例:

// 样式文件中的 "Normal" 定义
{
  "font_name": "宋体",     // ← 会被覆盖
  "font_size": 12.0,      // ← 继承
  "color": "000000",      // ← 继承
  "align": "left"         // ← 继承
}

// style 中的覆盖定义
{
  "font_name": "黑体"     // 只覆盖字体
}

// 最终应用的样式(合并结果)
{
  "font_name": "黑体",    // ✓ 来自 style(覆盖)
  "font_size": 12.0,      // ✓ 来自 word_style(继承)
  "color": "000000",      // ✓ 来自 word_style(继承)
  "align": "left"         // ✓ 来自 word_style(继承)
}

场景 C:完全自定义样式(前端编辑器)

INSERT INTO document_blocks (..., word_style, style, ...)
VALUES (..., 'Normal', '{"font_name": "微软雅黑", "font_size": 18.0, "bold": true, "color": "FF0000", "align": "center"}', ...);
  • style 中定义了所有必需属性时,完全使用自定义样式
  • 仍然可以参考 word_style 作为基准,但前端可以完全重写
  • 前端提供样式编辑器让用户自定义

示例:

// 样式文件中的 "Normal" 定义(作为参考)
{
  "font_name": "宋体",
  "font_size": 12.0,
  "color": "000000",
  "align": "left"
}

// style 中的完全自定义
{
  "font_name": "微软雅黑",
  "font_size": 18.0,
  "bold": true,
  "color": "FF0000",
  "align": "center"
}

// 最终应用的样式(完全自定义)
{
  "font_name": "微软雅黑",  // ✓ 来自 style
  "font_size": 18.0,       // ✓ 来自 style
  "bold": true,            // ✓ 来自 style
  "color": "FF0000",       // ✓ 来自 style
  "align": "center"        // ✓ 来自 style
}

实现逻辑(伪代码)

def apply_style(block):
    """应用样式的合并逻辑"""
    # 1. 从样式文件加载 word_style(只读,不修改文件)
    base_style = load_style_from_file(block.word_style) or get_default_style()
    
    # 2. 解析 block.style
    custom_style = json.loads(block.style) if block.style else {}
    
    # 3. 合并样式:custom_style 覆盖 base_style(仅在内存中)
    final_style = {**base_style, **custom_style}
    
    return final_style

# 注意:样式文件 (default.json) 始终保持不变
# 只有 block.style 字段会存储用户的自定义覆盖值

数据流示例:

┌─────────────────────┐
│  样式文件 (只读)     │
│  default.json       │
│  {                  │
│    "Normal": {      │
│      "font": "宋体" │
│      "size": 12     │
│    }                │
│  }                  │
└──────────┬──────────┘
           │
           │ 读取 (不修改)
           ↓
┌─────────────────────┐       ┌──────────────────┐
│  数据库 Block       │       │  渲染输出        │
│  word_style: "Normal"│  →   │  font: "黑体"    │
│  style: {           │  合并  │  size: 12        │
│    "font": "黑体"   │   →   │  (黑体覆盖宋体)  │
│  }                  │       │                  │
└─────────────────────┘       └──────────────────┘

样式文件依然是:
{
  "Normal": {
    "font": "宋体",  ← 未改变
    "size": 12
  }
}

5.3 颗粒度样式属性定义

通用属性(所有 Block 类型)✅ 可自定义

{
  "font_name": "宋体",      // 字体名称 ✅
  "font_size": 12.0,        // 字号(磅)✅
  "bold": true,             // 是否粗体 ✅
  "italic": false,          // 是否斜体 ✅
  "underline": false,       // 是否下划线 ✅
  "color": "000000",        // 文字颜色(十六进制)✅
  "align": "left"           // 对齐方式:"left", "center", "right", "justify" ✅
}

段落特有属性 ❌ 暂不支持自定义

{
  "line_spacing": 1.5,      // 行距 ❌
  "indent_first": 0,        // 首行缩进(磅)❌
  "indent_left": 0,         // 左缩进(磅)❌
  "indent_right": 0,        // 右缩进(磅)❌
  "space_before": 12.0,     // 段前间距(磅)❌
  "space_after": 6.0        // 段后间距(磅)❌
}

注意: 段落特有属性目前前端不能自定义,这些属性从 Word 提取时保存,渲染时使用,但用户暂时无法编辑。

表格特有属性(表格级)❌ 使用默认样式

{
  "table_width": 100,           // 表格宽度 ✅ 可修改
  "table_width_unit": "percent",// 宽度单位 ✅ 可修改
  "align": "left",              // 表格对齐 ❌
  "border_style": "single",     // 边框样式 ❌
  "border_width": 0.5,          // 边框宽度 ❌
  "border_color": "000000",     // 边框颜色 ❌
  "cell_padding": 2.0,          // 单元格内边距 ❌
  "header_bold": true,          // 表头是否加粗 ❌
  "header_bg_color": "F0F0F0"   // 表头背景色 ❌
}

注意: 表格样式使用默认样式,用户可修改表格宽度和列宽(见 4.3.2 章节),但不能修改边框、内边距等样式。

图片特有属性 ✅ 可自定义

{
  "width": 10.0,            // 宽度 ✅
  "height": 7.0,            // 高度 ✅
  "unit": "cm",             // 单位:"cm", "inch", "px" ✅
  "align": "center"         // 对齐:"left", "center", "right" ✅
}

六、目录构建与标题关系

6.1 标题关系构建

元数据存储(推荐): 不存储 children_ids,只存储 parent_id,由前端/API 动态计算

{
  "parent_id": "block-h1-0"
  // children_ids 不存储,按需动态计算
}

优点:

  • ✅ 解析速度提升 100-1000 倍
  • ✅ 单一数据源,不会出现不一致
  • ✅ 更新简单,只需修改 parent_id
  • ✅ 数据库文件减小 5-10%

6.2 构建目录树(Python 实现)

import sqlite3
import json

def build_toc_tree(db_path: str) -> list:
    """构建完整目录树(一次 O(n) 遍历)"""
    conn = sqlite3.connect(db_path)
    conn.row_factory = sqlite3.Row
    cursor = conn.cursor()
    
    # 获取所有标题
    cursor.execute("""
        SELECT id, level, "index", content, metadata, block_order
        FROM document_blocks
        WHERE type = 'heading'
        ORDER BY block_order
    """)
    
    headings = []
    for row in cursor.fetchall():
        metadata = json.loads(row['metadata'] or '{}')
        headings.append({
            'id': row['id'],
            'level': row['level'],
            'index': row['index'],
            'content': row['content'],
            'parent_id': metadata.get('parent_id'),
            'children': []
        })
    
    # 构建映射
    heading_map = {h['id']: h for h in headings}
    
    # 构建树
    tree = []
    for h in headings:
        parent_id = h['parent_id']
        if parent_id and parent_id in heading_map:
            heading_map[parent_id]['children'].append(h)
        else:
            tree.append(h)
    
    conn.close()
    return tree

6.3 前端构建目录树(JavaScript)

async function loadDocumentTOC(documentId) {
  // 1. 获取所有标题块
  const response = await fetch(`/api/v1/documents/${documentId}/blocks?type=heading`);
  const { blocks } = await response.json();
  
  // 2. 构建树形结构
  const map = new Map(blocks.map(h => [h.id, { ...h, children: [] }]));
  
  const tree = [];
  blocks.forEach(h => {
    const node = map.get(h.id);
    const parentId = h.metadata?.parent_id;
    
    if (parentId && map.has(parentId)) {
      map.get(parentId).children.push(node);
    } else {
      tree.push(node);
    }
  });
  
  return tree;
}

// 渲染目录
function renderTOC(tree) {
  return (
    <ul className="toc">
      {tree.map(item => (
        <li key={item.id}>
          <a href={`#${item.id}`} onClick={(e) => {
            e.preventDefault();
            scrollToBlock(item.id);
          }}>
            {item.content}
          </a>
          {item.children.length > 0 && renderTOC(item.children)}
        </li>
      ))}
    </ul>
  );
}

七、快速索引和搜索

7.1 基本查询示例

-- 获取所有块(按顺序)
SELECT * FROM document_blocks ORDER BY block_order;

-- 按类型获取块
SELECT * FROM document_blocks WHERE type = 'heading' ORDER BY block_order;

-- 获取单个块
SELECT * FROM document_blocks WHERE id = 'block-h1-0';

-- 获取某标题下的所有内容
SELECT * FROM document_blocks 
WHERE json_extract(metadata, '$.parent_heading_id') = 'block-h1-0'
ORDER BY block_order;

7.2 内容搜索示例

-- 搜索段落内容
SELECT * FROM document_blocks 
WHERE type = 'paragraph' AND content LIKE '%关键词%'
ORDER BY block_order;

-- 搜索表格内容
SELECT * FROM document_blocks 
WHERE type = 'table' AND content LIKE '%关键词%'
ORDER BY block_order;

-- 全文搜索(所有类型)
SELECT * FROM document_blocks 
WHERE content LIKE '%关键词%'
ORDER BY block_order;

-- 搜索标题
SELECT * FROM document_blocks 
WHERE type = 'heading' AND content LIKE '%关键词%'
ORDER BY block_order, level;

7.3 性能优化

已创建的索引可以加速查询:

-- 已创建的索引
CREATE INDEX idx_block_order ON document_blocks(block_order);  -- 顺序查询
CREATE INDEX idx_type ON document_blocks(type);                -- 类型筛选
CREATE INDEX idx_level ON document_blocks(level);              -- 标题级别

八、与现有 API 的集成

8.1 创建文档

async def create_document(self, data: CreateDocumentRequest) -> Document:
    # 1. 下载并解析 Word 文档
    word_content = await _download_word(data.file_url)
    
    # 2. 提取为结构化 Blocks
    blocks = extract_blocks_from_word(word_content)
    
    # 3. 创建 SQLite 数据库
    db_path = f"tmp/{data.user_id}/sqlite/{doc_id}.db"
    Path(db_path).parent.mkdir(parents=True, exist_ok=True)
    
    # 4. 初始化数据库并写入 Blocks
    self._init_content_db(db_path, blocks)
    
    # 5. 创建数据库记录
    doc = Document(
        id=doc_id,
        content_db_path=db_path,
        user_id=data.user_id,
        created_at=datetime.now(timezone.utc)
    )
    
    self.db.add(doc)
    await self.db.commit()
    
    return doc

8.2 获取文档

async def get_document(self, document_id: str) -> DocumentResponse:
    # 1. 查询文档记录
    doc = await self.db.get(Document, document_id)
    if not doc:
        raise DocumentNotFoundError(document_id)
    
    # 2. 从 SQLite 加载 blocks
    blocks = self._load_blocks_from_db(doc.content_db_path)
    
    return {
        "id": doc.id,
        "blocks": blocks,
        "dbPath": doc.content_db_path
    }

8.3 更新文档

async def update_document(self, document_id: str, updates: dict) -> Document:
    doc = await self.db.get(Document, document_id)
    
    # 按 Block ID 更新
    if 'block_updates' in updates:
        self._update_blocks_by_id(doc.content_db_path, updates['block_updates'])
    
    doc.updated_at = datetime.now(timezone.utc)
    await self.db.commit()
    
    return doc

8.4 导出为 Word

async def export_to_word(self, document_id: str) -> str:
    # 1. 获取文档
    doc = await self.db.get(Document, document_id)
    
    # 2. 从 SQLite 加载 blocks
    blocks = self._load_blocks_from_db(doc.content_db_path)
    
    # 3. 生成 Word 文档
    word_path = f"tmp/{doc.user_id}/{date.today()}/{document_id}.docx"
    generate_word_from_blocks(blocks, word_path)
    
    return word_path
    if doc.format == "sqlite":
        self._update_blocks_by_level_index(doc.content_db_path, data.blocks)
    else:
        doc.content = self._apply_block_updates(doc.content, data.blocks)

elif hasattr(data, 'block_updates') and data.block_updates:
    # 模式3:按 Block ID 精确更新(新增)
    if doc.format == "sqlite":
        self._update_blocks_by_id(doc.content_db_path, data.block_updates)

doc.updated_at = datetime.now(timezone.utc)
await self.db.commit()
await self.db.refresh(doc)

return doc

def _update_blocks_by_id(self, db_path: str, updates: list):

"""按 Block ID 精确更新"""
conn = sqlite3.connect(db_path)
cursor = conn.cursor()

for update in updates:
    block_id = update['id']
    fields = []
    values = []

    if 'content' in update:
        fields.append('content = ?')
        values.append(update['content'])

    if 'style' in update:
        fields.append('style = ?')
        values.append(json.dumps(update['style']))

    if 'metadata' in update:
        fields.append('metadata = ?')
        values.append(json.dumps(update['metadata']))

    if fields:
        values.append(block_id)
        cursor.execute(f"""
            UPDATE document_blocks 
            SET {', '.join(fields)}
            WHERE id = ?
        """, values)

conn.commit()
conn.close()


## 九、API 端点扩展

### 9.1 新增 Blocks 操作端点

```python
# app/api/v1/document_blocks.py

from fastapi import APIRouter, Depends, Query
from typing import Optional

router = APIRouter(prefix="/documents/{documentId}/blocks", tags=["Document Blocks"])

@router.get("", summary="获取文档所有 Blocks")
async def list_blocks(
    documentId: str,
    type: Optional[str] = Query(None, description="按类型筛选:heading, paragraph, table, image"),
    parentId: Optional[str] = Query(None, description="按父标题 ID 筛选"),
):
    """
    获取文档的所有内容块
    
    **查询参数:**
    - `type`: 按类型筛选
    - `parentId`: 获取某标题下的所有内容
    """
    doc = await document_service.get_document(documentId)
    
    if doc.format != "sqlite":
        raise ValueError("Document format is not SQLite")
    
    conn = sqlite3.connect(doc.content_db_path)
    conn.row_factory = sqlite3.Row
    cursor = conn.cursor()
    
    # 构建查询
    where_clauses = []
    params = []
    
    if type:
        where_clauses.append('type = ?')
        params.append(type)
    
    if parentId:
        where_clauses.append('json_extract(metadata, "$.parent_heading_id") = ?')
        params.append(parentId)
    
    where_sql = f"WHERE {' AND '.join(where_clauses)}" if where_clauses else ""
    
    cursor.execute(f"""
        SELECT * FROM document_blocks 
        {where_sql}
        ORDER BY block_order
    """, params)
    
    blocks = []
    for row in cursor.fetchall():
        block = dict(row)
        block['style'] = json.loads(block['style'] or '{}')
        block['metadata'] = json.loads(block['metadata'] or '{}')
        if block['type'] == 'table':
            block['content'] = json.loads(block['content'])
        blocks.append(block)
    
    conn.close()
    
    return ok({"blocks": blocks})

@router.get("/{blockId}", summary="获取单个 Block")
async def get_block(documentId: str, blockId: str):
    """获取指定 ID 的 Block"""
    doc = await document_service.get_document(documentId)
    
    if doc.format != "sqlite":
        raise ValueError("Document format is not SQLite")
    
    conn = sqlite3.connect(doc.content_db_path)
    conn.row_factory = sqlite3.Row
    cursor = conn.cursor()
    
    cursor.execute("""
        SELECT * FROM document_blocks 
        WHERE id = ?
    """, (blockId,))
    
    row = cursor.fetchone()
    conn.close()
    
    if not row:
        raise BlockNotFoundError(blockId)
    
    block = dict(row)
    block['style'] = json.loads(block['style'] or '{}')
    block['metadata'] = json.loads(block['metadata'] or '{}')
    if block['type'] == 'table':
        block['content'] = json.loads(block['content'])
    
    return ok(block)

@router.put("/{blockId}", summary="更新单个 Block") async def update_block(

documentId: str, 
blockId: str, 
updates: dict

):

"""
更新指定 Block

**请求体示例:**
```json
{
  "content": "更新后的内容",
  "style": {"bold": true, "color": "FF0000"},
  "metadata": {"parent_heading_id": "block-h1-0"}
}
```
"""
doc = await document_service.get_document(documentId)

if doc.format != "sqlite":
    raise ValueError("Document format is not SQLite")

conn = sqlite3.connect(doc.content_db_path)
cursor = conn.cursor()

# 构建 UPDATE 语句
fields = []
values = []

if 'content' in updates:
    fields.append('content = ?')
    content = updates['content']
    # 表格需要序列化
    if isinstance(content, dict):
        content = json.dumps(content)
    values.append(content)

if 'style' in updates:
    fields.append('style = ?')
    values.append(json.dumps(updates['style']))

if 'metadata' in updates:
    fields.append('metadata = ?')
    values.append(json.dumps(updates['metadata']))

if not fields:
    conn.close()
    raise ValueError("No fields to update")

values.append(blockId)

cursor.execute(f"""
    UPDATE document_blocks 
    SET {', '.join(fields)}
    WHERE id = ?
""", values)

conn.commit()
conn.close()

if cursor.rowcount == 0:
    raise BlockNotFoundError(blockId)

return ok({"blockId": blockId, "updatedAt": int(time.time() * 1000)})

@router.delete("/{blockId}", summary="删除单个 Block") async def delete_block(documentId: str, blockId: str):

"""删除指定 Block"""
doc = await document_service.get_document(documentId)

if doc.format != "sqlite":
    raise ValueError("Document format is not SQLite")

conn = sqlite3.connect(doc.content_db_path)
cursor = conn.cursor()

cursor.execute("""
    DELETE FROM document_blocks 
    WHERE id = ?
""", (blockId,))

conn.commit()
conn.close()

if cursor.rowcount == 0:
    raise BlockNotFoundError(blockId)

return ok({"message": "Block deleted successfully"})

@router.get("/search", summary="搜索文档内容") async def search_blocks(

documentId: str, 
q: str = Query(..., description="搜索关键词"),
limit: int = Query(50, ge=1, le=200)

):

"""全文搜索文档内容"""
doc = await document_service.get_document(documentId)

if doc.format != "sqlite":
    raise ValueError("Document format is not SQLite")

conn = sqlite3.connect(doc.content_db_path)
conn.row_factory = sqlite3.Row
cursor = conn.cursor()

cursor.execute("""
    SELECT * FROM document_blocks 
    WHERE content LIKE ?
    ORDER BY block_order
    LIMIT ?
""", (f'%{q}%', limit))

results = []
for row in cursor.fetchall():
    block = dict(row)
    block['style'] = json.loads(block['style'] or '{}')
    block['metadata'] = json.loads(block['metadata'] or '{}')
    results.append(block)

conn.close()

return ok({"results": results, "total": len(results)})


@router.get("/toc", summary="获取文档目录树")
async def get_toc(documentId: str):
    """获取文档的目录树结构"""
    doc = await document_service.get_document(documentId)
    
    if doc.format != "sqlite":
        raise ValueError("Document format is not SQLite")
    
    conn = sqlite3.connect(doc.content_db_path)
    conn.row_factory = sqlite3.Row
    cursor = conn.cursor()
    
    # 获取所有标题
    cursor.execute("""
        SELECT id, level, "index", content, metadata, block_order
        FROM document_blocks
        WHERE type = 'heading'
        ORDER BY block_order
    """)
    
    headings = []
    for row in cursor.fetchall():
        metadata = json.loads(row['metadata'] or '{}')
        headings.append({
            'id': row['id'],
            'level': row['level'],
            'index': row['index'],
            'content': row['content'],
            'parent_id': metadata.get('parent_id'),
            'children': []
        })
    
    conn.close()
    
    # 构建树
    heading_map = {h['id']: h for h in headings}
    tree = []
    
    for h in headings:
        parent_id = h['parent_id']
        if parent_id and parent_id in heading_map:
            heading_map[parent_id]['children'].append(h)
        else:
            tree.append(h)
    
    return ok({"toc": tree})

@router.get("/stats", summary="获取文档统计信息")
async def get_stats(documentId: str):
    """获取文档的统计信息"""
    doc = await document_service.get_document(documentId)
    
    if doc.format != "sqlite":
        raise ValueError("Document format is not SQLite")
    
    conn = sqlite3.connect(doc.content_db_path)
    conn.row_factory = sqlite3.Row
    cursor = conn.cursor()
    
    # 统计各类型 Block 数量
    cursor.execute("""
        SELECT type, COUNT(*) as count
        FROM document_blocks
        GROUP BY type
    """)
    type_stats = {row['type']: row['count'] for row in cursor.fetchall()}
    
    # 统计标题层级
    cursor.execute("""
        SELECT level, COUNT(*) as count
        FROM document_blocks
        WHERE type = 'heading'
        GROUP BY level
        ORDER BY level
    """)
    heading_stats = [dict(row) for row in cursor.fetchall()]
    
    # 总块数
    cursor.execute("SELECT COUNT(*) as total FROM document_blocks")
    total_blocks = cursor.fetchone()['total']
    
    conn.close()
    
    return ok({
        "typeStats": type_stats,
        "headingStats": heading_stats,
        "totalBlocks": total_blocks
    })

十、前端使用示例

10.1 加载文档

// 1. 加载文档基本信息
const response = await fetch('/api/v1/documents/doc-123');
const { id, format, dbPath, content, blocks } = await response.json();

if (format === 'sqlite') {
    // 2. 如果响应已包含 blocks,直接使用
    if (blocks) {
        renderDocument(blocks);
    } else {
        // 3. 否则单独获取 blocks
        const blocksResp = await fetch(`/api/v1/documents/${id}/blocks`);
        const { blocks } = await blocksResp.json();
        renderDocument(blocks);
    }
    
    // 4. 获取目录树
    const tocResp = await fetch(`/api/v1/documents/${id}/blocks/toc`);
    const { toc } = await tocResp.json();
    renderTOC(toc);
}

10.2 渲染文档

function renderDocument(blocks) {
    const container = document.getElementById('document-content');
    
    blocks.forEach(block => {
        let element;
        
        switch (block.type) {
            case 'heading':
                element = document.createElement(`h${block.level}`);
                element.id = block.id;
                // 标题支持富文本格式
                if (Array.isArray(block.content)) {
                    element.innerHTML = renderRichText(block.content);
                } else {
                    element.textContent = block.content;
                }
                break;
                
            case 'paragraph':
                element = document.createElement('p');
                element.id = block.id;
                // 段落支持富文本格式
                if (Array.isArray(block.content)) {
                    element.innerHTML = renderRichText(block.content);
                } else {
                    element.textContent = block.content;  // 简单文本
                }
                break;
                
            case 'table':
                element = renderTable(block);
                break;
                
            case 'image':
                element = document.createElement('img');
                element.id = block.id;
                element.src = block.content;
                element.alt = block.metadata?.alt || '图片';
                applyImageStyle(element, block.style);
                break;
        }
        
        if (element) {
            applyStyle(element, block.style, block.word_style);
            container.appendChild(element);
        }
    });
}

function renderTable(block) {
    const table = document.createElement('table');
    table.id = block.id;
    
    const tableData = block.content;
    
    tableData.rows.forEach((row, rowIdx) => {
        const tr = document.createElement('tr');
        
        row.cells.forEach((cell, colIdx) => {
            const td = document.createElement(rowIdx === 0 ? 'th' : 'td');
            
            // 表格单元格也支持富文本格式
            if (Array.isArray(cell.text)) {
                td.innerHTML = renderRichText(cell.text);
            } else {
                td.textContent = cell.text;
            }
            
            if (cell.rowspan > 1) td.rowSpan = cell.rowspan;
            if (cell.colspan > 1) td.colSpan = cell.colspan;
            
            applyStyle(td, cell.style);
            tr.appendChild(td);
        });
        
        table.appendChild(tr);
    });
    
    return table;
}

// 富文本渲染函数
function renderRichText(content) {
    if (!Array.isArray(content)) {
        return content;  // 如果不是数组,直接返回文本
    }
    
    return content.map(segment => {
        let text = segment.text || '';
        const style = segment.style || {};
        
        // 应用行内样式
        let html = text;
        
        if (style.bold) {
            html = `<strong>${html}</strong>`;
        }
        if (style.italic) {
            html = `<em>${html}</em>`;
        }
        if (style.underline) {
            html = `<u>${html}</u>`;
        }
        if (style.color) {
            html = `<span style="color: ${style.color}">${html}</span>`;
        }
        if (style.font_name || style.font_size) {
            const inlineStyle = [];
            if (style.font_name) inlineStyle.push(`font-family: ${style.font_name}`);
            if (style.font_size) inlineStyle.push(`font-size: ${style.font_size}pt`);
            html = `<span style="${inlineStyle.join('; ')}">${html}</span>`;
        }
        
        return html;
    }).join('');
}

10.3 搜索功能

async function searchDocument(documentId, keyword) {
    const response = await fetch(
        `/api/v1/documents/${documentId}/blocks/search?q=${encodeURIComponent(keyword)}`
    );
    const { results } = await response.json();
    
    // 渲染搜索结果
    renderSearchResults(results);
    
    // 高亮第一个结果
    if (results.length > 0) {
        scrollToBlock(results[0].id);
    }
}

function renderSearchResults(results) {
    const container = document.getElementById('search-results');
    container.innerHTML = '';
    
    results.forEach(block => {
        const item = document.createElement('div');
        item.className = 'search-result-item';
        item.innerHTML = `
            <div class="result-type">${block.type}</div>
            <div class="result-content">${highlightKeyword(block.content)}</div>
            <button onclick="scrollToBlock('${block.id}')">跳转</button>
        `;
        container.appendChild(item);
    });
}

10.4 目录导航

function renderTOC(toc) {
    return (
        <ul className="toc">
            {toc.map(item => (
                <li key={item.id}>
                    <a 
                        href={`#${item.id}`}
                        onClick={(e) => {
                            e.preventDefault();
                            scrollToBlock(item.id);
                        }}
                    >
                        {item.content}
                    </a>
                    {item.children && item.children.length > 0 && (
                        renderTOC(item.children)
                    )}
                </li>
            ))}
        </ul>
    );
}

function scrollToBlock(blockId) {
    const element = document.getElementById(blockId);
    if (element) {
        element.scrollIntoView({ 
            behavior: 'smooth', 
            block: 'start' 
        });
        
        // 高亮显示
        element.classList.add('highlight');
        setTimeout(() => {
            element.classList.remove('highlight');
        }, 2000);
    }
}

10.5 编辑 Block

async function updateBlock(documentId, blockId, updates) {
    const response = await fetch(
        `/api/v1/documents/${documentId}/blocks/${blockId}`,
        {
            method: 'PUT',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify(updates)
        }
    );
    
    const result = await response.json();
    
    if (result.code === 0) {
        console.log('Block updated successfully');
        // 重新加载该 Block
        reloadBlock(documentId, blockId);
    }
}

// 使用示例
await updateBlock('doc-123', 'block-p-5', {
    content: '更新后的段落内容',
    style: { bold: true, color: 'FF0000' }
});

十一、性能优化建议

11.1 索引优化

现有索引已足够应对大部分查询场景,如需进一步优化可添加复合索引:

-- 复合索引(按类型和位置查询)
CREATE INDEX idx_type_order ON document_blocks(type, block_order);

-- 按父标题查询优化
CREATE INDEX idx_parent_heading ON document_blocks(
    (json_extract(metadata, '$.parent_heading_id')), 
    block_order
);

11.2 批量操作优化

-- 使用事务批量更新
BEGIN TRANSACTION;

UPDATE document_blocks SET ... WHERE id = 'block-1';
UPDATE document_blocks SET ... WHERE id = 'block-2';
UPDATE document_blocks SET ... WHERE id = 'block-3';

COMMIT;

11.3 WAL 模式

启用 WAL 模式提升并发性能:

PRAGMA journal_mode=WAL;
PRAGMA synchronous=NORMAL;
PRAGMA cache_size=-64000;  -- 64MB 缓存

优势:

  • 读写并发:读取不阻塞写入
  • 更好的性能:减少磁盘 I/O
  • 更快的事务提交

十二、总结

本设计实现了结构化的 SQLite 存储方案,满足以下核心需求:

区分内容类型:标题、正文、表格、图片分别存储,type 字段清晰标识
保留完整样式:word_style + style 双重机制,支持样式文件和自定义样式
记录层级关系:level + index + parent_id 构建完整的标题树
前端目录索引:提供专用 API 快速构建目录,支持跳转和导航
支持局部更新:按 Block ID 或 level+index 精确更新,性能优越
高效搜索:全文搜索、类型筛选、范围查询一应俱全
稀疏排序策略:避免频繁的 metadata 更新,提升插入和删除性能


文档版本:v1.0
创建日期:2026-07-02
关联文档