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()