修复订阅快照过期误判,并加固下载解析与完成通知
过期判定(真实缺陷): - 节点原先按内容里的 generatedAt 判过期,而 revision 是内容寻址的。内容长期不变时 主站每分钟重发同一 revision 会被判为 APPLY_OLD 直接丢弃,新鲜度永远停在首次接收 那天——于是主站明明在线且持续同步,备机仍在第 7 天开始返回 503。 - 改为按「最近一次成功接收并校验通过的主站快照时刻」判定:同 revision 重发会刷新 新鲜度;重启时以落盘内容的生成时间作为起点,避免重启即续期。 - 该时刻取自节点本地时钟、不参与签名,重放旧 revision 无法续期。 下载 HTTP 服务: - 请求行改按 ISO-8859-1 显式解码(原依赖平台默认字符集)。 - 查询串与键值拆分的 split 加 limit=2:参数值含 '='(如 base64 padding)不再被截断。 - sendFileRange 缓冲 1 KiB -> 64 KiB,降低大包下载的系统调用开销。 队列上报与通知: - server/node 通道加 volatile;主站未连接或通道失效时跳过状态上报,不再以 NPE 形式 被外层 catch 吞掉后每 5 秒刷一条 error(队列保留,等重连重放)。 - 无待上报任务时不再发送空数组。 - 已下载完成的通知移出 addToQueue 的持锁路径并改为异步,避免同步 HTTP 卡住节点主锁; 且仅生产构造启用,测试不外呼。 已下载完成的重复校验: - 归档校验结果按「路径 + 大小」缓存(大小变化或超一小时才重算),避免重连时把所有 未完成任务整包读取重算 CRC。 测试:13 项全过(新增 MultiThreadedHTTPServerTest 5 项;SubscriptionSnapshotStoreTest 增加「同 revision 重发续期」「超期未收到推送才过期」;DownloadCheckServiceTest 增加 有效归档跨重复入队判定一致)。
This commit is contained in:
@@ -79,7 +79,8 @@ public class CustomUtil {
|
|||||||
|
|
||||||
try (RandomAccessFile randomAccessFile = new RandomAccessFile(file, "r")) {
|
try (RandomAccessFile randomAccessFile = new RandomAccessFile(file, "r")) {
|
||||||
randomAccessFile.seek(startByte);
|
randomAccessFile.seek(startByte);
|
||||||
byte[] buffer = new byte[1024];
|
// 1 KiB 每次系统调用太小,大包下载时开销明显;64 KiB 是常见的折中值。
|
||||||
|
byte[] buffer = new byte[64 * 1024];
|
||||||
int bytesRead;
|
int bytesRead;
|
||||||
long bytesRemaining = endByte - startByte + 1;
|
long bytesRemaining = endByte - startByte + 1;
|
||||||
while (bytesRemaining > 0 && (bytesRead = randomAccessFile.read(buffer, 0, (int) Math.min(buffer.length, bytesRemaining))) != -1) {
|
while (bytesRemaining > 0 && (bytesRead = randomAccessFile.read(buffer, 0, (int) Math.min(buffer.length, bytesRemaining))) != -1) {
|
||||||
|
|||||||
@@ -44,7 +44,8 @@ public class MultiThreadedHTTPServer {
|
|||||||
private static void handleClientRequest(Socket clientSocket) {
|
private static void handleClientRequest(Socket clientSocket) {
|
||||||
String fileName = "";
|
String fileName = "";
|
||||||
try {
|
try {
|
||||||
BufferedReader requestReader = new BufferedReader(new InputStreamReader(clientSocket.getInputStream()));
|
BufferedReader requestReader = new BufferedReader(
|
||||||
|
new InputStreamReader(clientSocket.getInputStream(), StandardCharsets.ISO_8859_1));
|
||||||
String requestLine = requestReader.readLine();
|
String requestLine = requestReader.readLine();
|
||||||
|
|
||||||
if (requestLine == null) {
|
if (requestLine == null) {
|
||||||
@@ -154,7 +155,7 @@ public class MultiThreadedHTTPServer {
|
|||||||
return null;
|
return null;
|
||||||
|
|
||||||
if (requestLine.contains("?")) {
|
if (requestLine.contains("?")) {
|
||||||
String[] requestParts = requestLine.split("\\?");
|
String[] requestParts = requestLine.split("\\?", 2);
|
||||||
String path = requestParts[0];
|
String path = requestParts[0];
|
||||||
queryParams.put("path", path);
|
queryParams.put("path", path);
|
||||||
|
|
||||||
@@ -164,7 +165,8 @@ public class MultiThreadedHTTPServer {
|
|||||||
|
|
||||||
// Split the query string into individual parameter key-value pairs
|
// Split the query string into individual parameter key-value pairs
|
||||||
for (String paramPair : paramPairs) {
|
for (String paramPair : paramPairs) {
|
||||||
String[] keyValue = paramPair.split("=");
|
// limit=2:参数值里含有 '='(例如 base64 结尾的 padding)时不能被截断。
|
||||||
|
String[] keyValue = paramPair.split("=", 2);
|
||||||
if (keyValue.length == 2) {
|
if (keyValue.length == 2) {
|
||||||
String key = URLDecoder.decode(keyValue[0], StandardCharsets.UTF_8);
|
String key = URLDecoder.decode(keyValue[0], StandardCharsets.UTF_8);
|
||||||
String value = URLDecoder.decode(keyValue[1], StandardCharsets.UTF_8);
|
String value = URLDecoder.decode(keyValue[1], StandardCharsets.UTF_8);
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import lombok.extern.slf4j.Slf4j;
|
|||||||
|
|
||||||
import java.io.*;
|
import java.io.*;
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
|
import java.util.concurrent.ExecutorService;
|
||||||
|
import java.util.concurrent.Executors;
|
||||||
import java.util.concurrent.ScheduledThreadPoolExecutor;
|
import java.util.concurrent.ScheduledThreadPoolExecutor;
|
||||||
import java.util.concurrent.TimeUnit;
|
import java.util.concurrent.TimeUnit;
|
||||||
import java.nio.file.*;
|
import java.nio.file.*;
|
||||||
@@ -30,6 +32,31 @@ public class DownloadCheckService {
|
|||||||
|
|
||||||
final Map<Integer, Long> retryAfter = new ConcurrentHashMap<>();
|
final Map<Integer, Long> retryAfter = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 已校验通过的归档缓存:路径 → (归档大小, 校验时刻)。
|
||||||
|
*
|
||||||
|
* <p>归档一旦落盘就不再变化(发布用 ATOMIC_MOVE),因此同一路径只有在大小变化时
|
||||||
|
* 才需要重新校验。没有它时,每次 {@link #addToQueue} 都会把候选 ZIP 整包读取并
|
||||||
|
* 重算 CRC——主站重连触发 resetUndone 时会对所有未完成任务走一遍,纯属浪费磁盘 I/O。
|
||||||
|
*/
|
||||||
|
final Map<String, VerifiedArchive> verifiedArchives = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
private static final long ARCHIVE_REVERIFY_MILLIS = 3_600_000L;
|
||||||
|
|
||||||
|
private record VerifiedArchive(long size, long verifiedAt) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 完成通知的专用线程。
|
||||||
|
*
|
||||||
|
* <p>{@link CustomUtil#notifyMe} 是同步 HTTP,原先在 {@link #addToQueue} 里直接调用,
|
||||||
|
* 而 addToQueue 由 Netty IO 线程持节点主锁执行,一次慢请求就会把整个节点的状态上报
|
||||||
|
* 与删除处理一起卡住。改为异步投递后,外呼延迟不再占用锁。
|
||||||
|
*/
|
||||||
|
private final ExecutorService notificationExecutor;
|
||||||
|
|
||||||
|
/** 是否发送完成通知;仅生产构造(启动调度器)时启用,单元测试保持静默不外呼。 */
|
||||||
|
private final boolean notificationsEnabled;
|
||||||
|
|
||||||
public DownloadCheckService(Map<Integer, GalleryTask> queue){
|
public DownloadCheckService(Map<Integer, GalleryTask> queue){
|
||||||
this(queue, true);
|
this(queue, true);
|
||||||
}
|
}
|
||||||
@@ -38,6 +65,12 @@ public class DownloadCheckService {
|
|||||||
DownloadCheckService(Map<Integer, GalleryTask> queue, boolean startScheduler){
|
DownloadCheckService(Map<Integer, GalleryTask> queue, boolean startScheduler){
|
||||||
this.queue = queue;
|
this.queue = queue;
|
||||||
compress_queue = new ArrayList<>(0);
|
compress_queue = new ArrayList<>(0);
|
||||||
|
this.notificationsEnabled = startScheduler;
|
||||||
|
this.notificationExecutor = Executors.newSingleThreadExecutor(r -> {
|
||||||
|
Thread thread = new Thread(r, "storage-node-notify");
|
||||||
|
thread.setDaemon(true);
|
||||||
|
return thread;
|
||||||
|
});
|
||||||
if (startScheduler) {
|
if (startScheduler) {
|
||||||
convert_thread = new ScheduledThreadPoolExecutor(1);
|
convert_thread = new ScheduledThreadPoolExecutor(1);
|
||||||
convert_thread.scheduleWithFixedDelay(this::compress, 0, 5, TimeUnit.SECONDS);
|
convert_thread.scheduleWithFixedDelay(this::compress, 0, 5, TimeUnit.SECONDS);
|
||||||
@@ -199,7 +232,7 @@ public class DownloadCheckService {
|
|||||||
queue.remove(galleryTask.getGid());
|
queue.remove(galleryTask.getGid());
|
||||||
galleryTask.setName(storedDirectory.getName());
|
galleryTask.setName(storedDirectory.getName());
|
||||||
galleryTask.setStatus(GalleryTask.COMPRESS_COMPLETE);
|
galleryTask.setStatus(GalleryTask.COMPRESS_COMPLETE);
|
||||||
CustomUtil.notifyMe(String.format("任务:%s在添加时已下载完成,更新任务状态", galleryTask.getName()));
|
notifyAlreadyStored(galleryTask.getName());
|
||||||
return galleryTask;
|
return galleryTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -242,12 +275,39 @@ public class DownloadCheckService {
|
|||||||
|
|
||||||
for(File directory : directories){
|
for(File directory : directories){
|
||||||
if(matchesGid(directory.getName(), gid)
|
if(matchesGid(directory.getName(), gid)
|
||||||
&& isValidArchive(new File(directory, directory.getName() + ".zip")))
|
&& isVerifiedArchive(new File(directory, directory.getName() + ".zip")))
|
||||||
return directory;
|
return directory;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 压缩包校验带缓存:归档按大小不变,校验结果可复用。
|
||||||
|
* 大小变化(重新压缩)或距上次校验超过一小时才重算,避免重复整包读取。
|
||||||
|
*/
|
||||||
|
private boolean isVerifiedArchive(File archive){
|
||||||
|
if(!archive.isFile())
|
||||||
|
return false;
|
||||||
|
long size = archive.length();
|
||||||
|
long now = System.currentTimeMillis();
|
||||||
|
VerifiedArchive cached = verifiedArchives.get(archive.getPath());
|
||||||
|
if(cached != null && cached.size() == size && now - cached.verifiedAt() < ARCHIVE_REVERIFY_MILLIS)
|
||||||
|
return true;
|
||||||
|
if(!isValidArchive(archive)){
|
||||||
|
verifiedArchives.remove(archive.getPath());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
verifiedArchives.put(archive.getPath(), new VerifiedArchive(size, now));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void notifyAlreadyStored(String taskName){
|
||||||
|
if(!notificationsEnabled)
|
||||||
|
return;
|
||||||
|
notificationExecutor.execute(() ->
|
||||||
|
CustomUtil.notifyMe(String.format("任务:%s在添加时已下载完成,更新任务状态", taskName)));
|
||||||
|
}
|
||||||
|
|
||||||
private File findDirectoryByGid(File parentDirectory, int gid){
|
private File findDirectoryByGid(File parentDirectory, int gid){
|
||||||
File[] directories = parentDirectory.listFiles(File::isDirectory);
|
File[] directories = parentDirectory.listFiles(File::isDirectory);
|
||||||
if(directories == null)
|
if(directories == null)
|
||||||
|
|||||||
@@ -38,6 +38,18 @@ public final class SubscriptionSnapshotStore {
|
|||||||
private final int maxPayloadBytes;
|
private final int maxPayloadBytes;
|
||||||
private final ObjectMapper objectMapper = CustomUtil.objectMapper;
|
private final ObjectMapper objectMapper = CustomUtil.objectMapper;
|
||||||
private final AtomicReference<Snapshot> current = new AtomicReference<>();
|
private final AtomicReference<Snapshot> current = new AtomicReference<>();
|
||||||
|
/**
|
||||||
|
* 最近一次收到并校验通过的主站快照的时刻(本地接收时间)。
|
||||||
|
*
|
||||||
|
* <p>过期判定必须基于它而不是内容里的 {@code generatedAt}:revision 是内容寻址的,
|
||||||
|
* 内容不变时主站每分钟重发同一 revision、{@code generatedAt} 却一直在刷新,
|
||||||
|
* 节点按 APPLY_OLD 丢弃后新鲜度永远停在首次接收那天——于是主站明明在线且持续同步,
|
||||||
|
* 备机也会在第 7 天被判过期并开始返回 503。改用本地接收时间后,「过期」才真正
|
||||||
|
* 表示「主站已失联」,与设计文档一致。
|
||||||
|
*
|
||||||
|
* <p>接收时间取自节点本地时钟、不参与签名,因此攻击者无法通过重放旧 revision 续期。
|
||||||
|
*/
|
||||||
|
private volatile long lastSyncAt;
|
||||||
|
|
||||||
public SubscriptionSnapshotStore(Path root, String syncSecret, long maxStaleSeconds, int maxPayloadBytes) {
|
public SubscriptionSnapshotStore(Path root, String syncSecret, long maxStaleSeconds, int maxPayloadBytes) {
|
||||||
this.root = Objects.requireNonNull(root);
|
this.root = Objects.requireNonNull(root);
|
||||||
@@ -72,6 +84,9 @@ public final class SubscriptionSnapshotStore {
|
|||||||
try {
|
try {
|
||||||
Snapshot snapshot = loadSnapshot(root.resolve("snapshots").resolve(revision));
|
Snapshot snapshot = loadSnapshot(root.resolve("snapshots").resolve(revision));
|
||||||
current.set(snapshot);
|
current.set(snapshot);
|
||||||
|
// 重启后还没有收到过主站快照,先以落盘内容的生成时间作为新鲜度起点,
|
||||||
|
// 否则重启即视为「刚同步过」,会让超期快照被错误续期。
|
||||||
|
lastSyncAt = snapshot.generatedAt();
|
||||||
writePointer(revision);
|
writePointer(revision);
|
||||||
log.info("加载订阅快照成功 revision={} accounts={} bindings={}", shortRevision(revision), snapshot.accountCount(), snapshot.bindingCount());
|
log.info("加载订阅快照成功 revision={} accounts={} bindings={}", shortRevision(revision), snapshot.accountCount(), snapshot.bindingCount());
|
||||||
return;
|
return;
|
||||||
@@ -106,8 +121,11 @@ public final class SubscriptionSnapshotStore {
|
|||||||
SnapshotData data = validatePayload(payload);
|
SnapshotData data = validatePayload(payload);
|
||||||
Snapshot old = current.get();
|
Snapshot old = current.get();
|
||||||
if (old != null) {
|
if (old != null) {
|
||||||
if (message.getRevision().equals(old.revision()))
|
if (message.getRevision().equals(old.revision())) {
|
||||||
|
// 内容未变但主站仍在同步:刷新新鲜度,避免备机因「内容长期不变」而误判过期。
|
||||||
|
lastSyncAt = System.currentTimeMillis();
|
||||||
return new ApplyResult(APPLY_OLD, "revision 已存在");
|
return new ApplyResult(APPLY_OLD, "revision 已存在");
|
||||||
|
}
|
||||||
if (message.getGeneratedAt() < old.generatedAt())
|
if (message.getGeneratedAt() < old.generatedAt())
|
||||||
return new ApplyResult(APPLY_OLD, "快照时间早于当前版本");
|
return new ApplyResult(APPLY_OLD, "快照时间早于当前版本");
|
||||||
}
|
}
|
||||||
@@ -139,6 +157,7 @@ public final class SubscriptionSnapshotStore {
|
|||||||
Snapshot snapshot = new Snapshot(message.getRevision(), message.getGeneratedAt(), data.byKeyHash,
|
Snapshot snapshot = new Snapshot(message.getRevision(), message.getGeneratedAt(), data.byKeyHash,
|
||||||
data.accounts.size(), data.bindingCount);
|
data.accounts.size(), data.bindingCount);
|
||||||
current.set(snapshot);
|
current.set(snapshot);
|
||||||
|
lastSyncAt = System.currentTimeMillis();
|
||||||
cleanupOldSnapshots(message.getRevision());
|
cleanupOldSnapshots(message.getRevision());
|
||||||
return new ApplyResult(APPLY_SUCCESS, "同步成功");
|
return new ApplyResult(APPLY_SUCCESS, "同步成功");
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
@@ -162,7 +181,7 @@ public final class SubscriptionSnapshotStore {
|
|||||||
|
|
||||||
public Lookup lookup(String client, String publicKey) {
|
public Lookup lookup(String client, String publicKey) {
|
||||||
Snapshot snapshot = current.get();
|
Snapshot snapshot = current.get();
|
||||||
if (snapshot == null || snapshot.expired(System.currentTimeMillis(), maxStaleMillis))
|
if (snapshot == null || isStale(System.currentTimeMillis()))
|
||||||
return null;
|
return null;
|
||||||
AccountFiles account = snapshot.byKeyHash().get(sha256(publicKey.getBytes(StandardCharsets.UTF_8)));
|
AccountFiles account = snapshot.byKeyHash().get(sha256(publicKey.getBytes(StandardCharsets.UTF_8)));
|
||||||
if (account == null)
|
if (account == null)
|
||||||
@@ -174,9 +193,14 @@ public final class SubscriptionSnapshotStore {
|
|||||||
Snapshot snapshot = current.get();
|
Snapshot snapshot = current.get();
|
||||||
if (snapshot == null)
|
if (snapshot == null)
|
||||||
return new Status("unavailable", null, 0, 0, 0);
|
return new Status("unavailable", null, 0, 0, 0);
|
||||||
long age = Math.max(0, System.currentTimeMillis() - snapshot.generatedAt());
|
long now = System.currentTimeMillis();
|
||||||
boolean expired = snapshot.expired(System.currentTimeMillis(), maxStaleMillis);
|
long age = Math.max(0, now - lastSyncAt);
|
||||||
return new Status(expired ? "expired" : "ready", snapshot.revision(), snapshot.accountCount(), snapshot.bindingCount(), age);
|
return new Status(isStale(now) ? "expired" : "ready", snapshot.revision(), snapshot.accountCount(), snapshot.bindingCount(), age);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 距最近一次成功接收主站快照是否已超过最大有效期。 */
|
||||||
|
private boolean isStale(long now) {
|
||||||
|
return maxStaleMillis > 0 && now - lastSyncAt > maxStaleMillis;
|
||||||
}
|
}
|
||||||
|
|
||||||
private Snapshot loadSnapshot(Path directory) throws IOException {
|
private Snapshot loadSnapshot(Path directory) throws IOException {
|
||||||
@@ -323,7 +347,5 @@ public final class SubscriptionSnapshotStore {
|
|||||||
public record Status(String state, String revision, int accountCount, int bindingCount, long ageMillis) {}
|
public record Status(String state, String revision, int accountCount, int bindingCount, long ageMillis) {}
|
||||||
private record AccountFiles(Integer accountId, byte[] v2, byte[] clash) {}
|
private record AccountFiles(Integer accountId, byte[] v2, byte[] clash) {}
|
||||||
private record SnapshotData(Map<Integer, AccountFiles> accounts, Map<String, AccountFiles> byKeyHash, int bindingCount) {}
|
private record SnapshotData(Map<Integer, AccountFiles> accounts, Map<String, AccountFiles> byKeyHash, int bindingCount) {}
|
||||||
private record Snapshot(String revision, long generatedAt, Map<String, AccountFiles> byKeyHash, int accountCount, int bindingCount) {
|
private record Snapshot(String revision, long generatedAt, Map<String, AccountFiles> byKeyHash, int accountCount, int bindingCount) {}
|
||||||
boolean expired(long now, long maxAge) { return maxAge > 0 && now - generatedAt > maxAge; }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,9 +25,10 @@ import java.util.concurrent.locks.ReentrantLock;
|
|||||||
@Slf4j
|
@Slf4j
|
||||||
public class storageNode {
|
public class storageNode {
|
||||||
|
|
||||||
Channel server;
|
// 分别由 Netty IO 线程写入、由 5 秒定时线程读取,必须保证可见性。
|
||||||
|
volatile Channel server;
|
||||||
|
|
||||||
Channel node;
|
volatile Channel node;
|
||||||
|
|
||||||
DownloadCheckService downloadCheckService;
|
DownloadCheckService downloadCheckService;
|
||||||
|
|
||||||
@@ -122,13 +123,21 @@ public class storageNode {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
//主站未连接时不上报。原先直接 server.writeAndFlush 会在 server 为 null 时抛 NPE,
|
||||||
|
//被外层 catch 吞掉后每 5 秒刷一条 error;队列保留,等主站重连时重放。
|
||||||
|
Channel target = server;
|
||||||
|
if (target == null || !target.isActive())
|
||||||
|
return;
|
||||||
|
//没有待上报任务时不必发送空数组(此时 downloadCheck 为 false 且无压缩完成任务)。
|
||||||
|
if (queue.isEmpty())
|
||||||
|
return;
|
||||||
//发送
|
//发送
|
||||||
//上锁后再发送,避免出现发送完之后再下载完成
|
//上锁后再发送,避免出现发送完之后再下载完成
|
||||||
lock.lock();
|
lock.lock();
|
||||||
try {
|
try {
|
||||||
DownloadStatusMessage downloadStatusMessage = new DownloadStatusMessage();
|
DownloadStatusMessage downloadStatusMessage = new DownloadStatusMessage();
|
||||||
downloadStatusMessage.setGalleryTasks(queue.values().toArray(GalleryTask[]::new));
|
downloadStatusMessage.setGalleryTasks(queue.values().toArray(GalleryTask[]::new));
|
||||||
server.writeAndFlush(downloadStatusMessage);
|
target.writeAndFlush(downloadStatusMessage);
|
||||||
|
|
||||||
queue.entrySet().removeIf(entry -> entry.getValue().is_compress_complete());
|
queue.entrySet().removeIf(entry -> entry.getValue().is_compress_complete());
|
||||||
log.info("任务状态发送完成");
|
log.info("任务状态发送完成");
|
||||||
@@ -176,7 +185,9 @@ public class storageNode {
|
|||||||
GalleryTask currentTask = downloadCheckService.addToQueue(dpm.getGalleryTask());
|
GalleryTask currentTask = downloadCheckService.addToQueue(dpm.getGalleryTask());
|
||||||
DownloadStatusMessage downloadStatusMessage = new DownloadStatusMessage();
|
DownloadStatusMessage downloadStatusMessage = new DownloadStatusMessage();
|
||||||
downloadStatusMessage.setGalleryTasks(new GalleryTask[]{currentTask});
|
downloadStatusMessage.setGalleryTasks(new GalleryTask[]{currentTask});
|
||||||
server.writeAndFlush(downloadStatusMessage);
|
Channel target = server;
|
||||||
|
if (target != null && target.isActive())
|
||||||
|
target.writeAndFlush(downloadStatusMessage);
|
||||||
log.info(String.valueOf(queue));
|
log.info(String.valueOf(queue));
|
||||||
} finally {
|
} finally {
|
||||||
lock.unlock();
|
lock.unlock();
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package lion;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 下载 HTTP 服务的请求行解析。
|
||||||
|
* 这条链路只接受主站 IP 的请求,解析细节直接决定能否按 gid 找到正确的压缩包。
|
||||||
|
*/
|
||||||
|
class MultiThreadedHTTPServerTest {
|
||||||
|
|
||||||
|
/** 普通请求:path 与各查询参数都要被拆出来。 */
|
||||||
|
@Test
|
||||||
|
void parsesPathAndQueryParameters() {
|
||||||
|
Map<String, String> params = MultiThreadedHTTPServer.parseRequestLine(
|
||||||
|
"/download?AuthCode=abc&gid=12345");
|
||||||
|
|
||||||
|
assertEquals("/download", params.get("path"));
|
||||||
|
assertEquals("abc", params.get("AuthCode"));
|
||||||
|
assertEquals("12345", params.get("gid"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 无查询串时只回 path,且不应抛异常。 */
|
||||||
|
@Test
|
||||||
|
void parsesPathWithoutQueryString() {
|
||||||
|
Map<String, String> params = MultiThreadedHTTPServer.parseRequestLine("/archive/file.zip");
|
||||||
|
assertEquals("/archive/file.zip", params.get("path"));
|
||||||
|
assertNull(params.get("gid"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 参数值里含 '=' 时必须完整保留。
|
||||||
|
* 回归:split("=") 无 limit 会在第一个 '=' 处截断,
|
||||||
|
* base64 padding(结尾的 '=')等取值会被悄悄改短。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void keepsEqualsSignInsideParameterValue() {
|
||||||
|
Map<String, String> params = MultiThreadedHTTPServer.parseRequestLine(
|
||||||
|
"/download?AuthCode=alone&token=YWJjZA==");
|
||||||
|
|
||||||
|
assertEquals("YWJjZA==", params.get("token"), "值内的 '=' 不得被截断");
|
||||||
|
assertEquals("alone", params.get("AuthCode"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** URL 编码的值必须解码回原文。 */
|
||||||
|
@Test
|
||||||
|
void decodesPercentEncodedValues() {
|
||||||
|
Map<String, String> params = MultiThreadedHTTPServer.parseRequestLine(
|
||||||
|
"/download?AuthCode=alone&name=a%20b");
|
||||||
|
assertEquals("a b", params.get("name"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** null 请求行返回 null,交由调用方兜底。 */
|
||||||
|
@Test
|
||||||
|
void returnsNullForNullRequestLine() {
|
||||||
|
assertNull(MultiThreadedHTTPServer.parseRequestLine(null));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -48,4 +48,31 @@ class DownloadCheckServiceTest {
|
|||||||
task.setGid(123);
|
task.setGid(123);
|
||||||
assertFalse(service.addToQueue(task).is_compress_complete());
|
assertFalse(service.addToQueue(task).is_compress_complete());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 已有有效归档时必须判为完成,且重复添加(命中校验缓存)行为保持一致。
|
||||||
|
* 校验缓存按「路径 + 大小」复用,目的是避免每次 addToQueue 都把整包读一遍,
|
||||||
|
* 但它绝不能让有效归档被误判为未完成。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void validArchiveIsRecognizedAcrossRepeatedQueueAdds(@TempDir Path root) throws Exception {
|
||||||
|
Path stored = Files.createDirectories(root.resolve("stored/sample [123]"));
|
||||||
|
Path zip = stored.resolve("sample [123].zip");
|
||||||
|
try (var out = new java.util.zip.ZipOutputStream(Files.newOutputStream(zip))) {
|
||||||
|
out.putNextEntry(new java.util.zip.ZipEntry("1.jpg"));
|
||||||
|
out.write("image bytes".getBytes(java.nio.charset.StandardCharsets.UTF_8));
|
||||||
|
out.closeEntry();
|
||||||
|
}
|
||||||
|
var service = new DownloadCheckService(new ConcurrentHashMap<>(), false);
|
||||||
|
service.storagePath = root.resolve("stored").toString();
|
||||||
|
service.downloadPath = root.resolve("downloads").toString();
|
||||||
|
|
||||||
|
GalleryTask first = new GalleryTask();
|
||||||
|
first.setGid(123);
|
||||||
|
assertTrue(service.addToQueue(first).is_compress_complete(), "存在有效归档时应判为完成");
|
||||||
|
|
||||||
|
GalleryTask second = new GalleryTask();
|
||||||
|
second.setGid(123);
|
||||||
|
assertTrue(service.addToQueue(second).is_compress_complete(), "第二次应命中校验缓存且结论一致");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,6 +58,46 @@ class SubscriptionSnapshotStoreTest {
|
|||||||
assertArrayEquals("clash-content".getBytes(StandardCharsets.UTF_8), second.lookup("cat", "public-key-1").content());
|
assertArrayEquals("clash-content".getBytes(StandardCharsets.UTF_8), second.lookup("cat", "public-key-1").content());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 回归:节点曾按「内容里的 generatedAt」判过期,而 revision 是内容寻址的——
|
||||||
|
* 内容长期不变时主站每分钟重发同一 revision 会被判为 APPLY_OLD 直接丢弃,
|
||||||
|
* 新鲜度永远停在首次接收那天,于是主站在线且持续同步,备机仍在第 7 天开始 503。
|
||||||
|
* 现在过期只取决于「最近一次成功接收主站快照的时刻」。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void repeatedSameRevisionRefreshesFreshnessAndNeverExpires(@TempDir Path directory) throws Exception {
|
||||||
|
// 有效期 1 秒,便于在用例内观察「不续期会过期、续期后恢复」。
|
||||||
|
SubscriptionSnapshotStore store = new SubscriptionSnapshotStore(directory, SECRET, 1, 1024 * 1024);
|
||||||
|
SubscriptionSnapshotMessage message = message("public-key-1", "v2-content", "clash-content", System.currentTimeMillis());
|
||||||
|
|
||||||
|
assertEquals(SubscriptionSnapshotStore.APPLY_SUCCESS, store.apply(message).code());
|
||||||
|
assertEquals("ready", store.status().state());
|
||||||
|
|
||||||
|
// 超过有效期且期间没有任何推送 → 过期(证明判定确实生效,不是恒 ready)
|
||||||
|
Thread.sleep(1_200);
|
||||||
|
assertEquals("expired", store.status().state(), "超过有效期且无推送应判过期");
|
||||||
|
|
||||||
|
// 同 revision 重发:仍是 APPLY_OLD(内容幂等),但必须刷新新鲜度
|
||||||
|
assertEquals(SubscriptionSnapshotStore.APPLY_OLD, store.apply(message).code());
|
||||||
|
assertEquals("ready", store.status().state(), "重新收到同 revision 后应恢复可用");
|
||||||
|
assertNotNull(store.lookup("v2", "public-key-1"), "持续同步期间必须能取到订阅");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 超过有效期后仍未收到任何快照,才判过期并停止分发。 */
|
||||||
|
@Test
|
||||||
|
void expiresOnlyAfterNoSnapshotArrivesWithinMaxAge(@TempDir Path directory) throws Exception {
|
||||||
|
SubscriptionSnapshotStore store = new SubscriptionSnapshotStore(directory, SECRET, 3600, 1024 * 1024);
|
||||||
|
// 内容生成于 2 小时前,且重启后再没收到主站推送 → 超期。
|
||||||
|
SubscriptionSnapshotMessage message = message("public-key-1", "v2-content", "clash-content",
|
||||||
|
System.currentTimeMillis() - 7_200_000L);
|
||||||
|
assertEquals(SubscriptionSnapshotStore.APPLY_SUCCESS, store.apply(message).code());
|
||||||
|
|
||||||
|
SubscriptionSnapshotStore reloaded = new SubscriptionSnapshotStore(directory, SECRET, 3600, 1024 * 1024);
|
||||||
|
reloaded.load();
|
||||||
|
assertEquals("expired", reloaded.status().state(), "重启后未再收到快照,超期应判过期");
|
||||||
|
assertNull(reloaded.lookup("v2", "public-key-1"), "过期快照不得继续分发");
|
||||||
|
}
|
||||||
|
|
||||||
private SubscriptionSnapshotMessage message(String publicKey, String v2, String clash, long generatedAt) throws Exception {
|
private SubscriptionSnapshotMessage message(String publicKey, String v2, String clash, long generatedAt) throws Exception {
|
||||||
byte[] v2Bytes = v2.getBytes(StandardCharsets.UTF_8);
|
byte[] v2Bytes = v2.getBytes(StandardCharsets.UTF_8);
|
||||||
byte[] clashBytes = clash.getBytes(StandardCharsets.UTF_8);
|
byte[] clashBytes = clash.getBytes(StandardCharsets.UTF_8);
|
||||||
|
|||||||
Reference in New Issue
Block a user