2026-09-12 消费记录双服务器上传与待上传订单管理设计

版本:v1.1(完整性复核修订版)
目标终端:Android 横屏消费终端
事实基线:2026-09-12 当前工作区;源码存在未提交的一卡通上传初步实现,本设计不覆盖其改动,而是在其上补齐可靠性和管理能力。

1. 目标与范围

在不改变补贴优先、现金补足和业务接口 C 既有上报口径的前提下,实现消费记录的本地可靠保存、两个服务器的独立上传、自动重传和终端手动补传。

本方案必须保证:

2. 非目标

3. 当前实现基线

当前数据库 consumption_records.db 已被并发中的初步实现升级为 V3:

因此当前 V3 是可复用的初步基线,但尚不能满足断电保护、并发防重、结果不确定、配置切换和手动管理要求。本设计目标数据库版本为 V4。

4. AI 可执行 Use Case

4.1 UC-CONSUMPTION-UPLOAD-001:可靠保存并独立上传消费记录

主流程:

步骤系统行为数据变化验收
M1取得订单立即插入主表AC-001
M2扣款前读卡成功更新 before_result_jsonAC-002
M3每个钱包扣款前先写入该段 DEBIT_INTENTAC-003
M4某钱包返回确认结果同一事务更新该段结果并插入一卡通任务AC-004
M5完成后读卡与整单判定更新整单状态;符合口径时创建业务任务AC-005
M6协调器原子认领任务写入 UPLOADING、租约和尝试次数AC-006
M7服务器明确成功仅持有当前租约者写入 UPLOADEDAC-007

主要异常与恢复:

编号场景处理
E1/R1第一钱包成功、第二钱包失败或断电第一笔结果和一卡通任务已独立落库;不再次扣第一笔
E2/R2认领后进程退出已确认服务端幂等的通道可回收并换租约重试;未确认幂等的通道转结果不确定;旧执行者晚响应因租约不匹配而失效
E3/R3请求超时且不能判断服务端是否已入账FAILED + result_uncertain=1 + auto_retry_allowed=0,禁止盲目重发
E4/R4当前服务器配置与任务快照不一致标记配置不匹配并阻止发送;配置恢复匹配后重新进入可处理状态
E5/R5本地数据库或磁盘空间不足扣款前停止新交易并明确告警,不允许“先扣款、后发现无法保存”

4.2 UC-CONSUMPTION-UPLOAD-002:查看并补传未完成订单

主流程:

步骤用户/系统行为验收
M1仅在 WAITING_TRAY 且没有锁定订单、读卡或扣款任务时允许进入AC-101
M2页面查询至少一个通道未完成的订单AC-102
M3两列网格展示两个通道状态并上拉加载AC-103
M4点击单笔“上传未完成”或底部“全部上传”AC-104
M5页面持续观察数据库;后台成功也会使卡片移除AC-105

本版本明确不提供批量勾选、“全选”或“上传当前已加载”。“全部上传”表示处理点击瞬间数据库中已经存在的全部可处理任务;点击后新产生的任务不属于本次范围。

5. 核心模型

5.1 一张订单主表加一张一卡通流水子表

consumption_records(一笔业务订单)
├── 订单交易三阶段 JSON
├── 业务服务器上传任务:0 或 1 个
└── onecard_upload_records(真实钱包扣款)
    ├── 补贴扣款:0 或 1 条
    └── 现金扣款:0 或 1 条

不能只在主表增加一个一卡通状态字段。两笔钱包流水可能一笔成功、一笔失败,必须在子表逐笔保存和重传;主表页面状态由子表聚合得出。

5.2 两个通道互不阻塞

6. V4 数据库设计

6.1 主表 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

业务字段规则:

6.2 子表 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 并验证级联和约束。

6.3 状态和辅助标记

两个通道共用五个主状态:

状态含义可否进入普通重试
NOT_REQUIRED按业务规则无需上传否
PENDING已入队,等待发送是
UPLOADING已被一个租约认领否,其他执行者只能观察
FAILED最近一次未成功取决于辅助标记
UPLOADED服务端已明确确认成功否

FAILED 必须结合以下字段解释:

状态字符串、失败类别、页大小、租约时长、错误最大长度和退避参数必须集中在枚举/命名常量中。

7. 逐阶段、逐钱包持久化

原“交易结束后一次性保存”的设计不足以覆盖断电窗口,改为以下强制顺序:

  1. 取得订单后立即插入主表,状态 ORDER_ACQUIRED。
  2. 扣款前读卡成功后立即写 before_result_json;失败则写本地失败终态。
  3. 首次读卡后先校验并冻结生成一卡通任务必需的卡信息、终端号、AccCode 和目标服务器指纹;无法形成可靠任务上下文时不得开始扣款。
  4. 每个钱包实际调用硬件扣款前,先把该段计划以 DEBIT_INTENT 写入 debit_result_json 并提交事务。
  5. 每个钱包取得包含金额、余额、计数器、PSAM 流水号和 TAC 的 ConsumptionResult 后,在同一事务中:
  1. 如果确认结果写库失败,扣款前留下的 DEBIT_INTENT 必须保留;立即停止后续钱包,将订单标记为结果待核查,禁止再次扣该段。由于硬件扣款和 SQLite 无法组成同一物理事务,这个意图记录是断电恢复的最小安全证据。
  2. 第一钱包成功后,即使第二钱包失败、线程异常或进程退出,第一笔已扣流水和上传任务仍然存在。
  3. 后读卡结束后更新 after_result_json 和整单交易分类。
  4. 只有符合接口 C 现有上报口径的完整成功订单或零元读卡成功订单,才创建业务上传任务。
  5. 所有本地事务提交成功后才允许触发网络调度。

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 确认步骤异常,不改变已经发生的卡扣款事实。

8. 各订单类型的保存与上传口径

场景主表一卡通子任务一卡通上传业务上传
仅现金扣款完整成功111 条1 笔订单
仅补贴扣款完整成功111 条1 笔订单
补贴加现金完整成功122 条独立流水1 笔订单
第一钱包成功、第二钱包失败11只上传已确认的第一笔NOT_REQUIRED,沿用接口 C 当前口径
扣款结果未知1只保存可证明已完成的流水可证明的流水按规则处理NOT_REQUIRED,不伪造失败上报
后读卡失败但扣款流水已确认1已确认流水均保存逐笔上传NOT_REQUIRED,沿用当前口径
零元单读卡成功10NOT_REQUIRED1 笔零元订单
零元单读卡失败1 条失败记录0NOT_REQUIREDNOT_REQUIRED

9. 上传协调、租约与重试

9.1 唯一业务入口

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 表示租约已变化,旧执行者的晚响应只记录日志,不能覆盖新状态。

9.2 失败分类和退避

9.3 结果不确定和幂等

9.4 调度边界

10. 目标服务器配置快照

创建任务时必须同时保存请求快照和目标身份指纹:

通道指纹输入
一卡通规范化服务 URL、AppID、AccCode、TermID、配置版本
业务API 基址/服务器档案、设备编码、租户或站点身份、配置版本

上传前重新计算当前指纹:

指纹只保存非秘密配置的规范化摘要;不得把 Token、密码、证书私钥写入数据库或日志。

11. 待上传订单页面

11.1 入口与安全状态

入口位于消费主页顶部,展示待处理订单数量角标。只有同时满足以下条件才允许进入:

WAITING_CARD 已经绑定当前订单,不属于安全入口状态。入口不可用时显示“当前交易处理中,请完成后再查看”。进入页面停止消费页自身的读卡监听,返回后按生命周期恢复;后台上传不停止。

11.2 展示范围

满足任一条件的订单才展示:

业务状态属于 PENDING / UPLOADING / FAILED
或
存在一卡通子任务属于 PENDING / UPLOADING / FAILED

两端均为 UPLOADED 或 NOT_REQUIRED 时不展示。页面标题数量按“订单数”统计,不按一卡通流水数统计。

11.3 网格与状态

一卡通聚合优先级固定为:

结果不确定 > 上传失败 > 上传中 > 待上传 > 全部已上传 > 无需上传

例如两笔一卡通流水一笔成功、一笔失败,显示红色“上传失败 1/2”,不能仅显示黄色“部分完成”。

11.4 上拉加载和动态刷新

页面显示:

当前第 1 / 2 页 · 已加载 4 / 8 条 · 上拉加载下一页

全部加载后显示“已加载全部”。排序为:结果不确定、失败、上传中、待上传,再按最早创建时间和订单号稳定排序。

后台完成会动态移除数据,因此不能使用固定 OFFSET 追加。页面维护 loadedPageCount,每次刷新重新查询前 loadedPageCount × PAGE_SIZE 条,并重新计算待处理总数、总页数和当前已加载页数,防止漏单或重复。

11.5 单笔与全部上传

11.6 结果反馈

最终页面参考:prototypes/2026-09-12-consumption-order-upload/pending-orders-grid-v6.html。

12. 页面刷新与并发生命周期

新增 ConsumptionUploadStatusChangedEvent,只表达“数据库状态已变化”。页面收到事件后重新查询,不直接渲染事件携带的旧对象。

刷新时机:

后台刚好上传成功时:数据库先提交 UPLOADED,再发事件;页面查询后自然移除卡片。不存在“这笔算手动还是后台”的认领统计。

13. 数据留存、磁盘与安全

14. 迁移、兼容与回滚

升级链必须逐级、事务化执行:

V1 -> V2 -> V3 -> V4

V4 步骤:

  1. 为主表增加业务租约、失败分类、配置指纹、结果不确定和时间字段。
  2. 创建 onecard_upload_records_v4。
  3. 复制 V3 子表数据;旧 PENDING/UPLOADED 状态保持,旧任务的指纹为空时标记为需配置确认,不直接发送。
  4. 删除旧子表并将 V4 临时表改名为 onecard_upload_records。
  5. 重建索引并执行 foreign_key_check、行数和状态数量校验。
  6. 已完成业务记录可用 updated_at 回填 report_uploaded_at。
  7. 不从历史 debit_result_json 自动生成一卡通任务,避免历史重复入账。

SQLite 结构升级不可自动降级,旧 V3 APK 不能被假定能够打开 V4 数据库。发布回滚必须使用“保留 V4 schema 的前向修复 APK”,或先导出并验证数据后执行受控降级;不得直接安装旧 APK 或通过删除数据库回滚。

15. 模块职责建议

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

新增或职责变化的类必须写职责/边界注释;逐钱包持久化、租约晚响应、高水位快照和动态分页要写“为什么”的注释。文案、颜色、尺寸和阈值进入资源或命名常量,不散落硬编码。

16. 验收与测试矩阵

AC场景预期证据
AC-001取得订单后进程退出主表仍有 ORDER_ACQUIRED
AC-003钱包扣款调用前断电模拟存在 DEBIT_INTENT,不伪造成功
AC-004第一钱包成功、第二钱包失败第一笔 JSON 和一卡通任务都存在
AC-006自动与手动同时认领仅一个租约取得发送权
AC-007旧租约晚响应影响行数为 0,不覆盖新状态
AC-008服务端写入后客户端超时进入结果不确定,自动/手动不重发
AC-009任务创建后切换服务器配置不匹配,旧任务不发到新服务器
AC-101WAITING_CARD 点击入口禁止进入并提示交易处理中
AC-102两端均完成页面不展示该订单
AC-103两条一卡通任务一条上传成功、一条上传失败红色显示“一卡通上传失败 1/2”
AC-104点击全部上传后产生新订单新订单不属于本次高水位范围
AC-105页面打开时后台完成订单卡片自动移除,后台不暂停
AC-106零元读卡成功无一卡通任务,有业务任务
AC-107低磁盘空间扣款前阻止新交易并告警
AC-108备份规则检查消费数据库不进入 Android 备份

自动化层级:

17. 发布前门槛

以下事项不阻塞本地代码和测试实施,但阻塞现场启用一卡通自动重传:

  1. 用真实/测试环境样本确认 UploadBusinessRecResult 的成功值规则,不能仅凭“rtn > 100”推断。
  2. 确认一卡通是否按 SAMCardNO + SAMTradeNO 幂等,或提供交易查询确认接口。
  3. 验证 PSAM 流水号复位/回绕规则后,再决定是否增加数据库硬唯一索引。
  4. 完成断网、服务端已写入但客户端超时、断电和重启恢复测试。
  5. 完成目标终端触控、两列布局、上拉加载和状态可读性验收。

门槛未完成时,一卡通自动/手动重传入口必须由集中配置开关关闭;本地流水与任务仍正常保存,页面明确显示“上传能力待现场确认”,不能静默丢单或按暂定成功码投入生产。

18. 最终确认的页面操作

本版本页面只保留:

明确移除:

最终交互以本设计为准;早期原型中仍出现上述已移除控件的部分不再作为实现依据。