For agentic workers: REQUIRED SUB-SKILL: Use
subagent-driven-developmentorexecuting-plansto implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.
Goal: 在结算页实现精确金额累加,并通过商米 T1mini 系统扫码组件采集付款码、调用现有消费支付接口完成单次支付。
Architecture: 将纯金额规则留在 AmountInputController,将商米 Intent 与返回值解析封装到 SunmiScannerClient,将支付协议字段集中到 PaymentRequestFactory。MainActivity 只编排页面、扫码和网络生命周期,避免金额运算、敏感扫码解析和请求常量散落在 Activity 中。
Tech Stack: Java 8、Android SDK 33(minSdk 23)、AppCompat、Retrofit 2、Gson、JUnit 4、商米系统扫码 Activity。
Git boundary: 当前目录没有 Git 元数据,且用户未授权提交;所有提交步骤均省略,只编辑、验证并报告。
app/src/main/java/com/cpt/sanquanconsumption/AmountInputController.java — 金额表达式、累加、删除、清空和精确合计。app/src/test/java/com/cpt/sanquanconsumption/AmountInputControllerTest.java — 金额表达式行为与边界测试。app/src/main/java/com/cpt/sanquanconsumption/SunmiScannerClient.java — 商米扫码组件选择、Intent 构造与返回结果解析。app/src/test/java/com/cpt/sanquanconsumption/SunmiScannerClientTest.java — 版本选择、扫码结果解析和付款码规范化测试。app/src/main/java/com/cpt/sanquanconsumption/PaymentRequestFactory.java — 集中构造 /api/consume/orderPay 请求。app/src/test/java/com/cpt/sanquanconsumption/PaymentRequestFactoryTest.java — 支付金额、自定义菜品和订单上下文字段测试。app/src/main/java/com/cpt/sanquanconsumption/MainActivity.java — 接入累加、扫码、单次支付、成功/失败处理和请求取消。app/src/main/AndroidManifest.xml — 声明商米扫码包可见性。app/src/main/res/values/strings.xml — 增加支付校验、处理中、成功和失败文案。app/src/main/res/layout/dialog_scan_payment.xml — 删除不再使用的占位扫码弹框,由商米系统扫码页面承担交互。zhctprompt/work_android/SanquanConsumption/change_records/CHANGELOG.md — 记录本次实现、验证和实机边界。zhctprompt/work_android/SanquanConsumption/change_records/CHANGELOG.html — 与 Markdown 变更记录保持同内容。Files:
Modify: app/src/test/java/com/cpt/sanquanconsumption/AmountInputControllerTest.java
Modify: app/src/main/java/com/cpt/sanquanconsumption/AmountInputController.java
Step 1: 写入失败测试
在现有测试类中增加以下用例,并保留原有输入精度测试:
@Test
public void accumulatesExpressionWithDecimalPrecision() {
AmountInputController input = new AmountInputController();
input.append("1");
input.addCurrent();
input.append("3");
input.append(".");
input.append("6");
input.addCurrent();
input.append("2");
input.addCurrent();
input.append("0");
input.append(".");
input.append("9");
input.append("1");
assertEquals("1+3.6+2+0.91", input.raw());
assertEquals("7.51", input.displayNumber());
assertEquals("7.51", input.paymentAmount());
}
@Test
public void ignoresEmptyZeroAndRepeatedAdditions() {
AmountInputController input = new AmountInputController();
input.addCurrent();
input.append("0");
input.addCurrent();
input.addCurrent();
assertEquals("0", input.raw());
assertEquals("0", input.displayNumber());
assertFalse(input.hasPayableAmount());
}
@Test
public void deleteCanCrossAdditionBoundaryAndClearResetsEverything() {
AmountInputController input = new AmountInputController();
input.append("1");
input.addCurrent();
input.append("6");
input.delete();
input.delete();
assertEquals("1", input.raw());
assertEquals("1", input.displayNumber());
input.clear();
assertEquals("", input.raw());
assertEquals("0", input.displayNumber());
}
并补充静态导入:
import static org.junit.Assert.assertFalse;
Run:
./gradlew :app:testDebugUnitTest --tests '*AmountInputControllerTest' --offline
Expected: 编译失败,提示 addCurrent()、paymentAmount() 或 hasPayableAmount() 尚未定义。
BigDecimal 实现金额表达式将控制器内部值改为完整表达式,新增常量和方法:
private static final String ADD_OPERATOR = "+";
private static final BigDecimal ZERO = BigDecimal.ZERO;
public void addCurrent() {
String current = currentToken();
if (current.endsWith(DECIMAL_POINT)) {
value.deleteCharAt(value.length() - 1);
current = currentToken();
}
if (current.isEmpty() || new BigDecimal(current).compareTo(ZERO) <= 0) return;
value.append(ADD_OPERATOR);
}
public boolean hasPayableAmount() {
return total().compareTo(ZERO) > 0;
}
public String paymentAmount() {
return format(total());
}
public String displayNumber() {
return format(total());
}
private String currentToken() {
int operatorIndex = value.lastIndexOf(ADD_OPERATOR);
return value.substring(operatorIndex + ADD_OPERATOR.length());
}
private BigDecimal total() {
BigDecimal total = ZERO;
for (String token : value.toString().split("\\+", -1)) {
if (!token.isEmpty() && !DECIMAL_POINT.equals(token)) {
total = total.add(new BigDecimal(token));
}
}
return total;
}
private String format(BigDecimal amount) {
return amount.stripTrailingZeros().toPlainString();
}
更新小数点和整数位数判断,使其只检查 currentToken();delete() 继续删除表达式最后一个字符,clear() 继续清空整个 StringBuilder。为类注释补充“使用十进制精确累计,不负责支付”的职责边界。
Run:
./gradlew :app:testDebugUnitTest --tests '*AmountInputControllerTest' --offline
Expected: AmountInputControllerTest 全部通过。
Files:
Create: app/src/main/java/com/cpt/sanquanconsumption/SunmiScannerClient.java
Create: app/src/test/java/com/cpt/sanquanconsumption/SunmiScannerClientTest.java
Modify: app/src/main/AndroidManifest.xml
Step 1: 写入扫码解析失败测试
创建测试类,覆盖旧版 Action、有效 QR、非 QR 和 C 前缀:
public class SunmiScannerClientTest {
@Test
public void oldScannerVersionUsesLegacyAction() {
assertEquals("com.summi.scan", SunmiScannerClient.actionForVersion("1.2.5"));
assertEquals("com.sunmi.scanner.qrscanner",
SunmiScannerClient.actionForVersion("4.4.4"));
}
@Test
public void parsesQrCodeAndRemovesTransportPrefix() {
ArrayList<HashMap<String, String>> payload = new ArrayList<>();
HashMap<String, String> item = new HashMap<>();
item.put("TYPE", "QR Code");
item.put("VALUE", " C281234567890\r\n");
payload.add(item);
assertEquals("281234567890", SunmiScannerClient.parsePayload(payload));
}
@Test
public void rejectsNonQrAndMalformedPayloads() {
ArrayList<HashMap<String, String>> payload = new ArrayList<>();
HashMap<String, String> item = new HashMap<>();
item.put("TYPE", "CODE_128");
item.put("VALUE", "281234567890");
payload.add(item);
assertNull(SunmiScannerClient.parsePayload(payload));
assertNull(SunmiScannerClient.parsePayload("unexpected"));
assertNull(SunmiScannerClient.parsePayload(new ArrayList<>()));
}
}
Run:
./gradlew :app:testDebugUnitTest --tests '*SunmiScannerClientTest' --offline
Expected: 编译失败,提示 SunmiScannerClient 不存在。
创建 SunmiScannerClient,包含以下公开边界:
public final class SunmiScannerClient {
public static final int REQUEST_CODE = 1024;
static final String LEGACY_ACTION = "com.summi.scan";
static final String MODERN_ACTION = "com.sunmi.scanner.qrscanner";
private static final String SCANNER_PACKAGE = "com.sunmi.scanner";
private static final String MODERN_MIN_VERSION = "4.4.4";
private SunmiScannerClient() {}
public static boolean launch(Activity activity) {
Intent intent = new Intent(actionForInstalledScanner(activity));
intent.putExtra("PLAY_SOUND", true);
intent.putExtra("PLAY_VIBRATE", true);
intent.putExtra("IDENTIFY_MORE_CODE", false);
intent.putExtra("SCAN_MODE", false);
intent.putExtra("IS_QR_CODE_ENABLE", true);
intent.putExtra("IS_SHOW_ALBUM", false);
intent.putExtra("IS_SHOW_SETTING", false);
try {
activity.startActivityForResult(intent, REQUEST_CODE);
return true;
} catch (ActivityNotFoundException error) {
return false;
}
}
public static String parseResult(Intent data) {
if (data == null || data.getExtras() == null) return null;
return parsePayload(data.getExtras().getSerializable("data"));
}
static String parsePayload(Object payload) {
if (!(payload instanceof ArrayList)) return null;
ArrayList<?> items = (ArrayList<?>) payload;
if (items.isEmpty() || !(items.get(0) instanceof Map)) return null;
Map<?, ?> first = (Map<?, ?>) items.get(0);
Object type = first.get("TYPE");
Object value = first.get("VALUE");
if (!(type instanceof String) || !(value instanceof String)
|| !((String) type).toUpperCase(Locale.ROOT).contains("QR")) return null;
String normalized = ((String) value).replace("\r", "").replace("\n", "").trim();
if (normalized.startsWith("C")) normalized = normalized.substring(1);
return normalized.isEmpty() ? null : normalized;
}
}
实现 actionForInstalledScanner(Context):查询 com.sunmi.scanner 的版本;不存在或版本解析异常时回退 LEGACY_ACTION。实现包级可见的 actionForVersion(String) 与逐段数字版本比较,确保 4.4.4 及以上使用新版 Action。
在 Manifest 现有 <queries> 中增加:
<package android:name="com.sunmi.scanner" />
<intent>
<action android:name="com.summi.scan" />
</intent>
<intent>
<action android:name="com.sunmi.scanner.qrscanner" />
</intent>
Run:
./gradlew :app:testDebugUnitTest --tests '*SunmiScannerClientTest' :app:compileDebugJavaWithJavac --offline
Expected: 测试通过且 Debug Java 编译成功。
Files:
Create: app/src/main/java/com/cpt/sanquanconsumption/PaymentRequestFactory.java
Create: app/src/test/java/com/cpt/sanquanconsumption/PaymentRequestFactoryTest.java
Step 1: 写入请求工厂失败测试
@Test
public void buildsQrPaymentRequestWithCustomDishAndTopics() {
ApiModels.MqttSettings mqtt = new ApiModels.MqttSettings();
mqtt.topic = "zhct_dev";
mqtt.groupId = "GID_consume";
mqtt.listenerDeviceId = "consume_work_0";
ApiModels.PaymentRequest request = PaymentRequestFactory.create(
"281234567890", "7.51", "DEVICE-01",
"account", "pwd", "key", "business", mqtt, 1_790_588_400_000L);
assertEquals("281234567890", request.payCode);
assertEquals("7.51", request.price);
assertEquals("DEVICE-01", request.deviceCode);
assertEquals("1", request.orderType);
assertEquals("consume", request.action);
assertEquals("DEVICE-011790588400000", request.messageId);
assertEquals("zhct_dev/business/p2p/GID_consume@@@consume_work_0",
request.publishTopic);
assertEquals("zhct_dev/business/p2p/GID_consume@@@DEVICE-01",
request.subscribeTopic);
assertEquals(1, request.dishes.size());
assertEquals("custom_dish", request.dishes.get(0).uuid);
assertEquals("1", request.dishes.get(0).number);
assertEquals("7.51", request.dishes.get(0).price);
}
Run:
./gradlew :app:testDebugUnitTest --tests '*PaymentRequestFactoryTest' --offline
Expected: 编译失败,提示 PaymentRequestFactory 不存在。
创建带职责注释的 PaymentRequestFactory,集中维护协议常量:
private static final String CUSTOM_DISH_ID = "custom_dish";
private static final String SINGLE_ITEM_COUNT = "1";
private static final String ONSITE_ORDER_TYPE = "1";
private static final String CONSUME_ACTION = "consume";
private static final String DATE_TIME_PATTERN = "yyyy-MM-dd HH:mm:ss";
public static ApiModels.PaymentRequest create(String payCode, String amount,
DeviceConfig config, long timestamp) {
return create(payCode, amount, config.deviceCode(), config.authAccount(),
config.authPwd(), config.authKey(), config.businessId(), config.mqtt(), timestamp);
}
static ApiModels.PaymentRequest create(String payCode, String amount, String deviceCode,
String account, String password, String authKey, String businessId,
ApiModels.MqttSettings mqtt, long timestamp) {
ApiModels.DishItem dish = new ApiModels.DishItem();
dish.uuid = CUSTOM_DISH_ID;
dish.number = SINGLE_ITEM_COUNT;
dish.price = amount;
ApiModels.PaymentRequest request = new ApiModels.PaymentRequest();
request.payCode = payCode;
request.userId = "";
request.price = amount;
request.deviceCode = deviceCode;
request.cardCode = "";
request.authAccount = account;
request.authPwd = password;
request.authKey = authKey;
request.dishes = Collections.singletonList(dish);
request.orderType = ONSITE_ORDER_TYPE;
request.action = CONSUME_ACTION;
request.createTime = new SimpleDateFormat(DATE_TIME_PATTERN, Locale.CHINA)
.format(new Date(timestamp));
request.messageId = deviceCode + timestamp;
request.publishTopic = buildTopic(mqtt, businessId,
mqtt == null ? null : mqtt.listenerDeviceId);
request.subscribeTopic = buildTopic(mqtt, businessId, deviceCode);
return request;
}
buildTopic 仅在 topic、businessId、groupId 和目标设备编号都非空时返回 topic/businessId/p2p/groupId@@@target,否则返回空字符串。协议字面量保留为有注释的命名常量,不散落到页面层。
Run:
./gradlew :app:testDebugUnitTest --tests '*PaymentRequestFactoryTest' --offline
Expected: PaymentRequestFactoryTest 通过。
Files:
Modify: app/src/main/java/com/cpt/sanquanconsumption/MainActivity.java
Modify: app/src/main/res/values/strings.xml
Delete: app/src/main/res/layout/dialog_scan_payment.xml
Step 1: 增加页面状态字段并绑定按钮
在 MainActivity 增加:
private Call<ApiModels.Result<ApiModels.Payment>> paymentCall;
private View scanPaymentButton;
private TextView amountValue;
private TextView balanceValue;
private boolean scannerActive;
在 bindViews() 绑定 key_scan、amount_value、balance_value。将“+”改为:
findViewById(R.id.key_plus).setOnClickListener(v -> {
amountInput.addCurrent();
renderAmount();
});
将扫码按钮改为 startQrPayment(),删除旧的 showPaymentBoundary() 和 showScanDialog()。
新增:
private void startQrPayment() {
if (paymentCall != null || scannerActive) {
showMessage(R.string.main_payment_in_progress);
return;
}
if (!amountInput.hasPayableAmount()) {
showMessage(R.string.main_payment_amount_required);
return;
}
if (!config.isComplete()) {
showMessage(R.string.main_payment_config_required);
return;
}
scannerActive = SunmiScannerClient.launch(this);
if (!scannerActive) showMessage(R.string.main_scanner_unavailable);
}
@Override
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
super.onActivityResult(requestCode, resultCode, data);
if (requestCode != SunmiScannerClient.REQUEST_CODE) return;
scannerActive = false;
if (resultCode != RESULT_OK || data == null) return;
String payCode = SunmiScannerClient.parseResult(data);
if (TextUtils.isEmpty(payCode)) {
showMessage(R.string.main_scan_invalid);
return;
}
submitPayment(payCode);
}
showMessage(int) 统一使用短 Toast,避免错误分支重复代码。
新增 submitPayment:
private void submitPayment(String payCode) {
if (paymentCall != null) return;
ApiModels.PaymentRequest request = PaymentRequestFactory.create(
payCode, amountInput.paymentAmount(), config, System.currentTimeMillis());
setPaymentBusy(true);
paymentCall = new ApiClient(this, config.apiUrl()).pay(config, request);
paymentCall.enqueue(new Callback<ApiModels.Result<ApiModels.Payment>>() {
@Override
public void onResponse(Call<ApiModels.Result<ApiModels.Payment>> call,
Response<ApiModels.Result<ApiModels.Payment>> response) {
if (isFinishing() || isDestroyed()) return;
paymentCall = null;
setPaymentBusy(false);
ApiModels.Result<ApiModels.Payment> result = response.body();
if (!response.isSuccessful() || result == null || !result.ok()) {
showPaymentFailure(result == null ? null : result.message);
return;
}
showPaymentSuccess(result.data);
}
@Override
public void onFailure(Call<ApiModels.Result<ApiModels.Payment>> call, Throwable error) {
paymentCall = null;
if (!call.isCanceled() && !isFinishing()) {
setPaymentBusy(false);
showPaymentFailure(null);
}
}
});
}
setPaymentBusy(true) 禁用扫码按钮,将状态文案改为“支付处理中…”;false 恢复按钮。showPaymentFailure 优先显示非空服务端消息,否则显示通用失败文案,并保留金额表达式。
showPaymentSuccess 更新左侧金额和余额,Toast 展示付款人、实付金额和余额,然后执行:
amountInput.clear();
renderAmount();
showSettlement(false);
loadOrders();
所有成功展示字段使用空值兜底;禁止记录或展示 payCode。
在 strings.xml 增加:
<string name="main_payment_amount_required">请先输入消费金额</string>
<string name="main_payment_config_required">请先完成设备设置</string>
<string name="main_payment_in_progress">支付处理中,请勿重复操作</string>
<string name="main_scanner_unavailable">商米扫码组件不可用</string>
<string name="main_scan_invalid">未识别到有效付款二维码</string>
<string name="main_payment_processing">支付处理中…</string>
<string name="main_payment_failed">支付失败,请重新扫码</string>
<string name="main_payment_success">支付成功\n付款人:%1$s\n实付:%2$s元\n余额:%3$s元</string>
<string name="main_payment_unknown_user">未知</string>
<string name="main_payment_unknown_value">--</string>
确认 dialog_scan_payment.xml 已无引用后删除该文件;同时删除只供该弹框使用且已无引用的字符串和尺寸资源,不删除主页二维码图标资源。
在 onDestroy() 增加:
if (paymentCall != null) {
paymentCall.cancel();
paymentCall = null;
}
不在 onPause() 取消支付,因为启动商米扫码页面必然触发主页暂停。
Run:
./gradlew :app:testDebugUnitTest :app:assembleDebug --offline
Expected: 所有单元测试通过并生成 app/build/outputs/apk/debug/app-debug.apk。
Files:
Modify: zhctprompt/work_android/SanquanConsumption/change_records/CHANGELOG.md
Modify: zhctprompt/work_android/SanquanConsumption/change_records/CHANGELOG.html
Step 1: 运行完整验证
Run:
./gradlew :app:testDebugUnitTest :app:assembleDebug :app:lintDebug --offline
Expected: BUILD SUCCESSFUL,测试、APK 构建和 lint 全部通过。
Run:
rg -n "payCode|paycode|付款码" app/src/main/java/com/cpt/sanquanconsumption
rg -n '"(custom_dish|consume|com\\.summi\\.scan|com\\.sunmi\\.scanner)' app/src/main/java/com/cpt/sanquanconsumption
Expected: 付款码只在扫码解析、请求工厂和支付调用之间传递,没有日志语句;协议和平台字面量集中在职责类的命名常量中,并有边界注释。
Run:
adb -s TM1819C540040 install -r app/build/outputs/apk/debug/app-debug.apk
Expected: Success。此命令只覆盖安装当前 Debug 包,不清除设备配置和应用数据。
在 T1mini 上执行:
1、+、3.6、+、2、+、0.91。1+3.6+2+0.91,合计为 7.51元。Expected: 页面行为与录屏一致;不需要真实付款码即可完成这组非扣款冒烟验证。
使用服务端提供的测试环境和测试付款码扫码一次,观察应用专属网络日志只出现 /api/consume/orderPay 的路径、状态和耗时,不出现付款码或请求体。
Expected: 只发起一次支付请求;成功则清空金额并刷新订单,失败则保留金额并显示服务端消息。没有测试付款码时明确标记此项“未执行”,不得用生产个人付款码代替。
执行结果:未执行;当前没有受控测试付款码,未使用生产个人付款码代替。
在两份 CHANGELOG 顶部增加同一条 2026-09-28:实现结算金额累加与商米扫码支付,内容包括:
BigDecimal 表达式累加与删除/清空规则。
T1mini 商米旧版/新版扫码 Action 兼容。
/api/consume/orderPay 单次提交、敏感付款码保护、成功刷新与失败保留金额。
实际执行过的 Gradle 命令、T1mini 安装与冒烟结果。
真实支付是否完成;未完成时写明缺失的联调条件。
Step 7: 最终检查修改范围
Run:
find app/src/main -type f -newermt '2026-09-28 16:30:00' -print
find app/src/test -type f -newermt '2026-09-28 16:30:00' -print
Expected: 只出现本计划列出的业务源文件、测试、Manifest 和资源;不存在无关页面、构建配置或本地设备配置改动。
执行结果:本次功能改动符合计划范围;检查同时发现工作区存在并行新增的 PebbleDividerView.java、arrays.xml 以及并行修改的 activity_main.xml、colors.xml,均未覆盖或回退,并在最终构建中一并验证通过。