【架构设计】教育类 App 服务端化改造:AI 上移、题库分层与个人题库归属

一个纯本地的 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 目标(可验收)

  1. 密钥不出服务端:APK 内不再出现任何模型凭证,抓包只见业务凭证
  2. 离线可用不回退 :断网状态下仍能答题,锁屏强制答题仍能触发
  3. 换机不丢:登录后个人题库、收藏、答题记录自动恢复
  4. 公共题库可运营:运营侧修订题目,客户端增量拉取,不发版
  5. 成本可控: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 总体架构

text 复制代码
┌──────────────────────────────────────────────────┐
│ 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 出题,带量级):

  1. App 发 POST /v1/ai/generations,请求体约 300 B(主题/类型/难度/数量)
  2. BFF 校验凭证 → 查额度(Redis 一次读,约 1 ms)
  3. 建任务行(status=PENDING),立刻返回 jobId,小于 50 ms,不等模型
  4. 后台 worker 取任务 → 服务端提示词模板 → 调模型
    • 单题:输入约 400 单位、输出约 900 单位,5-8 秒
    • 10 题两阶段:先出清单再并发补详情,12-18 秒
  5. 结果归一(答案口径)→ JSON 校验 → 内容审核 → 写结果表,任务置 SUCCEEDED
  6. App 轮询 GET /v1/ai/generations/{jobId}(2 秒一次,上限 60 秒)
  7. 用户点保存 → POST /v1/userbank/questions:batch 落个人题库(幂等键 = 客户端生成的 UUIDv7)

3.3 组件一:BFF 与接口契约

text 复制代码
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 出题流水线

text 复制代码
提示词模板(服务端,可热更新,按题型分)
   ↓
模型调用(超时 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 数据模型

四组表,分别解决四类问题。

① 公共题库(服务端唯一权威,客户端只读)

sql 复制代码
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);

② 个人题库(服务端权威 + 客户端缓存)

sql 复制代码
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) 做分页,避免全表扫描

③ 答题记录(唯一需要分区归档的表)

sql 复制代码
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,运营修订题目后,历史正确率会集体漂移,跨期数据不可比。

④ 配额流水与新手券

sql 复制代码
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 组件四:客户端同步引擎

text 复制代码
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,关键新增列:

sql 复制代码
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 组件五:锁屏链路的离线保证

这是整个方案里唯一不能妥协的地方:

text 复制代码
解锁 → LockScreenManager 判定通过
        ↓
取题:只读本地 Room(公共缓存 + 个人题库)
        ↓
本地可答题数 < 3 ?
   ├─ 是 → 用「最近用过的题」兜底;仍为 0 → 记录 skip 原因,跳过本次
   └─ 否 → 正常弹题

三条硬规则:

  1. 锁屏取题路径禁止任何网络调用(同步只在有网时后台跑)
  2. 本地可用题数低于阈值时,后台提前预取(WorkManager,充电 + WiFi 优先)
  3. 用户清空题库时保留兜底题(可来自内置种子包),避免"把自己锁死"

3.8 可运维性

项 做法
就绪判定 BFF 暴露 /healthz,检查 PG 与 Redis 连接
观测 每次 AI 调用记录 requestId,出问题能按 ID 串起日志
预算熔断 当日模型花费超阈值,降级为"仅公共题库",不让账单失控
灰度 AI 代理切换放远程开关,先 10% 用户,异常即回滚到直连
归档 答题记录按月分区,超 12 个月的分区导出对象存储后 detach

4. 关键实现

4.1 答案归一:判分口径必须单点定义

kotlin 复制代码
// 服务端出题结果归一:选择题把标签翻译成选项文本
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 离线写入队列与幂等上报

kotlin 复制代码
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。

原因:两处约定不一致。生成侧把模型返回的标签直接当答案存:

kotlin 复制代码
answer = generated.correctAnswer   // 存的是 "A"

判分侧却拿它和选项文本比:

kotlin 复制代码
isAnswer = currentQuestion.answer == option.text   // "A" != "launch"

判断题更隐蔽:存的是 "正确"/"错误",界面比的是 "true"/"false"。

解决 :生成侧做一次"标签 → 文本"的翻译,判断题统一存 true/false;判分抽成单一函数,供提交、计分、锁屏统计三处复用。

kotlin 复制代码
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 里,还被打进了日志

现象:排查网络问题时,日志里直接出现完整的鉴权头与请求体:

text 复制代码
I/okhttp.OkHttpClient: Authorization: Bearer <REDACTED>
I/okhttp.OkHttpClient: {"model":"...","messages":[...]}

反编译安装包也能直接读到 assets 里的密钥明文。

原因:两件事叠加。一是密钥放客户端资源文件(设计缺陷,只要装机就等于泄露);二是日志拦截器全局开了最高级别,把请求头和请求体都写进 logcat。

解决:短期先把日志按构建类型分级------debug 只记方法与状态码,release 彻底关闭:

kotlin 复制代码
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://。

解决:不要图省事开全局明文,用网络安全配置只放行目标域:

xml 复制代码
<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 道题,统计页仍然显示"暂无学习数据":

sql 复制代码
SELECT COUNT(*) FROM answer_records;   -- 0

原因:表、DAO、聚合查询、UI 全都齐了,唯独没有任何代码调用插入。这是典型的"链路两端都写了,中间断了"------而且因为空态文案设计得很温柔("开始答题后,这里将显示你的统计数据"),前几轮测试完全没发现。

解决:在提交答案的路径里落库,放在判分之后、UI 状态流转之前,保证"点过提交就有记录"。

预防:给每张表做一次"写入点盘点",确认至少有一处 insert/update/delete;空态文案不要过于"合理",否则会掩盖功能缺失。服务端化后这条更关键------多了上报队列,还要再确认一次"队列真的被消费了"。

坑 5:快速连点提交,一条答案落了两条记录

现象:压测时连点两次提交,答题记录变成两条,锁屏模式的"答对数"甚至超过题目数。

原因 :submitAnswer 没有任何重入防护。第一次提交后界面切到答案卡,但同一帧内的第二次点击仍会命中同一个按钮回调,于是再写一条记录、再加一次正确数。

解决:三重守卫------正在展示答案、整卷已完成、本题已有作答,任一成立即忽略:

kotlin 复制代码
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 阶段直接整包替换,代码里把阈值写成常量:

kotlin 复制代码
// 本地缓存策略:这批数据量小于阈值就整包替换,超过才走分片
private const val FULL_REFRESH_THRESHOLD = 2000

预防:容量类设计一律双列标注(当前规模 / 目标规模),并在实现处留阈值开关;评审时专门问一句"这个机制今天有实际作用吗"。

6. 总结

方案要点回顾:

问题 结论
AI 放哪 服务端 BFF,密钥与提示词都不下发;异步任务 + 轮询
公共题库放哪 只在服务端,客户端按需缓存(MVP 整包,扩展后分片)
个人题库放哪 服务端权威 + 客户端缓存与离线队列(纯本地会丢数据,纯服务端会断网即死)
冲突怎么解 字段级 LWW + 服务端裁决;删除用墓碑;主键客户端生成
额度怎么算 credit(一次性)与 daily(订阅)两层,先扣 credit;幂等账本防重复发放
成本 单用户模型成本上限约 1.8 元/月;MVP 单机 2C4G + 50 GB 足够

三条最值钱的经验:

  1. 权威源的定义比表结构重要。想清楚"谁是唯一真相",幂等键、墓碑、游标这些东西会自己长出来;反过来先设计表,一定会在同步阶段推翻重来。
  2. 判分/口径这类"双写"的地方必须单点定义。坑 1 和坑 5 本质是同一类错误------两处各写一套规则,然后在对不上时才发现。
  3. 平台上不可协商的约束要当设计输入。断网时锁屏必须能出题、明文 HTTP 被禁、后台不能随意启动页面------这些不是 bug,是硬边界,绕不过去就只能顺着设计。

后续计划:先落地填空题题型(严格判分、单行输入),再按里程碑推进 BFF 骨架、客户端 Room v8 迁移与同步引擎。

参考

相关推荐
墨狂之逸才1 小时前
USB 的 HOST 和 DEVICE 到底有什么区别?ADB 调试应该选哪个?
android
利威尔xu2 小时前
Go map 底层崩溃:3 个 goroutine 并发写同一张 map,fatal error 崩掉整个服务
golang·源码·map·并发·后端开发
奶油话梅糖2 小时前
苹果的内核跑上了安卓手机:2,631 Star 的 HakiOS 让 XNU 在 Adreno GPU 上画出 118 帧
android·macos·智能手机
恋猫de小郭2 小时前
Flutter THLC 的 WebView 滚动终于从 70 FPS 提升到 120 FPS
android·前端·flutter
Jeff-Tsai2 小时前
Day 15:API 契约分层与类型注册表
.net·架构设计·erp·definition-driven
Flynn Liang3 小时前
Apache Superset 重要特性详解(三):语义层 / 权限 / 缓存 / 嵌入的实战最佳实践(2026 版)
大数据·clickhouse·数据可视化·架构设计·superset·数据分析平台
好的佩奇13 小时前
Dart 之扩展类型
android·flutter·dart
alexhilton15 小时前
2027 Android 开发者路线图:学习、面试与 AI
android·kotlin·android jetpack
灿飞屋17 小时前
Kotlin与Java虚拟机三:控制流
android·java·开发语言·后端·python·kotlin·安卓