扫码支付超时时间设置 Implementation Plan

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

Goal: 在支付设置中提供默认 30 秒、范围 5~120 秒的扫码支付超时时间,并让该值只约束扫码支付整笔 HTTP 请求。

Architecture: 使用纯 Java 策略统一解析和校验超时值;扫码支付通过独立缓存的 Retrofit/OkHttp 客户端获得专用连接、读取和写入超时,再由 RxJava timeout 提供整笔请求总时限。普通 Retrofit 客户端、刷卡、刷脸、MQTT 倒计时和扫码枪收码延迟保持不变。

Tech Stack: Android Java 8、SharedPreferences、Retrofit 2.1.0、OkHttp 3.9.0、RxJava 2、JUnit 4、Android XML。


实施约束

文件结构

新增业务文件

修改业务文件

实施完成后新增记录

Task 1: 建立超时配置契约和纯逻辑策略

Files:

创建测试文件:

package com.cpt.aidishrecognition.payment;

import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;

import com.cpt.aidishrecognition.utils.Constants;

import org.junit.Test;

public class ScanPaymentTimeoutPolicyTest {

    @Test
    public void parseSeconds_acceptsOnlyIntegerText() {
        assertEquals(Integer.valueOf(30), ScanPaymentTimeoutPolicy.parseSeconds("30"));
        assertEquals(Integer.valueOf(30), ScanPaymentTimeoutPolicy.parseSeconds(" 30 "));
        assertNull(ScanPaymentTimeoutPolicy.parseSeconds(""));
        assertNull(ScanPaymentTimeoutPolicy.parseSeconds("30.5"));
        assertNull(ScanPaymentTimeoutPolicy.parseSeconds("abc"));
    }

    @Test
    public void isValid_acceptsClosedConfiguredRange() {
        assertTrue(ScanPaymentTimeoutPolicy.isValid(Constants.SCAN_PAYMENT_TIMEOUT_SECONDS_MIN));
        assertTrue(ScanPaymentTimeoutPolicy.isValid(Constants.SCAN_PAYMENT_TIMEOUT_SECONDS_MAX));
        assertFalse(ScanPaymentTimeoutPolicy.isValid(Constants.SCAN_PAYMENT_TIMEOUT_SECONDS_MIN - 1));
        assertFalse(ScanPaymentTimeoutPolicy.isValid(Constants.SCAN_PAYMENT_TIMEOUT_SECONDS_MAX + 1));
    }

    @Test
    public void normalize_fallsBackToThirtySecondsForInvalidValues() {
        assertEquals(30, ScanPaymentTimeoutPolicy.normalize(30));
        assertEquals(Constants.SCAN_PAYMENT_TIMEOUT_SECONDS_DEFAULT,
                ScanPaymentTimeoutPolicy.normalize(0));
        assertEquals(Constants.SCAN_PAYMENT_TIMEOUT_SECONDS_DEFAULT,
                ScanPaymentTimeoutPolicy.normalize(121));
    }
}

Run:

./gradlew :app:testDebugUnitTest --tests '*ScanPaymentTimeoutPolicyTest'

Expected: FAIL,提示 ScanPaymentTimeoutPolicy 或新增常量不存在。

放在现有支付方式配置附近:

public static final int PAYMENT_METHOD_CARD = 1;
public static final int PAYMENT_METHOD_SCAN_QR = 2;
public static final int PAYMENT_METHOD_FACE = 3;

public static final String SCAN_PAYMENT_TIMEOUT_SECONDS =
        "scan_payment_timeout_seconds";
public static final int SCAN_PAYMENT_TIMEOUT_SECONDS_DEFAULT = 30;
public static final int SCAN_PAYMENT_TIMEOUT_SECONDS_MIN = 5;
public static final int SCAN_PAYMENT_TIMEOUT_SECONDS_MAX = 120;
package com.cpt.aidishrecognition.payment;

import com.cpt.aidishrecognition.utils.Constants;

/**
 * 统一扫码支付超时值的解析和边界校验。
 * 本类不读取偏好设置,也不操作界面或网络,调用方负责持久化和应用结果。
 */
public final class ScanPaymentTimeoutPolicy {

    private ScanPaymentTimeoutPolicy() {
    }

    public static Integer parseSeconds(String value) {
        if (value == null || value.trim().isEmpty()) {
            return null;
        }
        try {
            return Integer.valueOf(value.trim());
        } catch (NumberFormatException ignored) {
            return null;
        }
    }

    public static boolean isValid(int timeoutSeconds) {
        return timeoutSeconds >= Constants.SCAN_PAYMENT_TIMEOUT_SECONDS_MIN
                && timeoutSeconds <= Constants.SCAN_PAYMENT_TIMEOUT_SECONDS_MAX;
    }

    public static int normalize(int timeoutSeconds) {
        return isValid(timeoutSeconds)
                ? timeoutSeconds
                : Constants.SCAN_PAYMENT_TIMEOUT_SECONDS_DEFAULT;
    }
}

Run:

./gradlew :app:testDebugUnitTest --tests '*ScanPaymentTimeoutPolicyTest'

Expected: PASS,3 个测试全部通过。

Run:

git diff --check
git diff -- app/src/main/java/com/cpt/aidishrecognition/utils/Constants.java \
  app/src/main/java/com/cpt/aidishrecognition/payment/ScanPaymentTimeoutPolicy.java \
  app/src/test/java/com/cpt/aidishrecognition/payment/ScanPaymentTimeoutPolicyTest.java

Expected: git diff --check 无输出;差异只包含配置契约、策略和测试,不提交 Git。

Task 2: 增加设置页输入与持久化

Files:

在 InputValueDialogFragment 中引入 android.text.InputType,增加参数和工厂方法:

private static final String ARG_INPUT_TYPE = "input_type";

public static InputValueDialogFragment newNumberInstance(
        String title, String hint, String value) {
    InputValueDialogFragment fragment = newInstance(title, hint, value);
    Bundle args = fragment.getArguments();
    if (args != null) {
        args.putInt(ARG_INPUT_TYPE, InputType.TYPE_CLASS_NUMBER);
    }
    return fragment;
}

在 onViewCreated() 读取参数后、设置文本前配置输入类型:

inputView.setInputType(args.getInt(ARG_INPUT_TYPE, InputType.TYPE_CLASS_TEXT));
inputView.setHint(args.getString(ARG_HINT, ""));
inputView.setText(args.getString(ARG_VALUE, ""));

增加字段、设置方法和接口:

private OnValueValidateListener onValueValidateListener;

public void setOnValueValidateListener(OnValueValidateListener listener) {
    this.onValueValidateListener = listener;
}

public interface OnValueValidateListener {
    @Nullable
    String validate(@NonNull String value);
}

将确认按钮分支改为先校验、后关闭:

if (id == R.id.input_dialog_confirm_view) {
    String value = inputView.getText() == null
            ? "" : inputView.getText().toString().trim();
    if (TextUtils.isEmpty(value)) {
        com.cpt.aidishrecognition.utils.ToastUtils.toast(
                requireContext(), R.string.setting_input_required, Toast.LENGTH_SHORT);
        return;
    }
    if (onValueValidateListener != null) {
        String validationMessage = onValueValidateListener.validate(value);
        if (!TextUtils.isEmpty(validationMessage)) {
            com.cpt.aidishrecognition.utils.ToastUtils.toast(
                    requireContext(), validationMessage, Toast.LENGTH_SHORT);
            return;
        }
    }
    dismiss();
    if (onValueConfirmListener != null) {
        onValueConfirmListener.onValueConfirm(value);
    }
}

Expected: 现有字符串输入调用保持 TYPE_CLASS_TEXT,只有新工厂方法使用数字键盘;校验不通过时弹窗不关闭。

在支付设置文案附近加入:

<string name="setting_scan_payment_timeout_title">扫码支付超时时间</string>
<string name="setting_scan_payment_timeout_value">%1$d 秒</string>
<string name="setting_scan_payment_timeout_input_hint">请输入 %1$d~%2$d 秒</string>
<string name="setting_scan_payment_timeout_invalid">请输入 %1$d~%2$d 之间的整数</string>

在扫码、刷脸支付复选框之后、订单管理入口之前加入:

<LinearLayout
    android:id="@+id/setting_scan_payment_timeout_entry"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:layout_marginTop="16dp"
    android:clickable="true"
    android:focusable="true"
    android:gravity="center_vertical"
    android:orientation="horizontal"
    android:paddingTop="12dp"
    android:paddingBottom="12dp">

    <TextView
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_weight="1"
        android:text="@string/setting_scan_payment_timeout_title"
        android:textColor="@color/setting_title"
        android:textSize="20sp" />

    <TextView
        android:id="@+id/setting_scan_payment_timeout_value"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:textColor="@color/setting_hint"
        android:textSize="20sp" />
</LinearLayout>

增加导入:

import android.widget.TextView;

import com.cpt.aidishrecognition.payment.ScanPaymentTimeoutPolicy;
import com.cpt.aidishrecognition.view.InputValueDialogFragment;

在 onViewCreated() 中调用:

bindScanPaymentTimeout(view);

增加以下方法:

private void bindScanPaymentTimeout(@NonNull View rootView) {
    TextView timeoutValueView = rootView.findViewById(
            R.id.setting_scan_payment_timeout_value);
    View timeoutEntry = rootView.findViewById(
            R.id.setting_scan_payment_timeout_entry);
    refreshScanPaymentTimeout(timeoutValueView);
    timeoutEntry.setOnClickListener(view ->
            showScanPaymentTimeoutInput(timeoutValueView));
}

private void refreshScanPaymentTimeout(@NonNull TextView timeoutValueView) {
    int savedSeconds = PreferenceUtils.getInt(
            Constants.SCAN_PAYMENT_TIMEOUT_SECONDS,
            Constants.SCAN_PAYMENT_TIMEOUT_SECONDS_DEFAULT);
    int normalizedSeconds = ScanPaymentTimeoutPolicy.normalize(savedSeconds);
    timeoutValueView.setText(getString(
            R.string.setting_scan_payment_timeout_value, normalizedSeconds));
}

private void showScanPaymentTimeoutInput(@NonNull TextView timeoutValueView) {
    int currentSeconds = ScanPaymentTimeoutPolicy.normalize(PreferenceUtils.getInt(
            Constants.SCAN_PAYMENT_TIMEOUT_SECONDS,
            Constants.SCAN_PAYMENT_TIMEOUT_SECONDS_DEFAULT));
    InputValueDialogFragment fragment = InputValueDialogFragment.newNumberInstance(
            getString(R.string.setting_scan_payment_timeout_title),
            getString(
                    R.string.setting_scan_payment_timeout_input_hint,
                    Constants.SCAN_PAYMENT_TIMEOUT_SECONDS_MIN,
                    Constants.SCAN_PAYMENT_TIMEOUT_SECONDS_MAX),
            String.valueOf(currentSeconds));
    fragment.setOnValueValidateListener(value -> {
        Integer seconds = ScanPaymentTimeoutPolicy.parseSeconds(value);
        return seconds != null && ScanPaymentTimeoutPolicy.isValid(seconds)
                ? null
                : getString(
                        R.string.setting_scan_payment_timeout_invalid,
                        Constants.SCAN_PAYMENT_TIMEOUT_SECONDS_MIN,
                        Constants.SCAN_PAYMENT_TIMEOUT_SECONDS_MAX);
    });
    fragment.setOnValueConfirmListener(value -> {
        Integer seconds = ScanPaymentTimeoutPolicy.parseSeconds(value);
        if (seconds == null || !ScanPaymentTimeoutPolicy.isValid(seconds)) {
            return;
        }
        PreferenceUtils.putInt(Constants.SCAN_PAYMENT_TIMEOUT_SECONDS, seconds);
        refreshScanPaymentTimeout(timeoutValueView);
    });
    fragment.show(getChildFragmentManager(), "scan_payment_timeout_input");
}

Run:

./gradlew :app:compileDebugJavaWithJavac

Expected: BUILD SUCCESSFUL;现有 InputValueDialogFragment.newInstance() 调用全部继续编译。

Run:

git diff --check
git diff -- app/src/main/java/com/cpt/aidishrecognition/view/InputValueDialogFragment.java \
  app/src/main/java/com/cpt/aidishrecognition/fragment/SettingsPaymentFragment.java \
  app/src/main/res/layout/fragment_setting_payment.xml \
  app/src/main/res/values/strings.xml

Expected: 无格式错误;设置文案均来自资源,超时边界均来自统一常量,不提交 Git。

Task 3: 创建扫码支付专用 HTTP 客户端

Files:

package com.cpt.aidishrecognition.network;

import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertSame;

import com.cpt.aidishrecognition.utils.Constants;

import org.junit.Test;

import okhttp3.OkHttpClient;

public class ScanPaymentHttpClientFactoryTest {

    @Test
    public void create_appliesConfiguredTimeoutWithoutReplacingSharedPool() {
        OkHttpClient baseClient = new OkHttpClient.Builder().build();

        OkHttpClient scanClient = ScanPaymentHttpClientFactory.create(baseClient, 30);

        assertEquals(30_000, scanClient.connectTimeoutMillis());
        assertEquals(30_000, scanClient.readTimeoutMillis());
        assertEquals(30_000, scanClient.writeTimeoutMillis());
        assertSame(baseClient.connectionPool(), scanClient.connectionPool());
        assertSame(baseClient.dispatcher(), scanClient.dispatcher());
    }

    @Test
    public void create_normalizesInvalidTimeoutToDefault() {
        OkHttpClient scanClient = ScanPaymentHttpClientFactory.create(
                new OkHttpClient.Builder().build(), 0);

        int expectedMillis = Constants.SCAN_PAYMENT_TIMEOUT_SECONDS_DEFAULT * 1_000;
        assertEquals(expectedMillis, scanClient.connectTimeoutMillis());
        assertEquals(expectedMillis, scanClient.readTimeoutMillis());
        assertEquals(expectedMillis, scanClient.writeTimeoutMillis());
    }
}

Run:

./gradlew :app:testDebugUnitTest --tests '*ScanPaymentHttpClientFactoryTest'

Expected: FAIL,提示 ScanPaymentHttpClientFactory 不存在。

package com.cpt.aidishrecognition.network;

import com.cpt.aidishrecognition.payment.ScanPaymentTimeoutPolicy;

import java.util.concurrent.TimeUnit;

import okhttp3.OkHttpClient;

/**
 * 从应用公共客户端派生扫码支付客户端,只覆盖连接、读取和写入超时。
 * 连接池、调度器、拦截器、证书与缓存均继承公共客户端,整笔请求上限由 RxJava 层负责。
 */
public final class ScanPaymentHttpClientFactory {

    private ScanPaymentHttpClientFactory() {
    }

    public static OkHttpClient create(OkHttpClient baseClient, int timeoutSeconds) {
        if (baseClient == null) {
            throw new IllegalArgumentException("baseClient cannot be null");
        }
        int normalizedSeconds = ScanPaymentTimeoutPolicy.normalize(timeoutSeconds);
        return baseClient.newBuilder()
                .connectTimeout(normalizedSeconds, TimeUnit.SECONDS)
                .readTimeout(normalizedSeconds, TimeUnit.SECONDS)
                .writeTimeout(normalizedSeconds, TimeUnit.SECONDS)
                .build();
    }
}

增加字段:

private static Retrofit scanPaymentRetrofit;
private static String scanPaymentBaseUrl;
private static int scanPaymentTimeoutSeconds = -1;

增加方法:

public static synchronized <T> T createScanPaymentApi(
        Class<T> service, int timeoutSeconds) {
    String baseUrl = PreferenceUtils.getString(
            Constants.CUSTOMER_BASE_URL,
            Constants.DEFAULT_CUSTOMER_BASE_URL);
    int normalizedSeconds = ScanPaymentTimeoutPolicy.normalize(timeoutSeconds);
    if (scanPaymentRetrofit == null
            || !baseUrl.equals(scanPaymentBaseUrl)
            || normalizedSeconds != scanPaymentTimeoutSeconds) {
        OkHttpClient paymentClient = ScanPaymentHttpClientFactory.create(
                CptApplication.getOkHttpClient(), normalizedSeconds);
        scanPaymentRetrofit = new Retrofit.Builder()
                .baseUrl(baseUrl)
                .client(paymentClient)
                .addCallAdapterFactory(RxJava2CallAdapterFactory.create())
                .addConverterFactory(MyGsonConverterFactory.create())
                .build();
        scanPaymentBaseUrl = baseUrl;
        scanPaymentTimeoutSeconds = normalizedSeconds;
    }
    return scanPaymentRetrofit.create(service);
}

引入:

import com.cpt.aidishrecognition.payment.ScanPaymentTimeoutPolicy;

import okhttp3.OkHttpClient;

在 updateBaseUrl() 中同步清空扫码支付缓存:

scanPaymentRetrofit = null;
scanPaymentBaseUrl = null;
scanPaymentTimeoutSeconds = -1;

Run:

./gradlew :app:testDebugUnitTest --tests '*ScanPaymentHttpClientFactoryTest'

Expected: PASS,2 个测试全部通过。

Run:

./gradlew :app:compileDebugJavaWithJavac

Expected: BUILD SUCCESSFUL;不得调用 OkHttp 3.9.0 不存在的 callTimeout。

Task 4: 实现 RxJava 整笔请求超时和错误映射

Files:

package com.cpt.aidishrecognition.network;

import org.junit.Test;

import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;

import io.reactivex.Observable;
import io.reactivex.observers.TestObserver;
import io.reactivex.schedulers.TestScheduler;

public class ScanPaymentRequestTimeoutTest {

    @Test
    public void apply_timesOutAtConfiguredWholeRequestLimit() {
        TestScheduler scheduler = new TestScheduler();
        TestObserver<Object> observer = ScanPaymentRequestTimeout.apply(
                Observable.never(), 30, scheduler).test();

        scheduler.advanceTimeBy(29, TimeUnit.SECONDS);
        observer.assertNoErrors();
        observer.assertNotComplete();

        scheduler.advanceTimeBy(1, TimeUnit.SECONDS);
        observer.assertError(TimeoutException.class);
    }
}

Run:

./gradlew :app:testDebugUnitTest --tests '*ScanPaymentRequestTimeoutTest'

Expected: FAIL,提示 ScanPaymentRequestTimeout 不存在。

package com.cpt.aidishrecognition.network;

import com.cpt.aidishrecognition.payment.ScanPaymentTimeoutPolicy;

import java.util.concurrent.TimeUnit;

import io.reactivex.Observable;
import io.reactivex.Scheduler;
import io.reactivex.schedulers.Schedulers;

/**
 * 为扫码支付响应链提供从订阅到终止的整体时间上限。
 * 超时会处置 Retrofit RxJava 订阅,从而取消对应的底层 HTTP Call。
 */
public final class ScanPaymentRequestTimeout {

    private ScanPaymentRequestTimeout() {
    }

    public static <T> Observable<T> apply(Observable<T> source, int timeoutSeconds) {
        return apply(source, timeoutSeconds, Schedulers.computation());
    }

    static <T> Observable<T> apply(
            Observable<T> source, int timeoutSeconds, Scheduler scheduler) {
        if (source == null) {
            throw new IllegalArgumentException("source cannot be null");
        }
        if (scheduler == null) {
            throw new IllegalArgumentException("scheduler cannot be null");
        }
        int normalizedSeconds = ScanPaymentTimeoutPolicy.normalize(timeoutSeconds);
        return source.timeout(normalizedSeconds, TimeUnit.SECONDS, scheduler);
    }
}
package com.cpt.aidishrecognition.network.exception;

import static org.junit.Assert.assertEquals;

import org.junit.Test;

import java.util.concurrent.TimeoutException;

public class ExceptionHandleTest {

    @Test
    public void handleException_mapsRxTimeoutToRequestTimeout() {
        OkHttpException exception = ExceptionHandle.handleException(
                new TimeoutException("scan payment timeout"));

        assertEquals(-1, exception.getEcode());
        assertEquals("请求超时", exception.getEmsg());
    }
}

Run:

./gradlew :app:testDebugUnitTest --tests '*ExceptionHandleTest'

Expected: FAIL,当前 TimeoutException 被映射成“未知错误”。

增加导入:

import java.util.concurrent.TimeoutException;

将超时分支改为:

} else if (e instanceof java.net.SocketTimeoutException
        || e instanceof TimeoutException) {
    ex = new OkHttpException(TIMEOUT_ERROR, TIMEOUTMSG);
    return ex;

增加方法:

public static void orderScanPayment(
        Context context,
        IResponseCallBack<BaseBean<PaySuccessResponse>> callBack,
        MqttOrderPaymentRequest request,
        int timeoutSeconds) {
    String authAccount = PreferenceUtils.getString(Constants.API_AUTH_ACCOUNT, "");
    String authPwd = PreferenceUtils.getString(Constants.API_AUTH_PWD, "");
    String authKey = PreferenceUtils.getString(Constants.API_AUTH_KEY, "");
    Observable<BaseBean<PaySuccessResponse>> observable =
            RetrofitClient.createScanPaymentApi(APIService.class, timeoutSeconds)
                    .orderPayment(authAccount, authPwd, authKey, request);
    RetrofitClient.request(
            context,
            ScanPaymentRequestTimeout.apply(observable, timeoutSeconds),
            callBack,
            "orderScanPayment");
}

保留现有 orderPayment(...) 不变,供非扫码支付和订单补传继续调用。

Run:

./gradlew :app:testDebugUnitTest \
  --tests '*ScanPaymentRequestTimeoutTest' \
  --tests '*ExceptionHandleTest'

Expected: PASS,两个测试类全部通过。

Task 5: 将扫码支付路由到专用超时请求

Files:

在 DishRecognitionActivity 和 PaymentTestActivity 中把本地数字定义改为统一常量引用:

private static final int METHOD_CARD = Constants.PAYMENT_METHOD_CARD;
private static final int METHOD_QR = Constants.PAYMENT_METHOD_SCAN_QR;
private static final int METHOD_FACE = Constants.PAYMENT_METHOD_FACE;

Expected: 页面行为不变,支付方式编号不再散落硬编码。

增加导入:

import com.cpt.aidishrecognition.payment.ScanPaymentTimeoutPolicy;

增加方法:

private int getScanPaymentTimeoutSeconds() {
    int savedSeconds = PreferenceUtils.getInt(
            Constants.SCAN_PAYMENT_TIMEOUT_SECONDS,
            Constants.SCAN_PAYMENT_TIMEOUT_SECONDS_DEFAULT);
    return ScanPaymentTimeoutPolicy.normalize(savedSeconds);
}

把 executeHttpPayment() 中的匿名回调原样移动到以下方法,回调内部的加载关闭、成功保存、余额不足提示、声音和 pendingPayments.remove() 行为保持不变:

private IResponseCallBack<BaseBean<PaySuccessResponse>> createHttpPaymentCallback(
        PaymentRequestData paymentRequest) {
    return new IResponseCallBack<BaseBean<PaySuccessResponse>>() {
        @Override
        public void onSuccess(BaseBean<PaySuccessResponse> data) {
            paymentRequest.callback.onPaymentLoading(paymentRequest.requestId, false);
            if (data != null && data.getErrorCode() == 0 && data.getData() != null) {
                PaySuccessResponse response = data.getData();
                playSound(1);
                saveOrder(paymentRequest, response);
                paymentRequest.callback.onPaymentSuccess(
                        paymentRequest.requestId,
                        paymentRequest.recognitionTime,
                        response.staffName,
                        response.totalPrice,
                        response.payPrice,
                        response.remainBalance);
            } else {
                handlePaymentError(
                        paymentRequest,
                        data == null ? "支付失败" : safe(data.getErrorMsg(), "支付失败"));
            }
            pendingPayments.remove(paymentRequest.requestId);
        }

        @Override
        public void onFail(OkHttpException failure) {
            paymentRequest.callback.onPaymentLoading(paymentRequest.requestId, false);
            String errorMsg = failure == null
                    ? "未知错误!" : safe(failure.getEmsg(), "未知错误!");
            if (failure != null && failure.getEcode() == 8001) {
                errorMsg = "余额不足,请充值后再尝试!";
                playSound(3);
            } else {
                playSound(2);
            }
            paymentRequest.callback.onPaymentFailed(paymentRequest.requestId, errorMsg);
            pendingPayments.remove(paymentRequest.requestId);
        }

        @Override
        public void onLoading() {
        }
    };
}

用以下实现替换 executeHttpPayment():

private void executeHttpPayment(
        PaymentRequestData paymentRequest, MqttOrderPaymentRequest request) {
    paymentRequest.callback.onPaymentLoading(paymentRequest.requestId, true);
    IResponseCallBack<BaseBean<PaySuccessResponse>> callback =
            createHttpPaymentCallback(paymentRequest);
    if (paymentRequest.paymentType == Constants.PAYMENT_METHOD_SCAN_QR) {
        int timeoutSeconds = getScanPaymentTimeoutSeconds();
        LogUtils.d("scan payment requestId=" + paymentRequest.requestId
                + ", timeoutSeconds=" + timeoutSeconds);
        RetrofitRequest.orderScanPayment(
                context, callback, request, timeoutSeconds);
        return;
    }
    RetrofitRequest.orderPayment(context, callback, request);
}

日志只能包含请求 ID、支付方式和超时值,不记录完整付款码。

保持现有条件不变,将:

// 刷卡支付不能走mqtt
if (paymentType == 2) {

改为:

// 当前扫码支付仍由 HTTP 接口处理,其他支付方式保持既有 MQTT 路由。
if (paymentType == Constants.PAYMENT_METHOD_SCAN_QR) {

Run:

./gradlew :app:testDebugUnitTest \
  --tests '*ScanPaymentTimeoutPolicyTest' \
  --tests '*ScanPaymentHttpClientFactoryTest' \
  --tests '*ScanPaymentRequestTimeoutTest' \
  --tests '*ExceptionHandleTest'
./gradlew :app:compileDebugJavaWithJavac

Expected: 全部测试 PASS,编译 BUILD SUCCESSFUL。

Run:

rg -n 'paymentType\s*==\s*2|METHOD_QR\s*=\s*2|callTimeout|SCAN_PAYMENT_TIMEOUT' app/src/main/java app/src/test/java

Expected:

Task 6: 完整验证与变更记录

Files:

Run:

./gradlew :app:testDebugUnitTest

Expected: BUILD SUCCESSFUL,现有测试和新增测试全部通过。

Run:

./gradlew :app:assembleDebug

Expected: BUILD SUCCESSFUL,生成 app/build/outputs/apk/debug/app-debug.apk。

Run:

git diff --check
git status --short

Expected: git diff --check 无输出;git status 只包含本计划列出的业务源码、资源和测试文件,不包含新建业务仓库文档目录。

按顺序验证:

  1. 未保存过该配置时显示“30 秒”。
  2. 点击设置行出现数字键盘和当前值。
  3. 空值、4、121 无法保存,弹窗保持打开并提示“请输入 5~120 之间的整数”。
  4. 5、30、120 可以保存;退出并重新进入设置页后仍显示保存值。
  5. 关闭再启用“扫码支付”后,超时值不被清除。

使用可延迟响应的测试接口或代理执行:

  1. 保存 5 秒,扫码支付接口延迟超过 5 秒,确认约 5 秒进入“请求超时”失败结果。
  2. 保存 30 秒,接口在 5~30 秒内响应,确认不会沿用全局 5 秒提前失败。
  3. 接口延迟超过 30 秒,确认约 30 秒结束等待且只回调一次失败。
  4. 超时后关闭结果弹窗,确认菜品识别和扫码监听恢复。
  5. 验证刷卡、刷脸和普通接口仍走原有客户端。

Run:

rg -n 'payCode=.*|付款码|scan payment' app/src/main/java/com/cpt/aidishrecognition

Expected: 本次新增日志不输出完整付款码;连续扫码支付复用相同 baseUrl + timeoutSeconds 对应的 Retrofit 实例。

写入以下已确定内容。执行 Step 1~3 后,在“验证结果”中逐项写入命令、退出码和终端最终状态;成功示例为 ./gradlew :app:testDebugUnitTest — exit 0 — BUILD SUCCESSFUL,失败时原样记录失败任务和首个错误原因,不预填成功:

# 扫码支付超时时间设置变更记录

## 变更目标

在支付设置中增加扫码支付超时时间,默认 30 秒、范围 5~120 秒,并只约束扫码支付整笔 HTTP 请求。

## 实现内容

- 增加超时配置、默认值和边界策略。
- 支付设置页支持数字输入、校验、持久化和当前值展示。
- 扫码支付使用独立 Retrofit/OkHttp 客户端,连接、读取和写入超时采用设置值。
- RxJava `timeout` 控制整笔请求上限,超时统一显示“请求超时”。
- 普通网络请求、刷卡、刷脸、MQTT 倒计时和扫码枪收码延迟保持不变。

## 验证结果

- 扫码支付超时策略单元测试:写入对应 Gradle 命令、退出码和最终状态。
- 专用 HTTP 客户端单元测试:写入对应 Gradle 命令、退出码和最终状态。
- RxJava 整体超时和异常映射测试:写入对应 Gradle 命令、退出码和最终状态。
- `:app:testDebugUnitTest`:写入命令、退出码和最终状态。
- `:app:assembleDebug`:写入命令、退出码和最终状态。
- `git diff --check`:写入命令、退出码和是否存在格式错误。

## Git 操作

未执行暂存、提交、推送、分支切换或历史改写。

使用提示词工程现有 Markdown 转 HTML 方式生成:

/Users/liang/AndroidStudioProjects/zhct/zhctprompt/work_android/AIDishRecognition/change_records/2026-09-10-scan-payment-timeout-settings.html

验证 Markdown 的每个标题、列表和真实测试结果均出现在 HTML 中。不得向业务仓库复制该记录。

Run:

find /Users/liang/AndroidStudioProjects/AIDishRecognition -maxdepth 2 \
  -type d \( -name docs -o -name doc -o -name prompt \) -print
git -C /Users/liang/AndroidStudioProjects/zhct/zhctprompt status --short \
  -- work_android/AIDishRecognition

Expected: 业务仓库没有本任务新建的文档目录;提示词工程存在设计稿、实施计划和实施完成后的 Markdown/HTML 变更记录配对。仍不执行 Git 提交。