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(手输核销成功、不泄露归属、撞码与猜错同一答复、
连续输错锁定且锁定期间不校验、输对清零、过期失效、轮换后新旧码都可用)。
| | |
| | | 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' |
| | |
| | | } |
| | | }, |
| | | { |
| | | "path": "pages/verification-code-input/verification-code-input", |
| | | "style": { |
| | | "navigationStyle": "custom", |
| | | "backgroundColor": "#F7F7F7", |
| | | "disableScroll": true |
| | | } |
| | | }, |
| | | { |
| | | "path": "pages/verification-success/verification-success", |
| | | "style": { |
| | | "navigationStyle": "custom", |
| | |
| | | <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> |
| | |
| | | let openedManualConsumption = false |
| | | let openedConfirmation = false |
| | | let openedVerificationFailure = false |
| | | let openedManualEntry = false |
| | | let cancelVerificationRequest: (() => void) | null = null |
| | | |
| | | const cancelPendingRequest = () => { |
| | |
| | | }) |
| | | } |
| | | |
| | | // 手输 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> |
| | |
| | | .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; } |
| New file |
| | |
| | | <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> |
| | |
| | | </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> |
| | |
| | | |
| | | <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) |
| | |
| | | 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) => { |
| | |
| | | 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() |
| | | }) |
| | |
| | | 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) |
| New file |
| | |
| | | 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())); |
| | | } |
| | | } |
| | |
| | | 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); |
| | |
| | | } |
| | | |
| | | /** |
| | | * 手输 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>订单核销不走这里:它必须写核销记录并确认延迟入账的资金,统一在 |
| | |
| | | } |
| | | |
| | | /** |
| | | * 手输 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}: |
| | | * 核销要写记录并确认延迟入账的资金,与不涉及支付的免费入住券是两条不同的线。 |
| | | */ |
| | |
| | | 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) { } |
| | | |
| New file |
| | |
| | | -- 商户端手输 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='商户端手输数字核销码的失败计数与锁定'; |
| New file |
| | |
| | | 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) { |
| | | } |
| | | } |
| | |
| | | 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"); |
| | | } |
| | | |
| | |
| | | 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"); |
| | | } |