一个纯本地的 AI 刷题 App 要上架,最硬的阻塞不是功能,而是「模型密钥打在 APK 里」和「用户换手机题库全丢」。这篇给出完整改造方案:AI 调用上移、公共题库与个人题库的分层存储、同步协议设计,以及一个必须回答的问题——个人题库到底放服务端还是手机。
1. 背景与目标
1.1 现状
项目是个 Android 端的学习监督 App:AI 出题 + 强制锁屏答题 + 定时提醒 + 题库管理 + 学习统计。技术栈 Kotlin + Compose + Hilt + Room,本地库study_must_database共 8 张表,没有任何服务端。
做完本地版本后,真机跑通才发现一条条硬伤:
| 维度 | 现状 | 问题 |
|---|---|---|
| AI 调用 | 客户端直连模型网关,密钥写在assets配置里 | 密钥随 APK 分发等于公开;网关若在内网,公网用户根本连不上 |
| 题库 | 全在手机 Room 里 | 换机即丢;无法沉淀公共题库;无法运营 |
| 账号 | 无 | 无法跨端、无法订阅、无法防刷 |
| 统计 | 本地聚合答题记录 | 单机口径,重装清零 |
| 提示词 | assets里的 properties | 改一句要发版审核,且容易被反编译抄走 |
1.2 目标(可验收)
- 密钥不出服务端:APK 内不再出现任何模型凭证,抓包只见业务凭证
- 离线可用不回退:断网状态下仍能答题,锁屏强制答题仍能触发
- 换机不丢:登录后个人题库、收藏、答题记录自动恢复
- 公共题库可运营:运营侧修订题目,客户端增量拉取,不发版
- 成本可控:AI 出题有配额、有审计、失败不扣费
第 2 条是整个方案的分水岭。很多"服务端化"最后做成"断网即残废",而强制锁屏答题的特性决定了:没有网络时也必须能把题弹出来,否则用户会被锁在一个没有题目的全屏页面里。
1.3 非目标
不做题目协同编辑、不做服务端渲染的 Web 题库、不做实时对战与排行榜。
2. 技术选型
2.1 AI 调用形态
| 维度 | 客户端直连模型 API | 自建 BFF 代理 |
|---|---|---|
| 开发成本 | 1 天 | 1-2 周 |
| 密钥安全 | ❌ 打进安装包 | ✅ 服务端持有 |
| 公网可达 | 取决于网关是否公网 | ✅ |
| 配额/计费 | 无线索 | ✅ 可按用户扣减 |
| 提示词保护 | ❌ 可反编译 | ✅ 可热更新 |
| 内容审核 | 客户端自审(等于没有) | ✅ 双向审核 |
结论:必须上 BFF。但原型阶段可以先直连,前提是把baseUrl、model、输出上限、temperature、超时、重试全部配置化——这样后面切换只需改配置,不动业务代码。实际项目里这条救了一次急:中途换模型网关只花了 3 分钟。
2.2 出题请求形态
| 维度 | 同步长请求 | 异步任务 + 轮询 |
|---|---|---|
| 移动网络鲁棒性 | 差(15 s 以上极易被系统或 NAT 掐断) | 好(重连可续) |
| 扣配额时机 | 不好界定 | 任务成功后再扣,天然合理 |
| 用户体验 | 转圈等到超时 | 可后台生成、可离开页面 |
| 实现复杂度 | 低 | 中(需任务表 + 状态机) |
结论:异步任务 + 轮询。单题生成实测 5-8 秒,10 题两阶段要 12-18 秒——同步请求在这个时长上几乎必被断。
2.3 数据存储
| 维度 | 全本地 | 全服务端 | 混合(服务端权威 + 本地缓存) |
|---|---|---|---|
| 离线可用 | ✅ | ❌ | ✅ |
| 换机恢复 | ❌ | ✅ | ✅ |
| 首屏延迟 | 0 ms | 80-300 ms | 0 ms(命中缓存) |
| 同步复杂度 | 无 | 低 | 中(需冲突裁决) |
| 运营能力 | 无 | ✅ | ✅ |
结论:混合。理由展开在第 3.7 与第 5 章。
2.4 数据库
PostgreSQL。理由不是"流行",而是这个场景需要三件东西:关系型聚合(按天/类型分组算正确率)、JSONB(存选项数组、审核元数据)、分区表(答题记录按月归档)。MVP 阶段单机 2C4G 就够,不需要分布式。
3. 架构设计
3.1 架构演进
| 阶段 | 形态 | 主要问题 | 演进本质 |
|---|---|---|---|
| V1 纯本地 | App ↔ Room ↔ 模型直连 | 密钥泄露、换机丢数据、无法运营 | — |
| V2 双写过渡 | 本地写 + 后台上传,不做下行 | 用户看到两套数据不一致时的困惑 | 写路径先上云,读路径不动(低风险) |
| V3 服务端权威 | 服务端唯一权威,本地降级为缓存 + 离线队列 | 同步冲突、离线一致性 | 权威源转移:本地从"唯一真相"变成"可丢弃的副本" |
演进的关键不是"加了个服务端",而是把"什么是权威"这件事重新定义。V3 之后,"本地有数据"不再等于"数据存在",只是一个缓存视图;所有写操作先落本地再上报,服务端返回的版本号才是最终事实。
这个定义一变,很多设计自然就出来了:为什么删除要用墓碑(本地不知道别的设备删了什么)、为什么主键要在客户端生成(离线时也要能创建)、为什么答题记录要客户端幂等键(重放不能重复计数)。
3.2 总体架构
┌──────────────────────────────────────────────────┐ │ Android App │ │ Compose UI → ViewModel → Repository │ │ ├── LocalDataSource (Room) │ │ └── RemoteDataSource (Retrofit)│ │ SyncEngine + WorkManager(后台同步) │ └────────────────────┬─────────────────────────────┘ │ HTTPS + JWT ┌────────────────────▼─────────────────────────────┐ │ BFF(单体,先跑通再拆) │ │ auth / ai / bank / userbank / stats / billing │ └──────┬──────────────────────────┬────────────────┘ │ │ ┌──────▼───────┐ ┌───────▼────────┐ │ PostgreSQL │ │ Redis │ │ 主数据 │ │ 配额/限流/缓存 │ └──────┬───────┘ └────────────────┘ │ ┌──────▼───────────┐ ┌──────────────────┐ │ 模型网关(外部) │ │ 运营后台 │ │ 服务端持有密钥 │ │ 题库审核/版本发布 │ └──────────────────┘ └──────────────────┘| 组件 | 职责 | 关键约束 |
|---|---|---|
| App | UI、判分、离线答题、同步引擎 | 不持有任何模型凭证 |
| BFF | 鉴权、AI 代理、题库下发、同步、统计上报 | 无状态,可水平扩 |
| PostgreSQL | 用户、公共题库、个人题库、答题记录、AI 任务 | 题库只读副本可加从库 |
| Redis | 每日配额、限流令牌桶、公共题库分片缓存 | 可丢可重建,不作权威 |
| 运营后台 | 公共题增删改、审核、版本发布 | 独立于 App 发版节奏 |
端到端走读(一次 AI 出题,带量级):
- App 发
POST /v1/ai/generations,请求体约 300 B(主题/类型/难度/数量) - BFF 校验凭证 → 查额度(Redis 一次读,约 1 ms)
- 建任务行(status=PENDING),立刻返回 jobId,小于 50 ms,不等模型
- 后台 worker 取任务 → 服务端提示词模板 → 调模型
- 单题:输入约 400 单位、输出约 900 单位,5-8 秒
- 10 题两阶段:先出清单再并发补详情,12-18 秒
- 结果归一(答案口径)→ JSON 校验 → 内容审核 → 写结果表,任务置 SUCCEEDED
- App 轮询
GET /v1/ai/generations/{jobId}(2 秒一次,上限 60 秒) - 用户点保存 →
POST /v1/userbank/questions:batch落个人题库(幂等键 = 客户端生成的 UUIDv7)
3.3 组件一:BFF 与接口契约
POST /v1/ai/generations 创建任务(body 带 requestId,幂等) GET /v1/ai/generations/{id} 轮询状态与结果 GET /v1/ai/quota 额度查询 GET /v1/sync/pull 增量同步拉取 POST /v1/sync/push 增量同步上报 POST /v1/answers:batch 答题记录上报(幂等)| 设计点 | 决策 | 理由 |
|---|---|---|
| 幂等键位置 | requestId放 body,不放 header | 客户端离线重放时能原样重建整个请求,header 容易被中间层改写 |
任务表UNIQUE (user_id, request_id) | 数据库兜底幂等 | 只靠应用层判重,并发重放会漏 |
| 轮询间隔 2 秒 | 不做长连接/SSE | 移动网络下长连接的保活成本高于收益;2 秒对 5-8 秒的生成可接受 |
| 结果保留 7 天 | 定时清理 | 支持"切后台再回来继续",也控制存储 |
错误码必须可区分,否则客户端只能弹一句"请求失败",用户不知道该付钱还是该等明天:
| HTTP | code | 含义 | 客户端表现 |
|---|---|---|---|
| 401 | CREDENTIAL_EXPIRED | 访问凭证过期 | 静默刷新后重试 |
| 403 | PLAN_REQUIRED | 免费用户调用付费能力 | 弹订阅页,不显示"错误" |
| 429 | QUOTA_EXHAUSTED | 订阅用户当日额度用完 | 提示"明天 0 点恢复" |
| 429 | RATE_LIMITED | 触发频次限制 | 退避重试 |
| 422 | GENERATION_FAILED | 模型重试后仍失败 | 提示换主题,不扣额度 |
| 409 | QUESTION_LIMIT_REACHED | 个人题库达上限 | 引导删除/兑换 |
最容易糊在一起的是
PLAN_REQUIRED(该付费)和QUOTA_EXHAUSTED(等明天)。这两个状态混用,免费转订阅的路径就断了。
3.4 组件二:AI 出题流水线
提示词模板(服务端,可热更新,按题型分) ↓ 模型调用(超时 90 s,重试 2 次;仅 5xx/超时/空内容重试,4xx 立即失败) ↓ JSON 结构校验(字段缺失 / 选项少于 2 / 答案不在选项内 → 该题失败,不影响其余题) ↓ 答案归一(选择题标签→选项文本;判断题→true/false) ↓ 内容审核(输入一次 + 输出逐题一次) ↓ 落结果表(TTL 7 天)| 设计点 | 决策 | 理由 | 不这么做会怎样 |
|---|---|---|---|
| 提示词位置 | 服务端,按题型分模板 | 调参不发版;防反编译 | 每次改提示词都要走商店审核 |
| 4xx 处理 | 立即失败,不重试 | 鉴权/余额/参数错误重试一万次也不会成功 | 客户端日志刷屏,用户多等 3 倍超时 |
| 部分成功 | 单题级隔离 | 10 题里 1 题结构坏了不该让整体失败 | 用户等 20 秒拿到 0 题 |
| 答案归一位置 | 服务端归一下发,客户端只做兜底 | 判分口径必须单点定义 | 客户端与模型各写一套,必然对不上(见坑 1) |
额度分两层:credit(注册赠送,一次性、不过期)与daily(订阅每日刷新),先扣 credit 再扣 daily。
这么设计的原因很实际:如果免费用户是 0 额度、直接撞付费墙,他没见过 AI 出的题,凭什么订阅?送 3 题的成本约 0.009 元/人,但要把"换号刷券"堵住——同一设备只能领一次(设备指纹唯一约束),否则脚本能无限刷。
3.5 组件三:PostgreSQL 数据模型
四组表,分别解决四类问题。
① 公共题库(服务端唯一权威,客户端只读)
CREATE TABLE public_question ( id uuid PRIMARY KEY, topic_id uuid REFERENCES topic(id), category varchar(64) NOT NULL, content text NOT NULL, type varchar(16) NOT NULL, -- MULTIPLE_CHOICE/TRUE_FALSE/SHORT_ANSWER/FILL_BLANK difficulty varchar(16) NOT NULL, answer text NOT NULL, -- 已归一:选项文本 或 'true'/'false' explanation text NOT NULL DEFAULT '', source varchar(16) NOT NULL DEFAULT 'AI', audit_status smallint NOT NULL DEFAULT 0, -- 0 待审 / 1 通过 / 2 拒绝 content_hash char(64) NOT NULL, -- 题干+选项归一后哈希,用于去重 bank_version bigint NOT NULL, -- 变更时取全局递增版本号 updated_at timestamptz NOT NULL DEFAULT now(), deleted_at timestamptz ); CREATE UNIQUE INDEX ux_pub_q_hash ON public_question(content_hash) WHERE deleted_at IS NULL; CREATE INDEX ix_pub_q_pull ON public_question(bank_version);② 个人题库(服务端权威 + 客户端缓存)
CREATE TABLE user_question ( id uuid PRIMARY KEY, -- UUIDv7,客户端生成 user_id uuid NOT NULL REFERENCES app_user(id), public_question_id uuid REFERENCES public_question(id), -- 非空 = 派生自公共题 origin varchar(16) NOT NULL, -- AI / MANUAL / CLONED_FROM_PUBLIC / IMPORT content text NOT NULL, type varchar(16) NOT NULL, difficulty varchar(16) NOT NULL, answer text NOT NULL, is_favorite boolean NOT NULL DEFAULT false, version bigint NOT NULL DEFAULT 1, updated_at timestamptz NOT NULL DEFAULT now(), deleted_at timestamptz ); CREATE INDEX ix_uq_sync ON user_question(user_id, updated_at, id);| 决策 | 理由 |
|---|---|
| 主键 UUIDv7,客户端生成 | 离线也要能建题;时间有序,索引不退化 |
| 与公共题引用不复制 | 复制的话同一题在 N 个用户里存 N 份,运营改一次要么全量重推要么版本发散,统计还会把同一题算成多个题 |
收藏位放user_question覆盖层 | 公共题的收藏是"个人状态",不该写回公共表 |
updated_at驱动增量 | 配合游标(updated_at, id)做分页,避免全表扫描 |
③ 答题记录(唯一需要分区归档的表)
CREATE TABLE answer_record ( id uuid PRIMARY KEY, client_record_id uuid NOT NULL, -- 幂等键,客户端生成 user_id uuid NOT NULL, question_id uuid NOT NULL, question_scope varchar(8) NOT NULL, -- PUBLIC / USER user_answer text NOT NULL, correct_answer text NOT NULL, -- 快照,不随题目修订变化 is_correct boolean NOT NULL, answer_time timestamptz NOT NULL, duration_ms int NOT NULL DEFAULT 0, answer_mode varchar(16) NOT NULL, UNIQUE (user_id, client_record_id) ) PARTITION BY RANGE (answer_time);两个细节值得单说:
client_record_id唯一约束是离线重放的命门:断网时先落本地队列,恢复后批量上报,服务端靠它去重,网络抖动不会产生重复记录。correct_answer存的是答题当时的快照。如果只存question_id,运营修订题目后,历史正确率会集体漂移,跨期数据不可比。
④ 配额流水与新手券
ALTER TABLE app_user ADD COLUMN ai_credit int NOT NULL DEFAULT 0; ALTER TABLE app_user ADD COLUMN signup_granted_at timestamptz; CREATE TABLE ai_quota_ledger ( id uuid PRIMARY KEY, user_id uuid NOT NULL REFERENCES app_user(id), kind varchar(8) NOT NULL, -- GRANT / DAILY / ADMIN delta int NOT NULL, balance int NOT NULL, created_at timestamptz NOT NULL DEFAULT now() ); CREATE UNIQUE INDEX ux_ledger_signup ON ai_quota_ledger(user_id, kind) WHERE kind = 'GRANT';ux_ledger_signup是防重复发放的兜底:注册回调可能重试,同用户同类型只能有一条 GRANT。
3.6 组件四:客户端同步引擎
GET /v1/sync/pull?publicVersion=1234&userCursor=1700000000000&limit=500 POST /v1/sync/push { questions:[...], options:[...], records:[...] }| 规则 | 落地 | 理由 |
|---|---|---|
| 冲突裁决 | 字段级:updated_at新者胜;偏差超 5 分钟以服务端为准 | 单用户多设备,冲突概率低但必须确定 |
| 删除 | 墓碑(deleted_at) | 本地不知道别的设备删了什么,硬删会导致"复活" |
| 幂等 | 上传用 UUIDv7 /client_record_id,服务端ON CONFLICT DO NOTHING | 重放安全 |
| 断点 | 游标取"已成功应用的最后一条(updated_at, id)" | 避免重复拉取与漏拉 |
| 频率 | 前台 5 分钟最多一次;后台WorkManager(充电 + WiFi,最小 6 小时) | 别为了同步把电耗掉 |
Room 侧要升到 v8,关键新增列:
ALTER TABLE questions ADD COLUMN questionScope TEXT NOT NULL DEFAULT 'USER'; ALTER TABLE questions ADD COLUMN publicQuestionId TEXT; ALTER TABLE questions ADD COLUMN syncState INTEGER NOT NULL DEFAULT 0; -- 0 已同步/1 待上传/2 待删除 ALTER TABLE questions ADD COLUMN updatedAt INTEGER NOT NULL DEFAULT 0; ALTER TABLE questions ADD COLUMN deletedAt INTEGER; ALTER TABLE answer_records ADD COLUMN clientRecordId TEXT; ALTER TABLE answer_records ADD COLUMN uploaded INTEGER NOT NULL DEFAULT 0;一个容易忽略的点:本地选项表的主键是自增Long,跨设备根本不唯一。两种处理:换成 UUID 主键(改动大),或以(questionId, position)作为跨端业务键(改动小,推荐后者)。
3.7 组件五:锁屏链路的离线保证
这是整个方案里唯一不能妥协的地方:
解锁 → LockScreenManager 判定通过 ↓ 取题:只读本地 Room(公共缓存 + 个人题库) ↓ 本地可答题数 < 3 ? ├─ 是 → 用「最近用过的题」兜底;仍为 0 → 记录 skip 原因,跳过本次 └─ 否 → 正常弹题三条硬规则:
- 锁屏取题路径禁止任何网络调用(同步只在有网时后台跑)
- 本地可用题数低于阈值时,后台提前预取(
WorkManager,充电 + WiFi 优先) - 用户清空题库时保留兜底题(可来自内置种子包),避免"把自己锁死"
3.8 可运维性
| 项 | 做法 |
|---|---|
| 就绪判定 | BFF 暴露/healthz,检查 PG 与 Redis 连接 |
| 观测 | 每次 AI 调用记录requestId,出问题能按 ID 串起日志 |
| 预算熔断 | 当日模型花费超阈值,降级为"仅公共题库",不让账单失控 |
| 灰度 | AI 代理切换放远程开关,先 10% 用户,异常即回滚到直连 |
| 归档 | 答题记录按月分区,超 12 个月的分区导出对象存储后 detach |
4. 关键实现
4.1 答案归一:判分口径必须单点定义
// 服务端出题结果归一:选择题把标签翻译成选项文本 val label = generated.correctAnswer.trim() val index = generated.options.indexOfFirst { it.label.trim().equals(label, ignoreCase = true) } val answerText = if (index >= 0) generated.options[index].content else label // 判断题统一存 "true"/"false" val answer = if (generated.correctAnswer) "true" else "false"客户端的判分函数只保留一层兜底兼容,不再各写一套规则。这条约定写进了接口文档:选择题存选项文本、判断题存 true/false、填空题存缺失字词。
4.2 离线写入队列与幂等上报
viewModelScope.launch { val record = AnswerRecord.create( clientRecordId = UUID.randomUUID().toString(), // 幂等键,随记录一起上传 questionId = question.id, isCorrect = isCorrect, duration = duration, answerMode = if (isLockScreenMode) AnswerMode.LOCK_SCREEN else AnswerMode.PRACTICE ) answerRecordDao.insert(record) // 先落本地,uploaded = 0 }上传成功后把uploaded置 1;失败保留在队列里,按指数退避重试。队列上限 500 条,超限时对同一题只保留最后一次。
4.3 公共题库的分片缓存
按目标规模,10 万题 ×(题干约 400 B + 4 个选项约 320 B)≈72 MB,首装下载和每次修订下发都不可接受。所以客户端只缓存"订阅分类 + 最近用过的题",冷启动靠 APK 内置种子包。
但首批只有 50 道题(约 36 KB),完全可以整包缓存。这里的处理是:MVP 直接整包,但保留版本号与分片接口,等题量涨上去再启用分片——见坑 6。
5. 踩坑记录(共 6 个)
坑 1:AI 出的选择题永远判错
现象:手写测试题正确率显示 100%,但 AI 生成的题怎么答都是错,统计页正确率恒为 0。
原因:两处约定不一致。生成侧把模型返回的标签直接当答案存:
answer = generated.correctAnswer // 存的是 "A"判分侧却拿它和选项文本比:
isAnswer = currentQuestion.answer == option.text // "A" != "launch"判断题更隐蔽:存的是"正确"/"错误",界面比的是"true"/"false"。
解决:生成侧做一次"标签 → 文本"的翻译,判断题统一存true/false;判分抽成单一函数,供提交、计分、锁屏统计三处复用。
private fun isAnswerCorrect(question: Question, userAnswer: String): Boolean { val expected = question.answer.trim() val actual = userAnswer.trim() if (question.type == QuestionType.SHORT_ANSWER) { val e = normalizeForCompare(expected) val a = normalizeForCompare(actual) if (a.isEmpty() || e.isEmpty()) return false if (a == e) return true val shorter = minOf(a.length, e.length) val longer = maxOf(a.length, e.length) if (shorter < 2 || shorter.toFloat() / longer < 0.5f) return false return a.contains(e) || e.contains(a) } return actual.equals(expected, ignoreCase = true) }预防:把"判分口径"写进接口文档,并由服务端单点产出归一化答案;同一语义在写入侧和比较侧必须走同一个函数,不允许一边存标签一边比文本。
坑 2:模型密钥躺在 APK 里,还被打进了日志
现象:排查网络问题时,日志里直接出现完整的鉴权头与请求体:
I/okhttp.OkHttpClient: Authorization: Bearer <REDACTED> I/okhttp.OkHttpClient: {"model":"...","messages":[...]}反编译安装包也能直接读到assets里的密钥明文。
原因:两件事叠加。一是密钥放客户端资源文件(设计缺陷,只要装机就等于泄露);二是日志拦截器全局开了最高级别,把请求头和请求体都写进 logcat。
解决:短期先把日志按构建类型分级——debug 只记方法与状态码,release 彻底关闭:
val debuggable = (context.applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE) != 0 HttpLoggingInterceptor().apply { level = if (debuggable) HttpLoggingInterceptor.Level.BASIC else HttpLoggingInterceptor.Level.NONE }长期方案就是本文主线:密钥上移服务端,客户端只持有业务凭证。
预防:发布检查清单加两条硬项——"日志级别是否已关闭/降级""反编译确认无密钥字符串";密钥轮换作为事故预案,而不是发现有泄露才做。
坑 3:切到内网网关后请求全失败
现象:改了 baseUrl 之后所有请求直接失败,报错大意是明文 HTTP 不被允许。
原因:targetSdk >= 28起系统默认禁止明文 HTTP,而内网网关是http://。
解决:不要图省事开全局明文,用网络安全配置只放行目标域:
<network-security-config> <base-config cleartextTrafficPermitted="false" /> <domain-config cleartextTrafficPermitted="true"> <domain includeSubdomains="true">ai-gateway.internal</domain> </domain-config> </network-security-config>预防:明文放行必须"按域白名单";上架前确认该域能否改 HTTPS。顺带一个衍生问题——推理型模型会把输出预算花在思考内容上,输出上限给小了正文会返回空串,表现为"解析失败",对策是把空内容归为可重试并把上限提到 4096。
坑 4:答题记录表从头到尾没有写入点
现象:答完 4 道题,统计页仍然显示"暂无学习数据":
SELECT COUNT(*) FROM answer_records; -- 0原因:表、DAO、聚合查询、UI 全都齐了,唯独没有任何代码调用插入。这是典型的"链路两端都写了,中间断了"——而且因为空态文案设计得很温柔("开始答题后,这里将显示你的统计数据"),前几轮测试完全没发现。
解决:在提交答案的路径里落库,放在判分之后、UI 状态流转之前,保证"点过提交就有记录"。
预防:给每张表做一次"写入点盘点",确认至少有一处 insert/update/delete;空态文案不要过于"合理",否则会掩盖功能缺失。服务端化后这条更关键——多了上报队列,还要再确认一次"队列真的被消费了"。
坑 5:快速连点提交,一条答案落了两条记录
现象:压测时连点两次提交,答题记录变成两条,锁屏模式的"答对数"甚至超过题目数。
原因:submitAnswer没有任何重入防护。第一次提交后界面切到答案卡,但同一帧内的第二次点击仍会命中同一个按钮回调,于是再写一条记录、再加一次正确数。
解决:三重守卫——正在展示答案、整卷已完成、本题已有作答,任一成立即忽略:
if (currentState.showAnswer || currentState.isQuizCompleted) return if (userAnswers.containsKey(currentQuestion.id)) return预防:凡是"提交一次就该固化"的动作,都要有幂等键或状态守卫;服务端化后把这条经验升级成client_record_id唯一约束——客户端漏拦,数据库还能兜住。
坑 6:按目标规模设计了分片,结果首批只有 50 道题
现象:方案里按"10 万题、72 MB"设计了分片下发 + LRU 淘汰,评审时发现首批内容只有 50 道成语题(约 36 KB),整包下发就够,分片机制属于纯空转。
原因:容量假设写的是目标规模,但落地阶段是 MVP;设计文档没有把"当前规模"和"目标规模"分开标注,读的人(包括三个月后的自己)会按错的那个执行。
解决:保留版本号与分片接口(避免题量涨上来返工),MVP 阶段直接整包替换,代码里把阈值写成常量:
// 本地缓存策略:这批数据量小于阈值就整包替换,超过才走分片 private const val FULL_REFRESH_THRESHOLD = 2000预防:容量类设计一律双列标注(当前规模 / 目标规模),并在实现处留阈值开关;评审时专门问一句"这个机制今天有实际作用吗"。
6. 总结
方案要点回顾:
| 问题 | 结论 |
|---|---|
| AI 放哪 | 服务端 BFF,密钥与提示词都不下发;异步任务 + 轮询 |
| 公共题库放哪 | 只在服务端,客户端按需缓存(MVP 整包,扩展后分片) |
| 个人题库放哪 | 服务端权威 + 客户端缓存与离线队列(纯本地会丢数据,纯服务端会断网即死) |
| 冲突怎么解 | 字段级 LWW + 服务端裁决;删除用墓碑;主键客户端生成 |
| 额度怎么算 | credit(一次性)与daily(订阅)两层,先扣 credit;幂等账本防重复发放 |
| 成本 | 单用户模型成本上限约 1.8 元/月;MVP 单机 2C4G + 50 GB 足够 |
三条最值钱的经验:
- 权威源的定义比表结构重要。想清楚"谁是唯一真相",幂等键、墓碑、游标这些东西会自己长出来;反过来先设计表,一定会在同步阶段推翻重来。
- 判分/口径这类"双写"的地方必须单点定义。坑 1 和坑 5 本质是同一类错误——两处各写一套规则,然后在对不上时才发现。
- 平台上不可协商的约束要当设计输入。断网时锁屏必须能出题、明文 HTTP 被禁、后台不能随意启动页面——这些不是 bug,是硬边界,绕不过去就只能顺着设计。
后续计划:先落地填空题题型(严格判分、单行输入),再按里程碑推进 BFF 骨架、客户端 Room v8 迁移与同步引擎。
参考
- PostgreSQL 分区表
- Room 迁移与 AutoMigration
- Android 网络安全配置
- WorkManager 约束与周期任务
- UUIDv7 规范(RFC 9562)