Ver código fonte

update:更新v2rayn节点订阅接口v1

zhiqiang.lv 1 semana atrás
pai
commit
4a47024126

+ 39 - 5
src/main/java/top/lvzhiqiang/controller/SubscriptionController.java

@@ -8,11 +8,10 @@ import org.springframework.web.bind.annotation.GetMapping;
 import org.springframework.web.bind.annotation.PathVariable;
 import org.springframework.web.bind.annotation.RequestMapping;
 import org.springframework.web.bind.annotation.RestController;
+
 import top.lvzhiqiang.entity.NetAccount;
 import top.lvzhiqiang.service.SubscribeService;
 
-import java.time.ZoneId;
-
 @RestController
 @RequestMapping("/api/sub")
 public class SubscriptionController {
@@ -21,10 +20,12 @@ public class SubscriptionController {
     private SubscribeService subscribeService;
 
     /**
+     * Clash 客户端 (Clash Verge / Clash Meta for Android)
+     * 
      * @param token
      * @return
      */
-    @GetMapping("/{token}")
+    @GetMapping("/yml/{token}")
     public ResponseEntity<String> downloadSub(@PathVariable String token) {
         try {
             // 1. 生成 YAML
@@ -36,7 +37,7 @@ public class SubscriptionController {
             // 3. 设置响应头
             HttpHeaders headers = new HttpHeaders();
             // 设置文件名
-            headers.add("Content-Disposition", "attachment; filename=jav-clash.yml");
+            headers.add("Content-Disposition", "attachment; filename=" + account.getUsername() + ".yml");
             // 标识文件类型
             headers.add("Content-Type", "text/plain; charset=UTF-8");
             // 流量统计条 (upload=0; download=used; total=total; expire=timestamp)
@@ -51,7 +52,40 @@ public class SubscriptionController {
             return new ResponseEntity<>(body, headers, HttpStatus.OK);
         } catch (Exception e) {
             e.printStackTrace();
-            return new ResponseEntity<>("Subscription Error: " + e.getMessage(), HttpStatus.BAD_REQUEST);
+            return new ResponseEntity<>("yml Subscription Error: " + e.getMessage(), HttpStatus.BAD_REQUEST);
+        }
+    }
+
+    /**
+     * 传统客户端 (v2rayN / v2rayNG / Shadowrocket / Karing 通用)
+     * 
+     * @param token
+     * @return
+     */
+    @GetMapping("/base/{token}")
+    public ResponseEntity<String> downloadV2raySub(@PathVariable String token) {
+        try {
+            // 1. 生成 Base64 编码的节点 URI 集合
+            String body = subscribeService.generateV2rayConfig(token);
+
+            // 2. 获取账号信息 (为了Header)
+            NetAccount account = subscribeService.getAccount(token);
+
+            // 3. 设置响应头 (复用你之前的逻辑)
+            HttpHeaders headers = new HttpHeaders();
+            headers.add("Content-Disposition", "attachment; filename=" + account.getUsername() + ".txt");
+            // 注意:V2rayN 期望的是纯文本格式
+            headers.add("Content-Type", "text/plain; charset=UTF-8");
+
+            String userInfo = String.format("upload=%d; download=%d; total=%d; expire=%d",
+                    account.getUploadUsedTraffic(), account.getDownloadUsedTraffic(), account.getTotalTraffic(), account.getExpireTimeSeconds());
+            headers.add("Subscription-Userinfo", userInfo);
+            headers.add("Profile-Update-Interval", "12");
+
+            return new ResponseEntity<>(body, headers, HttpStatus.OK);
+        } catch (Exception e) {
+            e.printStackTrace();
+            return new ResponseEntity<>("base Subscription Error: " + e.getMessage(), HttpStatus.BAD_REQUEST);
         }
     }
 }

+ 2 - 0
src/main/java/top/lvzhiqiang/service/SubscribeService.java

@@ -7,4 +7,6 @@ public interface SubscribeService {
     NetAccount getAccount(String token);
 
     String generateClashConfig(String token);
+
+    String generateV2rayConfig(String token);
 }

+ 277 - 11
src/main/java/top/lvzhiqiang/service/impl/SubscribeServiceImpl.java

@@ -2,6 +2,7 @@ package top.lvzhiqiang.service.impl;
 
 import java.io.IOException;
 import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
 import java.sql.Connection;
 import java.sql.PreparedStatement;
 import java.sql.ResultSet;
@@ -11,6 +12,7 @@ import java.time.LocalDate;
 import java.time.LocalTime;
 import java.time.ZoneId;
 import java.util.ArrayList;
+import java.util.Base64;
 import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
@@ -61,8 +63,7 @@ public class SubscribeServiceImpl implements SubscribeService {
     private static final String[] UNITS = new String[] { "B", "KB", "MB", "GB", "TB", "PB", "EB" };
 
     /**
-     * 3. 初始化方法
-     * 该方法会在 @Value 注入完成后,由 Spring 自动调用一次
+     * 3. 初始化方法 该方法会在 @Value 注入完成后,由 Spring 自动调用一次
      */
     @PostConstruct
     public void init() {
@@ -79,17 +80,16 @@ public class SubscribeServiceImpl implements SubscribeService {
         // 针对私有连接池,通常建议调小连接数
         privateDataSource.setMaximumPoolSize(5);
     }
-    
+
     /**
-     * 4. 销毁方法 (非常重要!)
-     * 当这个 Service 被销毁(或应用关闭)时,必须关闭连接池,
-     * 否则会导致数据库连接泄露(Connection Leak)。
+     * 4. 销毁方法 (非常重要!) 当这个 Service 被销毁(或应用关闭)时,必须关闭连接池, 否则会导致数据库连接泄露(Connection
+     * Leak)。
      */
     @PreDestroy
     public void cleanup() {
         if (privateDataSource != null && !privateDataSource.isClosed()) {
             log.info("正在关闭 Service 私有的连接池...");
-            
+
             privateDataSource.close();
         }
     }
@@ -235,6 +235,31 @@ public class SubscribeServiceImpl implements SubscribeService {
         }
     }
 
+    @Override
+    public String generateV2rayConfig(String token) {
+        // 1. 校验账号
+        NetAccount account = netMapper.findAccountByToken(token);
+        if (account == null || (account.getStatus() != null && account.getStatus() == 0)) {
+            throw new BusinessException("Account invalid or disabled");
+        }
+
+        // 2. 获取节点数据
+        List<NetNode> nodes = netMapper.findNodesByAccountId(account.getId());
+
+        // 3. 遍历节点,转换为 URI 格式
+        StringBuilder sb = new StringBuilder();
+        for (NetNode node : nodes) {
+            String uri = convertNodeToUri(node);
+            if (StringUtils.isNotEmpty(uri)) {
+                sb.append(uri).append("\n");
+            }
+        }
+
+        // 4. 将拼接好的多行字符串进行 Base64 编码,v2rayN 只认 Base64 格式的返回体
+        String plainText = sb.toString();
+        return Base64.getEncoder().encodeToString(plainText.getBytes(StandardCharsets.UTF_8));
+    }
+
     private Map<String, Object> convertNodeToProxy(NetNode node) {
         Map<String, Object> map = new LinkedHashMap<>();
         map.put("name", node.getName());
@@ -273,6 +298,7 @@ public class SubscribeServiceImpl implements SubscribeService {
 
     /**
      * 格式化文件大小
+     * 
      * @param size 字节数 (long)
      * @return 格式化后的字符串 (例如: "1.74 MB")
      */
@@ -280,19 +306,259 @@ public class SubscribeServiceImpl implements SubscribeService {
         if (size <= 0) {
             return "0";
         }
-        
+
         // 计算层级 (0=B, 1=KB, 2=MB, etc.)
         // 使用 log10(size) / log10(1024) 可以快速计算出该数字属于哪个量级
         int digitGroups = (int) (Math.log10(size) / Math.log10(1024));
-        
+
         // 格式化数字,保留两位小数 (#.##)
         // new DecimalFormat("#,##0.##") 也可以处理千分位,如 1,024.50 MB
         DecimalFormat df = new DecimalFormat("#0.##");
-        
+
         // 计算显示值:原数值 / (1024的n次方)
         double displayValue = size / Math.pow(1024, digitGroups);
-        
+
         // 拼接结果:数字 + 空格 + 单位
         return df.format(displayValue) + " " + UNITS[digitGroups];
     }
+
+    /**
+     * 将单个 NetNode 转换为对应的分享链接 (URI)
+     * 
+     * @param node
+     * @return
+     */
+    private String convertNodeToUri(NetNode node) {
+        String type = node.getType().toLowerCase();
+        String name = "";
+        try {
+            // 节点名称进行 URL 编码,防止特殊字符导致解析失败
+            name = java.net.URLEncoder.encode(node.getName(), "UTF-8");
+        } catch (Exception e) {
+            name = node.getName();
+        }
+
+        // 解析高级配置 meta_json
+        Map<String, Object> meta = new LinkedHashMap<>();
+        try {
+            if (StringUtils.isNotEmpty(node.getMetaJson())) {
+                meta = objectMapper.readValue(node.getMetaJson(), new com.fasterxml.jackson.core.type.TypeReference<Map<String, Object>>() {
+                });
+            }
+        } catch (Exception e) {
+            log.error("parse meta_json error", e);
+        }
+
+        // 根据协议类型生成不同的链接格式
+        switch (type) {
+        case "trojan":
+            return buildTrojanUri(node, meta, name);
+        case "vmess":
+            return buildVmessUri(node, meta, name);
+        case "hysteria2":
+        case "hy2":
+            return buildHysteria2Uri(node, meta, name);
+        case "vless":
+            return buildVlessUri(node, meta, name);
+        default:
+            log.warn("Unsupported node type for v2ray: {}", type);
+            return null;
+        }
+    }
+
+    /**
+     * 构建 Trojan 链接 格式: trojan://password@server:port?sni=xxx&type=tcp#name
+     * 
+     * @param node
+     * @param meta
+     * @param name
+     * @return
+     */
+    private String buildTrojanUri(NetNode node, Map<String, Object> meta, String name) {
+        StringBuilder uri = new StringBuilder("trojan://");
+        uri.append(node.getPassword()).append("@")
+                .append(node.getServer()).append(":")
+                .append(node.getPort()).append("?");
+
+        // 追加 sni 参数
+        if (meta.containsKey("sni")) {
+            uri.append("sni=").append(meta.get("sni")).append("&");
+        }
+        // 追加网络类型 (tcp/ws)
+        if (meta.containsKey("network")) {
+            uri.append("type=").append(meta.get("network")).append("&");
+            if ("ws".equals(meta.get("network")) && meta.containsKey("ws-opts")) {
+                Map<String, Object> wsOpts = (Map<String, Object>) meta.get("ws-opts");
+                if (wsOpts.containsKey("path")) {
+                    uri.append("path=").append(wsOpts.get("path")).append("&");
+                }
+                if (wsOpts.containsKey("headers")) {
+                    Map<String, String> headers = (Map<String, String>) wsOpts.get("headers");
+                    if (headers.containsKey("Host")) {
+                        uri.append("host=").append(headers.get("Host")).append("&");
+                    }
+                }
+            }
+        }
+        // 去掉末尾多余的 '?' 或 '&'
+        String result = uri.toString();
+        if (result.endsWith("?") || result.endsWith("&")) {
+            result = result.substring(0, result.length() - 1);
+        }
+        return result + "#" + name;
+    }
+
+    /**
+     * 构建 Hysteria2 链接 格式: hysteria2://password@server:port?sni=xxx&insecure=1#name
+     * 
+     * @param node
+     * @param meta
+     * @param name
+     * @return
+     */
+    private String buildHysteria2Uri(NetNode node, Map<String, Object> meta, String name) {
+        StringBuilder uri = new StringBuilder("hysteria2://");
+        uri.append(node.getPassword()).append("@")
+                .append(node.getServer()).append(":")
+                .append(node.getPort()).append("?");
+
+        if (meta.containsKey("sni")) {
+            uri.append("sni=").append(meta.get("sni")).append("&");
+        }
+        if (meta.containsKey("skip-cert-verify") && (Boolean) meta.get("skip-cert-verify")) {
+            uri.append("insecure=1").append("&");
+        }
+
+        String result = uri.toString();
+        if (result.endsWith("?") || result.endsWith("&")) {
+            result = result.substring(0, result.length() - 1);
+        }
+        return result + "#" + name;
+    }
+
+    /**
+     * 构建 Vmess 链接 (Vmess 是最特殊的,它要求将一个特定结构的 JSON 进行 Base64 编码) 格式:
+     * vmess://base64(json)
+     * 
+     * @param node
+     * @param meta
+     * @param name
+     * @return
+     */
+    private String buildVmessUri(NetNode node, Map<String, Object> meta, String name) {
+        Map<String, Object> vmessJson = new LinkedHashMap<>();
+        vmessJson.put("v", "2");
+        vmessJson.put("ps", java.net.URLDecoder.decode(name)); // Vmess JSON 里可以直接用明文中文
+        vmessJson.put("add", node.getServer());
+        vmessJson.put("port", String.valueOf(node.getPort()));
+        vmessJson.put("id", node.getPassword()); // UUID
+        vmessJson.put("aid", "0");
+        vmessJson.put("scy", "auto");
+
+        // 映射网络类型
+        String net = meta.containsKey("network") ? (String) meta.get("network") : "tcp";
+        vmessJson.put("net", net);
+        vmessJson.put("type", "none");
+
+        // 如果是 WS
+        if ("ws".equals(net) && meta.containsKey("ws-opts")) {
+            Map<String, Object> wsOpts = (Map<String, Object>) meta.get("ws-opts");
+            if (wsOpts.containsKey("path"))
+                vmessJson.put("path", wsOpts.get("path"));
+            if (wsOpts.containsKey("headers")) {
+                Map<String, String> headers = (Map<String, String>) wsOpts.get("headers");
+                if (headers.containsKey("Host"))
+                    vmessJson.put("host", headers.get("Host"));
+            }
+        }
+
+        // TLS 设置
+        if (meta.containsKey("tls") && (Boolean) meta.get("tls")) {
+            vmessJson.put("tls", "tls");
+            if (meta.containsKey("sni"))
+                vmessJson.put("sni", meta.get("sni"));
+        } else {
+            vmessJson.put("tls", "");
+        }
+
+        try {
+            String jsonStr = objectMapper.writeValueAsString(vmessJson);
+            String base64Json = java.util.Base64.getEncoder().encodeToString(jsonStr.getBytes(java.nio.charset.StandardCharsets.UTF_8));
+            return "vmess://" + base64Json;
+        } catch (Exception e) {
+            log.error("build vmess uri error", e);
+            return null;
+        }
+    }
+
+    /**
+     * 构建 Vless 链接 (如果你的业务用到 Vless) 完美适配 Clash Meta 格式的 meta_json 转换为 V2ray/Xray
+     * 标准链接
+     * 
+     * @param node
+     * @param meta
+     * @param name
+     * @return
+     */
+    private String buildVlessUri(NetNode node, Map<String, Object> meta, String name) {
+        StringBuilder uri = new StringBuilder("vless://");
+        uri.append(node.getPassword()).append("@")
+                .append(node.getServer()).append(":")
+                .append(node.getPort()).append("?encryption=none&");
+
+        // 1. 底层传输方式 (network -> type)
+        if (meta.containsKey("network")) {
+            uri.append("type=").append(meta.get("network")).append("&");
+        }
+
+        // 2. 传输层安全 (TLS)
+        if (meta.containsKey("tls") && (Boolean) meta.get("tls")) {
+            uri.append("security=tls&");
+        }
+
+        // 3. SNI (Clash 通常用 servername,也兼容 sni,Vless 链接里叫 sni)
+        String sni = meta.containsKey("servername") ? (String) meta.get("servername") : (String) meta.get("sni");
+        if (StringUtils.isNotEmpty(sni)) {
+            uri.append("sni=").append(sni).append("&");
+        }
+
+        // 4. 指纹 (client-fingerprint -> fp)
+        String fp = meta.containsKey("client-fingerprint") ? (String) meta.get("client-fingerprint") : (String) meta.get("fingerprint");
+        if (StringUtils.isNotEmpty(fp)) {
+            uri.append("fp=").append(fp).append("&");
+        }
+
+        // 5. WS 专属配置 (伪装域名 host,路径 path)
+        if ("ws".equals(meta.get("network")) && meta.containsKey("ws-opts")) {
+            Map<String, Object> wsOpts = (Map<String, Object>) meta.get("ws-opts");
+            try {
+                // 路径 path
+                if (wsOpts.containsKey("path")) {
+                    String path = (String) wsOpts.get("path");
+                    // ⚠️非常重要:你的 path 里有 "?ed=2048",必须进行 URLEncode,否则会破坏外部的 URI 参数结构
+                    uri.append("path=").append(java.net.URLEncoder.encode(path, "UTF-8")).append("&");
+                }
+
+                // 伪装域名 host
+                if (wsOpts.containsKey("headers")) {
+                    Map<String, String> headers = (Map<String, String>) wsOpts.get("headers");
+                    if (headers.containsKey("Host")) {
+                        String host = headers.get("Host");
+                        uri.append("host=").append(java.net.URLEncoder.encode(host, "UTF-8")).append("&");
+                    }
+                }
+            } catch (Exception e) {
+                log.error("build vless uri - ws_opts encode error", e);
+            }
+        }
+
+        // 去掉末尾多余的 '&'
+        String result = uri.toString();
+        if (result.endsWith("&")) {
+            result = result.substring(0, result.length() - 1);
+        }
+
+        // 加上备注名称
+        return result + "#" + name;
+    }
 }