OnecardDemo 用户信息 SQLite 分批持久化实施计划

For agentic workers: REQUIRED SUB-SKILL: Use executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: 每批最多 1000 条下载用户信息,每个成功批次立即把全部原始字段写入应用私有 SQLite,并以 CUSTOMERID 保证用户唯一。

Architecture: 使用纯 Java CustomerBatchParser 把每个 PROXY_BASE_CUSTOMERS 节点转换为不可变记录;使用 SQLiteCustomerRepository 在批事务内 upsert 主表和替换字段表;使用 CustomerSyncService 顺序请求、保存并报告进度。页面只显示同步摘要,不再拼接全部 SOAP XML。

Tech Stack: Java 8、Android SQLiteOpenHelper、JUnit 4、AndroidX Test、Gradle


Task 1:原始用户记录和 XML 解析

Files:

测试 XML 包含两个 PROXY_BASE_CUSTOMERS,覆盖 CUSTOMERIDNAMEPWDQUERYPWDIDCARDNO 和未知字段 NEWFIELD。断言返回两条记录、字段名和值保持原样;另测缺少合法账号、同节点重复字段、重复 CUSTOMERID 和空批次抛出 ServerException

Run: ./gradlew testDebugUnitTest --tests com.cpt.onecarddemo.customer.CustomerBatchParserTest

Expected: 编译失败,提示 CustomerRecordCustomerBatchParser 不存在。

CustomerRecord 构造器接收 long customerIdMap<String, String>,复制为不可修改的 LinkedHashMap;提供 getCustomerId()getFields() 和忽略大小写的 getField(String)

复用 SoapResponse 的禁用 DOCTYPE、外部实体和安全处理配置。遍历所有本地名等于 PROXY_BASE_CUSTOMERS 的节点,只读取其直接元素子节点;用大写字段名检测重复,用原始元素名作为 map key。CUSTOMERID 必须是非负十进制 long,同批账号不得重复,批次不得为空。

Run: ./gradlew testDebugUnitTest --tests com.cpt.onecarddemo.customer.CustomerBatchParserTest

Expected: BUILD SUCCESSFUL,解析测试全部通过。

Task 2:SQLite 仓库和批事务

Files:

接口方法固定为:

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;
int countCustomers();
String getField(long customerId, String fieldName);

使用 ApplicationProvider.getApplicationContext() 和独立测试数据库名。验证:两次保存同一 CUSTOMERID 后用户数仍为1且字段更新;PWDQUERYPWDIDCARDNO 可读回;一个批事务的第二条记录同时包含 PWDpwd,触发 COLLATE NOCASE 联合主键冲突并使整批回滚;只有 completeSync 才删除未在本轮看到的旧用户;failSync 保留旧数据。

Run: ./gradlew compileDebugAndroidTestJavaWithJavac

Expected: 编译失败,提示 SQLiteCustomerRepository 不存在。

数据库版本1,创建:

CREATE TABLE customers (
  customer_id INTEGER PRIMARY KEY,
  card_no TEXT,
  out_id TEXT,
  name TEXT,
  status TEXT,
  last_seen_sync_id TEXT NOT NULL,
  updated_at INTEGER NOT NULL
);
CREATE TABLE customer_fields (
  customer_id INTEGER NOT NULL,
  field_name TEXT COLLATE NOCASE NOT NULL,
  field_value TEXT,
  PRIMARY KEY(customer_id, field_name),
  FOREIGN KEY(customer_id) REFERENCES customers(customer_id) ON DELETE CASCADE
);
CREATE TABLE customer_sync_runs (
  sync_id TEXT PRIMARY KEY,
  started_at INTEGER NOT NULL,
  completed_at INTEGER,
  expected_count INTEGER NOT NULL,
  saved_count INTEGER NOT NULL DEFAULT 0,
  successful_batches INTEGER NOT NULL DEFAULT 0,
  status TEXT NOT NULL,
  error_message TEXT
);

并为 customers.card_nocustomers.out_idcustomer_fields(field_name, field_value) 建立索引,onConfigure 开启外键。

每个 saveBatch 开启事务;用 CONFLICT_REPLACE upsert 主表,删除该用户旧字段,再插入当前全部字段;更新同步记录的批次数和当前 last_seen_sync_id 用户数。任一插入失败回滚整批。

completeSync 在事务内删除 last_seen_sync_id != syncId 的旧用户并标记 SUCCESSfailSync 只更新同步状态和错误,不删除任何用户。

Run: ./gradlew connectedDebugAndroidTest -Pandroid.testInstrumentationRunnerArguments.class=com.cpt.onecarddemo.customer.SQLiteCustomerRepositoryTest

Expected: 测试通过且数据库在每个测试后删除。

Task 3:分批同步服务

Files:

用内存 fake source 返回 2 条加1条两个批次,用 fake repository 记录事件。断言顺序为 begin → save batch1 → progress1 → save batch2 → progress2 → complete;同一测试断言下一批起始账号等于上一批最大 CUSTOMERID + 1。第二个测试让第二批请求失败,断言第一批已经保存、调用 failSync、不调用 completeSync

Run: ./gradlew testDebugUnitTest --tests com.cpt.onecarddemo.customer.CustomerSyncServiceTest

Expected: 编译失败,提示同步服务类型不存在。

CustomerPageSource 提供 getCustomerCount()getCustomers(startingCustomerId, count)OnecardWebServiceClient 实现该接口。CustomerSyncResult 保存同步 ID、期望数、保存数、批次数、开始/结束时间和最终数据库用户数。

CustomerSyncService.sync(listener) 获取总数、开始同步,按 Constants.ONE_CARD_CUSTOMER_BATCH_SIZE 循环请求。每批解析成功后立即 saveBatch,提交后回调进度;剩余数按本批唯一记录数递减,下一起始账号为最大账号加1。任一异常调用 failSync 后重新抛出 ServerException

删除 OnecardWebServiceClient.getAllCustomers(),避免继续把全部 SoapResponse 保存在 List 中。

Run: ./gradlew testDebugUnitTest

Expected: BUILD SUCCESSFUL,0 failure、0 error。

Task 4:页面进度和安全设置

Files:

保持按钮 ID 不变,断言按钮文字仍为“获取全部用户信息(分段)”,结果区域存在且可选择;不新增敏感字段显示控件。

Activity 创建 SQLiteCustomerRepositoryCustomerSyncService。点击按钮后在现有单线程 executor 中运行同步;每批提交后更新状态为“第 N 批已保存,本批 X 条,累计 Y/总数”;成功显示同步 ID、保存数、数据库用户数和耗时,失败显示已保存批次/数量及错误。

删除 joinResponses 及其按钮调用,结果区域不再承载全量 XML。

把 Manifest 的 android:allowBackup 改为 false。数据库不写外部目录,敏感字段和值不写日志。

Run: ./gradlew assembleDebugAndroidTest
Run: ./gradlew connectedDebugAndroidTest

Expected: Android 测试编译成功,真机测试0失败。

Task 5:文档、构建和现场验证准备

Files:

记录三张表、唯一键、每批提交、失败保留前批、成功清理旧用户、全部原始字段含敏感字段、应用私有明文 SQLite 和关闭备份边界。

新增独立变更记录,写明 TDD RED/GREEN、测试数量、构建、Lint、安装和现场状态;不执行 Git 写操作。

Run: ./gradlew testDebugUnitTest assembleDebug assembleDebugAndroidTest lintDebug --rerun-tasks

Expected: BUILD SUCCESSFUL,JVM 测试0失败,Lint 0 error。

Run: adb install -r app/build/outputs/apk/debug/app-debug.apk

Expected: Success

用户连接服务器后点击“获取全部用户信息(分段)”。通过页面摘要和只读 ADB 数据库查询核对 GetCustomerCount、本轮保存数、customers 行数、customer_fields 字段数和 customer_sync_runs.status

执行约束