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 位数字核销码的失败计数与锁定。 * *

数字码只有 100 万空间,不限次就是一个可以暴力枚举的核销入口;也不能让攻击者用「这次报错是无效码 * 还是已锁定」把枚举继续下去——所以锁定期间**连校验都不做**。 * *

计数按「谁在输」记(商户 + 门店 + 操作员),而不是按商户:一个操作员连输几次不该把整个门店锁死。 * *

计数与锁定写在独立事务里提交({@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> 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> 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> 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())); } }