yeg
2026-09-17 dd22571b369970b35ca6c48f32010e2876b43d25
feat(verification): 核销码轮换放宽与手输 6 位数字码核销

商户扫码刚好赶上 60 秒轮换就报「核销凭证已失效」,而页面上那个 6 位数字码在系统里
没有任何地方能输入——这两件事一起把商户卡在现场(判定见 spec 的 D21–D23)。

- 轮换放宽:issueCode 不再一生成新码就立刻作废旧码,有效期改为「刷新间隔 60 秒 + 重叠 30 秒」,
扫到翻转瞬间不再失败;重叠期之外仍按 expires_at 自动失效。
- 新增按 6 位数字码核销:复用 POST /verification/scan(纯 6 位数字走数字码分支),确认核销新增
POST /verification/numeric/confirm —— 服务端从扫码尝试反查订单,因为数字码的扫码答复刻意
不返回订单号(6 位只有 100 万空间,返回归属等于给枚举一张映射表;撞码与猜错用同一句话回答)。
- 限次与锁定:新表 merchant_numeric_code_attempt(V150)按「商户 + 门店 + 操作员」记连续输错,
5 次锁 5 分钟,锁定期间连校验都不做;计数写在 REQUIRES_NEW 独立事务里,否则判定失败抛出时
会连计数一起回滚,限次就是一句空话。
- 商户端新增 pages/verification-code-input/ 页面与扫码页「手输 6 位核销码」入口(cover-view 入口 +
独立页输入,因为 cover-view 里放不下 input)。

回归:MerchantNumericCodeVerificationTests(手输核销成功、不泄露归属、撞码与猜错同一答复、
连续输错锁定且锁定期间不校验、输对清零、过期失效、轮换后新旧码都可用)。
4个文件已添加
10个文件已修改
617 ■■■■■ 已修改文件
rencaika_merchant/config/api.uts 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika_merchant/pages.json 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika_merchant/pages/scan/scan.uvue 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika_merchant/pages/verification-code-input/verification-code-input.uvue 90 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika_merchant/pages/verification-confirm/verification-confirm.uvue 22 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika_merchant/services/verification.uts 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantNumericCodeAttemptGuard.java 109 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantVerificationService.java 60 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantWorkController.java 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantWorkService.java 28 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/resources/db/migration/V150__merchant_numeric_code_attempt.sql 22 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/test/java/com/talentcard/server/business/merchant/MerchantNumericCodeVerificationTests.java 222 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/test/java/com/talentcard/server/business/merchant/MerchantVerificationFreeStayTests.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/test/java/com/talentcard/server/business/merchant/MerchantVerificationOrderCodeTests.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika_merchant/config/api.uts
@@ -52,6 +52,7 @@
    if (name == 'merchantOrderVerify') return '/api/merchant/orders/'
    if (name == 'merchantSettlementStatistics') return '/api/merchant/settlement-statistics'
    if (name == 'verificationScan') return '/api/merchant/verification/scan'
    if (name == 'verificationNumericConfirm') return '/api/merchant/verification/numeric/confirm'
    if (name == 'verificationConfirm') return '/api/merchant/verification/free-stay/confirm'
    if (name == 'verificationFreeStayConfirm') return '/api/merchant/verification/free-stay/confirm'
    if (name == 'manualConsumption') return '/api/merchant/manual-consumptions'
rencaika_merchant/pages.json
@@ -149,6 +149,14 @@
            }
        },
        {
            "path": "pages/verification-code-input/verification-code-input",
            "style": {
                "navigationStyle": "custom",
                "backgroundColor": "#F7F7F7",
                "disableScroll": true
            }
        },
        {
            "path": "pages/verification-success/verification-success",
            "style": {
                "navigationStyle": "custom",
rencaika_merchant/pages/scan/scan.uvue
@@ -11,6 +11,8 @@
            <cover-view class="frame-corner corner-tl"></cover-view><cover-view class="frame-corner corner-tr"></cover-view><cover-view class="frame-corner corner-bl"></cover-view><cover-view class="frame-corner corner-br"></cover-view>
        </cover-view>
        <cover-view v-if="scanState == 'scanning'" class="scan-tip">将取景框对准二维码,即可自动扫描</cover-view>
        <!-- 相机不好用或屏幕反光时的兜底:顾客屏幕上的 6 位数字码可以手输。 -->
        <cover-view v-if="scanState == 'scanning'" class="manual-entry" @tap.stop="openManualEntry">手输 6 位核销码</cover-view>
        <cover-view v-if="scanState != 'scanning'" class="status-panel">
            <cover-view v-if="scanState == 'verifying'" class="loading-spinner"></cover-view>
@@ -39,6 +41,7 @@
    let openedManualConsumption = false
    let openedConfirmation = false
    let openedVerificationFailure = false
    let openedManualEntry = false
    let cancelVerificationRequest: (() => void) | null = null
    const cancelPendingRequest = () => {
@@ -190,16 +193,27 @@
        })
    }
    // 手输 6 位数字码:扫码机上放不下普通 view(会被原生相机压住),所以入口做成 cover-view,
    // 输入本身放到一个独立页面里(cover-view 里不能放 input)。
    const openManualEntry = () => {
        openedManualEntry = true
        uni.navigateTo({
            url: '/pages/verification-code-input/verification-code-input',
            fail: () => { openedManualEntry = false }
        })
    }
    onUnload(() => {
        cancelPendingRequest()
        lastScanResult = ''
    })
    onShow(() => {
        if (!openedManualConsumption && !openedConfirmation && !openedVerificationFailure) return
        if (!openedManualConsumption && !openedConfirmation && !openedVerificationFailure && !openedManualEntry) return
        openedManualConsumption = false
        openedConfirmation = false
        openedVerificationFailure = false
        openedManualEntry = false
        resetScanning()
    })
</script>
@@ -225,6 +239,7 @@
    .corner-br { right: 0; bottom: 0; border-right-width: 7rpx; border-bottom-width: 7rpx; }
    .scan-line { width: 100%; height: 3rpx; position: absolute; top: 0; left: 0; background-color: rgba(133, 255, 192, 0.9); box-shadow: 0 0 15rpx rgba(111, 241, 170, 0.8); animation: scan-line-move 1800ms ease-in-out infinite alternate; }
    .scan-tip { width: 100%; position: absolute; top: 975rpx; left: 0; z-index: 10; color: #FFFFFF; font-size: 27rpx; line-height: 38rpx; text-align: center; }
    .manual-entry { display: flex; width: 320rpx; height: 76rpx; position: absolute; top: 1060rpx; left: 215rpx; z-index: 10; justify-content: center; align-items: center; border-width: 2rpx; border-style: solid; border-color: rgba(255, 255, 255, 0.65); border-radius: 38rpx; color: #FFFFFF; font-size: 27rpx; line-height: 38rpx; }
    .status-panel { display: none; }
    .loading-spinner { width: 58rpx; height: 58rpx; border-width: 6rpx; border-style: solid; border-color: #FFD5BF; border-top-color: #FF6B2C; border-radius: 50%; animation: loading-rotate 800ms linear infinite; }
    .status-mark { display: flex; width: 64rpx; height: 64rpx; justify-content: center; align-items: center; border-radius: 32rpx; color: #FFFFFF; font-size: 38rpx; line-height: 64rpx; font-weight: 700; text-align: center; }
rencaika_merchant/pages/verification-code-input/verification-code-input.uvue
New file
@@ -0,0 +1,90 @@
<template>
    <view class="page">
        <view class="navigation-bar">
            <view class="back-button" @tap.stop="goBack"><image class="back-icon" src="/static/icons/lucide/back-left.svg" mode="aspectFit"></image></view>
            <text class="navigation-title">手输核销码</text>
        </view>
        <view class="content">
            <text class="hint">请输入顾客屏幕上的 6 位数字核销码</text>
            <view class="input-shell" :class="errorText.length > 0 ? 'field-invalid' : ''">
                <input class="code-input" type="number" maxlength="6" :value="code" placeholder="6 位数字"
                    placeholder-class="code-placeholder" @input="onCodeInput" @confirm="submit" />
            </view>
            <text v-if="errorText.length > 0" class="field-error">{{errorText}}</text>
            <text class="tip">连续输错会短暂锁定,锁定期间请改用扫码核销。</text>
        </view>
        <view class="bottom-bar">
            <view class="submit-button" :class="submitting || !codeReady() ? 'submit-disabled' : ''" hover-class="submit-active" @tap="submit">
                <text>{{submitting ? '核验中...' : '核验并核销'}}</text>
            </view>
        </view>
    </view>
</template>
<script setup lang="uts">
    import { scanVerificationCode, VerificationScanResult } from '../../services/verification.uts'
    const code = ref('')
    const errorText = ref('')
    const submitting = ref(false)
    const goBack = () => uni.navigateBack()
    const codeReady = () : boolean => code.value.length == 6
    // 只留数字:数字键盘上仍然可能粘进空格或其它字符。
    const onCodeInput = (event: any) : void => {
        const raw = event != null && event.detail != null && event.detail.value != null ? '' + event.detail.value : ''
        code.value = raw.replace(/[^0-9]/g, '').substring(0, 6)
        if (errorText.value.length > 0) errorText.value = ''
    }
    const submit = () : void => {
        if (submitting.value || !codeReady()) return
        submitting.value = true
        errorText.value = ''
        scanVerificationCode(code.value, (result: VerificationScanResult, _traceId: string) => {
            submitting.value = false
            if (!result.canVerify) {
                errorText.value = result.message.length > 0 ? result.message : '核销码无效,请核对后重新输入'
                return
            }
            // 确认核销放在承接页上(与扫码同一条动线):那里会再把金额、权益与顾客信息摆一次让商户确认。
            // 数字码这条路服务端不返回订单号,所以带 numeric=1 让确认页走按扫码尝试确认的接口。
            uni.navigateTo({
                url: '/pages/verification-confirm/verification-confirm?numeric=1'
                    + '&verificationId=' + encodeURIComponent(result.verificationId)
                    + '&credentialType=' + encodeURIComponent(result.credentialType)
                    + '&customerName=' + encodeURIComponent(result.customerName)
                    + '&customerMobile=' + encodeURIComponent(result.customerMobile)
                    + '&cardLevel=' + encodeURIComponent(result.cardLevel)
            })
        }, (error: any) => {
            submitting.value = false
            errorText.value = error != null && error.message != null ? '' + error.message : '核验失败,请重试'
        })
    }
    onLoad(() => {})
</script>
<style>
    page { background-color: #F7F7F7; }
    .page { display: flex; width: 100%; height: 100vh; flex-direction: column; background-color: #F7F7F7; }
    .navigation-bar { display: flex; height: 174rpx; flex: none; position: relative; align-items: center; justify-content: center; }
    .back-button { display: flex; width: 72rpx; height: 72rpx; justify-content: center; align-items: center; position: absolute; left: 28rpx; bottom: 22rpx; }
    .back-icon { width: 40rpx; height: 40rpx; }
    .navigation-title { margin-top: 60rpx; color: #181A1E; font-size: 36rpx; font-weight: 700; }
    .content { display: flex; padding: 24rpx 44rpx 0; flex-direction: column; }
    .hint { color: #4A5261; font-size: 28rpx; line-height: 42rpx; }
    .input-shell { display: flex; height: 104rpx; margin-top: 26rpx; padding: 0 30rpx; border-width: 1rpx; border-style: solid; border-color: #E0E5EC; border-radius: 30rpx; background-color: #FFFEFD; box-sizing: border-box; align-items: center; }
    .field-invalid { border-color: #FF633A; }
    .code-input { width: 100%; height: 100%; color: #1F2530; font-size: 40rpx; font-weight: 700; letter-spacing: 12rpx; }
    .code-placeholder { color: #B4BAC4; font-size: 30rpx; font-weight: 400; letter-spacing: 0; }
    .field-error { margin-top: 16rpx; color: #FF633A; font-size: 25rpx; line-height: 36rpx; }
    .tip { margin-top: 18rpx; color: #97A0AE; font-size: 23rpx; line-height: 34rpx; }
    .bottom-bar { display: flex; padding: 24rpx 44rpx; padding-bottom: calc(24rpx + env(safe-area-inset-bottom)); margin-top: auto; }
    .submit-button { display: flex; flex: 1; height: 92rpx; justify-content: center; align-items: center; border-radius: 46rpx; background-image: linear-gradient(90deg, #FF7625 0%, #FF962F 100%); }
    .submit-button text { color: #FFFFFF; font-size: 29rpx; font-weight: 700; }
    .submit-disabled { opacity: .55; }
    .submit-active { opacity: .82; }
</style>
rencaika_merchant/pages/verification-confirm/verification-confirm.uvue
@@ -14,7 +14,7 @@
            </view>
            <text class="section-title">订单信息</text>
            <view class="info-card">
                <view class="info-row"><text class="info-label">订单号</text><text class="info-value">{{orderNo}}</text></view>
                <view v-if="orderNo.length > 0" class="info-row"><text class="info-label">订单号</text><text class="info-value">{{orderNo}}</text></view>
                <view class="info-row"><text class="info-label">商品金额</text><text class="info-value">¥{{formatAmount(orderAmount)}}</text></view>
                <view class="info-row"><text class="info-label">人才折扣</text><text class="info-value">-¥{{formatAmount(talentDiscountAmount)}}</text></view>
                <view class="info-row"><text class="info-label">商品优惠</text><text class="info-value">-¥{{formatAmount(couponAmount)}}</text></view>
@@ -33,12 +33,15 @@
<script setup lang="uts">
    import { confirmMerchantOrderVerification, loadMerchantOrderDetail, MerchantOrderDetail } from '../../services/orders.uts'
    import { confirmNumericVerificationCode } from '../../services/verification.uts'
    const verificationId = ref('')
    const customerName = ref('持卡人')
    const customerMobile = ref('')
    const cardLevel = ref('人才卡')
    const orderNo = ref('')
    // 手输 6 位数字码时服务端不返回订单号(避免给枚举提供映射表),确认核销也走另一条接口。
    const numericEntry = ref(false)
    const title = ref('订单')
    const benefitName = ref('人才专属权益')
    const orderAmount = ref(0)
@@ -95,10 +98,24 @@
            success: (modal: any) => {
                if (!modal.confirm) return
                submitting.value = true
                const key = 'merchant-verify-' + verificationId.value + '-' + Date.now()
                if (numericEntry.value) {
                    // 手输 6 位数字码:扫码时服务端刻意没给订单号,所以只能走按扫码尝试确认的那条接口。
                    // 核销成功后订单号出现在回执里——这时客户就在眼前,把单号给商户是为了留痕,不是枚举。
                    confirmNumericVerificationCode(verificationId.value, key, (result: any, _traceId: string) => {
                        submitting.value = false
                        const confirmedOrderNo = result != null && result.orderNo != null ? '' + result.orderNo : orderNo.value
                        uni.redirectTo({ url: '/pages/verification-success/verification-success?orderNo=' + encodeURIComponent(confirmedOrderNo) })
                    }, (error: any) => {
                        submitting.value = false
                        uni.showToast({ title: error.message != null ? error.message : '核销失败,请重试', icon: 'none' })
                    })
                    return
                }
                // 订单核销统一走订单核销接口:核销要写核销记录并确认延迟入账的资金。
                // 带上扫码尝试编号,核销成功后把这次扫码标记为已完成。
                confirmMerchantOrderVerification(orderNo.value, verificationId.value, 'ORDER_CODE',
                    'merchant-verify-' + verificationId.value + '-' + Date.now(), (result: any, _traceId: string) => {
                    key, (result: any, _traceId: string) => {
                    submitting.value = false
                    uni.redirectTo({ url: '/pages/verification-success/verification-success?orderNo=' + encodeURIComponent(orderNo.value) })
                }, (error: any) => {
@@ -115,6 +132,7 @@
        customerMobile.value = textOption(options, 'customerMobile', '')
        cardLevel.value = textOption(options, 'cardLevel', '人才卡')
        orderNo.value = textOption(options, 'orderNo', '')
        numericEntry.value = textOption(options, 'numeric', '') == '1'
        subsidyAmount.value = Number(textOption(options, 'subsidyAmount', '0'))
        loadOrder()
    })
rencaika_merchant/services/verification.uts
@@ -102,3 +102,21 @@
    idempotencyKey: idempotencyKey,
    data: { verificationId: verificationId }
}, onSuccess, onError)
/**
 * 手输 6 位数字核销码之后的确认核销。
 *
 * 这条路不能复用订单核销接口:数字码的扫码答复刻意不返回订单号(6 位只有 100 万空间,返回归属
 * 等于给枚举一张映射表),所以客户端拿不到单号;服务端从这次扫码尝试反查订单再核销。
 */
export const confirmNumericVerificationCode = (
    verificationId: string,
    idempotencyKey: string,
    onSuccess: (result: any, traceId: string) => void,
    onError: (error: HttpError) => void
): (() => void) => request<any>({
    path: getApiEndpoint('verificationNumericConfirm'),
    method: 'POST',
    idempotencyKey: idempotencyKey,
    data: { verificationId: verificationId }
}, onSuccess, onError)
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantNumericCodeAttemptGuard.java
New file
@@ -0,0 +1,109 @@
package com.talentcard.server.business.merchant;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import com.talentcard.server.business.DatabaseRows;
import com.talentcard.server.business.merchant.auth.MerchantPrincipal;
import com.talentcard.server.common.BusinessException;
import com.talentcard.server.common.ErrorCode;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
/**
 * 手输 6 位数字核销码的失败计数与锁定。
 *
 * <p>数字码只有 100 万空间,不限次就是一个可以暴力枚举的核销入口;也不能让攻击者用「这次报错是无效码
 * 还是已锁定」把枚举继续下去——所以锁定期间**连校验都不做**。
 *
 * <p>计数按「谁在输」记(商户 + 门店 + 操作员),而不是按商户:一个操作员连输几次不该把整个门店锁死。
 *
 * <p>计数与锁定写在独立事务里提交({@link Propagation#REQUIRES_NEW}):调用方(扫码)在判定失败时会抛
 * 业务异常回滚,如果计数跟着一起回滚,连续输错永远累积不到上限,限次就是一句空话。
 */
@Service
public class MerchantNumericCodeAttemptGuard {
    /** 连续输错到几次就锁定。 */
    static final int MAX_CONSECUTIVE_FAILURES = 5;
    /** 锁定多久。太短等于没锁,太长会把偶然手误的收银员一起挡住。 */
    static final int LOCK_MINUTES = 5;
    private final JdbcTemplate jdbc;
    public MerchantNumericCodeAttemptGuard(JdbcTemplate jdbc) {
        this.jdbc = jdbc;
    }
    /** 锁定期间拒绝一切手输核销:不再校验码,免得变成一个「猜对了没有」的探针。 */
    public void requireUsable(MerchantPrincipal principal) {
        LocalDateTime lockedUntil = lockedUntil(principal);
        if (lockedUntil == null || !lockedUntil.isAfter(LocalDateTime.now())) return;
        long minutes = Math.max(1, Duration.between(LocalDateTime.now(), lockedUntil).toMinutes() + 1);
        throw new BusinessException(ErrorCode.CONFLICT,
                "手输核销码已连续输错,锁定 " + minutes + " 分钟;也可以改用扫码核销");
    }
    /** 记录一次输错;到上限就把这次尝试锁起来,并把计数归零(解锁后重新开始数)。 */
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void recordFailure(MerchantPrincipal principal) {
        int failures = consecutiveFailures(principal) + 1;
        LocalDateTime now = LocalDateTime.now();
        if (failures >= MAX_CONSECUTIVE_FAILURES) {
            jdbc.update("""
                    update merchant_numeric_code_attempt
                    set consecutive_failures = 0, locked_until = ?, last_attempt_at = ?
                    where merchant_id = ? and store_id = ? and operator_id = ?
                    """, now.plusMinutes(LOCK_MINUTES), now, principal.merchantId(), principal.storeId(), principal.operatorId());
            return;
        }
        jdbc.update("""
                update merchant_numeric_code_attempt
                set consecutive_failures = ?, last_attempt_at = ?
                where merchant_id = ? and store_id = ? and operator_id = ?
                """, failures, now, principal.merchantId(), principal.storeId(), principal.operatorId());
    }
    /** 输对一次就把连续输错的计数清掉,也顺手解锁(例如锁定期间换了正确的方式再来)。 */
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void recordSuccess(MerchantPrincipal principal) {
        jdbc.update("""
                update merchant_numeric_code_attempt
                set consecutive_failures = 0, locked_until = null, last_attempt_at = ?
                where merchant_id = ? and store_id = ? and operator_id = ?
                """, LocalDateTime.now(), principal.merchantId(), principal.storeId(), principal.operatorId());
    }
    private LocalDateTime lockedUntil(MerchantPrincipal principal) {
        List<Map<String, Object>> rows = rows(principal);
        if (rows.isEmpty()) return null;
        Object value = rows.get(0).get("lockedUntil");
        return value instanceof LocalDateTime time ? time : null;
    }
    private int consecutiveFailures(MerchantPrincipal principal) {
        List<Map<String, Object>> rows = rows(principal);
        if (rows.isEmpty()) {
            jdbc.update("""
                    insert into merchant_numeric_code_attempt(merchant_id, store_id, operator_id)
                    values (?, ?, ?)
                    """, principal.merchantId(), principal.storeId(), principal.operatorId());
            return 0;
        }
        Object value = rows.get(0).get("consecutiveFailures");
        return value instanceof Number number ? number.intValue() : 0;
    }
    private List<Map<String, Object>> rows(MerchantPrincipal principal) {
        return DatabaseRows.normalize(jdbc.queryForList("""
                select consecutive_failures, locked_until
                from merchant_numeric_code_attempt
                where merchant_id = ? and store_id = ? and operator_id = ?
                limit 1
                """, principal.merchantId(), principal.storeId(), principal.operatorId()));
    }
}
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantVerificationService.java
@@ -25,23 +25,40 @@
    private static final String ORDER_CODE = "ORDER_CODE";
    private static final String FREE_STAY_CODE = "FREE_STAY_CODE";
    /**
     * 6 位数字核销码:顾客屏幕上的数字码,相机不好用或屏幕反光时手输兜底。
     *
     * <p>它就是订单核销码的另一副面孔(同一张 {@code order_dynamic_verification_code} 的
     * {@code display_code}),因此有效期与动态码完全相同;但它只有 100 万空间,必须限次,
     * 见 {@link MerchantNumericCodeAttemptGuard}。
     */
    private static final java.util.regex.Pattern NUMERIC_CODE = java.util.regex.Pattern.compile("\\d{6}");
    private final JdbcTemplate jdbc;
    private final ManualConsumptionService manualConsumptionService;
    private final OrderVerificationService orderVerificationService;
    private final MerchantNumericCodeAttemptGuard numericCodeGuard;
    public MerchantVerificationService(JdbcTemplate jdbc, ManualConsumptionService manualConsumptionService,
                                       OrderVerificationService orderVerificationService) {
                                       OrderVerificationService orderVerificationService,
                                       MerchantNumericCodeAttemptGuard numericCodeGuard) {
        this.jdbc = jdbc;
        this.manualConsumptionService = manualConsumptionService;
        this.orderVerificationService = orderVerificationService;
        this.numericCodeGuard = numericCodeGuard;
    }
    /**
     * A QR token is opaque to the client. Its source table is the authority for its type and scope.
     *
     * <p>纯 6 位数字的输入走手输核销码那条路:码令牌是 32 位十六进制,不可能是 6 位数字。
     */
    @Transactional
    public Map<String, Object> scan(MerchantPrincipal principal, VerificationScanRequest request) {
        String token = request.scanResult().trim();
        if (NUMERIC_CODE.matcher(token).matches()) {
            return scanNumericCode(principal, token);
        }
        Map<String, Object> talentCode = findTalentCode(token);
        if (talentCode != null) {
            return createTalentAttempt(principal, talentCode);
@@ -58,6 +75,47 @@
    }
    /**
     * 手输 6 位数字核销码。
     *
     * <p>{@code display_code} 只有 100 万空间,同一时刻完全可能有两张活着的码撞在一起,所以按
     * {@code limit 2} 取行:拿不到**唯一**一张就拒绝。而「猜错(0 条)」与「撞码(2 条)」必须用同一句话
     * 回答——否则攻击者只要比较这两种答复,就知道「这个号确实有主」,那正是枚举要的探针。
     *
     * <p>答复里**不带订单号**:客户就在眼前,商户不需要靠接口知道这个码属于哪笔订单;带回去却等于给
     * 枚举提供了一张「号码 → 订单」的映射表。确认核销时由服务端从扫码尝试里把订单找回来。
     */
    private Map<String, Object> scanNumericCode(MerchantPrincipal principal, String code) {
        numericCodeGuard.requireUsable(principal);
        List<Map<String, Object>> codes = rows("""
                select c.id order_verification_code_id, c.expires_at, o.order_no, o.order_status, o.verify_status,
                       o.merchant_id, o.store_id, coalesce(o.product_name_snapshot, '订单权益') product_name,
                       t.name talent_name, t.mobile talent_mobile, tc.card_level
                from order_dynamic_verification_code c
                join benefit_order o on o.id = c.order_id and o.deleted = 0
                left join talent_user t on t.id = o.talent_id and t.deleted = 0
                left join talent_card tc on tc.id = o.card_id and tc.deleted = 0
                where c.display_code = ? and c.status = 0 and c.expires_at > now()
                  and o.merchant_id = ? and (o.store_id is null or o.store_id = ?)
                  and o.order_status = 1 and o.verify_status = 0
                limit 2
                """, code, principal.merchantId(), principal.storeId());
        if (codes.size() != 1) {
            numericCodeGuard.recordFailure(principal);
            throw new BusinessException(ErrorCode.CONFLICT, "核销码无效或已过期,请核对后重新输入");
        }
        numericCodeGuard.recordSuccess(principal);
        Map<String, Object> found = codes.get(0);
        String verificationId = createAttempt(principal, ORDER_CODE, null,
                number(found.get("orderVerificationCodeId")), null, found.get("expiresAt"));
        Map<String, Object> result = scanResult(verificationId, ORDER_CODE, "核销码有效,请确认后完成核销",
                found.get("talentName"), found.get("talentMobile"), found.get("cardLevel"), null, found.get("expiresAt"), 0L);
        // 连字段都不留:空字符串容易被客户端当成「没查到」,直接不给这个键更清楚。
        result.remove("orderNo");
        result.put("numericEntry", true);
        return result;
    }
    /**
     * 免费入住券核销:与支付无关的独立业务——用户不付钱,核销只扣券次数,补贴由平台线下结算。
     *
     * <p>订单核销不走这里:它必须写核销记录并确认延迟入账的资金,统一在
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantWorkController.java
@@ -160,6 +160,21 @@
    }
    /**
     * 手输 6 位数字核销码之后的确认核销({@code POST /verification/scan} 的数字码分支)。
     *
     * <p>这条路不能复用 {@code POST /orders/{orderNo}/verify}:数字码的扫码答复刻意不返回订单号,
     * 客户端拿不到;服务端从扫码尝试反查订单后再走同一套核销实现。
     */
    @PostMapping("/verification/numeric/confirm")
    public ApiResponse<Map<String, Object>> confirmNumericVerification(
            @RequestAttribute(MerchantAuthService.PRINCIPAL_ATTRIBUTE) MerchantPrincipal principal,
            @RequestBody Map<String, String> body,
            @RequestHeader(value = "X-Idempotency-Key", required = false) String idempotencyKey
    ) {
        return ApiResponse.success(merchantWorkService.confirmNumericVerification(principal, body.get("verificationId"), idempotencyKey));
    }
    /**
     * 免费入住券核销。涉及支付的订单核销走 {@code POST /orders/{orderNo}/verify}:
     * 核销要写记录并确认延迟入账的资金,与不涉及支付的免费入住券是两条不同的线。
     */
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantWorkService.java
@@ -215,6 +215,34 @@
        return requested == null || requested.isBlank() ? "merchant" : requested.trim();
    }
    /**
     * 手输 6 位数字核销码之后的确认核销。
     *
     * <p>为什么不复用 {@code /api/merchant/orders/{orderNo}/verify}:手输那条路的扫码答复刻意不返回
     * 「这个码属于哪笔订单」(6 位只有 100 万空间,带回去等于给枚举一张映射表),所以客户端拿不到单号。
     * 这里由服务端从扫码尝试反查订单,再走同一套核销实现——幂等、行锁、资金确认都不重复写第二份。
     */
    @Transactional
    public Map<String, Object> confirmNumericVerification(MerchantPrincipal principal, String verificationId, String idempotencyKey) {
        if (idempotencyKey == null || idempotencyKey.isBlank()) {
            throw new BusinessException(ErrorCode.BAD_REQUEST, "缺少幂等键");
        }
        String attemptId = verificationId == null ? "" : verificationId.trim();
        if (attemptId.isBlank()) throw new BusinessException(ErrorCode.BAD_REQUEST, "缺少核销凭证编号");
        List<Map<String, Object>> orders = query("""
                select o.order_no
                from merchant_verification_attempt a
                join order_dynamic_verification_code c on c.id = a.order_verification_code_id
                join benefit_order o on o.id = c.order_id and o.deleted = 0
                where a.verification_id = ? and a.credential_type = 'ORDER_CODE'
                  and a.merchant_id = ? and a.store_id = ? and a.operator_id = ?
                limit 1
                """, attemptId, principal.merchantId(), principal.storeId(), principal.operatorId());
        if (orders.isEmpty()) throw new BusinessException(ErrorCode.NOT_FOUND, "核销凭证不存在或无权操作");
        return verify(String.valueOf(orders.get(0).get("orderNo")).trim(), principal,
                new VerifyRequest("ORDER_CODE", "", attemptId), idempotencyKey);
    }
    /** 扫码尝试:核销成功后要标记完成,并且只有扫码码未过期才能核销。 */
    private record Attempt(Long id, java.time.LocalDateTime expiresAt) { }
talent-card-server/src/main/resources/db/migration/V150__merchant_numeric_code_attempt.sql
New file
@@ -0,0 +1,22 @@
-- 商户端手输 6 位数字核销码的失败计数与锁定。
--
-- 核销码本来就下发一个 6 位数字码给顾客(order_dynamic_verification_code.display_code),但系统里
-- 没有任何地方能输入它:相机不好用或屏幕反光时,商户只能让顾客反复刷新二维码。补上手输入口的同时
-- 必须限次——数字码只有 100 万空间,不限次就是一个可以暴力枚举的核销入口。
--
-- 计数按「谁在输」记(商户 + 门店 + 操作员):连续输错到上限就锁定一段时间,锁定期间连校验都不做,
-- 否则攻击者仍然可以用「这次报错是无效码还是已锁定」把枚举继续下去。
--
-- 幂等:`create table if not exists` 在 MySQL 5.7 与 8.0 上都能直接重复执行,所以这份迁移也可以
-- 用 scripts/apply-sql-file.ps1 直接落到开发库(开发库 Flyway 关闭、结构不由迁移维护)。
create table if not exists merchant_numeric_code_attempt (
    id bigint not null auto_increment,
    merchant_id bigint not null,
    store_id bigint not null,
    operator_id bigint not null,
    consecutive_failures int not null default 0 comment '连续输错次数,成功或锁定时归零',
    locked_until datetime default null comment '锁定到期时间,未锁定为 NULL',
    last_attempt_at datetime not null default current_timestamp,
    primary key (id),
    unique key uk_merchant_numeric_code_attempt (merchant_id, store_id, operator_id)
) engine=InnoDB default charset=utf8mb4 comment='商户端手输数字核销码的失败计数与锁定';
talent-card-server/src/test/java/com/talentcard/server/business/merchant/MerchantNumericCodeVerificationTests.java
New file
@@ -0,0 +1,222 @@
package com.talentcard.server.business.merchant;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.sql.Timestamp;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import com.talentcard.server.business.merchant.auth.MerchantPrincipal;
import com.talentcard.server.business.portal.PendingUsePortalService;
import com.talentcard.server.common.BusinessException;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.context.ActiveProfiles;
/**
 * Seam: the 6-digit code shown to the customer can be typed in when the camera is unusable, and the
 * code rotation no longer invalidates the code the merchant has already scanned.
 *
 * <p>The rotation half is exercised through the talent side ({@link PendingUsePortalService#issueCode}):
 * a new code must not kill the previous one, so a scan that lands on the rotation instant still works.
 */
@SpringBootTest
@ActiveProfiles("test")
class MerchantNumericCodeVerificationTests {
    @Autowired
    private JdbcTemplate jdbc;
    @Autowired
    private MerchantVerificationService verificationService;
    @Autowired
    private MerchantWorkService merchantWorkService;
    @Autowired
    private PendingUsePortalService pendingUsePortalService;
    private Long talentId;
    private Long merchantId;
    private Long storeId;
    private Long cardId;
    private String huifuId;
    private String suffix;
    private long sequence;
    @BeforeEach
    void prepareData() {
        suffix = String.valueOf(System.nanoTime());
        jdbc.update("insert into talent_user(openid, name, mobile, status, deleted) values (?, ?, ?, 1, 0)",
                "numeric-code-openid-" + suffix, "Numeric code user", "13900000006");
        talentId = jdbc.queryForObject("select max(id) from talent_user", Long.class);
        jdbc.update("insert into merchant(merchant_name, contact_mobile, address, status, deleted) values (?, ?, ?, 1, 0)",
                "Numeric code merchant " + suffix, "08120000005", "Test address");
        merchantId = jdbc.queryForObject("select max(id) from merchant", Long.class);
        storeId = merchantId + 1;
        huifuId = "H" + suffix.substring(Math.max(0, suffix.length() - 8));
        jdbc.update("insert into talent_card(talent_id, card_no, card_level, card_type, status, deleted) values (?, ?, ?, 'PERSONAL', 1, 0)",
                talentId, "CARD-" + suffix, "卓越人才卡");
        cardId = jdbc.queryForObject("select max(id) from talent_card", Long.class);
        sequence = 0;
    }
    @Test
    void theSixDigitCodeCanBeTypedInAndConfirmedWithoutDisclosingWhichOrderItBelongsTo() {
        PaidOrder order = paidOrder();
        Map<String, Object> scanned = verificationService.scan(principal(), new VerificationScanRequest(order.displayCode()));
        assertThat(scanned)
                .as("手输的 6 位码就是订单核销码的另一副面孔")
                .containsEntry("credentialType", "ORDER_CODE")
                .containsEntry("canVerify", true)
                .containsEntry("numericEntry", true);
        assertThat(scanned)
                .as("不返回订单归属:6 位只有 100 万空间,带回去就是一张可以枚举的「号码 → 订单」映射表")
                .doesNotContainKey("orderNo");
        Map<String, Object> confirmed = merchantWorkService.confirmNumericVerification(
                principal(), (String) scanned.get("verificationId"), "numeric-confirm-" + suffix);
        assertThat(confirmed).as("确认核销走的是与扫码完全相同的那套订单核销实现").isNotEmpty();
        assertThat(jdbc.queryForObject("select verify_status from benefit_order where order_no = ?", Integer.class, order.orderNo()))
                .isEqualTo(1);
        assertThat(jdbc.queryForObject("select count(*) from verify_record where order_id = ?", Long.class, order.orderId()))
                .as("手输核销与扫码核销是同一套核销实现(核销记录只写一条)")
                .isEqualTo(1L);
    }
    @Test
    void aWrongGuessAndACollidingCodeAnswerExactlyTheSameWay() {
        PaidOrder first = paidOrder();
        PaidOrder second = paidOrder();
        String sharedCode = "424242";
        jdbc.update("update order_dynamic_verification_code set display_code = ? where order_id in (?, ?)",
                sharedCode, first.orderId(), second.orderId());
        String colliding = errorFrom(() -> verificationService.scan(principal(), new VerificationScanRequest(sharedCode)));
        String wrong = errorFrom(() -> verificationService.scan(principal(), new VerificationScanRequest("999999")));
        assertThat(colliding)
                .as("撞码与猜错必须用同一句话回答,否则答复本身就是「这个号确实有主」的探针")
                .isEqualTo(wrong);
    }
    @Test
    void consecutiveWrongCodesLockTheNumericEntryAndLockedMeansItIsNotEvenChecked() {
        PaidOrder order = paidOrder();
        for (int attempt = 0; attempt < MerchantNumericCodeAttemptGuard.MAX_CONSECUTIVE_FAILURES; attempt++) {
            assertThatThrownBy(() -> verificationService.scan(principal(), new VerificationScanRequest("000000")))
                    .isInstanceOf(BusinessException.class)
                    .hasMessageContaining("无效或已过期");
        }
        assertThat(errorFrom(() -> verificationService.scan(principal(), new VerificationScanRequest(order.displayCode()))))
                .as("锁定期间连校验都不做:正确的码也一样被挡在门外,枚举无从继续")
                .contains("锁定");
        assertThat(jdbc.queryForObject("""
                select count(*) from merchant_verification_attempt
                where merchant_id = ? and store_id = ? and operator_id = ?
                """, Long.class, merchantId, storeId, principal().operatorId()))
                .as("锁定期间不该产生任何扫码尝试")
                .isZero();
    }
    @Test
    void aWrongCodeThatIsFollowedByTheRightOneClearsTheFailureCount() {
        PaidOrder order = paidOrder();
        for (int attempt = 0; attempt < MerchantNumericCodeAttemptGuard.MAX_CONSECUTIVE_FAILURES - 1; attempt++) {
            assertThatThrownBy(() -> verificationService.scan(principal(), new VerificationScanRequest("000000")))
                    .isInstanceOf(BusinessException.class);
        }
        verificationService.scan(principal(), new VerificationScanRequest(order.displayCode()));
        assertThat(jdbc.queryForObject("""
                select consecutive_failures from merchant_numeric_code_attempt
                where merchant_id = ? and store_id = ? and operator_id = ?
                """, Integer.class, merchantId, storeId, principal().operatorId()))
                .as("输对一次就重新开始数,偶尔手误不该把收银员锁住")
                .isZero();
    }
    @Test
    void anExpiredNumericCodeIsRefusedLikeAnyOtherInvalidCode() {
        PaidOrder order = paidOrder();
        jdbc.update("update order_dynamic_verification_code set expires_at = ? where order_id = ?",
                Timestamp.valueOf(LocalDateTime.now().minusSeconds(1)), order.orderId());
        assertThat(errorFrom(() -> verificationService.scan(principal(), new VerificationScanRequest(order.displayCode()))))
                .as("数字码与动态码同有效期,过了就一样失效")
                .contains("无效或已过期");
    }
    /**
     * 轮换不再作废上一张码:商户扫到的那一瞬间恰好赶上翻转,两次都应该成功。
     *
     * <p>旧的 {@code issueCode} 一生成新码就把旧码置为已失效,于是「刚好翻转」必然失败。
     */
    @Test
    void rotatingTheCodeKeepsThePreviousOneUsableDuringTheOverlap() {
        PaidOrder order = paidOrder();
        String seededCodeToken = jdbc.queryForObject("select code_token from order_dynamic_verification_code where order_id = ?", String.class, order.orderId());
        Map<String, Object> first = pendingUsePortalService.issueCode(order.orderNo(), talentId);
        Map<String, Object> second = pendingUsePortalService.issueCode(order.orderNo(), talentId);
        assertThat(jdbc.queryForObject("select status from order_dynamic_verification_code where code_token = ?", Integer.class, first.get("token")))
                .as("轮换不再把上一张码立刻作废(旧实现一生成新码就把旧码置为已失效)")
                .isZero();
        // 新旧两张码都还能被识别:扫到时刚好翻转,不会再报「核销凭证已失效」。
        scanSucceeds(seededCodeToken);
        scanSucceeds((String) first.get("token"));
        scanSucceeds((String) second.get("token"));
    }
    /** 扫这张码会被识别(失败会抛业务异常)。 */
    private void scanSucceeds(String codeToken) {
        verificationService.scan(principal(), new VerificationScanRequest(codeToken));
    }
    private String errorFrom(org.assertj.core.api.ThrowableAssert.ThrowingCallable action) {
        Throwable thrown = org.assertj.core.api.Assertions.catchThrowable(action);
        assertThat(thrown).isInstanceOf(BusinessException.class);
        return thrown.getMessage();
    }
    private MerchantPrincipal principal() {
        return new MerchantPrincipal(merchantId + 1000, merchantId, storeId, "operator");
    }
    /** 一笔已支付、未核销、可核销的订单,附带一张有效期内的动态核销码(含 6 位数字码)。 */
    private PaidOrder paidOrder() {
        String orderNo = "NUMERIC-" + suffix + "-" + (++sequence);
        jdbc.update("""
                insert into benefit_order(order_no, talent_id, merchant_id, store_id, order_type, order_amount,
                                          base_pay_amount, pay_amount, order_status, verify_status, price_version,
                                          collect_huifu_id, product_name_snapshot)
                values (?, ?, ?, ?, 'GROUP_BUY', 10000, 10000, 10000, 1, 0, 1, ?, '手输核销码测试商品')
                """, orderNo, talentId, merchantId, storeId, huifuId);
        Long orderId = jdbc.queryForObject("select id from benefit_order where order_no = ?", Long.class, orderNo);
        String displayCode = String.format("%06d", 100000 + (System.nanoTime() % 900000));
        jdbc.update("""
                insert into order_dynamic_verification_code(code_token, display_code, order_id, talent_id, expires_at, status)
                values (?, ?, ?, ?, ?, 0)
                """, "numeric-token-" + orderNo, displayCode, orderId, talentId,
                Timestamp.valueOf(LocalDateTime.now().plusSeconds(60)));
        jdbc.update("""
                insert into payment_transaction(payment_no, provider, order_id, order_no, talent_id, merchant_id,
                                                provider_huifu_id, amount, status, idempotency_key, paid_at)
                values (?, 'HUIFU', ?, ?, ?, ?, ?, 10000, 'SUCCEEDED', ?, now())
                """, "P-" + orderNo, orderId, orderNo, talentId, merchantId, huifuId, "numeric-payment-" + orderNo);
        return new PaidOrder(orderNo, orderId, displayCode);
    }
    private record PaidOrder(String orderNo, Long orderId, String displayCode) {
    }
}
talent-card-server/src/test/java/com/talentcard/server/business/merchant/MerchantVerificationFreeStayTests.java
@@ -142,7 +142,8 @@
        jdbc.execute("create table payment_transaction (id bigint primary key)");
        // 这些用例只验证核销本身,资金确认用替身:确认逻辑由 OrderVerificationService 的用例覆盖。
        service = new MerchantVerificationService(jdbc, new ManualConsumptionService(jdbc, null),
                org.mockito.Mockito.mock(com.talentcard.server.business.order.OrderVerificationService.class));
                org.mockito.Mockito.mock(com.talentcard.server.business.order.OrderVerificationService.class),
                new MerchantNumericCodeAttemptGuard(jdbc));
        principal = new MerchantPrincipal(9L, 88L, 7L, "operator");
    }
talent-card-server/src/test/java/com/talentcard/server/business/merchant/MerchantVerificationOrderCodeTests.java
@@ -118,7 +118,7 @@
                        new com.talentcard.server.business.order.OrderFundBoundaryService(jdbc),
                        new com.talentcard.server.business.payment.PaymentFundConfirmationService(jdbc, transactions,
                                org.mockito.Mockito.mock(com.talentcard.server.business.payment.huifu.HuifuDelayAccountGateway.class)));
        service = new MerchantVerificationService(jdbc, new ManualConsumptionService(jdbc, null), orderVerification);
        service = new MerchantVerificationService(jdbc, new ManualConsumptionService(jdbc, null), orderVerification, new MerchantNumericCodeAttemptGuard(jdbc));
        this.orderVerification = orderVerification;
        principal = new MerchantPrincipal(9L, 88L, 7L, "operator");
    }