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