feat(tracker): 完成客户端连接与 announce 接口逻辑,支持 Redis 存储 peers

- 编写 announce 接口核心逻辑,支持 info_hash / peer_id 等参数解析
- 完成客户端连接 Tracker 的逻辑对接
- 使用 Redis 存储活跃 peers 列表,初步完成数据结构设计
- 准备接入 .torrent 文件上传至 OSS
- 明日计划:测试本地 qBittorrent + 前端 UI 对接
Change-Id: Iff1678b6312a50c1ed5cbd50ad8b3da8cf030a90
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
new file mode 100644
index 0000000..f3bd624
--- /dev/null
+++ b/ruoyi-admin/src/main/java/com/ruoyi/announce/controller/AnnounceController.java
@@ -0,0 +1,61 @@
+package com.ruoyi.announce.controller;
+
+import com.ruoyi.common.core.controller.BaseController;
+import com.ruoyi.announce.service.IAnnounceService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+
+import java.net.URLDecoder;
+import java.nio.charset.StandardCharsets;
+import java.util.Map;
+
+@RestController
+public class AnnounceController extends BaseController {
+
+    @Autowired
+    private IAnnounceService announceService;
+
+    /**
+     * 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 信息)
+        Map<String, Object> reply = announceService.handleAnnounce(
+                infoHash, peerId, port, uploaded, downloaded, left, event, passkey,
+                request.getRemoteAddr()
+        );
+
+        // 3. bencode 编码并返回给客户端
+        response.setStatus(HttpServletResponse.SC_OK);
+        response.setHeader("Content-Type", "application/x-bittorrent");
+        try (var out = response.getOutputStream()) {
+            byte[] bencoded = announceService.encodeBencode(reply);
+            out.write(bencoded);
+            out.flush();
+        }
+    }
+}
diff --git a/ruoyi-admin/src/main/java/com/ruoyi/announce/service/IAnnounceService.java b/ruoyi-admin/src/main/java/com/ruoyi/announce/service/IAnnounceService.java
new file mode 100644
index 0000000..e6e7aa7
--- /dev/null
+++ b/ruoyi-admin/src/main/java/com/ruoyi/announce/service/IAnnounceService.java
@@ -0,0 +1,46 @@
+package com.ruoyi.announce.service;
+
+import java.io.IOException;
+import java.util.Map;
+
+/**
+ * BT Tracker announce 服务接口
+ */
+public interface IAnnounceService {
+
+    /**
+     * 处理一次 announce 请求:
+     * - 校验 passkey
+     * - 记录/刷新 peer 信息到 Redis
+     * - 从 Redis 随机挑选 peers
+     * - 构造待 bencode 的响应 Map
+     *
+     * @param infoHash  原始 20 字节 info_hash
+     * @param peerId    原始 20 字节 peer_id
+     * @param port      客户端监听端口
+     * @param uploaded  已上传字节数
+     * @param downloaded 已下载字节数
+     * @param left      剩余下载字节数
+     * @param event     事件(started/completed/stopped)
+     * @param passkey   用户的 passkey
+     * @param ip        客户端 IP
+     * @return 一个可以直接 bencode 的 Map<String,Object>
+     * @throws Exception 业务或编码异常
+     */
+    Map<String, Object> handleAnnounce(
+            byte[] infoHash,
+            byte[] peerId,
+            int port,
+            long uploaded,
+            long downloaded,
+            long left,
+            String event,
+            String passkey,
+            String ip
+    ) throws Exception;
+
+    /**
+     * 将一个 Map<String,Object> 编码成 bencode 二进制
+     */
+    byte[] encodeBencode(Map<String, Object> reply) throws IOException;
+}
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
new file mode 100644
index 0000000..f80050f
--- /dev/null
+++ b/ruoyi-admin/src/main/java/com/ruoyi/announce/service/impl/AnnounceServiceImpl.java
@@ -0,0 +1,132 @@
+package com.ruoyi.announce.service.impl;
+
+import com.ruoyi.announce.service.IAnnounceService;
+import com.ruoyi.announce.util.BencodeEncoder;
+import com.ruoyi.common.core.redis.RedisCache;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import java.io.IOException;
+import java.net.InetAddress;
+import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
+import java.util.*;
+import java.util.concurrent.TimeUnit;
+
+@Service
+public class AnnounceServiceImpl implements IAnnounceService {
+
+    private static final int ANNOUNCE_INTERVAL = 1800; // 秒
+
+    @Autowired
+    private RedisCache redisCache;
+
+    @Override
+    public Map<String, Object> handleAnnounce(
+            byte[] infoHash,
+            byte[] peerId,
+            int port,
+            long uploaded,
+            long downloaded,
+            long left,
+            String event,
+            String passkey,
+            String ip
+    ) throws Exception {
+        // 1. 转 hex 作为 Redis key 前缀
+        String infoHashHex = bytesToHex(infoHash);
+        String peerIdStr   = new String(peerId, StandardCharsets.ISO_8859_1);
+
+        // 2. 校验 passkey(可根据业务到 MySQL 查 userId,此处略过)
+
+        // 3. 在 Redis 中记录/刷新此 peer 的信息
+        //    使用 Hash 存储详情,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("downloaded", downloaded);
+        peerData.put("left",       left);
+        peerData.put("lastSeen",   System.currentTimeMillis());
+        redisCache.setCacheMap(peerKey, peerData);
+        redisCache.expire(peerKey, 120, TimeUnit.SECONDS);
+
+        // 4. 从 Redis 中扫描所有同 info_hash 的 peer
+        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);
+            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
+        }
+
+        // 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("incomplete", countLeechers(infoHashHex));
+        reply.put("peers",      peersBin);
+
+        return reply;
+    }
+
+    @Override
+    public byte[] encodeBencode(Map<String, Object> reply) throws IOException {
+        BencodeEncoder encoder = new BencodeEncoder();
+        return encoder.encodeBencode(reply);
+    }
+
+    // —— 辅助方法 —— //
+
+    /** 统计 left == 0 的 Seeder 数 */
+    private int countSeeders(String infoHashHex) {
+        int count = 0;
+        for (String key : redisCache.keys("peer:" + infoHashHex + ":*")) {
+            @SuppressWarnings("unchecked")
+            Map<String, Object> data = (Map<String, Object>) redisCache.getCacheMap(key);
+            long left = ((Number) data.get("left")).longValue();
+            if (left == 0) count++;
+        }
+        return count;
+    }
+
+    /** 统计 left > 0 的 Leecher 数 */
+    private int countLeechers(String infoHashHex) {
+        int count = 0;
+        for (String key : redisCache.keys("peer:" + infoHashHex + ":*")) {
+            @SuppressWarnings("unchecked")
+            Map<String, Object> data = (Map<String, Object>) redisCache.getCacheMap(key);
+            long left = ((Number) data.get("left")).longValue();
+            if (left > 0) count++;
+        }
+        return count;
+    }
+
+    /** 将 IPv4 + port 编码成 6 字节:4 字节 IP + 2 字节 port */
+    private byte[] encodePeer(String ip, int port) throws Exception {
+        String[] parts = ip.split("\\.");
+        ByteBuffer buf = ByteBuffer.allocate(6);
+        for (int i = 0; i < 4; i++) {
+            buf.put((byte) Integer.parseInt(parts[i]));
+        }
+        buf.putShort((short) port);
+        return buf.array();
+    }
+
+    /** 将字节数组转成十六进制字符串 */
+    private static final char[] HEX = "0123456789abcdef".toCharArray();
+    private String bytesToHex(byte[] bytes) {
+        char[] cs = new char[bytes.length * 2];
+        for (int i = 0; i < bytes.length; i++) {
+            int v = bytes[i] & 0xFF;
+            cs[i * 2]     = HEX[v >>> 4];
+            cs[i * 2 + 1] = HEX[v & 0x0F];
+        }
+        return new String(cs);
+    }
+}
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
new file mode 100644
index 0000000..a2cdb30
--- /dev/null
+++ b/ruoyi-admin/src/main/java/com/ruoyi/announce/util/BencodeEncoder.java
@@ -0,0 +1,61 @@
+package com.ruoyi.announce.util;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.util.Map;
+
+public class BencodeEncoder {
+
+    public byte[] encodeBencode(Map<String, Object> reply) throws IOException {
+        ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
+        encodeMap(reply, outputStream);
+        return outputStream.toByteArray();
+    }
+
+    private void encodeMap(Map<String, Object> map, ByteArrayOutputStream outputStream) throws IOException {
+        outputStream.write('d'); // Start of a dictionary
+
+        for (Map.Entry<String, Object> entry : map.entrySet()) {
+            encodeString(entry.getKey(), outputStream); // Encode key
+            encodeObject(entry.getValue(), outputStream); // Encode value
+        }
+
+        outputStream.write('e'); // End of a dictionary
+    }
+
+    private void encodeObject(Object obj, ByteArrayOutputStream outputStream) throws IOException {
+        if (obj instanceof Integer) {
+            encodeInteger((Integer) obj, outputStream);
+        } else if (obj instanceof String) {
+            encodeString((String) obj, outputStream);
+        } else if (obj instanceof Map) {
+            encodeMap((Map<String, Object>) obj, outputStream);
+        } else if (obj instanceof Iterable) {
+            encodeList((Iterable<Object>) obj, outputStream);
+        } else {
+            throw new IllegalArgumentException("Unsupported object type: " + obj.getClass());
+        }
+    }
+
+    private void encodeInteger(Integer value, ByteArrayOutputStream outputStream) throws IOException {
+        outputStream.write('i');
+        outputStream.write(value.toString().getBytes());
+        outputStream.write('e');
+    }
+
+    private void encodeString(String value, ByteArrayOutputStream outputStream) throws IOException {
+        outputStream.write(Integer.toString(value.length()).getBytes());
+        outputStream.write(':');
+        outputStream.write(value.getBytes());
+    }
+
+    private void encodeList(Iterable<Object> list, ByteArrayOutputStream outputStream) throws IOException {
+        outputStream.write('l'); // Start of a list
+
+        for (Object item : list) {
+            encodeObject(item, outputStream);
+        }
+
+        outputStream.write('e'); // End of a list
+    }
+}