你有没有想过,预算类应用其实掌握着你最敏感的一类数据:每一笔消费、收入、账单日、余额趋势。传统预算 App 为了“自动记账”,往往需要一键连接银行账户,换取的是实时的交易流水,代价则是银行凭证或聚合支付令牌被集中托管在一个第三方平台上。网络里任何一个环节被拖库、被内部泄露、被越权调用,你的账户收支全貌都会暴露。
“A budgeting app that can't touch your bank. Privacy by design” 这个项目标题,核心思路非常明确:做一款预算应用,但它在架构上根本不碰银行,也不拿你的交易流水,所有数据默认留在你本机。本文会围绕这个理念,拆解隐私优先设计如何落到预算管理应用里,并带你实现一个带加密备份能力的本地预算工具。全文既讲设计原则,也讲代码实现,适合想入门隐私设计、想自己做本地工具,或者正在评估预算应用技术方案的后端开发者。
1. 为什么“不连接银行”反而更实用
1.1 传统预算应用的数据链路
一个常见的联网预算产品,数据链路通常是这样:
- 用户在 App 里输入网银账号或选择银行。
- App 通过第三方聚合服务(往往基于开放银行接口或模拟登录)换取访问令牌。
- 聚合服务定期拉取交易流水,同步到产品服务器。
- 产品服务器做清洗、分类、打标签,再返回给用户终端展示。
这个链路可以做得很顺滑,但也带来几个无法回避的问题:
- 银行凭据或令牌被保存在第三方平台,一旦泄露,攻击者可能读取用户完整的收支流水。
- 交易明细会长期留存于服务端,即使用户删除 App,数据可能仍保留在备份系统里。
- 用户必须信任“服务端只看数据、不滥用数据”,这种信任很难被审计。
- 如果未来平台被收购、倒闭或改变隐私政策,用户数据归属会变得非常不确定。
1.2 “触不到银行”到底指什么
“can't touch your bank” 的意思,不是“技术上不能做银行集成”,而是设计上主动放弃银行连接能力。应用拿不到银行账号、余额、流水,也就不存在“银行数据泄露”这条攻击面。
这带来几个直接结果:
- 没有账号体系,不需要手机号、邮箱注册。
- 没有银行令牌,即使数据库文件丢失,也不会牵连真实账户。
- 数据录入靠用户手动登记,或从用户自己导出的 CSV 文件中导入。
- 应用的信任模型从“信任平台不滥用”变成“数据只在你手里,平台无数据可拿”。
1.3 适合什么场景
这种设计并不适合所有人,但非常适合下面这些场景:
| 使用场景 | 为什么适合 |
|---|---|
| 个人预算管理、家庭记账 | 数据敏感度低,无需实时余额,手动记账完全够用 |
| 注重隐私的开发者 | 自己掌控数据库文件、加密备份、迁移方式 |
| 离线环境使用 | 不依赖网络,断网也能记账 |
| 教学与二次开发 | 数据流简单,适合作为隐私设计示例项目 |
如果你需要“自动抓取银行流水”的省心体验,那这个方案不适合;如果你更在意数据自主权,那“不碰银行”反而是一个更有价值的产品特性。
2. Privacy by Design 如何落到预算应用
2.1 隐私设计不是加一个“隐私模式”
很多产品把隐私视为一个开关:允许匿名浏览、不保存历史、可清除记录。但 Privacy by Design(隐私由设计而来)强调的是一开始就把隐私嵌入架构,而不是事后打补丁。
这里有七个核心原则,可以一一映射到预算应用的设计上:
| 原则 | 应用落地方式 |
|---|---|
| 主动预防,而非事后补救 | 不在服务端保留账单数据,从源头避免泄露 |
| 默认隐私 | 默认无账号、无追踪、无分析 SDK |
| 隐私内嵌于设计 | 数据库、备份、日志都按最小化原则设计 |
| 完整功能 | 不牺牲预算统计能力,同样支持分类、报表、导入导出 |
| 端到端安全 | 备份文件加密,传输过程加密,数据库文件可加密 |
| 可见性与透明 | 开源代码、日志可见、数据存储位置清晰 |
| 尊重用户隐私 | 用户可随时导出全部数据并彻底删除应用文件 |
2.2 预算应用的数据最小化
预算应用真正需要的核心数据是什么?其实只有三类:
- 交易记录:金额、日期、分类、备注。
- 预算规则:每个分类的月度限额。
- 用户设置:币种、起始结算日、偏好主题。
它不需要你的真实姓名、银行账号、手机号、GPS 位置、设备标识符。做隐私设计时,第一步就是砍掉这些非必需字段。
数据最小化不仅是道德选择,也能降低开发成本:不保存不需要的字段,就不需要设计对应的接口、校验逻辑、日志和备份方案。
2.3 明确信任边界
隐私设计最重要的产出是一张“信任边界图”。本文实现的预算应用信任边界如下:
- 用户本机:保存数据库明文,由用户自行保护。
- 用户备份文件:使用用户密码派生密钥加密,可以放到网盘或移动硬盘,但应用本身不主动上传。
- 其他任何系统:完全不可见。
应用代码可以开源,让任何人审计“确实没有网络上传逻辑”。这比“我们承诺不会上传”更有说服力。
3. 技术架构与项目结构
3.1 Local-first 数据流向
应用采用 Local-first(本地优先)架构,不设置服务端。数据流向是:
用户手动记账 / CSV 导入 ↓ 本地 SQLite 数据库 ↓ 预算统计与报表展示 ↓ 导出加密备份(AES-GCM)整个流程不涉及外部 API,应用启动时也不会发起任何网络请求。数据库文件、配置文件和日志都只存放在用户指定目录中。
如果你后续想加“多设备同步”,可以做成“用户手动导入加密备份”,而不是自动同步到云端。这样依然保持“平台碰不到明文数据”的信任边界。
3.2 技术选型
为了便于复现,本文使用 Python 3.11 编写示例,第三方依赖尽量少:
| 组件 | 选型 | 说明 |
|---|---|---|
| 语言 | Python 3.11+ | 标准库丰富,适合快速实现 |
| 数据库 | SQLite(内置 sqlite3) | 单文件数据库,无需安装服务 |
| 交互方式 | 命令行菜单 | 减少界面代码,突出核心逻辑 |
| 加密备份 | cryptography 库 | 提供 Fernet/AES-GCM 能力,唯一第三方依赖 |
| 测试工具 | 无 | 直接通过命令行验证 |
如果你的环境没有 cryptography,可以执行安装命令:
pip install cryptography如果你希望连这个第三方依赖也不要,可以只做不加密的 JSON/CSV 导出,但这样备份文件就不具备保密性。下文会同时给出两个版本思路。
3.3 项目文件结构
privacy_budget/ ├── budget.py # 主程序,命令行入口 ├── db.py # 数据库初始化与核心操作 ├── crypto_utils.py # 加密备份与恢复 ├── data/ # 数据库文件目录(自动创建) │ └── budget.db └── backup/ # 备份导出目录(自动创建)代码拆分为三个模块,目的是让“数据访问”和“加密备份”互相独立:哪怕将来换 Web 界面,db.py 和 crypto_utils.py 都可以直接复用。
4. 核心功能实现
4.1 创建数据库表结构
预算应用至少需要两类表:交易记录表 transactions 和预算规则表 budgets。
打开db.py,写入初始化逻辑:
# 文件路径:db.py import sqlite3 from pathlib import Path BASE_DIR = Path(__file__).resolve().parent DATA_DIR = BASE_DIR / "data" BACKUP_DIR = BASE_DIR / "backup" DB_PATH = DATA_DIR / "budget.db" def get_connection(): """获取数据库连接,并开启外键约束""" conn = sqlite3.connect(DB_PATH) conn.row_factory = sqlite3.Row conn.execute("PRAGMA foreign_keys = ON") return conn def init_db(): """初始化数据库表结构""" DATA_DIR.mkdir(exist_ok=True) BACKUP_DIR.mkdir(exist_ok=True) conn = get_connection() conn.executescript( """ CREATE TABLE IF NOT EXISTS transactions ( id INTEGER PRIMARY KEY AUTOINCREMENT, tx_date TEXT NOT NULL, category TEXT NOT NULL, amount REAL NOT NULL, note TEXT DEFAULT '', created_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS budgets ( id INTEGER PRIMARY KEY AUTOINCREMENT, category TEXT NOT NULL UNIQUE, monthly_limit REAL NOT NULL ); CREATE INDEX IF NOT EXISTS idx_transactions_date ON transactions(tx_date); """ ) conn.commit() conn.close()这里有几个设计细节值得注意:
tx_date使用 TEXT 类型,保存YYYY-MM-DD格式,方便排序和比较。amount使用 REAL 类型,对个人预算应用够用;实际金融系统则建议用整数分或 DECIMAL。created_at记录数据写入时间,便于审计。- 对所有交易记录按日期建索引,避免后续按月统计时全表扫描。
4.2 添加交易与预算规则
预算应用录入过程并不复杂:选择类型、填金额、选分类、写备注。代码放在db.py:
# 文件路径:db.py(追加) def add_transaction(tx_date: str, category: str, amount: float, note: str = ""): """添加一条交易记录""" conn = get_connection() conn.execute( """ INSERT INTO transactions (tx_date, category, amount, note) VALUES (?, ?, ?, ?) """, (tx_date, category, amount, note), ) conn.commit() conn.close() def set_budget(category: str, monthly_limit: float): """设置或更新某个分类的月度预算""" conn = get_connection() conn.execute( """ INSERT INTO budgets (category, monthly_limit) VALUES (?, ?) ON CONFLICT(category) DO UPDATE SET monthly_limit = excluded.monthly_limit """, (category, monthly_limit), ) conn.commit() conn.close()关于写入操作的提示:本文示例是个人本地应用,所以直接用 commit 提交。如果你打算改成多用户服务端程序,绝对不能在 SQL 里直接拼接用户输入,应该像上面这样使用参数化查询?占位符,避免 SQL 注入。
4.3 月度预算统计
统计逻辑是预算应用的核心。下面这段代码会计算某个月份里每个分类的总支出,再与预算表对比:
# 文件路径:db.py(追加) def monthly_report(year_month: str): """ 按月统计支出,并和预算对比。 year_month 格式:YYYY-MM,例如 2025-06 """ conn = get_connection() # 该月各分类支出 rows = conn.execute( """ SELECT t.category, SUM(t.amount) AS total_spent FROM transactions t WHERE t.tx_date LIKE ? GROUP BY t.category """, (year_month + "%",), ).fetchall() budgets = { row["category"]: row["monthly_limit"] for row in conn.execute("SELECT category, monthly_limit FROM budgets") } conn.close() print(f"--- {year_month} 月度支出报告 ---") print(f"{'分类':<12}{'支出':>10}{'预算':>10}{'剩余':>10}") for row in rows: category = row["category"] spent = row["total_spent"] limit = budgets.get(category, 0) remaining = limit - spent print(f"{category:<12}{spent:>10.2f}{limit:>10.2f}{remaining:>10.2f}")这个统计方式简单直白,LIKE '2025-06%'能够匹配该月份所有日期。当数据量变大后,可以改成WHERE t.tx_date >= ? AND t.tx_date < ?的区间查询,以便命中索引。
4.4 加密备份与恢复
既然应用不把数据上传到云端,备份就完全依赖用户自己。如果备份文件只是纯 CSV,落在移动硬盘或网盘里依然是明文。这里利用cryptography库做加密压缩包,让备份文件在“静态存储”状态下也安全。
# 文件路径:crypto_utils.py import base64 import hashlib import json import zipfile from pathlib import Path from cryptography.fernet import Fernet def _derive_key(password: str, salt: bytes) -> bytes: """使用 PBKDF2 从用户口令派生 Fernet 密钥""" kdf = hashlib.pbkdf2_hmac( "sha256", password.encode("utf-8"), salt, 200_000, ) return base64.urlsafe_b64encode(kdf) def export_encrypted_backup( db_path: Path, backup_path: Path, password: str ) -> None: """把 SQLite 数据库导出为加密压缩包""" salt = hashlib.sha256(str(backup_path).encode()).digest()[:16] key = _derive_key(password, salt) fernet = Fernet(key) raw_bytes = Path(db_path).read_bytes() encrypted = fernet.encrypt(raw_bytes) with zipfile.ZipFile(backup_path, "w", zipfile.ZIP_DEFLATED) as zf: zf.writestr("budget.db.enc", encrypted) zf.writestr("salt.hex", salt.hex()) zf.writestr("meta.json", json.dumps({ "app": "privacy_budget", "version": 1, "export_time": Path(db_path).stat().st_mtime })) def import_encrypted_backup( backup_path: Path, db_path: Path, password: str ) -> None: """从加密压缩包恢复数据库""" with zipfile.ZipFile(backup_path, "r") as zf: salt_hex = zf.read("salt.hex").decode() encrypted = zf.read("budget.db.enc") salt = bytes.fromhex(salt_hex) key = _derive_key(password, salt) fernet = Fernet(key) try: raw_bytes = fernet.decrypt(encrypted) except Exception: raise ValueError("密码错误或备份文件已被修改") db_path.write_bytes(raw_bytes)实现细节:
- 导出时保存一个随机 salt,导入时从压缩包中读出 salt,再生成相同密钥。
- 密钥不落盘,只存在内存里,整个过程完成后进程退出即消失。
- 如果用户忘记密码,没有任何找回入口,这一点必须在界面文案中明确提示。
- 恢复前建议先对当前数据库再做一次备份,避免覆盖后反悔。
4.5 命令行入口
把以上模块串起来,形成主程序budget.py:
# 文件路径:budget.py import sys from pathlib import Path import crypto_utils import db def main(): db.init_db() while True: print("\n=== 隐私预算管理 ===") print("1. 添加交易") print("2. 设置月度预算") print("3. 查看月度报告") print("4. 导出加密备份") print("5. 从备份恢复") print("6. 退出") choice = input("请选择操作:").strip() if choice == "1": tx_date = input("日期(YYYY-MM-DD):").strip() category = input("分类(如 餐饮):").strip() amount = float(input("金额(支出为负数):").strip()) note = input("备注(可留空):").strip() db.add_transaction(tx_date, category, amount, note) print("交易已添加。") elif choice == "2": category = input("分类:").strip() limit = float(input("月度预算:").strip()) db.set_budget(category, limit) print("预算已设置。") elif choice == "3": year_month = input("月份(YYYY-MM):").strip() db.monthly_report(year_month) elif choice == "4": password = input("备份密码:").strip() backup_file = db.BACKUP_DIR / "budget_backup.zip" crypto_utils.export_encrypted_backup( db.DB_PATH, backup_file, password ) print(f"备份已导出:{backup_file}") elif choice == "5": backup_file = input("备份文件路径:").strip() password = input("备份密码:").strip() crypto_utils.import_encrypted_backup( Path(backup_file), db.DB_PATH, password ) print("恢复完成,请重启程序后查看。") elif choice == "6": print("退出程序。") sys.exit(0) else: print("无效选择。") if __name__ == "__main__": main()到这里,一个具备基本记账、预算对比、加密备份恢复的本地预算应用已经完成。
5. 运行演示与验证
5.1 初始化并添加交易
在终端执行:
python budget.py程序会自动创建data/budget.db,然后进入菜单。添加两条测试数据:
日期(YYYY-MM-DD):2025-06-01 分类(如 餐饮):餐饮 金额(支出为负数):-36.50 备注(可留空):午餐 日期(YYYY-MM-DD):2025-06-03 分类(如 餐饮):交通 金额(支出为负数):-8.00 备注(可留空):地铁5.2 设置预算并查看报告
设置预算:
分类:餐饮 月度预算:1200.00查看月度报告:
月份(YYYY-MM):2025-06预期输出:
--- 2025-06 月度支出报告 --- 分类 支出 预算 剩余 餐饮 36.50 1200.00 1163.50 交通 8.00 0.00 -8.00这里要注意,未设置预算的分类,剩余值会按“0 减支出”计算,所以是负数。如果需要更友好的显示,可以把这类“未设置预算”的分类单独列一行,不计算剩余,这部分可以作为扩展优化点。
5.3 备份与恢复验证
在菜单中选择 4,输入备份密码,例如:
备份密码:P@ssw0rd-2025程序会生成backup/budget_backup.zip。这个压缩包里的budget.db.enc是加密后的数据库原文,直接打开只会看到密文。
接着模拟数据损坏,删除data/budget.db:
rm data/budget.db重新运行程序,选择菜单 5,输入刚才的备份文件路径与密码。恢复后,数据重新出现在本机。如果输入错误密码,程序会抛出密码错误或备份文件已被修改,不会覆盖数据库。
6. 常见问题与排查思路
6.1 常见问题速查表
| 问题现象 | 常见原因 | 解决思路 |
|---|---|---|
| 导入备份时提示密码错误 | 密码输入不一致,或备份文件被篡改 | 先确认密码是否一致;再检查备份文件是否被外部工具修改 |
| 恢复后数据显示不完整 | 备份本身是在旧版本数据库上导出的 | 检查导出时间,尽量用相同程序版本恢复 |
| CSV 中文乱码 | 修改了 CSV 文件编码 | 统一使用 UTF-8 编码读取 |
| 数据库文件无法打开 | 程序运行中断导致 SQLite 文件写入不完整 | 停止程序,用sqlite3命令行执行PRAGMA integrity_check |
| 忘记备份密码 | 密钥由密码派生,无法找回 | 无解,只能提醒用户用密码管理器保存备份密码 |
6.2 备份密码忘记后怎么办
这是本地加密方案最关键的边界:密码一旦丢失,加密备份就无法恢复。没有“手机号找回”“邮箱验证”这类后门,因为在设计时就没有服务端可以干预。实际使用时建议:
- 把备份密码保存在密码管理器里。
- 不要在备份文件同目录下保存密码文本文件。
- 定期导出新备份,并验证一次恢复流程。
6.3 程序崩溃导致数据损坏怎么办
SQLite 本身对崩溃处理相对稳健,但如果在写入过程中强制断电,仍可能出现数据库损坏。针对个人应用,建议:
- 每次写入都使用事务,前面代码已经通过 commit 完成了这一点。
- 周期性执行
PRAGMA integrity_check检查完整性。 - 不要直接对数据库文件做热拷贝,推荐使用本项目的加密备份机制。
7. 隐私设计的工程建议
7.1 日志零敏感
很多隐私事故不是数据库泄露,而是日志泄露。开发时要注意:
- 不打记录金明细的业务日志。
- 不打包含密码或数据库路径的调试日志。
- 异常堆栈中如果包含 SQL,需要脱敏后再输出。
- 日志按天滚动,并设置保留周期。
本项目里没有引入 logging 配置,属于“最小日志”特例。如果你要扩展成更完整的应用,建议用logging模块并默认只记录 WARNING 以上级别。
7.2 崩溃上报不收集账单数据
一些桌面应用为了排查崩溃,会自动收集日志上报。对于隐私优先的预算应用,我不建议默认开启崩溃上报;即使要上报,也要满足三点:
- 上报内容完全不包含交易金额、分类、日期字段。
- 用户首次启动时显式询问,而不是藏在设置里默认开启。
- 上报通道使用 HTTPS,并且在隐私政策中写清楚收集范围。
7.3 安全升级与备份迁移
隐私设计不是“永远不上传数据”,而是“用户有清晰的导出能力和迁移能力”。建议做三件事:
- 每次版本升级前自动在本地生成一份加密快照。
- 数据库表结构变更时使用 SQLite 的迁移脚本,而不是直接删表重建。
- 提供“导出明文 CSV”功能,方便用户迁移到其他工具。
7.4 对代码做可审计改造
如果是开源项目,建议把所有网络调用集中到一个模块,并在 README 里声明“默认构建不包含任何网络权限”。这样审计者只需要看一个文件就能确认应用不会乱发数据。如果后续有人提交了“自动更新”“远程配置”等功能,也必须在特性说明里明确标注,不能让用户误以为所有操作都保持离线。
7.5 密码策略与密钥管理
加密备份使用 PBKDF2 派生密钥,迭代次数设置为 200000。这个数值对于普通个人电脑的实时解密是可接受的,对于暴力破解会带来较高的计算成本。如果你要面向更多用户,建议:
- 改用 Argon2id 或 scrypt 作为 KDF。
- 不要把 salt 固定值写死在代码里,而是每个备份文件随机生成。
- 用户设置密码时,在界面层强制最小长度和复杂度提示。
8. 总结与下一步方向
到这里,我们完成了一款“能记账、能统计预算、能加密备份”的本地优先预算应用,并在架构上落实了不连接银行、不建立账号、默认离线的隐私设计原则。核心收获可以总结为三点:
- 理解 Privacy by Design 在真实应用里不是口号,而是由数据流向、表结构、备份方案共同决定的架构决策。
- 掌握 SQLite 的本地存储与按月统计方法,了解如何用 Python 标准库快速搭建命令行工具。
- 掌握加密备份与恢复的关键流程,明白用户密码派生密钥的工作原理,以及失去密码后不可找回的边界。
下一步可以继续扩展的方向包括:Web 本地界面(例如用 Flask 或 FastAPI 提供 localhost 页面)、多币种换算、预算超支提醒、定期自动备份,以及把同一个加密备份机制迁移到移动端。
如果你正在规划自己的独立项目,最值得优先关注的风险点就是备份与密码管理:本地应用一旦形成数据,没有任何云服务兜底,备份策略必须在产品设计文档里写清楚,而不是等用户丢了数据再做提示。动手把这套代码跑起来,再根据你自己的记账习惯做调整,会比阅读十篇设计文章更有收获。