For agentic workers: REQUIRED SUB-SKILL: Use
executing-plansto 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。
git status --short 和定向 git diff -- <files>,只读核对用户改动,不执行任何 Git 写操作。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: 当前已有测试通过;如果基线失败,先记录现有失败,不把它误归因于本计划。
app/src/main/java/com/zhct/traybinding/onecard/customer/CustomerBatchSnapshot.java:单批不可变人员快照及排序、唯一性、数量边界。app/src/main/java/com/zhct/traybinding/onecard/customer/CustomerBatchMetadata.java:文件名、人数、起止账号、字节数和 SHA-256。app/src/main/java/com/zhct/traybinding/onecard/customer/CustomerBatchJsonCodec.java:CustomerRecord 与稳定 JSON DTO 的严格往返。app/src/main/java/com/zhct/traybinding/onecard/customer/CustomerSyncManifest.java:拉取、远端和数据库三组持久化状态。app/src/main/java/com/zhct/traybinding/onecard/customer/CustomerSyncManifestJsonCodec.java:manifest JSON 严格编解码。app/src/main/java/com/zhct/traybinding/onecard/customer/CustomerBatchSink.java:拉取服务只写批次的边界。app/src/main/java/com/zhct/traybinding/onecard/customer/CustomerSyncSpool.java:原子批次写入、强制刷盘、校验、扫描、恢复和清理。app/src/main/java/com/zhct/traybinding/onecard/customer/CustomerPullResult.java:完整 JSON 拉取结果。app/src/main/java/com/zhct/traybinding/onecard/customer/CustomerSpoolPullService.java:从一卡通分批拉取并生成完整 JSON 快照。app/src/main/java/com/zhct/traybinding/onecard/customer/CustomerBatchSource.java:远端上传与数据库导入共享的只读批次边界。app/src/main/java/com/zhct/traybinding/onecard/customer/CustomerSyncManifestStore.java:manifest 读写抽象。app/src/main/java/com/zhct/traybinding/onecard/customer/AtomicCustomerSyncManifestStore.java:生产环境 Android AtomicFile 实现。app/src/main/java/com/zhct/traybinding/onecard/customer/RemoteProgressStore.java:远端成功批次确认边界。app/src/main/java/com/zhct/traybinding/onecard/customer/DatabaseProgressStore.java:后台入库进度边界。app/src/main/java/com/zhct/traybinding/onecard/customer/CustomerSnapshotImporter.java:从 JSON 批次导入候选快照并激活。app/src/main/java/com/zhct/traybinding/onecard/customer/ConsumptionPersonSyncRecoveryScheduler.java:唯一远端恢复任务和唯一数据库导入任务。app/src/main/java/com/zhct/traybinding/onecard/customer/ConsumptionPersonSyncRuntime.java:应用级单任务门禁、观察者和状态流转。app/src/main/java/com/zhct/traybinding/onecard/customer/ConsumptionPersonSyncTask.java:读取最新配置并装配一次拉取/上传。app/src/main/java/com/zhct/traybinding/workmanager/ConsumptionPersonUploadRecoveryWorker.java:有网络约束的远端续传。app/src/main/java/com/zhct/traybinding/workmanager/CustomerSnapshotImportWorker.java:无网络约束的 SQLite 后台导入。app/src/main/java/com/zhct/traybinding/widget/ConsumptionPersonSyncDialog.javaapp/src/main/res/layout/consumption_person_sync_dialog.xmlapp/src/main/res/drawable/ic_onecard_service.xmlapp/src/main/res/drawable/consumption_onecard_status_button_background.xmlapp/src/main/res/drawable/consumption_sync_manual_button_background.xmlapp/src/main/java/com/zhct/traybinding/onecard/customer/BusinessPersonSyncService.javaapp/src/main/java/com/zhct/traybinding/onecard/customer/ConsumptionPersonSyncCoordinator.javaapp/src/main/java/com/zhct/traybinding/onecard/customer/ConsumptionPersonSyncStatus.javaapp/src/main/java/com/zhct/traybinding/onecard/customer/ConsumptionPersonSyncStatusJsonCodec.javaapp/src/main/java/com/zhct/traybinding/onecard/customer/ConsumptionPersonSyncStatusStore.javaapp/src/main/java/com/zhct/traybinding/onecard/customer/ConsumptionPersonSyncController.javaapp/src/main/java/com/zhct/traybinding/onecard/customer/CustomerRepository.javaapp/src/main/java/com/zhct/traybinding/onecard/customer/SQLiteCustomerRepository.javaapp/src/main/java/com/zhct/traybinding/application/CptApplication.javaapp/src/main/java/com/zhct/traybinding/main/ConsumptionActivity.javaapp/src/main/java/com/zhct/traybinding/utils/Constants.javaapp/src/main/res/layout/activity_consumption.xmlapp/src/main/res/values/strings.xmlapp/src/main/res/values/dimens.xmlapp/src/main/res/values/colors.xml(仅在现有状态色无法复用时)app/src/main/java/com/zhct/traybinding/onecard/customer/CustomerSyncService.java:旧实现把 SQLite 写入放在拉取关键路径,应用链路迁移后删除。app/src/main/java/com/zhct/traybinding/onecard/customer/CustomerSyncResult.java:由语义准确的 CustomerPullResult 取代;删除前必须确认 rg -n 'CustomerSyncResult' app/src 只剩本计划迁移点。app/src/test/java/com/zhct/traybinding/onecard/customer/CustomerBatchJsonCodecTest.javaapp/src/test/java/com/zhct/traybinding/onecard/customer/CustomerSyncManifestTest.javaapp/src/test/java/com/zhct/traybinding/onecard/customer/CustomerSyncSpoolTest.javaapp/src/test/java/com/zhct/traybinding/onecard/customer/CustomerSpoolPullServiceTest.javaapp/src/test/java/com/zhct/traybinding/onecard/customer/CustomerSnapshotImporterTest.javaBusinessPersonSyncServiceTest.javaConsumptionPersonSyncCoordinatorTest.javaConsumptionPersonSyncControllerTest.javaapp/src/androidTest/java/com/zhct/traybinding/onecard/customer/SQLiteCustomerRepositorySnapshotTest.java/Users/liang/AndroidStudioProjects/zhct/zhctprompt/work_android/ZhctTrayBindingMachine/change_records/2026-08-29-consumption-onecard-sync-dialog.md/Users/liang/AndroidStudioProjects/zhct/zhctprompt/work_android/ZhctTrayBindingMachine/change_records/2026-08-29-consumption-onecard-sync-dialog.htmlFiles:
app/src/main/java/com/zhct/traybinding/onecard/customer/ConsumptionPersonSyncStatus.javaapp/src/main/java/com/zhct/traybinding/onecard/customer/ConsumptionPersonSyncStatusJsonCodec.javaapp/src/test/java/com/zhct/traybinding/onecard/customer/ConsumptionPersonSyncStatusJsonCodecTest.java@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 兼容测试全部通过。
Files:
app/src/main/java/com/zhct/traybinding/onecard/customer/CustomerBatchSnapshot.javaapp/src/main/java/com/zhct/traybinding/onecard/customer/CustomerBatchMetadata.javaapp/src/main/java/com/zhct/traybinding/onecard/customer/CustomerBatchJsonCodec.javaapp/src/main/java/com/zhct/traybinding/onecard/customer/CustomerSyncManifest.javaapp/src/main/java/com/zhct/traybinding/onecard/customer/CustomerSyncManifestJsonCodec.javaapp/src/test/java/com/zhct/traybinding/onecard/customer/CustomerBatchJsonCodecTest.javaapp/src/test/java/com/zhct/traybinding/onecard/customer/CustomerSyncManifestTest.java@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: 往返、非法输入和状态机测试通过。
Files:
app/src/main/java/com/zhct/traybinding/onecard/customer/CustomerSyncSpool.javaapp/src/main/java/com/zhct/traybinding/onecard/customer/CustomerBatchSink.javaapp/src/main/java/com/zhct/traybinding/onecard/customer/CustomerBatchSource.javaapp/src/main/java/com/zhct/traybinding/onecard/customer/CustomerSyncManifestStore.javaapp/src/main/java/com/zhct/traybinding/onecard/customer/AtomicCustomerSyncManifestStore.javaapp/src/main/java/com/zhct/traybinding/onecard/customer/RemoteProgressStore.javaapp/src/main/java/com/zhct/traybinding/onecard/customer/DatabaseProgressStore.javaapp/src/test/java/com/zhct/traybinding/onecard/customer/CustomerSyncSpoolTest.java@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 安全余量 → 写同目录 .tmp → flush() → getFD().sync() → 计算长度/SHA-256并解码复核人数 → 检查同目录 renameTo() 返回值 → 通过 CustomerSyncManifestStore 写 manifest。生产注入使用 Android AtomicFile 的 AtomicCustomerSyncManifestStore 和 File::getUsableSpace;本地 JUnit 注入纯 Java测试 store 和固定空间。生产根目录由 new File(context.getNoBackupFilesDir(), ROOT_DIRECTORY) 创建;测试直接传 TemporaryFolder。
扫描时删除 .tmp 和未被 manifest 登记的孤儿 .json;deleteCompleted() 只允许删除远端全部确认,且数据库为 ACTIVE,或已被另一个可验证为 ACTIVE 的更新快照标记为 SUPERSEDED 的目录。仅有更新的 READY 文件、导入中的快照或导入失败的快照,均不能触发旧目录清理。
所有性能值写日志:batchBytes、tempWriteMs、fsyncMs。日志不得包含 JSON 正文、姓名或卡号。
Run: Step 2 命令。
Expected: 原子提交、损坏拒绝、扫描恢复测试通过。
Files:
app/src/main/java/com/zhct/traybinding/onecard/customer/CustomerPullResult.javaapp/src/main/java/com/zhct/traybinding/onecard/customer/CustomerSpoolPullService.javaapp/src/test/java/com/zhct/traybinding/onecard/customer/CustomerSpoolPullServiceTest.java@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: CustomerSpoolPullService 和 sync(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、游标和重复账号测试通过。
Files:
app/src/main/java/com/zhct/traybinding/onecard/customer/BusinessPersonSyncService.javaapp/src/test/java/com/zhct/traybinding/onecard/customer/BusinessPersonSyncServiceTest.java@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 == 0、total == request size、created+updated+failed == total 且 failed == 0 时才原子确认;failed > 0 返回失败汇总并停止后续批次。网络/响应异常抛出 ServerException,文件和确认游标保持可恢复。
Run: Step 2 命令。
Expected: 1001 分批、确认游标、部分失败不确认、网络失败停止测试通过。
Files:
app/src/main/java/com/zhct/traybinding/onecard/customer/CustomerRepository.javaapp/src/main/java/com/zhct/traybinding/onecard/customer/SQLiteCustomerRepository.javaapp/src/androidTest/java/com/zhct/traybinding/onecard/customer/SQLiteCustomerRepositorySnapshotTest.java@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: 编译必须通过;无在线设备时第二条明确记录“未执行”,不得写成通过。
Files:
app/src/main/java/com/zhct/traybinding/onecard/customer/CustomerSnapshotImporter.javaapp/src/test/java/com/zhct/traybinding/onecard/customer/CustomerSnapshotImporterTest.javaapp/src/main/java/com/zhct/traybinding/onecard/customer/ConsumptionPersonSyncRecoveryScheduler.javaapp/src/main/java/com/zhct/traybinding/workmanager/CustomerSnapshotImportWorker.javaapp/src/main/java/com/zhct/traybinding/workmanager/ConsumptionPersonUploadRecoveryWorker.java@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 测试和主源码编译通过。
Files:
app/src/main/java/com/zhct/traybinding/onecard/customer/ConsumptionPersonSyncCoordinator.javaapp/src/test/java/com/zhct/traybinding/onecard/customer/ConsumptionPersonSyncCoordinatorTest.javaapp/src/main/java/com/zhct/traybinding/onecard/customer/ConsumptionPersonSyncRuntime.javaapp/src/main/java/com/zhct/traybinding/onecard/customer/ConsumptionPersonSyncTask.javaapp/src/main/java/com/zhct/traybinding/onecard/customer/ConsumptionPersonSyncController.javaapp/src/test/java/com/zhct/traybinding/onecard/customer/ConsumptionPersonSyncControllerTest.javaapp/src/main/java/com/zhct/traybinding/onecard/customer/ConsumptionPersonSyncStatusStore.javaapp/src/main/java/com/zhct/traybinding/onecard/customer/CustomerRepository.javaapp/src/main/java/com/zhct/traybinding/onecard/customer/SQLiteCustomerRepository.javaapp/src/main/java/com/zhct/traybinding/utils/Constants.javaapp/src/main/java/com/zhct/traybinding/onecard/customer/CustomerSyncService.javaapp/src/main/java/com/zhct/traybinding/onecard/customer/CustomerSyncResult.javaassertEquals(Arrays.asList(
"connecting", "connect", "connected:1001",
"pull:1001", "database-scheduled:sync-1",
"upload-started:1001", "upload", "completed", "disconnect"), events);
另写测试断言拉取失败时没有 database-scheduled 和 upload;数据库调度器抛错时记录本地失败但仍执行上传,不能把辅助存储异常变成主同步失败。
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.onCompleted、ConsumptionPersonSyncStatus.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.java、CustomerSyncResult.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 和状态持久化测试通过。
Files:
app/src/main/java/com/zhct/traybinding/application/CptApplication.javainitNetWork();
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 引用。
Files:
app/src/main/java/com/zhct/traybinding/widget/ConsumptionPersonSyncDialog.javaapp/src/main/res/layout/consumption_person_sync_dialog.xmlapp/src/main/res/drawable/ic_onecard_service.xmlapp/src/main/res/drawable/consumption_onecard_status_button_background.xmlapp/src/main/res/drawable/consumption_sync_manual_button_background.xmlapp/src/main/res/layout/activity_consumption.xmlapp/src/main/res/values/strings.xmlapp/src/main/res/values/dimens.xmlapp/src/main/res/values/colors.xml only if existing colors cannot express the statesconsumption_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 和资源编译通过。
Files:
app/src/main/java/com/zhct/traybinding/main/ConsumptionActivity.javaprivate 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() 在现有仓库、网络、语音和读卡初始化完成后:
ConsumptionPersonSyncRuntime.getInstance(getApplicationContext());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:
git diff --check 无输出;cacheDir;在可用设备和测试服务器条件下逐项记录证据:
tempWriteMs + fsyncMs,P95 ≤ 100 ms;.tmp、刷盘后、原子改名后分别杀进程,恢复时不消费半文件;customerid 无重复人员;uploadMs 不等待数据库完成;没有设备、服务地址或故障注入条件时,将对应项标记“未执行:缺少条件”,不得写成通过。
变更记录必须包含:
| 设计验收 | 实施任务 | 主要证据 |
|---|---|---|
| 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 7 | SQLite 仪器测试、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 单批安全落盘 P95 | Task 3、Task 11 | tempWriteMs + fsyncMs 目标设备统计 |
zhctprompt 均包含大量用户修改;每次只补丁本任务列出的文件,任何重叠语义不明时停止该文件并报告。