取餐记录定时补传方案(Android 端)
1. 方案结论
在不修改现有上传接口和服务端逻辑的前提下,由 Android 端增加取餐记录补传机制:
- 固定每 3 分钟触发一次补传检查。
- 每轮开始先检查网络,无网络时直接结束本轮,不修改记录状态和重试次数。
- 有网络时,从本地数据库取出最多 20 条待补传记录。
- 按本地记录 ID 升序逐条串行上传;必须等待上一条返回结果或超时后,才处理下一条。
- 每条记录处理完成后立即更新主记录状态,并追加一条不可覆盖的状态变更日志。
- 单条失败不阻塞后续记录;如果检测到网络已经断开,则停止当前批次,未处理记录保持原状态,等待下一轮。
- 同一时刻只允许一个补传任务执行,避免重复处理和并发上传。
本方案只实现 Android 端的“至少一次”补传。由于现有接口没有幂等键,网络超时、连接中断或进程被杀时,Android 端无法确认服务端是否已经成功入库,因此不能从根本上保证绝不重复。
2. 当前问题
当前取餐记录上传链路为:取餐结束后本地落库,立即调用 /p/api/zhctPushMeal,请求失败时在当前流程内最多重试 3 次,最终失败后将记录标记为 FAILED。
当前缺口:
PENDING、FAILED记录虽然能够被本地查询,但没有后台任务继续消费。- App 重启、网络恢复或页面切换后,不会自动补传失败记录。
- 只有主记录最终状态,没有完整的每次尝试和状态迁移轨迹。
- 数据积压后如果一次全部上传,容易长时间占用网络和后台线程。
本次检查的设备数据库中共有 86 条取餐记录,当前均为 UPLOADED,没有发现已积压的 PENDING 或 FAILED 记录。因此方案上线时,这 86 条记录不进入补传队列。
3. 目标和范围
3.1 目标
- 断网或临时请求失败后能够自动补传取餐记录。
- 以固定周期、固定单批上限控制补传压力。
- 保证设备端同一条记录不会被多个任务同时上传。
- 对每次处理的状态变化、结果和失败原因留痕。
- 异常数据和持续失败数据不会永久占用每轮 20 条的处理名额。
3.2 本次范围
- 只修改 Android 端。
- 继续使用现有
/p/api/zhctPushMeal接口和现有请求字段。 - 不修改服务端入库逻辑,不新增服务端查询或幂等接口。
- 不改变正常取餐结束后的首次实时上传流程;定时任务用于兜底补传。
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 状态判定规则
- 只有服务端明确返回成功码
code=0,才能标记为UPLOADED。 - 服务端明确返回业务拒绝码时标记为
REJECTED,不再盲目重试。 - 请求超时、连接失败、DNS 异常、响应解析失败、HTTP 异常等标记为
FAILED。 - 周期开始时无网络不属于某条记录的上传失败,不增加
retry_count。 - 本轮中途断网时,当前已经发出请求但未成功的记录按实际结果更新;尚未处理的记录保持原状态。
- 进入
UPLOADING只表示设备已开始尝试,不表示服务端已经收到。
6. 单轮处理逻辑
6.1 调度流程
AlarmManager 每 3 分钟触发
|
v
检查网络是否已连接
|
+-- 否:记录一次批次跳过日志,结束
|
+-- 是:提交唯一 OneTime WorkManager 任务
|
+-- 已有补传任务运行:跳过本次重复提交
|
+-- 无任务运行:进入补传 Worker
使用 AlarmManager 保证 3 分钟触发间隔,使用一次性 WorkManager 执行实际后台任务。不能使用 WorkManager 的周期任务代替,因为其周期下限不满足 3 分钟要求。
6.2 Worker 处理步骤
- 生成本轮唯一
batch_id。 - 再次检查网络;无网络则结束,不修改取餐记录。
- 将超过 10 分钟仍处于
UPLOADING的记录恢复为FAILED,并写恢复日志。 - 查询
PENDING、FAILED且retry_count < 10的记录,按pick_id ASC取最多 20 条,形成固定批次。 - 按批次顺序逐条处理:
- 上传前再次检查网络;断网则停止本批次。
- 校验本地数据。不符合上传条件时转为
IGNORED,继续下一条。 - 通过条件更新将记录从
PENDING/FAILED原子修改为UPLOADING。更新行数为 0 表示已被其他流程处理,跳过该条。 - 写入“开始上传”状态日志。
- 发起现有接口请求,等待返回、失败或超时。
- 根据结果更新主记录最终状态、重试次数和错误信息。
- 写入本次结果日志。
- 继续下一条。
- 记录本轮选取数、成功数、失败数、终态数、跳过数和耗时,结束任务。
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 |
REALTIME、PERIODIC、STALE_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/响应解析异常 | FAILED 或 FAILED_FINAL |
是 | 是 |
服务端 code=0 |
UPLOADED |
是 | 否;可单独记录总尝试次数 |
| 服务端明确业务拒绝 | REJECTED |
是 | 不再进入自动补传 |
| 取餐重量小于 5g | IGNORED |
是 | 否 |
| 必填数据缺失或格式无效 | IGNORED |
是 | 否 |
App 在 UPLOADING 时被杀 |
暂时保留 UPLOADING |
下轮先恢复 | 恢复动作不增加 |
UPLOADING 超过 10 分钟 |
恢复为 FAILED |
正常进入后续批次 | 否 |
失败次数达到 10 次后转为 FAILED_FINAL,防止某些永久失败记录长期占用每轮 20 条的名额。后续如需人工处理,可通过设备维护页或专用诊断命令重置为 FAILED,但不在本次实现范围内。
retry_count 统计同一条记录在实时上传和周期补传中的累计失败次数。现有实时流程内的失败尝试也必须通过统一上传协调器计入,避免实时重试和周期重试各自重新计数。
10. 并发与一致性控制
采用三层控制:
- 唯一后台任务:补传 Worker 使用固定唯一名称和
ExistingWorkPolicy.KEEP,已有任务运行时不重复创建。 - 进程内互斥:补传执行器在同一进程内只允许一个实例执行。
- 数据库原子占用:只有
PENDING/FAILED -> UPLOADING条件更新成功的记录才能发起请求。
实时上传与定时补传必须共用同一个记录占用和状态更新入口,避免取餐结束时的实时上传与 3 分钟补传同时处理同一条记录。
11. 调度生命周期
以下时机确保 3 分钟任务已安排:
- App 初始化完成后。
- 设备开机广播后。
- App 升级后首次启动。
- 调度广播执行后立即安排下一次触发。
设备重启、App 被系统杀死后,依靠开机广播和下一次 App 启动恢复调度。闹钟只负责触发,不在广播接收器中执行网络请求,避免广播超时。
12. 历史记录处理
数据库升级后:
- 现有
UPLOADED、REJECTED、IGNORED记录保持原状态,不进入补传。 - 现有
PENDING、FAILED记录初始化retry_count=0,进入周期补传队列。 - 现有异常遗留的
UPLOADING记录按占用超时规则恢复。 - 当前设备检查到的 86 条记录均为
UPLOADED,上线后不会被重新上传。
对于其他设备上历史 PENDING/FAILED 数据,由于服务端没有幂等能力,无法确认其中是否存在“服务端已成功、Android 未收到成功响应”的记录。选择补传这些记录能够优先保证不漏单,但存在少量重复入库的可能。
13. 重复数据风险边界
仅修改 Android 端时,以下场景无法完全消除重复:
- 服务端已经保存成功,但成功响应在网络中丢失。
- 服务端已经保存成功,但 App 在更新本地
UPLOADED前被杀死或断电。 - 请求超时发生在服务端已处理、客户端尚未收到结果的阶段。
Android 端可以通过串行处理、原子占用、唯一任务和状态日志减少本机并发导致的重复,但无法判断一次不确定请求是否已被服务端入库。因此本方案的交付语义是:
优先保证不漏传,允许极端异常下发生重复,即“至少一次”上传。
若未来允许修改接口,应增加设备端稳定唯一键,例如 device_code + pick_record_uuid,由服务端做唯一约束或幂等返回,才能实现接近“恰好一次”。
14. 预计 Android 端实现组件
| 组件 | 责任 |
|---|---|
PickMealRetryScheduler |
创建、恢复和续订 3 分钟闹钟。 |
PickMealRetryReceiver |
接收闹钟,检查基本条件并提交唯一 Worker。 |
PickMealRetryWorker |
执行单轮最多 20 条的串行补传。 |
PickMealUploadCoordinator |
统一实时上传和补传的占用、请求、状态转换。 |
PickMealRecordImpl |
查询批次、原子占用、更新状态、恢复超时记录。 |
PickMealUploadLogImpl |
追加状态变更日志和批次结果。 |
具体类名可按项目现有包结构微调,但职责边界不变。
15. 验收测试
15.1 核心用例
- 无网络:准备 5 条
FAILED,断网等待周期;5 条状态和重试次数均不变。 - 少于上限:准备 8 条,单轮只发起 8 次请求,全部按顺序完成。
- 超过上限:准备 21 条;第一轮最多处理 20 条,下一轮处理剩余 1 条。
- 大量积压:准备 45 条;正常情况下按 20、20、5 分三轮处理。
- 严格串行:记录请求开始和结束时间,后一条开始时间不得早于前一条结束时间。
- 单条失败继续:第 3 条返回失败但网络正常,第 4 条仍应继续上传。
- 中途断网:处理第 5 条时断网,本轮停止;第 6 条及以后保持原状态。
- 任务防重:上一轮未结束时再次触发,不产生第二个并行 Worker。
- 进程异常恢复:记录处于
UPLOADING后杀进程,超过 10 分钟后恢复为FAILED并重新进入队列。 - 失败封顶:同一条累计失败 10 次后进入
FAILED_FINAL,后续周期不再选中。 - 终态不重传:
UPLOADED、REJECTED、IGNORED、FAILED_FINAL均不得进入补传批次。 - 日志完整:每次占用、成功、失败、拒绝、忽略和超时恢复均能在日志表中查到前后状态及时间。
15.2 通过标准
- 固定 3 分钟触发,误差以 Android 系统闹钟实际调度能力为准。
- 单轮选取数量不超过 20。
- 同时在途的补传请求数量始终为 1。
- 无网络时不产生记录级失败和重试次数增长。
- 所有处理过的记录都有主状态和追加日志,二者可相互核对。
- 现有 86 条
UPLOADED历史记录保持不变。
16. 最终实施口径
本次方案实施时固定采用以下口径,不再保留多套策略分支:
- Android 端独立实现,接口和服务端不改。
- 固定每 3 分钟检查一次,不使用指数退避代替固定周期。
- 每轮最多 20 条,按最早记录优先。
- 20 条严格逐条等待结果,不并行上传。
- 网络可用时单条失败继续下一条;网络断开时停止本轮。
- 自动补传对象为
PENDING、FAILED且失败次数小于 10 的记录。 - 每次状态变更必须写追加日志。
- 超过 10 分钟的
UPLOADING自动恢复,失败 10 次进入FAILED_FINAL。 - 采用至少一次语义:优先防漏传,接受服务端无幂等情况下的极端重复风险。