FEATURED · 精选文章

隐私设计的记账App:不连银行、本地处理、端侧加密的架构实践

发布时间 / 2026/8/28 19:32:26
来源 / 创域科博编辑部
栏目 / 资讯中心
隐私设计的记账App:不连银行、本地处理、端侧加密的架构实践 很多人以为记账 App 越“智能”越值得信任。但现实恰恰相反一个记账 App 越懂你它掌握的风险信息就越多。市面上不少记账工具会请求读取银行短信、同步网银账单甚至会申请通讯录、定位等完全不相关的权限。拿到这些数据之后App 把它们存在哪里、是否加密、会不会分享给第三方广告 SDK用户几乎无从得知。这篇文章要聊的是一类刻意“不做连接”的记账应用。这类 App 的核心卖点直接写在标题里A budgeting app that cant touch your bank. Privacy by design——它不触碰你的银行账户不读取你的短信验证码不索取网银登录凭证所有账单数据都在你控制的本地方完成处理。这个设计不是功能缺失而是主动的隐私架构决策。读完这篇文章你会理解三件事Privacy by design隐私设计到底是什么意思它如何落地到软件架构一个不连接银行账户的记账 App凭什么还能做到好用如何用一套最小原型实现“本地存储 导入解析 端侧加密导出”的隐私记账核心并在浏览器里验证它确实没有任何网络请求。1. 这篇文章真正要解决的问题先看几个真实场景。场景一为了“自动记账”交出了银行短信权限。很多记账 App 会提示开启短信读取权限就可以自动识别消费短信、自动记账。听起来很方便但代价是 App 的服务器或第三方 SDK 会持续看到你的每一笔支付通知、每一个商户名称、每一次余额变动。场景二云同步意味着明文数据上了服务端。另一类记账 App 需要注册账号才能使用账单数据默认同步到云端。如果服务端没做端到端加密那么服务端运维人员、被攻破的数据库、被拉取的接口都可能拿到你的完整消费记录。消费记录比浏览记录更敏感它直接反映收入水平、生活习惯、所在城市和消费能力。场景三第三方统计 SDK 的“匿名”追踪。不少 App 集成了分析工具或广告归因 SDK。这些 SDK 会采集设备标识、启动来源、页面路径。即便没有直接传账单字段用户行为轨迹同样会被拼接成画像。这三个场景指向同一个判断记账 App 的隐私问题大多数不是技术 bug而是商业模式决定的。如果一款产品把用户消费数据当作资产那么用户使用它越久风险就越大。“A budgeting app that cant touch your bank”这类产品选择的路线是直接从源头切断数据收集面不连银行、不收短信、不建账号甚至默认不上云。它不是通过“保护你已有的数据”来解决问题而是通过“根本不收集数据”来让问题不发生。这正是隐私设计里最核心的一条原则数据最小化。这篇文章适合三类读者正在做记账、支付、金融类 App 的开发者需要理解隐私设计的落地方式独立开发者想做一个无服务端、本地优先的隐私产品对个人数据敏感的用户想搞清楚“不连银行”的记账 App 到底安不安全。2. 理解 Privacy by Design它不是“不加功能”而是“换一种架构”Privacy by Design隐私设计是加拿大信息与隐私专员 Ann Cavoukian 在 1990 年代末提出的隐私保护框架最早用于公共机构和商业系统的隐私治理后来逐渐被软件行业接受成为 GDPR 等法规中“数据保护设计”理念的思想来源。很多人第一次听到这个概念会误以为隐私设计就是“功能少一点”“权限别乱要”。实际上不是这样。隐私设计是一种系统架构方法它的核心是把隐私保护能力当成系统的默认属性而不是上线之后补丁式的功能。它包含 7 项基本原则这里直接映射到软件开发场景隐私设计原则在软件架构中的落地方式主动预防而非事后补救在设计评审阶段就做数据流分析而不是等数据泄露后再加日志默认隐私新用户默认不采集行为数据默认不上传账单用户主动开启才上传内嵌于设计加密、匿名化、最小权限在代码架构层实现不依赖单个开发者的自觉全功能正和隐私保护不牺牲核心体验例如本地计算预算、本地做分类同样够用端到端安全数据在采集、存储、传输、销毁全生命周期都受保护可见性与透明度用户能清楚看到 App 收集了什么、不收集什么、数据存放在哪以用户为中心用户对自己数据有控制权能导出、能删除、能完全离线使用对照上面的原则再回头看“不碰银行”这类产品它不是刻意为难用户而是把“默认不收集”和“数据最小化”落实成了产品定义。比如用户想看本月预算这个计算完全可以在手机本地完成用户想导入历史账单可以在本机导入 CSV 文件用户想备份数据可以在本机生成一份加密文件。这中间没有任何一个环节必须把原始账单明文发给服务器。从工程角度理解隐私设计关键是一句话先问能不能不收集再问收集后怎么保护。这个问题的顺序一旦反了后面所有加密和合规手段都只是补救不是设计。3. 为什么记账 App 可以不触碰银行账户记账 App 获取账单数据通常有三条路线。对比一下它们各自的隐私风险和用户体验就能理解“不触碰银行账户”这个选择的合理性。数据获取方式用户操作成本隐私风险对服务端的依赖手动记账较高每次消费后手工输入最低数据只在本机无批量导入银行导出的 CSV/OFX 文件中等每月一次从网银导出再导入低文件在用户本地解析后入库无通过开放银行 API 或读取短信自动同步最低几乎全自动高需要长期授权、读取验证码或敏感权限高通常需要服务端中转第三种路线对用户最“省事”但它要求 App 能持续访问高度敏感的资源。这种访问一旦被恶意使用或者服务端被攻击泄露面是整个账户体系。更麻烦的是用户很难感知第三方到底读取了什么、保存了什么、传给谁了。有些开发者可能会觉得“不做银行连接自动记账没了产品还怎么和竞品打”这里有个产品逻辑上的误区自动记账解决的是“录入效率”而不是“记账价值”。用户可以花 3 秒手动记一笔也可以每月从网银导出 CSV 再一次性导入。后者虽然多了两步操作但没有把银行凭证交给任何第三方。对“隐私优先”的目标用户来说这种控制感本身就是购买理由。同时通过产品功能设计可以补偿录入成本常用商户自动联想输入前几个字就能补全支持自定义分类规则同一商户匹配同一分类预算与报表实时计算账单一进本机分析结果立刻可见批量导入模板自适应兼容常见银行导出的 CSV 字段。所以“不能触碰你的银行账户”不是一句口号它意味着产品愿意牺牲一部分自动化能力换取用户对数据主权的掌控。对隐私敏感用户来说这种取舍是加分项。4. 隐私优先架构的整体设计把“A budgeting app that cant touch your bank”拆成架构可以分成四个层次采集层、存储层、计算层、同步层。每一层都要做隐私设计。4.1 采集层入口处就做数据最小化采集层的核心原则是不申请与记账无关的权限。不申请读取短信不申请读取通讯录不申请定位权限不需要用户提供银行卡号、支付密码、网银登录凭据。如果系统版本要求通知权限也要说明用途而不是无理由诱导开启。权限是隐私的物理边界。一个记账 App 说自己重视隐私但申请了通讯录权限这是无法用“为了建立买家关系图谱”之类理由解释的。从入口处砍掉权限比之后再加权限审计更有效。4.2 存储层本地优先数据库加密账单数据默认存在本机应用沙盒或浏览器 IndexedDB 中。移动端可以用 SQLite SQLCipherWeb 端可以用 IndexedDB 加字段级加密。目的是保证即使设备丢失或应用容器被提取数据文件也无法被直接读取。本地优先还有一个好处离线可用。地铁里、电梯里、没有信号的地方记账功能照样能跑。这不是把“云端”简单替换成“本地”而是重新设计数据流让所有核心操作都在端上完成。4.3 计算层预算、报表、分类全部本地做计算层不需要服务端参与。打开月度报表时直接查本地数据库生成分类统计时用 SQL 分组聚合即可。不要让客户端把原始账单 POST 到服务器去算再回传结果。本地计算既保护隐私又省了服务器成本响应还更快。4.4 同步层如果必须上云就端到端加密完全离线可以满足一部分极简用户但很多人仍然希望手机和电脑数据互通。如果要做同步应该采用端到端加密客户端用用户口令派生出密钥只把密文上传到服务端。服务端不知道密钥就永远无法读取用户账单内容。同步层设计里服务端退化成“只存密文的文件柜”。它不知道前一个字节是什么、属于哪个用户、金额是多少。用户换设备时只要导出加密备份再在新设备上输入口令解密恢复。5. 最小可行原型从零实现一个“隐私记账核心”下面这部分用 TypeScript IndexedDB Web Crypto 实现一个最小原型。选择 Web 技术栈是因为浏览器天然提供了本地沙盒和标准加密 API最容易验证“零网络请求”这一条隐私承诺。实际产品换成 Flutter、Swift、Kotlin 时核心思路完全一致。这个原型会实现四件事本地数据库表结构定义IndexedDB 封装提供保存和查询能力CSV 导入解析模拟用户从银行导出账单文件后本地入库端侧加密导出备份用户可以用密码保护备份文件。先说明项目基本结构privacy-ledger/ ├── index.html ├── src/ │ ├── db/schema.sql │ ├── db/local-db.ts │ ├── services/csv-import.ts │ └── services/encrypted-export.ts └── package.json5.1 数据表设计交易记录和账户是核心实体。金额用整数存储避免浮点误差所有时间字段用 Unix 毫秒时间戳。-- 文件路径: src/db/schema.sql PRAGMA journal_mode WAL; CREATE TABLE IF NOT EXISTS accounts ( id TEXT PRIMARY KEY, name TEXT NOT NULL, currency TEXT NOT NULL DEFAULT CNY, type TEXT NOT NULL, created_at INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS transactions ( id TEXT PRIMARY KEY, account_id TEXT NOT NULL, occurred_at INTEGER NOT NULL, amount_cents INTEGER NOT NULL, currency TEXT NOT NULL DEFAULT CNY, category TEXT, merchant TEXT, note TEXT, source TEXT NOT NULL DEFAULT manual, imported_at INTEGER NOT NULL, FOREIGN KEY (account_id) REFERENCES accounts(id) ); CREATE INDEX IF NOT EXISTS idx_tx_occurred_at ON transactions(occurred_at); CREATE INDEX IF NOT EXISTS idx_tx_category ON transactions(category);字段解释amount_cents使用“分”作为单位避免0.1 0.2这类浮点问题source标记这条记录是手动输入还是 CSV 导入便于用户追踪数据来源occurred_at表示实际消费时间而不是导入时间category字段可以保存用户最终确认的分类结果。5.2 本地存储层封装在浏览器端使用 IndexedDB 保存交易记录。IndexedDB 是浏览器内置的 NoSQL 数据库数据默认存放在本机不会自动发送到任何服务器。// 文件路径: src/db/local-db.ts // 交易记录类型定义 export interface TxRecord { id: string; accountId: string; occurredAt: number; amountCents: number; currency: string; category: string; merchant: string; note: string; source: manual | csv; importedAt: number; } const DB_NAME privacy-ledger; const DB_VERSION 1; const STORE transactions; function openDb(): PromiseIDBDatabase { return new Promise((resolve, reject) { const request indexedDB.open(DB_NAME, DB_VERSION); request.onupgradeneeded () { const db request.result; if (!db.objectStoreNames.contains(STORE)) { const store db.createObjectStore(STORE, { keyPath: id }); store.createIndex(occurred_at, occurred_at, { unique: false }); store.createIndex(category, category, { unique: false }); } }; request.onsuccess () resolve(request.result); request.onerror () reject(request.error); }); } export async function saveTransaction(tx: TxRecord): Promisevoid { const db await openDb(); return new Promise((resolve, reject) { const txOp db.transaction(STORE, readwrite); txOp.objectStore(STORE).put(tx); txOp.oncomplete () resolve(); txOp.onerror () reject(txOp.error); }); } export async function listTransactions(): PromiseTxRecord[] { const db await openDb(); return new Promise((resolve, reject) { const txOp db.transaction(STORE, readonly); const request txOp.objectStore(STORE).getAll(); request.onsuccess () resolve(request.result as TxRecord[]); request.onerror () reject(request.error); }); }这段代码里没有出现任何fetch或XMLHttpRequest。IndexedDB 的读写操作全部由浏览器本地引擎完成。这是整个隐私架构的基石。5.3 CSV 导入解析用户从网银导出 CSV 文件后在浏览器本地选择文件App 解析每一行并写入 IndexedDB。整个过程不经过服务端用户也不需要把自己的网银凭证交给任何第三方。CSV 解析可以使用papaparse这种成熟的开源库也可以用开发者自己实现的分行解析逻辑。这里为了演示标准流程使用papaparse安装命令不指定版本以官方发布为准npm install papaparse// 文件路径: src/services/csv-import.ts // 用户从银行导出的 CSV 通常包含日期、商户、金额、备注等列。 // 解析后转换为内部 TxRecord 结构再写入本地 IndexedDB。 import Papa from papaparse; interface RawCsvRow { date: string; merchant: string; amount: string; note?: string; } export function parseBankCsv( text: string, accountId: string ): TxRecord[] { const result Papa.parseRawCsvRow(text, { header: true, skipEmptyLines: true, transformHeader: (h) h.trim(), }); if (result.errors.length 0) { throw new Error(CSV 解析失败: ${result.errors[0].message}); } return result.data.map((row, index) { const occurredAt new Date(row.date).getTime(); if (Number.isNaN(occurredAt)) { throw new Error(第 ${index 2} 行日期格式无法识别: ${row.date}); } const amount parseFloat(row.amount); if (Number.isNaN(amount)) { throw new Error(第 ${index 2} 行金额格式无法识别: ${row.amount}); } return { id: crypto.randomUUID(), accountId, occurredAt, amountCents: Math.round(amount * 100), currency: CNY, category: 未分类, merchant: row.merchant.trim(), note: row.note?.trim() ?? , source: csv, importedAt: Date.now(), }; }); }这里的要点是银行 CSV 文件在用户浏览器本地被解析原始文件不会被上传到任何服务器。解析完成后用户可以手动删除原始 CSV 文件只保留已经录入本地数据库的结构化数据。5.4 端侧加密导出备份本地数据最怕设备丢失。隐私优先的备份方式是把数据打包成 JSON 后用用户提供的密码在浏览器端完成 AES-GCM 加密然后导出一个备份文件。因为加密密钥由用户密码通过 PBKDF2 派生而来服务端如果只接收到这个备份文件没有任何办法解密。真正掌握数据的只有用户本人。// 文件路径: src/services/encrypted-export.ts // 将本地交易记录加密为 JSON 备份文件。 // 文件格式: { format, version, salt, iv, ciphertext } export async function exportEncryptedBackup( records: TxRecord[], password: string ): PromiseBlob { const encoder new TextEncoder(); const data encoder.encode( JSON.stringify({ version: 1, records }) ); // 1. 导入用户口令 const keyMaterial await crypto.subtle.importKey( raw, encoder.encode(password), PBKDF2, false, [deriveKey] ); // 2. 生成随机盐值并用 PBKDF2 派生加密密钥 const salt crypto.getRandomValues(new Uint8Array(16)); const key await crypto.subtle.deriveKey( { name: PBKDF2, salt: salt as BufferSource, iterations: 150000, hash: SHA-256, }, keyMaterial, { name: AES-GCM, length: 256 }, false, [encrypt] ); // 3. 使用 AES-GCM 加密附带随机 IV const iv crypto.getRandomValues(new Uint8Array(12)); const encrypted await crypto.subtle.encrypt( { name: AES-GCM, iv: iv as BufferSource }, key, data ); // 4. 把 salt、iv、密文一起放进可导出的 JSON const payload { format: privacy-ledger-backup, version: 1, salt: Array.from(salt), iv: Array.from(iv), ciphertext: Array.from(new Uint8Array(encrypted)), }; return new Blob([JSON.stringify(payload)], { type: application/json, }); }解密流程就是加密流程的逆运算读取备份文件用同一密码派生出密钥再调用crypto.subtle.decrypt得到原始 JSON。这个解密操作同样在用户端完成。5.5 如何验证“零网络请求”Privacy by design 的承诺最终要经得起验证。浏览器 DevTools 提供了最直接的检查方式。打开应用页面后按 F12 打开开发者工具切到Network网络面板刷新页面并完整操作一遍新增一笔手动账、导入一个 CSV、执行一次导出备份。整个过程中Network 面板里应该看不到任何请求indexedDB以外的 HTTP 请求。如果页面引入了字体 CDN、外部图标库、远程统计脚本Network 面板会立刻显示出来。隐私设计产品在工程上应该坚持所有静态资源尽量打包进本地 bundle运行期不拉取任何远程资源。6. 运行结果与效果验证6.1 启动方式如果你把上面几个文件组合成一个最小项目只需要一个静态服务器就能预览npx serve .浏览器打开http://localhost:3000后打开控制台执行import { saveTransaction, listTransactions } from ./src/db/local-db; const tx { id: crypto.randomUUID(), accountId: default, occurredAt: Date.now(), amountCents: 3250, currency: CNY, category: 餐饮, merchant: 示例咖啡店, note: , source: manual as const, importedAt: Date.now(), }; await saveTransaction(tx); const all await listTransactions(); console.log(本地记录数:, all.length); console.log(all);预期输出控制台打印本地记录数: 1并显示这条交易记录对象。6.2 验证加密备份调用加密导出函数后下载得到的备份 JSON 文件应为一长串 base64 风格数据无法直接看到明文。用文本编辑器打开文件内容只有format、version、salt、iv、ciphertext字段不包含merchant、amountCents等可读的账单字段。这就是“端侧加密”与“云存储”的关键差异即使备份文件泄露没有用户密码任何人都无法还原账单内容。6.3 验证导入功能准备一个测试 CSVdate,merchant,amount,note 2025-01-05,某超市,68.50,日用品 2025-01-06,某餐厅,35.00,午餐调用parseBankCsv后会返回两条TxRecord。此时打开 Network 面板确认页面没有发起任何远程请求。这一步通过就验证了“账单数据只在本机处理”这条核心承诺。如果导入失败第一步看抛出的异常信息日期格式、金额格式、CSV 表头是否与代码里的字段名一致。解析错误信息已经定位到具体行号顺着行号检查原始 CSV 即可。7. 常见问题与排查思路问题现象可能原因排查方式解决方案打开页面后 IndexedDB 报错浏览器隐私模式限制或存储已满查看 Console 错误信息清理站点数据指导用户更换普通窗口或清理该站点的 IndexedDB 存储CSV 导入报“日期格式无法识别”银行导出的日期格式不是YYYY-MM-DD先用文本编辑器查看 CSV 原始内容在解析函数里增加常见日期格式的兼容分支金额小数位丢失或出现精度误差将字符串金额直接用浮点数运算检查金额转换逻辑使用整数分存储或者用Decimal库处理后再转分加密备份文件无法解密用户输入密码与原密码不一致或备份文件被改动核对密码检查ciphertext是否完整重新使用原密码解密如果文件损坏只能从本库重新导出浏览器提示“crypto.subtle 不可用”页面运行在非 HTTPS 环境检查地址栏协议使用https://localhost或部署到 HTTPS 环境Network 面板出现远程请求引入了外部字体、图标库或统计脚本逐个检查外部资源的引用将静态资源打包到本地 bundle移除统计脚本用户误以为“不连银行 功能缺失”产品说明不到位检查 App Store 描述和引导页文案用功能对比图说明 CSV 导入和本地计算如何保障体验8. 最佳实践与工程建议8.1 权限最小化不是口号要写进代码评审团队里可以约定一个硬性清单记账类 App 不申请短信、通讯录、定位权限。任何新增权限都必须解释与记账核心功能的关系。权限评审和代码评审一起做而不是等应用上架审核时由平台发现。8.2 服务端即使存在也要做成“可丢弃”的隐私优先产品如果做了账号和同步服务端应该保持可丢弃状态服务器丢数据用户本地数据仍然完整服务器被攻破密文无法解密。实现上要做到客户端默认离线可用账号只是多设备同步的附属能力而不是使用前提。8.3 加密密钥由用户控制密钥管理是整个同步架构的核心。客户端使用用户口令派生密钥不要把口令直接发送到服务端做“口令校验”。可以通过验证salt 密文能否解密来判断口令是否正确而不是让服务端看到明文口令本身。8.4 数据可迁移性是对用户的基本尊重隐私产品应当提供开放的导出格式。JSON、CSV、SQLite 备份都可以。不要用私有加密格式把用户数据困在产品里。用户有离开的自由才是真正的以用户为中心。8.5 隐私政策要写清楚“不做什么”很多隐私政策通篇在解释“我们收集了什么、如何使用”但对用户来说“不做什么”往往更有信息量。一份隐私优先产品的政策至少应该明确写出不读取银行短信不保存网银登录凭证不把账单明文上传服务器不出售、不出租用户数据用户可随时导出、删除全部数据。8.6 保持可审计性开源是隐私设计最有力的佐证。用户如果看不懂代码至少可以让社区审计。闭源产品宣称“我们不会上传数据”很难让技术用户信任开源 可本地编译 可禁用更新才是更扎实的承诺。8.7 法律合规不要自己拍脑袋不同国家或地区的个人信息保护法规不同。做全球市场时数据存储位置、删除响应时限、未成年用户处理等细节都应咨询专业法律意见。技术方案可以设计得很干净但合规需要结合产品实际运营区域确认。9. 总结与后续学习方向“A budgeting app that cant touch your bank”这个产品定位本质上是用架构换信任。它不连接银行账户、不读取短信权限、不把账单明文上传服务端用户由此获得了完整的数据控制权。这件事在技术上并不复杂本地数据库做存储本地代码做计算导入导出用标准格式需要同步时做端到端加密。真正复杂的是产品取舍是愿意牺牲一部分自动化来换取隐私承诺的可信度。如果你打算继续深入这个方向可以从这几个技术点入手学习 local-first 软件架构了解离线优先、多端同步、冲突解决等完整方案研究移动端的系统级密钥存储例如 iOS 的 Keychain 和 Android 的 Keystore了解 SQLCipher 或基于 SQLite 的端侧加密方案读一读关于数据最小化和隐私工程的实际落地案例把隐私设计思维内化成自己的架构习惯。隐私设计不是一劳永逸的标签。它要求产品在每次新增功能时都重新问一遍这个功能真的需要收集这么多数据吗能不能在用户本机完成如果答案不确定那就应该默认不收集。这个“默认为隐私”的决策习惯比任何加密算法都重要。
RELATED — 相关阅读

相关资讯

LATEST — 最新资讯

最新发布

TODAY — 本日精选

新闻

WEEKLY — 本周精选

新闻

MONTHLY — 本月精选

新闻