
这次我们来看一个全栈项目里很实际的拐点存储层重构。项目做到中期数据处理早就不是几个 JSON 文件的简单读写了查询、筛选、排序、多用户、批量导入每个需求都在给存储层加压。这个阶段把存储层换掉最平滑的方案就是 SQLite。先给结论。SQLite 不需要单独安装数据库服务不需要配置账号和端口数据就放在单个文件里Python 标准库自带的sqlite3模块可以直接操作。对一个中小型全栈项目来说查询、事务、索引、批量写入这些能力完全够用而且 SQL 语法和 MySQL、PostgreSQL 高度接近后期真要换数据库迁移成本也小。这篇文章会按一条完整路线走先准备环境和数据库文件再设计存储层重构方案接着封装数据访问层把旧 JSON 数据迁进 SQLite最后通过 Web API 把读写能力暴露出来并补上功能测试、性能观察和常见问题排查。适合刚完成全栈入门、正在做自己的完整项目或者想弄清楚“项目里到底该怎么正确引入数据库”的读者。1. 核心能力速览能力项说明项目类型零到全栈系列中的重构实战核心主题用 SQLite 替换原文件/内存存储层数据能力SQL 查询、事务、索引、外键、批量写入部署方式嵌入式数据库无需独立服务进程支持平台Windows / macOS / LinuxWeb API可配合 Flask / FastAPI 等框架对外提供接口批量任务支持executemany批量写入可视化工具DB Browser for SQLite 等适合场景全栈项目的本地持久化、中小型业务数据存储、教程项目从重构的角度看SQLite 在这里承担三个角色。第一统一数据访问入口业务层不再各自读写文件第二提供标准 SQL 能力后续升级数据库时迁移成本可控第三把持久化变成强制行为应用重启后数据不会丢。下面所有章节都会围绕这三个角色展开。也许你会问为什么不直接用 MySQL 或 PostgreSQL答案是看阶段。开发期、原型期、个人项目SQLite 的零运维特点能帮你省掉大量时间真正出现多进程高并发写入瓶颈时再迁移到服务型数据库SQL 基础也能平移。这不是二选一而是一条升级路径。2. 适用场景与使用边界SQLite 适合的场景很明确个人项目、教程项目、公司内部工具、中小型 Web 应用的原型阶段以及单机工具软件。如果你的项目是这几个类型引入 SQLite 基本是零负担的——不需要运维不需要单独部署数据库文件跟着项目走备份就是复制一个文件。不适合的场景也要说清楚。高并发写入场景比如多进程同时频繁写同一个数据库文件SQLite 的串行写模型会成为瓶颈需要细粒度行级权限控制的场景SQLite 没有完整的权限体系数据量到 TB 级或需要分布式部署的场景SQLite 也不合适。这些情况应该直接选 PostgreSQL 或 MySQL。再补充一条安全边界。无论用哪种数据库涉及用户数据、业务数据和敏感资料时都要先确认授权和合规要求。不要把包含真实用户数据的 db 文件提交到公开仓库不要在不确定用途的前提下把敏感信息落到明文数据库里。本文所有代码和配置都建议在测试环境跑通后再考虑应用到生产。3. 环境准备与前置条件3.1 本机 Python 与 sqlite3 检查SQLite 最大的优势是零依赖。Python 3 自带sqlite3模块属于官方标准库不需要pip install任何第三方包。环境准备只需要两步确认本机 Python 版本和sqlite3模块可用以及规划一个清晰的目录结构。先用一段代码确认环境import sys import sqlite3 print(Python 版本:, sys.version) print(SQLite 版本:, sqlite3.sqlite_version)如果有输出版本号说明可以直接进入下一节。如果这里报ModuleNotFoundError说明当前 Python 环境不完整建议重新安装 Python 3不要继续往下走。3.2 图形化管理工具可选命令行不是必须的但可视化工具能让你快速看到表结构和数据。常用的开源工具是 DB Browser for SQLite跨平台支持下载安装后打开数据库文件即可查看表、执行 SQL、导入导出数据。也可以直接使用 PyCharm、VS Code 里的 SQLite 插件。这个工具和 Python 代码互不冲突主要用于调试和数据检查不影响运行逻辑。3.3 项目目录规划重构前先规划好目录避免数据库文件和旧数据、源码混在一起后面找问题会非常痛苦。推荐的结构是这样project/ ├── app.py # Web 入口 ├── db.py # 数据库连接管理本次新增 ├── todo_repo.py # 数据访问层本次新增 ├── init_db.py # 建表脚本本次新增 ├── data/ │ └── app.db # SQLite 数据库文件 ├── old_data/ │ └── data.json # 旧 JSON 数据迁移后归档 └── requirements.txt这是一个通用目录结构实际项目按你自己的模块名调整。关键点是数据库文件、旧数据、源码分开管理这样后续做备份和迁移都方便。4. 安装部署与启动方式4.1 初始化数据库与建表SQLite 的“启动”不像 MySQL 那样要起服务。所谓的“启动”其实就是建立连接并执行建表语句。以下代码会在data目录里生成app.db文件并创建一个todos表# init_db.py import sqlite3 import os db_dir data db_path os.path.join(db_dir, app.db) os.makedirs(db_dir, exist_okTrue) conn sqlite3.connect(db_path) cursor conn.cursor() cursor.execute( CREATE TABLE IF NOT EXISTS todos ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, completed INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL DEFAULT (datetime(now, localtime)) ) ) conn.commit() print(数据库创建成功:, db_path) conn.close()这里用IF NOT EXISTS保证脚本重复执行不会报错。AUTOINCREMENT让 id 自增created_at自动取当前本地时间。实际项目里如果还有用户表、标签表、分类表请按业务字段继续补充建表语句示例的todos表只是用来演示一条完整的重构链路。4.2 命令行验证Python 创建完 db 文件后可以用命令行工具直接查看。macOS 和 Linux 一般自带sqlite3Windows 没有默认命令行可以用图形工具或者在 Python 里执行查询。命令行方式sqlite3 data/app.db .tables sqlite3 data/app.db .schema todos如果本机没有sqlite3命令直接用 Python 验证import sqlite3 conn sqlite3.connect(data/app.db) rows conn.execute( SELECT name FROM sqlite_master WHERE typetable ).fetchall() print(rows) conn.close()看到[(todos,)]这样的输出说明建表成功。5. 存储层重构方案5.1 重构目标假设项目原来的存储层是这样的所有数据保存在一个data.json文件里每个功能模块自己负责读写文件查询、去重、排序都在 Python 里用列表循环完成没有事务概念写文件的时候可能互相覆盖丢失。这是很多全栈教程项目中期阶段的典型状态。这次重构的目标有三条。第一数据统一由数据库管理业务模块通过数据访问层读写不再直接碰文件第二提供标准 SQL 查询能力把筛选、排序、统计交给数据库处理而不是在 Python 里循环第三对外保持接口稳定让上层调用方尽量不感知底层变化减少业务代码改动。5.2 重构步骤整个重构过程可以拆成六步评估现状找出所有读取和写入数据的代码位置列一份清单。设计表结构把原来的对象字段映射成数据库字段明确主键、外键、默认值。编写建表脚本集中管理CREATE TABLE不散落在业务代码里。实现数据访问层提供create / read / update / delete函数。迁移旧数据把 JSON 批量导入数据库并验证数量一致。替换调用点让业务模块走 repo 层函数删除原来的文件读写逻辑。这个过程的要点是“接口不变、底层替换”。上层调用方还是调用list_todos()但函数内部的实现从读 JSON 变成了查数据库改动影响面小风险也可控。5.3 数据访问层职责划分引入数据库后最怕出现另一种乱SQL 满天飞。路由层写 SQL业务逻辑写 SQL模板渲染前也要拼 SQL这比文件存储还难维护。所以推荐把数据库操作集中到一个 repo 文件里Web 层和业务层不直接写 SQL。推荐的文件职责db.py负责连接管理、事务和通用操作todo_repo.py负责todos表的 CRUD只暴露业务函数。这个分层在后续换 ORM比如 SQLAlchemy时影响面也小。6. 数据访问层封装6.1 连接管理与事务先写一个db.py用上下文管理器统一管理连接。这样做的好处是函数退出时自动 commit出错自动 rollback而且连接一定被关闭不会出现句柄泄漏也不会出现“数据看着写进去了重启后丢失”的问题。# db.py import sqlite3 from contextlib import contextmanager DB_PATH data/app.db contextmanager def get_conn(): conn sqlite3.connect(DB_PATH) conn.row_factory sqlite3.Row conn.execute(PRAGMA foreign_keys ON) try: yield conn conn.commit() except Exception: conn.rollback() raise finally: conn.close()三个细节值得说明。row_factory sqlite3.Row让查询结果可以按字段名访问转 dict 更方便PRAGMA foreign_keys ON开启外键约束SQLite 默认不启用需要每次连接设置上下文管理器在with块正常结束后 commit异常后 rollback确保事务边界清晰。6.2 CRUD 函数实现接着写todo_repo.py把插入、查询、更新、删除都封装成函数。注意所有 SQL 都使用?占位符传参而不是字符串拼接这是防止 SQL 注入的基本要求。业务层输入的内容即使包含引号、分号也不会被当成 SQL 执行。# todo_repo.py from db import get_conn def create_todo(title: str) - int: 新增一条待办返回自增 id with get_conn() as conn: cursor conn.execute( INSERT INTO todos (title) VALUES (?), (title,) ) return cursor.lastrowid def list_todos(): 查询所有待办按 id 倒序 with get_conn() as conn: rows conn.execute( SELECT id, title, completed, created_at FROM todos ORDER BY id DESC ).fetchall() return [dict(row) for row in rows] def update_todo(todo_id: int, title: str None, completed: bool None): 按 id 更新待办title 和 completed 可传一个或两个 with get_conn() as conn: if title is not None: conn.execute( UPDATE todos SET title ? WHERE id ?, (title, todo_id) ) if completed is not None: conn.execute( UPDATE todos SET completed ? WHERE id ?, (1 if completed else 0, todo_id) ) def delete_todo(todo_id: int): 按 id 删除待办 with get_conn() as conn: conn.execute(DELETE FROM todos WHERE id ?, (todo_id,)) def count_todos() - int: 统计待办总数 with get_conn() as conn: row conn.execute( SELECT COUNT(*) AS total FROM todos ).fetchone() return row[total]update_todo的设计很实用两个参数都可选前端传哪个就更新哪个避免一次更新把所有字段全查出来再写回去。count_todos在迁移验证时会用到。7. 旧数据迁移与批量导入7.1 JSON 数据读取重构过程中旧数据不能丢。假设旧数据在old_data/data.json结构大概是这样的数组[ {title: 学习 SQLite, completed: 0}, {title: 重构存储层, completed: 1} ]迁移脚本要做的三件事读取 JSON、写入数据库、验证数量。这一步同时也是全栈项目里最常见的批量导入任务逻辑不复杂但很容易在编码和重复执行上出错。7.2 批量插入批量插入尽量用executemany不要用for循环逐条execute。executemany会把多条 INSERT 一次性绑定写入速度比逐个提交快很多尤其数据量到几千条以上时差异非常明显。# migrate.py import json import sqlite3 JSON_PATH old_data/data.json DB_PATH data/app.db def migrate_json_to_sqlite(json_path: str JSON_PATH, db_path: str DB_PATH): with open(json_path, r, encodingutf-8) as f: old_data json.load(f) conn sqlite3.connect(db_path) cursor conn.cursor() # 建表与 init_db.py 保持一致 cursor.execute( CREATE TABLE IF NOT EXISTS todos ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, completed INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL DEFAULT (datetime(now, localtime)) ) ) rows [ (item[title], int(item.get(completed, 0))) for item in old_data ] cursor.executemany( INSERT INTO todos (title, completed) VALUES (?, ?), rows ) conn.commit() count cursor.execute( SELECT COUNT(*) FROM todos ).fetchone()[0] print(f迁移完成todos 表当前共 {count} 条数据) conn.close() if __name__ __main__: migrate_json_to_sqlite()如果源 JSON 字段名和示例不一致需要把映射逻辑换成自己项目的字段名。执行迁移前先确认数据库里没有重复数据如果可能重复执行可以使用INSERT OR IGNORE并配合唯一索引具体去重策略根据业务决定。7.3 迁移验证与验证结果迁移后不要急着删旧文件先验证。通过count_todos()查总数和旧数据条数是否一致再随机抽查几条记录对比 title 和 completed 字段然后让应用以新存储层启动确认页面能正常显示数据最后把旧 JSON 文件改名归档保留一段时间再删除。from todo_repo import count_todos, list_todos print(总数:, count_todos()) for item in list_todos()[:5]: print(item[id], item[title], item[completed])只有确认这些结果都正常才可以把原来的文件读写逻辑删掉。保留旧文件不是胆小而是给回滚留一条退路。8. Web API 衔接示例8.1 Flask 接口改造如果全栈项目有 Web 后端接下来就是把原来的路由处理函数从“直接读写文件”改成“调用 repo 层函数”。这里以 Flask 为例FastAPI 的用法类似把装饰器和请求对象换成 FastAPI 风格即可。核心变化是路由函数不再关心数据存在哪里只调用todo_repo里的函数。# app.py from flask import Flask, request, jsonify from todo_repo import create_todo, list_todos from todo_repo import update_todo, delete_todo app Flask(__name__) app.route(/api/todos, methods[GET]) def get_todos(): return jsonify(list_todos()) app.route(/api/todos, methods[POST]) def add_todo(): data request.get_json(forceTrue) title (data.get(title) or ).strip() if not title: return jsonify({error: title 不能为空}), 400 todo_id create_todo(title) return jsonify({id: todo_id, title: title}), 201 app.route(/api/todos/int:todo_id, methods[PUT]) def edit_todo(todo_id): data request.get_json(forceTrue) update_todo( todo_id, titledata.get(title), completeddata.get(completed) ) return jsonify({ok: True}) app.route(/api/todos/int:todo_id, methods[DELETE]) def remove_todo(todo_id): delete_todo(todo_id) return jsonify({ok: True}) if __name__ __main__: app.run(host127.0.0.1, port5000, debugTrue)这段代码里可以看到路由层只做 HTTP 参数解析和返回值格式化所有数据操作用一行函数调用完成。后续即使把 SQLite 换成 PostgreSQL也只需要替换todo_repo的实现路由层几乎不用动。8.2 curl 与 Python 请求验证启动 Flask 服务后可以用 curl 直接测接口。先启动服务python app.py然后打开另一个终端执行# 查询列表 curl http://127.0.0.1:5000/api/todos # 新增一条 curl -X POST http://127.0.0.1:5000/api/todos \ -H Content-Type: application/json \ -d {title: 测试 SQLite 接口} # 更新 curl -X PUT http://127.0.0.1:5000/api/todos/1 \ -H Content-Type: application/json \ -d {completed: true} # 删除 curl -X DELETE http://127.0.0.1:5000/api/todos/1也可以用 Python requests 做一次完整链路测试import requests base http://127.0.0.1:5000/api/todos # 新增 r requests.post(base, json{title: 用 requests 测试}) print(POST, r.status_code, r.json()) # 查询 r requests.get(base) print(GET, r.status_code, len(r.json())) # 更新 todo_id r.json()[0][id] r requests.put(f{base}/{todo_id}, json{completed: True}) print(PUT, r.status_code, r.json()) # 删除 r requests.delete(f{base}/{todo_id}) print(DELETE, r.status_code, r.json())接口能跑通后面就可以把前端页面指向这些 API或者把批量数据导入、定时任务都接到这个 repo 层上。9. 功能测试与效果验证9.1 测试用例清单重构之后必须验证的不只是“接口能通”而是“数据链路全流程正确”。建议至少覆盖下面这些用例测试项操作预期结果建表执行init_db.pydata/app.db生成todos表存在新增调用create_todo返回自增 id数据库中多一条记录查询调用list_todos返回列表字段完整更新修改 title / completed对应记录字段发生变化删除删除一条记录记录消失其他记录不受影响持久化重启应用后再查询重启前写入的数据仍然存在事务回滚故意在事务中抛异常已执行的 INSERT 被回滚批量导入导入旧 JSON数据条数一致字段无误如果项目里有多张表还需要额外验证联表查询。SQLite 支持INNER JOIN和LEFT JOIN一旦涉及联表要确认关联字段的索引以及外键约束是否生效。可以用EXPLAIN QUERY PLAN查看执行计划确认是否走了索引避免全表扫描拖慢接口。sqlite3 data/app.db \ EXPLAIN QUERY PLAN SELECT * FROM todos t LEFT JOIN tags tg ON t.tag_id tg.id;这段 SQL 只是演示语句实际字段以你的表结构为准。9.2 持久化与事务验证持久化验证很关键因为这是从文件存储换成数据库最核心的收益之一。操作方法是调用create_todo写入一条数据重启 Web 服务或重新执行脚本再查询数据是否还在。from todo_repo import create_todo, list_todos create_todo(重启后这条数据应该还在) for item in list_todos(): print(item[id], item[title], item[completed])事务回滚验证import sqlite3 conn sqlite3.connect(data/app.db) try: conn.execute(INSERT INTO todos (title) VALUES (事务测试)) raise RuntimeError(手动抛错) except RuntimeError: conn.rollback() print(已回滚) count conn.execute( SELECT COUNT(*) FROM todos WHERE title事务测试 ).fetchone()[0] print(事务测试数据存在条数:, count) conn.close()如果事务回滚生效最后的 count 应该是 0。生产环境不要用这种裸连接写法这里只是为了把事务语义演示清楚。9.3 判断重构是否成功一个很实用的判断标准把旧存储层代码删掉或注释掉整个项目还能正常工作。如果你的业务代码仍然在直接读data.json说明重构还没改干净。可以全局搜索open、json.load、json.dump这些关键字逐一确认是否都替换成 repo 层调用。另一个判断标准是数据一致性。在测试环境里模拟一次崩溃或异常退出再重启应用看看已提交的数据是否完整、未提交的脏数据是否被回滚。这个测试通过重构的核心目标就达成了。10. 资源占用与性能观察10.1 数据库文件大小SQLite 数据都保存在单个 db 文件里文件大小能直观反映数据量。不同平台查看方式不同用 Python 打印最通用import os size os.path.getsize(data/app.db) print(fapp.db 大小: {size} bytes {size / 1024:.1f} KB)实际大小取决于数据量和是否执行过VACUUM。删除大量数据后文件不会自动缩小可以定期执行VACUUM;回收空间。10.2 并发与 WAL 模式SQLite 默认的 journal 模式在并发读写时可能出现database is locked。如果你的 Web 服务有多个请求同时写库建议开启 WAL 模式conn.execute(PRAGMA journal_mode WAL)WAL 模式下读操作和写操作可以并发执行对中小型 Web 应用非常友好。这个设置不是只执行一次而是每次连接都要确认可以放到db.py的get_conn里。10.3 批量写入性能对比批量写入是常见的性能瓶颈。一个简单的观察方法是插入 1000 条数据分别用单条循环和executemany测试import sqlite3 import time conn sqlite3.connect(data/app.db) cursor conn.cursor() # 方案一逐条插入包在同一个事务里 start time.time() conn.execute(BEGIN) for i in range(1000): cursor.execute( INSERT INTO todos (title) VALUES (?), (f任务{i},) ) conn.commit() print(逐条插入 1000 条耗时:, time.time() - start) # 方案二executemany 批量插入 start time.time() cursor.executemany( INSERT INTO todos (title) VALUES (?), [(f批量任务{i},) for i in range(1000)] ) conn.commit() print(executemany 插入 1000 条耗时:, time.time() - start) conn.close()注意上面代码里先BEGIN再逐条插入是为了把多条 INSERT 包在一个事务里如果每执行一条就 commit 一次性能会差得更多。具体数字会因机器和磁盘不同而不同建议你自己跑一次重点不是记一个绝对数字而是掌握这个观察方法后续在真实项目里做批量任务时知道怎么评估性能。10.4 内存与 CPU 观察全栈项目运行期的内存和 CPU 数据和你的 Web 框架、业务代码关系更大。SQLite 本身是文件 IO 密集型不是常驻进程所以它不像 MySQL 那样独占内存。要观察项目整体资源占用可以用系统自带工具Windows 的任务管理器macOS 的活动监视器Linux 的top或ps。重点看服务进程有没有异常增长、有没有大量连接积压。11. 常见问题与排查方法这一节把重构成 SQLite 后最常遇到的问题整理成表适合收藏备用。问题现象可能原因排查方式解决方案查询报 no such table建表脚本未执行或表名/字段名不一致打印sqlite_master查看表列表先执行建表脚本确认表名大小写报 database is locked多进程或多线程同时写一个 db 文件检查是否有进程持有连接开启 WAL设置timeout5避免长事务插入数据后查询不到没有 commit检查代码中是否调用conn.commit()使用上下文管理器统一提交/回滚中文数据乱码读写文件编码不一致检查 JSON 文件编码和终端编码读写时指定encodingutf-8db 文件被占用无法删除有连接未关闭检查进程是否存活关闭所有连接结束残留进程外键约束不生效SQLite 默认关闭外键打印PRAGMA foreign_keys每次连接执行PRAGMA foreign_keys ON迁移导入数据重复重复执行迁移脚本查询表中总条数导入前清空表或加唯一索引配合INSERT OR IGNORE文件路径找不到工作目录不对打印os.getcwd()和 db 路径使用绝对路径或统一从项目根目录启动针对最常遇到的两个问题再展开一下。database is locked。SQLite 对写入是串行的多个连接同时写同一个文件会锁库。短期的解决方案是设置sqlite3.connect(db_path, timeout5)让写操作等待锁长期方案是切换 WAL 模式并把连接放在短事务里不要在事务中做耗时的网络请求。如果你的服务是多进程模型还要确认每个进程用的是同一个 db 文件路径避免出现多个拷贝文件互相看不见数据的问题。no such table。很多人第一次跑会漏掉初始化脚本。建议把建表逻辑独立成一个init_db.py部署和测试时先执行它。否则业务代码访问时数据库文件是新的空文件里面根本没有表。这个错误还有一个变种表名和字段名大小写不一致。SQLite 的表名和字段名在某些情况下区分大小写最稳妥的做法是建表时统一小写代码里也一律用小写。12. 最佳实践与使用建议12.1 项目工程化建议把这几个习惯固定下来项目会少很多隐蔽的 bug。第一所有 SQL 都走参数化查询。业务层输入的字符串永远不要用fSELECT ... WHERE name{value}这种写法去拼接这是最基本的防注入手段也是全栈项目的合规底线。第二连接统一管理。写一个get_conn上下文管理器所有函数都用with get_conn() as conn包裹commit、rollback、close 全部自动处理。不要在多个函数里各写一遍sqlite3.connect那样会漏掉关闭导致文件被占用。第三建表语句集中管理。不要散落在业务代码里数据库 schema 变化时应该走显式的迁移脚本而不是在某个请求里顺手改表。第四重要表加索引。比如按 completed 筛选、按 created_at 排序的场景建索引后查询差距明显CREATE INDEX idx_todos_completed ON todos(completed); CREATE INDEX idx_todos_created_at ON todos(created_at);12.2 数据与合规建议使用 SQLite 存储真实业务数据时要养成三个习惯。定期备份。SQLite 备份最简单的方式是复制 db 文件也可以使用sqlite3的在线备份 API不要把包含真实用户数据的数据库文件提交到公开仓库开发环境用一个app-dev.db生产环境用另一个不受版本控制的 db 文件涉及敏感数据时不管用哪种数据库都必须先确认授权范围做好访问控制和定时清理。12.3 性能与维护习惯数据量增长后建议定期执行VACUUM; ANALYZE;VACUUM可以回收文件空间ANALYZE更新查询优化器使用的统计信息。这两个命令在数据量大、频繁增删的场景下更值得重视。另外SQLite 的备份、导出、导入都可以用sqlite3命令完成遇到数据问题时多一个处理工具总是好的。13. 总结与下一步这次重构的核心思路可以归纳成一句话业务层尽量不碰 SQL 和文件所有数据操作都收口到数据访问层。你最先应该验证的是三个点建表脚本能不能稳定执行、CRUD 函数能不能跑通、重启进程后数据还在不在。这三个点确认了重构就等于完成了一大半。最容易踩的坑也有三个一是建表脚本没有执行就查表报no such table二是连接没有 commit数据看着写进去了但重启消失三是多进程写库没有开启 WAL偶发database is locked。下一步可以继续做三件事把 repo 层换成 SQLAlchemy提前把 ORM 接入路径铺好引入迁移工具 Alembic让表结构变更可以回滚和追溯在集成测试里把所有 CRUD 和事务用例自动化防止后续改版回退。SQLite 不是一个“临时凑合”的方案。很多轻量级全栈项目、桌面工具、嵌入式应用生产环境也长期跑在 SQLite 上。这次重构把存储层理顺之后后续不管是继续加功能、加接口还是换更重的数据库路径都会比现在清楚得多。建议把上面的初始化脚本、repo 层代码和测试用例保存成模板下一个项目直接复用。