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