消费记录双服务器上传与待上传订单管理 Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking. 用户要求尽量少用子智能体,因此默认在当前会话内串行执行;源码工作区存在并发改动,每次补丁前必须重新读取目标文件。本项目 Git 操作保持只读,本计划不包含提交步骤。

Goal: 在现有消费记录 V3 初步实现上补齐逐钱包可靠落库、双通道租约上传、自动/手动重试和横屏待上传订单管理页面。

Architecture: consumption_records 保存订单和业务上传任务,onecard_upload_records 保存逐钱包一卡通任务;V4 通过数据库租约令牌协调自动与手动执行者。扣款流程在每个钱包调用前写意图、确认后原子写结果和一卡通任务;页面只查询未完成聚合订单,支持单笔和高水位“全部上传”,不提供选择或全选。

Tech Stack: Java 8、Android API 24/target 30、SQLiteOpenHelper、Gson、RecyclerView/GridLayoutManager、WorkManager 2.5、EventBus 3.3、JUnit 4、AndroidX Instrumentation/Espresso。


执行前基线

git status --short
git diff -- app/src/main/java/com/zhct/traybinding/consumption/report \
  app/src/main/java/com/zhct/traybinding/main/ConsumptionActivity.java \
  app/src/main/java/com/zhct/traybinding/workmanager/ConsumptionReportSyncWorker.java

Expected: 能区分当前并发实现与本计划后续改动;若同一目标文件继续变化,重新读取并做语义合并,不整文件覆盖。

export JAVA_HOME=/Users/liang/Library/Java/JavaVirtualMachines/corretto-1.8.0_482/Contents/Home
./gradlew -version

Expected: JVM 为 1.8,Gradle 可以解析项目。

./gradlew -q :app:testDebugUnitTest \
  --tests 'com.zhct.traybinding.consumption.*' \
  --tests 'com.zhct.traybinding.onecard.reader.*' \
  --tests 'com.zhct.traybinding.onecard.server.*'

Expected: 记录基线通过数;已有失败单独记录,不归因于本计划。

文件结构

新建:上传领域与页面

修改:现有链路

测试与记录

Task 1:建立统一状态、失败分类、租约和退避模型

Files: 新建 consumption/upload 下的 UploadStatus、UploadFailureCategory、UploadClaim、ConsumptionUploadScope、UploadRetryPolicy、UploadDestinationFingerprint 及对应测试。

@Test public void unknownResultNeverRetries() {
    assertFalse(UploadFailureCategory.RESULT_UNKNOWN.isAutoRetryAllowed());
}

@Test public void backoffIsCappedAndDeterministicForSeed() {
    UploadRetryPolicy policy = new UploadRetryPolicy(60_000L, 3_600_000L, 0.20d);
    long delay = policy.nextDelayMillis(20, 7L);
    assertTrue(delay >= 2_880_000L && delay <= 3_600_000L);
}
public enum UploadStatus { NOT_REQUIRED, PENDING, UPLOADING, FAILED, UPLOADED }

public enum UploadFailureCategory {
    NETWORK(true), SERVER(true), CONFIG(false), DATA(false),
    RESULT_UNKNOWN(false), LOCAL(false);
    private final boolean autoRetryAllowed;
    UploadFailureCategory(boolean allowed) { autoRetryAllowed = allowed; }
    public boolean isAutoRetryAllowed() { return autoRetryAllowed; }
}

UploadClaim 必须包含 taskId/orderNo/requestJson/destinationKey/leaseId/attempts;ConsumptionUploadScope 只允许 order(orderNo) 和 all(businessMaxRowId, onecardMaxId, capturedAt),不包含触发来源字段。

public static String onecard(String normalizedUrl, long appId,
                             long accCode, long terminalId, int configVersion) {
    return sha256("ONECARD\n" + normalizedUrl + "\n" + appId + "\n"
            + accCode + "\n" + terminalId + "\n" + configVersion);
}

URL 统一去首尾空白、协议和主机小写、移除默认端口,保留 .asmx 路径;摘要日志只显示前 12 位。

./gradlew -q :app:testDebugUnitTest \
  --tests 'com.zhct.traybinding.consumption.upload.Upload*Test'

Expected: 新增测试全部通过。

Task 2:把数据库迁移到 V4 并建立可靠约束

Files: 修改两个 Repository 接口和 SQLiteConsumptionRecordRepository.java;新增两个 SQLite 仪器测试。

测试分别创建旧版 schema 和样本记录,再用 V4 Repository 打开,断言:

assertEquals(4, readUserVersion(database));
assertEquals(1, count(database, "consumption_records"));
assertEquals(1, count(database, "onecard_upload_records"));
assertTrue(hasColumn(database, "onecard_upload_records", "upload_lease_id"));
assertEquals(0, foreignKeyViolationCount(database));

DATABASE_VERSION 改为 4。保留 V1→V2 和 V2→V3;新增 upgradeFrom3To4(),使用 onecard_upload_records_v4 复制、核对行数、替换和重建索引。旧 UPLOADED 保持完成;旧未完成任务使用 destination_key='LEGACY_UNVERIFIED'、failure_category='CONFIG'、auto_retry_allowed=0,上线前按发布门槛处理。

@Override public void onConfigure(SQLiteDatabase db) {
    super.onConfigure(db);
    db.setForeignKeyConstraintsEnabled(true);
}

Repository 写错误摘要前统一调用 UploadErrorSanitizer.sanitize(message, MAX_ERROR_CHARS);不得保存认证头、完整卡号或原始 SOAP 请求。

./gradlew -q :app:connectedDebugAndroidTest \
  -Pandroid.testInstrumentationRunnerArguments.class=com.zhct.traybinding.consumption.report.SQLiteConsumptionRecordRepositoryMigrationTest

Expected: 三条升级路径、外键和行数校验通过;无设备时保留命令为真机验收门槛,不把未运行写成通过。

Task 3:实现逐钱包意图与确认后的原子落库

Files: 修改 Reader/Executor/Snapshot/Repository/Activity,新增 WalletDebitObserver.java,修改读卡测试。

assertEquals(Arrays.asList(
        "intent:SUBSIDY", "confirmed:SUBSIDY", "intent:MAIN", "failed:MAIN"),
        observer.events);
assertEquals(1, repository.onecardTaskCount(orderNo));
assertEquals("DEBIT_CONFIRMED", repository.segmentState(orderNo, 0));
assertEquals("DEBIT_INTENT", repository.segmentState(orderNo, 1));
public interface WalletDebitObserver {
    void onDebitIntent(int sequence, ConsumptionPlanner.Item item, long now);
    void onDebitConfirmed(int sequence, ConsumptionResult result, long now);
    void onDebitFailed(int sequence, ConsumptionPlanner.Item item,
                       ReaderException error, long now);
}

CardReader 增加带 observer 的重载;旧重载委托到 WalletDebitObserver.NONE,保持其他调用点兼容。

首次读卡后先校验并冻结卡信息、TermID、AccCode 和目标服务器指纹,确保能够稳定生成逐笔任务。NewcapecUsbCardReader 循环顺序必须是:onDebitIntent → protocol.consumeSingleWallet → onDebitConfirmed。意图回调抛出本地异常时不得继续硬件扣款;确认回调落库失败时保留已有 DEBIT_INTENT、停止后续钱包、不再次扣当前钱包,并把订单转入结果待核查状态。

void recordDebitIntent(String orderNo, int sequence,
                       WalletType wallet, long amount, long now);

void recordDebitConfirmedAndEnqueueOnecard(
        String orderNo, int sequence, ConsumptionResult result,
        String onecardRequestJson, String destinationKey, long now);

第二个方法在一个 SQLite 事务内更新 debit_result_json 并 INSERT OR IGNORE 子任务;UNIQUE(order_no, segment_index) 保证重放回调不生成第二条。

删除整单成功后才调用的 enqueueOnecardUploads() 路径;在扣款开始前创建 observer,逐笔使用 OnecardUploadRecordFactory.createSingle(beforeCard, result, ...)。psamConfirmed=false 仍保存并上传真实结果,但记录警告,不改变已经发生的扣款事实。

./gradlew -q :app:testDebugUnitTest \
  --tests com.zhct.traybinding.onecard.reader.ConsumptionCardTransactionExecutorTest \
  --tests com.zhct.traybinding.onecard.server.OnecardUploadRecordFactoryTest

Expected: 单钱包、双钱包、部分成功、零元和回调失败场景通过。

Task 4:原子创建业务任务并冻结目标配置

Files: 修改 Repository、Activity、OnecardSettings、Constants 和两个 JSON codec 测试。

repository.finalizeTransactionAndEnqueueReport(
        orderNo, afterJson, "DEBIT_SUCCEEDED", requestJson, destinationKey, now);
assertEquals(requestJson, read(orderNo, "report_request_json"));
assertEquals(destinationKey, read(orderNo, "report_destination_key"));
assertEquals("PENDING", read(orderNo, "report_status"));

完整成功和零元读卡成功调用 finalizeTransactionAndEnqueueReport();部分成功、结果未知和零元读卡失败将业务状态明确保存为 NOT_REQUIRED,不生成接口 C 请求。

一卡通指纹使用服务 URL/AppID/AccCode/TermID/配置版本;业务指纹使用 Constants.NETWORK_BASE_URL、设备编码和站点身份。上传前重新计算当前值,不一致时写 FAILED/CONFIG/auto_retry_allowed=0。

./gradlew -q :app:testDebugUnitTest \
  --tests 'com.zhct.traybinding.consumption.report.*Json*Test' \
  --tests com.zhct.traybinding.consumption.upload.UploadDestinationFingerprintTest

Expected: 新旧 JSON 兼容、目标指纹稳定且配置变化会改变指纹。

Task 5:用数据库租约替换实例级防重

Files: 修改两个 Repository、两个 Runner;新增 ConsumptionUploadCoordinator.java 和协调器/SQLite 租约测试。

UploadClaim first = repository.claimOnecard(id, "lease-A", now, false);
UploadClaim second = repository.claimOnecard(id, "lease-B", now, false);
assertNotNull(first);
assertNull(second);

repository.recoverStaleBusinessClaims(now + LEASE_TIMEOUT_MS);
UploadClaim replacement = repository.claimBusiness(orderNo, "lease-B", later, false);
assertFalse(repository.markBusinessUploaded(orderNo, "lease-A", later));
assertTrue(repository.markBusinessUploaded(orderNo, "lease-B", later));
assertEquals(UploadFailureCategory.RESULT_UNKNOWN,
        repository.recoverStaleOnecardClaim(onecardId, later).getFailureCategory());

业务主表和一卡通子表分别提供 claim...、mark...Uploaded、mark...Failed、recoverStale...。认领成功时立即增加 attempts;所有终态更新必须匹配 UPLOADING + leaseId。测试中的换租约只适用于已确认幂等的通道;未确认幂等的一卡通租约超时必须转 RESULT_UNKNOWN,不得直接生成 lease-B 重发。

网络在请求写出后发生超时、连接中断或响应解析缺失时,写入:

status=FAILED
failure_category=RESULT_UNKNOWN
result_uncertain=1
auto_retry_allowed=0

明确的 HTTP 未发送、DNS 失败可归类 NETWORK 并退避;无法证明“请求未到服务端”时采用更保守的 RESULT_UNKNOWN。

./gradlew -q :app:testDebugUnitTest \
  --tests com.zhct.traybinding.consumption.upload.ConsumptionUploadCoordinatorTest
./gradlew -q :app:connectedDebugAndroidTest \
  -Pandroid.testInstrumentationRunnerArguments.class=com.zhct.traybinding.consumption.report.SQLiteConsumptionUploadLeaseTest

Expected: 同一任务仅一次取得发送权;旧租约不能覆盖新租约。

Task 6:拆开两个通道的异常边界并固化响应契约

Files: 修改 ConsumptionReportSyncWorker、两个 Runner、OnecardUploadResult 及其测试。

businessGateway.throwOnUpload = true;
workerHarness.runRound();
assertEquals(1, onecardGateway.calls);

业务与一卡通各自运行在独立 try/catch 中;一个通道抛异常后仍运行另一个通道。Runner 的 AtomicBoolean 可保留为同实例优化,但正确性只依赖数据库租约。

OnecardUploadResultTest 至少覆盖 SOAP Fault、缺失 rtn、明确失败码、明确成功码和格式异常。当前 rtn > 100 只能作为待现场验证的暂定契约;生产启用前将真实样本固化为常量或解析策略测试。

在 Constants/OnecardSettings 增加集中配置的消费流水上传启用开关。现场契约门槛完成前,自动和手动一卡通发送均关闭,但任务继续本地保存并在页面显示“上传能力待现场确认”;不得静默丢弃任务。

./gradlew -q :app:testDebugUnitTest \
  --tests 'com.zhct.traybinding.consumption.report.*SyncRunnerTest' \
  --tests 'com.zhct.traybinding.onecard.server.OnecardUpload*Test'

Expected: 通道隔离、失败分类和 SOAP 判定测试通过。

Task 7:实现待上传订单聚合、稳定排序和无 OFFSET 分页

Files: 新建页面查询模型/Repository/Mapper 及测试;扩展 SQLite Repository。

assertEquals("上传失败 1/2", mapper.map(oneUploadedOneFailed).getOnecardText());
assertEquals(UiTone.DANGER, mapper.map(oneUploadedOneFailed).getOnecardTone());
assertEquals("结果待确认", mapper.map(uncertain).getOnecardText());
assertFalse(mapper.map(bothComplete).isVisible());

查询只返回业务未完成或存在一卡通未完成的订单;使用条件聚合计算一卡通总数、成功数、失败数、上传中数、待上传数和结果不确定数。整体排序固定为结果不确定、失败、上传中、待上传、created_at、order_no。

PendingConsumptionPage loadFirst(int itemLimit, int pageSize);

页面每次以 loadedPageCount * PAGE_SIZE 重新查询前 N 条,不使用 OFFSET;返回 totalOrders 并用 (total + pageSize - 1) / pageSize 计算总页数。

./gradlew -q :app:testDebugUnitTest \
  --tests com.zhct.traybinding.consumption.upload.PendingConsumptionOrderMapperTest

Expected: 混合状态优先级、隐藏完成订单和页数计算通过。

Task 8:实现单笔和高水位“全部上传”后台任务

Files: 新建 ManualConsumptionUploadWorker.java,修改协调器和调度器,增加测试。

ConsumptionUploadScope scope = repository.captureAllPendingScope(now);
insertNewOrderAfter(scope.getCapturedAt());
coordinator.run(scope, true);
assertTrue(oldOrderWasAttempted());
assertFalse(newOrderWasAttempted());

全部上传的 WorkManager Data 只包含 businessMaxRowId、onecardMaxId、capturedAt;单笔只包含规范化 orderNo。禁止传递无界 ID 数组。

手动触发只绕过 next_retry_at;不能认领 UPLOADING、result_uncertain=1、配置不匹配或数据错误。Activity 不直接发送网络请求。

Worker 结束不等于页面立即提示成功。页面以 countUnresolved(scope)==0 为成功条件;存在 UPLOADING 继续观察,存在失败/不确定则保留卡片。

./gradlew -q :app:testDebugUnitTest \
  --tests 'com.zhct.traybinding.consumption.upload.*Scope*Test' \
  --tests com.zhct.traybinding.consumption.upload.ConsumptionUploadCoordinatorTest

Expected: 点击后新订单不进入本轮,范围内后台已完成任务不会被重复发送。

Task 9:实现横屏两列待上传订单页面

Files: 新建 Activity、Adapter、ViewData、两个 layout;修改资源和 Manifest;新增 UI 测试。

根布局包含标题/待处理角标、RecyclerView、空态、页数信息和底部大按钮 全部上传。RecyclerView 使用:

recyclerView.setLayoutManager(new GridLayoutManager(this, 2));
recyclerView.setAdapter(adapter);

不创建 checkbox、选择模式、全选、当前已加载上传或查询按钮。

滚动接近末尾且未全部加载时 loadedPageCount++ 并重新查询前 N 条。全部上传期间禁用上拉;结束后按原容量刷新。

Activity 在 onStart/onStop 注册和注销 EventBus;收到 ConsumptionUploadStatusChangedEvent 后在主线程重新查询。数据库提交成功后才发送事件。

卡片同时显示文字、图标和颜色;按钮最小触控高度放入 dimens.xml。红色失败、蓝色上传中、黄色待上传、绿色已上传、灰色无需上传;结果不确定使用警示文案,不只改变颜色。

测试空态、两列、失败原因、上传中、动态移除、上拉页数和全部上传按钮;断言视图中不存在“全选”和“上传当前已加载”。

./gradlew -q :app:processDebugResources
./gradlew -q :app:connectedDebugAndroidTest \
  -Pandroid.testInstrumentationRunnerArguments.class=com.zhct.traybinding.main.PendingConsumptionUploadActivityTest

Expected: 资源编译通过;真机/模拟器 UI 测试通过。

Task 10:增加消费主页安全入口

Files: 修改 ConsumptionActivity.java、activity_consumption.xml、资源和 Manifest。

static boolean canOpenPendingUploads(State state, boolean hasCurrentOrder,
                                     boolean cardOperationRunning,
                                     boolean localWriteRunning) {
    return state == State.WAITING_TRAY && !hasCurrentOrder
            && !cardOperationRunning && !localWriteRunning;
}

入口显示待处理“订单数”;安全状态下启动 PendingConsumptionUploadActivity。WAITING_CARD 或任何交易在途时提示“当前交易处理中,请完成后再查看”。

进入管理页面时只停止消费页页面级读卡监听;返回后由现有 onStart/onResume 恢复。WorkManager 不受 Activity 生命周期影响。

./gradlew -q :app:testDebugUnitTest \
  --tests 'com.zhct.traybinding.consumption.*'
./gradlew -q :app:processDebugResources

Expected: WAITING_TRAY 可进入,WAITING_CARD/DEBIT/AFTER_READ 均被阻止。

Task 11:增加清理、低磁盘保护和备份排除

Files: 修改 Repository/Activity/Manifest;新建 backup_rules.xml 和策略测试。

清理 SQL 只能删除业务为 UPLOADED/NOT_REQUIRED 且不存在未完成一卡通子任务的主表记录。保留天数、最大成功订单数和单次清理条数使用命名常量或配置。

在硬件扣款前验证数据库可写及可用空间高于 MIN_CONSUMPTION_STORAGE_BYTES;不足时停止新扣款、写告警并显示维护提示。该失败不得创建一卡通或业务任务。

<full-backup-content>
    <exclude domain="database" path="consumption_records.db" />
    <exclude domain="database" path="consumption_records.db-wal" />
    <exclude domain="database" path="consumption_records.db-shm" />
</full-backup-content>

Manifest 设置 android:fullBackupContent="@xml/backup_rules";target 30 不增加仅 API 31 使用的 dataExtractionRules。

./gradlew -q :app:testDebugUnitTest --tests '*ConsumptionRetention*Test'
./gradlew -q :app:processDebugResources

Expected: 未完成、异常和结果不确定记录不被清理;备份资源可编译。

Task 12:全量回归、现场门槛和文档闭环

Files: 所有本次文件;新增成对变更记录,保持本设计和计划的 Markdown/HTML 同步。

./gradlew -q :app:testDebugUnitTest

Expected: 全部单元测试通过。

./gradlew -q :app:assembleDebug

Expected: BUILD SUCCESSFUL,APK 生成。

rg -n '"(PENDING|UPLOADING|FAILED|UPLOADED|NOT_REQUIRED)"' \
  app/src/main/java/com/zhct/traybinding | rg -v 'UploadStatus|SQLiteConsumptionRecordRepository|Test'
git diff --check

Expected: 没有无说明的状态硬编码;diff 无空白错误。

依次验证单钱包、双钱包、第一笔成功第二笔失败、零元成功/失败、双通道各自断网、后台和手动并发、进程重启、配置切换、结果不确定、上拉分页、后台完成自动移除、低磁盘保护。

保存脱敏的成功、失败、SOAP Fault 和“服务端已入账但客户端超时”样本;确认 UploadBusinessRecResult 成功规则、幂等键和 PSAM 流水号生命周期。未完成时,一卡通自动重传保持禁用或结果不确定保护,不宣称可上线。

V4 数据库不得通过直接安装旧 V3 APK 或删除数据库回滚。准备并验证保留 V4 schema 的前向修复 APK;如必须降级,先导出、校验和备份数据,再执行受控转换。

创建同名 Markdown/HTML 变更记录,写明实际文件、迁移、测试命令/结果、现场未完成项和回滚限制。不得把计划中的预期测试写成实际通过。

追踪矩阵

Use Case任务验收
UC-CONSUMPTION-UPLOAD-001 M1-M5Task 2-4AC-001 至 AC-005
UC-CONSUMPTION-UPLOAD-001 M6-M7Task 5-6AC-006 至 AC-009
UC-CONSUMPTION-UPLOAD-002 M1Task 10AC-101
UC-CONSUMPTION-UPLOAD-002 M2-M3Task 7、9AC-102、AC-103
UC-CONSUMPTION-UPLOAD-002 M4Task 8、9AC-104
UC-CONSUMPTION-UPLOAD-002 M5Task 9AC-105
零元、存储和备份Task 3、4、11AC-106 至 AC-108

实施顺序与停点

按 Task 1→12 串行执行。Task 2 的 V4 迁移、Task 3 的扣款前持久化和 Task 5 的租约是高风险共享契约,必须先完成并验证,再进入页面开发。若执行时发现并发任务仍在修改同一文件,应停止该文件补丁、重新读取差异并先协调写路径;不得覆盖或回退他人改动。