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:
Create:
/Users/liang/AndroidStudioProjects/OnecardDemo/app/src/main/java/com/cpt/onecarddemo/customer/CustomerRecord.javaCreate:
/Users/liang/AndroidStudioProjects/OnecardDemo/app/src/main/java/com/cpt/onecarddemo/customer/CustomerBatchParser.javaCreate:
/Users/liang/AndroidStudioProjects/OnecardDemo/app/src/test/java/com/cpt/onecarddemo/customer/CustomerBatchParserTest.javaModify:
/Users/liang/AndroidStudioProjects/OnecardDemo/app/src/main/java/com/cpt/onecarddemo/server/SoapResponse.javaStep 1:编写解析 RED 测试
测试 XML 包含两个 PROXY_BASE_CUSTOMERS,覆盖 CUSTOMERID、NAME、PWD、QUERYPWD、IDCARDNO 和未知字段 NEWFIELD。断言返回两条记录、字段名和值保持原样;另测缺少合法账号、同节点重复字段、重复 CUSTOMERID 和空批次抛出 ServerException。
- Step 2:运行解析测试确认 RED
Run: ./gradlew testDebugUnitTest --tests com.cpt.onecarddemo.customer.CustomerBatchParserTest
Expected: 编译失败,提示 CustomerRecord 或 CustomerBatchParser 不存在。
- Step 3:实现不可变记录
CustomerRecord 构造器接收 long customerId 和 Map<String, String>,复制为不可修改的 LinkedHashMap;提供 getCustomerId()、getFields() 和忽略大小写的 getField(String)。
- Step 4:实现安全解析
复用 SoapResponse 的禁用 DOCTYPE、外部实体和安全处理配置。遍历所有本地名等于 PROXY_BASE_CUSTOMERS 的节点,只读取其直接元素子节点;用大写字段名检测重复,用原始元素名作为 map key。CUSTOMERID 必须是非负十进制 long,同批账号不得重复,批次不得为空。
- Step 5:运行解析测试确认 GREEN
Run: ./gradlew testDebugUnitTest --tests com.cpt.onecarddemo.customer.CustomerBatchParserTest
Expected: BUILD SUCCESSFUL,解析测试全部通过。
Task 2:SQLite 仓库和批事务
Files:
Create:
/Users/liang/AndroidStudioProjects/OnecardDemo/app/src/main/java/com/cpt/onecarddemo/customer/CustomerRepository.javaCreate:
/Users/liang/AndroidStudioProjects/OnecardDemo/app/src/main/java/com/cpt/onecarddemo/customer/SQLiteCustomerRepository.javaCreate:
/Users/liang/AndroidStudioProjects/OnecardDemo/app/src/androidTest/java/com/cpt/onecarddemo/customer/SQLiteCustomerRepositoryTest.javaStep 1:定义仓库契约
接口方法固定为:
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);
- Step 2:编写 SQLite RED 测试
使用 ApplicationProvider.getApplicationContext() 和独立测试数据库名。验证:两次保存同一 CUSTOMERID 后用户数仍为1且字段更新;PWD、QUERYPWD、IDCARDNO 可读回;一个批事务的第二条记录同时包含 PWD 和 pwd,触发 COLLATE NOCASE 联合主键冲突并使整批回滚;只有 completeSync 才删除未在本轮看到的旧用户;failSync 保留旧数据。
- Step 3:运行 Android 测试编译确认 RED
Run: ./gradlew compileDebugAndroidTestJavaWithJavac
Expected: 编译失败,提示 SQLiteCustomerRepository 不存在。
- Step 4:实现数据库结构
数据库版本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_no、customers.out_id 和 customer_fields(field_name, field_value) 建立索引,onConfigure 开启外键。
- Step 5:实现每批原子保存
每个 saveBatch 开启事务;用 CONFLICT_REPLACE upsert 主表,删除该用户旧字段,再插入当前全部字段;更新同步记录的批次数和当前 last_seen_sync_id 用户数。任一插入失败回滚整批。
- Step 6:实现完成和失败语义
completeSync 在事务内删除 last_seen_sync_id != syncId 的旧用户并标记 SUCCESS;failSync 只更新同步状态和错误,不删除任何用户。
- Step 7:运行真机仓库测试确认 GREEN
Run: ./gradlew connectedDebugAndroidTest -Pandroid.testInstrumentationRunnerArguments.class=com.cpt.onecarddemo.customer.SQLiteCustomerRepositoryTest
Expected: 测试通过且数据库在每个测试后删除。
Task 3:分批同步服务
Files:
Create:
/Users/liang/AndroidStudioProjects/OnecardDemo/app/src/main/java/com/cpt/onecarddemo/customer/CustomerPageSource.javaCreate:
/Users/liang/AndroidStudioProjects/OnecardDemo/app/src/main/java/com/cpt/onecarddemo/customer/CustomerSyncResult.javaCreate:
/Users/liang/AndroidStudioProjects/OnecardDemo/app/src/main/java/com/cpt/onecarddemo/customer/CustomerSyncService.javaCreate:
/Users/liang/AndroidStudioProjects/OnecardDemo/app/src/test/java/com/cpt/onecarddemo/customer/CustomerSyncServiceTest.javaModify:
/Users/liang/AndroidStudioProjects/OnecardDemo/app/src/main/java/com/cpt/onecarddemo/server/OnecardWebServiceClient.javaStep 1:编写同步 RED 测试
用内存 fake source 返回 2 条加1条两个批次,用 fake repository 记录事件。断言顺序为 begin → save batch1 → progress1 → save batch2 → progress2 → complete;同一测试断言下一批起始账号等于上一批最大 CUSTOMERID + 1。第二个测试让第二批请求失败,断言第一批已经保存、调用 failSync、不调用 completeSync。
- Step 2:运行同步测试确认 RED
Run: ./gradlew testDebugUnitTest --tests com.cpt.onecarddemo.customer.CustomerSyncServiceTest
Expected: 编译失败,提示同步服务类型不存在。
- Step 3:实现请求源和结果类型
CustomerPageSource 提供 getCustomerCount() 和 getCustomers(startingCustomerId, count);OnecardWebServiceClient 实现该接口。CustomerSyncResult 保存同步 ID、期望数、保存数、批次数、开始/结束时间和最终数据库用户数。
- Step 4:实现同步编排
CustomerSyncService.sync(listener) 获取总数、开始同步,按 Constants.ONE_CARD_CUSTOMER_BATCH_SIZE 循环请求。每批解析成功后立即 saveBatch,提交后回调进度;剩余数按本批唯一记录数递减,下一起始账号为最大账号加1。任一异常调用 failSync 后重新抛出 ServerException。
- Step 5:移除内存聚合路径
删除 OnecardWebServiceClient.getAllCustomers(),避免继续把全部 SoapResponse 保存在 List 中。
- Step 6:运行同步测试和完整 JVM 测试确认 GREEN
Run: ./gradlew testDebugUnitTest
Expected: BUILD SUCCESSFUL,0 failure、0 error。
Task 4:页面进度和安全设置
Files:
Modify:
/Users/liang/AndroidStudioProjects/OnecardDemo/app/src/main/java/com/cpt/onecarddemo/ServerOperationsActivity.javaModify:
/Users/liang/AndroidStudioProjects/OnecardDemo/app/src/main/AndroidManifest.xmlModify:
/Users/liang/AndroidStudioProjects/OnecardDemo/app/src/androidTest/java/com/cpt/onecarddemo/ServerOperationsUiTest.javaStep 1:更新 UI 测试期望
保持按钮 ID 不变,断言按钮文字仍为“获取全部用户信息(分段)”,结果区域存在且可选择;不新增敏感字段显示控件。
- Step 2:接入同步服务
Activity 创建 SQLiteCustomerRepository 和 CustomerSyncService。点击按钮后在现有单线程 executor 中运行同步;每批提交后更新状态为“第 N 批已保存,本批 X 条,累计 Y/总数”;成功显示同步 ID、保存数、数据库用户数和耗时,失败显示已保存批次/数量及错误。
- Step 3:删除完整 XML 页面拼接
删除 joinResponses 及其按钮调用,结果区域不再承载全量 XML。
- Step 4:关闭系统自动备份
把 Manifest 的 android:allowBackup 改为 false。数据库不写外部目录,敏感字段和值不写日志。
- Step 5:运行 UI 编译和测试
Run: ./gradlew assembleDebugAndroidTest
Run: ./gradlew connectedDebugAndroidTest
Expected: Android 测试编译成功,真机测试0失败。
Task 5:文档、构建和现场验证准备
Files:
Modify:
/Users/liang/AndroidStudioProjects/zhct/zhctprompt/work_android/OnecardDemo/designs/2026-08-24-用户信息SQLite分批持久化设计.mdModify:
/Users/liang/AndroidStudioProjects/zhct/zhctprompt/work_android/OnecardDemo/protocols/Change单点功能与字段说明.mdModify:
/Users/liang/AndroidStudioProjects/zhct/zhctprompt/work_android/OnecardDemo/change_records/CHANGELOG.mdRegenerate: 上述文件和本计划的同名
.htmlStep 1:同步协议和字段口径
记录三张表、唯一键、每批提交、失败保留前批、成功清理旧用户、全部原始字段含敏感字段、应用私有明文 SQLite 和关闭备份边界。
- Step 2:记录实现和验证证据
新增独立变更记录,写明 TDD RED/GREEN、测试数量、构建、Lint、安装和现场状态;不执行 Git 写操作。
- Step 3:运行完整验证
Run: ./gradlew testDebugUnitTest assembleDebug assembleDebugAndroidTest lintDebug --rerun-tasks
Expected: BUILD SUCCESSFUL,JVM 测试0失败,Lint 0 error。
- Step 4:安装 APK
Run: adb install -r app/build/outputs/apk/debug/app-debug.apk
Expected: Success。
- Step 5:交付现场同步
用户连接服务器后点击“获取全部用户信息(分段)”。通过页面摘要和只读 ADB 数据库查询核对 GetCustomerCount、本轮保存数、customers 行数、customer_fields 字段数和 customer_sync_runs.status。
执行约束
- 当前会话内联执行,不调度子代理。
- 不执行 Git 暂存、提交、推送、分支切换或其他 Git 写操作。
- 不在自动构建阶段自行触发约 2.6 万用户的现场全量同步;只读取2条结构样本作为解析依据。
- 不在工具输出、日志、测试报告或文档中写入真实敏感字段值。