feat(tracker): Redis 存储 peer 测试通过,支持跨客户端通信 & 心跳处理初步完成

- ✅ 实现并测试 Redis 中存储 peer 信息
- ✅ 成功验证 qBittorrent 做种,Transmission 本地下载互通
- ✅ 补全客户端定期心跳的接收与处理逻辑,确保活跃 peer 的状态维护
- 🧪 增加 announce 接口和 peer 存取的集成测试

BREAKING CHANGE:
- 实时 peer 列表现存于 Redis,移除原内存存储逻辑

后续计划:
- 补充做种停止时从 Redis 移除对应 key
- 定期将 Redis 中 peer 数据持久化入数据库
- 制定更精细的缓存更新策略,优化同步与清理效率

Change-Id: I9db447d04f74ba354fe4d62bff3fb8ed28e100c1
diff --git a/ruoyi-admin/src/main/java/com/ruoyi/announce/controller/AnnounceController.java b/ruoyi-admin/src/main/java/com/ruoyi/announce/controller/AnnounceController.java
index f3bd624..8fbb688 100644
--- a/ruoyi-admin/src/main/java/com/ruoyi/announce/controller/AnnounceController.java
+++ b/ruoyi-admin/src/main/java/com/ruoyi/announce/controller/AnnounceController.java
@@ -1,7 +1,10 @@
 package com.ruoyi.announce.controller;
 
+import com.fasterxml.jackson.databind.ObjectMapper;
 import com.ruoyi.common.core.controller.BaseController;
 import com.ruoyi.announce.service.IAnnounceService;
+import org.apache.commons.codec.DecoderException;
+import org.apache.commons.codec.net.URLCodec;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.web.bind.annotation.GetMapping;
 import org.springframework.web.bind.annotation.RequestParam;
@@ -10,52 +13,116 @@
 import jakarta.servlet.http.HttpServletRequest;
 import jakarta.servlet.http.HttpServletResponse;
 
+import java.io.ByteArrayOutputStream;
+import java.io.UnsupportedEncodingException;
 import java.net.URLDecoder;
 import java.nio.charset.StandardCharsets;
+import java.util.Base64;
 import java.util.Map;
+import java.util.stream.Collectors;
 
 @RestController
 public class AnnounceController extends BaseController {
+    // 在你的 Controller 中:
 
     @Autowired
     private IAnnounceService announceService;
 
+
+    private byte[] decodeInfoHashAndPeerId(String param) {
+        ByteArrayOutputStream bos = new ByteArrayOutputStream();
+        for (int i = 0; i < param.length(); ) {
+            char c = param.charAt(i);
+            if (c == '%' && i + 2 < param.length()) {
+                char hi = param.charAt(i + 1);
+                char lo = param.charAt(i + 2);
+                if (isHexDigit(hi) && isHexDigit(lo)) {
+                    int value = Character.digit(hi, 16) << 4 | Character.digit(lo, 16);
+                    bos.write(value);
+                    i += 3;
+                    continue;
+                }
+            }
+            // 不是合法的 %xx,直接跳过或处理为异常字符
+            bos.write((byte) c); // 或者 throw new IllegalArgumentException(...)
+            i++;
+        }
+        return bos.toByteArray();
+    }
+
+    private boolean isHexDigit(char c) {
+        return (c >= '0' && c <= '9') ||
+                (c >= 'A' && c <= 'F') ||
+                (c >= 'a' && c <= 'f');
+    }
+
+
     /**
      * BT Tracker /announce 接口
      * 接收客户端(qBittorrent 等)发来的 announce 请求,返回 bencoded peers 列表
      */
-    @GetMapping(value = "/announce", produces = "application/x-bittorrent")
-    public void announce(
-            HttpServletRequest request,
-            HttpServletResponse response,
-            @RequestParam("info_hash") String infoHashParam,
-            @RequestParam("peer_id")   String peerIdParam,
-            @RequestParam("port")      int port,
-            @RequestParam("uploaded")  long uploaded,
-            @RequestParam("downloaded") long downloaded,
-            @RequestParam("left")      long left,
-            @RequestParam(value = "event",    required = false) String event,
-            @RequestParam("passkey")   String passkey
-    ) throws Exception {
-        // 1. URL Decode 得到原始二进制
-        byte[] infoHash = URLDecoder.decode(infoHashParam, StandardCharsets.ISO_8859_1.name())
-                .getBytes(StandardCharsets.ISO_8859_1);
-        byte[] peerId   = URLDecoder.decode(peerIdParam,   StandardCharsets.ISO_8859_1.name())
-                .getBytes(StandardCharsets.ISO_8859_1);
 
-        // 2. 处理 announce 请求(验证 passkey,更新 peer 列表,获取 peers 信息)
+    @GetMapping(value = "/announce", produces = "application/x-bittorrent")
+    public void announce(HttpServletRequest request, HttpServletResponse response) throws Exception {
+
+        // —— 4. 获取参数(不使用 @RequestParam) ——
+        String infoHashParam = request.getParameter("info_hash");
+        String peerIdParam   = request.getParameter("peer_id");
+        String portStr       = request.getParameter("port");
+        String uploadedStr   = request.getParameter("uploaded");
+        String downloadedStr = request.getParameter("downloaded");
+        String leftStr       = request.getParameter("left");
+        String event         = request.getParameter("event");
+        String passkey       = request.getParameter("passkey");
+        // 打印接收到的参数
+        System.out.println("Received announce request:");
+        System.out.println("  info_hash: " + infoHashParam);
+        System.out.println("  peer_id:   " + peerIdParam);
+        System.out.println("  port:      " + portStr);
+        System.out.println("  uploaded:  " + uploadedStr);
+        System.out.println("  downloaded:" + downloadedStr);
+        System.out.println("  left:      " + leftStr);
+        System.out.println("  event:     " + event);
+        System.out.println("  passkey:   " + passkey);
+        System.out.println("  IP:        " + request.getRemoteAddr());
+        // —— 校验是否有必要参数为空 ——
+        if (infoHashParam == null || peerIdParam == null || portStr == null ||
+                uploadedStr == null || downloadedStr == null || leftStr == null) {
+            System.out.println("参数缺失,终止处理。");
+            response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
+            response.setContentType("text/plain");
+            response.getWriter().write("Missing required announce parameters.");
+            return;
+        }
+
+
+
+        // —— 5. 转换参数类型 ——
+        int port = Integer.parseInt(portStr);
+        long uploaded = Long.parseLong(uploadedStr);
+        long downloaded = Long.parseLong(downloadedStr);
+        long left = Long.parseLong(leftStr);
+
+        // 确保 URL 解码不会失败,使用更健壮的方法
+        byte[] infoHash = decodeInfoHashAndPeerId(infoHashParam);
+        byte[] peerId   = decodeInfoHashAndPeerId(peerIdParam);
+
+        // —— 7. 调用业务逻辑处理 ——
         Map<String, Object> reply = announceService.handleAnnounce(
                 infoHash, peerId, port, uploaded, downloaded, left, event, passkey,
                 request.getRemoteAddr()
         );
 
-        // 3. bencode 编码并返回给客户端
+        // —— 8. 返回 Bencode 编码的 tracker 响应 ——
         response.setStatus(HttpServletResponse.SC_OK);
         response.setHeader("Content-Type", "application/x-bittorrent");
         try (var out = response.getOutputStream()) {
             byte[] bencoded = announceService.encodeBencode(reply);
+            System.out.println(bencoded);
+
             out.write(bencoded);
             out.flush();
         }
     }
+
 }
diff --git a/ruoyi-admin/src/main/java/com/ruoyi/announce/service/impl/AnnounceServiceImpl.java b/ruoyi-admin/src/main/java/com/ruoyi/announce/service/impl/AnnounceServiceImpl.java
index f80050f..4408fa1 100644
--- a/ruoyi-admin/src/main/java/com/ruoyi/announce/service/impl/AnnounceServiceImpl.java
+++ b/ruoyi-admin/src/main/java/com/ruoyi/announce/service/impl/AnnounceServiceImpl.java
@@ -6,6 +6,7 @@
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.stereotype.Service;
 
+import java.io.ByteArrayOutputStream;
 import java.io.IOException;
 import java.net.InetAddress;
 import java.nio.ByteBuffer;
@@ -35,46 +36,72 @@
     ) throws Exception {
         // 1. 转 hex 作为 Redis key 前缀
         String infoHashHex = bytesToHex(infoHash);
-        String peerIdStr   = new String(peerId, StandardCharsets.ISO_8859_1);
+        String peerIdStr = new String(peerId, StandardCharsets.ISO_8859_1);
 
         // 2. 校验 passkey(可根据业务到 MySQL 查 userId,此处略过)
 
-        // 3. 在 Redis 中记录/刷新此 peer 的信息
-        //    使用 Hash 存储详情,TTL 120 秒
+        // 3. 在 Redis 中记录/刷新此 peer 的信息(TTL 120 秒)
         String peerKey = "peer:" + infoHashHex + ":" + peerIdStr;
         Map<String, Object> peerData = new HashMap<>();
-        peerData.put("ip",         ip);
-        peerData.put("port",       port);
-        peerData.put("uploaded",   uploaded);
+        peerData.put("ip", ip);
+        peerData.put("port", port);
+        peerData.put("uploaded", uploaded);
         peerData.put("downloaded", downloaded);
-        peerData.put("left",       left);
-        peerData.put("lastSeen",   System.currentTimeMillis());
+        peerData.put("left", left);
+        peerData.put("lastSeen", System.currentTimeMillis());
         redisCache.setCacheMap(peerKey, peerData);
-        redisCache.expire(peerKey, 120, TimeUnit.SECONDS);
+        redisCache.expire(peerKey, 1200, TimeUnit.SECONDS);
 
-        // 4. 从 Redis 中扫描所有同 info_hash 的 peer
+        // 4. 收集 peers,根据自身状态区分返回哪些 peer
+        boolean isSeeder = left == 0;
         Collection<String> keys = redisCache.keys("peer:" + infoHashHex + ":*");
         List<byte[]> peersBin = new ArrayList<>(keys.size());
+
         for (String key : keys) {
             @SuppressWarnings("unchecked")
             Map<String, Object> data = (Map<String, Object>) redisCache.getCacheMap(key);
+            if (data == null) continue;
+
+            String otherPeerId = key.substring(key.lastIndexOf(":") + 1);
+            if (otherPeerId.equals(peerIdStr)) continue; // 忽略自己
+
             String peerIp = (String) data.get("ip");
-            int    peerPort = ((Number) data.get("port")).intValue();
-            peersBin.add(encodePeer(peerIp, peerPort));
-            if (peersBin.size() >= 50) break;  // 最多返回 50 个 peers
+            int peerPort = ((Number) data.get("port")).intValue();
+            long otherLeft = ((Number) data.get("left")).longValue();
+
+            if (peerIp.contains(":")) continue; // 跳过 IPv6
+
+            // 按条件过滤
+            if (isSeeder && otherLeft > 0) {
+                peersBin.add(encodePeer(peerIp, peerPort));
+            } else if (!isSeeder) {
+                peersBin.add(encodePeer(peerIp, peerPort));
+            }
+
+            if (peersBin.size() >= 50) break;
         }
 
-        // 5. 构造返回数据 Map(有序)
+        // 4.5 合并 peers 到 compact 格式
+        ByteArrayOutputStream peerStream = new ByteArrayOutputStream();
+        for (byte[] peer : peersBin) {
+            if (peer != null && peer.length == 6) {
+                peerStream.write(peer);
+            }
+        }
+        byte[] compactPeers = peerStream.toByteArray();
+
+        // 5. 构造返回 Map
         Map<String, Object> reply = new LinkedHashMap<>();
         reply.put("interval", ANNOUNCE_INTERVAL);
         reply.put("min interval", ANNOUNCE_INTERVAL / 2);
-        reply.put("complete",   countSeeders(infoHashHex));
+        reply.put("complete", countSeeders(infoHashHex));
         reply.put("incomplete", countLeechers(infoHashHex));
-        reply.put("peers",      peersBin);
+        reply.put("peers", compactPeers);
 
         return reply;
     }
 
+
     @Override
     public byte[] encodeBencode(Map<String, Object> reply) throws IOException {
         BencodeEncoder encoder = new BencodeEncoder();
@@ -107,17 +134,22 @@
         return count;
     }
 
-    /** 将 IPv4 + port 编码成 6 字节:4 字节 IP + 2 字节 port */
     private byte[] encodePeer(String ip, int port) throws Exception {
+        if (ip.contains(":")) return null; // 跳过 IPv6
+
         String[] parts = ip.split("\\.");
+        if (parts.length != 4) throw new IllegalArgumentException("无效的 IPv4 地址: " + ip);
+
         ByteBuffer buf = ByteBuffer.allocate(6);
-        for (int i = 0; i < 4; i++) {
-            buf.put((byte) Integer.parseInt(parts[i]));
+        for (String part : parts) {
+            buf.put((byte) Integer.parseInt(part));
         }
         buf.putShort((short) port);
         return buf.array();
     }
 
+
+
     /** 将字节数组转成十六进制字符串 */
     private static final char[] HEX = "0123456789abcdef".toCharArray();
     private String bytesToHex(byte[] bytes) {
diff --git a/ruoyi-admin/src/main/java/com/ruoyi/announce/util/BencodeEncoder.java b/ruoyi-admin/src/main/java/com/ruoyi/announce/util/BencodeEncoder.java
index a2cdb30..c937042 100644
--- a/ruoyi-admin/src/main/java/com/ruoyi/announce/util/BencodeEncoder.java
+++ b/ruoyi-admin/src/main/java/com/ruoyi/announce/util/BencodeEncoder.java
@@ -2,6 +2,7 @@
 
 import java.io.ByteArrayOutputStream;
 import java.io.IOException;
+import java.nio.charset.StandardCharsets;
 import java.util.Map;
 
 public class BencodeEncoder {
@@ -32,11 +33,20 @@
             encodeMap((Map<String, Object>) obj, outputStream);
         } else if (obj instanceof Iterable) {
             encodeList((Iterable<Object>) obj, outputStream);
+        } else if (obj instanceof byte[]) {
+            encodeByteArray((byte[]) obj, outputStream);
         } else {
             throw new IllegalArgumentException("Unsupported object type: " + obj.getClass());
         }
     }
 
+    private void encodeByteArray(byte[] bytes, ByteArrayOutputStream outputStream) throws IOException {
+        outputStream.write(Integer.toString(bytes.length).getBytes());
+        outputStream.write(':');
+        outputStream.write(bytes);
+    }
+
+
     private void encodeInteger(Integer value, ByteArrayOutputStream outputStream) throws IOException {
         outputStream.write('i');
         outputStream.write(value.toString().getBytes());
@@ -44,11 +54,13 @@
     }
 
     private void encodeString(String value, ByteArrayOutputStream outputStream) throws IOException {
-        outputStream.write(Integer.toString(value.length()).getBytes());
+        byte[] bytes = value.getBytes(StandardCharsets.UTF_8); // Assuming UTF-8 encoding
+        outputStream.write(Integer.toString(bytes.length).getBytes());
         outputStream.write(':');
-        outputStream.write(value.getBytes());
+        outputStream.write(bytes);
     }
 
+
     private void encodeList(Iterable<Object> list, ByteArrayOutputStream outputStream) throws IOException {
         outputStream.write('l'); // Start of a list
 
diff --git a/ruoyi-admin/src/main/resources/application.yml b/ruoyi-admin/src/main/resources/application.yml
index cb1a87c..c86c6b3 100644
--- a/ruoyi-admin/src/main/resources/application.yml
+++ b/ruoyi-admin/src/main/resources/application.yml
@@ -9,7 +9,7 @@
   # 文件路径 示例( Windows配置D:/ruoyi/uploadPath,Linux配置 /home/ruoyi/uploadPath)
   profile: D:/ruoyi/uploadPath
   # 获取ip地址开关
-  addressEnabled: false
+  addressEnabled: true
   # 验证码类型 math 数字计算 char 字符验证
   captchaType: math
 
diff --git a/ruoyi-framework/src/main/java/com/ruoyi/framework/config/SecurityConfig.java b/ruoyi-framework/src/main/java/com/ruoyi/framework/config/SecurityConfig.java
index 330039f..378c8eb 100644
--- a/ruoyi-framework/src/main/java/com/ruoyi/framework/config/SecurityConfig.java
+++ b/ruoyi-framework/src/main/java/com/ruoyi/framework/config/SecurityConfig.java
@@ -111,7 +111,7 @@
             .authorizeHttpRequests((requests) -> {
                 permitAllUrl.getUrls().forEach(url -> requests.requestMatchers(url).permitAll());
                 // 对于登录login 注册register 验证码captchaImage 允许匿名访问
-                requests.requestMatchers("/login", "/register", "/captchaImage").permitAll()
+                requests.requestMatchers("/login", "/register", "/captchaImage","/announce").permitAll()
                     // 静态资源,可匿名访问
                     .requestMatchers(HttpMethod.GET, "/", "/*.html", "/**.html", "/**.css", "/**.js", "/profile/**").permitAll()
                     .requestMatchers("/swagger-ui.html", "/v3/api-docs/**", "/swagger-ui/**", "/druid/**").permitAll()