消费页一卡通服务同步弹框 Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking. The source worktree and prompt repository both contain user changes, so execution uses one inline writer, re-reads every touched file before patching, and contains no Git commit step.

Goal: 在消费页实现自动/手动一卡通全量人员同步,以可靠 JSON 快照解耦远端转发和 SQLite 入库,并展示连接、同步、本地存储结果及毫秒级时间。

Architecture: 一卡通每批最多 1000 人先写入 noBackupFilesDir 中可校验、可恢复的 JSON 快照;只有完整全量标记 READY 后,远端上传和低优先级数据库导入才并行消费。远端按 customerid 幂等,采用至少一次投递;SQLite 维护 STAGING/ACTIVE 多版本快照,完整入库后原子切换,数据库结果不参与主同步成功判定。

Tech Stack: Java 8、Android API 24+、Android AtomicFile、Gson、SQLite、WorkManager 2.5、MMKV、Retrofit、AndroidX Fragment、JUnit 4、AndroidX Instrumentation。


执行前基线

export JAVA_HOME=/Users/liang/Library/Java/JavaVirtualMachines/corretto-1.8.0_482/Contents/Home
./gradlew -q :app:testDebugUnitTest \
  --tests 'com.zhct.traybinding.onecard.customer.*'

Expected: 当前已有测试通过;如果基线失败,先记录现有失败,不把它误归因于本计划。

文件结构

新建:JSON 快照与恢复

新建:数据库与后台恢复

新建:页面

修改

删除

测试

变更记录

Task 1:扩展页面状态但保持已有终态兼容

Files:

@Test public void roundTripsSpoolingAndLocalStoreState() {
    ConsumptionPersonSyncStatus status = ConsumptionPersonSyncStatus.restored(
            ConsumptionPersonSyncStatus.ConnectionResult.SUCCESS, 1001L, 1001,
            ConsumptionPersonSyncStatus.SyncResult.SUCCESS, 2002L,
            1001, 1001, 1001, 1001, 0, 2, "",
            ConsumptionPersonSyncStatus.LocalStoreState.RETRY_WAIT, 0L);
    assertEquals(status, codec.decode(codec.encode(status)));
}

@Test public void oldJsonWithoutLocalStoreFieldsStillLoads() {
    ConsumptionPersonSyncStatus status = codec.decode(
            "{\"connectionResult\":\"SUCCESS\",\"syncResult\":\"SUCCESS\"}");
    assertEquals(ConsumptionPersonSyncStatus.LocalStoreState.IDLE,
            status.getLocalStoreState());
}
./gradlew -q :app:testDebugUnitTest \
  --tests com.zhct.traybinding.onecard.customer.ConsumptionPersonSyncStatusJsonCodecTest

Expected: 缺少 LocalStoreState、新 restored 参数或 getter。

public enum Phase {
    IDLE, CONNECTING, PULLING, SPOOLING, UPLOADING, COMPLETED, FAILED
}
public enum LocalStoreState {
    IDLE, PENDING, WRITING, COMPLETED, RETRY_WAIT
}

public ConsumptionPersonSyncStatus spooling(
        int batch, int totalPulled, int expected) {
    return copy(Phase.SPOOLING, true, expected, totalPulled,
            processedCount, succeededCount, failedCount, batch,
            "", LocalStoreState.PENDING, localStoreCompletedAt);
}

public ConsumptionPersonSyncStatus localStore(
        LocalStoreState state, long completedAt) {
    return copy(phase, running, expectedCount, pulledCount,
            processedCount, succeededCount, failedCount, batchNumber,
            message, state, completedAt);
}

private ConsumptionPersonSyncStatus copy(Phase newPhase, boolean newRunning,
        int newExpected, int newPulled, int newProcessed, int newSucceeded,
        int newFailed, int newBatch, String newMessage,
        LocalStoreState newLocalState, long newLocalCompletedAt) {
    return new ConsumptionPersonSyncStatus(newPhase, newRunning,
            connectionResult, connectionCompletedAt, serverCustomerCount,
            syncResult, syncCompletedAt, newExpected, newPulled,
            newProcessed, newSucceeded, newFailed, newBatch, newMessage,
            newLocalState, newLocalCompletedAt);
}

本任务只增加阶段和本地存储状态,暂时保留现有 completed(CustomerSyncResult, BusinessPersonSyncResult, long),避免尚未迁移的协调器失去编译兼容;Task 8 再一次性切换为 CustomerPullResult。编解码器对旧 JSON 缺失字段使用 IDLE/0L,错误摘要继续去换行并限制 256 字符。

Run: Step 2 命令。

Expected: 新旧 JSON 兼容测试全部通过。

Task 2:建立不可变批次与 manifest 状态机

Files:

@Test public void roundTripsAllOriginalFieldsInCustomerOrder() {
    CustomerBatchSnapshot batch = new CustomerBatchSnapshot(
            "sync-1", 1, 1001, Arrays.asList(record(10), record(11)));
    CustomerBatchSnapshot decoded = codec.decode(codec.encode(batch));
    assertEquals(batch, decoded);
    assertEquals("card-10", decoded.getRecords().get(0).getField("EXTRA"));
}

@Test(expected = IllegalArgumentException.class)
public void rejectsDuplicateOrDescendingCustomerIds() {
    new CustomerBatchSnapshot("sync-1", 1, 2,
            Arrays.asList(record(11), record(11)));
}
@Test public void cannotBecomeReadyUntilExpectedCountMatches() {
    CustomerSyncManifest manifest = CustomerSyncManifest.collecting(
            "sync-1", 1001, 100L);
    manifest = manifest.appendBatch(metadata(1, 1000));
    CustomerSyncManifest incomplete = manifest;
    assertIllegalState(() -> incomplete.markReady(200L));
}

@Test public void remoteAckAndDatabaseProgressAreIndependent() {
    CustomerSyncManifest ready = readyManifest();
    CustomerSyncManifest changed = ready.confirmRemoteBatch(
            1, 1000, 600, 400, 0, "trace-1", 300L)
            .recordDatabaseBatch(1, 1000, 301L);
    assertEquals(1, changed.getRemoteConfirmedBatch());
    assertEquals(1, changed.getDatabaseCompletedBatch());
}

JUnit 4.12 没有内置 assertThrows 时,在测试类中增加:

private static void assertIllegalState(ThrowingRunnable action) {
    try { action.run(); fail("Expected IllegalStateException"); }
    catch (IllegalStateException expected) { }
    catch (Exception other) { throw new AssertionError(other); }
}
./gradlew -q :app:testDebugUnitTest \
  --tests com.zhct.traybinding.onecard.customer.CustomerBatchJsonCodecTest \
  --tests com.zhct.traybinding.onecard.customer.CustomerSyncManifestTest

Expected: 新类型不存在。

public final class CustomerBatchSnapshot {
    public static final int MAX_RECORDS = SyncPersonsRequest.MAX_ITEMS;
    public CustomerBatchSnapshot(String syncId, int batchNumber,
            int expectedTotal, List<CustomerRecord> records);
    public String getSyncId();
    public int getBatchNumber();
    public int getExpectedTotal();
    public List<CustomerRecord> getRecords();
}

public final class CustomerBatchMetadata {
    public CustomerBatchMetadata(String fileName, int batchNumber, int recordCount,
            long firstCustomerId, long lastCustomerId, long byteLength, String sha256);
}

public final class CustomerSyncManifest {
    public enum PullState { COLLECTING, READY, FAILED }
    public enum DatabaseState {
        PENDING, WRITING, ACTIVE, RETRY_WAIT, SUPERSEDED
    }
    public static CustomerSyncManifest collecting(String syncId,
            int expectedCount, long startedAt);
    public CustomerSyncManifest appendBatch(CustomerBatchMetadata metadata);
    public CustomerSyncManifest markReady(long completedAt);
    public CustomerSyncManifest confirmRemoteBatch(int batchNumber, int total,
            int created, int updated, int failed, String traceId, long confirmedAt);
    public CustomerSyncManifest recordDatabaseBatch(
            int batchNumber, int storedCount, long updatedAt);
    public CustomerSyncManifest markDatabaseActive(long completedAt);
    public CustomerSyncManifest markDatabaseRetry(String message, long updatedAt);
    public CustomerSyncManifest markDatabaseSuperseded(
            String newerSyncId, long updatedAt);
}

构造器必须复制集合、检查非空 syncId、批次连续、账号严格递增、单批不超过现有 1000 上限、累计人数不超过预期。只有更新快照已经进入 ACTIVE 后,旧的远端已完成、数据库待处理快照才允许进入 SUPERSEDED,并记录 newerSyncId。JSON codec 使用显式 DTO,不依赖 Gson 绕过 CustomerRecord 构造器。

Run: Step 3 命令。

Expected: 往返、非法输入和状态机测试通过。

Task 3:实现可靠 JSON 文件队列

Files:

@Rule public TemporaryFolder files = new TemporaryFolder();

@Test public void onlyCommittedBatchCanBeRead() throws Exception {
    CustomerSyncSpool spool = spool(files.getRoot());
    spool.begin("sync-1", 2, 100L);
    spool.writeBatch(batch("sync-1", 1, 2, record(1), record(2)));
    CustomerSyncManifest manifest = spool.markReady("sync-1", 200L);
    assertEquals(CustomerSyncManifest.PullState.READY, manifest.getPullState());
    assertEquals(2, spool.readBatch("sync-1", 1).getRecords().size());
    assertFalse(new File(files.getRoot(), "sync-1/batch-00001.json.tmp").exists());
}

@Test public void checksumMismatchPreventsRead() throws Exception {
    CustomerSyncSpool spool = readySpool(files.getRoot());
    overwriteCommittedBatchWithTruncatedJson(files.getRoot());
    assertReadFailsWith("checksum", spool);
}

@Test public void scanDiscardsTmpButKeepsReadyManifest() throws Exception {
    CustomerSyncSpool spool = readySpool(files.getRoot());
    createFile("sync-1/batch-00002.json.tmp", "partial");
    assertEquals("sync-1", spool.findRecoverable().get(0).getSyncId());
    assertFalse(new File(files.getRoot(), "sync-1/batch-00002.json.tmp").exists());
}

@Test public void lowSpaceFailsBeforeCommittedFileAppears() throws Exception {
    CustomerSyncSpool spool = spool(files.getRoot(), directory -> 0L);
    spool.begin("sync-1", 1, 100L);
    assertWriteFailsWith("space", () -> spool.writeBatch(
            batch("sync-1", 1, 1, record(1))));
    assertFalse(new File(files.getRoot(), "sync-1/batch-00001.json").exists());
}

@Test public void cleanupRequiresRemoteCompleteAndDatabaseActive() throws Exception {
    CustomerSyncSpool spool = readySpool(files.getRoot());
    assertFalse(spool.deleteCompleted("sync-1"));
    markRemoteCompleteAndDatabaseActive(spool, "sync-1");
    assertTrue(spool.deleteCompleted("sync-1"));
}

@Test public void supersededCleanupRequiresNewerSnapshotToBeActive() throws Exception {
    CustomerSyncSpool spool = readyRemoteCompleteSpool(files.getRoot(), "sync-1");
    assertMarkSupersededFails(spool, "sync-1", "sync-2");
    createReadyRemoteCompleteSnapshot(spool, "sync-2");
    spool.markDatabaseActive("sync-2", 2, 300L);
    spool.markDatabaseSuperseded("sync-1", "sync-2", 301L);
    assertTrue(spool.deleteCompleted("sync-1"));
}

测试类的私有 fixture 必须用纯 Java CustomerSyncManifestStore 构造确定性的记录和 manifest;overwriteCommittedBatchWithTruncatedJson() 直接截断已提交文件,assertMarkSupersededFails() 必须断言更新快照尚未 ACTIVE 时状态不变。

./gradlew -q :app:testDebugUnitTest \
  --tests com.zhct.traybinding.onecard.customer.CustomerSyncSpoolTest

Expected: CustomerSyncSpool 不存在。

public final class CustomerSyncSpool implements CustomerBatchSource,
        CustomerBatchSink, RemoteProgressStore, DatabaseProgressStore {
    public static final String ROOT_DIRECTORY = "onecard_sync_spool";
    public CustomerSyncSpool(File root, CustomerBatchJsonCodec batchCodec,
            CustomerSyncManifestStore manifestStore,
            StorageSpaceProvider storageSpace, SyncLogger logger);
    public CustomerSyncManifest begin(String syncId, int expectedCount, long startedAt)
            throws IOException;
    public CustomerBatchMetadata writeBatch(CustomerBatchSnapshot batch)
            throws IOException;
    public CustomerSyncManifest markReady(String syncId, long completedAt)
            throws IOException;
    public CustomerSyncManifest markFailed(String syncId, long completedAt,
            String message) throws IOException;
    public CustomerSyncManifest loadManifest(String syncId) throws IOException;
    public CustomerBatchSnapshot readBatch(String syncId, int batchNumber)
            throws IOException;
    public List<CustomerSyncManifest> findRecoverable() throws IOException;
    public List<String> findOlderRemoteCompleteDatabasePending(
            String activeSyncId) throws IOException;
    public void confirmBatch(String syncId, int batchNumber,
            SyncPersonsResult result, String traceId,
            long confirmedAt) throws IOException;
    public void recordDatabaseBatch(String syncId, int batchNumber,
            int storedCount, long updatedAt) throws IOException;
    public void markDatabaseActive(String syncId, int activeCount,
            long completedAt) throws IOException;
    public void markDatabaseRetry(String syncId, String message,
            long updatedAt) throws IOException;
    public void markDatabaseSuperseded(String syncId, String newerSyncId,
            long updatedAt) throws IOException;
    public boolean deleteCompleted(String syncId) throws IOException;
}

public interface CustomerBatchSource {
    CustomerSyncManifest loadManifest(String syncId) throws Exception;
    CustomerBatchSnapshot readBatch(String syncId, int batchNumber) throws Exception;
    List<String> findOlderRemoteCompleteDatabasePending(
            String activeSyncId) throws Exception;
}

public interface CustomerBatchSink {
    CustomerSyncManifest begin(String syncId, int expectedCount,
            long startedAt) throws Exception;
    CustomerBatchMetadata writeBatch(CustomerBatchSnapshot batch) throws Exception;
    CustomerSyncManifest markReady(String syncId, long completedAt) throws Exception;
    CustomerSyncManifest markFailed(String syncId, long completedAt,
            String message) throws Exception;
}

public interface CustomerSyncManifestStore {
    CustomerSyncManifest load(File syncDirectory) throws IOException;
    void save(File syncDirectory, CustomerSyncManifest manifest) throws IOException;
}

public final class AtomicCustomerSyncManifestStore
        implements CustomerSyncManifestStore {
    public AtomicCustomerSyncManifestStore(CustomerSyncManifestJsonCodec codec);
}

public interface RemoteProgressStore {
    void confirmBatch(String syncId, int batchNumber, SyncPersonsResult result,
            String traceId, long confirmedAt) throws Exception;
}

public interface DatabaseProgressStore {
    void recordDatabaseBatch(String syncId, int batchNumber,
            int storedCount, long updatedAt) throws Exception;
    void markDatabaseActive(String syncId, int activeCount,
            long completedAt) throws Exception;
    void markDatabaseRetry(String syncId, String message,
            long updatedAt) throws Exception;
    void markDatabaseSuperseded(String syncId, String newerSyncId,
            long updatedAt) throws Exception;
}

interface StorageSpaceProvider {
    long usableBytes(File directory);
}

writeBatch() 的顺序必须是:编码内存字节 → 用 StorageSpaceProvider 校验可用空间足以容纳临时文件、最终文件和 manifest 安全余量 → 写同目录 .tmpflush()getFD().sync() → 计算长度/SHA-256并解码复核人数 → 检查同目录 renameTo() 返回值 → 通过 CustomerSyncManifestStore 写 manifest。生产注入使用 Android AtomicFileAtomicCustomerSyncManifestStoreFile::getUsableSpace;本地 JUnit 注入纯 Java测试 store 和固定空间。生产根目录由 new File(context.getNoBackupFilesDir(), ROOT_DIRECTORY) 创建;测试直接传 TemporaryFolder

扫描时删除 .tmp 和未被 manifest 登记的孤儿 .jsondeleteCompleted() 只允许删除远端全部确认,且数据库为 ACTIVE,或已被另一个可验证为 ACTIVE 的更新快照标记为 SUPERSEDED 的目录。仅有更新的 READY 文件、导入中的快照或导入失败的快照,均不能触发旧目录清理。

所有性能值写日志:batchBytestempWriteMsfsyncMs。日志不得包含 JSON 正文、姓名或卡号。

Run: Step 2 命令。

Expected: 原子提交、损坏拒绝、扫描恢复测试通过。

Task 4:新增不依赖 SQLite 的一卡通 JSON 拉取服务

Files:

@Test public void spools1001CustomersAs1000And1WithoutRepository() throws Exception {
    RecordingSpool spool = new RecordingSpool();
    CustomerPullResult result = new CustomerSpoolPullService(
            sourceWithCustomers(1001), spool, 1000, fixedTime(), noOpLogger())
            .sync(1001, null);
    assertEquals(Arrays.asList(1000, 1), spool.batchSizes);
    assertTrue(spool.ready);
    assertEquals(1001, result.getPulledCount());
}

@Test public void secondPageFailureNeverMarksSnapshotReady() {
    RecordingSpool spool = new RecordingSpool();
    assertSyncFails(() -> new CustomerSpoolPullService(
            sourceFailingOnSecondPage(), spool, 1000, fixedTime(), noOpLogger())
            .sync(1001, null));
    assertFalse(spool.ready);
}
./gradlew -q :app:testDebugUnitTest \
  --tests com.zhct.traybinding.onecard.customer.CustomerSpoolPullServiceTest

Expected: CustomerSpoolPullServicesync(int expectedCount, ProgressListener listener) 不存在。

public final class CustomerPullResult {
    public CustomerPullResult(String syncId, int expectedCount, int pulledCount,
            int batchCount, long startedAt, long completedAt);
    public String getSyncId();
    public int getExpectedCount();
    public int getPulledCount();
    public int getBatchCount();
}

CustomerSpoolPullService 构造器接收 Task 3 已定义的 CustomerBatchSink,生产传入 CustomerSyncSpool,测试传入只记录调用的 fake sink。

CustomerSpoolPullService.sync(int expectedCount, ProgressListener listener) 不调用 getCustomerCount(),直接使用连接验证已经返回的人数;复用现有解析器并保留账号游标、单批/跨批重复检查和最大账号保护。每批解析成功后先调用 writeBatch() 再报告进度;只有累计人数等于 expectedCount 才调用 markReady(),异常路径调用 markFailed()

./gradlew -q :app:compileDebugJavaWithJavac
rg -n 'CustomerSyncService|CustomerSyncResult' app/src/main app/src/test

Expected: 编译通过;旧类型仍只服务尚未迁移的协调器和既有测试,新应用链路将在 Task 8 一次迁移后删除它们。

Run: Step 2 命令。

Expected: 1001 分批、失败不 READY、游标和重复账号测试通过。

Task 5:把业务上传数据源改为 JSON 并记录幂等确认

Files:

@Test public void uploadsReadySpoolInOrderAndConfirmsEachSuccessfulBatch()
        throws Exception {
    FakeBatchSource source = readySource(batch(1, 1000), batch(2, 1));
    RecordingProgressStore progress = new RecordingProgressStore();
    BusinessPersonSyncResult result = new BusinessPersonSyncService(
            source, progress, successfulGateway()).sync("sync-1", null);
    assertEquals(Arrays.asList(1, 2), progress.confirmedBatches);
    assertEquals(1001, result.getTotal());
}

@Test public void failedPersonsLeaveBatchUnconfirmedAndStopLaterBatches()
        throws Exception {
    FakeBatchSource source = readySource(batch(1, 1000), batch(2, 1));
    RecordingProgressStore progress = new RecordingProgressStore();
    BusinessPersonSyncResult result = new BusinessPersonSyncService(
            source, progress, gatewayReturning(1000, 999, 0, 1))
            .sync("sync-1", null);
    assertEquals(Collections.emptyList(), progress.confirmedBatches);
    assertEquals(1, result.getFailed());
    assertEquals(1, gatewayCallCount());
}
./gradlew -q :app:testDebugUnitTest \
  --tests com.zhct.traybinding.onecard.customer.BusinessPersonSyncServiceTest

Expected: 新数据源和进度存储构造器不存在。

public BusinessPersonSyncService(CustomerBatchSource source,
        RemoteProgressStore progressStore, BusinessPersonGateway gateway,
        TimeSource timeSource);

sync() 必须先验证 manifest 为 READY,从 remoteConfirmedBatch + 1 顺序读取。每批 code == 0total == request sizecreated+updated+failed == totalfailed == 0 时才原子确认;failed > 0 返回失败汇总并停止后续批次。网络/响应异常抛出 ServerException,文件和确认游标保持可恢复。

Run: Step 2 命令。

Expected: 1001 分批、确认游标、部分失败不确认、网络失败停止测试通过。

Task 6:把 SQLite 改为 STAGING/ACTIVE 完整快照

Files:

@Test public void stagingRowsStayInvisibleUntilActivation() throws Exception {
    repository.beginSnapshot("old", 1, 100L);
    repository.saveSnapshotBatch("old", records(record(1, "旧姓名")), 101L);
    repository.activateSnapshot("old", 102L);
    repository.beginSnapshot("new", 1, 200L);
    repository.saveSnapshotBatch("new", records(record(1, "新姓名")), 201L);
    assertEquals("旧姓名", repository.getField(1L, "NAME"));
    repository.activateSnapshot("new", 202L);
    assertEquals("新姓名", repository.getField(1L, "NAME"));
}

@Test public void activationRejectsCountMismatch() throws Exception {
    repository.beginSnapshot("new", 2, 200L);
    repository.saveSnapshotBatch("new", records(record(1, "一个人")), 201L);
    assertActivationFails("new");
    assertNull(repository.getActiveSnapshotId());
}

@Test public void versionOneRowsMigrateIntoActiveLegacySnapshot() throws Exception {
    createVersionOneDatabaseWithCustomer(7L, "旧库人员");
    SQLiteCustomerRepository upgraded = openVersionTwoRepository();
    assertNotNull(upgraded.getActiveSnapshotId());
    assertEquals("旧库人员", upgraded.getField(7L, "NAME"));
    upgraded.close();
}
public interface CustomerRepository {
    // Task 8 前保留的兼容入口,分别委托给对应 snapshot 方法。
    void beginSync(String syncId, int expectedCount, long startedAt) throws Exception;
    int saveBatch(String syncId, List<CustomerRecord> records, long updatedAt)
            throws Exception;
    int completeSync(String syncId, long completedAt) throws Exception;
    void failSync(String syncId, long completedAt, String errorMessage) throws Exception;
    List<CustomerRecord> loadBatch(String syncId, long afterCustomerId, int limit);

    void beginSnapshot(String syncId, int expectedCount, long startedAt) throws Exception;
    int saveSnapshotBatch(String syncId, List<CustomerRecord> records,
            long updatedAt) throws Exception;
    int activateSnapshot(String syncId, long completedAt) throws Exception;
    void markSnapshotFailed(String syncId, long completedAt, String error) throws Exception;
    void discardStagingSnapshot(String syncId) throws Exception;
    String getActiveSnapshotId();
    int countCustomers();
    String getField(long customerId, String fieldName);
}

Task 6 先保留旧接口以确保尚未删除的 CustomerSyncService 及其测试可编译;beginSync/saveBatch/completeSync/failSync 分别委托新快照方法,loadBatch 从指定快照读取。Task 8 删除旧服务后再删除这些兼容入口,远端正式链路不会再读取数据库。

customer_snapshots(
  sync_id PRIMARY KEY, expected_count, saved_count, status,
  started_at, completed_at, error_message
)
snapshot_customers(
  snapshot_id, customer_id, card_no, out_id, name, status, updated_at,
  PRIMARY KEY(snapshot_id, customer_id)
)
snapshot_customer_fields(
  snapshot_id, customer_id, field_name, field_value,
  PRIMARY KEY(snapshot_id, customer_id, field_name)
)
active_customer_snapshot(singleton_id PRIMARY KEY CHECK(singleton_id=1), sync_id)

DATABASE_VERSION 从 1 升到 2。V1 升级时把现有 customers/customer_fields 复制到固定前缀加时间戳的 legacy 快照,并在同一升级事务写入活动指针;没有旧人员时允许活动指针为空。所有现有 getField/countCustomers 查询增加活动快照过滤。

activateSnapshot() 在一个事务内再次核对 saved_count == expected_count、更新候选为 ACTIVE、切换单行指针;旧快照删除放在切换成功后的独立清理方法,不能扩大指针事务。

./gradlew -q :app:compileDebugJavaWithJavac :app:compileDebugAndroidTestJavaWithJavac
./gradlew -q :app:connectedDebugAndroidTest \
  -Pandroid.testInstrumentationRunnerArguments.class=com.zhct.traybinding.onecard.customer.SQLiteCustomerRepositorySnapshotTest

Expected: 编译必须通过;无在线设备时第二条明确记录“未执行”,不得写成通过。

Task 7:实现后台数据库导入和唯一恢复任务

Files:

@Test public void importsAllBatchesThenActivatesOnce() throws Exception {
    CustomerSnapshotImporter.Result result = importer(readySource(1000, 1))
            .importSnapshot("sync-1");
    assertEquals(1001, result.getStoredCount());
    assertEquals(Collections.singletonList("sync-1"), repository.activations);
}

@Test public void failedSecondBatchKeepsOldSnapshotActive() {
    FakeRepository repository = repositoryFailingOnBatch(2);
    assertImportFails(() -> importer(repository, readySource(1000, 1))
            .importSnapshot("sync-1"));
    assertEquals("old", repository.activeSnapshotId);
    assertTrue(progress.databaseRetryWait);
}

@Test public void activatingNewerSnapshotSupersedesOlderPendingSnapshot() throws Exception {
    CustomerSnapshotImporter importer = importerWithPendingOlderSnapshot(
            "sync-1", readySource("sync-2", 1000, 1));
    importer.importSnapshot("sync-2");
    assertEquals("sync-2", repository.activeSnapshotId);
    assertEquals("sync-2", progress.supersededBy.get("sync-1"));
}

@Test public void failedNewerSnapshotDoesNotSupersedeOlderPendingSnapshot() {
    CustomerSnapshotImporter importer = failingImporterWithPendingOlderSnapshot(
            "sync-1", readySource("sync-2", 1000, 1));
    assertImportFails(() -> importer.importSnapshot("sync-2"));
    assertFalse(progress.supersededBy.containsKey("sync-1"));
}
./gradlew -q :app:testDebugUnitTest \
  --tests com.zhct.traybinding.onecard.customer.CustomerSnapshotImporterTest

Expected: importer 不存在。

public final class CustomerSnapshotImporter {
    public Result importSnapshot(String syncId) throws Exception {
        CustomerSyncManifest manifest = source.loadManifest(syncId);
        repository.discardStagingSnapshot(syncId);
        repository.beginSnapshot(syncId, manifest.getExpectedCount(),
                manifest.getStartedAt());
        for (CustomerBatchMetadata batch : manifest.getBatches()) {
            int stored = repository.saveSnapshotBatch(syncId,
                    source.readBatch(syncId, batch.getBatchNumber()).getRecords(),
                    timeSource.now());
            progress.recordDatabaseBatch(syncId, batch.getBatchNumber(),
                    stored, timeSource.now());
        }
        int activeCount = repository.activateSnapshot(syncId, timeSource.now());
        progress.markDatabaseActive(syncId, activeCount, timeSource.now());
        for (String olderSyncId : source.findOlderRemoteCompleteDatabasePending(syncId)) {
            progress.markDatabaseSuperseded(
                    olderSyncId, syncId, timeSource.now());
        }
        return new Result(syncId, activeCount);
    }
}

导入线程设置 Process.THREAD_PRIORITY_BACKGROUND;失败时 manifest 进入 RETRY_WAIT,旧活动快照不变。只有 activateSnapshot()markDatabaseActive() 均成功后,才扫描更旧且远端已完成、数据库仍为 PENDING/RETRY_WAIT 的 manifest,并将其标记为 SUPERSEDED;标记后再进入独立清理,不得在新快照导入或激活失败时替代旧候选。

private static final String UNIQUE_UPLOAD_RECOVERY =
        "consumption-person-upload-recovery";
private static final String UNIQUE_DATABASE_IMPORT =
        "consumption-person-database-import";

WorkManager.getInstance(appContext).enqueueUniqueWork(
        UNIQUE_UPLOAD_RECOVERY, ExistingWorkPolicy.KEEP,
        uploadRequestWithConnectedConstraint);
WorkManager.getInstance(appContext).enqueueUniqueWork(
        UNIQUE_DATABASE_IMPORT, ExistingWorkPolicy.KEEP,
        databaseImportRequest);

上传 Worker 只恢复 READY 且远端未完成的 manifest;数据库 Worker 在排除 ACTIVE/SUPERSEDED 后只处理最新的完整快照。新快照成功进入 ACTIVE 后才把更旧的远端已完成候选标记为 SUPERSEDED 并调用受门禁保护的清理。Worker 捕获可恢复错误并返回 Result.retry(),损坏 JSON 或不可恢复校验错误记录失败并返回 Result.success(),避免无限无效重试。

./gradlew -q :app:testDebugUnitTest \
  --tests com.zhct.traybinding.onecard.customer.CustomerSnapshotImporterTest
./gradlew -q :app:compileDebugJavaWithJavac

Expected: importer 测试和主源码编译通过。

Task 8:重构协调器、应用级运行时与真实任务装配

Files:

assertEquals(Arrays.asList(
        "connecting", "connect", "connected:1001",
        "pull:1001", "database-scheduled:sync-1",
        "upload-started:1001", "upload", "completed", "disconnect"), events);

另写测试断言拉取失败时没有 database-scheduledupload;数据库调度器抛错时记录本地失败但仍执行上传,不能把辅助存储异常变成主同步失败。

interface SourceSyncTask {
    CustomerPullResult sync(int expectedCount,
            CustomerSpoolPullService.ProgressListener listener) throws ServerException;
}
interface DatabaseImportStarter {
    void start(String syncId);
}
interface TargetSyncTask {
    BusinessPersonSyncResult sync(String syncId,
            BusinessPersonSyncService.ProgressListener listener) throws ServerException;
}

连接得到的 customerCount 直接传给 source,避免再次调用 GetCustomerCount。source 完成后先触发数据库唯一任务,再立即开始上传;数据库 starter 的运行结果不被等待。

同时把 ConsumptionPersonSyncCoordinator.Listener.onCompletedConsumptionPersonSyncStatus.completed 和控制器测试的 source 类型统一改为 CustomerPullResult;状态中的拉取人数使用 getPulledCount(),不再出现数据库人数或 getSavedCount()

public interface Runtime {
    boolean start();
    ConsumptionPersonSyncStatus getCurrentStatus();
    void addObserver(Observer observer);
    void removeObserver(Observer observer);
}

public final class ConsumptionPersonSyncController {
    public ConsumptionPersonSyncController(Runtime runtime, Observer observer);
    public boolean start();
    public ConsumptionPersonSyncStatus getCurrentStatus();
    public void close(); // 仅移除 observer,不停止应用级任务
}

现有 close() 中的 backgroundExecutor.shutdownNow() 必须移除。控制器测试增加“关闭页面后 fake runtime 仍运行,且不再回调旧 observer”。

public final class ConsumptionPersonSyncRuntime
        implements ConsumptionPersonSyncController.Runtime {
    public static ConsumptionPersonSyncRuntime getInstance(Context context);
    @Override public boolean start();
    @Override public ConsumptionPersonSyncStatus getCurrentStatus();
    @Override public void addObserver(Observer observer);
    @Override public void removeObserver(Observer observer);
    public void recover();
    public void updateLocalStoreState(
            ConsumptionPersonSyncStatus.LocalStoreState state, long completedAt);
}

runtime 持有单线程主执行器和 AtomicBoolean,不持有 Activity/Dialog/View;通过主线程 Handler 发布状态。连接完成和主同步终态写 MMKV;数据库 Worker 更新次要状态。终态 failed > 0 为主同步失败。

String serverUrl = MmkvUtils.getInstance().get(
        Constants.SETTING_ONECARD_SERVER_URL, OnecardSettings.DEFAULT_SERVER_URL);
long appId = MmkvUtils.getInstance().get(
        Constants.SETTING_ONECARD_APP_ID, OnecardSettings.DEFAULT_APP_ID);
File spoolRoot = new File(appContext.getNoBackupFilesDir(),
        CustomerSyncSpool.ROOT_DIRECTORY);

每轮重新读取一卡通配置;业务上传继续使用 NetworkManager 当前 NetworkService,因此自动跟随设置 API 地址。装配 OnecardWebServiceClient → CustomerSpoolPullService(spool) → BusinessPersonSyncService(spool),不创建或读取 SQLiteCustomerRepository

rg -n 'CustomerSyncService|CustomerSyncResult|getSavedCount\(\)|loadBatch\(' \
  app/src/main app/src/test

Expected before deletion: 只剩旧文件和 Task 6 的兼容仓库方法,没有新生产链路调用方。删除 CustomerSyncService.javaCustomerSyncResult.java 以及 CustomerRepository/SQLiteCustomerRepository 中的 beginSync/saveBatch/completeSync/failSync/loadBatch 兼容入口;再次运行同一命令应无旧类型或 loadBatch() 输出。

./gradlew -q :app:testDebugUnitTest \
  --tests com.zhct.traybinding.onecard.customer.ConsumptionPersonSyncCoordinatorTest \
  --tests com.zhct.traybinding.onecard.customer.ConsumptionPersonSyncControllerTest \
  --tests com.zhct.traybinding.onecard.customer.ConsumptionPersonSyncStatusJsonCodecTest

Expected: 顺序、失败隔离、页面 detach 和状态持久化测试通过。

Task 9:接入应用启动和网络恢复

Files:

initNetWork();
ConsumptionPersonSyncRuntime.getInstance(this).recover();
ConsumptionPersonSyncRecoveryScheduler.triggerDatabaseImport(this);

恢复必须位于 MMKV 和 NetworkManager 初始化之后;不得在网络服务未构建时创建上传 Worker。

在现有 network.isAvailable() 分支中保留消费结果补传,并增加:

ConsumptionPersonSyncRecoveryScheduler.triggerUploadRecovery(this);

仅当设备运行模式为消费模式时触发;enqueueUniqueWork(UNIQUE_UPLOAD_RECOVERY, ExistingWorkPolicy.KEEP, request) 合并应用启动、页面进入和网络变化。

./gradlew -q :app:compileDebugJavaWithJavac

Expected: 编译通过,且没有新增静态 Activity 引用。

Task 10:实现弹框与右侧状态入口

Files:

consumption_onecard_service_status
consumption_onecard_service_icon
consumption_onecard_service_text
onecard_sync_connection_status
onecard_sync_connection_time
onecard_sync_connection_count
onecard_sync_person_status
onecard_sync_person_time
onecard_sync_person_progress
onecard_sync_person_summary
onecard_sync_local_store_status
onecard_sync_error
onecard_sync_manual
onecard_sync_close

可见文案全部进入 strings.xml;尺寸进入 dimens.xml;颜色优先复用 consumption_reader_status_available/unavailable/pending

public final class ConsumptionPersonSyncDialog extends DialogFragment {
    public interface Listener {
        void onManualSyncRequested();
        void onSyncDialogClosed();
    }
    public static ConsumptionPersonSyncDialog newInstance();
    public void render(ConsumptionPersonSyncStatus status);
    public boolean isDialogVisible();
}

render() 在 View 未创建时缓存最后状态;时间格式使用命名常量 yyyy-MM-dd HH:mm:ss.SSS;运行时禁用手动按钮;本地状态显示“后台更新中/已完成/等待重试”,但不改变主结果颜色。错误为空时区域 GONE

activity_consumption.xml 中与左侧读卡器状态同一纵向位置、右对齐增加可点击容器。图标和文字的 tint/textColor 由 Activity 根据最近连接验证结果设置:成功绿色,失败或未验证红色;连接中保持上次结果。

./gradlew -q :app:compileDebugJavaWithJavac

Expected: Java、布局、drawable 和资源编译通过。

Task 11:接入 ConsumptionActivity、自动关闭和最终验收

Files:

private static final long SYNC_DIALOG_AUTO_DISMISS_MS = 1000L;
private static final String SYNC_DIALOG_TAG = "onecard_person_sync";
private View onecardServiceStatusView;
private ImageView onecardServiceIcon;
private TextView onecardServiceText;
private ConsumptionPersonSyncController personSyncController;
private ConsumptionPersonSyncDialog personSyncDialog;

onCreate() 在现有仓库、网络、语音和读卡初始化完成后:

  1. 获取 ConsumptionPersonSyncRuntime.getInstance(getApplicationContext())
  2. 创建页面 controller 并恢复最近状态;
  3. 绑定右侧入口;
  4. 显示弹框;
  5. 触发未完成任务恢复;
  6. 在主任务门禁允许时自动开始本页面实例的一次新同步。
private final Runnable dismissSyncDialog = () -> {
    if (!destroyed && personSyncDialog != null
            && personSyncDialog.isDialogVisible()) {
        personSyncDialog.dismissAllowingStateLoss();
    }
};

Observer 每次刷新右侧入口和弹框;只有 currentRunTerminal == true 时才 mainHandler.postDelayed(dismissSyncDialog, SYNC_DIALOG_AUTO_DISMISS_MS)。历史打开不触发自动关闭;手动同步开始前移除旧关闭回调。

mainHandler.removeCallbacks(dismissSyncDialog);
if (personSyncController != null) personSyncController.close();
personSyncDialog = null;

保留现有订单、卡片、读卡器、消费记录仓库、人员查询仓库和语音资源释放顺序。不得调用 runtime executor 的 shutdownNow()

./gradlew -q :app:testDebugUnitTest

Expected: 退出码 0,0 failure。

./gradlew -q :app:assembleDebug

Expected: 退出码 0,生成 app/build/outputs/apk/debug/app-debug.apk

git diff --check
rg -n 'cacheDir|getCacheDir\(' app/src/main/java/com/zhct/traybinding/onecard/customer
rg -n 'CustomerSyncResult|loadBatch\(' app/src/main app/src/test
rg -n 'FileDescriptor\.sync|SHA-256|noBackupFilesDir|enqueueUniqueWork' \
  app/src/main/java/com/zhct/traybinding
rg -n '1000L|1000\.0|#[0-9A-Fa-f]{6,8}' \
  app/src/main/java/com/zhct/traybinding/onecard/customer \
  app/src/main/java/com/zhct/traybinding/main/ConsumptionActivity.java
git status --short

Expected:

在可用设备和测试服务器条件下逐项记录证据:

  1. 1000 人批次重复写入,统计 tempWriteMs + fsyncMs,P95 ≤ 100 ms;
  2. .tmp、刷盘后、原子改名后分别杀进程,恢复时不消费半文件;
  3. 远端成功、本地确认前杀进程,重传后服务端按 customerid 无重复人员;
  4. 数据库第二批失败时旧姓名仍可查,恢复完成后一次切换新姓名;
  5. 截断 JSON 后 SHA-256 拒绝上传和激活;
  6. 模拟磁盘空间不足,主同步明确失败且不跳过人员;
  7. 远端上传运行时人为拖慢 SQLite,uploadMs 不等待数据库完成;
  8. 页面和弹框关闭后任务继续,重新打开显示当前进度。

没有设备、服务地址或故障注入条件时,将对应项标记“未执行:缺少条件”,不得写成通过。

变更记录必须包含:

规格覆盖映射

设计验收实施任务主要证据
AC-001、AC-002、AC-003 页面、唯一任务和连接红绿状态Task 8、Task 10、Task 11控制器测试、资源编译、真机页面检查
AC-004、AC-005、AC-006 1001 分批、可靠 JSON、完整后才上传Task 2~Task 4批次/manifest/spool/拉取单元测试
AC-007 上传与数据库互不等待Task 7、Task 8协调顺序测试、fake 慢数据库并发测试
AC-008、AC-009 顺序上传、确认游标和主结果口径Task 5、Task 8业务上传与状态单元测试
AC-010 终态1秒关闭且数据库继续Task 10、Task 11调度测试、真机检查
AC-011 旧快照持续可查并原子切换Task 6、Task 7SQLite 仪器测试、importer 单元测试
AC-012、AC-013 幂等恢复、损坏和空间失败Task 3、Task 5、Task 7、Task 11故障注入与恢复证据
AC-014、AC-015 手动门禁、重启恢复Task 8、Task 9、Task 11控制器测试、WorkManager 编译、真机恢复
AC-016 单批安全落盘 P95Task 3、Task 11tempWriteMs + fsyncMs 目标设备统计

最终执行约束