commit 8de2fdc8a4c5c31ab14153966a02afd9663ed33a
Author: hongawen <83944980@qq.com>
Date: Mon Apr 13 11:50:14 2026 +0800
项目初始化
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..c6cb740
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,51 @@
+# Compiled class file
+*.class
+*.iml
+*.idea
+target/
+logs/
+docs/
+
+# Log file
+*.log
+
+# BlueJ files
+*.ctxt
+
+# Mobile Tools for Java (J2ME)
+.mtj.tmp/
+
+# Package Files #
+*.jar
+*.war
+*.ear
+*.tar.gz
+*.rar
+
+# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml
+hs_err_pid*
+
+*velocity.log*
+
+# Eclipse #
+.classpath
+.project
+.settings/
+
+.DS_Store
+
+_dockerCerts/
+
+.factorypath
+
+node_modules/
+package-lock.json
+yarn.lock
+
+rebel.xml
+
+!DmJdbcDriver18.jar
+!kingbase8-8.6.0.jar
+/.fastRequest/collections/Root/Default Group/directory.json
+/.fastRequest/collections/Root/directory.json
+/.fastRequest/config/fastRequestCurrentProjectConfig.json
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..7fda346
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,41 @@
+# Repository Guidelines
+
+## Agent 工作方式
+进入本仓库后,先阅读本文件,再开始分析、修改或输出结论。不要跳过现有文档直接下判断,至少先结合根目录 `README.md`、`docs/`、目标模块代码和相关配置确认上下文。
+
+日常交互遵循以下习惯:
+
+- 先整理执行方案,说明目标、涉及模块、预计修改点和验证方式,待用户评审确认后再执行。
+- 不要想当然;如果需求存在歧义、前提不清或有多种实现路径,先说清假设与取舍,再继续。
+- 先确认任务位于 `entrance`、`system`、`user`、`detection` 还是 `tools/activate-tool`,再沿配置、Controller、Service、Mapper、XML 和调用链向下分析。
+- 涉及认证、字典、日志、注册资源、WebSocket 或 Netty 通信链路时,先核对已有实现和 `docs/` 中的说明,避免只看局部代码就下结论。
+- 回复风格保持简洁、直接,优先给出可执行结果;如果存在限制、风险或未验证部分,需要明确说明。
+
+## 执行与修改原则
+- 简单优先:只做当前需求所需的最小改动,不额外引入新功能、抽象层、配置项或“顺手优化”。
+- 外科手术式修改:只改与任务直接相关的文件和代码行,不重构无关模块,不调整无关格式或注释。
+- 保持现有风格:遵循仓库已有包结构、分层方式、命名和写法,不按个人偏好重写。
+- 只清理自己造成的问题:可以删除因本次修改而产生的未使用 `import`、变量或方法;不要删除仓库中原本就存在的死代码,除非用户明确要求。
+- 先定义验证方式:执行方案里要写清楚“改哪里、怎么判断改对了”;默认通过代码路径、配置一致性和受影响范围检查进行验证。
+- 除非用户明确要求,否则不执行任何 `mvn` 编译、打包、测试或其他构建命令。
+
+## 项目结构与模块划分
+`CN_Tool` 是一个 Maven 多模块后端项目,根目录的 [`pom.xml`](C:/code/gitea/cn_tool/CN_Tool/pom.xml) 聚合了 `entrance`、`system`、`user`、`detection` 和 `tools`。
+
+- `entrance`:Spring Boot 启动模块,入口类为 `entrance/src/main/java/com/njcn/gather/EntranceApplication.java`。
+- `system`:系统字典、日志、配置、注册资源等公共能力。
+- `user`:认证、用户、角色、功能资源及相关过滤逻辑。
+- `detection`:Netty / WebSocket 通信与连接生命周期管理。
+- `tools/activate-tool`:激活码与许可能力。
+- `docs/`:项目基线、配置和运行说明文档。
+
+Java 源码位于 `src/main/java`,配置文件位于 `src/main/resources`,MyBatis XML 映射文件按包结构存放在 `**/mapper/mapping/*.xml`。
+
+## 代码风格与命名规范
+保持现有 Java 风格:4 空格缩进、UTF-8 文件编码、基础包名使用 `com.njcn.gather`。命名沿用分层后缀,如 `*Controller`、`*Service`、`*ServiceImpl`、`*Mapper`、`*Param`、`*PO`、`*VO`。优先复用现有 Lombok 注解,如 `@Data`、`@RequiredArgsConstructor`、`@Slf4j`。Mapper XML 文件名应与接口名保持一致。业务代码中,关键流程、分支判断、状态流转或容易误解的节点需要补充简洁的中文注释,但不要添加无信息量的注释。
+
+## 提交与合并请求规范
+当前 `main` 分支尚无可参考的提交历史,仓库内也没有既有提交规范。建议使用“模块前缀 + 动词短句”的提交格式,例如 `user: 优化登录会话校验`、`system: 增加字典参数校验`。提交 PR 时应说明影响模块、配置或数据结构变更、人工验证步骤;若接口行为有变化,附上请求与响应示例。
+
+## 安全与配置提示
+将 `application.yml` 视为环境配置文件处理,不要在提交中新增明文密钥、数据库口令或许可证材料。本地运行时需保证 `D:\logs` 可写;如部署环境不同,应通过配置覆盖日志目录。
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..afbe981
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,65 @@
+# CLAUDE.md
+
+用于减少 LLM 常见编码失误的行为准则。可按需与项目特定说明合并使用。
+
+**权衡:** 这些准则更偏向谨慎而非速度。对于非常简单的任务,请自行判断。
+
+## 1. 编码前先思考
+
+**不要想当然。不要掩饰困惑。把权衡点说出来。**
+
+开始实现前:
+- 明确说明你的假设;如果不确定,就提问。
+- 如果存在多种理解方式,请列出来,不要默默自行选择。
+- 如果有更简单的方案,请直接指出;必要时应当提出不同意见。
+- 如果有内容不清楚,就先停下来;说清楚困惑点,然后提问。
+
+## 2. 简单优先
+
+**只写解决问题所需的最少代码。不要做猜测式扩展。**
+
+- 不要添加用户未要求的功能。
+- 不要为一次性代码引入抽象。
+- 不要加入未被要求的“灵活性”或“可配置性”。
+- 不要为不可能发生的场景补充错误处理。
+- 如果你写了 200 行,而实际上 50 行就能解决,就重写。
+
+问问自己:“一个资深工程师会认为这太复杂了吗?”如果答案是会,那就继续简化。
+
+## 3. 外科手术式修改
+
+**只改必须改的地方。只清理自己造成的问题。**
+
+修改现有代码时:
+- 不要顺手“优化”相邻代码、注释或格式。
+- 不要重构没有问题的部分。
+- 保持与现有风格一致,即使你个人会写成别的样子。
+- 如果发现无关的死代码,可以指出,但不要直接删除。
+
+当你的改动产生“遗留物”时:
+- 删除因你的改动而变成未使用的 `import`、变量或函数。
+- 不要删除原本就存在的死代码,除非用户明确要求。
+
+检验标准:每一行变更都应该能直接对应到用户的请求。
+
+## 4. 以目标驱动执行
+
+**先定义成功标准,再循环推进直到验证完成。**
+
+把任务转换成可验证的目标:
+- “增加校验” → “先为非法输入编写测试,再让测试通过”
+- “修复 bug” → “先写出能复现问题的测试,再让测试通过”
+- “重构 X” → “确保改动前后测试都通过”
+
+对于多步骤任务,先给出简短计划:
+```
+1. [步骤] → 验证方式:[检查项]
+2. [步骤] → 验证方式:[检查项]
+3. [步骤] → 验证方式:[检查项]
+```
+
+明确的成功标准可以支持你独立推进;模糊的标准(例如“把它弄好”)则会导致反复确认。
+
+---
+
+**如果这些准则有效,你会看到:** diff 中不必要的改动更少,因过度设计导致的返工更少,而且澄清问题会发生在实现之前,而不是出错之后。
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..a01a9bf
--- /dev/null
+++ b/README.md
@@ -0,0 +1,84 @@
+# CN_Tool
+
+CN_Tool 是一个基于 Spring Boot 的多模块后端聚合工程,当前仓库内保留的核心能力包括:
+
+- 用户认证、用户/角色/菜单资源管理
+- 系统字典、日志、系统配置、注册资源管理
+- WebSocket / Netty 通信基础设施
+- 激活码与许可证能力
+
+## 当前真实模块
+
+根聚合模块下当前包含以下子模块:
+
+- `entrance`
+- `system`
+- `user`
+- `detection`
+- `tools`
+
+其中 `tools` 当前仅保留:
+
+- `activate-tool`
+
+## 启动入口
+
+当前主启动入口位于:
+
+- `entrance/src/main/java/com/njcn/gather/EntranceApplication.java`
+
+`entrance` 模块聚合了 `system`、`user`、`detection`、`activate-tool`,是当前运行时主入口。
+
+## 技术基线
+
+- Java:源码目标版本为 `1.8`
+- Spring Boot:`2.3.12.RELEASE`
+- 构建方式:Maven 多模块工程
+- ORM:MyBatis-Plus
+- 数据库:MySQL
+
+## 运行与构建前提
+
+当前项目存在以下前提条件:
+
+- 需要可用的 JDK 8 环境
+- 需要 Maven 环境
+- 当前仓库未发现 `mvnw`
+- 依赖私有 `com.njcn` 组件
+- 根 `pom.xml` 中存在内网 Nexus 发布仓库配置
+- 运行前通常需要可访问的 MySQL 数据库和基础表数据
+
+说明:
+
+当前这份仓库并不保证在任意外部环境下可直接编译运行。
+如果要做真实构建和启动,需要先满足内部依赖和环境条件。
+
+## 文档入口
+
+P0 已补齐基线文档,建议按以下顺序阅读:
+
+1. [docs/01-项目总览.md](./docs/01-项目总览.md)
+2. [docs/02-配置清单.md](./docs/02-配置清单.md)
+3. [docs/03-构建与运行前提.md](./docs/03-构建与运行前提.md)
+4. [docs/04-过时文档说明.md](./docs/04-过时文档说明.md)
+
+## 模块说明
+
+- `user`
+ - 负责认证、用户、角色、菜单资源相关能力
+- `system`
+ - 负责字典、日志、系统配置、注册资源相关能力
+- `detection`
+ - 当前以通信基础设施为主,包含 WebSocket / Netty 相关组件
+- `tools/activate-tool`
+ - 负责激活码生成、激活码验证、许可证读取等能力
+
+## 文档使用规则
+
+当前仓库中部分历史说明仍然存在。
+如文档之间出现冲突,建议按以下优先级理解:
+
+1. `docs/` 下的基线文档
+2. 根 `README.md`
+3. 各模块下的 `Readme.md`
+4. 最终以源码和配置为准
diff --git a/detection/Readme.md b/detection/Readme.md
new file mode 100644
index 0000000..1334595
--- /dev/null
+++ b/detection/Readme.md
@@ -0,0 +1,9 @@
+#### 简介
+ 设备模块主要包含以下功能:
+* 检测计划管理
+* 被检设备管理
+* 被检设备下监测点管理
+* 误差体系管理
+* 检测脚本管理
+* 检测源管理
+* 检测报告管理
diff --git a/detection/pom.xml b/detection/pom.xml
new file mode 100644
index 0000000..d91fb47
--- /dev/null
+++ b/detection/pom.xml
@@ -0,0 +1,38 @@
+
+
+ 4.0.0
+
+ com.njcn.gather
+ CN_Tool
+ 1.0.0
+
+ detection
+
+
+
+ com.njcn
+ njcn-common
+ 0.0.1
+
+
+ com.njcn
+ spingboot2.3.12
+ 2.3.12
+
+
+
+ com.alibaba
+ fastjson
+ 1.2.83
+
+
+ io.netty
+ netty-all
+ 4.1.68.Final
+
+
+
+
diff --git a/detection/src/main/java/com/njcn/gather/detection/pojo/vo/SocketDataMsg.java b/detection/src/main/java/com/njcn/gather/detection/pojo/vo/SocketDataMsg.java
new file mode 100644
index 0000000..5dab158
--- /dev/null
+++ b/detection/src/main/java/com/njcn/gather/detection/pojo/vo/SocketDataMsg.java
@@ -0,0 +1,43 @@
+package com.njcn.gather.detection.pojo.vo;
+
+import com.alibaba.fastjson.annotation.JSONField;
+import lombok.Data;
+
+/**
+ * @author wr
+ * @description
+ * @date 2024/12/13 9:09
+ */
+@Data
+public class SocketDataMsg {
+
+ /**
+ * 标识不同业务
+ */
+ private String type = "aaa";
+
+ /**
+ * 请求id,确保接收到响应时,知晓是针对的哪次请求的应答
+ */
+ @JSONField(ordinal = 1)
+ private String requestId;
+
+ /**
+ * 源初始化 INIT_GATHER$01 INIT_GATHER采集初始化,01 统计采集、02 暂态采集、03 实时采集
+ */
+ @JSONField(ordinal = 2)
+ private String operateCode;
+
+ /**
+ * 数据体,传输前需要将对象、Array等转为String
+ */
+ @JSONField(ordinal = 4)
+ private String data;
+
+ /**
+ * code码
+ */
+ @JSONField(ordinal = 3)
+ private Integer code;
+
+}
diff --git a/detection/src/main/java/com/njcn/gather/detection/pojo/vo/SocketMsg.java b/detection/src/main/java/com/njcn/gather/detection/pojo/vo/SocketMsg.java
new file mode 100644
index 0000000..b813e04
--- /dev/null
+++ b/detection/src/main/java/com/njcn/gather/detection/pojo/vo/SocketMsg.java
@@ -0,0 +1,31 @@
+package com.njcn.gather.detection.pojo.vo;
+
+import com.alibaba.fastjson.annotation.JSONField;
+import lombok.Data;
+
+/**
+ * @author wr
+ * @description socket 通用发送报文请求
+ * @date 2024/12/11 15:57
+ */
+@Data
+public class SocketMsg {
+
+ /**
+ * 请求id,确保接收到响应时,知晓是针对的哪次请求的应答
+ */
+ @JSONField(ordinal = 1)
+ private String requestId;
+
+ /**
+ * 源初始化 INIT_GATHER$01 INIT_GATHER采集初始化,01 统计采集、02 暂态采集、03 实时采集
+ */
+ @JSONField(ordinal = 2)
+ private String operateCode;
+
+ /**
+ * 数据体,传输前需要将对象、Array等转为String
+ */
+ @JSONField(ordinal = 3)
+ private T data;
+}
diff --git a/detection/src/main/java/com/njcn/gather/detection/pojo/vo/WebSocketVO.java b/detection/src/main/java/com/njcn/gather/detection/pojo/vo/WebSocketVO.java
new file mode 100644
index 0000000..7b84011
--- /dev/null
+++ b/detection/src/main/java/com/njcn/gather/detection/pojo/vo/WebSocketVO.java
@@ -0,0 +1,30 @@
+package com.njcn.gather.detection.pojo.vo;
+
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+/**
+ * Generic WebSocket payload wrapper.
+ *
+ * @author chendaofei
+ * @author hongawen
+ * @date 2026/04/08
+ */
+@Data
+@AllArgsConstructor
+@NoArgsConstructor
+public class WebSocketVO {
+
+ private String type = "transport";
+
+ private String requestId;
+
+ private String operateCode;
+
+ private Integer code;
+
+ private String desc;
+
+ private T data;
+}
diff --git a/detection/src/main/java/com/njcn/gather/detection/util/socket/MsgUtil.java b/detection/src/main/java/com/njcn/gather/detection/util/socket/MsgUtil.java
new file mode 100644
index 0000000..ba1936b
--- /dev/null
+++ b/detection/src/main/java/com/njcn/gather/detection/util/socket/MsgUtil.java
@@ -0,0 +1,32 @@
+package com.njcn.gather.detection.util.socket;
+
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.serializer.SerializerFeature;
+import com.njcn.gather.detection.pojo.vo.SocketDataMsg;
+
+/**
+ * Generic socket message helper retained by the communication foundation.
+ * Stage 4-B removes detection-specific text assembly helpers and keeps only
+ * payload parsing and JSON framing methods used by the base transport layer.
+ *
+ * @author wr
+ * @author hongawen
+ * @date 2026/04/08
+ */
+public final class MsgUtil {
+
+ private MsgUtil() {
+ }
+
+ public static SocketDataMsg socketDataMsg(String textMsg) {
+ return JSON.parseObject(textMsg, SocketDataMsg.class);
+ }
+
+ public static String toJsonWithNewLine(Object obj) {
+ return JSON.toJSONString(obj, SerializerFeature.PrettyFormat) + "\n";
+ }
+
+ public static String toJsonWithNewLinePlain(Object obj) {
+ return JSON.toJSONString(obj) + "\n";
+ }
+}
diff --git a/detection/src/main/java/com/njcn/gather/detection/util/socket/SocketManager.java b/detection/src/main/java/com/njcn/gather/detection/util/socket/SocketManager.java
new file mode 100644
index 0000000..dfacf9d
--- /dev/null
+++ b/detection/src/main/java/com/njcn/gather/detection/util/socket/SocketManager.java
@@ -0,0 +1,170 @@
+package com.njcn.gather.detection.util.socket;
+
+import cn.hutool.core.util.ObjectUtil;
+import cn.hutool.core.util.StrUtil;
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONObject;
+import com.njcn.gather.detection.util.socket.cilent.NettyClient;
+import com.njcn.gather.detection.util.socket.communication.model.ConnectionContext;
+import com.njcn.gather.detection.util.socket.communication.model.ConnectionType;
+import com.njcn.gather.detection.util.socket.config.SocketConnectionConfig;
+import io.netty.channel.Channel;
+import io.netty.channel.nio.NioEventLoopGroup;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+
+import javax.annotation.Resource;
+import java.util.Map;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ConcurrentHashMap;
+
+/**
+ * Generic socket session manager.
+ * Stage 4-A removes detection-only caches from this class and keeps only the
+ * retained transport responsibilities: session registry, auto-connect and
+ * outbound dispatch.
+ *
+ * @author wr
+ * @author hongawen
+ * @date 2026/04/07
+ */
+@Slf4j
+@Component
+public class SocketManager {
+
+ @Resource
+ private SocketConnectionConfig socketConnectionConfig;
+
+ @Resource
+ private NettyClient nettyClient;
+
+ /**
+ * Key: sessionKey(userId + connection tag), value: active channel.
+ */
+ private static final Map SOCKET_SESSIONS = new ConcurrentHashMap<>();
+
+ /**
+ * Key: sessionKey(userId + connection tag), value: event loop group.
+ */
+ private static final Map SOCKET_GROUPS = new ConcurrentHashMap<>();
+
+ public static void addUser(String sessionKey, Channel channel) {
+ SOCKET_SESSIONS.put(sessionKey, channel);
+ }
+
+ public static void addGroup(String sessionKey, NioEventLoopGroup group) {
+ SOCKET_GROUPS.put(sessionKey, group);
+ }
+
+ public static void removeUser(String sessionKey) {
+ Channel channel = SOCKET_SESSIONS.remove(sessionKey);
+ NioEventLoopGroup eventLoopGroup = SOCKET_GROUPS.remove(sessionKey);
+
+ if (ObjectUtil.isNotNull(channel)) {
+ try {
+ channel.close().sync();
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ log.warn("Close socket channel interrupted: sessionKey={}", sessionKey, e);
+ }
+ }
+
+ if (ObjectUtil.isNotNull(eventLoopGroup)) {
+ eventLoopGroup.shutdownGracefully();
+ log.info("Socket connection closed: sessionKey={}", sessionKey);
+ }
+ }
+
+ public static Channel getChannelByUserId(String sessionKey) {
+ return SOCKET_SESSIONS.get(sessionKey);
+ }
+
+ public static NioEventLoopGroup getGroupByUserId(String sessionKey) {
+ return SOCKET_GROUPS.get(sessionKey);
+ }
+
+ public static boolean isChannelActive(String sessionKey) {
+ Channel channel = getChannelByUserId(sessionKey);
+ return ObjectUtil.isNotNull(channel) && channel.isActive();
+ }
+
+ public static void sendMsg(String sessionKey, String msg) {
+ Channel channel = SOCKET_SESSIONS.get(sessionKey);
+ if (ObjectUtil.isNull(channel)) {
+ log.warn("Send socket message failed because channel does not exist: sessionKey={}, message={}",
+ sessionKey, msg);
+ return;
+ }
+ channel.writeAndFlush(msg + '\n');
+ log.info("{}__{} -> {} : {}", sessionKey, channel.id(), channel.remoteAddress(), msg);
+ }
+
+ /**
+ * Key refactor point: auto-connect now depends only on connection context
+ * and transport callbacks attached to that context.
+ */
+ public void smartSend(ConnectionContext context, String msg) {
+ if (ObjectUtil.isNull(context) || ObjectUtil.isNull(context.getConnectionType())) {
+ log.warn("smartSend skipped because connection context is null");
+ return;
+ }
+ String sessionKey = context.getSessionKey();
+ String requestId = extractRequestId(msg);
+ if (StrUtil.isBlank(sessionKey)) {
+ log.warn("smartSend skipped because sessionKey is blank, requestId={}", requestId);
+ return;
+ }
+
+ if (needsAutoConnect(context.getConnectionType(), requestId) && !isChannelActive(sessionKey)) {
+ String ip = resolveIp(context.getConnectionType());
+ Integer port = resolvePort(context.getConnectionType());
+ log.info("Socket auto connect triggered: type={}, sessionKey={}, requestId={}",
+ context.getConnectionType(), sessionKey, requestId);
+ CompletableFuture.runAsync(() -> nettyClient.connect(ip, port, context, msg));
+ return;
+ }
+
+ sendMsg(sessionKey, msg);
+ }
+
+ private static String extractRequestId(String msg) {
+ try {
+ if (StrUtil.isBlank(msg)) {
+ return "unknown";
+ }
+ JSONObject jsonObject = JSON.parseObject(msg);
+ String requestId = jsonObject.getString("requestId");
+ if (StrUtil.isNotBlank(requestId)) {
+ return requestId;
+ }
+ requestId = jsonObject.getString("request_id");
+ if (StrUtil.isNotBlank(requestId)) {
+ return requestId;
+ }
+ } catch (Exception e) {
+ log.debug("Extract requestId from socket message failed: {}", msg, e);
+ }
+ return "unknown";
+ }
+
+ private boolean needsAutoConnect(ConnectionType connectionType, String requestId) {
+ if (connectionType == ConnectionType.SOURCE) {
+ return SocketConnectionConfig.needsSourceConnection(requestId);
+ }
+ return SocketConnectionConfig.needsDeviceConnection(requestId);
+ }
+
+ private String resolveIp(ConnectionType connectionType) {
+ if (connectionType == ConnectionType.SOURCE) {
+ return socketConnectionConfig.getSource().getIp();
+ }
+ return socketConnectionConfig.getDevice().getIp();
+ }
+
+ private Integer resolvePort(ConnectionType connectionType) {
+ if (connectionType == ConnectionType.SOURCE) {
+ return socketConnectionConfig.getSource().getPort();
+ }
+ return socketConnectionConfig.getDevice().getPort();
+ }
+}
diff --git a/detection/src/main/java/com/njcn/gather/detection/util/socket/cilent/AbstractNettyClientHandler.java b/detection/src/main/java/com/njcn/gather/detection/util/socket/cilent/AbstractNettyClientHandler.java
new file mode 100644
index 0000000..b0ee3dd
--- /dev/null
+++ b/detection/src/main/java/com/njcn/gather/detection/util/socket/cilent/AbstractNettyClientHandler.java
@@ -0,0 +1,103 @@
+package com.njcn.gather.detection.util.socket.cilent;
+
+import cn.hutool.core.util.StrUtil;
+import com.njcn.gather.detection.util.socket.SocketManager;
+import com.njcn.gather.detection.util.socket.communication.handler.ConnectionLifecycleHandler;
+import com.njcn.gather.detection.util.socket.communication.handler.SocketMessageHandler;
+import com.njcn.gather.detection.util.socket.communication.model.ConnectionContext;
+import io.netty.channel.ChannelHandlerContext;
+import io.netty.channel.SimpleChannelInboundHandler;
+import io.netty.handler.timeout.IdleState;
+import io.netty.handler.timeout.IdleStateEvent;
+import lombok.extern.slf4j.Slf4j;
+
+/**
+ * Shared client handler skeleton for the retained Netty communication layer.
+ * Stage 4-A centralizes common session registration, message delegation and
+ * idle cleanup so concrete handlers stay transport-oriented.
+ *
+ * @author hongawen
+ * @date 2026/04/07
+ */
+@Slf4j
+public abstract class AbstractNettyClientHandler extends SimpleChannelInboundHandler {
+
+ private final String handlerName;
+
+ protected final ConnectionContext connectionContext;
+
+ private final SocketMessageHandler socketMessageHandler;
+
+ private final ConnectionLifecycleHandler lifecycleHandler;
+
+ protected AbstractNettyClientHandler(String handlerName, ConnectionContext connectionContext,
+ SocketMessageHandler socketMessageHandler,
+ ConnectionLifecycleHandler lifecycleHandler) {
+ this.handlerName = handlerName;
+ this.connectionContext = connectionContext;
+ this.socketMessageHandler = socketMessageHandler;
+ this.lifecycleHandler = lifecycleHandler == null ? ConnectionLifecycleHandler.NO_OP : lifecycleHandler;
+ }
+
+ @Override
+ public void channelActive(ChannelHandlerContext ctx) throws Exception {
+ String sessionKey = resolveSessionKey();
+ log.info("{} channel active: channelId={}, sessionKey={}", handlerName, ctx.channel().id(), sessionKey);
+ if (StrUtil.isNotBlank(sessionKey)) {
+ SocketManager.addUser(sessionKey, ctx.channel());
+ } else {
+ log.warn("{} channel active without sessionKey, skip registration", handlerName);
+ }
+ lifecycleHandler.onConnected(connectionContext);
+ super.channelActive(ctx);
+ }
+
+ @Override
+ public void channelInactive(ChannelHandlerContext ctx) throws Exception {
+ String sessionKey = resolveSessionKey();
+ log.warn("{} channel inactive: channelId={}, sessionKey={}", handlerName, ctx.channel().id(), sessionKey);
+ if (StrUtil.isNotBlank(sessionKey)) {
+ SocketManager.removeUser(sessionKey);
+ }
+ lifecycleHandler.onDisconnected(connectionContext);
+ super.channelInactive(ctx);
+ }
+
+ @Override
+ protected void channelRead0(ChannelHandlerContext ctx, String msg) throws Exception {
+ if (socketMessageHandler == null) {
+ log.warn("{} receive message but handler is null: sessionKey={}, message={}",
+ handlerName, resolveSessionKey(), msg);
+ return;
+ }
+ try {
+ socketMessageHandler.handle(connectionContext, msg);
+ } catch (Exception e) {
+ lifecycleHandler.onMessageHandlingError(connectionContext, msg, e);
+ throw e;
+ }
+ }
+
+ @Override
+ public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception {
+ if (evt instanceof IdleStateEvent && ((IdleStateEvent) evt).state() == IdleState.READER_IDLE) {
+ log.warn("{} trigger reader idle timeout: sessionKey={}", handlerName, resolveSessionKey());
+ lifecycleHandler.onIdleTimeout(connectionContext);
+ ctx.close();
+ return;
+ }
+ super.userEventTriggered(ctx, evt);
+ }
+
+ @Override
+ public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
+ log.error("{} catch transport exception: sessionKey={}, message={}",
+ handlerName, resolveSessionKey(), cause.getMessage(), cause);
+ lifecycleHandler.onException(connectionContext, cause);
+ ctx.close();
+ }
+
+ protected String resolveSessionKey() {
+ return connectionContext == null ? null : connectionContext.getSessionKey();
+ }
+}
diff --git a/detection/src/main/java/com/njcn/gather/detection/util/socket/cilent/HeartbeatHandler.java b/detection/src/main/java/com/njcn/gather/detection/util/socket/cilent/HeartbeatHandler.java
new file mode 100644
index 0000000..bad7c13
--- /dev/null
+++ b/detection/src/main/java/com/njcn/gather/detection/util/socket/cilent/HeartbeatHandler.java
@@ -0,0 +1,138 @@
+package com.njcn.gather.detection.util.socket.cilent;
+
+import cn.hutool.core.util.StrUtil;
+import com.njcn.gather.detection.util.socket.SocketManager;
+import com.njcn.gather.detection.util.socket.communication.handler.ConnectionLifecycleHandler;
+import com.njcn.gather.detection.util.socket.communication.handler.HeartbeatMessageStrategy;
+import com.njcn.gather.detection.util.socket.communication.model.ConnectionContext;
+import io.netty.channel.ChannelHandlerContext;
+import io.netty.channel.SimpleChannelInboundHandler;
+import lombok.extern.slf4j.Slf4j;
+
+import java.time.LocalDateTime;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Generic Netty heartbeat handler.
+ * Stage 4-A moves heartbeat framing behind a strategy interface so the
+ * retained transport layer can keep heartbeat capability without embedding
+ * detection-specific packet structures.
+ *
+ * @author cdf
+ * @author hongawen
+ * @date 2026/04/07
+ */
+@Slf4j
+public class HeartbeatHandler extends SimpleChannelInboundHandler {
+
+ private static final int MAX_HEARTBEAT_MISSES = 3;
+
+ private final ScheduledExecutorService heartbeatExecutor = Executors.newScheduledThreadPool(1);
+
+ private final ConnectionContext connectionContext;
+
+ private final HeartbeatMessageStrategy heartbeatMessageStrategy;
+
+ private final ConnectionLifecycleHandler lifecycleHandler;
+
+ private ScheduledFuture> heartbeatFuture;
+
+ private int consecutiveHeartbeatMisses;
+
+ public HeartbeatHandler(ConnectionContext connectionContext, HeartbeatMessageStrategy heartbeatMessageStrategy,
+ ConnectionLifecycleHandler lifecycleHandler) {
+ this.connectionContext = connectionContext;
+ this.heartbeatMessageStrategy = heartbeatMessageStrategy;
+ this.lifecycleHandler = lifecycleHandler == null ? ConnectionLifecycleHandler.NO_OP : lifecycleHandler;
+ }
+
+ @Override
+ public void channelActive(ChannelHandlerContext ctx) throws Exception {
+ scheduleHeartbeat(ctx);
+ super.channelActive(ctx);
+ }
+
+ @Override
+ public void channelInactive(ChannelHandlerContext ctx) throws Exception {
+ shutdownExecutorGracefully();
+ super.channelInactive(ctx);
+ }
+
+ private void scheduleHeartbeat(ChannelHandlerContext ctx) {
+ if (heartbeatMessageStrategy == null) {
+ log.debug("Skip heartbeat scheduling because strategy is null: sessionKey={}", resolveSessionKey());
+ return;
+ }
+ heartbeatFuture = heartbeatExecutor.scheduleAtFixedRate(() -> {
+ if (!ctx.channel().isActive()) {
+ return;
+ }
+ try {
+ String heartbeatMessage = heartbeatMessageStrategy.buildHeartbeatMessage(connectionContext);
+ if (StrUtil.isBlank(heartbeatMessage)) {
+ return;
+ }
+ // The client pipeline still uses line based frames, so the
+ // generic heartbeat writer normalizes the trailing separator.
+ if (!heartbeatMessage.endsWith("\n")) {
+ heartbeatMessage = heartbeatMessage + "\n";
+ }
+ ctx.channel().writeAndFlush(heartbeatMessage);
+ consecutiveHeartbeatMisses++;
+ log.debug("Send heartbeat packet: sessionKey={}, time={}, misses={}",
+ resolveSessionKey(), LocalDateTime.now(), consecutiveHeartbeatMisses);
+ if (consecutiveHeartbeatMisses >= MAX_HEARTBEAT_MISSES) {
+ handleHeartbeatTimeout(ctx);
+ }
+ } catch (Exception e) {
+ log.error("Send heartbeat packet failed: sessionKey={}", resolveSessionKey(), e);
+ }
+ }, 3, 10, TimeUnit.SECONDS);
+ }
+
+ private void handleHeartbeatTimeout(ChannelHandlerContext ctx) {
+ log.warn("Heartbeat timeout reached: sessionKey={}, misses={}",
+ resolveSessionKey(), consecutiveHeartbeatMisses);
+ lifecycleHandler.onIdleTimeout(connectionContext);
+ String sessionKey = resolveSessionKey();
+ if (StrUtil.isNotBlank(sessionKey)) {
+ SocketManager.removeUser(sessionKey);
+ }
+ consecutiveHeartbeatMisses = 0;
+ if (ctx.channel().isActive()) {
+ ctx.close();
+ }
+ }
+
+ @Override
+ protected void channelRead0(ChannelHandlerContext ctx, String msg) throws Exception {
+ if (heartbeatMessageStrategy != null && heartbeatMessageStrategy.isHeartbeatResponse(connectionContext, msg)) {
+ consecutiveHeartbeatMisses = 0;
+ log.debug("Receive heartbeat response: sessionKey={}, time={}", resolveSessionKey(), LocalDateTime.now());
+ return;
+ }
+ ctx.fireChannelRead(msg);
+ }
+
+ private String resolveSessionKey() {
+ return connectionContext == null ? null : connectionContext.getSessionKey();
+ }
+
+ private void shutdownExecutorGracefully() {
+ try {
+ if (heartbeatFuture != null && !heartbeatFuture.isCancelled()) {
+ heartbeatFuture.cancel(false);
+ }
+ heartbeatExecutor.shutdown();
+ if (!heartbeatExecutor.awaitTermination(5, TimeUnit.SECONDS)) {
+ heartbeatExecutor.shutdownNow();
+ }
+ } catch (InterruptedException e) {
+ heartbeatExecutor.shutdownNow();
+ Thread.currentThread().interrupt();
+ }
+ }
+}
diff --git a/detection/src/main/java/com/njcn/gather/detection/util/socket/cilent/NettyClient.java b/detection/src/main/java/com/njcn/gather/detection/util/socket/cilent/NettyClient.java
new file mode 100644
index 0000000..f47323d
--- /dev/null
+++ b/detection/src/main/java/com/njcn/gather/detection/util/socket/cilent/NettyClient.java
@@ -0,0 +1,154 @@
+package com.njcn.gather.detection.util.socket.cilent;
+
+import cn.hutool.core.util.ObjectUtil;
+import com.njcn.gather.detection.util.socket.SocketManager;
+import com.njcn.gather.detection.util.socket.communication.handler.ConnectionLifecycleHandler;
+import com.njcn.gather.detection.util.socket.communication.handler.HeartbeatMessageStrategy;
+import com.njcn.gather.detection.util.socket.communication.handler.SocketMessageHandler;
+import com.njcn.gather.detection.util.socket.communication.model.ConnectionContext;
+import com.njcn.gather.detection.util.socket.communication.model.ConnectionType;
+import io.netty.bootstrap.Bootstrap;
+import io.netty.channel.ChannelFuture;
+import io.netty.channel.ChannelFutureListener;
+import io.netty.channel.ChannelInitializer;
+import io.netty.channel.ChannelOption;
+import io.netty.channel.ChannelPipeline;
+import io.netty.channel.SimpleChannelInboundHandler;
+import io.netty.channel.nio.NioEventLoopGroup;
+import io.netty.channel.socket.nio.NioSocketChannel;
+import io.netty.handler.codec.LineBasedFrameDecoder;
+import io.netty.handler.codec.string.StringDecoder;
+import io.netty.handler.codec.string.StringEncoder;
+import io.netty.handler.timeout.IdleStateHandler;
+import io.netty.util.CharsetUtil;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Generic Netty client entry.
+ * Stage 4-A removes direct dependencies on detection handlers and services.
+ * Message parsing, heartbeat framing and lifecycle side effects now come from
+ * callbacks attached to {@link ConnectionContext}.
+ *
+ * @author wr
+ * @author hongawen
+ * @date 2026/04/07
+ */
+@Slf4j
+@Component
+public class NettyClient {
+
+ public void connect(String ip, Integer port, ConnectionContext context, String msg) {
+ if (ObjectUtil.isNull(context) || ObjectUtil.isNull(context.getConnectionType())) {
+ log.warn("Skip socket connect because connection context is null");
+ return;
+ }
+ SocketMessageHandler messageHandler = context.getMessageHandler();
+ ConnectionLifecycleHandler lifecycleHandler = resolveLifecycleHandler(context);
+ HeartbeatMessageStrategy heartbeatMessageStrategy = context.getHeartbeatStrategy();
+ SimpleChannelInboundHandler handler = createHandler(context, messageHandler, lifecycleHandler);
+ executeSocketConnection(ip, port, context, msg, handler, lifecycleHandler, heartbeatMessageStrategy);
+ }
+
+ private SimpleChannelInboundHandler createHandler(ConnectionContext context,
+ SocketMessageHandler messageHandler,
+ ConnectionLifecycleHandler lifecycleHandler) {
+ if (context.getConnectionType() == ConnectionType.SOURCE) {
+ return new NettySourceClientHandler(context, messageHandler, lifecycleHandler);
+ }
+ if (context.getConnectionType() == ConnectionType.DEVICE) {
+ return new NettyDevClientHandler(context, messageHandler, lifecycleHandler);
+ }
+ return new NettyContrastClientHandler(context, messageHandler, lifecycleHandler);
+ }
+
+ private ConnectionLifecycleHandler resolveLifecycleHandler(ConnectionContext context) {
+ ConnectionLifecycleHandler lifecycleHandler = context.getLifecycleHandler();
+ return lifecycleHandler == null ? ConnectionLifecycleHandler.NO_OP : lifecycleHandler;
+ }
+
+ private void executeSocketConnection(String ip, Integer port, ConnectionContext context, String msg,
+ SimpleChannelInboundHandler handler,
+ ConnectionLifecycleHandler lifecycleHandler,
+ HeartbeatMessageStrategy heartbeatMessageStrategy) {
+ NioEventLoopGroup group = new NioEventLoopGroup();
+ try {
+ Bootstrap bootstrap = new Bootstrap()
+ .group(group)
+ .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 5000)
+ .channel(NioSocketChannel.class)
+ .handler(createChannelInitializer(context, handler, lifecycleHandler, heartbeatMessageStrategy));
+ ChannelFuture channelFuture = bootstrap.connect(ip, port).sync();
+ handleConnectionResult(channelFuture, context, group, msg, lifecycleHandler);
+ } catch (Exception e) {
+ log.warn("Connect socket server error: type={}, sessionKey={}",
+ context.getConnectionType(), context.getSessionKey(), e);
+ group.shutdownGracefully();
+ lifecycleHandler.onConnectFailed(context, e);
+ }
+ }
+
+ private ChannelInitializer createChannelInitializer(ConnectionContext context,
+ SimpleChannelInboundHandler handler,
+ ConnectionLifecycleHandler lifecycleHandler,
+ HeartbeatMessageStrategy heartbeatMessageStrategy) {
+ return new ChannelInitializer() {
+ @Override
+ protected void initChannel(NioSocketChannel ch) {
+ setupPipeline(ch.pipeline(), context, handler, lifecycleHandler, heartbeatMessageStrategy);
+ }
+ };
+ }
+
+ /**
+ * Key refactor point: pipeline extension now comes from generic strategy
+ * and lifecycle callbacks instead of fixed detection business classes.
+ */
+ private void setupPipeline(ChannelPipeline pipeline, ConnectionContext context,
+ SimpleChannelInboundHandler handler,
+ ConnectionLifecycleHandler lifecycleHandler,
+ HeartbeatMessageStrategy heartbeatMessageStrategy) {
+ pipeline.addLast(new LineBasedFrameDecoder(10240 * 2))
+ .addLast(new StringDecoder(CharsetUtil.UTF_8))
+ .addLast(new StringEncoder(CharsetUtil.UTF_8))
+ .addLast(new HeartbeatHandler(context, heartbeatMessageStrategy, lifecycleHandler));
+ if (context.getConnectionType().isEnableIdleMonitor()) {
+ pipeline.addLast(new IdleStateHandler(60, 0, 0, TimeUnit.SECONDS));
+ }
+ pipeline.addLast(handler);
+ }
+
+ private void handleConnectionResult(ChannelFuture channelFuture, ConnectionContext context,
+ NioEventLoopGroup group, String msg,
+ ConnectionLifecycleHandler lifecycleHandler) {
+ channelFuture.addListener((ChannelFutureListener) future -> {
+ if (!future.isSuccess()) {
+ log.warn("Connect socket server failed: type={}, sessionKey={}",
+ context.getConnectionType(), context.getSessionKey(), future.cause());
+ group.shutdownGracefully();
+ lifecycleHandler.onConnectFailed(context, future.cause());
+ return;
+ }
+ log.info("Connect socket server success: type={}, sessionKey={}",
+ context.getConnectionType(), context.getSessionKey());
+ manageSocketConnection(context, group);
+ SocketManager.addUser(context.getSessionKey(), future.channel());
+ SocketManager.sendMsg(context.getSessionKey(), msg);
+ });
+ }
+
+ private void manageSocketConnection(ConnectionContext context, NioEventLoopGroup group) {
+ String sessionKey = context.getSessionKey();
+ NioEventLoopGroup existingGroup = SocketManager.getGroupByUserId(sessionKey);
+ if (ObjectUtil.isNotNull(existingGroup)) {
+ try {
+ existingGroup.shutdownGracefully().sync();
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+ }
+ SocketManager.addGroup(sessionKey, group);
+ }
+}
diff --git a/detection/src/main/java/com/njcn/gather/detection/util/socket/cilent/NettyContrastClientHandler.java b/detection/src/main/java/com/njcn/gather/detection/util/socket/cilent/NettyContrastClientHandler.java
new file mode 100644
index 0000000..066b7e8
--- /dev/null
+++ b/detection/src/main/java/com/njcn/gather/detection/util/socket/cilent/NettyContrastClientHandler.java
@@ -0,0 +1,20 @@
+package com.njcn.gather.detection.util.socket.cilent;
+
+import com.njcn.gather.detection.util.socket.communication.handler.ConnectionLifecycleHandler;
+import com.njcn.gather.detection.util.socket.communication.handler.SocketMessageHandler;
+import com.njcn.gather.detection.util.socket.communication.model.ConnectionContext;
+
+/**
+ * Contrast device client transport handler.
+ *
+ * @author caozehui
+ * @author hongawen
+ * @date 2026/04/07
+ */
+public class NettyContrastClientHandler extends AbstractNettyClientHandler {
+
+ public NettyContrastClientHandler(ConnectionContext connectionContext, SocketMessageHandler socketMessageHandler,
+ ConnectionLifecycleHandler lifecycleHandler) {
+ super("contrast-device-client", connectionContext, socketMessageHandler, lifecycleHandler);
+ }
+}
diff --git a/detection/src/main/java/com/njcn/gather/detection/util/socket/cilent/NettyDevClientHandler.java b/detection/src/main/java/com/njcn/gather/detection/util/socket/cilent/NettyDevClientHandler.java
new file mode 100644
index 0000000..45f1503
--- /dev/null
+++ b/detection/src/main/java/com/njcn/gather/detection/util/socket/cilent/NettyDevClientHandler.java
@@ -0,0 +1,20 @@
+package com.njcn.gather.detection.util.socket.cilent;
+
+import com.njcn.gather.detection.util.socket.communication.handler.ConnectionLifecycleHandler;
+import com.njcn.gather.detection.util.socket.communication.handler.SocketMessageHandler;
+import com.njcn.gather.detection.util.socket.communication.model.ConnectionContext;
+
+/**
+ * Device client transport handler.
+ *
+ * @author wr
+ * @author hongawen
+ * @date 2026/04/07
+ */
+public class NettyDevClientHandler extends AbstractNettyClientHandler {
+
+ public NettyDevClientHandler(ConnectionContext connectionContext, SocketMessageHandler socketMessageHandler,
+ ConnectionLifecycleHandler lifecycleHandler) {
+ super("device-client", connectionContext, socketMessageHandler, lifecycleHandler);
+ }
+}
diff --git a/detection/src/main/java/com/njcn/gather/detection/util/socket/cilent/NettySourceClientHandler.java b/detection/src/main/java/com/njcn/gather/detection/util/socket/cilent/NettySourceClientHandler.java
new file mode 100644
index 0000000..8648af7
--- /dev/null
+++ b/detection/src/main/java/com/njcn/gather/detection/util/socket/cilent/NettySourceClientHandler.java
@@ -0,0 +1,20 @@
+package com.njcn.gather.detection.util.socket.cilent;
+
+import com.njcn.gather.detection.util.socket.communication.handler.ConnectionLifecycleHandler;
+import com.njcn.gather.detection.util.socket.communication.handler.SocketMessageHandler;
+import com.njcn.gather.detection.util.socket.communication.model.ConnectionContext;
+
+/**
+ * Source client transport handler.
+ *
+ * @author wr
+ * @author hongawen
+ * @date 2026/04/07
+ */
+public class NettySourceClientHandler extends AbstractNettyClientHandler {
+
+ public NettySourceClientHandler(ConnectionContext connectionContext, SocketMessageHandler socketMessageHandler,
+ ConnectionLifecycleHandler lifecycleHandler) {
+ super("source-client", connectionContext, socketMessageHandler, lifecycleHandler);
+ }
+}
diff --git a/detection/src/main/java/com/njcn/gather/detection/util/socket/communication/constants/SocketTransportConstants.java b/detection/src/main/java/com/njcn/gather/detection/util/socket/communication/constants/SocketTransportConstants.java
new file mode 100644
index 0000000..1dfde89
--- /dev/null
+++ b/detection/src/main/java/com/njcn/gather/detection/util/socket/communication/constants/SocketTransportConstants.java
@@ -0,0 +1,21 @@
+package com.njcn.gather.detection.util.socket.communication.constants;
+
+/**
+ * Socket transport constants kept by the communication foundation.
+ * Stage 4-A extracts these values from detection-only helpers so the
+ * retained transport layer does not depend on business utility classes.
+ *
+ * @author hongawen
+ * @date 2026/04/07
+ */
+public final class SocketTransportConstants {
+
+ public static final String SOURCE_SESSION_TAG = "_Source";
+
+ public static final String DEVICE_SESSION_TAG = "_Dev";
+
+ public static final String CONTRAST_SESSION_TAG = "_Contrast_Dev";
+
+ private SocketTransportConstants() {
+ }
+}
diff --git a/detection/src/main/java/com/njcn/gather/detection/util/socket/communication/handler/ConnectionLifecycleHandler.java b/detection/src/main/java/com/njcn/gather/detection/util/socket/communication/handler/ConnectionLifecycleHandler.java
new file mode 100644
index 0000000..cffba42
--- /dev/null
+++ b/detection/src/main/java/com/njcn/gather/detection/util/socket/communication/handler/ConnectionLifecycleHandler.java
@@ -0,0 +1,35 @@
+package com.njcn.gather.detection.util.socket.communication.handler;
+
+import com.njcn.gather.detection.util.socket.communication.model.ConnectionContext;
+
+/**
+ * Connection lifecycle callback for the retained communication foundation.
+ * Business modules can attach optional callbacks here without leaking their
+ * own service types into Netty and WebSocket infrastructure.
+ *
+ * @author hongawen
+ * @date 2026/04/07
+ */
+public interface ConnectionLifecycleHandler {
+
+ ConnectionLifecycleHandler NO_OP = new ConnectionLifecycleHandler() {
+ };
+
+ default void onConnected(ConnectionContext context) {
+ }
+
+ default void onDisconnected(ConnectionContext context) {
+ }
+
+ default void onConnectFailed(ConnectionContext context, Throwable cause) {
+ }
+
+ default void onIdleTimeout(ConnectionContext context) {
+ }
+
+ default void onMessageHandlingError(ConnectionContext context, String message, Throwable cause) {
+ }
+
+ default void onException(ConnectionContext context, Throwable cause) {
+ }
+}
diff --git a/detection/src/main/java/com/njcn/gather/detection/util/socket/communication/handler/HeartbeatMessageStrategy.java b/detection/src/main/java/com/njcn/gather/detection/util/socket/communication/handler/HeartbeatMessageStrategy.java
new file mode 100644
index 0000000..7bced6b
--- /dev/null
+++ b/detection/src/main/java/com/njcn/gather/detection/util/socket/communication/handler/HeartbeatMessageStrategy.java
@@ -0,0 +1,25 @@
+package com.njcn.gather.detection.util.socket.communication.handler;
+
+import com.njcn.gather.detection.util.socket.communication.model.ConnectionContext;
+
+/**
+ * Heartbeat protocol strategy for generic client connections.
+ * The transport layer only knows when to send and detect heartbeat frames;
+ * the concrete heartbeat payload is provided by the business side.
+ *
+ * @author hongawen
+ * @date 2026/04/07
+ */
+public interface HeartbeatMessageStrategy {
+
+ /**
+ * Build the outbound heartbeat packet. Return {@code null} or blank to
+ * disable heartbeat sending for the current connection.
+ */
+ String buildHeartbeatMessage(ConnectionContext context);
+
+ /**
+ * Check whether the inbound message is a heartbeat response frame.
+ */
+ boolean isHeartbeatResponse(ConnectionContext context, String message);
+}
diff --git a/detection/src/main/java/com/njcn/gather/detection/util/socket/communication/handler/SocketMessageHandler.java b/detection/src/main/java/com/njcn/gather/detection/util/socket/communication/handler/SocketMessageHandler.java
new file mode 100644
index 0000000..90a593a
--- /dev/null
+++ b/detection/src/main/java/com/njcn/gather/detection/util/socket/communication/handler/SocketMessageHandler.java
@@ -0,0 +1,23 @@
+package com.njcn.gather.detection.util.socket.communication.handler;
+
+import com.njcn.gather.detection.util.socket.communication.model.ConnectionContext;
+
+/**
+ * Socket 消息处理接口。
+ * 第一阶段先把消息回调从具体业务 Service 中抽离成统一入口,后续可以继续沉淀为独立通讯模块。
+ *
+ * @author hongawen
+ * @date 2026/04/07
+ */
+@FunctionalInterface
+public interface SocketMessageHandler {
+
+ /**
+ * 处理收到的 Socket 消息。
+ *
+ * @param context 连接上下文
+ * @param message 文本消息
+ * @throws Exception 处理异常
+ */
+ void handle(ConnectionContext context, String message) throws Exception;
+}
diff --git a/detection/src/main/java/com/njcn/gather/detection/util/socket/communication/model/ConnectionContext.java b/detection/src/main/java/com/njcn/gather/detection/util/socket/communication/model/ConnectionContext.java
new file mode 100644
index 0000000..09d711c
--- /dev/null
+++ b/detection/src/main/java/com/njcn/gather/detection/util/socket/communication/model/ConnectionContext.java
@@ -0,0 +1,111 @@
+package com.njcn.gather.detection.util.socket.communication.model;
+
+import cn.hutool.core.util.StrUtil;
+import com.njcn.gather.detection.util.socket.communication.handler.ConnectionLifecycleHandler;
+import com.njcn.gather.detection.util.socket.communication.handler.HeartbeatMessageStrategy;
+import com.njcn.gather.detection.util.socket.communication.handler.SocketMessageHandler;
+
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+
+/**
+ * Generic communication connection context.
+ * Stage 4-A keeps transport identity and optional callbacks in one place so
+ * Netty client/server code does not need to know detection business types.
+ *
+ * @author hongawen
+ * @date 2026/04/07
+ */
+public class ConnectionContext {
+
+ public static final String ATTR_PRE_DETECTION_PARAM = "preDetectionParam";
+
+ public static final String ATTR_CONTRAST_PARAM = "contrastDetectionParam";
+
+ public static final String ATTR_SOCKET_MESSAGE_HANDLER = "socketMessageHandler";
+
+ public static final String ATTR_CONNECTION_LIFECYCLE_HANDLER = "connectionLifecycleHandler";
+
+ public static final String ATTR_HEARTBEAT_MESSAGE_STRATEGY = "heartbeatMessageStrategy";
+
+ private final String userId;
+
+ private final ConnectionType connectionType;
+
+ private final Map attributes = new ConcurrentHashMap<>();
+
+ public ConnectionContext(String userId, ConnectionType connectionType) {
+ this.userId = userId;
+ this.connectionType = connectionType;
+ }
+
+ public static ConnectionContext of(String userId, ConnectionType connectionType) {
+ return new ConnectionContext(userId, connectionType);
+ }
+
+ public String getUserId() {
+ return userId;
+ }
+
+ public ConnectionType getConnectionType() {
+ return connectionType;
+ }
+
+ /**
+ * Key refactor point: the transport foundation now resolves the session
+ * key from one place instead of reassembling it across multiple classes.
+ */
+ public String getSessionKey() {
+ if (StrUtil.isBlank(userId) || connectionType == null) {
+ return userId;
+ }
+ return userId + connectionType.getSessionTag();
+ }
+
+ public ConnectionContext addAttribute(String key, Object value) {
+ if (StrUtil.isNotBlank(key) && value != null) {
+ attributes.put(key, value);
+ }
+ return this;
+ }
+
+ public ConnectionContext addMessageHandler(SocketMessageHandler handler) {
+ return addAttribute(ATTR_SOCKET_MESSAGE_HANDLER, handler);
+ }
+
+ public SocketMessageHandler getMessageHandler() {
+ return getAttribute(ATTR_SOCKET_MESSAGE_HANDLER, SocketMessageHandler.class);
+ }
+
+ public ConnectionContext addLifecycleHandler(ConnectionLifecycleHandler lifecycleHandler) {
+ return addAttribute(ATTR_CONNECTION_LIFECYCLE_HANDLER, lifecycleHandler);
+ }
+
+ public ConnectionLifecycleHandler getLifecycleHandler() {
+ return getAttribute(ATTR_CONNECTION_LIFECYCLE_HANDLER, ConnectionLifecycleHandler.class);
+ }
+
+ public ConnectionContext addHeartbeatStrategy(HeartbeatMessageStrategy heartbeatMessageStrategy) {
+ return addAttribute(ATTR_HEARTBEAT_MESSAGE_STRATEGY, heartbeatMessageStrategy);
+ }
+
+ public HeartbeatMessageStrategy getHeartbeatStrategy() {
+ return getAttribute(ATTR_HEARTBEAT_MESSAGE_STRATEGY, HeartbeatMessageStrategy.class);
+ }
+
+ public Object getAttribute(String key) {
+ return attributes.get(key);
+ }
+
+ public T getAttribute(String key, Class type) {
+ Object value = attributes.get(key);
+ if (type.isInstance(value)) {
+ return type.cast(value);
+ }
+ return null;
+ }
+
+ public Map getAttributes() {
+ return attributes;
+ }
+}
diff --git a/detection/src/main/java/com/njcn/gather/detection/util/socket/communication/model/ConnectionType.java b/detection/src/main/java/com/njcn/gather/detection/util/socket/communication/model/ConnectionType.java
new file mode 100644
index 0000000..0d7a383
--- /dev/null
+++ b/detection/src/main/java/com/njcn/gather/detection/util/socket/communication/model/ConnectionType.java
@@ -0,0 +1,42 @@
+package com.njcn.gather.detection.util.socket.communication.model;
+
+import com.njcn.gather.detection.util.socket.communication.constants.SocketTransportConstants;
+
+/**
+ * Communication connection types retained by the transport foundation.
+ * Stage 4-A keeps the session tags in a neutral constants class so the
+ * Netty/WebSocket base layer no longer depends on detection helpers.
+ *
+ * @author hongawen
+ * @date 2026/04/07
+ */
+public enum ConnectionType {
+
+ SOURCE(SocketTransportConstants.SOURCE_SESSION_TAG, "程控源", false),
+ DEVICE(SocketTransportConstants.DEVICE_SESSION_TAG, "被检设备", true),
+ CONTRAST(SocketTransportConstants.CONTRAST_SESSION_TAG, "比对被检设备", true);
+
+ private final String sessionTag;
+
+ private final String displayName;
+
+ private final boolean enableIdleMonitor;
+
+ ConnectionType(String sessionTag, String displayName, boolean enableIdleMonitor) {
+ this.sessionTag = sessionTag;
+ this.displayName = displayName;
+ this.enableIdleMonitor = enableIdleMonitor;
+ }
+
+ public String getSessionTag() {
+ return sessionTag;
+ }
+
+ public String getDisplayName() {
+ return displayName;
+ }
+
+ public boolean isEnableIdleMonitor() {
+ return enableIdleMonitor;
+ }
+}
diff --git a/detection/src/main/java/com/njcn/gather/detection/util/socket/config/SocketConnectionConfig.java b/detection/src/main/java/com/njcn/gather/detection/util/socket/config/SocketConnectionConfig.java
new file mode 100644
index 0000000..f053029
--- /dev/null
+++ b/detection/src/main/java/com/njcn/gather/detection/util/socket/config/SocketConnectionConfig.java
@@ -0,0 +1,169 @@
+package com.njcn.gather.detection.util.socket.config;
+
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.Set;
+
+/**
+ * Socket连接配置管理类
+ * 定义哪些requestId需要建立通道连接,以及IP/PORT配置
+ *
+ * @Author: hongawen
+ * @Date: 2024/12/10
+ */
+@Component
+@ConfigurationProperties(prefix = "socket")
+public class SocketConnectionConfig {
+
+ /**
+ * 程控源设备配置
+ */
+ private SourceConfig source = new SourceConfig();
+
+ /**
+ * 被检设备配置
+ */
+ private DeviceConfig device = new DeviceConfig();
+
+ @Data
+ public static class SourceConfig {
+ /**
+ * 程控源IP地址
+ */
+ private String ip;
+
+ /**
+ * 程控源端口号
+ */
+ private Integer port;
+ }
+
+ @Data
+ public static class DeviceConfig {
+ /**
+ * 被检设备IP地址
+ */
+ private String ip;
+
+ /**
+ * 被检设备端口号
+ */
+ private Integer port;
+ }
+
+ /**
+ * 获取程控源配置
+ */
+ public SourceConfig getSource() {
+ return source;
+ }
+
+ /**
+ * 获取被检设备配置
+ */
+ public DeviceConfig getDevice() {
+ return device;
+ }
+
+ /**
+ * 需要建立程控源通道的requestId集合
+ * 这些requestId在发送消息时,如果程控源通道不存在,会自动建立连接
+ */
+ private static final Set SOURCE_CONNECTION_REQUEST_IDS = new HashSet<>(Arrays.asList(
+ // 源通讯检测
+ "yjc_ytxjy"
+ // 可以根据实际业务需求添加更多requestId
+ ));
+
+ /**
+ * 需要建立被检设备通道的requestId集合
+ * 这些requestId在发送消息时,如果被检设备通道不存在,会自动建立连接
+ */
+ private static final Set DEVICE_CONNECTION_REQUEST_IDS = new HashSet<>(Arrays.asList(
+ // 连接建立
+ "yjc_sbtxjy",
+ // ftp文件传送指令
+ "FTP_SEND$01"
+ // 可以根据实际业务需求添加更多requestId
+ ));
+
+ /**
+ * 检查指定的requestId是否需要建立程控源连接
+ *
+ * @param requestId 请求ID
+ * @return boolean true:需要建立连接, false:不需要建立连接
+ */
+ public static boolean needsSourceConnection(String requestId) {
+ return SOURCE_CONNECTION_REQUEST_IDS.contains(requestId);
+ }
+
+ /**
+ * 检查指定的requestId是否需要建立被检设备连接
+ *
+ * @param requestId 请求ID
+ * @return boolean true:需要建立连接, false:不需要建立连接
+ */
+ public static boolean needsDeviceConnection(String requestId) {
+ return DEVICE_CONNECTION_REQUEST_IDS.contains(requestId);
+ }
+
+ /**
+ * 添加需要建立程控源连接的requestId
+ * 支持运行时动态添加
+ *
+ * @param requestId 请求ID
+ */
+ public static void addSourceConnectionRequestId(String requestId) {
+ SOURCE_CONNECTION_REQUEST_IDS.add(requestId);
+ }
+
+ /**
+ * 添加需要建立被检设备连接的requestId
+ * 支持运行时动态添加
+ *
+ * @param requestId 请求ID
+ */
+ public static void addDeviceConnectionRequestId(String requestId) {
+ DEVICE_CONNECTION_REQUEST_IDS.add(requestId);
+ }
+
+ /**
+ * 移除程控源连接requestId
+ *
+ * @param requestId 请求ID
+ */
+ public static void removeSourceConnectionRequestId(String requestId) {
+ SOURCE_CONNECTION_REQUEST_IDS.remove(requestId);
+ }
+
+ /**
+ * 移除被检设备连接requestId
+ *
+ * @param requestId 请求ID
+ */
+ public static void removeDeviceConnectionRequestId(String requestId) {
+ DEVICE_CONNECTION_REQUEST_IDS.remove(requestId);
+ }
+
+ /**
+ * 获取所有需要建立程控源连接的requestId集合(只读)
+ *
+ * @return Set requestId集合
+ */
+ public static Set getSourceConnectionRequestIds() {
+ return new HashSet<>(SOURCE_CONNECTION_REQUEST_IDS);
+ }
+
+ /**
+ * 获取所有需要建立被检设备连接的requestId集合(只读)
+ *
+ * @return Set requestId集合
+ */
+ public static Set getDeviceConnectionRequestIds() {
+ return new HashSet<>(DEVICE_CONNECTION_REQUEST_IDS);
+ }
+}
\ No newline at end of file
diff --git a/detection/src/main/java/com/njcn/gather/detection/util/socket/websocket/WebServiceManager.java b/detection/src/main/java/com/njcn/gather/detection/util/socket/websocket/WebServiceManager.java
new file mode 100644
index 0000000..1f9783b
--- /dev/null
+++ b/detection/src/main/java/com/njcn/gather/detection/util/socket/websocket/WebServiceManager.java
@@ -0,0 +1,87 @@
+package com.njcn.gather.detection.util.socket.websocket;
+
+import com.alibaba.fastjson.JSON;
+import io.netty.channel.Channel;
+import io.netty.handler.codec.http.websocketx.TextWebSocketFrame;
+import lombok.extern.slf4j.Slf4j;
+
+import java.time.LocalDateTime;
+import java.util.HashSet;
+import java.util.Iterator;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+
+/**
+ * Generic WebSocket session manager.
+ * Stage 4-A removes detection payload conventions and detection parameter
+ * caches from this class so it remains a pure WebSocket session registry.
+ *
+ * @author wr
+ * @author hongawen
+ * @date 2026/04/07
+ */
+@Slf4j
+public class WebServiceManager {
+
+ private static final Map USER_SESSIONS = new ConcurrentHashMap<>();
+
+ private WebServiceManager() {
+ }
+
+ public static void addUser(String userId, Channel channel) {
+ USER_SESSIONS.put(userId, channel);
+ }
+
+ public static Channel removeByUserId(String userId) {
+ return USER_SESSIONS.remove(userId);
+ }
+
+ @Deprecated
+ public static void removeChannel(String channelId) {
+ Iterator> iterator = USER_SESSIONS.entrySet().iterator();
+ while (iterator.hasNext()) {
+ Map.Entry entry = iterator.next();
+ if (entry.getValue().id().toString().equals(channelId)) {
+ iterator.remove();
+ break;
+ }
+ }
+ }
+
+ public static void sendMsg(String userId, String msg) {
+ Channel channel = USER_SESSIONS.get(userId);
+ if (Objects.nonNull(channel) && channel.isActive()) {
+ channel.writeAndFlush(new TextWebSocketFrame(msg));
+ return;
+ }
+ log.error("WebSocket push failed because session is offline, time={}, userId={}",
+ LocalDateTime.now(), userId);
+ WebSocketHandler.cleanupSocketResources(userId);
+ }
+
+ public static void sendJson(String userId, Object payload) {
+ Channel channel = USER_SESSIONS.get(userId);
+ if (Objects.nonNull(channel) && channel.isActive()) {
+ channel.writeAndFlush(new TextWebSocketFrame(JSON.toJSONString(payload)));
+ return;
+ }
+ log.error("WebSocket json push failed because session is offline, time={}, userId={}",
+ LocalDateTime.now(), userId);
+ WebSocketHandler.cleanupSocketResources(userId);
+ }
+
+ public static int getOnlineUserCount() {
+ return USER_SESSIONS.size();
+ }
+
+ public static boolean isUserOnline(String userId) {
+ Channel channel = USER_SESSIONS.get(userId);
+ return Objects.nonNull(channel) && channel.isActive();
+ }
+
+ public static Set getOnlineUserIds() {
+ return new HashSet<>(USER_SESSIONS.keySet());
+ }
+}
diff --git a/detection/src/main/java/com/njcn/gather/detection/util/socket/websocket/WebSocketConstants.java b/detection/src/main/java/com/njcn/gather/detection/util/socket/websocket/WebSocketConstants.java
new file mode 100644
index 0000000..a547218
--- /dev/null
+++ b/detection/src/main/java/com/njcn/gather/detection/util/socket/websocket/WebSocketConstants.java
@@ -0,0 +1,49 @@
+package com.njcn.gather.detection.util.socket.websocket;
+
+/**
+ * WebSocket常量管理类
+ *
+ * @author wr
+ * @date 2024/12/10
+ */
+public final class WebSocketConstants {
+
+ /**
+ * URL参数分隔符
+ */
+ public static final String QUESTION_MARK = "?";
+
+ /**
+ * URL参数等号分隔符
+ */
+ public static final String EQUAL_TO = "=";
+
+ /**
+ * 客户端心跳消息
+ */
+ public static final String HEARTBEAT_PING = "alive";
+
+ /**
+ * 服务端心跳响应
+ */
+ public static final String HEARTBEAT_PONG = "over";
+
+ /**
+ * 心跳超时最大次数
+ */
+ public static final int MAX_HEARTBEAT_MISS_COUNT = 3;
+
+ /**
+ * WebSocket握手失败状态码
+ */
+ public static final int HANDSHAKE_FAILED_STATUS = 4000;
+
+ /**
+ * WebSocket握手失败原因
+ */
+ public static final String HANDSHAKE_FAILED_REASON = "Missing required userId parameter";
+
+ private WebSocketConstants() {
+ // 私有构造函数,防止实例化
+ }
+}
\ No newline at end of file
diff --git a/detection/src/main/java/com/njcn/gather/detection/util/socket/websocket/WebSocketHandler.java b/detection/src/main/java/com/njcn/gather/detection/util/socket/websocket/WebSocketHandler.java
new file mode 100644
index 0000000..430baf5
--- /dev/null
+++ b/detection/src/main/java/com/njcn/gather/detection/util/socket/websocket/WebSocketHandler.java
@@ -0,0 +1,154 @@
+package com.njcn.gather.detection.util.socket.websocket;
+
+import com.njcn.gather.detection.util.socket.SocketManager;
+import com.njcn.gather.detection.util.socket.communication.model.ConnectionType;
+import io.netty.channel.ChannelHandlerContext;
+import io.netty.channel.SimpleChannelInboundHandler;
+import io.netty.handler.codec.CorruptedFrameException;
+import io.netty.handler.codec.DecoderException;
+import io.netty.handler.codec.http.websocketx.TextWebSocketFrame;
+import io.netty.handler.codec.http.websocketx.WebSocketHandshakeException;
+import io.netty.handler.codec.http.websocketx.WebSocketServerProtocolHandler;
+import io.netty.handler.timeout.IdleStateEvent;
+import io.netty.util.AttributeKey;
+import lombok.extern.slf4j.Slf4j;
+
+import java.io.IOException;
+
+import static com.njcn.gather.detection.util.socket.websocket.WebSocketConstants.HEARTBEAT_PING;
+import static com.njcn.gather.detection.util.socket.websocket.WebSocketConstants.HEARTBEAT_PONG;
+import static com.njcn.gather.detection.util.socket.websocket.WebSocketConstants.MAX_HEARTBEAT_MISS_COUNT;
+
+/**
+ * Generic WebSocket handler retained by the communication foundation.
+ * Stage 4-A keeps only handshake, heartbeat, session registry and transport
+ * cleanup. Detection-specific quit flows are removed from this class.
+ *
+ * @author wr
+ * @author hongawen
+ * @date 2026/04/07
+ */
+@Slf4j
+public class WebSocketHandler extends SimpleChannelInboundHandler {
+
+ private static final String HEARTBEAT_RESPONSE_TEXT = HEARTBEAT_PONG;
+
+ private int times;
+
+ private String userId;
+
+ @Override
+ public void channelActive(ChannelHandlerContext ctx) throws Exception {
+ log.info("WebSocket channel active: channelId={}", ctx.channel().id());
+ super.channelActive(ctx);
+ }
+
+ @Override
+ protected void channelRead0(ChannelHandlerContext ctx, TextWebSocketFrame msg) {
+ String messageText = msg.text();
+ if (HEARTBEAT_PING.equals(messageText)) {
+ handleHeartbeat(ctx);
+ return;
+ }
+ log.debug("Receive WebSocket business message: userId={}, channelId={}, message={}",
+ userId, ctx.channel().id(), messageText);
+ }
+
+ @Override
+ public void handlerAdded(ChannelHandlerContext ctx) {
+ log.info("WebSocket handler added: channelId={}", ctx.channel().id());
+ }
+
+ @Override
+ public void handlerRemoved(ChannelHandlerContext ctx) {
+ log.info("WebSocket handler removed: channelId={}, userId={}", ctx.channel().id(), userId);
+ if (userId != null) {
+ WebServiceManager.removeByUserId(userId);
+ } else {
+ WebServiceManager.removeChannel(ctx.channel().id().toString());
+ }
+ }
+
+ @Override
+ public void channelInactive(ChannelHandlerContext ctx) throws Exception {
+ log.info("WebSocket channel inactive: channelId={}, userId={}", ctx.channel().id(), userId);
+ cleanupSocketResources(userId);
+ super.channelInactive(ctx);
+ }
+
+ @Override
+ public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception {
+ if (evt instanceof WebSocketServerProtocolHandler.HandshakeComplete) {
+ WebSocketServerProtocolHandler.HandshakeComplete handshakeComplete =
+ (WebSocketServerProtocolHandler.HandshakeComplete) evt;
+ userId = ctx.channel().attr(AttributeKey.valueOf("userId")).get();
+ log.info("WebSocket handshake complete: userId={}, channelId={}, requestUri={}",
+ userId, ctx.channel().id(), handshakeComplete.requestUri());
+ if (userId != null) {
+ WebServiceManager.addUser(userId, ctx.channel());
+ }
+ sendConnectionSuccessMessage(ctx);
+ return;
+ }
+
+ if (evt instanceof IdleStateEvent) {
+ times++;
+ log.warn("WebSocket heartbeat miss: channelId={}, userId={}, missCount={}",
+ ctx.channel().id(), userId, times);
+ if (times > MAX_HEARTBEAT_MISS_COUNT) {
+ cleanupSocketResources(userId);
+ ctx.close();
+ }
+ return;
+ }
+
+ super.userEventTriggered(ctx, evt);
+ }
+
+ @Override
+ public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
+ logExceptionByType(ctx.channel().id().toString(), cause);
+ cleanupSocketResources(userId);
+ ctx.close();
+ }
+
+ private void sendConnectionSuccessMessage(ChannelHandlerContext ctx) {
+ String welcomeMessage = String.format(
+ "{\"type\":\"connection\",\"status\":\"success\",\"message\":\"WebSocket连接建立成功\",\"userId\":\"%s\",\"timestamp\":%d}",
+ userId, System.currentTimeMillis());
+ ctx.channel().writeAndFlush(new TextWebSocketFrame(welcomeMessage));
+ }
+
+ private void handleHeartbeat(ChannelHandlerContext ctx) {
+ times = 0;
+ ctx.channel().writeAndFlush(new TextWebSocketFrame(HEARTBEAT_RESPONSE_TEXT));
+ }
+
+ private void logExceptionByType(String channelId, Throwable cause) {
+ if (cause instanceof IOException) {
+ log.info("WebSocket network exception: channelId={}, message={}", channelId, cause.getMessage());
+ } else if (cause instanceof WebSocketHandshakeException) {
+ log.warn("WebSocket handshake exception: channelId={}, message={}", channelId, cause.getMessage());
+ } else if (cause instanceof DecoderException || cause instanceof CorruptedFrameException) {
+ log.error("WebSocket decode exception: channelId={}, message={}", channelId, cause.getMessage(), cause);
+ } else if (cause instanceof IllegalArgumentException) {
+ log.warn("WebSocket argument exception: channelId={}, message={}", channelId, cause.getMessage());
+ } else {
+ log.error("WebSocket unclassified exception: channelId={}, message={}", channelId, cause.getMessage(), cause);
+ }
+ }
+
+ /**
+ * Key refactor point: websocket disconnect now performs generic transport
+ * cleanup only, which makes this layer independent from detection flows.
+ */
+ public static void cleanupSocketResources(String userId) {
+ if (userId == null || userId.trim().isEmpty()) {
+ return;
+ }
+ WebServiceManager.removeByUserId(userId);
+ for (ConnectionType connectionType : ConnectionType.values()) {
+ SocketManager.removeUser(userId + connectionType.getSessionTag());
+ }
+ }
+}
diff --git a/detection/src/main/java/com/njcn/gather/detection/util/socket/websocket/WebSocketInitializer.java b/detection/src/main/java/com/njcn/gather/detection/util/socket/websocket/WebSocketInitializer.java
new file mode 100644
index 0000000..021d1d8
--- /dev/null
+++ b/detection/src/main/java/com/njcn/gather/detection/util/socket/websocket/WebSocketInitializer.java
@@ -0,0 +1,184 @@
+package com.njcn.gather.detection.util.socket.websocket;
+
+import io.netty.channel.ChannelHandlerContext;
+import io.netty.channel.ChannelInboundHandlerAdapter;
+import io.netty.channel.ChannelInitializer;
+import io.netty.channel.ChannelPipeline;
+import io.netty.channel.socket.SocketChannel;
+import io.netty.handler.codec.http.*;
+import io.netty.handler.codec.http.websocketx.WebSocketServerProtocolHandler;
+import io.netty.handler.stream.ChunkedWriteHandler;
+import io.netty.handler.timeout.IdleStateHandler;
+import io.netty.util.AttributeKey;
+import lombok.extern.slf4j.Slf4j;
+
+import java.util.concurrent.TimeUnit;
+
+/**
+ * WebSocket服务端管道初始化器
+ *
+ * 职责:
+ * 1. 为每个新的WebSocket连接配置处理器链(Pipeline)
+ * 2. 按正确顺序添加各种Handler,确保数据流正确处理
+ * 3. 配置HTTP到WebSocket的协议升级
+ * 4. 设置心跳检测和异常处理机制
+ *
+ * 处理流程:
+ * HTTP请求 → HTTP编解码 → 分块处理 → 消息聚合 → 协议升级 → 心跳检测 → 业务处理 → 异常处理
+ *
+ * @Description: webSocket服务端自定义配置
+ * @Author: wr
+ * @Date: 2024/12/10 14:20
+ */
+@Slf4j
+public class WebSocketInitializer extends ChannelInitializer {
+
+ /**
+ * WebSocket访问路径
+ */
+ private static final String WEBSOCKET_PATH = "/hello";
+
+ /**
+ * HTTP消息最大聚合大小:512KB
+ * 用于WebSocket握手和消息传输
+ */
+ private static final int MAX_CONTENT_LENGTH = 512 * 1024;
+
+ /**
+ * 心跳检测间隔:13秒
+ * 13秒内没有收到客户端消息则触发空闲事件
+ */
+ private static final int READER_IDLE_TIME_SECONDS = 13;
+
+ /**
+ * 为每个新连接初始化处理器管道
+ * 注意:Handler的添加顺序非常重要,决定了数据的处理流向
+ *
+ * @param ch 新建立的Socket通道
+ * @throws Exception 初始化过程中的异常
+ */
+ @Override
+ protected void initChannel(SocketChannel ch) throws Exception {
+ ChannelPipeline pipeline = ch.pipeline();
+
+ // 1. HTTP协议处理器
+ // HttpServerCodec = HttpRequestDecoder + HttpResponseEncoder
+ // 负责HTTP请求解码和HTTP响应编码
+ pipeline.addLast("http-codec", new HttpServerCodec());
+
+ // 2. 分块写入处理器
+ // 用于处理大文件的分块传输,防止内存溢出
+ // 支持ChunkedInput,如ChunkedFile、ChunkedNioFile等
+ pipeline.addLast("chunked-write", new ChunkedWriteHandler());
+
+ // 3. HTTP消息聚合器
+ // 将分片的HTTP消息重新组装成完整的FullHttpRequest或FullHttpResponse
+ // WebSocket握手需要完整的HTTP请求,所以这个Handler必须添加
+ pipeline.addLast("http-aggregator", new HttpObjectAggregator(MAX_CONTENT_LENGTH));
+
+ // 4. WebSocket URL预处理器
+ // 在WebSocket握手之前处理URL参数,验证用户ID
+ pipeline.addLast("websocket-preprocessor", new WebSocketPreprocessor());
+
+ // 5. WebSocket协议升级处理器
+ // 处理WebSocket握手,将HTTP协议升级为WebSocket协议
+ // 只有访问指定路径(WEBSOCKET_PATH)的请求才会被升级
+ // 升级后会移除HTTP相关的Handler,添加WebSocket相关的Handler
+ pipeline.addLast("websocket-protocol", new WebSocketServerProtocolHandler(WEBSOCKET_PATH));
+
+ // 6. 空闲状态检测器
+ // 检测连接的空闲状态,用于心跳机制
+ // readerIdleTime: 读空闲时间,writerIdleTime: 写空闲时间,allIdleTime: 读写空闲时间
+ pipeline.addLast("idle-state", new IdleStateHandler(READER_IDLE_TIME_SECONDS, 0, 0, TimeUnit.SECONDS));
+
+ // 7. 自定义WebSocket业务处理器
+ // 处理WebSocket帧,实现具体的业务逻辑
+ // 包括心跳处理、消息路由、连接管理等
+ pipeline.addLast("websocket-handler", new WebSocketHandler());
+
+ // 7. 全局异常处理器
+ // 处理整个管道中未被捕获的异常,作为最后的异常处理兜底
+ pipeline.addLast("exception-handler", new GlobalExceptionHandler());
+ }
+
+ /**
+ * WebSocket预处理器
+ * 在WebSocket握手之前验证URL参数并清理URL
+ */
+ private static class WebSocketPreprocessor extends ChannelInboundHandlerAdapter {
+
+ @Override
+ public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
+ if (msg instanceof FullHttpRequest) {
+ FullHttpRequest request = (FullHttpRequest) msg;
+ String uri = request.uri();
+
+ log.debug("WebSocket预处理器收到HTTP请求:{}", uri);
+
+ // 验证并提取userId
+ String userId = extractUserId(uri);
+ if (userId == null || userId.trim().isEmpty()) {
+ log.warn("WebSocket连接被拒绝:缺少userId参数, uri: {}", uri);
+ FullHttpResponse response = new DefaultFullHttpResponse(
+ HttpVersion.HTTP_1_1,
+ HttpResponseStatus.BAD_REQUEST
+ );
+ ctx.writeAndFlush(response).addListener(f -> ctx.close());
+ return;
+ }
+
+ // 将userId存储到Channel属性中
+ ctx.channel().attr(AttributeKey.valueOf("userId")).set(userId);
+
+ // 清理URL参数
+ if (uri.contains("?")) {
+ String cleanUri = uri.substring(0, uri.indexOf("?"));
+ request.setUri(cleanUri);
+ log.debug("URL已清理,原始: {}, 清理后: {}, userId: {}", uri, cleanUri, userId);
+ }
+ }
+
+ // 继续传递给下一个Handler
+ super.channelRead(ctx, msg);
+ }
+
+ private String extractUserId(String uri) {
+ if (!uri.contains("name=")) {
+ return null;
+ }
+ int start = uri.indexOf("name=") + 5;
+ int end = uri.indexOf("&", start);
+ if (end == -1) {
+ return uri.substring(start);
+ } else {
+ return uri.substring(start, end);
+ }
+ }
+ }
+
+ /**
+ * 全局异常处理器
+ * 作为管道中的最后一个Handler,捕获所有未处理的异常
+ */
+ private static class GlobalExceptionHandler extends ChannelInboundHandlerAdapter {
+
+ @Override
+ public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
+ // 记录异常详情,便于问题排查
+ log.error("WebSocket连接发生未处理异常,远程地址:{},异常信息:{}",
+ ctx.channel().remoteAddress(), cause.getMessage(), cause);
+
+ // 优雅关闭连接
+ if (ctx.channel().isActive()) {
+ ctx.close();
+ }
+ }
+
+ @Override
+ public void channelInactive(ChannelHandlerContext ctx) throws Exception {
+ log.debug("WebSocket连接断开,远程地址:{}", ctx.channel().remoteAddress());
+ super.channelInactive(ctx);
+ }
+ }
+}
+
diff --git a/detection/src/main/java/com/njcn/gather/detection/util/socket/websocket/WebSocketService.java b/detection/src/main/java/com/njcn/gather/detection/util/socket/websocket/WebSocketService.java
new file mode 100644
index 0000000..df44eb0
--- /dev/null
+++ b/detection/src/main/java/com/njcn/gather/detection/util/socket/websocket/WebSocketService.java
@@ -0,0 +1,237 @@
+package com.njcn.gather.detection.util.socket.websocket;
+
+
+import io.netty.bootstrap.ServerBootstrap;
+import io.netty.channel.Channel;
+import io.netty.channel.ChannelFuture;
+import io.netty.channel.ChannelOption;
+import io.netty.channel.EventLoopGroup;
+import io.netty.channel.nio.NioEventLoopGroup;
+import io.netty.channel.socket.nio.NioServerSocketChannel;
+import io.netty.handler.logging.LoggingHandler;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.boot.ApplicationArguments;
+import org.springframework.boot.ApplicationRunner;
+import org.springframework.stereotype.Component;
+
+import javax.annotation.PreDestroy;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.TimeUnit;
+
+
+/**
+ * WebSocket服务端核心类
+ *
+ * 职责:
+ * 1. 启动基于Netty的WebSocket服务器
+ * 2. 管理服务器生命周期(启动/关闭)
+ * 3. 提供高性能的WebSocket通信支持
+ *
+ * 特性:
+ * - 使用ApplicationRunner确保在Spring容器完全启动后再启动WebSocket服务
+ * - 使用CompletableFuture异步启动,避免阻塞Spring Boot主线程
+ * - 支持优雅关闭,确保资源正确释放
+ * - 完善的异常处理和日志记录
+ *
+ * @Description: websocket服务端
+ * @Author: wr
+ * @Date: 2024/12/10 13:59
+ */
+@Component
+@RequiredArgsConstructor
+@Slf4j
+public class WebSocketService implements ApplicationRunner {
+
+ /**
+ * WebSocket服务器监听端口
+ * 默认7777端口,可通过配置文件webSocket.port自定义
+ * 客户端连接地址:ws://host:port/hello?name=userId
+ */
+ @Value("${webSocket.port:7777}")
+ int port;
+
+ /**
+ * Netty Boss线程组
+ * 专门负责接受新的客户端连接请求
+ * 通常配置1个线程即可,因为接受连接的操作相对简单
+ */
+ EventLoopGroup bossGroup;
+
+ /**
+ * Netty Worker线程组
+ * 专门负责处理已建立连接的I/O操作和业务逻辑
+ * 默认线程数 = CPU核心数 * 2,用于并发处理多个客户端
+ */
+ EventLoopGroup workerGroup;
+
+ /**
+ * 服务器通道引用
+ * 保存绑定端口后的Channel,用于服务器关闭时释放资源
+ */
+ private Channel serverChannel;
+
+ /**
+ * 异步启动任务的Future对象
+ * 用于管理WebSocket服务器的异步启动过程
+ * 可以用来取消启动任务或检查启动状态
+ */
+ private CompletableFuture serverFuture;
+
+
+
+ /**
+ * Spring Boot应用启动完成后自动调用此方法
+ * 使用ApplicationRunner确保在所有Bean初始化完成后再启动WebSocket服务
+ */
+ @Override
+ public void run(ApplicationArguments args){
+ // 使用CompletableFuture异步启动WebSocket服务,避免阻塞Spring Boot主线程
+ // 这样可以让应用快速启动完成,WebSocket服务在后台异步启动
+ serverFuture = CompletableFuture.runAsync(this::startWebSocketServer)
+ .exceptionally(throwable -> {
+ // 如果启动过程中发生异常,记录日志但不影响应用启动
+ log.error("WebSocket服务启动异常", throwable);
+ return null;
+ });
+ }
+
+ /**
+ * 启动WebSocket服务器的核心方法
+ * 此方法会一直阻塞直到服务器关闭,所以需要在异步线程中执行
+ */
+ private void startWebSocketServer() {
+ try {
+ // 1. 创建线程组
+ // bossGroup: 专门负责接受新的客户端连接请求
+ // 可以自定义线程的数量,这里使用默认值(通常为1个线程)
+ bossGroup = new NioEventLoopGroup(1);
+
+ // workerGroup: 专门负责处理已建立连接的I/O操作
+ // 默认创建的线程数量 = CPU 处理器数量 * 2,用于处理业务逻辑
+ workerGroup = new NioEventLoopGroup();
+
+ // 2. 配置服务器启动参数
+ ServerBootstrap serverBootstrap = new ServerBootstrap();
+ serverBootstrap.group(bossGroup, workerGroup)
+ .channel(NioServerSocketChannel.class)
+ .handler(new LoggingHandler())
+ // 网络配置参数
+ .option(ChannelOption.SO_BACKLOG, 128)
+ // TCP连接建立超时时间5秒
+ .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 5000)
+ // 子通道配置(针对每个客户端连接)
+ // 启用TCP keepalive机制,检测死连接
+ .childOption(ChannelOption.SO_KEEPALIVE, true)
+ .childHandler(new WebSocketInitializer());
+
+ // 3. 绑定端口并启动服务器
+ ChannelFuture future = serverBootstrap.bind(port).sync();
+ // 保存服务器通道引用,用于后续关闭操作
+ serverChannel = future.channel();
+ // 4. 监听绑定结果并记录日志
+ future.addListener(f -> {
+ if (future.isSuccess()) {
+ log.info("webSocket服务启动成功,端口:{}", port);
+ } else {
+ log.error("webSocket服务启动失败,端口:{}", port);
+ }
+ });
+
+ // 5. 等待服务器关闭
+ // 这里会一直阻塞,直到serverChannel被外部关闭
+ // 这就是为什么需要在异步线程中执行此方法的原因
+ future.channel().closeFuture().sync();
+
+ } catch (InterruptedException e) {
+ // 如果线程被中断(比如应用关闭),记录日志并恢复中断状态
+ log.error("WebSocket服务启动过程中被中断", e);
+ Thread.currentThread().interrupt(); // 恢复中断状态
+ } catch (Exception e) {
+ // 捕获其他所有异常,记录日志并抛出运行时异常
+ log.error("WebSocket服务启动失败", e);
+ throw new RuntimeException("WebSocket服务启动失败", e);
+ } finally {
+ // 无论成功还是失败,都要清理资源
+ shutdownGracefully();
+ }
+ }
+
+
+ /**
+ * 优雅关闭Netty线程组资源
+ * 私有方法,用于在服务器启动异常时清理资源
+ */
+ private void shutdownGracefully() {
+ // 优雅关闭接收连接的线程组
+ if (bossGroup != null) {
+ bossGroup.shutdownGracefully();
+ }
+ // 优雅关闭处理I/O的线程组
+ if (workerGroup != null) {
+ workerGroup.shutdownGracefully();
+ }
+ }
+
+ /**
+ * Spring容器销毁时自动调用此方法释放资源
+ * 使用@PreDestroy确保在应用关闭时优雅地关闭WebSocket服务
+ */
+ @PreDestroy
+ public void destroy() throws InterruptedException {
+ log.info("正在关闭WebSocket服务...");
+
+ // 步骤1: 首先关闭服务器通道,停止接受新的连接请求
+ // 这样可以确保不会有新的客户端连接进来
+ if (serverChannel != null) {
+ try {
+ // 等待最多5秒让服务器通道关闭
+ serverChannel.close().awaitUninterruptibly(5, TimeUnit.SECONDS);
+ log.debug("服务器通道已关闭");
+ } catch (Exception e) {
+ log.warn("关闭服务器通道时发生异常", e);
+ }
+ }
+
+ // 步骤2: 关闭bossGroup线程组
+ // bossGroup负责接受连接,现在可以安全关闭了
+ if (bossGroup != null) {
+ try {
+ // 优雅关闭:静默期0秒,超时时间5秒
+ // 静默期0秒意味着立即开始关闭,超时5秒后强制关闭
+ bossGroup.shutdownGracefully(0, 5, TimeUnit.SECONDS).sync();
+ log.debug("bossGroup线程组已关闭");
+ } catch (InterruptedException e) {
+ log.warn("关闭bossGroup时被中断", e);
+ Thread.currentThread().interrupt(); // 恢复中断状态
+ }
+ }
+
+ // 步骤3: 关闭workerGroup线程组
+ // workerGroup负责处理I/O,需要等待现有连接处理完成
+ if (workerGroup != null) {
+ try {
+ // 等待现有任务完成,但最多等待5秒
+ workerGroup.shutdownGracefully(0, 5, TimeUnit.SECONDS).sync();
+ log.debug("workerGroup线程组已关闭");
+ } catch (InterruptedException e) {
+ log.warn("关闭workerGroup时被中断", e);
+ Thread.currentThread().interrupt(); // 恢复中断状态
+ }
+ }
+
+ // 步骤4: 取消异步启动任务(如果还在运行)
+ // 这可以避免在应用关闭后还有线程在后台运行
+ if (serverFuture != null && !serverFuture.isDone()) {
+ // true表示允许中断正在执行的任务
+ boolean cancelled = serverFuture.cancel(true);
+ if (cancelled) {
+ log.debug("异步启动任务已取消");
+ }
+ }
+
+ log.info("webSocket服务已销毁");
+ }
+
+}
diff --git a/entrance/pom.xml b/entrance/pom.xml
new file mode 100644
index 0000000..9f044dc
--- /dev/null
+++ b/entrance/pom.xml
@@ -0,0 +1,74 @@
+
+
+ 4.0.0
+
+ com.njcn.gather
+ CN_Tool
+ 1.0.0
+
+ entrance
+
+
+
+ com.njcn.gather
+ system
+ 1.0.0
+
+
+ com.njcn.gather
+ detection
+ 1.0.0
+
+
+ com.njcn.gather
+ user
+ 1.0.0
+
+
+
+ com.njcn.gather
+ activate-tool
+ 1.0.0
+
+
+
+
+ entrance
+
+
+ org.springframework.boot
+ spring-boot-maven-plugin
+
+
+ package
+
+ repackage
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-compiler-plugin
+ 3.8.1
+
+ 1.8
+ 1.8
+
+
+
+
+
+ src/main/resources
+
+ **/*
+
+
+
+
+
+
diff --git a/entrance/src/main/java/com/njcn/gather/EntranceApplication.java b/entrance/src/main/java/com/njcn/gather/EntranceApplication.java
new file mode 100644
index 0000000..ea7ca1b
--- /dev/null
+++ b/entrance/src/main/java/com/njcn/gather/EntranceApplication.java
@@ -0,0 +1,20 @@
+package com.njcn.gather;
+
+import lombok.extern.slf4j.Slf4j;
+import org.mybatis.spring.annotation.MapperScan;
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.context.annotation.EnableAspectJAutoProxy;
+
+
+@Slf4j
+@MapperScan("com.njcn.**.mapper")
+@SpringBootApplication(scanBasePackages = "com.njcn")
+//@EnableAspectJAutoProxy
+public class EntranceApplication {
+
+ public static void main(String[] args) {
+ SpringApplication.run(EntranceApplication.class, args);
+ }
+
+}
diff --git a/entrance/src/main/resources/application.yml b/entrance/src/main/resources/application.yml
new file mode 100644
index 0000000..4d3d925
--- /dev/null
+++ b/entrance/src/main/resources/application.yml
@@ -0,0 +1,54 @@
+server:
+ port: 18192
+
+spring:
+ application:
+ name: entrance
+ datasource:
+ druid:
+ driver-class-name: com.mysql.cj.jdbc.Driver
+ url: jdbc:mysql://192.168.1.22:13306/cn_tool?useUnicode=true&characterEncoding=utf-8&useSSL=true&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true
+ username: root
+ password: njcnpqs
+ initial-size: 5
+ min-idle: 5
+ max-active: 50
+ max-wait: 60000
+ min-evictable-idle-time-millis: 300000
+ validation-query: select 1
+ test-while-idle: true
+ test-on-borrow: false
+ test-on-return: false
+ pool-prepared-statements: true
+ max-pool-prepared-statement-per-connection-size: 20
+
+mybatis-plus:
+ mapper-locations: classpath*:com/njcn/**/mapping/*.xml
+ # Key refactor point: remove stale business alias packages and retain only
+ # the surviving foundational modules.
+ type-aliases-package: com.njcn.gather.system.dictionary.pojo.po
+ configuration:
+ map-underscore-to-camel-case: true
+ log-impl: org.apache.ibatis.logging.nologging.NoLoggingImpl
+ global-config:
+ db-config:
+ id-type: assign_uuid
+
+socket:
+ source:
+ ip: 127.0.0.1
+ port: 62000
+ device:
+ ip: 127.0.0.1
+ port: 61000
+
+webSocket:
+ port: 7777
+
+log:
+ homeDir: D:\logs
+ commonLevel: info
+
+activate:
+ private-key: "MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCcUyYhVqczGxblL+o/xZzF/8nf+LjrfUE/dS1aRHM7uMDD0cgCArhjtfneFePrMxt+Z7W8yNBzSarub8qsfhaVNikV7Es7oaeTygfjQXTi2n4AFkir3fM07J08RpWhl5M8f8uWTCuvFUYAw00gq55typqmnbkmJa2VIUy/iQf+cMCP7abz4/jNhUzUR3qA7TV4oMRgTdIEDUp63YF8dOC+JH8XxYrCVeHXV6fLCwmesdMzl0lB2VTEKMfLbXhOmF5g7P9y/16VCcN8UBuZlbyYfn+GAxJOSbeHi5HshOKfoSuD7Jz+3WQZpNavOWjIFExKIU38/CvnJCOP7XBCqpSTAgMBAAECggEAYeWokWRE3TpvwiOZnUpR/aVMdVi75a3ROL5XIpqPV61B+t/bU3cEpl0GF9C5pUeiRi0IoStZb3mI9D1KPW/REKyUWkhabQO1gFYbTnRlkNOn6MILzKX4cwJjDaZeeo4EBPU7N+qHyOOXrU6hdH5FfxhMdV983ajm5eeuupxER1C2kAcIklTeVpTX6EKOgZb5LBp5ssOVm2P42pOauvcRozRcvZmqnErXmukv0H4l3EVNt4rHpTn9riHUC63e8JfiYzVaF6zuNUxv6nHEft0/SRMw11XSTnNfDzcKqgjz6ksFBS/6eQQYKESk+ONC53HUuYHFAknkwsPupDCT2W8FIQKBgQDLHT/xCU3nxGr4vFKBDNaO2D5oK20ECbBO4oDvLWWmQG7f+6TsMy8PgVdMnoL4RfqGlwFAKEpS6KVFHnBVqnNEhcdy9uCI7x7Xx8UnyUtxj1EDTm76uta9Ki9OrlqB6tImDM9+Ya3vGktW37ht4WOx2OsJRhG1dbf6RLwFlH7DWwKBgQDFBxvi5I1BR6hg6Tj7xd2SqOT2Y+BED3xuSYENhWbmMhLJDResaB7mjztbxlYaY2mOE0holWm2uDmVFFhMh4jYXik4hYH8nmDzq9mDpZCZ9pyjYqnAP8THoAa8EbgrUWB8A6BPH4iL3KbMnBfBKY0pIr2xrvnjQjNBAgta7KDRKQKBgCe6oe4wxrdF2TKsC2tIqpMoQxS3Icy/ZGgZr+SYuaBKTCWtoDW/UT40K3JGMxIDBhzbXphBCUCsVt9tM8Xd4EwP6tJW7dZ7B0pnve2pVwNwaAVAiz6p2yUHIle+jN+Koe5lZRSwYIg7WW81tWpwwsJfzqFyvjYDP6hJV4mz4ROvAoGAaRcdnKvjXApomShMqJ4lTPChD3q+SA8qg3jZSOj6tZXHx00gb2kp8jg7pPvpOTIFPy6x1Ha9aCRjMk0ju84fA6lVuzwa1S907wOehUVuF3Eeo1cgy9Y3k3KbpPyeixxgpkUY4JslLdSHc2NemD0dee951qhJyRmqVOZOQDUuoeECgYEAqBw2cAFk3vM97WY06TSldGA8ajVHx3BYRjj+zl62NTQthy8fw3tqxb3c5e8toOmZWKjZvDhg2TRLhsDDQWEYg3LZG87REqVIjgEPcpjNLidjygGX8n3JF2o0O5I/EMvl0s/+LVQONfduOBvhwDqr8QNisbLsyneiAq7umewMolo="
+ public-key: "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAnFMmIVanMxsW5S/qP8Wcxf/J3/i4631BP3UtWkRzO7jAw9HIAgK4Y7X53hXj6zMbfme1vMjQc0mq7m/KrH4WlTYpFexLO6Gnk8oH40F04tp+ABZIq93zNOydPEaVoZeTPH/LlkwrrxVGAMNNIKuebcqapp25JiWtlSFMv4kH/nDAj+2m8+P4zYVM1Ed6gO01eKDEYE3SBA1Ket2BfHTgviR/F8WKwlXh11enywsJnrHTM5dJQdlUxCjHy214TpheYOz/cv9elQnDfFAbmZW8mH5/hgMSTkm3h4uR7ITin6Erg+yc/t1kGaTWrzloyBRMSiFN/Pwr5yQjj+1wQqqUkwIDAQAB"
diff --git a/entrance/src/main/resources/logback.xml b/entrance/src/main/resources/logback.xml
new file mode 100644
index 0000000..5a87599
--- /dev/null
+++ b/entrance/src/main/resources/logback.xml
@@ -0,0 +1,140 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n
+ UTF-8
+
+
+
+
+
+
+
+ ${logHomeDir}/${log.projectName}/debug/debug.log
+
+
+
+
+ DEBUG
+
+ ACCEPT
+
+ DENY
+
+
+
+
+
+ ${logHomeDir}/${log.projectName}/debug/debug.log.%d{yyyy-MM-dd}.%i.log
+
+ 10MB
+
+ ${log.maxHistory:-30}
+
+
+
+
+
+
+
+
+
+ ${log.pattern}
+
+ UTF-8
+
+
+
+
+
+
+ INFO
+ ACCEPT
+ DENY
+
+
+ ${logHomeDir}/${log.projectName}/info/info.log
+
+
+
+ ${logHomeDir}/${log.projectName}/info/info.log.%d{yyyy-MM-dd}.%i.log
+
+ 10MB
+ ${log.maxHistory:-30}
+
+
+
+ ${log.pattern}
+
+ UTF-8
+
+
+
+
+
+
+
+ ${logHomeDir}/${log.projectName}/error/error.log
+
+
+ ERROR
+ ACCEPT
+ DENY
+
+
+
+ ${logHomeDir}/${log.projectName}/error/error.log.%d{yyyy-MM-dd}.%i.log
+
+ 10MB
+ ${log.maxHistory:-30}
+
+
+
+ ${log.pattern}
+
+ UTF-8
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/pom.xml b/pom.xml
new file mode 100644
index 0000000..ce81522
--- /dev/null
+++ b/pom.xml
@@ -0,0 +1,87 @@
+
+
+ 4.0.0
+ com.njcn.gather
+ CN_Tool
+ 1.0.0
+ pom
+ CN_Tool
+
+
+ entrance
+ system
+ user
+ detection
+ tools
+
+
+
+
+ nexus-releases
+ Nexus Release Repository
+ http://192.168.1.22:8001/nexus/content/repositories/releases/
+
+
+ nexus-snapshots
+ Nexus Snapshot Repository
+ http://192.168.1.22:8001/nexus/content/repositories/snapshots/
+
+
+
+
+ 2.3.12.RELEASE
+ UTF-8
+ UTF-8
+ UTF-8
+
+
+
+
+
+ org.springframework.boot
+ spring-boot-dependencies
+ ${spring-boot.version}
+ pom
+ import
+
+
+
+
+
+
+
+ org.springframework.boot
+ spring-boot-maven-plugin
+ 2.2.2.RELEASE
+
+ true
+ true
+
+
+
+ org.apache.maven.plugins
+ maven-compiler-plugin
+ 3.1
+
+ 1.8
+ 1.8
+ UTF-8
+
+
+
+
+
+ src/main/resources
+ true
+
+
+ src/main/java
+
+ **/*.xml
+
+
+
+
+
diff --git a/system/Readme.md b/system/Readme.md
new file mode 100644
index 0000000..3d336e5
--- /dev/null
+++ b/system/Readme.md
@@ -0,0 +1,9 @@
+#### 简介
+ 系统模块主要包含以下功能:
+* 审计日志管理
+* 字典、树形字典管理
+* 版本注册
+* 主题管理
+* 系统文件资源管理
+* 定时任务管理
+
\ No newline at end of file
diff --git a/system/pom.xml b/system/pom.xml
new file mode 100644
index 0000000..d0a7a61
--- /dev/null
+++ b/system/pom.xml
@@ -0,0 +1,55 @@
+
+
+ 4.0.0
+
+ com.njcn.gather
+ CN_Tool
+ 1.0.0
+
+ system
+
+
+ com.njcn
+ njcn-common
+ 0.0.1
+
+
+
+ com.njcn
+ mybatis-plus
+ 0.0.1
+
+
+
+ com.njcn
+ spingboot2.3.12
+ 2.3.12
+
+
+
+ com.njcn.gather
+ user
+ 1.0.0
+
+
+ com.alibaba
+ fastjson
+ 1.2.83
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/system/src/main/java/com/njcn/gather/system/cfg/controller/SysTestConfigController.java b/system/src/main/java/com/njcn/gather/system/cfg/controller/SysTestConfigController.java
new file mode 100644
index 0000000..784af70
--- /dev/null
+++ b/system/src/main/java/com/njcn/gather/system/cfg/controller/SysTestConfigController.java
@@ -0,0 +1,80 @@
+package com.njcn.gather.system.cfg.controller;
+
+import com.njcn.common.pojo.annotation.OperateInfo;
+import com.njcn.common.pojo.constant.OperateType;
+import com.njcn.common.pojo.enums.common.LogEnum;
+import com.njcn.common.pojo.enums.response.CommonResponseEnum;
+import com.njcn.common.pojo.response.HttpResult;
+import com.njcn.common.utils.LogUtil;
+import com.njcn.gather.system.cfg.pojo.param.SysTestConfigParam;
+import com.njcn.gather.system.cfg.pojo.po.SysTestConfig;
+import com.njcn.gather.system.cfg.service.ISysTestConfigService;
+import com.njcn.web.controller.BaseController;
+import com.njcn.web.utils.HttpResultUtil;
+import io.swagger.annotations.Api;
+import io.swagger.annotations.ApiImplicitParam;
+import io.swagger.annotations.ApiOperation;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.*;
+
+
+/**
+ * @author caozehui
+ * @date 2024-11-16
+ */
+@Slf4j
+@Api(tags = "检测相关配置")
+@RestController
+@RequestMapping("/sysTestConfig")
+@RequiredArgsConstructor
+public class SysTestConfigController extends BaseController {
+ private final ISysTestConfigService sysTestConfigService;
+
+ @OperateInfo(info = LogEnum.SYSTEM_COMMON)
+ @GetMapping("/getConfig")
+ @ApiOperation("获取检测相关配置信息")
+ public HttpResult getConfig() {
+ String methodDescribe = getMethodDescribe("getConfig");
+ LogUtil.njcnDebug(log, "{}", methodDescribe);
+ SysTestConfig sysTestConfig = sysTestConfigService.getOneConfig();
+ return HttpResultUtil.assembleCommonResponseResult(CommonResponseEnum.SUCCESS, sysTestConfig, methodDescribe);
+ }
+
+ @OperateInfo(info = LogEnum.SYSTEM_COMMON, operateType = OperateType.UPDATE)
+ @PostMapping("/update")
+ @ApiOperation("修改检测相关配置信息")
+ @ApiImplicitParam(name = "sysTestConfig", value = "检测相关配置信息", required = true)
+ public HttpResult update(@RequestBody @Validated SysTestConfigParam.UpdateParam sysTestConfig) {
+ String methodDescribe = getMethodDescribe("update");
+ LogUtil.njcnDebug(log, "{}", methodDescribe);
+ boolean result = sysTestConfigService.updateTestConfig(sysTestConfig);
+ if (result) {
+ return HttpResultUtil.assembleCommonResponseResult(CommonResponseEnum.SUCCESS, true, methodDescribe);
+ } else {
+ return HttpResultUtil.assembleCommonResponseResult(CommonResponseEnum.FAIL, false, methodDescribe);
+ }
+ }
+
+ @OperateInfo(info = LogEnum.SYSTEM_COMMON)
+ @ApiOperation("获取当前场景")
+ @GetMapping("/getCurrentScene")
+ public HttpResult getCurrentScene() {
+ String methodDescribe = getMethodDescribe("getCurrentScene");
+ LogUtil.njcnDebug(log, "{}", methodDescribe);
+ String currrentScene = sysTestConfigService.getCurrrentScene();
+ return HttpResultUtil.assembleCommonResponseResult(CommonResponseEnum.SUCCESS, currrentScene, methodDescribe);
+ }
+
+ @OperateInfo(info = LogEnum.SYSTEM_COMMON)
+ @ApiOperation("获取是否在检测时同时生成报告")
+ @GetMapping("/getAutoGenerate")
+ public HttpResult getAutoGenerate() {
+ String methodDescribe = getMethodDescribe("getAutoGenerate");
+ LogUtil.njcnDebug(log, "{}", methodDescribe);
+ Integer autoGenerate = sysTestConfigService.getAutoGenerate();
+ return HttpResultUtil.assembleCommonResponseResult(CommonResponseEnum.SUCCESS, autoGenerate, methodDescribe);
+ }
+}
+
diff --git a/system/src/main/java/com/njcn/gather/system/cfg/mapper/SysTestConfigMapper.java b/system/src/main/java/com/njcn/gather/system/cfg/mapper/SysTestConfigMapper.java
new file mode 100644
index 0000000..54d8a5a
--- /dev/null
+++ b/system/src/main/java/com/njcn/gather/system/cfg/mapper/SysTestConfigMapper.java
@@ -0,0 +1,13 @@
+package com.njcn.gather.system.cfg.mapper;
+
+import com.github.yulichang.base.MPJBaseMapper;
+import com.njcn.gather.system.cfg.pojo.po.SysTestConfig;
+
+/**
+ * @author caozehui
+ * @date 2024-11-16
+ */
+public interface SysTestConfigMapper extends MPJBaseMapper {
+
+}
+
diff --git a/system/src/main/java/com/njcn/gather/system/cfg/mapper/mapping/SysTestConfigMapper.xml b/system/src/main/java/com/njcn/gather/system/cfg/mapper/mapping/SysTestConfigMapper.xml
new file mode 100644
index 0000000..17fd037
--- /dev/null
+++ b/system/src/main/java/com/njcn/gather/system/cfg/mapper/mapping/SysTestConfigMapper.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/system/src/main/java/com/njcn/gather/system/cfg/pojo/enums/SceneEnum.java b/system/src/main/java/com/njcn/gather/system/cfg/pojo/enums/SceneEnum.java
new file mode 100644
index 0000000..02c2ebc
--- /dev/null
+++ b/system/src/main/java/com/njcn/gather/system/cfg/pojo/enums/SceneEnum.java
@@ -0,0 +1,42 @@
+package com.njcn.gather.system.cfg.pojo.enums;
+
+import lombok.Getter;
+
+/**
+ * @author caozehui
+ * @data 2025-03-25
+ */
+@Getter
+public enum SceneEnum {
+ /**
+ * 省级平台
+ */
+ PROVINCE_PLATFORM("0", "province_platform"),
+
+ /**
+ * 设备出场
+ */
+ LEAVE_FACTORY_TEST("1", "leave_factory_test"),
+
+ /**
+ * 研发自测
+ */
+ SELF_TEST("2", "self_test");
+
+ private String value;
+ private String msg;
+
+ SceneEnum(String value, String msg) {
+ this.value = value;
+ this.msg = msg;
+ }
+
+ public static SceneEnum getSceneEnum(String value) {
+ for (SceneEnum sceneEnum : SceneEnum.values()) {
+ if (sceneEnum.getValue().equals(value)) {
+ return sceneEnum;
+ }
+ }
+ return null;
+ }
+}
diff --git a/system/src/main/java/com/njcn/gather/system/cfg/pojo/param/SysTestConfigParam.java b/system/src/main/java/com/njcn/gather/system/cfg/pojo/param/SysTestConfigParam.java
new file mode 100644
index 0000000..89de591
--- /dev/null
+++ b/system/src/main/java/com/njcn/gather/system/cfg/pojo/param/SysTestConfigParam.java
@@ -0,0 +1,40 @@
+package com.njcn.gather.system.cfg.pojo.param;
+
+import com.njcn.common.pojo.constant.PatternRegex;
+import com.njcn.gather.system.pojo.constant.SystemValidMessage;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+
+import javax.validation.constraints.*;
+
+/**
+ * @author caozehui
+ * @data 2024/11/16
+ */
+@Data
+public class SysTestConfigParam {
+
+ @ApiModelProperty(value = "检测报告是否自动生成0 否;1是")
+ @Min(value = 0, message = SystemValidMessage.AUTO_GENERATE_FORMAT_ERROR)
+ @Max(value = 1, message = SystemValidMessage.AUTO_GENERATE_FORMAT_ERROR)
+ private Integer autoGenerate;
+
+ @ApiModelProperty(value = "最大检测次数")
+ private Integer maxTime;
+
+ @ApiModelProperty(value = "数据精度")
+ private Integer scale;
+
+ @ApiModelProperty(value = "场景")
+ private String scene;
+
+ @ApiModelProperty(value = "比对监测后,当电压、电流不符合时,是否对标准设备进行系数校准")
+ private Integer coefficient;
+
+ @Data
+ public static class UpdateParam extends SysTestConfigParam {
+ @ApiModelProperty("id")
+ @Pattern(regexp = PatternRegex.SYSTEM_ID, message = SystemValidMessage.ID_FORMAT_ERROR)
+ private String id;
+ }
+}
diff --git a/system/src/main/java/com/njcn/gather/system/cfg/pojo/po/SysTestConfig.java b/system/src/main/java/com/njcn/gather/system/cfg/pojo/po/SysTestConfig.java
new file mode 100644
index 0000000..f881bee
--- /dev/null
+++ b/system/src/main/java/com/njcn/gather/system/cfg/pojo/po/SysTestConfig.java
@@ -0,0 +1,65 @@
+package com.njcn.gather.system.cfg.pojo.po;
+
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.njcn.db.mybatisplus.bo.BaseEntity;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+import java.io.Serializable;
+
+/**
+ * @author caozehui
+ * @date 2024-11-16
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+@TableName("sys_test_config")
+public class SysTestConfig extends BaseEntity implements Serializable {
+ private static final long serialVersionUID = 352471858515754310L;
+ /**
+ * 系统配置表Id
+ */
+ private String id;
+
+ /**
+ * 检测报告是否自动生成: 0 否;1 是
+ */
+ @TableField("Auto_Generate")
+ private Integer autoGenerate;
+
+ /**
+ * 最大检测次数,默认3次
+ */
+ @TableField("Max_Time")
+ private Integer maxTime;
+
+ /**
+ * 数据处理规则, 关联字典(所有值、部分值、cp95值、平均值、任意值),默认任意值
+ */
+// @TableField("Data_Rule")
+// private String dataRule;
+
+ /**
+ * 业务场景
+ */
+ @TableField("Scene")
+ private String scene;
+
+ /**
+ * 小数点精度
+ */
+ private Integer scale;
+
+ /**
+ * 比对监测后,当电压、电流不符合时,是否对标准设备进行系数校准
+ */
+ private Integer coefficient;
+
+
+ /**
+ * 状态:0-删除 1-正常
+ */
+ private Integer state;
+}
+
diff --git a/system/src/main/java/com/njcn/gather/system/cfg/service/ISysTestConfigService.java b/system/src/main/java/com/njcn/gather/system/cfg/service/ISysTestConfigService.java
new file mode 100644
index 0000000..c490339
--- /dev/null
+++ b/system/src/main/java/com/njcn/gather/system/cfg/service/ISysTestConfigService.java
@@ -0,0 +1,41 @@
+package com.njcn.gather.system.cfg.service;
+
+import com.baomidou.mybatisplus.extension.service.IService;
+import com.njcn.gather.system.cfg.pojo.param.SysTestConfigParam;
+import com.njcn.gather.system.cfg.pojo.po.SysTestConfig;
+
+/**
+ * @author caozehui
+ * @date 2024-11-16
+ */
+public interface ISysTestConfigService extends IService {
+
+ /**
+ * 添加检测配置
+ * @param scene 场景
+ * @return 是否添加成功
+ */
+ boolean addTestConfig(String scene);
+
+ /**
+ * 更新检测配置
+ * @param param 检测配置
+ * @return 是否更新成功
+ */
+ boolean updateTestConfig(SysTestConfigParam.UpdateParam param);
+
+ /**
+ * 获取检测配置
+ * @return
+ */
+ SysTestConfig getOneConfig();
+
+ String getCurrrentScene();
+
+ /**
+ * 获取是否在检测时自动生成报告
+ *
+ * @return 0-否,1-是
+ */
+ Integer getAutoGenerate();
+}
diff --git a/system/src/main/java/com/njcn/gather/system/cfg/service/impl/SysTestConfigServiceImpl.java b/system/src/main/java/com/njcn/gather/system/cfg/service/impl/SysTestConfigServiceImpl.java
new file mode 100644
index 0000000..8383266
--- /dev/null
+++ b/system/src/main/java/com/njcn/gather/system/cfg/service/impl/SysTestConfigServiceImpl.java
@@ -0,0 +1,78 @@
+package com.njcn.gather.system.cfg.service.impl;
+
+import cn.hutool.core.util.ObjectUtil;
+import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
+import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
+import com.njcn.common.pojo.enums.common.DataStateEnum;
+import com.njcn.gather.system.cfg.mapper.SysTestConfigMapper;
+import com.njcn.gather.system.cfg.pojo.param.SysTestConfigParam;
+import com.njcn.gather.system.cfg.pojo.po.SysTestConfig;
+import com.njcn.gather.system.cfg.service.ISysTestConfigService;
+import com.njcn.gather.system.dictionary.pojo.po.DictData;
+import com.njcn.gather.system.dictionary.service.IDictDataService;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.lang3.StringUtils;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+/**
+ * @author caozehui
+ * @date 2024-11-16
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class SysTestConfigServiceImpl extends ServiceImpl implements ISysTestConfigService {
+
+ private final IDictDataService dictDataService;
+
+
+ @Override
+ @Transactional
+ public boolean addTestConfig(String scene) {
+ SysTestConfig sysTestConfig = new SysTestConfig();
+ sysTestConfig.setAutoGenerate(1);
+ // 最大被检次数默认为3次
+ sysTestConfig.setMaxTime(3);
+ //sysTestConfig.setDataRule("46cf964bd76fb12a19cfb1700442eeeb"); // 任意值
+ sysTestConfig.setScene(scene);
+ sysTestConfig.setState(DataStateEnum.ENABLE.getCode());
+ return this.save(sysTestConfig);
+ }
+
+ @Override
+ @Transactional
+ public boolean updateTestConfig(SysTestConfigParam.UpdateParam param) {
+ SysTestConfig oneConfig = this.getOneConfig();
+ oneConfig.setAutoGenerate(ObjectUtil.isNotNull(param.getAutoGenerate()) ? param.getAutoGenerate() : oneConfig.getAutoGenerate());
+ oneConfig.setScale(ObjectUtil.isNotNull(param.getScale()) ? param.getScale() : oneConfig.getScale());
+ oneConfig.setMaxTime(ObjectUtil.isNotNull(param.getMaxTime()) ? param.getMaxTime() : oneConfig.getMaxTime());
+ oneConfig.setScene(StringUtils.isNotBlank(param.getScene()) ? param.getScene() : oneConfig.getScene());
+ oneConfig.setCoefficient(param.getCoefficient());
+ return this.updateById(oneConfig);
+ }
+
+ @Override
+ public SysTestConfig getOneConfig() {
+ QueryWrapper queryWrapper = new QueryWrapper<>();
+ queryWrapper.eq("state", DataStateEnum.ENABLE.getCode());
+ queryWrapper.last("LIMIT 1");
+ return this.getOne(queryWrapper);
+ }
+
+ @Override
+ public String getCurrrentScene() {
+ String scene = getOneConfig().getScene();
+ DictData dictData = dictDataService.getDictDataById(scene);
+ if (ObjectUtil.isNotNull(dictData)) {
+ return dictData.getValue();
+ }
+ return null;
+ }
+
+ @Override
+ public Integer getAutoGenerate() {
+ return getOneConfig().getAutoGenerate();
+ }
+}
diff --git a/system/src/main/java/com/njcn/gather/system/config/LogExecutorConfig.java b/system/src/main/java/com/njcn/gather/system/config/LogExecutorConfig.java
new file mode 100644
index 0000000..215a7a5
--- /dev/null
+++ b/system/src/main/java/com/njcn/gather/system/config/LogExecutorConfig.java
@@ -0,0 +1,31 @@
+package com.njcn.gather.system.config;
+
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.LinkedBlockingQueue;
+import java.util.concurrent.ThreadPoolExecutor;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+
+@Slf4j
+@Configuration
+public class LogExecutorConfig {
+
+ @Bean(name = "logAuditExecutor", destroyMethod = "shutdown")
+ public ExecutorService logAuditExecutor() {
+ AtomicInteger threadIndex = new AtomicInteger(1);
+ return new ThreadPoolExecutor(
+ 4, 8, 30, TimeUnit.SECONDS,
+ new LinkedBlockingQueue<>(100),
+ runnable -> {
+ Thread thread = new Thread(runnable);
+ thread.setName("log-audit-" + threadIndex.getAndIncrement());
+ return thread;
+ },
+ (runnable, executor) -> log.warn("审计日志线程池已满,丢弃本次日志任务")
+ );
+ }
+}
diff --git a/system/src/main/java/com/njcn/gather/system/config/WebConfig.java b/system/src/main/java/com/njcn/gather/system/config/WebConfig.java
new file mode 100644
index 0000000..6a56e2b
--- /dev/null
+++ b/system/src/main/java/com/njcn/gather/system/config/WebConfig.java
@@ -0,0 +1,45 @@
+package com.njcn.gather.system.config;
+
+import cn.hutool.extra.spring.SpringUtil;
+import com.njcn.common.bean.CustomCacheUtil;
+import org.springframework.boot.web.servlet.MultipartConfigFactory;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.util.unit.DataSize;
+
+import javax.servlet.MultipartConfigElement;
+
+/**
+ * @author caozehui
+ * @data 2025-03-24
+ */
+@Configuration
+public class WebConfig {
+
+ /**
+ * 将自定缓存工具类注入到spring容器中
+ *
+ * @return
+ */
+ @Bean
+ public CustomCacheUtil customCacheUtil() {
+ CustomCacheUtil customCacheUtil = SpringUtil.getBean(CustomCacheUtil.CACHE_NAME);
+ return customCacheUtil;
+ }
+
+ /**
+ * 配置上传文件大小限制
+ *
+ * @return
+ */
+ @Bean
+ public MultipartConfigElement multipartConfigElement() {
+ MultipartConfigFactory factory = new MultipartConfigFactory();
+ // 单个文件最大6MB
+ factory.setMaxFileSize(DataSize.ofMegabytes(1024));
+ // 整个请求最大12MB
+ factory.setMaxRequestSize(DataSize.ofMegabytes(2048));
+ return factory.createMultipartConfig();
+ }
+
+}
diff --git a/system/src/main/java/com/njcn/gather/system/config/advice/LogAdvice.java b/system/src/main/java/com/njcn/gather/system/config/advice/LogAdvice.java
new file mode 100644
index 0000000..f690a75
--- /dev/null
+++ b/system/src/main/java/com/njcn/gather/system/config/advice/LogAdvice.java
@@ -0,0 +1,182 @@
+package com.njcn.gather.system.config.advice;
+
+import cn.hutool.core.util.StrUtil;
+import com.njcn.common.pojo.enums.response.CommonResponseEnum;
+import com.njcn.common.pojo.response.HttpResult;
+import com.njcn.gather.system.log.pojo.dto.SysLogAuditRecord;
+import com.njcn.gather.system.log.service.ISysLogAuditService;
+import com.njcn.web.utils.ReflectCommonUtil;
+import com.njcn.web.utils.RequestUtil;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.core.MethodParameter;
+import org.springframework.http.MediaType;
+import org.springframework.http.server.ServerHttpRequest;
+import org.springframework.http.server.ServerHttpResponse;
+import org.springframework.web.bind.annotation.ControllerAdvice;
+import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Resource;
+import java.lang.reflect.Method;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.concurrent.Executor;
+
+/**
+ * @author caozehui
+ * @data 2024-12-2
+ */
+@Slf4j
+@ControllerAdvice
+public class LogAdvice implements ResponseBodyAdvice