取餐记录定时补传方案(Android 端)

1. 方案结论

在不修改现有上传接口和服务端逻辑的前提下,由 Android 端增加取餐记录补传机制:

本方案只实现 Android 端的“至少一次”补传。由于现有接口没有幂等键,网络超时、连接中断或进程被杀时,Android 端无法确认服务端是否已经成功入库,因此不能从根本上保证绝不重复。

2. 当前问题

当前取餐记录上传链路为:取餐结束后本地落库,立即调用 /p/api/zhctPushMeal,请求失败时在当前流程内最多重试 3 次,最终失败后将记录标记为 FAILED

当前缺口:

本次检查的设备数据库中共有 86 条取餐记录,当前均为 UPLOADED,没有发现已积压的 PENDINGFAILED 记录。因此方案上线时,这 86 条记录不进入补传队列。

3. 目标和范围

3.1 目标

3.2 本次范围

4. 固定运行参数

参数 固定值 说明
调度周期 3 分钟 每 180 秒触发一次检查。
单轮上限 20 条 不足 20 条时处理全部;超过 20 条留到后续轮次。
处理顺序 pick_id ASC 优先处理最早产生的记录。
上传方式 严格串行 上一条结束后再处理下一条,不并发请求。
最大失败次数 10 次 达到上限后进入 FAILED_FINAL,停止自动补传。
占用超时 10 分钟 UPLOADING 超过 10 分钟视为异常中断,恢复为 FAILED
并发任务数 1 使用唯一任务和记录原子占用双重防重。

“每轮 20 条”表示最多选取 20 条作为本轮固定批次,而不是同时发起 20 个请求。

5. 记录状态设计

状态 含义 是否进入自动补传队列
PENDING 已落库,尚未确认上传成功
UPLOADING 已被当前任务占用,正在上传
UPLOADED 服务端明确返回成功
FAILED 本次上传失败,仍可继续重试
FAILED_FINAL 失败次数达到 10 次,停止自动重试
REJECTED 服务端明确返回业务拒绝,例如现有 code=1
IGNORED 本地判定不应上传,例如取餐重量小于 5g 或关键字段无效

5.1 状态迁移

PENDING / FAILED
       |
       | 原子占用成功
       v
   UPLOADING
       |
       +-- 服务端 code=0 ----------------------> UPLOADED
       +-- 服务端明确业务拒绝 -----------------> REJECTED
       +-- 本地数据不满足上传条件 -------------> IGNORED
       +-- 请求失败且累计失败次数 < 10 --------> FAILED
       +-- 请求失败且累计失败次数 >= 10 -------> FAILED_FINAL

UPLOADING 超过 10 分钟
       |
       +----------------------------------------> FAILED

5.2 状态判定规则

6. 单轮处理逻辑

6.1 调度流程

AlarmManager 每 3 分钟触发
        |
        v
检查网络是否已连接
        |
        +-- 否:记录一次批次跳过日志,结束
        |
        +-- 是:提交唯一 OneTime WorkManager 任务
                         |
                         +-- 已有补传任务运行:跳过本次重复提交
                         |
                         +-- 无任务运行:进入补传 Worker

使用 AlarmManager 保证 3 分钟触发间隔,使用一次性 WorkManager 执行实际后台任务。不能使用 WorkManager 的周期任务代替,因为其周期下限不满足 3 分钟要求。

6.2 Worker 处理步骤

  1. 生成本轮唯一 batch_id
  2. 再次检查网络;无网络则结束,不修改取餐记录。
  3. 将超过 10 分钟仍处于 UPLOADING 的记录恢复为 FAILED,并写恢复日志。
  4. 查询 PENDINGFAILEDretry_count < 10 的记录,按 pick_id ASC 取最多 20 条,形成固定批次。
  5. 按批次顺序逐条处理:
    1. 上传前再次检查网络;断网则停止本批次。
    2. 校验本地数据。不符合上传条件时转为 IGNORED,继续下一条。
    3. 通过条件更新将记录从 PENDING/FAILED 原子修改为 UPLOADING。更新行数为 0 表示已被其他流程处理,跳过该条。
    4. 写入“开始上传”状态日志。
    5. 发起现有接口请求,等待返回、失败或超时。
    6. 根据结果更新主记录最终状态、重试次数和错误信息。
    7. 写入本次结果日志。
    8. 继续下一条。
  6. 记录本轮选取数、成功数、失败数、终态数、跳过数和耗时,结束任务。

6.3 伪代码

if (!networkConnected()) {
    logBatchSkipped("NO_NETWORK");
    return Result.success();
}

recoverStaleUploadingRecords(10, TimeUnit.MINUTES);
List<PickMealRecord> batch = queryRetryableRecords(20, "pick_id ASC");

for (PickMealRecord record : batch) {
    if (!networkConnected()) {
        logBatchStopped("NETWORK_LOST");
        break;
    }

    if (!isValidForUpload(record)) {
        changeStatus(record, IGNORED, "LOCAL_DATA_INVALID");
        continue;
    }

    if (!claimRecordAtomically(record, PENDING, FAILED, UPLOADING)) {
        continue;
    }

    UploadResult result = uploadAndWait(record);
    saveResultAndAppendLog(record, result);
}

7. 串行处理说明

20 条记录严格按以下方式执行:

第 1 条请求 -> 等待成功/失败/超时 -> 更新状态和日志
第 2 条请求 -> 等待成功/失败/超时 -> 更新状态和日志
……
第 20 条请求 -> 等待成功/失败/超时 -> 更新状态和日志

不采用 20 条并发上传,原因是:

如果一轮执行超过 3 分钟,下一次闹钟触发时发现唯一任务仍在运行,只记录“已有任务执行中”并跳过,不启动第二个任务。当前任务结束后,等待下一个 3 分钟周期继续处理。

8. 本地数据库调整

8.1 取餐记录表新增字段

字段 类型建议 默认值 用途
retry_count INTEGER 0 已实际发起但未成功的累计次数。
last_attempt_time TEXT/LONG 最近一次实际请求时间。
last_error_code TEXT 最近一次失败或拒绝代码。
last_error_message TEXT 最近一次错误摘要。
upload_updated_at TEXT/LONG 当前时间 最近一次上传状态更新时间。

建议增加组合索引:

(upload_status, retry_count, pick_id)

用于快速选取最早的可重试记录。

8.2 状态变更日志表

新增 pick_record_upload_log,日志只追加、不覆盖。

字段 说明
id 日志主键。
pick_record_id 对应本地取餐记录 ID。
batch_id 本轮批次唯一 ID。
trigger_type REALTIMEPERIODICSTALE_RECOVERY
before_status 变更前状态。
after_status 变更后状态。
attempt_no 本条记录第几次实际请求。
request_start_time 请求开始时间。
request_end_time 请求结束时间。
result_code 服务端业务码、HTTP 码或本地错误码。
error_type 网络、超时、解析、业务拒绝、本地校验等分类。
error_message 截断后的错误摘要,不记录敏感信息。
created_at 日志创建时间。

主记录保存当前状态,日志表保存完整历史,便于回答“哪一条从什么时候开始漏传、重试了几次、每次为什么失败”。

9. 失败与异常处理

场景 当前记录处理 是否继续下一条 是否增加重试次数
周期开始无网络 不选取记录 本轮结束
本轮中途网络断开 当前请求按结果落状态;未处理记录不变 停止本轮 仅实际失败的请求增加
请求超时/连接失败 FAILED 或达到上限后 FAILED_FINAL 网络仍在线则继续
HTTP/响应解析异常 FAILEDFAILED_FINAL
服务端 code=0 UPLOADED 否;可单独记录总尝试次数
服务端明确业务拒绝 REJECTED 不再进入自动补传
取餐重量小于 5g IGNORED
必填数据缺失或格式无效 IGNORED
App 在 UPLOADING 时被杀 暂时保留 UPLOADING 下轮先恢复 恢复动作不增加
UPLOADING 超过 10 分钟 恢复为 FAILED 正常进入后续批次

失败次数达到 10 次后转为 FAILED_FINAL,防止某些永久失败记录长期占用每轮 20 条的名额。后续如需人工处理,可通过设备维护页或专用诊断命令重置为 FAILED,但不在本次实现范围内。

retry_count 统计同一条记录在实时上传和周期补传中的累计失败次数。现有实时流程内的失败尝试也必须通过统一上传协调器计入,避免实时重试和周期重试各自重新计数。

10. 并发与一致性控制

采用三层控制:

  1. 唯一后台任务:补传 Worker 使用固定唯一名称和 ExistingWorkPolicy.KEEP,已有任务运行时不重复创建。
  2. 进程内互斥:补传执行器在同一进程内只允许一个实例执行。
  3. 数据库原子占用:只有 PENDING/FAILED -> UPLOADING 条件更新成功的记录才能发起请求。

实时上传与定时补传必须共用同一个记录占用和状态更新入口,避免取餐结束时的实时上传与 3 分钟补传同时处理同一条记录。

11. 调度生命周期

以下时机确保 3 分钟任务已安排:

设备重启、App 被系统杀死后,依靠开机广播和下一次 App 启动恢复调度。闹钟只负责触发,不在广播接收器中执行网络请求,避免广播超时。

12. 历史记录处理

数据库升级后:

对于其他设备上历史 PENDING/FAILED 数据,由于服务端没有幂等能力,无法确认其中是否存在“服务端已成功、Android 未收到成功响应”的记录。选择补传这些记录能够优先保证不漏单,但存在少量重复入库的可能。

13. 重复数据风险边界

仅修改 Android 端时,以下场景无法完全消除重复:

  1. 服务端已经保存成功,但成功响应在网络中丢失。
  2. 服务端已经保存成功,但 App 在更新本地 UPLOADED 前被杀死或断电。
  3. 请求超时发生在服务端已处理、客户端尚未收到结果的阶段。

Android 端可以通过串行处理、原子占用、唯一任务和状态日志减少本机并发导致的重复,但无法判断一次不确定请求是否已被服务端入库。因此本方案的交付语义是:

优先保证不漏传,允许极端异常下发生重复,即“至少一次”上传。

若未来允许修改接口,应增加设备端稳定唯一键,例如 device_code + pick_record_uuid,由服务端做唯一约束或幂等返回,才能实现接近“恰好一次”。

14. 预计 Android 端实现组件

组件 责任
PickMealRetryScheduler 创建、恢复和续订 3 分钟闹钟。
PickMealRetryReceiver 接收闹钟,检查基本条件并提交唯一 Worker。
PickMealRetryWorker 执行单轮最多 20 条的串行补传。
PickMealUploadCoordinator 统一实时上传和补传的占用、请求、状态转换。
PickMealRecordImpl 查询批次、原子占用、更新状态、恢复超时记录。
PickMealUploadLogImpl 追加状态变更日志和批次结果。

具体类名可按项目现有包结构微调,但职责边界不变。

15. 验收测试

15.1 核心用例

  1. 无网络:准备 5 条 FAILED,断网等待周期;5 条状态和重试次数均不变。
  2. 少于上限:准备 8 条,单轮只发起 8 次请求,全部按顺序完成。
  3. 超过上限:准备 21 条;第一轮最多处理 20 条,下一轮处理剩余 1 条。
  4. 大量积压:准备 45 条;正常情况下按 20、20、5 分三轮处理。
  5. 严格串行:记录请求开始和结束时间,后一条开始时间不得早于前一条结束时间。
  6. 单条失败继续:第 3 条返回失败但网络正常,第 4 条仍应继续上传。
  7. 中途断网:处理第 5 条时断网,本轮停止;第 6 条及以后保持原状态。
  8. 任务防重:上一轮未结束时再次触发,不产生第二个并行 Worker。
  9. 进程异常恢复:记录处于 UPLOADING 后杀进程,超过 10 分钟后恢复为 FAILED 并重新进入队列。
  10. 失败封顶:同一条累计失败 10 次后进入 FAILED_FINAL,后续周期不再选中。
  11. 终态不重传UPLOADEDREJECTEDIGNOREDFAILED_FINAL 均不得进入补传批次。
  12. 日志完整:每次占用、成功、失败、拒绝、忽略和超时恢复均能在日志表中查到前后状态及时间。

15.2 通过标准

16. 最终实施口径

本次方案实施时固定采用以下口径,不再保留多套策略分支:

  1. Android 端独立实现,接口和服务端不改。
  2. 固定每 3 分钟检查一次,不使用指数退避代替固定周期。
  3. 每轮最多 20 条,按最早记录优先。
  4. 20 条严格逐条等待结果,不并行上传。
  5. 网络可用时单条失败继续下一条;网络断开时停止本轮。
  6. 自动补传对象为 PENDINGFAILED 且失败次数小于 10 的记录。
  7. 每次状态变更必须写追加日志。
  8. 超过 10 分钟的 UPLOADING 自动恢复,失败 10 次进入 FAILED_FINAL
  9. 采用至少一次语义:优先防漏传,接受服务端无幂等情况下的极端重复风险。