diff --git a/llm_readme.txt b/llm_readme.txt
index c5329d6..61942bc 100644
--- a/llm_readme.txt
+++ b/llm_readme.txt
@@ -53,7 +53,9 @@ storageNode/
│ ├── simplelogger.properties # SLF4J 日志配置(输出到 run.out)
│ └── reflect-config.json # GraalVM 反射配置(Jackson 序列化)
└── test/
- └── java/ # 订阅快照、下载/压缩恢复与主站通道自愈测试
+ └── java/ # 协议编解码、节点消息处理与上报循环、
+ # 订阅快照与分发、下载/压缩恢复、
+ # 主站通道自愈、删除与工具方法测试
```
---
@@ -170,7 +172,9 @@ SubscriptionSocketTimeoutMs=10000
## 架构说明
-- 单模块 Maven 项目,现有 5 个测试用例覆盖订阅快照和压缩失败恢复
+- 单模块 Maven 项目,现有 62 个测试用例:协议编解码(MessageCodec)、节点消息处理与
+ 状态上报循环、备机订阅分发 HTTP(8889)、订阅快照存取、下载/压缩恢复与 gid 匹配、
+ 主站通道自愈、删除与工具方法
- 硬编码文件系统路径(`/root/gallery/...`)→ 仅限 Linux 部署
- 外部连接:`lionwebsite.xyz`、`personal.lionwebsite.xyz`、`aaaa.gay`
- GraalVM 原生镜像编译,包含 Jackson 反射配置
diff --git a/src/main/java/lion/Config/Config.java b/src/main/java/lion/Config/Config.java
index 43caaf0..41ac141 100644
--- a/src/main/java/lion/Config/Config.java
+++ b/src/main/java/lion/Config/Config.java
@@ -3,12 +3,16 @@ package lion.Config;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
import java.util.Properties;
import lombok.extern.slf4j.Slf4j;
@Slf4j
public class Config {
+ public static final String CONFIG_PATH = "/root/gallery/storageNode/config.properties";
+
public static boolean subscriptionSyncEnabled;
public static String subscriptionSyncSecret;
public static String subscriptionDataDir;
@@ -19,24 +23,47 @@ public class Config {
public static int subscriptionSocketTimeoutMs;
public static void loadConfig(){
+ loadConfig(CONFIG_PATH);
+ }
+
+ /**
+ * 从指定文件加载配置。
+ *
+ *
拆出带路径的版本是为了能直接对「默认值填充」和「启用同步却没配密钥就拒绝启动」
+ * 写断言,不必依赖生产路径存在。文件缺失不是致命错误(保留内置默认值),
+ * 但会记录明确日志,避免以错误配置静默运行。
+ */
+ public static void loadConfig(String path){
Properties prop = new Properties();
- try (InputStream input = new FileInputStream("/root/gallery/storageNode/config.properties")) {
+ if (!Files.isRegularFile(Path.of(path))) {
+ log.warn("配置文件不存在:{},使用内置默认值", path);
+ apply(prop);
+ return;
+ }
+
+ try (InputStream input = new FileInputStream(path)) {
prop.load(input);
- subscriptionSyncEnabled = Boolean.parseBoolean(value(prop, "SubscriptionSyncEnabled", "false"));
- subscriptionSyncSecret = System.getenv().getOrDefault("SUBSCRIPTION_SYNC_SECRET",
- value(prop, "SubscriptionSyncSecret", ""));
- subscriptionDataDir = value(prop, "SubscriptionDataDir", "/root/gallery/storageNode/sub");
- subscriptionMaxStaleSeconds = Long.parseLong(value(prop, "SubscriptionMaxStaleSeconds", "604800"));
- subscriptionMaxPayloadBytes = Integer.parseInt(value(prop, "SubscriptionMaxPayloadBytes", "52428800"));
- subscriptionHttpPort = Integer.parseInt(value(prop, "SubscriptionHttpPort", "8889"));
- subscriptionHttpWorkers = Integer.parseInt(value(prop, "SubscriptionHttpWorkers", "4"));
- subscriptionSocketTimeoutMs = Integer.parseInt(value(prop, "SubscriptionSocketTimeoutMs", "10000"));
- if (subscriptionSyncEnabled && subscriptionSyncSecret.isBlank())
- throw new IllegalStateException("启用订阅同步时必须配置 SUBSCRIPTION_SYNC_SECRET");
} catch (IOException ex) {
log.error("加载配置失败:{}", ex.getMessage());
+ apply(prop);
+ return;
}
+ apply(prop);
+ }
+
+ private static void apply(Properties prop){
+ subscriptionSyncEnabled = Boolean.parseBoolean(value(prop, "SubscriptionSyncEnabled", "false"));
+ subscriptionSyncSecret = System.getenv().getOrDefault("SUBSCRIPTION_SYNC_SECRET",
+ value(prop, "SubscriptionSyncSecret", ""));
+ subscriptionDataDir = value(prop, "SubscriptionDataDir", "/root/gallery/storageNode/sub");
+ subscriptionMaxStaleSeconds = Long.parseLong(value(prop, "SubscriptionMaxStaleSeconds", "604800"));
+ subscriptionMaxPayloadBytes = Integer.parseInt(value(prop, "SubscriptionMaxPayloadBytes", "52428800"));
+ subscriptionHttpPort = Integer.parseInt(value(prop, "SubscriptionHttpPort", "8889"));
+ subscriptionHttpWorkers = Integer.parseInt(value(prop, "SubscriptionHttpWorkers", "4"));
+ subscriptionSocketTimeoutMs = Integer.parseInt(value(prop, "SubscriptionSocketTimeoutMs", "10000"));
+ if (subscriptionSyncEnabled && subscriptionSyncSecret.isBlank())
+ throw new IllegalStateException("启用订阅同步时必须配置 SUBSCRIPTION_SYNC_SECRET");
}
private static String value(Properties prop, String key, String fallback) {
diff --git a/src/main/java/lion/CustomUtil.java b/src/main/java/lion/CustomUtil.java
index e65b940..da629cb 100644
--- a/src/main/java/lion/CustomUtil.java
+++ b/src/main/java/lion/CustomUtil.java
@@ -9,6 +9,8 @@ import java.io.*;
import java.net.ServerSocket;
import java.net.Socket;
import java.net.SocketException;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
@Slf4j
public class CustomUtil {
@@ -16,12 +18,15 @@ public class CustomUtil {
public static ObjectMapper objectMapper = new ObjectMapper();
public static void notifyMe(String message) {
- String url = "https://personal.lionwebsite.xyz/message2me?AuthCode=alone&message=" + message;
- HttpRequest request = HttpRequest.post(url);
+ // 画廊名可能含空格、'&'、'#' 或非 ASCII,必须编码后再拼进查询串,否则会截断或串参数。
+ String url = "https://personal.lionwebsite.xyz/message2me?AuthCode=alone&message="
+ + URLEncoder.encode(message == null ? "" : message, StandardCharsets.UTF_8);
+ // 外呼必须有超时;调用方虽有专用守护线程,但不设超时仍可能长期占住它。
+ HttpRequest request = HttpRequest.post(url).timeout(5_000);
request.header("User-Agent", "Mozilla/5.0");
try(HttpResponse response = request.execute()) {
if(response.getStatus() != 200) {
- System.out.println("通知失败, status code:" + response.getStatus() + ", message:" + message);
+ log.warn("通知失败, status code:{}, message:{}", response.getStatus(), message);
}
}
}
@@ -65,6 +70,19 @@ public class CustomUtil {
public static void sendFileRange(Socket clientSocket, File file, long startByte, long endByte, String contentDispositionFileName) throws IOException {
long fileLength = file.length();
+
+ // 区间必须夹在文件范围内:客户端可能请求远超文件末端(例如 bytes=0-999999),
+ // 若照原样写进 Content-Length / Content-Range,对端会一直等一个永不到来的长度,
+ // 连接要挂到超时才断。起点晚于终点(含空文件)则无可发送内容,直接回 416。
+ if (startByte < 0)
+ startByte = 0;
+ if (endByte > fileLength - 1)
+ endByte = fileLength - 1;
+ if (startByte > endByte) {
+ sendErrorResponse(clientSocket, "416 Range Not Satisfiable");
+ return;
+ }
+
OutputStream responseStream = clientSocket.getOutputStream();
PrintWriter responseWriter = new PrintWriter(responseStream, true);
responseWriter.println("HTTP/1.1 206 Partial Content");
diff --git a/src/main/java/lion/Message/MessageCodec.java b/src/main/java/lion/Message/MessageCodec.java
index 0dc64e9..902b6ab 100644
--- a/src/main/java/lion/Message/MessageCodec.java
+++ b/src/main/java/lion/Message/MessageCodec.java
@@ -5,6 +5,8 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import io.netty.buffer.ByteBuf;
import io.netty.channel.ChannelHandlerContext;
import io.netty.handler.codec.ByteToMessageCodec;
+import io.netty.handler.codec.CorruptedFrameException;
+import io.netty.handler.codec.EncoderException;
import lion.CustomUtil;
import lion.Message.Main.*;
import lombok.extern.slf4j.Slf4j;
@@ -15,6 +17,9 @@ import java.util.List;
@Slf4j
public class MessageCodec extends ByteToMessageCodec {
+ /** 单帧负载上限,与上游 LengthFieldBasedFrameDecoder 的 100MB 保持一致。 */
+ private static final int MAX_FRAME_BYTES = 100_000_000;
+
ObjectMapper objectMapper;
public MessageCodec(){
@@ -23,21 +28,25 @@ public class MessageCodec extends ByteToMessageCodec {
@Override
protected void encode(ChannelHandlerContext channelHandlerContext, AbstractMessage abstractMessage, ByteBuf byteBuf) {
- byteBuf.writeByte(abstractMessage.messageType);
-
+ // 先序列化、再写帧头:否则中途失败会留下只有类型字节、没有长度和 JSON 的半帧,
+ // 对端的分帧器会为了等长度前缀一直挂住这条连接。
+ byte[] bytes;
try {
- byte[] bytes = objectMapper.writeValueAsBytes(abstractMessage);
- byteBuf.writeInt(bytes.length);
- byteBuf.writeBytes(bytes);
+ bytes = objectMapper.writeValueAsBytes(abstractMessage);
} catch (Exception e) {
- log.error("序列化消息失败:{}", e.getMessage());
+ throw new EncoderException("序列化消息失败:" + e.getMessage(), e);
}
+ byteBuf.writeByte(abstractMessage.messageType);
+ byteBuf.writeInt(bytes.length);
+ byteBuf.writeBytes(bytes);
}
@Override
protected void decode(ChannelHandlerContext channelHandlerContext, ByteBuf byteBuf, List