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()));
|
}
|
}
|