【架构设计】教育类 App 服务端化改造:AI 上移、题库分层与个人题库归属 一个纯本地的 AI 刷题 App 要上架最硬的阻塞不是功能而是「模型密钥打在 APK 里」和「用户换手机题库全丢」。这篇给出完整改造方案AI 调用上移、公共题库与个人题库的分层存储、同步协议设计以及一个必须回答的问题——个人题库到底放服务端还是手机。1. 背景与目标1.1 现状项目是个 Android 端的学习监督 AppAI 出题 强制锁屏答题 定时提醒 题库管理 学习统计。技术栈 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 ms80-300 ms0 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 │ │ 主数据 │ │ 配额/限流/缓存 │ └──────┬───────┘ └────────────────┘ │ ┌──────▼───────────┐ ┌──────────────────┐ │ 模型网关外部 │ │ 运营后台 │ │ 服务端持有密钥 │ │ 题库审核/版本发布 │ └──────────────────┘ └──────────────────┘组件职责关键约束AppUI、判分、离线答题、同步引擎不持有任何模型凭证BFF鉴权、AI 代理、题库下发、同步、统计上报无状态可水平扩PostgreSQL用户、公共题库、个人题库、答题记录、AI 任务题库只读副本可加从库Redis每日配额、限流令牌桶、公共题库分片缓存可丢可重建不作权威运营后台公共题增删改、审核、版本发布独立于 App 发版节奏端到端走读一次 AI 出题带量级App 发POST /v1/ai/generations请求体约 300 B主题/类型/难度/数量BFF 校验凭证 → 查额度Redis 一次读约 1 ms建任务行statusPENDING立刻返回 jobId小于 50 ms不等模型后台 worker 取任务 → 服务端提示词模板 → 调模型单题输入约 400 单位、输出约 900 单位5-8 秒10 题两阶段先出清单再并发补详情12-18 秒结果归一答案口径→ JSON 校验 → 内容审核 → 写结果表任务置 SUCCEEDEDApp 轮询GET /v1/ai/generations/{jobId}2 秒一次上限 60 秒用户点保存 →POST /v1/userbank/questions:batch落个人题库幂等键 客户端生成的 UUIDv73.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 天定时清理支持切后台再回来继续也控制存储错误码必须可区分否则客户端只能弹一句请求失败用户不知道该付钱还是该等明天HTTPcode含义客户端表现401CREDENTIAL_EXPIRED访问凭证过期静默刷新后重试403PLAN_REQUIRED免费用户调用付费能力弹订阅页不显示错误429QUOTA_EXHAUSTED订阅用户当日额度用完提示明天 0 点恢复429RATE_LIMITED触发频次限制退避重试422GENERATION_FAILED模型重试后仍失败提示换主题不扣额度409QUESTION_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?publicVersion1234userCursor1700000000000limit500 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 个月的分区导出对象存储后 detach4. 关键实现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 个坑 1AI 出的选择题永远判错现象手写测试题正确率显示 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 cleartextTrafficPermittedfalse / domain-config cleartextTrafficPermittedtrue domain includeSubdomainstrueai-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 迁移与 AutoMigrationAndroid 网络安全配置WorkManager 约束与周期任务UUIDv7 规范RFC 9562