|
|
@@ -0,0 +1,176 @@
|
|
|
+package top.lvzhiqiang.service.impl;
|
|
|
+
|
|
|
+import com.fasterxml.jackson.core.type.TypeReference;
|
|
|
+import com.fasterxml.jackson.databind.ObjectMapper;
|
|
|
+import lombok.extern.slf4j.Slf4j;
|
|
|
+import org.springframework.beans.factory.annotation.Autowired;
|
|
|
+import org.springframework.core.io.ClassPathResource;
|
|
|
+import org.springframework.stereotype.Service;
|
|
|
+import org.yaml.snakeyaml.DumperOptions;
|
|
|
+import org.yaml.snakeyaml.Yaml;
|
|
|
+import top.lvzhiqiang.entity.NetAccount;
|
|
|
+import top.lvzhiqiang.entity.NetNode;
|
|
|
+import top.lvzhiqiang.exception.BusinessException;
|
|
|
+import top.lvzhiqiang.mapper.NetMapper;
|
|
|
+import top.lvzhiqiang.service.SubscribeService;
|
|
|
+import top.lvzhiqiang.util.StringUtils;
|
|
|
+
|
|
|
+import java.io.IOException;
|
|
|
+import java.io.InputStream;
|
|
|
+import java.util.ArrayList;
|
|
|
+import java.util.LinkedHashMap;
|
|
|
+import java.util.List;
|
|
|
+import java.util.Map;
|
|
|
+
|
|
|
+@Service
|
|
|
+@Slf4j
|
|
|
+public class SubscribeServiceImpl implements SubscribeService {
|
|
|
+
|
|
|
+ @Autowired
|
|
|
+ private NetMapper netMapper;
|
|
|
+
|
|
|
+ // Jackson 实例建议定义为全局单例或静态常量
|
|
|
+ private final ObjectMapper objectMapper = new ObjectMapper();
|
|
|
+
|
|
|
+ @Override
|
|
|
+ public NetAccount getAccount(String token) {
|
|
|
+ return netMapper.findAccountByToken(token);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 生成完整的 Clash YAML 配置
|
|
|
+ *
|
|
|
+ * @param token
|
|
|
+ * @return
|
|
|
+ */
|
|
|
+ @Override
|
|
|
+ public String generateClashConfig(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. 准备数据结构
|
|
|
+ List<Map<String, Object>> proxies = new ArrayList<>();
|
|
|
+ List<String> allNodeNames = new ArrayList<>();
|
|
|
+ Map<String, List<String>> regionMap = new LinkedHashMap<>();
|
|
|
+
|
|
|
+ // 4. 转换节点并归类
|
|
|
+ for (NetNode node : nodes) {
|
|
|
+ Map<String, Object> proxyMap = convertNodeToProxy(node);
|
|
|
+ proxies.add(proxyMap);
|
|
|
+
|
|
|
+ String name = (String) proxyMap.get("name");
|
|
|
+ allNodeNames.add(name);
|
|
|
+
|
|
|
+ // 国家归类
|
|
|
+ String region = StringUtils.isEmpty(node.getRegion()) ? "OTHER" : node.getRegion().toUpperCase();
|
|
|
+ regionMap.computeIfAbsent(region, k -> new ArrayList<>()).add(name);
|
|
|
+ }
|
|
|
+
|
|
|
+ // 5. 构建策略组
|
|
|
+ List<Map<String, Object>> groups = new ArrayList<>();
|
|
|
+
|
|
|
+ // 5.1 [手动选择] 主组
|
|
|
+ Map<String, Object> manualGroup = new LinkedHashMap<>();
|
|
|
+ manualGroup.put("name", "手动选择");
|
|
|
+ manualGroup.put("type", "select");
|
|
|
+ List<String> manualProxies = new ArrayList<>();
|
|
|
+ manualProxies.add("自动选择"); // 首选自动
|
|
|
+ manualProxies.addAll(regionMap.keySet()); // 动态添加所有国家组名
|
|
|
+ manualProxies.add("DIRECT"); // 这里添加 "DIRECT" 允许用户手动关代理
|
|
|
+ manualGroup.put("proxies", manualProxies);
|
|
|
+ groups.add(manualGroup);
|
|
|
+
|
|
|
+ // 5.2 [自动选择] 组(自动选择延迟最低的节点)
|
|
|
+ Map<String, Object> autoGroup = new LinkedHashMap<>();
|
|
|
+ autoGroup.put("name", "自动选择");
|
|
|
+ autoGroup.put("type", "url-test"); // select 类型组不需要 url 和 interval(仅 url-test/fallback 需要)
|
|
|
+ autoGroup.put("proxies", allNodeNames);
|
|
|
+ autoGroup.put("url", "https://www.google.com/generate_204"); // 延迟测试 URL
|
|
|
+ autoGroup.put("interval", 300); // 每 5 分钟测试一次
|
|
|
+ autoGroup.put("tolerance", 50); // 允许 50ms 延迟波动(避免因网络波动导致频繁切换节点)
|
|
|
+ autoGroup.put("include-all:", false); // 会强制包含所有节点(包括未定义的节点),可能导致策略混乱。需谨慎使用。
|
|
|
+ groups.add(autoGroup);
|
|
|
+
|
|
|
+ // 5.3 [国家/地区] 组 (US, JP, SG...)
|
|
|
+ for (Map.Entry<String, List<String>> entry : regionMap.entrySet()) {
|
|
|
+ Map<String, Object> regionGroup = new LinkedHashMap<>();
|
|
|
+ regionGroup.put("name", entry.getKey());
|
|
|
+ regionGroup.put("type", "select");
|
|
|
+ regionGroup.put("proxies", entry.getValue());
|
|
|
+
|
|
|
+ groups.add(regionGroup);
|
|
|
+ }
|
|
|
+
|
|
|
+ // 6. 读取模板并合并数据
|
|
|
+ try {
|
|
|
+ // 设置 YAML 输出格式
|
|
|
+ DumperOptions options = new DumperOptions();
|
|
|
+ options.setDefaultFlowStyle(DumperOptions.FlowStyle.BLOCK); // 块状风格
|
|
|
+ options.setPrettyFlow(true);
|
|
|
+ options.setAllowUnicode(true); // 允许中文
|
|
|
+ Yaml yaml = new Yaml(options);
|
|
|
+
|
|
|
+ // 从 resources 读取模板
|
|
|
+ ClassPathResource resource = new ClassPathResource("clash-template.yml");
|
|
|
+ Map<String, Object> config;
|
|
|
+ try (InputStream inputStream = resource.getInputStream()) {
|
|
|
+ config = yaml.load(inputStream);
|
|
|
+ }
|
|
|
+
|
|
|
+ if (config == null) {
|
|
|
+ config = new LinkedHashMap<>();
|
|
|
+ }
|
|
|
+
|
|
|
+ // 注入我们生成的节点和组
|
|
|
+ config.put("proxies", proxies);
|
|
|
+ config.put("proxy-groups", groups);
|
|
|
+
|
|
|
+ // 7. 返回最终字符串
|
|
|
+ return yaml.dump(config);
|
|
|
+
|
|
|
+ } catch (IOException e) {
|
|
|
+ throw new RuntimeException("Failed to load yaml template", e);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private Map<String, Object> convertNodeToProxy(NetNode node) {
|
|
|
+ Map<String, Object> map = new LinkedHashMap<>();
|
|
|
+ map.put("name", node.getName());
|
|
|
+ map.put("type", node.getType());
|
|
|
+ map.put("server", node.getServer());
|
|
|
+ map.put("port", node.getPort());
|
|
|
+
|
|
|
+ // 1. 处理 密码/UUID 差异
|
|
|
+ if (node.getPassword() != null) {
|
|
|
+ String t = node.getType().toLowerCase();
|
|
|
+ if (t.contains("vmess") || t.contains("vless")) {
|
|
|
+ map.put("uuid", node.getPassword());
|
|
|
+ } else {
|
|
|
+ map.put("password", node.getPassword());
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 2. 处理 JSON 扩展字段 (Hysteria2/Vless 特有字段等)
|
|
|
+ try {
|
|
|
+ if (StringUtils.isNotEmpty(node.getMetaJson())) {
|
|
|
+ Map<String, Object> meta = objectMapper.readValue(node.getMetaJson(), new TypeReference<Map<String, Object>>() {
|
|
|
+ });
|
|
|
+ map.putAll(meta);
|
|
|
+ }
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.error("convertNodeToProxy JSON parse error for node:{}", node.getId(), e);
|
|
|
+ }
|
|
|
+
|
|
|
+ // 3. 兜底默认值
|
|
|
+ map.putIfAbsent("udp", true);
|
|
|
+ map.putIfAbsent("skip-cert-verify", true);
|
|
|
+
|
|
|
+ return map;
|
|
|
+ }
|
|
|
+}
|