Quellcode durchsuchen

add:新增clash节点订阅接口v1

lvzhiqiang vor 1 Monat
Ursprung
Commit
76f49873d0

+ 6 - 2
src/main/java/top/lvzhiqiang/config/UnifiedReturnConfig.java

@@ -3,6 +3,7 @@ package top.lvzhiqiang.config;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.context.annotation.Configuration;
 import org.springframework.core.MethodParameter;
+import org.springframework.http.HttpEntity;
 import org.springframework.http.HttpStatus;
 import org.springframework.http.MediaType;
 import org.springframework.http.ResponseEntity;
@@ -43,10 +44,13 @@ public class UnifiedReturnConfig {
         public Object beforeBodyWrite(Object body, MethodParameter methodParameter,
                                       MediaType mediaType, Class<? extends HttpMessageConverter<?>> aClass,
                                       ServerHttpRequest serverHttpRequest, ServerHttpResponse serverHttpResponse) {
-            if (serverHttpRequest.getURI().toASCIIString().indexOf("/bg") >= 0) {
+            if (serverHttpRequest.getURI().toASCIIString().contains("/bg")) {
                 return body;
             }
-            if (serverHttpRequest.getURI().toASCIIString().indexOf("/coin/orderDetail") >= 0) {
+            if (serverHttpRequest.getURI().toASCIIString().contains("/coin/orderDetail")) {
+                return body;
+            }
+            if (serverHttpRequest.getURI().toASCIIString().contains("/api/sub")) {
                 return body;
             }
 

+ 56 - 0
src/main/java/top/lvzhiqiang/controller/SubscriptionController.java

@@ -0,0 +1,56 @@
+package top.lvzhiqiang.controller;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+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 {
+
+    @Autowired
+    private SubscribeService subscribeService;
+
+    /**
+     * @param token
+     * @return
+     */
+    @GetMapping("/{token}")
+    public ResponseEntity<String> downloadSub(@PathVariable String token) {
+        try {
+            // 1. 生成 YAML
+            String body = subscribeService.generateClashConfig(token);
+
+            // 2. 获取账号信息 (为了Header)
+            NetAccount account = subscribeService.getAccount(token);
+
+            // 3. 设置响应头
+            HttpHeaders headers = new HttpHeaders();
+            // 设置文件名
+            headers.add("Content-Disposition", "attachment; filename=jav-clash.yml");
+            // 标识文件类型
+            headers.add("Content-Type", "text/plain; charset=UTF-8");
+            // 流量统计条 (upload=0; download=used; total=total; expire=timestamp)
+            long expire = account.getExpireTime() != null ? account.getExpireTime().atZone(ZoneId.systemDefault()).toInstant().getEpochSecond() : 0;
+            String userInfo = String.format("upload=0; download=%d; total=%d; expire=%d",
+                    account.getUsedTraffic(), account.getTotalTraffic(), expire);
+            headers.add("Subscription-Userinfo", userInfo);
+            // 建议客户端更新间隔 (秒)
+            headers.add("Profile-Update-Interval", "21600");
+
+            return new ResponseEntity<>(body, headers, HttpStatus.OK);
+        } catch (Exception e) {
+            e.printStackTrace();
+            return new ResponseEntity<>("Subscription Error: " + e.getMessage(), HttpStatus.BAD_REQUEST);
+        }
+    }
+}

+ 66 - 0
src/main/java/top/lvzhiqiang/entity/NetAccount.java

@@ -0,0 +1,66 @@
+package top.lvzhiqiang.entity;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+/**
+ * 代理订阅账号表:专门用于存放连接代理的用户
+ */
+@Data
+public class NetAccount {
+
+    /**
+     * 主键
+     */
+    private Long id;
+
+    /**
+     * 账号备注
+     */
+    private String username;
+
+    /**
+     * 订阅Token(唯一凭证)
+     */
+    private String token;
+
+    /**
+     * 总流量(字节)
+     */
+    private Long totalTraffic;
+
+    /**
+     * 已用流量(字节)
+     */
+    private Long usedTraffic;
+
+    /**
+     * 过期时间
+     */
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
+    private LocalDateTime expireTime;
+
+    /**
+     * 1:启用 0:禁用
+     */
+    private Integer status;
+
+    /**
+     * 备注
+     */
+    private String remark;
+
+    /**
+     * 创建时间
+     */
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
+    private LocalDateTime createTime;
+
+    /**
+     * 最后修改时间
+     */
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
+    private LocalDateTime modifyTime;
+}

+ 80 - 0
src/main/java/top/lvzhiqiang/entity/NetNode.java

@@ -0,0 +1,80 @@
+package top.lvzhiqiang.entity;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+/**
+ * 网络节点表: 存放 Vmess/Trojan 等节点信息
+ */
+@Data
+public class NetNode {
+
+    /**
+     * 主键
+     */
+    private Long id;
+
+    /**
+     * 节点显示名称
+     */
+    private String name;
+
+    /**
+     * 协议: vmess, trojan, hysteria2
+     */
+    private String type;
+
+    /**
+     * 服务器IP或域名
+     */
+    private String server;
+
+    /**
+     * 连接端口
+     */
+    private Integer port;
+
+    /**
+     * UUID或密码
+     */
+    private String password;
+
+    /**
+     * 地区代码: US, HK, JP, SG
+     */
+    private String region;
+
+    /**
+     * 高级配置: sni, flow, fingerprint等
+     */
+    private String metaJson;
+
+    /**
+     * 排序权重(越大越靠前)
+     */
+    private Integer sort;
+
+    /**
+     * 1:在线 0:维护
+     */
+    private Integer status;
+
+    /**
+     * 备注
+     */
+    private String remark;
+
+    /**
+     * 创建时间
+     */
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
+    private LocalDateTime createTime;
+
+    /**
+     * 最后修改时间
+     */
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
+    private LocalDateTime modifyTime;
+}

+ 4 - 0
src/main/java/top/lvzhiqiang/exception/BusinessException.java

@@ -23,4 +23,8 @@ public class BusinessException extends RuntimeException {
         super(resultCodeEnum.getMessage());
         this.code = resultCodeEnum.getCode();
     }
+
+    public BusinessException(String message) {
+        super(message);
+    }
 }

+ 34 - 0
src/main/java/top/lvzhiqiang/mapper/NetMapper.java

@@ -0,0 +1,34 @@
+package top.lvzhiqiang.mapper;
+
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Result;
+import org.apache.ibatis.annotations.Results;
+import org.apache.ibatis.annotations.Select;
+import top.lvzhiqiang.entity.NetAccount;
+import top.lvzhiqiang.entity.NetNode;
+
+import java.util.List;
+
+@Mapper
+public interface NetMapper {
+
+    // 1. 查询账号信息
+    @Select("SELECT * FROM net_account WHERE token = #{token} LIMIT 1")
+    @Results({
+            @Result(property = "totalTraffic", column = "total_traffic"),
+            @Result(property = "usedTraffic", column = "used_traffic"),
+            @Result(property = "expireTime", column = "expire_time")
+    })
+    NetAccount findAccountByToken(String token);
+
+    // 2. 核心关联查询
+    @Select("SELECT n.* FROM net_node n " +
+            "INNER JOIN net_account_node_map m ON n.id = m.node_id " +
+            "WHERE m.account_id = #{accountId} " +
+            "  AND n.status = 1 " +
+            "ORDER BY n.sort DESC,n.id asc")
+    @Results({
+            @Result(property = "metaJson", column = "meta_json")
+    })
+    List<NetNode> findNodesByAccountId(Long accountId);
+}

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

@@ -0,0 +1,10 @@
+package top.lvzhiqiang.service;
+
+import top.lvzhiqiang.entity.NetAccount;
+
+public interface SubscribeService {
+
+    NetAccount getAccount(String token);
+
+    String generateClashConfig(String token);
+}

+ 176 - 0
src/main/java/top/lvzhiqiang/service/impl/SubscribeServiceImpl.java

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

+ 132 - 0
src/main/resources/clash-template.yml

@@ -0,0 +1,132 @@
+# 基础配置
+# 混合端口建议不写或写通用值,Clash Verge 会以 GUI 设置为准覆盖它
+mixed-port: 7897
+allow-lan: false
+mode: rule
+log-level: info
+ipv6: true
+# 外部控制 (一般订阅不写,但为了兼容性保留默认)
+external-controller: 127.0.0.1:9090
+
+# --- 核心优化 1: 域名嗅探 (Mihomo/Meta 必备) ---
+# 解决没域名只有 IP 的情况,精准分流
+sniffer:
+  enable: true
+  force-dns-mapping: true
+  parse-pure-ip: true
+  override-destination: true
+  sniff:
+    HTTP:
+      ports: [ 80, 8080-8880 ]
+      override-destination: true
+    TLS:
+      ports: [ 443, 8443 ]
+      override-destination: true
+    QUIC:
+      ports: [ 443, 8443 ]
+      override-destination: true
+
+# --- 核心优化 2: 现代化的 DNS 配置 (Fake-IP 模式) ---
+dns:
+  enable: true
+  prefer-h3: true
+  listen: 0.0.0.0:53
+  ipv6: true
+  enhanced-mode: fake-ip
+  fake-ip-range: 198.18.0.1/16
+  fake-ip-filter:
+    - '*'
+    - '+.lan'
+    - '+.local'
+  # 默认 DNS (解析国外域名,和 rule-providers 更新)
+  nameserver:
+    - https://doh.pub/dns-query
+    - https://dns.alidns.com/dns-query
+  # 专门用于解析国外域名的 DNS (如果 nameserver 解析出来是国外 IP,会走这个,但 Fake-IP 下其实主要看分流规则)
+  fallback:
+    - https://1.1.1.1/dns-query
+    - https://8.8.8.8/dns-query
+  fallback-filter:
+    geoip: true
+    ipcidr:
+      - 240.0.0.0/4
+
+# --- 核心部分:
+
+# 节点列表
+proxies:
+
+# 策略组
+proxy-groups:
+
+# --- 核心优化 3: 规则集 (Rule Providers) ---
+# 引用 GitHub 上的成熟规则,不占用文件体积,自动更新
+rule-providers:
+  Reject:
+    type: http
+    behavior: domain
+    url: "https://cdn.jsdelivr.net/gh/Loyalsoldier/clash-rules@release/reject.txt"
+    path: ./ruleset/reject.yaml
+    interval: 86400
+
+  Apple:
+    type: http
+    behavior: domain
+    url: "https://cdn.jsdelivr.net/gh/Loyalsoldier/clash-rules@release/apple.txt"
+    path: ./ruleset/apple.yaml
+    interval: 86400
+
+  Google:
+    type: http
+    behavior: domain
+    url: "https://cdn.jsdelivr.net/gh/Loyalsoldier/clash-rules@release/google.txt"
+    path: ./ruleset/google.yaml
+    interval: 86400
+
+  Proxy:
+    type: http
+    behavior: domain
+    url: "https://cdn.jsdelivr.net/gh/Loyalsoldier/clash-rules@release/proxy.txt"
+    path: ./ruleset/proxy.yaml
+    interval: 86400
+
+  Direct:
+    type: http
+    behavior: domain
+    url: "https://cdn.jsdelivr.net/gh/Loyalsoldier/clash-rules@release/direct.txt"
+    path: ./ruleset/direct.yaml
+    interval: 86400
+
+  CN:
+    type: http
+    behavior: domain
+    url: "https://cdn.jsdelivr.net/gh/Loyalsoldier/clash-rules@release/cn.txt"
+    path: ./ruleset/cn.yaml
+    interval: 86400
+
+  LAN:
+    type: http
+    behavior: classical
+    url: "https://cdn.jsdelivr.net/gh/Loyalsoldier/clash-rules@release/lancidr.txt"
+    path: ./ruleset/lancidr.yaml
+    interval: 86400
+
+# --- 规则链 ---
+# 从上到下匹配,逻辑清晰
+# 格式: RULE-SET, 规则集名称, 策略组名称
+rules:
+  # 1. 广告拦截
+  - RULE-SET,Reject,REJECT
+  # 2. 局域网直连
+  - RULE-SET,LAN,DIRECT
+  # 3. 国内直连
+  - RULE-SET,CN,DIRECT
+  - RULE-SET,Direct,DIRECT
+  - GEOIP,LAN,DIRECT
+  - GEOIP,CN,DIRECT
+  # 4. 走代理 (自动选择 或 指定组)
+  - RULE-SET,Apple,DIRECT
+  - RULE-SET,Google,手动选择
+  - RULE-SET,Proxy,手动选择
+  # 5. 兜底规则 (剩下的全部走代理)
+  - MATCH,手动选择