版本:v1.1(完整性复核修订版)
目标终端:Android 横屏消费终端
事实基线:2026-09-12 当前工作区;源码存在未提交的一卡通上传初步实现,本设计不覆盖其改动,而是在其上补齐可靠性和管理能力。
在不改变补贴优先、现金补足和业务接口 C 既有上报口径的前提下,实现消费记录的本地可靠保存、两个服务器的独立上传、自动重传和终端手动补传。
本方案必须保证:
当前数据库 consumption_records.db 已被并发中的初步实现升级为 V3:
consumption_records:一行对应一笔业务订单,三个阶段结果以 SQLite TEXT 保存 JSON 字符串。before_result_json:扣款前读卡结果。debit_result_json:扣款结果;其中 transactions 可保存补贴、现金两笔流水。after_result_json:扣款后读卡结果。onecard_upload_records,按钱包保存一卡通请求 JSON 和简单的 PENDING/UPLOADED 状态。AtomicBoolean 防重;数据库没有跨组件原子认领。因此当前 V3 是可复用的初步基线,但尚不能满足断电保护、并发防重、结果不确定、配置切换和手动管理要求。本设计目标数据库版本为 V4。
主流程:
| 步骤 | 系统行为 | 数据变化 | 验收 |
|---|---|---|---|
| M1 | 取得订单 | 立即插入主表 | AC-001 |
| M2 | 扣款前读卡成功 | 更新 before_result_json | AC-002 |
| M3 | 每个钱包扣款前 | 先写入该段 DEBIT_INTENT | AC-003 |
| M4 | 某钱包返回确认结果 | 同一事务更新该段结果并插入一卡通任务 | AC-004 |
| M5 | 完成后读卡与整单判定 | 更新整单状态;符合口径时创建业务任务 | AC-005 |
| M6 | 协调器原子认领任务 | 写入 UPLOADING、租约和尝试次数 | AC-006 |
| M7 | 服务器明确成功 | 仅持有当前租约者写入 UPLOADED | AC-007 |
主要异常与恢复:
| 编号 | 场景 | 处理 |
|---|---|---|
| E1/R1 | 第一钱包成功、第二钱包失败或断电 | 第一笔结果和一卡通任务已独立落库;不再次扣第一笔 |
| E2/R2 | 认领后进程退出 | 已确认服务端幂等的通道可回收并换租约重试;未确认幂等的通道转结果不确定;旧执行者晚响应因租约不匹配而失效 |
| E3/R3 | 请求超时且不能判断服务端是否已入账 | FAILED + result_uncertain=1 + auto_retry_allowed=0,禁止盲目重发 |
| E4/R4 | 当前服务器配置与任务快照不一致 | 标记配置不匹配并阻止发送;配置恢复匹配后重新进入可处理状态 |
| E5/R5 | 本地数据库或磁盘空间不足 | 扣款前停止新交易并明确告警,不允许“先扣款、后发现无法保存” |
主流程:
| 步骤 | 用户/系统行为 | 验收 |
|---|---|---|
| M1 | 仅在 WAITING_TRAY 且没有锁定订单、读卡或扣款任务时允许进入 | AC-101 |
| M2 | 页面查询至少一个通道未完成的订单 | AC-102 |
| M3 | 两列网格展示两个通道状态并上拉加载 | AC-103 |
| M4 | 点击单笔“上传未完成”或底部“全部上传” | AC-104 |
| M5 | 页面持续观察数据库;后台成功也会使卡片移除 | AC-105 |
本版本明确不提供批量勾选、“全选”或“上传当前已加载”。“全部上传”表示处理点击瞬间数据库中已经存在的全部可处理任务;点击后新产生的任务不属于本次范围。
consumption_records(一笔业务订单)
├── 订单交易三阶段 JSON
├── 业务服务器上传任务:0 或 1 个
└── onecard_upload_records(真实钱包扣款)
├── 补贴扣款:0 或 1 条
└── 现金扣款:0 或 1 条
不能只在主表增加一个一卡通状态字段。两笔钱包流水可能一笔成功、一笔失败,必须在子表逐笔保存和重传;主表页面状态由子表聚合得出。
try/catch 边界内运行。consumption_records保留 V3 所有订单、交易和业务上传字段,新增:
report_failure_category TEXT,
report_auto_retry_allowed INTEGER NOT NULL DEFAULT 1,
report_result_uncertain INTEGER NOT NULL DEFAULT 0,
report_lease_id TEXT,
report_destination_key TEXT NOT NULL DEFAULT '',
report_payload_schema_version INTEGER NOT NULL DEFAULT 1,
report_enqueued_at INTEGER NOT NULL DEFAULT 0,
report_upload_started_at INTEGER NOT NULL DEFAULT 0,
report_uploaded_at INTEGER NOT NULL DEFAULT 0
业务字段规则:
report_status 保存订单级业务上传状态。report_lease_id 是一次认领的随机令牌,不是手动/后台归属。report_destination_key 是任务创建时对业务 API 基址、设备/租户身份和配置版本生成的稳定指纹;不保存口令、Token 或密钥。report_payload_schema_version 用于未来兼容旧请求快照。report_attempts 在认领成功、真正准备发送时加 1,不能等响应后再加。onecard_upload_records沿用当前 V3 物理表名,V4 通过重建临时表后复制数据补齐约束:
CREATE TABLE onecard_upload_records_v4 (
id INTEGER PRIMARY KEY AUTOINCREMENT,
order_no TEXT NOT NULL,
flow_id TEXT NOT NULL,
segment_index INTEGER NOT NULL,
wallet_type TEXT NOT NULL,
record_json TEXT NOT NULL,
payload_schema_version INTEGER NOT NULL DEFAULT 1,
destination_key TEXT NOT NULL DEFAULT '',
upload_status TEXT NOT NULL,
upload_attempts INTEGER NOT NULL DEFAULT 0,
failure_category TEXT,
auto_retry_allowed INTEGER NOT NULL DEFAULT 1,
result_uncertain INTEGER NOT NULL DEFAULT 0,
upload_lease_id TEXT,
last_upload_error TEXT,
next_retry_at INTEGER NOT NULL DEFAULT 0,
upload_enqueued_at INTEGER NOT NULL DEFAULT 0,
upload_started_at INTEGER NOT NULL DEFAULT 0,
uploaded_at INTEGER NOT NULL DEFAULT 0,
psam_id TEXT,
psam_transaction_no INTEGER,
tac INTEGER,
psam_confirmed INTEGER NOT NULL DEFAULT 1,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
FOREIGN KEY(order_no) REFERENCES consumption_records(order_no) ON DELETE CASCADE,
UNIQUE(order_no, segment_index)
);
不直接建立 UNIQUE(psam_id, psam_transaction_no),因为 PSAM 流水号是否复位或回绕尚需现场确认。应用层使用以下组合做审计和软去重:
destination_key + terminal_id + psam_id + psam_transaction_no + card_no + wallet_type
待现场确认流水号生命周期后,再决定是否建立不会误伤合法交易的数据库唯一索引。
建议索引:
CREATE INDEX idx_consumption_report_pending
ON consumption_records(report_status, report_auto_retry_allowed,
next_retry_at, created_at);
CREATE INDEX idx_onecard_upload_pending
ON onecard_upload_records(upload_status, auto_retry_allowed,
next_retry_at, created_at);
CREATE INDEX idx_onecard_upload_order
ON onecard_upload_records(order_no, segment_index);
数据库连接必须启用外键;迁移测试必须实际执行 PRAGMA foreign_keys=ON 并验证级联和约束。
两个通道共用五个主状态:
| 状态 | 含义 | 可否进入普通重试 |
|---|---|---|
NOT_REQUIRED | 按业务规则无需上传 | 否 |
PENDING | 已入队,等待发送 | 是 |
UPLOADING | 已被一个租约认领 | 否,其他执行者只能观察 |
FAILED | 最近一次未成功 | 取决于辅助标记 |
UPLOADED | 服务端已明确确认成功 | 否 |
FAILED 必须结合以下字段解释:
failure_category:NETWORK、SERVER、CONFIG、DATA、RESULT_UNKNOWN、LOCAL。auto_retry_allowed=1:允许自动退避重试。result_uncertain=1:服务端是否已入账不明确,自动和手动都不得直接重发。PENDING。状态字符串、失败类别、页大小、租约时长、错误最大长度和退避参数必须集中在枚举/命名常量中。
原“交易结束后一次性保存”的设计不足以覆盖断电窗口,改为以下强制顺序:
ORDER_ACQUIRED。before_result_json;失败则写本地失败终态。DEBIT_INTENT 写入 debit_result_json 并提交事务。ConsumptionResult 后,在同一事务中:DEBIT_INTENT 更新为 DEBIT_CONFIRMED;debit_result_json.transactions;DEBIT_INTENT 必须保留;立即停止后续钱包,将订单标记为结果待核查,禁止再次扣该段。由于硬件扣款和 SQLite 无法组成同一物理事务,这个意图记录是断电恢复的最小安全证据。after_result_json 和整单交易分类。debit_result_json 需要兼容旧结构,并为每段增加稳定状态:
{
"schema_version": 2,
"requested_amount": 500,
"segments": [
{
"sequence": 0,
"wallet": "SUBSIDY",
"planned_amount": 300,
"state": "DEBIT_CONFIRMED",
"intent_at": 1789180000000,
"confirmed_at": 1789180001000,
"transaction": {}
},
{
"sequence": 1,
"wallet": "MAIN",
"planned_amount": 200,
"state": "DEBIT_INTENT",
"intent_at": 1789180002000
}
]
}
当 psam_confirmed=false 但卡扣款返回已包含有效金额、计数器和 TAC 时,不得丢弃该真实流水或再次扣卡;仍创建并上传一卡通任务,并保存 psam_confirmed=0 供日志和现场核查。该字段表示本地 PSAM 确认步骤异常,不改变已经发生的卡扣款事实。
| 场景 | 主表 | 一卡通子任务 | 一卡通上传 | 业务上传 |
|---|---|---|---|---|
| 仅现金扣款完整成功 | 1 | 1 | 1 条 | 1 笔订单 |
| 仅补贴扣款完整成功 | 1 | 1 | 1 条 | 1 笔订单 |
| 补贴加现金完整成功 | 1 | 2 | 2 条独立流水 | 1 笔订单 |
| 第一钱包成功、第二钱包失败 | 1 | 1 | 只上传已确认的第一笔 | NOT_REQUIRED,沿用接口 C 当前口径 |
| 扣款结果未知 | 1 | 只保存可证明已完成的流水 | 可证明的流水按规则处理 | NOT_REQUIRED,不伪造失败上报 |
| 后读卡失败但扣款流水已确认 | 1 | 已确认流水均保存 | 逐笔上传 | NOT_REQUIRED,沿用当前口径 |
| 零元单读卡成功 | 1 | 0 | NOT_REQUIRED | 1 笔零元订单 |
| 零元单读卡失败 | 1 条失败记录 | 0 | NOT_REQUIRED | NOT_REQUIRED |
ConsumptionUploadCoordinator 是自动和手动上传的唯一业务入口。业务和一卡通可各有 Runner/Gateway,但都必须经过协调器的认领、结果提交和状态通知。
认领操作在单条 SQL 或单个 SQLite 事务中完成:
UPDATE onecard_upload_records
SET upload_status = 'UPLOADING',
upload_lease_id = :newLeaseId,
upload_started_at = :now,
upload_attempts = upload_attempts + 1,
updated_at = :now
WHERE id = :id
AND upload_status IN ('PENDING', 'FAILED')
AND auto_retry_allowed = 1
AND result_uncertain = 0
AND next_retry_at <= :eligibleAt;
手动重试可以绕过普通退避时间,但不能绕过 UPLOADING、结果不确定、数据错误或配置不匹配保护。
成功、失败和失效回收都必须携带当前 upload_lease_id:
UPDATE onecard_upload_records
SET upload_status = :newStatus, ...
WHERE id = :id
AND upload_status = 'UPLOADING'
AND upload_lease_id = :leaseId;
受影响行数为 0 表示租约已变化,旧执行者的晚响应只记录日志,不能覆盖新状态。
NETWORK、可恢复 SERVER:指数退避、上限和抖动均使用命名常量。CONFIG:阻止发送;配置恢复为任务快照后可重新激活。DATA:不自动重试,页面提示联系维护人员。RESULT_UNKNOWN:result_uncertain=1,自动和手动都不得盲目重发。LOCAL:数据库或序列化错误,记录并告警,不把任务误标成功。order_no 作为幂等业务键,并保持接口 C 现有判定。SAMCardNO + SAMTradeNO 及终端/目标服务器身份实现服务端幂等或查询确认。UPLOADING 超时就重新发送。UploadBusinessRecResult 符合现场确认的成功契约,才能写 UPLOADED。创建任务时必须同时保存请求快照和目标身份指纹:
| 通道 | 指纹输入 |
|---|---|
| 一卡通 | 规范化服务 URL、AppID、AccCode、TermID、配置版本 |
| 业务 | API 基址/服务器档案、设备编码、租户或站点身份、配置版本 |
上传前重新计算当前指纹:
CONFIG,不把旧交易发送到新服务器。PENDING。指纹只保存非秘密配置的规范化摘要;不得把 Token、密码、证书私钥写入数据库或日志。
入口位于消费主页顶部,展示待处理订单数量角标。只有同时满足以下条件才允许进入:
WAITING_TRAY。WAITING_CARD 已经绑定当前订单,不属于安全入口状态。入口不可用时显示“当前交易处理中,请完成后再查看”。进入页面停止消费页自身的读卡监听,返回后按生命周期恢复;后台上传不停止。
满足任一条件的订单才展示:
业务状态属于 PENDING / UPLOADING / FAILED
或
存在一卡通子任务属于 PENDING / UPLOADING / FAILED
两端均为 UPLOADED 或 NOT_REQUIRED 时不展示。页面标题数量按“订单数”统计,不按一卡通流水数统计。
一卡通聚合优先级固定为:
结果不确定 > 上传失败 > 上传中 > 待上传 > 全部已上传 > 无需上传
例如两笔一卡通流水一笔成功、一笔失败,显示红色“上传失败 1/2”,不能仅显示黄色“部分完成”。
页面显示:
当前第 1 / 2 页 · 已加载 4 / 8 条 · 上拉加载下一页
全部加载后显示“已加载全部”。排序为:结果不确定、失败、上传中、待上传,再按最早创建时间和订单号稳定排序。
后台完成会动态移除数据,因此不能使用固定 OFFSET 追加。页面维护 loadedPageCount,每次刷新重新查询前 loadedPageCount × PAGE_SIZE 条,并重新计算待处理总数、总页数和当前已加载页数,防止漏单或重复。
rowid、一卡通最大 id 和创建时间;Worker 只处理边界内任务。UPLOADING 时继续显示上传中,不提前提示成功。最终页面参考:prototypes/2026-09-12-consumption-order-upload/pending-orders-grid-v6.html。
新增 ConsumptionUploadStatusChangedEvent,只表达“数据库状态已变化”。页面收到事件后重新查询,不直接渲染事件携带的旧对象。
刷新时机:
onResume()。后台刚好上传成功时:数据库先提交 UPLOADED,再发事件;页面查询后自然移除卡片。不存在“这笔算手动还是后台”的认领统计。
UPLOADED/NOT_REQUIRED 的旧记录。PENDING、UPLOADING、FAILED、结果不确定、配置不匹配和交易异常记录永不被普通清理任务删除。android:allowBackup="true" 不得把消费数据库带入云备份或设备迁移;V4 增加备份排除规则,至少排除 consumption_records.db 及其 WAL/SHM。升级链必须逐级、事务化执行:
V1 -> V2 -> V3 -> V4
V4 步骤:
onecard_upload_records_v4。PENDING/UPLOADED 状态保持,旧任务的指纹为空时标记为需配置确认,不直接发送。onecard_upload_records。foreign_key_check、行数和状态数量校验。updated_at 回填 report_uploaded_at。debit_result_json 自动生成一卡通任务,避免历史重复入账。SQLite 结构升级不可自动降级,旧 V3 APK 不能被假定能够打开 V4 数据库。发布回滚必须使用“保留 V4 schema 的前向修复 APK”,或先导出并验证数据后执行受控降级;不得直接安装旧 APK 或通过删除数据库回滚。
consumption/upload/
├── UploadStatus.java 状态枚举
├── UploadFailureCategory.java 失败分类
├── UploadLease.java 租约令牌和值对象
├── UploadDestinationFingerprint.java 配置规范化和摘要
├── ConsumptionUploadCoordinator.java 手动/自动统一认领和结果提交
├── ConsumptionUploadScope.java 单笔和高水位全部范围
├── ConsumptionUploadStatusChangedEvent.java 数据变化通知
└── UploadRetryPolicy.java 退避、抖动和重试资格
consumption/report/
├── ConsumptionRecordRepository.java
├── OnecardUploadRepository.java
├── SQLiteConsumptionRecordRepository.java
├── ConsumptionReportSyncRunner.java
└── OnecardUploadSyncRunner.java
onecard/reader/
├── CardReader.java
├── WalletDebitObserver.java 每钱包意图/确认回调边界
├── ConsumptionCardTransactionExecutor.java
└── NewcapecUsbCardReader.java
main/
└── PendingConsumptionUploadActivity.java
consumption/upload/ui/
├── PendingConsumptionOrderAdapter.java
├── PendingConsumptionOrderViewData.java
└── PendingConsumptionOrderMapper.java
workmanager/
├── ConsumptionReportSyncWorker.java
└── ManualConsumptionUploadWorker.java
新增或职责变化的类必须写职责/边界注释;逐钱包持久化、租约晚响应、高水位快照和动态分页要写“为什么”的注释。文案、颜色、尺寸和阈值进入资源或命名常量,不散落硬编码。
| AC | 场景 | 预期证据 |
|---|---|---|
| AC-001 | 取得订单后进程退出 | 主表仍有 ORDER_ACQUIRED |
| AC-003 | 钱包扣款调用前断电模拟 | 存在 DEBIT_INTENT,不伪造成功 |
| AC-004 | 第一钱包成功、第二钱包失败 | 第一笔 JSON 和一卡通任务都存在 |
| AC-006 | 自动与手动同时认领 | 仅一个租约取得发送权 |
| AC-007 | 旧租约晚响应 | 影响行数为 0,不覆盖新状态 |
| AC-008 | 服务端写入后客户端超时 | 进入结果不确定,自动/手动不重发 |
| AC-009 | 任务创建后切换服务器 | 配置不匹配,旧任务不发到新服务器 |
| AC-101 | WAITING_CARD 点击入口 | 禁止进入并提示交易处理中 |
| AC-102 | 两端均完成 | 页面不展示该订单 |
| AC-103 | 两条一卡通任务一条上传成功、一条上传失败 | 红色显示“一卡通上传失败 1/2” |
| AC-104 | 点击全部上传后产生新订单 | 新订单不属于本次高水位范围 |
| AC-105 | 页面打开时后台完成订单 | 卡片自动移除,后台不暂停 |
| AC-106 | 零元读卡成功 | 无一卡通任务,有业务任务 |
| AC-107 | 低磁盘空间 | 扣款前阻止新交易并告警 |
| AC-108 | 备份规则检查 | 消费数据库不进入 Android 备份 |
自动化层级:
git diff --check。以下事项不阻塞本地代码和测试实施,但阻塞现场启用一卡通自动重传:
UploadBusinessRecResult 的成功值规则,不能仅凭“rtn > 100”推断。SAMCardNO + SAMTradeNO 幂等,或提供交易查询确认接口。门槛未完成时,一卡通自动/手动重传入口必须由集中配置开关关闭;本地流水与任务仍正常保存,页面明确显示“上传能力待现场确认”,不能静默丢单或按暂定成功码投入生产。
本版本页面只保留:
上传未完成。全部上传。明确移除:
全选。上传当前已加载。最终交互以本设计为准;早期原型中仍出现上述已移除控件的部分不再作为实现依据。