结算累加与商米扫码支付 Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use subagent-driven-development or executing-plans to 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 元数据,且用户未授权提交;所有提交步骤均省略,只编辑、验证并报告。


文件结构

Task 1: 用测试定义金额累加行为

Files:

在现有测试类中增加以下用例,并保留原有输入精度测试:

@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() 尚未定义。

将控制器内部值改为完整表达式,新增常量和方法:

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 全部通过。

Task 2: 封装并测试商米扫码契约

Files:

创建测试类,覆盖旧版 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 编译成功。

Task 3: 用测试锁定支付请求契约

Files:

@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 通过。

Task 4: 把累加、扫码和支付接入结算页面

Files:

在 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。

Task 5: 静态检查、实机冒烟与变更记录

Files:

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. 点击左侧二维码进入结算页。
  2. 依次输入 1、+、3.6、+、2、+、0.91。
  3. 确认表达式为 1+3.6+2+0.91,合计为 7.51元。
  4. 点击扫码支付,确认拉起商米扫码界面。
  5. 取消扫码,确认返回后表达式和合计保持不变。

Expected: 页面行为与录屏一致;不需要真实付款码即可完成这组非扣款冒烟验证。

使用服务端提供的测试环境和测试付款码扫码一次,观察应用专属网络日志只出现 /api/consume/orderPay 的路径、状态和耗时,不出现付款码或请求体。

Expected: 只发起一次支付请求;成功则清空金额并刷新订单,失败则保留金额并显示服务端消息。没有测试付款码时明确标记此项“未执行”,不得用生产个人付款码代替。

执行结果:未执行;当前没有受控测试付款码,未使用生产个人付款码代替。

在两份 CHANGELOG 顶部增加同一条 2026-09-28:实现结算金额累加与商米扫码支付,内容包括:

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,均未覆盖或回退,并在最终构建中一并验证通过。