yeg
2 天以前 3773bc4f2d966fb248f96c7e5cedce58c91df689
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
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()));
    }
}