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 字符串。 * *
为什么需要它:原来的短码是 {@code String.format("%03X", id)},等于把自增主键直接写在 * 短信链接里({@code /wt?p=1F4})。3~4 位十六进制的空间只有 4096 / 65536,而这张表已有上万条记录, * 空间几乎被占满 —— 攻击者逐个试就能把所有随访记录拉一遍。2026-11-05 浙江省立同德医院的 * 网络安全通报(S20262767)就是打的这个点。 * *
做了什么:对主键做一次带密钥的伪随机双射(6 轮 Feistel 网络,轮函数用 * HmacSHA256),输出仍是 6 位、仍是"主键的纯函数",因此: *
为什么不直接用随机码 + 落库:随机码要求"生成一次、之后处处读库",而现在有 14 处 * 是从 id 现算短码的(如 {@code RyTask} 补偿发送、{@code RedisMqReceiver} 各类发送通道), * 改成读库要动数据流,且任何一处漏改都会导致"链接打不开"。双射方案是纯函数,天然不会有这个问题。 * *
为什么不用 AES:6 位 base62 的空间是 238328²,正好可以做成平衡 Feistel, * 而不像 AES 那样要求 2 的幂次分组。用 Feistel 可以做到"输出长度完全可控"。 * *
密钥:由 {@code shortcode.secret} 配置注入(见 {@link ShortCodeConfig})。 * 上线后不要再改 —— 改了之后已发出的 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; } }