很多人在挑选记账或预算类 App 时,都会遇到一个几乎无法回避的矛盾:想要自动化统计,就必须把银行卡或信用卡授权给第三方应用,所有交易流水被上传到服务商云端;想要保护隐私,就只能回到手动记账,不仅录入繁琐,月底还要面对一屏对不上的账目。
这个矛盾的本质,是传统预算 App 把“便利”建立在“让渡隐私”之上。而一种正在被越来越多安全敏感型团队采用的设计思路,叫做Privacy by Design,它的核心判断是:隐私不应该是一个开关,而应该是一套从架构层面就定死的约束。对应到预算产品上,最极致的落地就是标题里说的——can't touch your bank。
这篇文章不会只谈理念。我会从工程角度拆解,一个预算 App 要做到“默认隐私”,需要如何在数据架构、加密存储、密钥管理、备份导出和验证流程上落地;也会给出一个可在 Android 端运行的最小示例。读完你可以获得一套可以直接参考的技术方案,也能在团队做隐私敏感型产品时,知道哪些设计决策比“加一个隐私开关”重要得多。
1. 传统预算 App 的隐私矛盾
1.1 自动化便利与隐私让渡
预算类应用的常规做法,是引导用户去授权银行账户。授权之后,银行流水会自动同步到应用,再经过分类、打标签、生成月报。便利性确实很强,但代价也很清晰:所有交易数据都会经过第三方服务器。
这里的风险不是“数据值多少钱”这么简单。交易流水具有强身份属性,它能分析出你的作息、消费习惯、地理位置、收入变化,甚至能推断家庭结构和健康状况。一旦云端数据库被拖库,或者内部员工越权查询,用户没有任何撤回能力。
1.2 云存储的聚合风险
更麻烦的是,很多传统预算 App 为了做“智能分析”和“个性化推荐”,会在服务器端长期保存用户的交易明细。数据一旦聚合,安全风险就不再是单个用户的问题,而是整个数据池的问题。就算 App 方承诺“加密存储”,用户也无法验证密钥掌握在谁手里、谁有能力解密。
从开发者的角度,这也意味着要承担远高于产品本身的责任:从合规备案、审计日志、数据泄露响应到用户数据删除请求,每一项都是成本。对一个预算工具来说,这些成本本来可以通过架构设计来避免。
1.3 用户真正要的是什么
用户真正想要的,不是“App 替我保管流水”,而是“App 能帮我看清钱花在哪”。看清账目完全可以在本地完成,不需要把明细送出去。因此,一个更合理的产品边界是:账本留在设备上,银行保持不可见。
2. Privacy by Design 的技术本质
2.1 七条原则不是口号
Privacy by Design 最早由加拿大安大略省信息与隐私专员 Ann Cavoukian 提出,核心是七条原则。很多产品谈隐私,只是把这些原则写进 PPT,真正到技术方案层面,原则必须翻译成可执行的工程约束。
| 隐私原则 | 技术落地示例 |
|---|---|
| 主动预防,而非事后补救 | 需求阶段就做数据流清单,逐项确认哪些字段能出现在日志里 |
| 默认隐私 | App 默认不申请网络权限,默认不同步、不推荐 |
| 嵌入设计,而非附加组件 | 数据库层直接接入加密,不依赖业务代码“记得加密” |
| 正和双赢,而非零和博弈 | 既保留自动记账能力,又让数据只留本地 |
| 端到端安全 | 本地数据库强加密,备份文件单独加密 |
| 可见与透明 | 面向用户提供权限状态和“本机数据导出”能力 |
| 尊重用户 | 支持一键清空、完全退出、彻底删除 |
2.2 对预算 App 意味着什么
如果把这七条原则套到预算 App 上,结论非常明确:
- 默认隐私 = 默认不联网。
- 数据最小化 = 不需要银行账号密码,也不需要短信验证码。
- 端到端安全 = 数据库加密、备份加密、密钥不进云端。
- 尊重用户 = 删除账本就是彻底删除,不留服务端副本。
这就是“can't touch your bank”的真正含义。它不是靠用户协议约束自己不去碰,而是从能力上就限制住了系统边界:没有网络权限,就没有上传通道;没有银行授权,就没有外部数据来源。
2.3 开发者最容易误解的一点
很多人以为 Privacy by Design 只是“把数据加密再存一下”。加密只是其中一环,真正的核心是划定数据边界。加密解决“数据被拿走也读不懂”的问题;数据边界解决“数据根植根本不会被拿走”的问题。前者是被动防御,后者是主动约束。
3. 架构选型:本地优先与三种产品形态
3.1 “不触碰银行”不等于“不导入银行数据”
这里必须澄清概念。“can't touch your bank”可以从两个层面理解:
- 技术层:App 没有能力读取银行账户,没有网络权限,没有 SDK 通道。
- 产品层:App 不要求用户授权任何金融数据源,账本内容完全由用户自己产生。
它并不排斥用户主动导入对账单。比如用户从网银导出一份 CSV/OFX 文件,在本地解析后入账,这一过程中银行数据只经过本地内存和加密数据库,服务器端不存在任何副本。这仍然符合 Privacy by Design 的原则。
3.2 三种产品形态对比
| 产品形态 | 数据获取方式 | 隐私水平 | 实现复杂度 | 是否需要网络权限 |
|---|---|---|---|---|
| 纯手动离线 | 用户逐笔记账 | 最高 | 低 | 不需要 |
| 手动 + CSV/OFX 本地导入 | 用户从网银导出后本地解析 | 高 | 中 | 不需要 |
| 可选只读银行 API | 用户主动授权,服务商返回只读数据,本地加密留存 | 中高 | 高,合规成本大 | 需要 |
对于个人开发者或小团队,我建议从第二种形态起步。它既保留了用户体验上的基本便利,又在架构上做到了“默认离线”。等产品验证了需求,再评估是否需要引入只读银行 API。
3.3 本地优先架构
完整的隐私优先预算 App 架构可以分成四层:
- 展示层:Jetpack Compose 页面,只做状态渲染。
- 业务层:Repository 统一处理记账、预算、统计和导入逻辑。
- 加密数据层:Room + SQLCipher 管理本地数据库,所有落盘数据加密。
- 备份与安全层:Android Keystore 存根密钥,备份文件使用 AEAD 加密。
整个系统里,核心账本唯一持久化位置是设备内数据库。如果外部网络通道不存在,那么无论客户端被攻击还是数据被拿到,攻击者看到的都只是密文。
4. 环境准备与项目配置
4.1 前置环境
本文示例以 Android 端为主,你需要准备:
- Android Studio 最新稳定版。
- JDK 17。
- Android SDK,编译目标建议 34 及以上。
- 一台或模拟器运行测试,真机调试更可靠。
版本号以你开发时的当前稳定版为准,仓库代码不依赖某个特殊版本,重点是理解集成方式。
4.2 添加依赖
在app/build.gradle.kts中添加关键依赖:
// 文件路径:app/build.gradle.kts plugins { id("com.android.application") id("org.jetbrains.kotlin.android") id("org.jetbrains.kotlin.kapt") } android { namespace = "com.example.privacybudget" compileSdk = 34 defaultConfig { applicationId = "com.example.privacybudget" minSdk = 26 targetSdk = 34 versionCode = 1 versionName = "1.0" } compileOptions { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 } kotlinOptions { jvmTarget = "17" } } dependencies { implementation("androidx.core:core-ktx:1.12.0") implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0") implementation("androidx.activity:activity-compose:1.8.0") implementation("androidx.compose.ui:ui:1.5.0") implementation("androidx.compose.material3:material3:1.1.0") // Room 本地数据库 implementation("androidx.room:room-runtime:2.6.0") implementation("androidx.room:room-ktx:2.6.0") kapt("androidx.room:room-compiler:2.6.0") // SQLCipher 加密数据库 implementation("net.zetetic:android-database-sqlcipher:4.5.4") implementation("androidx.sqlite:sqlite-ktx:2.1.0") }4.3 不申请网络权限
隐私优先的第一条架构约束,就是不在AndroidManifest.xml中申请网络权限。没有INTERNET权限,App 从系统层面就失去了联网能力。
<!-- 文件路径:app/src/main/AndroidManifest.xml --> <manifest xmlns:android="http://schemas.android.com/apk/res/android"> <!-- 刻意不申请 android.permission.INTERNET --> <application android:allowBackup="false" android:label="@string/app_name" android:supportsRtl="true" android:theme="@style/Theme.PrivacyBudget"> <activity android:name=".MainActivity" android:exported="true"> <intent-filter> <action android:name="android.intent.action.MAIN" /> <category android:name="android.intent.category.LAUNCHER" /> </intent-filter> </activity> </application> </manifest>这里有两个细节容易被忽略:
android:allowBackup="false"要显式设置。Android 系统默认允许应用数据备份到云,如果账本数据被系统备份,就绕过了“数据不出设备”的承诺。- 不要引入任何需要网络权限的第三方 SDK,包括推送、统计、崩溃上报和广告 SDK。这些 SDK 往往是隐私泄漏的隐形通道。
5. 数据模型与 SQLCipher 加密存储
5.1 核心数据模型
预算 App 的最小数据模型至少包含三类:交易、分类、预算。采用金额的“分”作为单位,避免浮点数误差。
// 文件路径:app/src/main/java/com/example/privacybudget/data/entity/Transaction.kt @Entity( tableName = "transactions", indices = [Index(value = ["categoryId"])] ) data class Transaction( @PrimaryKey(autoGenerate = true) val id: Long = 0, val categoryId: Long, val amountCents: Long, val note: String, val spentAt: Long, val importedFromBank: Boolean = false )// 文件路径:app/src/main/java/com/example/privacybudget/data/entity/Budget.kt @Entity(tableName = "budgets") data class Budget( @PrimaryKey(autoGenerate = true) val id: Long = 0, val categoryId: Long, val monthlyLimitCents: Long, val monthStart: Long )5.2 用 SQLCipher 给 Room 加一层加密
Room 默认生成的数据库是明文 SQLite 文件。要让数据落盘即加密,最成熟的做法是接入 SQLCipher。SQLCipher 是 SQLite 的加密扩展,支持 AES-256,并且能直接集成到 Room 中。
// 文件路径:app/src/main/java/com/example/privacybudget/data/AppDatabase.kt @Database( entities = [Transaction::class, Budget::class], version = 1, exportSchema = true ) abstract class AppDatabase : RoomDatabase() { abstract fun transactionDao(): TransactionDao abstract fun budgetDao(): BudgetDao companion object { private const val DB_FILE = "privacy_budget.db" @Volatile private var instance: AppDatabase? = null fun getInstance(context: Context, passphrase: ByteArray): AppDatabase { return instance ?: synchronized(this) { instance ?: build(context, passphrase).also { instance = it } } } private fun build(context: Context, passphrase: ByteArray): AppDatabase { SQLiteDatabase.loadLibs(context) val hook = object : SQLiteDatabaseHook { override fun preKey(database: SQLiteDatabase) { // 打开数据库前,SQLCipher 会使用 SupportOpenHelperFactory 设置的口令完成 PRAGMA key } override fun postKey(database: SQLiteDatabase) { // 口令验证通过后,追加安全加固参数 database.rawExecSQL("PRAGMA cipher_memory_security = ON") } } val factory = SupportOpenHelperFactory(passphrase, hook) return Room.databaseBuilder(context, AppDatabase::class.java, DB_FILE) .openHelperFactory(factory) .build() } } }这里的核心调用是SupportOpenHelperFactory(passphrase, hook)。它把数据库口令交给 SQLCipher,之后 Room 对数据库的所有读写,都会经过 SQLCipher 解密。
5.3 定义 DAO
数据访问层和普通 Room 没有区别,加密对业务代码透明:
// 文件路径:app/src/main/java/com/example/privacybudget/data/TransactionDao.kt @Dao interface TransactionDao { @Insert suspend fun insert(transaction: Transaction): Long @Query("SELECT * FROM transactions WHERE spentAt BETWEEN :start AND :end ORDER BY spentAt DESC") suspend fun queryBetween(start: Long, end: Long): List<Transaction> @Query("DELETE FROM transactions WHERE id = :id") suspend fun deleteById(id: Long) }一个常见的误区是:以为只要在代码里写“加密”,所有数据就安全了。实际上,如果口令硬编码在源码里,或者数据库文件被原样复制时密钥仍能从日志中被提取,加密就形同虚设。下一章的密钥管理才是加密体系的关键。
6. 密钥管理与生物认证
6.1 密钥分层
本地加密数据的安全,取决于密钥最终落在哪里。如果密钥放在普通的SharedPreferences里,攻击者可以借助备份或漏洞直接读取。Android 上更稳妥的方式是使用Android Keystore。
Keystore 的作用不是替你保存所有数据,而是保存一个“根密钥”。数据库口令由高熵随机数生成,用根密钥加密后再落盘。这样即使拿到SharedPreferences文件,也解不出数据库口令。
6.2 生成与保护数据库口令
// 文件路径:app/src/main/java/com/example/privacybudget/security/KeyStoreManager.kt object KeyStoreManager { private const val KEY_ALIAS = "privacy_budget_root_key" private const val PREFS_NAME = "secure_prefs" private const val PREF_CIPHER = "db_passphrase_cipher" fun createOrGetDbPassphrase(context: Context): ByteArray { val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) val savedCipher = prefs.getString(PREF_CIPHER, null) if (savedCipher != null) { return decrypt(android.util.Base64.decode(savedCipher, Base64.NO_WRAP)) } // 生成 32 字节高熵随机数作为数据库口令 val passphrase = ByteArray(32).also { SecureRandom().nextBytes(it) } val encrypted = encrypt(passphrase) prefs.edit() .putString(PREF_CIPHER, Base64.encodeToString(encrypted, Base64.NO_WRAP)) .apply() return passphrase } private fun getOrCreateRootKey(): SecretKey { val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) } (keyStore.getKey(KEY_ALIAS, null) as? SecretKey)?.let { return it } val generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore") generator.init( KeyGenParameterSpec.Builder( KEY_ALIAS, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT ) .setBlockModes(KeyProperties.BLOCK_MODE_GCM) .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) .setUserAuthenticationRequired(false) .build() ) return generator.generateKey() } private fun encrypt(plain: ByteArray): ByteArray { val cipher = Cipher.getInstance("AES/GCM/NoPadding") cipher.init(Cipher.ENCRYPT_MODE, getOrCreateRootKey()) val iv = cipher.iv val encrypted = cipher.doFinal(plain) return iv + encrypted } private fun decrypt(data: ByteArray): ByteArray { val iv = data.copyOfRange(0, 12) val encrypted = data.copyOfRange(12, data.size) val cipher = Cipher.getInstance("AES/GCM/NoPadding") cipher.init(Cipher.DECRYPT_MODE, getOrCreateRootKey(), GCMParameterSpec(128, iv)) return cipher.doFinal(encrypted) } }这个方案至少解决两个问题:
- 数据库口令是高熵随机数,但不是硬编码在 App 里。
- 口令被 Keystore 根密钥加密,只有这台设备上的应用能够解开。
6.3 生物认证的取舍
如果要求每次打开 App 都要指纹或人脸认证,可以把setUserAuthenticationRequired(true)打开,同时配合setUserAuthenticationParameters(0, KeyProperties.AUTH_BIOMETRIC_STRONG)。但要注意,这会带来密钥失效风险:一旦系统移除生物特征或重置安全锁屏,密钥可能无法再使用,用户数据就无法解密。
因此,生产环境通常建议做成“可选强安全模式”:默认只依赖设备锁屏保护,用户主动开启“生物认证解锁”后才在解密路径中绑定认证结果。
7. 完整示例:记账、预算与加密备份
7.1 记录一笔交易
用 Repository 统一封装数据操作。加密数据库的打开发生在AppDatabase.getInstance,业务层不需要感知加密细节。
// 文件路径:app/src/main/java/com/example/privacybudget/data/TransactionRepository.kt class TransactionRepository(private val dao: TransactionDao) { suspend fun addTransaction(categoryId: Long, amountCents: Long, note: String) { val transaction = Transaction( categoryId = categoryId, amountCents = amountCents, note = note.trim(), spentAt = System.currentTimeMillis(), importedFromBank = false ) dao.insert(transaction) } suspend fun getMonthlySummary(start: Long, end: Long): List<Transaction> { return dao.queryBetween(start, end) } }7.2 计算月度预算剩余
// 文件路径:app/src/main/java/com/example/privacybudget/domain/BudgetCalculator.kt class BudgetCalculator(private val transactionDao: TransactionDao) { suspend fun remaining(budget: Budget, monthStart: Long, monthEnd: Long): Long { val transactions = transactionDao.queryBetween(monthStart, monthEnd) val spent = transactions.sumOf { it.amountCents } return budget.monthlyLimitCents - spent } }这个代码简单,但它体现了本地优先的好处:计算全部在设备内存中完成,不会把月账单发送到任何服务器。
7.3 导出加密备份
即使没有网络,用户也可能需要换机或备份。备份文件必须单独加密。Android 支持AES/GCM/NoPadding作为 AEAD 加密方案,可以同时保证机密性和完整性。
// 文件路径:app/src/main/java/com/example/privacybudget/backup/BackupManager.kt class BackupManager( private val applicationContext: Context, private val transactionDao: TransactionDao ) { suspend fun exportEncryptedBackup( outputFile: File, backupKey: SecretKey ): File { // 1. 查询并构建明文 JSON val transactions = transactionDao.queryBetween(0, Long.MAX_VALUE) val jsonArray = JSONArray() transactions.forEach { tx -> jsonArray.put( JSONObject() .put("id", tx.id) .put("categoryId", tx.categoryId) .put("amountCents", tx.amountCents) .put("note", tx.note) .put("spentAt", tx.spentAt) .put("importedFromBank", tx.importedFromBank) ) } val root = JSONObject().put("version", 1).put("transactions", jsonArray) // 2. 使用 AES-GCM 加密 val cipher = Cipher.getInstance("AES/GCM/NoPadding") cipher.init(Cipher.ENCRYPT_MODE, backupKey) val plain = root.toString().toByteArray(StandardCharsets.UTF_8) val cipherBytes = cipher.doFinal(plain) // 3. 写文件:IV + 密文 outputFile.outputStream().use { output -> output.write(cipher.iv) output.write(cipherBytes) } return outputFile } suspend fun restoreFromEncryptedBackup( backupFile: File, backupKey: SecretKey ) { backupFile.inputStream().use { input -> val iv = ByteArray(12) if (input.read(iv) != iv.size) { throw IllegalArgumentException("备份文件格式错误") } val cipherText = input.readBytes() val cipher = Cipher.getInstance("AES/GCM/NoPadding") cipher.init(Cipher.DECRYPT_MODE, backupKey, GCMParameterSpec(128, iv)) val jsonString = String(cipher.doFinal(cipherText), StandardCharsets.UTF_8) val root = JSONObject(jsonString) val transactions = root.getJSONArray("transactions") // 解析后通过 DAO 重新写入本地数据库 } } }备份密钥本身可以用 Keystore 根密钥派生,也可以要求用户输入口令并用 Argon2 或 PBKDF2 做密钥派生。如果用户口令强度低,建议使用 Argon2id 参数并保存随机 Salt。
7.4 初始化流程
在Application中完成加密数据库初始化:
// 文件路径:app/src/main/java/com/example/privacybudget/PrivacyBudgetApp.kt class PrivacyBudgetApp : Application() { lateinit var database: AppDatabase private set override fun onCreate() { super.onCreate() val passphrase = KeyStoreManager.createOrGetDbPassphrase(this) database = AppDatabase.getInstance(this, passphrase) } }7.5 运行验证
用下面命令构建并安装到设备:
./gradlew :app:installDebug启动 App 后,进入存放数据库文件的沙箱目录检查文件类型:
adb shell run-as com.example.privacybudget ls -l databases/ adb shell run-as com.example.privacybudget file databases/privacy_budget.db正常预期输出中不会出现SQLite字样,因为文件头已经不再是 SQLite 明文的特征头。用file命令看到的应该是加密后的随机数据。
8. 验证与排查:数据真的留在设备上吗
8.1 从权限层面验证
最直接的验证是检查 APK 的权限声明:
aapt dump permissions app-debug.apk如果输出中没有android.permission.INTERNET,说明应用从系统权限上就没有联网能力。对隐私产品来说,这是最容易验证、也最有说服力的一点。
8.2 从流量层面验证
如果后续版本因为导入功能引入了网络权限,建议在测试阶段用抓包工具验证流量。注意,抓包分析只应针对自己开发的 App,并且要在合法授权的测试环境中进行。
预期结果: - 启动 App 时没有任何网络请求 - 记账、预算计算、导出备份时也没有网络请求 - 即使有用户授权,也只访问用户明确授权的只读数据源如果抓包发现 App 在用户不知情的情况下向第三方域名发送数据,说明隐私边界被突破了,必须回退版本。
8.3 从备份恢复层面验证
隐私设计是否合格,可以通过一次完整流程来验证:
- 安装 App,创建交易数据。
- 导出加密备份到外部存储。
- 卸载并重装 App。
- 恢复备份文件。
- 确认数据完整恢复。
如果本地数据库口令丢失,重装后数据将无法恢复,这也是隐私保护的预期代价。产品设计上要提醒用户提前导出加密备份。
9. 常见问题与排查方向
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
启动时数据库报file is not a database | 数据库口令错误或文件被覆盖 | 检查是否在初始化前修改了 SharedPreferences | 确认使用同一套 KeyStore 内容,不要直接复制旧库文件 |
| 升级 App 后数据消失 | Keystore 密钥不可用或数据库被系统清理 | 查看日志中是否有 KeyStore 异常 | 升级前备份,禁止误删 secure_prefs |
| 备份文件恢复失败 | 使用错误密钥或文件被截断 | 检查 IV 长度和密文长度 | 恢复时核对版本号和 GCM 标签是否完整 |
| Room 数据库 schema 升级失败 | 迁移脚本没有适配加密库 | 查看room-migration日志 | 先做完整备份,再编写Migration并执行测试 |
| 生物认证开启后无法解密 | 系统锁屏或生物特征变更导致 Keystore 密钥失效 | 查看KeyPermanentlyInvalidatedException | 提供“使用锁屏验证”替代方案,并引导用户使用备份恢复 |
| 抓包看到非预期请求 | 某个第三方 SDK 隐含网络权限 | 检查第三方依赖声明 | 移除该 SDK,重新构建后再验证 |
10. 工程建议与最佳实践
10.1 最小权限清单
隐私优先项目的依赖审批应该严格执行“最小权限”:
- 不使用推送 SDK。
- 不使用统计 SDK。
- 不使用需要网络权限的崩溃上报 SDK。
- 不申请
READ_SMS、READ_CONTACTS、READ_PHONE_STATE等敏感权限。
任何依赖加入之前,至少确认两点:它是否需要网络权限,它是否会把数据写到外部目录。把这两条写进团队依赖评审清单,能减少大量后期合规返工。
10.2 日志脱敏
数据不出设备,不代表日志不会把数据带出来。Log.d打印交易金额、备注、余额,会在崩溃日志中被记录下来。日志系统应实现统一的PrivacyLogger,对金额、商家名、备注字段做脱敏处理。
10.3 数据库迁移要留退路
加密数据库的迁移比明文数据库更严格。每次 schema 升级都要在测试环境验证:
- 旧版本导出的备份能否成功恢复。
- 升级后能否正常读写新字段。
- 如果迁移失败,能否回滚到旧版本。
建议在 CI 流程中加入“从 v1 备份迁移到 v2”的自动化测试。
10.4 面向用户的透明说明
最后,隐私设计不能只藏在代码里。用户应该能在设置页看到:
- 允许查看当前版本是否申请了网络权限。
- 明确说明数据存储位置是“仅本机”。
- 提供导出和删除所有数据的入口。
透明不是为了合规交差,而是让用户能验证产品的隐私承诺。真正的 Privacy by Design,应该让用户无需信任开发者,也能确认数据边界。
10.5 从最小 MVP 开始
如果你正在考虑做一个隐私优先的预算 App,建议不要一步到位做银行直连。先做完全离线、本地加密、支持 CSV 导入的 MVP,跑通“记账-预算-统计-加密备份”闭环。当产品确实需要自动导入流水时,再设计一套更复杂的只读数据通道,并在用户授权和本地清理机制上做足功课。
隐私优先的本质是约束优先。很多团队是先做好功能再补隐私,结果发现数据早就散落在各种外部服务里,补不回来了。反过来,把“不能触碰银行”当作架构约束,功能设计和数据边界一起迭代,才能真正做出用户敢长期使用的记账工具。