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