liusheng
7 天以前 da1db3c859e25d796d1030cc61f2c3023be53031
代码提交
已添加2个文件
209 ■■■■■ 文件已修改
ruoyi-common/src/main/java/com/ruoyi/common/config/ShortCodeConfig.java 28 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-common/src/main/java/com/ruoyi/common/utils/ShortCode.java 181 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-common/src/main/java/com/ruoyi/common/config/ShortCodeConfig.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,28 @@
package com.ruoyi.common.config;
import com.ruoyi.common.utils.ShortCode;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import javax.annotation.PostConstruct;
/**
 * æŠŠé…ç½®é‡Œçš„外链短码密钥注入 {@link ShortCode}。
 *
 * <p>短码算法是"主键 â†’ 6 ä½"的带密钥双射。密钥只在服务端参与计算,不出现在链接里;
 * <b>上线后不要修改</b>,否则已发出的 6 ä½çŸ­é“¾æ— æ³•反解(会回落到 {@code service_out_path.radix}
 * å…œåº•,而该列可能已被截断)。配置项缺省时 {@link ShortCode} ä½¿ç”¨å†…置缺省密钥。
 *
 * @author smartor
 */
@Component
public class ShortCodeConfig {
    @Value("${shortcode.secret:}")
    private String secret;
    @PostConstruct
    public void init() {
        ShortCode.setSecret(secret);
    }
}
ruoyi-common/src/main/java/com/ruoyi/common/utils/ShortCode.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,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;
    }
}