liusheng
16 小时以前 18f876d637cf1e9b0b264a4586e4a676a21f8166
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
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
package com.ruoyi.common.utils;
 
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
 
/**
 * 外链短码:把 {@code service_out_path.id} 映射成固定 6 位 base62 字符串。
 *
 * <p><b>为什么需要它</b>:原来的短码是 {@code String.format("%03X", id)},等于把自增主键直接写在
 * 短信链接里({@code /wt?p=1F4})。3~4 位十六进制的空间只有 4096 / 65536,而这张表已有上万条记录,
 * 空间几乎被占满 —— 攻击者逐个试就能把所有随访记录拉一遍。2026-11-05 浙江省立同德医院的
 * 网络安全通报(S20262767)就是打的这个点。
 *
 * <p><b>做了什么</b>:对主键做一次<b>带密钥的伪随机双射</b>(6 轮 Feistel 网络,轮函数用
 * HmacSHA256),输出仍是 6 位、仍是"主键的纯函数",因此:
 * <ul>
 *   <li>同一个 id 在任何机器、任何时候都算出同一个码(与 {@code %03X} 一样是确定性的),
 *       所以散落在 RyTask / RedisMqReceiver / ServiceSubtaskServiceImpl 等 14 处的
 *       "从 id 重算短码"逻辑不需要改数据流;</li>
 *   <li>不需要新增数据库列,也不需要为存量数据做迁移;</li>
 *   <li>码空间 62^6 = 56,800,235,584(约 568 亿),上万条记录下随机命中率约 1/710 万,
 *       逐个遍历在限流下不可行。</li>
 * </ul>
 *
 * <p><b>为什么不直接用随机码 + 落库</b>:随机码要求"生成一次、之后处处读库",而现在有 14 处
 * 是从 id 现算短码的(如 {@code RyTask} 补偿发送、{@code RedisMqReceiver} 各类发送通道),
 * 改成读库要动数据流,且任何一处漏改都会导致"链接打不开"。双射方案是纯函数,天然不会有这个问题。
 *
 * <p><b>为什么不用 AES</b>:6 位 base62 的空间是 238328²,正好可以做成平衡 Feistel,
 * 而不像 AES 那样要求 2 的幂次分组。用 Feistel 可以做到"输出长度完全可控"。
 *
 * <p><b>密钥</b>:由 {@code shortcode.secret} 配置注入(见 {@link ShortCodeConfig})。
 * <b>上线后不要再改</b> —— 改了之后已发出的 6 位短链无法反解,只能靠 {@code radix} 列兜底。
 *
 * @author smartor
 */
public final class ShortCode {
 
    /** 短码长度:6 位。 */
    public static final int LENGTH = 6;
 
    /** base62 字符表,顺序即编码顺序。 */
    private static final char[] ALPHABET =
            "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz".toCharArray();
 
    /** 进制:62。 */
    private static final int BASE = ALPHABET.length;
 
    /** 半区大小:62^3 = 238328。 */
    private static final int HALF = 238328;
 
    /** 整个码空间:62^6 = 238328 * 238328 = 56800235584。 */
    private static final long DOMAIN = 56800235584L;
 
    /** Feistel 轮数。6 轮配合 HMAC 轮函数,对本场景(只能向服务端"猜一次 yes/no")足够。 */
    private static final int ROUNDS = 6;
 
    private static final String ALGORITHM = "HmacSHA256";
 
    /** 缺省密钥:与 application.yml 里的 {@code shortcode.secret} 保持一致。 */
    private static final String DEFAULT_SECRET = "smartor-outpath-shortcode-2026";
 
    private static volatile byte[] secret = DEFAULT_SECRET.getBytes(StandardCharsets.UTF_8);
 
    private ShortCode() {
    }
 
    /**
     * 注入密钥(由 {@link ShortCodeConfig} 在启动时调用)。传空值则保持缺省密钥不变。
     */
    public static void setSecret(String value) {
        if (value != null && value.trim().length() > 0) {
            secret = value.trim().getBytes(StandardCharsets.UTF_8);
        }
    }
 
    /**
     * 主键 → 6 位短码。
     *
     * @param id {@code service_out_path.id},取值范围 [0, 56800235584)
     * @return 定长 6 位 base62 字符串
     */
    public static String encode(long id) {
        if (id < 0 || id >= DOMAIN) {
            throw new IllegalArgumentException("主键超出短码可表示范围 [0, " + DOMAIN + "):" + id);
        }
        long left = id / HALF;
        long right = id % HALF;
        Mac mac = newMac();
        for (int round = 0; round < ROUNDS; round++) {
            long f = round(mac, round, right);
            long next = (left + f) % HALF;
            left = right;
            right = next;
        }
        return toBase62(left * HALF + right);
    }
 
    /**
     * 6 位短码 → 主键。不是合法短码(长度不为 6、含非 base62 字符)时返回 {@code null},
     * 调用方据此回落到历史的 {@code radix} 查法。
     *
     * @param code 短码
     * @return 主键;不合法时为 {@code null}
     */
    public static Long decode(String code) {
        if (code == null || code.length() != LENGTH) {
            return null;
        }
        long value = 0L;
        for (int i = 0; i < LENGTH; i++) {
            int index = indexOf(code.charAt(i));
            if (index < 0) {
                return null;
            }
            value = value * BASE + index;
        }
        // Feistel 反演:正向是 L'=R, R'=(L+F(R)) mod n;逆向取 R=L'、L=(R'-F(L')) mod n
        long left = value / HALF;
        long right = value % HALF;
        Mac mac = newMac();
        for (int round = ROUNDS - 1; round >= 0; round--) {
            long f = round(mac, round, left);
            long previousLeft = (right - f + HALF) % HALF;
            right = left;
            left = previousLeft;
        }
        return left * HALF + right;
    }
 
    private static Mac newMac() {
        try {
            Mac mac = Mac.getInstance(ALGORITHM);
            mac.init(new SecretKeySpec(secret, ALGORITHM));
            return mac;
        } catch (Exception e) {
            // HmacSHA256 是 JDK 标准算法,走到这里说明运行环境异常,直接暴露出来
            throw new IllegalStateException("短码计算失败:无法初始化 " + ALGORITHM, e);
        }
    }
 
    /** 轮函数 F(round, block) = HmacSHA256(secret, round||block) 取前 4 字节,模 HALF。 */
    private static long round(Mac mac, int round, long block) {
        byte[] data = new byte[9];
        data[0] = (byte) round;
        data[1] = (byte) (block >>> 56);
        data[2] = (byte) (block >>> 48);
        data[3] = (byte) (block >>> 40);
        data[4] = (byte) (block >>> 32);
        data[5] = (byte) (block >>> 24);
        data[6] = (byte) (block >>> 16);
        data[7] = (byte) (block >>> 8);
        data[8] = (byte) block;
        byte[] digest = mac.doFinal(data);
        int value = ((digest[0] & 0xFF) << 24)
                | ((digest[1] & 0xFF) << 16)
                | ((digest[2] & 0xFF) << 8)
                | (digest[3] & 0xFF);
        return (value & 0x7FFFFFFFL) % HALF;
    }
 
    /** 定长 6 位 base62(左侧补 '0')。 */
    private static String toBase62(long value) {
        char[] out = new char[LENGTH];
        for (int i = LENGTH - 1; i >= 0; i--) {
            out[i] = ALPHABET[(int) (value % BASE)];
            value /= BASE;
        }
        return new String(out);
    }
 
    private static int indexOf(char c) {
        for (int i = 0; i < ALPHABET.length; i++) {
            if (ALPHABET[i] == c) {
                return i;
            }
        }
        return -1;
    }
}