Files
storageNode/src/test/java/lion/Service/SubscriptionSnapshotStoreTest.java
T
root ced322123a 修复订阅快照过期误判,并加固下载解析与完成通知
过期判定(真实缺陷):
- 节点原先按内容里的 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 增加
有效归档跨重复入队判定一致)。
2026-09-21 16:17:02 +08:00

145 lines
8.2 KiB
Java

package lion.Service;
import com.fasterxml.jackson.databind.ObjectMapper;
import lion.CustomUtil;
import lion.Message.Main.SubscriptionAccountSnapshot;
import lion.Message.Main.SubscriptionBindingSnapshot;
import lion.Message.Main.SubscriptionSnapshotMessage;
import lion.Message.Main.SubscriptionSnapshotPayload;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.io.ByteArrayOutputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.security.MessageDigest;
import java.util.Base64;
import java.util.HexFormat;
import java.util.zip.GZIPOutputStream;
import static org.junit.jupiter.api.Assertions.*;
class SubscriptionSnapshotStoreTest {
private static final String SECRET = "snapshot-test-secret";
private final ObjectMapper mapper = CustomUtil.objectMapper;
@Test
void appliesSnapshotAndServesByHashedPublicKey(@TempDir Path directory) throws Exception {
SubscriptionSnapshotStore store = new SubscriptionSnapshotStore(directory, SECRET, 3600, 1024 * 1024);
SubscriptionSnapshotMessage message = message("public-key-1", "v2-content", "clash-content", System.currentTimeMillis());
assertEquals(SubscriptionSnapshotStore.APPLY_SUCCESS, store.apply(message).code());
SubscriptionSnapshotStore.Lookup v2 = store.lookup("v2", "public-key-1");
SubscriptionSnapshotStore.Lookup clash = store.lookup("cat", "public-key-1");
assertArrayEquals("v2-content".getBytes(StandardCharsets.UTF_8), v2.content());
assertArrayEquals("clash-content".getBytes(StandardCharsets.UTF_8), clash.content());
assertEquals(SubscriptionSnapshotStore.APPLY_OLD, store.apply(message).code());
}
@Test
void rejectsTamperedPayloadAndKeepsPreviousSnapshot(@TempDir Path directory) throws Exception {
SubscriptionSnapshotStore store = new SubscriptionSnapshotStore(directory, SECRET, 3600, 1024 * 1024);
SubscriptionSnapshotMessage message = message("public-key-1", "v2-content", "clash-content", System.currentTimeMillis());
assertEquals(SubscriptionSnapshotStore.APPLY_SUCCESS, store.apply(message).code());
message.setPayloadBase64(Base64.getEncoder().encodeToString("tampered".getBytes(StandardCharsets.UTF_8)));
assertEquals(SubscriptionSnapshotStore.APPLY_INVALID, store.apply(message).code());
assertArrayEquals("v2-content".getBytes(StandardCharsets.UTF_8), store.lookup("v2", "public-key-1").content());
}
@Test
void loadsLastGoodSnapshotAfterRestart(@TempDir Path directory) throws Exception {
SubscriptionSnapshotMessage message = message("public-key-1", "v2-content", "clash-content", System.currentTimeMillis());
SubscriptionSnapshotStore first = new SubscriptionSnapshotStore(directory, SECRET, 3600, 1024 * 1024);
assertEquals(SubscriptionSnapshotStore.APPLY_SUCCESS, first.apply(message).code());
SubscriptionSnapshotStore second = new SubscriptionSnapshotStore(directory, SECRET, 3600, 1024 * 1024);
second.load();
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 {
byte[] v2Bytes = v2.getBytes(StandardCharsets.UTF_8);
byte[] clashBytes = clash.getBytes(StandardCharsets.UTF_8);
SubscriptionAccountSnapshot account = new SubscriptionAccountSnapshot();
account.setAccountId(1);
account.setEnabled(true);
account.setV2ContentBase64(Base64.getEncoder().encodeToString(v2Bytes));
account.setV2Sha256(sha256(v2Bytes));
account.setClashContentBase64(Base64.getEncoder().encodeToString(clashBytes));
account.setClashSha256(sha256(clashBytes));
SubscriptionBindingSnapshot binding = new SubscriptionBindingSnapshot();
binding.setPublicKeySha256(sha256(publicKey.getBytes(StandardCharsets.UTF_8)));
binding.setAccountId(1);
SubscriptionSnapshotPayload payload = new SubscriptionSnapshotPayload();
payload.setSchemaVersion(1);
payload.setAccounts(java.util.List.of(account));
payload.setBindings(java.util.List.of(binding));
byte[] json = mapper.writeValueAsBytes(payload);
byte[] compressed = gzip(json);
SubscriptionSnapshotMessage message = new SubscriptionSnapshotMessage();
message.setSchemaVersion(1);
message.setRevision(sha256(json));
message.setGeneratedAt(generatedAt);
message.setPayloadBase64(Base64.getEncoder().encodeToString(compressed));
message.setPayloadSha256(sha256(compressed));
String input = "1\n" + message.getRevision() + "\n" + generatedAt + "\n" + message.getPayloadSha256();
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(SECRET.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
message.setSignature(hex(mac.doFinal(input.getBytes(StandardCharsets.UTF_8))));
return message;
}
private static byte[] gzip(byte[] bytes) throws Exception {
ByteArrayOutputStream output = new ByteArrayOutputStream();
try (GZIPOutputStream gzip = new GZIPOutputStream(output)) { gzip.write(bytes); }
return output.toByteArray();
}
private static String sha256(byte[] bytes) throws Exception {
return hex(MessageDigest.getInstance("SHA-256").digest(bytes));
}
private static String hex(byte[] bytes) { return HexFormat.of().formatHex(bytes); }
}