Files
storageNode/llm_readme.txt
T
root 77f817d402 修复主站通道引用被误清空导致任务状态永久卡住
问题:主站重连期间节点先后认证多条通道,引用被最后一条覆盖;那条通道断开后
引用被清空,而更早建立、仍然可用的通道继续发送可用性探测。节点因引用为空不再
上报任何任务状态,主站又能在旧通道上收到探活响应,双方都判定连接正常。于是
未完成任务永久停在「已提交」,只有人工触发重连或重启主站才会恢复。

现场证据:gid 4203383 于 19:31 创建、19:32 归档完成,节点此后每 5 秒扫到
status=4,却再未打印过「任务状态发送完成」;主站侧 17:23 之后一条「下载进度」
都没有。20:28/20:29 两次收到任务下发时节点判定 server 为空而静默丢弃。

修复:

- 新增 PrimaryChannelTracker 统一管理主站通道引用,并保留全部已认证通道。
  首选通道失效时立即回退到其它仍可用的已认证通道;引用被清空时,已认证通道上
  的下一条消息即可恢复它。认领口径严格等于主站实际会发的消息类型(身份/任务下发/
  删除/探活/订阅快照),备机身份与节点自身出站类型都不算证据。
- 新增 RekickPolicy:有待上报任务却无可用通道时,以 30 秒为限流间隔主动重新
  唤起主站,不再干等最长半小时的探测周期。阻塞式探测投递到独立线程,避免占用
  5 秒调度线程拖住下载扫描与压缩。
- 通道不可用时的告警与重连判断放在 downloadCheck 之前,但不提前返回,
  本地下载与压缩必须继续推进;否则正在下载、进度无变化的任务会走「无需上报」
  的提前返回路径,通道失联后既不告警也不重连,正是历史事故的成因。

验证:新增 13 项测试(通道引用自愈 6、事故场景复现 3、重连限流 4),
节点侧合计 28 项全绿。已部署到存储节点并与主站重新握手,双向报文正常,
未完成任务数归零。
2026-09-21 22:17:14 +08:00

179 lines
8.4 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# storageNode 项目结构
## 概述
分布式存储/下载节点服务端,用于画廊网站。职责包括:从远程源下载图片集、压缩为 ZIP 归档、通过 HTTP 提供下载(支持断点续传)、管理代理订阅配置(V2Ray/Clash),并通过 Netty 自定义 TCP 协议与中心服务器通信。
- **Group ID:** `org.lion`
- **Version:** `1.0`
- **Java 版本:** 21 字节码(生产使用 GraalVM JDK 25)
- **构建工具:** Maven(单模块)
- **编译目标:** 当前以 JVM/JAR + `lib/` 运行;保留 GraalVM 原生配置但尚未完成 JDK 25 原生验证
---
## 目录结构
```
storageNode/
├── pom.xml # Maven 构建配置
└── src/
├── main/
│ ├── java/
│ │ └── lion/
│ │ ├── Main.java # 程序入口
│ │ ├── storageNode.java # Netty TCP 核心节点
│ │ ├── CustomUtil.java # 工具方法(HTTP 通知、端口查找、ObjectMapper)
│ │ ├── MultiThreadedHTTPServer.java # HTTP 文件下载服务(端口 8888)
│ │ ├── Config/
│ │ │ └── Config.java # 加载 config.properties
│ │ ├── Domain/
│ │ │ └── GalleryTask.java # 下载任务领域模型
│ │ ├── ErrorCode/
│ │ │ └── ErrorCode.java # 错误码常量
│ │ ├── Externel/
│ │ │ └── BackupSubServer.java # 代理订阅文件服务(端口 8889)
│ │ ├── Message/
│ │ │ ├── AbstractMessage.java # 消息基类 + 消息类型常量
│ │ │ ├── MessageCodec.java # Netty 编解码器(ByteBuf ↔ AbstractMessage,JSON 格式)
│ │ │ └── Main/
│ │ │ ├── AvailableCheckMessage.java # 可用性检查(type=8)
│ │ │ ├── DeleteGalleryMessage.java # 删除画廊请求(type=3)
│ │ │ ├── DownloadPostMessage.java # 提交下载任务(type=1)
│ │ │ ├── DownloadStatusMessage.java # 上报任务状态(type=2)
│ │ │ ├── IdentityMessage.java # 身份认证握手(type=6)
│ │ │ ├── MaintainMessage.java # 心跳维持(type=7)
│ │ │ └── ResponseMessage.java # 通用响应(type=0)
│ │ └── Service/
│ │ ├── DeleteService.java # 删除画廊目录
│ │ ├── DownloadCheckService.java # 下载监控与压缩服务
│ │ ├── PrimaryChannelTracker.java # 主站通道引用登记与自愈
│ │ └── RekickPolicy.java # 主动重新唤起主站的限流判定
│ └── resources/
│ ├── config.properties # DouNai 订阅地址配置
│ ├── simplelogger.properties # SLF4J 日志配置(输出到 run.out)
│ └── reflect-config.json # GraalVM 反射配置(Jackson 序列化)
└── test/
└── java/ # 订阅快照、下载/压缩恢复与主站通道自愈测试
```
---
## 关键依赖
| 依赖 | 版本 | 用途 |
|---|---|---|
| `io.netty:netty-all` | 4.1.138.Final | TCP 服务端/客户端 |
| `com.fasterxml.jackson.core:jackson-databind` | 2.22.2 | JSON 序列化 |
| `org.projectlombok:lombok` | 1.18.48 | 简化样板代码(`@Data`, `@Slf4j`) |
| `ch.qos.logback:logback-classic` | 1.5.38 | 日志实现 |
| `cn.hutool:hutool-all` | 5.8.47 | 文件操作、ZIP 压缩、HTTP 请求 |
| `org.apache.commons:commons-compress` | 1.28.0 | 压缩归档 |
| `org.graalvm.buildtools:native-maven-plugin` | 1.1.8 | 原生镜像配置(待验证) |
---
## 启动流程
1. `Main.main()` → 调用 `boot()`(遗留的 Netty Bootstrap),然后执行 `Config.loadConfig()`
2. 在新线程中启动 `BackupSubServer`(端口 8889)— 提供代理订阅文件下载
3. 在新线程中启动 `MultiThreadedHTTPServer`(端口 8888)— 提供画廊 ZIP 文件下载
4. 主线程创建 `storageNode()` 实例(阻塞构造函数,永不返回):
- 从端口 26321 开始查找空闲端口,绑定 Netty TCP 服务端
- 作为 TCP 客户端连接 `lionwebsite.xyz:26322~26342`,唤醒中心服务器
- 启动 `DownloadCheckService` 和 5 秒定时任务 `mainThread()`
---
## 通信协议(Netty TCP)
自定义协议格式:`[1字节类型] + [4字节长度] + [JSON 负载]`
| 类型字节 | 消息类 | 方向 |
|---|---|---|
| 0 | `ResponseMessage` | 响应 |
| 1 | `DownloadPostMessage` | 服务端 → 节点 |
| 2 | `DownloadStatusMessage` | 节点 → 服务端 |
| 3 | `DeleteGalleryMessage` | 服务端 → 节点 |
| 6 | `IdentityMessage` | 握手 |
| 7 | `MaintainMessage` | 心跳 |
| 8 | `AvailableCheckMessage` | 可用性检查 |
| 9 | `SubscriptionSnapshotMessage` | 主站 → 节点,完整订阅备机快照 |
---
## HTTP 服务
### MultiThreadedHTTPServer(端口 8888)
- 提供压缩后的画廊 ZIP 文件下载
- 仅接受来自 `lionwebsite.xyz` IP 的连接
- 支持 HTTP 206 Partial Content(断点续传)
- 参数:`AuthCode`(管理员访问)、`gid`(画廊 ID)
### BackupSubServer(端口 8889)
- 提供 V2Ray 和 Clash 代理订阅文件
- 不直接访问上游;主站完成下载和倍率过滤后,通过 Netty 类型 9 推送完整快照
- 按公开 Key 的 SHA-256 查找用户绑定的子账号,未知 Key 返回 404
- 快照经 SHA-256 和 HMAC 校验后原子落盘,主站离线时继续分发最后成功版本
- 文件存储于 `sub/snapshots/{revision}/accounts/{accountId}/`
- 没有有效快照或快照超过最大有效期时返回 503,不回退旧共享订阅
- `GET /health/subscription` 提供不含 Key 和订阅正文的快照状态
---
## 核心服务
### DownloadCheckService
- 扫描下载目录(`/root/gallery/hentai/download/`)监控进度
- 通过检测 `galleryinfo.txt` 文件判断下载完成
- 将完成的下载任务移入压缩队列
- 后台线程每 5 秒执行 ZIP 压缩,完成后删除源目录
### DeleteService
- 按名称删除画廊目录
- 失败时返回 `ErrorCode.IO_ERROR` 或 `ErrorCode.FILE_NOT_FOUND`
### PrimaryChannelTracker
- 登记「哪条通道是主站」,供任务状态上报与心跳使用
- 只有主站会发的消息类型(身份/任务下发/探活/订阅快照)才可用于认领引用;
备机的身份消息与节点自己的出站类型都不算证据
- 首选通道断开时立即回退到其它仍可用的已认证通道;引用被清空时,
已认证通道上的下一条消息即可恢复它,避免任务状态静默停止上报
### RekickPolicy
- 判定「有待上报任务、却无可用通道」时是否应主动重新唤起主站
- 以 30 秒为最小间隔限流,既能快速自愈,又不会在主站确实离线时形成重连风暴
---
## 配置说明
### config.properties(从 `/root/gallery/storageNode/config.properties` 加载)
```properties
SubscriptionSyncEnabled=false
SubscriptionSyncSecret=
SubscriptionDataDir=/root/gallery/storageNode/sub
SubscriptionMaxStaleSeconds=604800
SubscriptionMaxPayloadBytes=52428800
SubscriptionHttpPort=8889
SubscriptionHttpWorkers=4
SubscriptionSocketTimeoutMs=10000
```
生产同步密钥优先通过 `SUBSCRIPTION_SYNC_SECRET` 环境变量提供,不得提交到仓库或写入日志。
### simplelogger.properties
- 日志级别:`info`
- 时间戳格式:`yyyy-MM-dd HH:mm:ss`
- 输出文件:`run.out`
---
## 架构说明
- 单模块 Maven 项目,现有 5 个测试用例覆盖订阅快照和压缩失败恢复
- 硬编码文件系统路径(`/root/gallery/...`)→ 仅限 Linux 部署
- 外部连接:`lionwebsite.xyz`、`personal.lionwebsite.xyz`、`aaaa.gay`
- GraalVM 原生镜像编译,包含 Jackson 反射配置
- 大量使用 Lombok(`@Data`、`@Slf4j`)
- 使用 Hutool 工具库处理文件/ZIP/HTTP 操作