本篇对应仓库
app/db.py(303 行,是最值得逐行读的文件之一)。讲清楚:表怎么设计、为什么不用 ORM、WAL 是什么、种子数据与增量迁移怎么做。
一、先看全景:15 张表支撑 5 大功能
打开 app/db.py,SCHEMA 一共 15 张 CREATE TABLE IF NOT EXISTS。按功能分组:
| 功能域 | 表 | 存什么 |
|---|---|---|
| 账号权限 | users / sessions / user_assistants |
用户、会话 token、admin 给员工的显式助手授权 |
| 知识库 | knowledge_bases / documents / chunks |
库、文档、分块(含稀疏词频 counts_json) |
| 助手对话 | assistants / conversations / messages |
助手配置、会话、消息(refs_json 存引用来源) |
| 工作流 | workflows / workflow_runs / node_runs |
DAG 定义、运行记录、每节点输入输出耗时 |
| 审计设置 | settings / llm_calls / audit_logs |
模型配置、每次 LLM 调用审计、安全审计 |
先精读三条最有信息量的表定义(摘自真实 SCHEMA,含作者注释):
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
salt TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'employee', -- admin / employee
department TEXT NOT NULL DEFAULT '', -- 部门,多个用逗号分隔
display_name TEXT NOT NULL DEFAULT '',
must_change_password INTEGER NOT NULL DEFAULT 0, -- 1=下次登录强制改密
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
conversation_id INTEGER NOT NULL,
role TEXT NOT NULL, -- user / assistant
content TEXT NOT NULL,
refs_json TEXT NOT NULL DEFAULT '[]', -- 引用来源(RAG 命中的知识块)
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS llm_calls ( -- LLM 网关调用审计
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER, assistant_id INTEGER, run_id INTEGER,
provider TEXT NOT NULL DEFAULT '',
model TEXT NOT NULL DEFAULT '',
prompt_tokens INTEGER NOT NULL DEFAULT 0,
completion_tokens INTEGER NOT NULL DEFAULT 0,
latency_ms INTEGER NOT NULL DEFAULT 0,
attempts INTEGER NOT NULL DEFAULT 1, -- 实际尝试次数(含重试)
status TEXT NOT NULL DEFAULT 'success', -- success / degraded / failed
error TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL
);
从表结构反推需求(教小白读表的方法):
- users.salt + password_hash 分开存 → 口令是"加盐哈希",不存明文(05 篇);
- users.department 注释"多个用逗号分隔" → 部门可见性用字符串匹配实现,简单但有纪律(05 篇讲纪律是什么);
- must_change_password → 种子/管理员重置的初始口令,下次登录强制改(安全需求进了表结构);
- messages.refs_json → RAG 引用来源随消息持久化,刷新页面引用还在;
- llm_calls.attempts → 连"重试了几次"都审计,成本分析才完整。
二、为什么不用 ORM:一个真实权衡
SQLAlchemy 能省多少事?大概省 300 行 SQL。那为什么不用?
- 审计面:手写 SQL 全部走参数绑定(
?占位符),逐条可见。安全加固时作者能逐条确认"没有一处字符串拼接 SQL"。ORM 的隐式查询让这种确认变成大海捞针。 - 学习成本与透明度:本教程读者是小白。
conn.execute("SELECT * FROM users WHERE id=?", (uid,))一眼看懂;ORM 的 session/lazy-load 心智模型要额外一章。 - 依赖哲学(02 篇定下的):标准库
sqlite3零依赖。
代价也如实说:换 PostgreSQL 要改 SQL 方言(Roadmap 已列);复杂查询没有 ORM 的组合能力。对这个规模(单机、15 张表),划算。
三、连接管理:短连接 + 两个 PRAGMA
app/db.py:
def get_conn(db_path: str) -> sqlite3.Connection:
conn = sqlite3.connect(db_path, timeout=10)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA foreign_keys = ON")
# 多线程(FastAPI 线程池 + 工作流线程)并发读写:等锁而不是立刻抛 database is locked
conn.execute("PRAGMA busy_timeout = 5000")
return conn
为什么每请求开短连接而不是全局长连接:FastAPI 同步路由跑在线程池里,多线程共享一个 sqlite 连接要处理线程安全;短连接(用完即 conn.close())简单、无共享状态,SQLite 开连接的开销本来就小。你在仓库每个路由函数里都能看到 conn = get_conn(...) / finally: conn.close() 的固定节奏——忘了 close 是初学者最常见的资源泄漏,仓库统一用 try/finally 保证。
四、WAL 模式:并发读写的钥匙
init_db 里两行:
conn.execute("PRAGMA journal_mode = WAL")
conn.execute("PRAGMA synchronous = NORMAL")
WAL(Write-Ahead Logging)是什么:默认的回滚日志模式下,写事务会阻塞读。WAL 把写入先追加到 -wal 文件,读操作继续读主文件——读写不互斥(写仍串行)。对"一边流式对话一边跑工作流一边刷新后台统计"的场景,这是体验的分水岭。
为什么 synchronous = NORMAL 够安全:WAL 下 NORMAL 表示事务提交不强制 fsync,断电可能丢最后几笔事务但不会损坏数据库。对"用量统计、对话记录"这个级别的数据,性能收益远大于风险。作者注释原话:"WAL 下配套 NORMAL 同步足够安全。"
动手验证(本教程实测):
python -c "import sqlite3; c=sqlite3.connect('data/platform.db'); print(c.execute('PRAGMA journal_mode').fetchone())"
# ('wal',)
五、init_db:幂等建库三步走
def init_db(db_path: str) -> None:
"""建表 + 迁移 + 写入种子数据(幂等)。"""
path = resolve_db_path(db_path)
conn = get_conn(path)
try:
conn.execute("PRAGMA journal_mode = WAL")
conn.execute("PRAGMA synchronous = NORMAL")
conn.executescript(SCHEMA) # ① 全部 CREATE TABLE IF NOT EXISTS
_migrate(conn) # ② 增量迁移:老库补新列
_seed(conn) # ③ 种子数据(只在空表时写入)
conn.commit()
finally:
conn.close()
"幂等"= 跑一遍和跑十遍结果一样。CREATE TABLE IF NOT EXISTS + "只在 COUNT(*)==0 时插种子"保证了这一点——服务每次启动都调 init_db,第二次启动不会重复建数据。
5.1 种子数据为什么这么设计
_seed 在空库上写入:admin 与演示员工 emp001、两个知识库(各含一篇 ingest_text 真实入库的示例文档)、3 个内置助手、2 个内置工作流。三个讲究:
- 开箱即用:README 承诺"首次启动自动建库并写入种子数据……开箱即可体验 RAG 与工作流"。没有种子数据,新用户体验是三张空表。
- 安全兜底:出厂演示口令一律标记
must_change_password=1(首次登录强制改密);运维用环境变量AIP_ADMIN_PASSWORD显式指定的口令才不强制。作者甚至把默认口令写成"admin" + "123"拆开拼接——避免被"硬编码凭据"扫描器误报,注释里明确"这不是上线凭据"。 - 函数内导入防循环依赖:
_seed里from . import auth写在函数体内——db.py与auth.py顶层互相导入会形成循环导入,这是 Python 新手最常见的报错之一。
5.2 增量迁移:_migrate 的朴素做法
def _migrate(conn: sqlite3.Connection) -> None:
"""旧库增量迁移:CREATE TABLE IF NOT EXISTS 覆盖不了的新增列在这里补。"""
tables = {r["name"] for r in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")}
def has_col(pragma: str, col: str) -> bool:
return any(r["name"] == col for r in conn.execute(pragma))
if "users" in tables and not has_col("PRAGMA table_info(users)", "must_change_password"):
conn.execute("ALTER TABLE users ADD COLUMN must_change_password INTEGER NOT NULL DEFAULT 0")
# ... sessions.expires_at、knowledge_bases.departments 同理
这是"最小可用迁移":不引 alembic,靠 PRAGMA table_info 检查列存在与否,缺则 ALTER TABLE ADD COLUMN。老库启动即自动升级。什么时候该上正式迁移框架:多人协作、需要回滚、列变更频繁时。当前规模不值得。
注意那条注释"pragma 是本文件内写死的语句(PRAGMA 不支持参数绑定),无任何外部输入"——连检查语句本身都说明了为什么不存在注入面。这种注释习惯贯穿全仓库。
5.3 老数据也要照顾
_seed 开头还有一段:历史版本的 sessions 行没有过期时间(expires_at=''),启动时统一补一个从现在起的有效期。迁移不仅要管表结构,还要管旧数据的语义——不然老用户的会话会被 05 篇要讲的"过期即删"逻辑误杀。
六、审计日志:record_audit
def record_audit(conn, *, user_id, username, action, detail="", ip="") -> None:
"""写一条安全审计日志。调用方负责 commit(多数场景与业务写在同一事务里)。"""
conn.execute(
"INSERT INTO audit_logs(user_id,username,action,detail,ip,created_at) VALUES(?,?,?,?,?,?)",
(user_id, (username or "")[:64], action, (detail or "")[:500], (ip or "")[:64], now()),
)
两个细节:username 冗余存储(用户被删除后日志仍可溯源);所有字段截断到上限(审计输入不可信,防止超长字符串刷库)。登录成败、注册、改密、知识库/工作流变更、模型设置——全走这一个函数。
七、动手清单
- 把本篇的 SCHEMA 抄进你的
db.py(照仓库补全 15 张表),实现init_db+ 空的_migrate/_seed - 启动服务,用 sqlite3 命令或 DB Browser 确认
data/platform.db里 15 张业务表全部存在(sqlite_master 里还会多一个 SQLite 内部的 sqlite_sequence,是 AUTOINCREMENT 的伴生表,正常现象),且 journal_mode 为 wal - 重启一次服务,确认种子数据没有重复插入(幂等性验证)
- 【进阶】给
users表"加一列"(如email):先启动老版本建库,再在 SCHEMA 与_migrate里同时加列,重启验证老库被自动升级
常见坑:
- database is locked 长期不消失:检查是否有别的进程(DB Browser 没关)持有写锁;确认 busy_timeout 已设置
- 建表后查不到数据:get_conn 后忘了 conn.commit()(get_conn 默认不开自动提交)
- 中文乱码:确认文件以 UTF-8 保存(Windows 上编辑器默认 GBK 是重灾区)
下一篇给 users/sessions 表注入灵魂:pbkdf2 口令哈希、会话生命周期、FastAPI 依赖注入实现的两级权限——以及那个"列表校验了、使用处漏校验"的真实越权教训。