30 Commits

Author SHA1 Message Date
4b8b90af04 Merge remote-tracking branch 'origin/develop' 2025-07-26 00:43:18 +08:00
690e9288b8 v2.1.3 更新:
1、使用ServletContextListener方式获取实时日志
    2、页面日志要素添加进程id和线程名称,优化样式
    3、日志打印级别调整为INFO
2025-07-26 00:42:38 +08:00
2d93f70704 Merge remote-tracking branch 'origin/develop' 2025-07-25 17:09:34 +08:00
9f74a467ba v2.1.2 更新:
1、升级metona-mq-mini-pro到2.0.1
2025-07-25 17:08:56 +08:00
4b36e35ace v2.1.2 更新:
1、升级metona-mq-mini-pro到2.0.1
2025-07-25 17:08:31 +08:00
40f07bdd6e Merge remote-tracking branch 'origin/develop' 2025-07-25 14:35:11 +08:00
71ca306336 v2.1.1 更新:
1、升级metona-mq-mini-pro到2.0.0,重构实时日志获取方式
2025-07-25 14:34:42 +08:00
5274830cc6 v2.1.1 更新:
1、升级metona-mq-mini-pro到2.0.0,重构实时日志获取方式
2025-07-25 14:32:21 +08:00
3b6c8bedf6 Merge remote-tracking branch 'origin/develop'
# Conflicts:
#	README.md
2025-07-25 09:30:36 +08:00
b37da657fa 修改版本记录和描述 2025-07-25 09:29:34 +08:00
1931697a87 !1 Update README.md
Merge pull request !1 from gitee-agent/N/A
2025-07-24 15:39:24 +00:00
gitee-bot
b882e85134 Update README.md 2025-07-24 15:35:16 +00:00
e17e31edfd v2.1.0 更新:
1、引入metona-mq-mini-pro消息队列,重构实时日志获取方式
2025-07-23 23:26:56 +08:00
bb99e48275 v2.1.0 更新:
1、引入metona-mq-mini-pro消息队列,重构实时日志获取方式
2025-07-23 18:27:16 +08:00
7d8cda234e v2.0.1 更新:
1、常规BUG修复,参数名称修改
2025-07-19 16:43:22 +08:00
4ce49e1205 v2.0.0 更新:
1、SpringBoot从3.1.1升级到3.5.3
    2、JDK从17升级到21并开启虚拟线程
    3、删除RocksDB相关配置,不再使用该缓存方案
    4、修改文件下载方式,使用StreamingResponseBody,支持大文件下载
    5、引入metona-cache-spring-boot-starter,使用此缓存方案
    6、重构在线日志页面及实现方式,不再使用读取日志文件方式,自定义日志拦截器实时获取日志
    7、不再生成自定义日志文件,日志打印从INFO改为DEBUG,打印更详细的内容
2025-07-18 15:29:26 +08:00
abfbc181b3 Merge remote-tracking branch 'origin/master' into develop
# Conflicts:
#	src/main/java/cn/somkit/fmt/action/DownloadAction.java
#	src/main/java/cn/somkit/fmt/action/UploadAction.java
#	src/main/java/cn/somkit/fmt/config/LogSocketConfig.java
#	src/main/java/cn/somkit/fmt/config/RocksDBConfig.java
#	src/main/java/cn/somkit/fmt/utils/LogSocketUtils.java
#	src/main/java/cn/somkit/fmt/utils/RocksDBUtils.java
#	src/main/resources/application.yml
#	src/main/resources/templates/logging.html
2025-07-18 15:00:42 +08:00
d48bf42532 代码提交 2025-07-18 14:59:07 +08:00
3334aa23d9 代码提交 2025-07-18 11:18:45 +08:00
515fb83408 代码提交 2025-07-18 09:32:46 +08:00
d6383ac74d 代码提交 2025-07-17 23:37:44 +08:00
3d3fe0cd96 代码提交 2025-07-17 23:00:52 +08:00
7400f85c88 代码提交 2025-07-17 21:11:57 +08:00
b336a78183 代码提交 2025-07-17 18:22:19 +08:00
WIN-VSNMD38DUOC\Administrator
2e0f8e82a8 修改文件下载方式,使用StreamingResponseBody,支持大文件下载 2024-12-06 17:29:36 +08:00
WIN-VSNMD38DUOC\Administrator
2ed8e1159c 修改版本号,修改打包参数 2024-05-09 17:58:53 +08:00
WIN-VSNMD38DUOC\Administrator
e5872de15b 新增系统设置页面,支持在线配置上传文件地址、日志文件地址等参数 2024-04-30 11:13:42 +08:00
WIN-VSNMD38DUOC\Administrator
e2d5ad6d1a 新增临时文件存放地址配置,支持相对路径 2024-04-28 17:42:15 +08:00
WIN-VSNMD38DUOC\Administrator
5f3e17f0cf 修改上传文件目录配置,使其支持使用相对路径 2024-04-28 16:50:07 +08:00
WIN-VSNMD38DUOC\Administrator
3e8bc50d1b 修改RocksDB文件目录配置,使其支持使用相对路径 2024-04-28 15:13:16 +08:00
38 changed files with 1036 additions and 1276 deletions

109
README.md
View File

@@ -1,32 +1,77 @@
# fmt
#### 介绍
1. 简易文件传输管理系统
2. 文件下载、文件上传、在线日志
#### 软件架构
1. Jdk 17
2. Maven 3.6.3
3. SpringBoot 3.1.1
4. SpringBoot Starter Thymeleaf 3.1.1
5. SpringBoot Starter WebSocket 3.1.1
6. RocksDB 8.3.2
7. Hutool Json 5.8.21
7. AXUI 2.1.1
#### 安装教程
1. 项目导入IDEA配置Jdk、Maven根据自己实际环境修改配置文件然后启动项目
2. 下载发行版本压缩包解压后修改bin目录下的启动文件配置然后执行即可
#### 使用说明
1. 启动项目访问http://127.0.0.1:8098/fmt
#### 参与贡献
1. Fork 本仓库
2. 新建 Feat_xxx 分支
3. 提交代码
4. 新建 Pull Request
# fmt
## 介绍
`fmt` 是一个基于 Spring Boot 的文件管理工具,旨在提供便捷的文件上传、下载、打包、删除等操作。它结合了简洁的前端界面与强大的后端功能,适用于需要进行文件管理的各类应用场景。
## 软件架构
本项目采用前后端一体化架构,后端基于 Spring Boot 框架,使用 Java 语言开发,前端采用 HTML、CSS 和 JavaScript 技术,结合 `axui` 框架进行界面渲染。主要模块包括:
- **文件上传模块**:支持文件上传并保存至服务器。
- **文件下载模块**:支持文件下载及 ZIP 打包下载。
- **文件管理模块**:支持文件删除、批量删除等操作。
- **日志模块**:实时推送日志信息。
- **WebSocket 模块**:用于前后端实时通信。
- **工具类模块**:包括 MD5 工具、操作系统工具等。
## 安装教程
1. **克隆项目**
```bash
git clone https://gitee.com/thzxx/fmt.git
```
2. **构建项目**
使用 Maven 构建项目:
```bash
cd fmt
mvn clean package
```
3. **运行项目**
启动 Spring Boot 应用:
```bash
java -jar target/fmt.jar
```
4. **访问项目**
打开浏览器访问:
```
http://localhost:8080
```
## 使用说明
- **上传文件**
访问 `/upload/index` 页面,选择文件进行上传。
- **下载文件**
访问 `/download/index` 页面,点击文件进行下载,或使用 `/download/file` 接口下载指定路径文件。
- **打包下载**
使用 `/download/packZip` 接口,传入文件名列表进行 ZIP 打包下载。
- **删除文件**
使用 `/download/delete` 或 `/download/batchDel` 接口进行单个或批量删除。
- **查看日志**
访问 `/logging/index` 页面,实时查看日志信息。
- **WebSocket 实时通信**
前端通过 WebSocket 连接 `/ws`,接收服务器推送的消息。
## 参与贡献
欢迎贡献代码和改进项目。请遵循以下步骤:
1. Fork 项目。
2. 创建新分支。
3. 提交代码。
4. 创建 Pull Request。
如发现 Bug 或有改进建议,请提交 Issue 或直接联系项目维护者。
## 许可证
本项目采用 MIT 许可证。详情请查看项目根目录下的 `LICENSE` 文件。

48
pom.xml
View File

@@ -5,53 +5,57 @@
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.1.1</version>
<version>3.5.3</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>cn.somkit</groupId>
<artifactId>fmt</artifactId>
<version>1.2.0</version>
<version>2.1.3</version>
<name>fmt</name>
<description>fmt</description>
<description>File Manage System for by SpringBoot</description>
<properties>
<java.version>17</java.version>
<java.version>21</java.version>
</properties>
<repositories>
<repository>
<id>metona-maven</id>
<url>https://gitee.com/thzxx/maven/raw/master</url>
</repository>
</repositories>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
<dependency>
<groupId>org.aspectj</groupId>
<artifactId>aspectjweaver</artifactId>
<version>1.9.7</version>
</dependency>
<dependency>
<groupId>org.rocksdb</groupId>
<artifactId>rocksdbjni</artifactId>
<version>8.3.2</version>
</dependency>
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-json</artifactId>
<version>5.8.21</version>
<version>5.8.25</version>
</dependency>
<dependency>
<groupId>cn.metona</groupId>
<artifactId>metona-cache-spring-boot-starter</artifactId>
<version>1.0.0</version>
</dependency>
<dependency>
<groupId>cn.metona</groupId>
<artifactId>metona-mq-mini-pro</artifactId>
<version>2.0.1</version>
</dependency>
</dependencies>

View File

@@ -1,11 +1,15 @@
package cn.somkit.fmt;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class FmtApplication {
private static final Logger logger = LoggerFactory.getLogger(FmtApplication.class);
public static void main(String[] args) {
SpringApplication.run(FmtApplication.class, args);
}

View File

@@ -1,25 +1,29 @@
package cn.somkit.fmt.action;
import cn.somkit.fmt.annotation.ApiOperate;
import cn.somkit.fmt.config.RocksDBConfig;
import cn.somkit.fmt.utils.MD5Utils;
import cn.hutool.json.JSONUtil;
import cn.somkit.fmt.utils.OsInfoUtil;
import cn.somkit.fmt.utils.RocksDBUtils;
import jakarta.servlet.http.HttpServletResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.util.CollectionUtils;
import org.springframework.util.FileSystemUtils;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.context.request.async.DeferredResult;
import org.springframework.web.servlet.ModelAndView;
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
import java.io.*;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.text.SimpleDateFormat;
import java.util.*;
import java.util.stream.Collectors;
@@ -30,6 +34,8 @@ import java.util.zip.ZipOutputStream;
@RequestMapping("/download")
public class DownloadAction {
private final Logger logger = LoggerFactory.getLogger(this.getClass());
@Value("${somkit.upload.path.windows}")
private String windows_path;
@@ -37,61 +43,29 @@ public class DownloadAction {
private String linux_path;
@GetMapping("/index")
@ApiOperate(description = "下载页面跳转")
public ModelAndView index(String keyboard) throws Exception{
String path = OsInfoUtil.isWindows() ? windows_path :
OsInfoUtil.isLinux() ? linux_path : null;
logger.info("获取文件存储路径:{}", path);
assert path != null;
File folder = new File(path);
File[] listOfFiles = folder.listFiles();
List<Map<String, Object>> list = new ArrayList<>();
List<String> keys = RocksDBUtils.getAllKey(RocksDBConfig.RocksDB_Column_Family);
if(listOfFiles != null){
for (File file : listOfFiles) {
if(!file.isFile()){
continue;
}
if(!keys.contains(file.getName())){
try (FileInputStream is = new FileInputStream(file)) {
String hash = MD5Utils.md5HashCode(is);
RocksDBUtils.put(RocksDBConfig.RocksDB_Column_Family, file.getName(), hash);
}catch (Exception e){e.printStackTrace();}
}
BigDecimal filesize = BigDecimal.valueOf(file.length())
.divide(BigDecimal.valueOf(1024 * 1024), 2, RoundingMode.HALF_UP);
Long time = file.lastModified();
SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
String filetime = format.format(time);
Map<String, Object> map = new HashMap<>();
map.put("filename", file.getName());
map.put("filepath", file.getAbsolutePath());
map.put("filesize", filesize);
map.put("filetime", filetime);
Map<String, Object> map = getObjectMap(file);
list.add(map);
}
List<String> filenames = list.stream().map(map -> String.valueOf(map.get("filename"))).toList();
keys.forEach(key -> {
try {
if(!filenames.contains(key)){
RocksDBUtils.delete(RocksDBConfig.RocksDB_Column_Family, key);
}
} catch (Exception e) {
throw new RuntimeException(e);
}
});
}else{
keys.forEach(key -> {
try {
RocksDBUtils.delete(RocksDBConfig.RocksDB_Column_Family, key);
} catch (Exception e) {
throw new RuntimeException(e);
}
});
}
if(StringUtils.hasText(keyboard) && !CollectionUtils.isEmpty(list)){
list = list.stream().filter(map -> String.valueOf(map.get("filename"))
.contains(keyboard)).collect(Collectors.toList());
}
logger.info("关键词:{},文件数量:{}", keyboard, list.size());
ModelAndView mv = new ModelAndView();
mv.setViewName("download");
mv.addObject("files", list);
@@ -99,37 +73,64 @@ public class DownloadAction {
return mv;
}
@RequestMapping("/execute")
@ApiOperate(description = "单文件下载")
public void execute(String path, Boolean temp, HttpServletResponse response) throws Exception {
File file = new File(path);
if (!file.isFile()) {
throw new FileNotFoundException("文件不存在");
private Map<String, Object> getObjectMap(File file) {
BigDecimal filesize = BigDecimal.valueOf(file.length())
.divide(BigDecimal.valueOf(1024 * 1024), 2, RoundingMode.HALF_UP);
Long time = file.lastModified();
SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
String filetime = format.format(time);
Map<String, Object> map = new HashMap<>();
map.put("filename", file.getName());
map.put("filepath", file.getAbsolutePath());
map.put("filesize", filesize);
map.put("filetime", filetime);
logger.info("文件信息:{}", JSONUtil.toJsonStr(map));
return map;
}
@GetMapping(value = "/file", produces = MediaType.APPLICATION_OCTET_STREAM_VALUE)
public DeferredResult<ResponseEntity<StreamingResponseBody>> downloadFileByPath(String path, Boolean temp) throws Exception {
final File file = new File(path);
final DeferredResult<ResponseEntity<StreamingResponseBody>> result = new DeferredResult<>();
if (!file.exists()) {
result.setErrorResult(new RuntimeException("文件不存在"));
return result;
}
String filename = file.getName();
response.setHeader(HttpHeaders.CONTENT_DISPOSITION, "attachment;filename=" + URLEncoder.encode(filename, StandardCharsets.UTF_8));
response.setHeader(HttpHeaders.CONTENT_LENGTH, String.valueOf(file.length()));
try (FileInputStream fileInputStream = new FileInputStream(file);
BufferedInputStream bufferedInputStream = new BufferedInputStream(fileInputStream);
BufferedOutputStream bufferedOutputStream = new BufferedOutputStream(response.getOutputStream())) {
byte[] buffer_ = new byte[1024];
int n = bufferedInputStream.read(buffer_);
while (n != -1) {
bufferedOutputStream.write(buffer_);
n = bufferedInputStream.read(buffer_);
HttpHeaders headers = new HttpHeaders();
headers.add(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=" + URLEncoder.encode(file.getName(), StandardCharsets.UTF_8));
headers.add(HttpHeaders.CONTENT_LENGTH, String.valueOf(file.length()));
StreamingResponseBody body = outputStream -> {
try (InputStream inputStream = new FileInputStream(file)) {
byte[] buffer = new byte[1024];
int bytesRead;
while ((bytesRead = inputStream.read(buffer)) != -1) {
outputStream.write(buffer, 0, bytesRead);
}
outputStream.flush();
} catch (IOException e) {
logger.error("IO错误", e);
throw new RuntimeException("IO错误: " + e.getMessage());
} finally {
// 尝试在流关闭后删除文件
try {
if(temp){
Files.deleteIfExists(file.toPath());
}
} catch (IOException e) {
// 记录日志或采取其他措施
logger.error("删除文件失败:{}", e.getMessage());
}
}
} catch (Exception e) {
e.printStackTrace();
} finally {
if(temp){//删除临时文件
FileSystemUtils.deleteRecursively(new File(path));
}
}
};
result.setResult(new ResponseEntity<>(body, headers, HttpStatus.OK));
return result;
}
@PostMapping("/packZip")
@ResponseBody
@ApiOperate(description = "批量文件打包下载")
public Map<String, Object> packZip(String filenames) throws Exception{
try {
String path = OsInfoUtil.isWindows() ? windows_path :
@@ -173,17 +174,14 @@ public class DownloadAction {
@PostMapping("/delete")
@ResponseBody
@ApiOperate(description = "单文件删除")
public String delete(String path) throws Exception{
File file = new File(path);
RocksDBUtils.delete(RocksDBConfig.RocksDB_Column_Family, file.getName());
FileSystemUtils.deleteRecursively(file);
return "删除成功";
}
@PostMapping("/batchDel")
@ResponseBody
@ApiOperate(description = "批量文件删除")
public String batchDel(String filenames) throws Exception{
String path = OsInfoUtil.isWindows() ? windows_path :
OsInfoUtil.isLinux() ? linux_path : null;
@@ -195,7 +193,6 @@ public class DownloadAction {
if(!Arrays.asList(filenames.split(",")).contains(file.getName())){
continue;
}
RocksDBUtils.delete(RocksDBConfig.RocksDB_Column_Family, file.getName());
FileSystemUtils.deleteRecursively(file);
}
return "删除成功";

View File

@@ -1,6 +1,5 @@
package cn.somkit.fmt.action;
import cn.somkit.fmt.annotation.ApiOperate;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.servlet.ModelAndView;
@@ -9,7 +8,6 @@ import org.springframework.web.servlet.ModelAndView;
public class IndexAction {
@RequestMapping("/")
@ApiOperate(description = "默认跳转")
public ModelAndView index(){
return new ModelAndView("redirect:/download/index");
}

View File

@@ -1,6 +1,5 @@
package cn.somkit.fmt.action;
import cn.somkit.fmt.annotation.ApiOperate;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
@@ -10,7 +9,6 @@ import org.springframework.web.bind.annotation.RequestMapping;
public class LoggingAction {
@GetMapping("/index")
@ApiOperate(description = "在线日志页面跳转")
public String index() throws Exception{
return "logging";
}

View File

@@ -1,14 +1,11 @@
package cn.somkit.fmt.action;
import cn.somkit.fmt.annotation.ApiOperate;
import cn.somkit.fmt.config.RocksDBConfig;
import cn.somkit.fmt.utils.MD5Utils;
import cn.somkit.fmt.utils.OsInfoUtil;
import cn.somkit.fmt.utils.RocksDBUtils;
import jakarta.servlet.http.HttpServletRequest;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Controller;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
@@ -23,6 +20,8 @@ import java.util.*;
@RequestMapping("/upload")
public class UploadAction {
private final Logger logger = LoggerFactory.getLogger(this.getClass());
@Value("${somkit.upload.path.windows}")
private String windows_path;
@@ -30,14 +29,12 @@ public class UploadAction {
private String linux_path;
@GetMapping("/index")
@ApiOperate(description = "上传页面跳转")
public String index() throws Exception{
return "upload";
}
@PostMapping("/execute")
@ResponseBody
@ApiOperate(description = "文件上传")
public Map<String, Object> execute(HttpServletRequest request) throws Exception{
//多个文件上传 就只是简单的多文件上传保存在本地的磁盘
if (request instanceof MultipartHttpServletRequest mrequest) {
@@ -47,7 +44,7 @@ public class UploadAction {
try {
this.saveFile(file); // 保存上传信息
} catch (Exception e) {
e.printStackTrace();
logger.error("文件保存失败:{}", e.getMessage());
}
}
}
@@ -63,33 +60,19 @@ public class UploadAction {
* @throws Exception 上传异常
*/
public void saveFile(MultipartFile file) throws Exception {
String path = OsInfoUtil.isWindows() ? windows_path :
OsInfoUtil.isLinux() ? linux_path : null;
assert path != null;
String filePath = "";
if (file != null && file.getSize() > 0) { // 有文件上传
String fileName = verify(file, null);// 创建文件名称
if(StringUtils.hasText(fileName)){
String hash = MD5Utils.md5HashCode(file.getInputStream());
RocksDBUtils.put(RocksDBConfig.RocksDB_Column_Family, fileName, hash);
filePath = path + File.separator + fileName;
try {
String path = OsInfoUtil.isWindows() ? windows_path :
OsInfoUtil.isLinux() ? linux_path : null;
assert path != null;
String filePath = "";
if (file != null && file.getSize() > 0) { // 有文件上传
filePath = path + File.separator + file.getOriginalFilename();
File saveFile = new File(filePath) ;
file.transferTo(saveFile); // 文件保存
logger.info("文件保存成功:{}", filePath);
}
} catch (Exception e) {
throw new RuntimeException(e);
}
}
private String verify(MultipartFile file, String filename) throws Exception {
String key = StringUtils.hasText(filename) ? filename : Objects.requireNonNull(file.getOriginalFilename());
String hash = RocksDBUtils.get(RocksDBConfig.RocksDB_Column_Family, key);
if(!StringUtils.hasText(hash)){
return StringUtils.hasText(filename) ? filename : file.getOriginalFilename();
}
String newHash = MD5Utils.md5HashCode(file.getInputStream());
if(!hash.equals(newHash)){
String newFilename = "(1)" + file.getOriginalFilename();
return verify(file, newFilename);
}
return null;
}
}

View File

@@ -1,14 +0,0 @@
package cn.somkit.fmt.annotation;
import java.lang.annotation.*;
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD})
@Documented
public @interface ApiOperate {
/**
* 描述
*/
String description() default "";
}

View File

@@ -1,93 +0,0 @@
package cn.somkit.fmt.annotation.aspect;
import cn.hutool.json.JSONUtil;
import cn.somkit.fmt.annotation.ApiOperate;
import jakarta.servlet.http.HttpServletRequest;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import java.util.Arrays;
import java.util.Enumeration;
import java.util.HashMap;
import java.util.Map;
@Aspect
@Component
public class ApiOperateAspect {
private final Logger logger = LoggerFactory.getLogger(this.getClass());
/** 换行符 */
private static final String LINE_SEPARATOR = System.lineSeparator();
/** 以自定义 @ApiOperate 注解为切点 */
@Pointcut("@annotation(apiOperate)")
public void ApiOperate(ApiOperate apiOperate) {}
/**
* 环绕注入
* @param joinPoint
* @param apiOperate
* @return
* @throws Throwable
*/
@Around(value = "ApiOperate(apiOperate)", argNames = "joinPoint,apiOperate")
public Object doAround(ProceedingJoinPoint joinPoint, ApiOperate apiOperate) throws Throwable {
long startTime = System.currentTimeMillis();
Object result = joinPoint.proceed();
// 开始打印请求日志
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
assert attributes != null;
HttpServletRequest request = attributes.getRequest();
long timeConsuming = System.currentTimeMillis() - startTime;
// 打印请求相关参数
logger.info("========================================== Start ==========================================");
// 打印请求 url
logger.info("URL : {}", request.getRequestURL().toString());
// 打印描述信息
logger.info("Description : {}", apiOperate.description());
// 打印 Http method
logger.info("HTTP Method : {}", request.getMethod());
// 打印调用 controller 的全路径以及执行方法
logger.info("Class Method : {}.{}", joinPoint.getSignature().getDeclaringTypeName(), joinPoint.getSignature().getName());
// 打印请求的 IP
logger.info("IP : {}", request.getRemoteAddr());
// 打印请求Headers
logger.info("Header Args : {}", headers(request));
// 打印请求入参
logger.info("Request Args : {}", Arrays.toString(joinPoint.getArgs()));
// 打印出参
logger.info("Response Args : {}", JSONUtil.toJsonStr(result));
// 执行耗时
logger.info("Time-Consuming : {} ms", timeConsuming);
// 接口结束后换行,方便分割查看
logger.info("=========================================== End ===========================================" + LINE_SEPARATOR);
return result;
}
/**
* 获取所有header参数
* @param request
* @return Map<String, String> headers
*/
private Map<String, String> headers(HttpServletRequest request){
Map<String, String> headerMap = new HashMap<>();
Enumeration<String> enumeration = request.getHeaderNames();
while (enumeration.hasMoreElements()) {
String name = enumeration.nextElement();
String value = request.getHeader(name);
headerMap.put(name, value);
}
return headerMap;
}
}

View File

@@ -0,0 +1,35 @@
package cn.somkit.fmt.config;
import ch.qos.logback.classic.spi.ILoggingEvent;
import ch.qos.logback.core.UnsynchronizedAppenderBase;
import cn.hutool.core.date.DateUtil;
import cn.hutool.json.JSONUtil;
import cn.metona.mq.MQToolkit;
import cn.metona.mq.core.MessageQueue;
import cn.somkit.fmt.entity.LoggerMessage;
import java.time.Instant;
import java.time.ZoneId;
public class LogMonitorAppender extends UnsynchronizedAppenderBase<ILoggingEvent> {
private static final String Queue_Name = "log-monitor";
@Override
protected void append(ILoggingEvent event) {
LoggerMessage msg = new LoggerMessage(
event.getLevel().toString(),
event.getThreadName(),
event.getLoggerName(),
event.getFormattedMessage(),
DateUtil.format(Instant.ofEpochMilli(event.getTimeStamp()).atZone(ZoneId.systemDefault()).toLocalDateTime(),
"yyyy-MM-dd HH:mm:ss.SSS"),
ProcessHandle.current().pid()
);
//发送日志信息到日志监控队列
MessageQueue.QueueStats queueStats = MQToolkit.getQueueStats(Queue_Name);
if(queueStats != null && queueStats.isRunning() && !queueStats.isPaused()){
MQToolkit.sendMessage(Queue_Name, "log.monitor", JSONUtil.toJsonStr(msg));
}
}
}

View File

@@ -0,0 +1,50 @@
package cn.somkit.fmt.config;
import cn.metona.mq.MQToolkit;
import cn.metona.mq.consumer.MessageConsumer;
import cn.metona.mq.utils.MonitorUtils;
import cn.somkit.fmt.socket.SocketManage;
import jakarta.annotation.PostConstruct;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.TextMessage;
import org.springframework.web.socket.WebSocketSession;
import java.io.IOException;
import java.util.List;
@Component
public class LogMonitorConfig {
private final Logger logger = LoggerFactory.getLogger(this.getClass());
private static final String Queue_Name = "log-monitor";
private static final String Consumer_Id = "log-monitor-consumer";
@PostConstruct
public void run(){
//创建日志监控队列
MQToolkit.createQueue(Queue_Name, Boolean.FALSE);
//创建日志监控消费者
MessageConsumer consumer = MQToolkit.createConsumer(Consumer_Id, Queue_Name, message -> {
List<WebSocketSession> list = SocketManage.all();
if(list != null && !list.isEmpty()){
list.forEach(session -> {
if(session.isOpen()){
try {
session.sendMessage(new TextMessage(message.body()));
} catch (IOException e) {
logger.error("发送消息失败:", e);
}
}
});
}
});
//启动消费者
consumer.start();
// 显示队列统计
MonitorUtils.printQueueStats(Queue_Name);
}
}

View File

@@ -1,36 +0,0 @@
package cn.somkit.fmt.config;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
@Component
public class LogSocketConfig {
public static String Log_File_Path = "./logs/fmt-server.log";
public static String Max_Read_Length = "500";
public static String Read_Interval = "1000";
@Value("${somkit.logging.socket.log-file-path}")
public void setLog_File_Path(String log_File_Path) {
if(StringUtils.hasText(log_File_Path)){
Log_File_Path = log_File_Path;
}
}
@Value("${somkit.logging.socket.max-read-length}")
public void setMax_Read_Length(String max_Read_Length) {
if(StringUtils.hasText(max_Read_Length)){
Max_Read_Length = max_Read_Length;
}
}
@Value("${somkit.logging.socket.read-interval}")
public void setRead_Interval(String read_Interval) {
if(StringUtils.hasText(read_Interval)){
Read_Interval = read_Interval;
}
}
}

View File

@@ -0,0 +1,25 @@
package cn.somkit.fmt.config;
import ch.qos.logback.classic.Logger;
import ch.qos.logback.classic.LoggerContext;
import jakarta.servlet.ServletContextEvent;
import jakarta.servlet.ServletContextListener;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
@Component
public class LogbackConfigListener implements ServletContextListener {
@Override
public void contextInitialized(ServletContextEvent event) {
// 配置logback
LoggerContext loggerContext = (LoggerContext) LoggerFactory.getILoggerFactory();
Logger rootLogger = loggerContext.getLogger(Logger.ROOT_LOGGER_NAME);
// 添加自定义appender
LogMonitorAppender appender = new LogMonitorAppender();
appender.setContext(loggerContext);
appender.start();
rootLogger.addAppender(appender);
}
}

View File

@@ -1,36 +0,0 @@
package cn.somkit.fmt.config;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
@Component
public class RocksDBConfig {
public static String RocksDB_Path_Windows = "D://RocksDB";
public static String RocksDB_Path_Linux = "/usr/local/rocksdb";
public static String RocksDB_Column_Family = "default";
@Value("${somkit.db.rocks.path.windows}")
public void setRocksDB_Path_Windows(String rocksDB_Path_Windows) {
if(StringUtils.hasText(rocksDB_Path_Windows)){
RocksDB_Path_Windows = rocksDB_Path_Windows;
}
}
@Value("${somkit.db.rocks.path.linux}")
public void setRocksDB_Path_Linux(String rocksDB_Path_Linux) {
if(StringUtils.hasText(rocksDB_Path_Linux)){
RocksDB_Path_Linux = rocksDB_Path_Linux;
}
}
@Value("${somkit.db.rocks.column-family}")
public void setRocksDB_Column_Family(String rocksDB_Column_Family){
if(StringUtils.hasText(rocksDB_Column_Family)){
RocksDB_Column_Family = rocksDB_Column_Family;
}
}
}

View File

@@ -0,0 +1,67 @@
package cn.somkit.fmt.entity;
public class LoggerMessage {
String level;
String threadName;
String name;
String message;
String timestamp;
long number;
public LoggerMessage(String level, String threadName, String loggerName, String message, String timestamp, long number) {
this.level = level;
this.threadName = threadName;
this.name = loggerName;
this.message = message;
this.timestamp = timestamp;
this.number = number;
}
public String getLevel() {
return level;
}
public void setLevel(String level) {
this.level = level;
}
public String getThreadName() {
return threadName;
}
public void setThreadName(String threadName) {
this.threadName = threadName;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getMessage() {
return message;
}
public void setMessage(String message) {
this.message = message;
}
public String getTimestamp() {
return timestamp;
}
public void setTimestamp(String timestamp) {
this.timestamp = timestamp;
}
public long getNumber() {
return number;
}
public void setNumber(long number) {
this.number = number;
}
}

View File

@@ -0,0 +1,31 @@
package cn.somkit.fmt.socket;
import org.springframework.web.socket.WebSocketSession;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
public class SocketManage {
private SocketManage() {
}
private static final Map<String, WebSocketSession> onlineMap = new ConcurrentHashMap<>();
public static void put(WebSocketSession session){
onlineMap.put(session.getId(), session);
}
public static WebSocketSession get(String sessionId){
return onlineMap.get(sessionId);
}
public static void remove(String sessionId){
onlineMap.remove(sessionId);
}
public static List<WebSocketSession> all(){
return onlineMap.values().stream().toList();
}
}

View File

@@ -18,7 +18,6 @@ public class WebSocketAutoConfig implements WebSocketConfigurer {
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
registry.addHandler(webSocketServerHandler(), "/socket/ws")//设置连接路径和处理
.setAllowedOrigins("*")//允许跨域访问
.addInterceptors(new WebSocketServerInterceptor());//设置拦截器
.setAllowedOrigins("*");//允许跨域访问
}
}

View File

@@ -1,27 +0,0 @@
package cn.somkit.fmt.socket;
public class WebSocketEntity {
/**
* 发送类型 HeartBeat:心跳 Message:消息
*/
private String send;
private Object data;
public String getSend() {
return send;
}
public void setSend(String send) {
this.send = send;
}
public Object getData() {
return data;
}
public void setData(Object data) {
this.data = data;
}
}

View File

@@ -1,27 +0,0 @@
package cn.somkit.fmt.socket;
public enum WebSocketEnums {
//消息来源
SEND_TP_HEARTBEAT("HeartBeat", "心跳"),
SEND_TP_LOGGING("Logging", "日志")
;
private String code;
private String name;
WebSocketEnums(){};
WebSocketEnums(String code, String name) {
this.code = code;
this.name = name;
}
public String getCode() {
return code;
}
public String getName() {
return name;
}
}

View File

@@ -1,59 +0,0 @@
package cn.somkit.fmt.socket;
import org.springframework.web.socket.TextMessage;
import org.springframework.web.socket.WebSocketSession;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;
public class WebSocketOnline {
//在线连接数
private static final AtomicInteger onlineCount = new AtomicInteger(0);
//在线连接
private static final Map<String, WebSocketSession> onlineMap = new ConcurrentHashMap<>();
//日志读取位置记录
public static final Map<String, Integer> logLengthMap = new ConcurrentHashMap<>();
public static synchronized int getOnlineCount() {
return onlineCount.get();
}
public static void setOnline(String id, WebSocketSession session) throws Exception{
//如果已经存在,先把原来的连接关闭
if(onlineMap.containsKey(id)){
if(onlineMap.get(id).isOpen()){
onlineMap.get(id).close();
}
}
onlineMap.put(id, session);
onlineCount.set(getOnlineCount() + 1);
}
public static WebSocketSession getOnline(String id) throws Exception{
return onlineMap.get(id);
}
public static List<WebSocketSession> getOnlineList() throws Exception{
List<WebSocketSession> list = new ArrayList<>();
for (String key : onlineMap.keySet()) {
list.add(onlineMap.get(key));
}
return list;
}
public static void closeOnline(String id) throws Exception{
onlineMap.remove(id);
onlineCount.set(getOnlineCount() - 1);
}
public static void sendMessage(String id, String entityJsonStr) throws Exception {
WebSocketSession session = getOnline(id);
if(session != null && session.isOpen()){
session.sendMessage(new TextMessage(entityJsonStr));
}
}
}

View File

@@ -1,67 +1,29 @@
package cn.somkit.fmt.socket;
import cn.hutool.json.JSONUtil;
import cn.somkit.fmt.utils.ErrorUtil;
import cn.somkit.fmt.utils.LogSocketUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.socket.CloseStatus;
import org.springframework.web.socket.WebSocketHandler;
import org.springframework.web.socket.WebSocketMessage;
import org.springframework.web.socket.WebSocketSession;
import java.util.Objects;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.*;
@Component
public class WebSocketServerHandler implements WebSocketHandler {
private final Logger logger = LoggerFactory.getLogger(this.getClass());
@Override
public void afterConnectionEstablished(WebSocketSession session) throws Exception {
String id = session.getAttributes().get("id").toString();
WebSocketOnline.setOnline(id, session);
logger.info("有新窗口开始监听:" + id + ", 当前在线人数为:" + WebSocketOnline.getOnlineCount());
try {
//返回一个心跳检测
WebSocketEntity entity = new WebSocketEntity();
entity.setSend(WebSocketEnums.SEND_TP_HEARTBEAT.getCode());
entity.setData("心跳检测");
WebSocketOnline.sendMessage(id, JSONUtil.toJsonStr(entity));
}catch (Exception e){
logger.error("连接成功发送心跳异常:{}", ErrorUtil.errorInfoToString(e));
}
//读取并实时发送日志
LogSocketUtils.LoggingChannel(id);
SocketManage.put(session);
}
@Override
public void handleMessage(WebSocketSession session, WebSocketMessage<?> message) throws Exception {
String id = session.getAttributes().get("id").toString();
String messageStr = message.getPayload().toString();
WebSocketEntity entity = JSONUtil.toBean(messageStr, WebSocketEntity.class);
if(Objects.equals(WebSocketEnums.SEND_TP_HEARTBEAT.getCode(), entity.getSend())) {
//心跳检测消息,原路返回客户端
WebSocketOnline.sendMessage(id, JSONUtil.toJsonStr(entity));
}
}
@Override
public void handleTransportError(WebSocketSession session, Throwable e) throws Exception {
String id = session.getAttributes().get("id").toString();
//发生错误时主动关闭连接
if(session.isOpen()){
session.close();
}
WebSocketOnline.closeOnline(id);
logger.error("{}连接发生错误:{}", id, ErrorUtil.errorInfoToString(e));
public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception {
SocketManage.remove(session.getId());
}
@Override
public void afterConnectionClosed(WebSocketSession session, CloseStatus closeStatus) throws Exception {
String id = session.getAttributes().get("id").toString();
WebSocketOnline.closeOnline(id);
logger.info("连接关闭:{},状态:{},当前在线人数为:{}", id, closeStatus.toString(), WebSocketOnline.getOnlineCount());
SocketManage.remove(session.getId());
}
@Override

View File

@@ -1,32 +0,0 @@
package cn.somkit.fmt.socket;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.http.server.ServerHttpRequest;
import org.springframework.http.server.ServerHttpResponse;
import org.springframework.http.server.ServletServerHttpRequest;
import org.springframework.http.server.ServletServerHttpResponse;
import org.springframework.web.socket.WebSocketHandler;
import org.springframework.web.socket.server.HandshakeInterceptor;
import java.util.Map;
public class WebSocketServerInterceptor implements HandshakeInterceptor {
@Override
public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response,
WebSocketHandler wsHandler, Map<String, Object> attributes) throws Exception {
HttpServletRequest rs = ((ServletServerHttpRequest) request).getServletRequest();
HttpServletResponse hp = ((ServletServerHttpResponse)response).getServletResponse();
//request 参数放入 attributes中
rs.getParameterMap().forEach((k, v) -> {
attributes.put(k, v[0]);
});
return true;
}
@Override
public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response,
WebSocketHandler wsHandler, Exception exception) {
}
}

View File

@@ -0,0 +1,29 @@
package cn.somkit.fmt.utils;
import org.springframework.beans.BeansException;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationContextAware;
import org.springframework.stereotype.Component;
@Component
public class AppContext implements ApplicationContextAware {
private static ApplicationContext context;
@Override
public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
context = applicationContext;
}
public static ApplicationContext getApplicationContext() {
return context;
}
public static <T> T getBean(Class<T> clazz) {
return context.getBean(clazz);
}
public static Object getBean(String name) {
return context.getBean(name);
}
}

View File

@@ -1,40 +0,0 @@
package cn.somkit.fmt.utils;
import java.io.IOException;
import java.io.PrintWriter;
import java.io.StringWriter;
/**
* 捕获报错日志处理工具类
*/
public class ErrorUtil {
/**
* Exception出错的栈信息转成字符串
* 用于打印到日志中
*/
public static String errorInfoToString(Throwable e) {
StringWriter sw = null;
PrintWriter pw = null;
try {
sw = new StringWriter();
pw = new PrintWriter(sw);
// 将出错的栈信息输出到printWriter中
e.printStackTrace(pw);
pw.flush();
sw.flush();
} finally {
if (sw != null) {
try {
sw.close();
} catch (IOException e1) {
e1.printStackTrace();
}
}
if (pw != null) {
pw.close();
}
}
return sw.toString();
}
}

View File

@@ -1,94 +0,0 @@
package cn.somkit.fmt.utils;
import cn.hutool.json.JSONUtil;
import cn.somkit.fmt.config.LogSocketConfig;
import cn.somkit.fmt.socket.WebSocketEntity;
import cn.somkit.fmt.socket.WebSocketEnums;
import cn.somkit.fmt.socket.WebSocketOnline;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.thymeleaf.util.StringUtils;
import java.io.BufferedReader;
import java.io.FileReader;
import java.io.IOException;
import java.util.Arrays;
public class LogSocketUtils {
private static final Logger logger = LoggerFactory.getLogger(LogSocketUtils.class);
public static void LoggingChannel(String socketSessionId) {
//获取日志信息
new Thread(() -> {
try {
boolean first = true;
while (WebSocketOnline.getOnline(socketSessionId) != null) {
BufferedReader reader = null;
try {
//日志文件路径
String filePath = LogSocketConfig.Log_File_Path;
//字符流
reader = new BufferedReader(new FileReader(filePath));
Object[] lines = reader.lines().toArray();
//只取从上次之后产生的日志
Integer length = WebSocketOnline.logLengthMap.get(socketSessionId);
Object[] copyOfRange = Arrays.copyOfRange(lines, length == null ? 0 : length, lines.length);
//对日志进行着色,更加美观 PS注意这里要根据日志生成规则来操作
for (int i = 0; i < copyOfRange.length; i++) {
String line = (String) copyOfRange[i];
//先转义
line = line.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll("\"", "&quot;");
String[] stars = line.split("-&gt;");
if(stars.length == 2){
line = line.replace(stars[0], "<span style='color: #298a8a;'>"+stars[0]+"</span>");
}
//处理等级
line = line.replace("[DEBUG ]", "[<span style='color: blue;'>DEBUG</span>]");
line = line.replace("[INFO ]", "[<span style='color: green;'>INFO</span>]");
line = line.replace("[WARN ]", "[<span style='color: orange;'>WARN</span>]");
line = line.replace("[ERROR ]", "[<span style='color: red;'>ERROR</span>]");
copyOfRange[i] = line;
}
//存储最新一行开始
WebSocketOnline.logLengthMap.put(socketSessionId, lines.length);
//第一次如果太大截取最新的200行就够了避免传输的数据太大
int maxLength = Integer.parseInt(LogSocketConfig.Max_Read_Length);
if(first && copyOfRange.length > maxLength){
copyOfRange = Arrays.copyOfRange(copyOfRange, copyOfRange.length - maxLength, copyOfRange.length);
first = false;
}
String result = StringUtils.join(copyOfRange, "<br/>");
WebSocketEntity entity = new WebSocketEntity();
entity.setSend(WebSocketEnums.SEND_TP_LOGGING.getCode());
entity.setData(result);
WebSocketOnline.sendMessage(socketSessionId, JSONUtil.toJsonStr(entity));
//休眠一秒
Thread.sleep(Long.parseLong(LogSocketConfig.Read_Interval));
} catch (Exception e) {
logger.error("读取日志异常:{}", ErrorUtil.errorInfoToString(e));
} finally {
try {
assert reader != null;
reader.close();
} catch (IOException ioe) {
logger.error("字符流关闭异常:{}", ErrorUtil.errorInfoToString(ioe));
}
}
}
} catch (Exception e) {
logger.error("读取日志异常:{}", ErrorUtil.errorInfoToString(e));
}
}).start();
}
}

View File

@@ -1,235 +0,0 @@
package cn.somkit.fmt.utils;
import cn.somkit.fmt.config.RocksDBConfig;
import org.rocksdb.*;
import org.springframework.util.ObjectUtils;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
public class RocksDBUtils {
private static RocksDB rocksDB;
public static ConcurrentMap<String, ColumnFamilyHandle> columnFamilyHandleMap = new ConcurrentHashMap<>();
public static int GET_KEYS_BATCH_SIZE = 100000;
static {
try {
String rocksDBPath = null; //RocksDB文件目录
if (OsInfoUtil.isWindows()) {
rocksDBPath = RocksDBConfig.RocksDB_Path_Windows; // 指定windows系统下RocksDB文件目录
} else if(OsInfoUtil.isLinux()){
rocksDBPath = RocksDBConfig.RocksDB_Path_Linux; // 指定linux系统下RocksDB文件目录
}
RocksDB.loadLibrary();
Options options = new Options();
options.setCreateIfMissing(true); //如果数据库不存在则创建
List<byte[]> cfArr = RocksDB.listColumnFamilies(options, rocksDBPath); // 初始化所有已存在列族
List<ColumnFamilyDescriptor> columnFamilyDescriptors = new ArrayList<>(); //ColumnFamilyDescriptor集合
if (!ObjectUtils.isEmpty(cfArr)) {
for (byte[] cf : cfArr) {
columnFamilyDescriptors.add(new ColumnFamilyDescriptor(cf, new ColumnFamilyOptions()));
}
} else {
columnFamilyDescriptors.add(new ColumnFamilyDescriptor(RocksDB.DEFAULT_COLUMN_FAMILY, new ColumnFamilyOptions()));
}
DBOptions dbOptions = new DBOptions();
dbOptions.setCreateIfMissing(true);
List<ColumnFamilyHandle> columnFamilyHandles = new ArrayList<>(); //ColumnFamilyHandle集合
rocksDB = RocksDB.open(dbOptions, rocksDBPath, columnFamilyDescriptors, columnFamilyHandles);
for (int i = 0; i < columnFamilyDescriptors.size(); i++) {
ColumnFamilyHandle columnFamilyHandle = columnFamilyHandles.get(i);
String cfName = new String(columnFamilyDescriptors.get(i).getName(), StandardCharsets.UTF_8);
columnFamilyHandleMap.put(cfName, columnFamilyHandle);
}
System.out.println("RocksDB init success!! path:" + rocksDBPath);
} catch (Exception e) {
System.out.println("RocksDB init failure!! error:" + e.getMessage());
e.printStackTrace();
}
}
private RocksDBUtils(){}
public static ColumnFamilyHandle cfAddIfNotExist(String cfName) throws RocksDBException {
ColumnFamilyHandle columnFamilyHandle;
if (!columnFamilyHandleMap.containsKey(cfName)) {
columnFamilyHandle = rocksDB.createColumnFamily(new ColumnFamilyDescriptor(cfName.getBytes(), new ColumnFamilyOptions()));
columnFamilyHandleMap.put(cfName, columnFamilyHandle);
System.out.println("cfAddIfNotExist success!! cfName:" + cfName);
} else {
columnFamilyHandle = columnFamilyHandleMap.get(cfName);
}
return columnFamilyHandle;
}
/**
* 列族,删除(如果存在)
*/
public static void cfDeleteIfExist(String cfName) throws RocksDBException {
if (columnFamilyHandleMap.containsKey(cfName)) {
rocksDB.dropColumnFamily(columnFamilyHandleMap.get(cfName));
columnFamilyHandleMap.remove(cfName);
System.out.println("cfDeleteIfExist success!! cfName:" + cfName);
} else {
System.out.println("cfDeleteIfExist containsKey!! cfName:" + cfName);
}
}
/**
* 增
*/
public static void put(String cfName, String key, String value) throws RocksDBException {
ColumnFamilyHandle columnFamilyHandle = cfAddIfNotExist(cfName); //获取列族Handle
rocksDB.put(columnFamilyHandle, key.getBytes(), value.getBytes());
}
/**
* 增(批量)
*/
public static void batchPut(String cfName, Map<String, String> map) throws RocksDBException {
ColumnFamilyHandle columnFamilyHandle = cfAddIfNotExist(cfName); //获取列族Handle
WriteOptions writeOptions = new WriteOptions();
WriteBatch writeBatch = new WriteBatch();
for (Map.Entry<String, String> entry : map.entrySet()) {
writeBatch.put(columnFamilyHandle, entry.getKey().getBytes(), entry.getValue().getBytes());
}
rocksDB.write(writeOptions, writeBatch);
}
/**
* 删
*/
public static void delete(String cfName, String key) throws RocksDBException {
ColumnFamilyHandle columnFamilyHandle = cfAddIfNotExist(cfName); //获取列族Handle
rocksDB.delete(columnFamilyHandle, key.getBytes());
}
/**
* 查
*/
public static String get(String cfName, String key) throws RocksDBException {
String value = null;
ColumnFamilyHandle columnFamilyHandle = cfAddIfNotExist(cfName); //获取列族Handle
byte[] bytes = rocksDB.get(columnFamilyHandle, key.getBytes());
if (!ObjectUtils.isEmpty(bytes)) {
value = new String(bytes, StandardCharsets.UTF_8);
}
return value;
}
/**
* 查(多个键值对)
*/
public static Map<String, String> multiGetAsMap(String cfName, List<String> keys) throws RocksDBException {
Map<String, String> map = new HashMap<>(keys.size());
ColumnFamilyHandle columnFamilyHandle = cfAddIfNotExist(cfName); //获取列族Handle
List<ColumnFamilyHandle> columnFamilyHandles;
List<byte[]> keyBytes = keys.stream().map(String::getBytes).collect(Collectors.toList());
columnFamilyHandles = IntStream.range(0, keys.size()).mapToObj(i -> columnFamilyHandle).collect(Collectors.toList());
List<byte[]> bytes = rocksDB.multiGetAsList(columnFamilyHandles, keyBytes);
for (int i = 0; i < bytes.size(); i++) {
byte[] valueBytes = bytes.get(i);
String value = "";
if (!ObjectUtils.isEmpty(valueBytes)) {
value = new String(valueBytes, StandardCharsets.UTF_8);
}
map.put(keys.get(i), value);
}
return map;
}
/**
* 查(多个值)
*/
public static List<String> multiGetValueAsList(String cfName, List<String> keys) throws RocksDBException {
List<String> values = new ArrayList<>(keys.size());
ColumnFamilyHandle columnFamilyHandle = cfAddIfNotExist(cfName); //获取列族Handle
List<ColumnFamilyHandle> columnFamilyHandles = new ArrayList<>();
List<byte[]> keyBytes = keys.stream().map(String::getBytes).collect(Collectors.toList());
for (int i = 0; i < keys.size(); i++) {
columnFamilyHandles.add(columnFamilyHandle);
}
List<byte[]> bytes = rocksDB.multiGetAsList(columnFamilyHandles, keyBytes);
for (byte[] valueBytes : bytes) {
String value = "";
if (!ObjectUtils.isEmpty(valueBytes)) {
value = new String(valueBytes, StandardCharsets.UTF_8);
}
values.add(value);
}
return values;
}
/**
* 查(所有键)
*/
public static List<String> getAllKey(String cfName) throws RocksDBException {
List<String> list = new ArrayList<>();
ColumnFamilyHandle columnFamilyHandle = cfAddIfNotExist(cfName); //获取列族Handle
try (RocksIterator rocksIterator = rocksDB.newIterator(columnFamilyHandle)) {
for (rocksIterator.seekToFirst(); rocksIterator.isValid(); rocksIterator.next()) {
list.add(new String(rocksIterator.key(), StandardCharsets.UTF_8));
}
}
return list;
}
/**
* 分片查(键)
*/
public static List<String> getKeysFrom(String cfName, String lastKey) throws RocksDBException {
List<String> list = new ArrayList<>(GET_KEYS_BATCH_SIZE);
// 获取列族Handle
ColumnFamilyHandle columnFamilyHandle = cfAddIfNotExist(cfName);
try (RocksIterator rocksIterator = rocksDB.newIterator(columnFamilyHandle)) {
if (lastKey != null) {
rocksIterator.seek(lastKey.getBytes(StandardCharsets.UTF_8));
rocksIterator.next();
} else {
rocksIterator.seekToFirst();
}
// 一批次最多 GET_KEYS_BATCH_SIZE 个 key
while (rocksIterator.isValid() && list.size() < GET_KEYS_BATCH_SIZE) {
list.add(new String(rocksIterator.key(), StandardCharsets.UTF_8));
rocksIterator.next();
}
}
return list;
}
/**
* 查(所有键值)
*/
public static Map<String, String> getAll(String cfName) throws RocksDBException {
Map<String, String> map = new HashMap<>();
ColumnFamilyHandle columnFamilyHandle = cfAddIfNotExist(cfName); //获取列族Handle
try (RocksIterator rocksIterator = rocksDB.newIterator(columnFamilyHandle)) {
for (rocksIterator.seekToFirst(); rocksIterator.isValid(); rocksIterator.next()) {
map.put(new String(rocksIterator.key(), StandardCharsets.UTF_8), new String(rocksIterator.value(), StandardCharsets.UTF_8));
}
}
return map;
}
/**
* 查总条数
*/
public static int getCount(String cfName) throws RocksDBException {
int count = 0;
ColumnFamilyHandle columnFamilyHandle = cfAddIfNotExist(cfName); //获取列族Handle
try (RocksIterator rocksIterator = rocksDB.newIterator(columnFamilyHandle)) {
for (rocksIterator.seekToFirst(); rocksIterator.isValid(); rocksIterator.next()) {
count++;
}
}
return count;
}
}

View File

@@ -15,26 +15,52 @@ spring:
multipart:
max-file-size: 1024MB
max-request-size: 10240MB
threads:
virtual:
enabled: true
somkit:
upload:
path:
windows: D://data/install/upload
linux: /mnt/files
db:
rocks:
path:
windows: D://RocksDB//fmt
linux: /usr/local/rocksdb/fmt
column-family: default
logging:
socket:
#日志文件地址
log-file-path: ./logs/fmt-server.log
#最大读取展示行数
max-read-length: 500
#读取间隔时间 毫秒
read-interval: 1000
logging:
config: classpath:logback-spring.xml
config: classpath:logback-spring.xml
metona:
cache:
# 缓存类型,支持以下类型:
# - CONCURRENT_HASH_MAP: 基于 ConcurrentHashMap 的线程安全缓存
# - WEAK_HASH_MAP: 基于 WeakHashMap 的弱引用缓存
# - LINKED_HASH_MAP: 基于 LinkedHashMap 的 LRU 缓存
type: LINKED_HASH_MAP
# 缓存的初始容量,默认值为 16
initial-capacity: 128
# 缓存的最大容量,当缓存条目数超过该值时,会根据策略移除旧条目
# 仅对 LINKED_HASH_MAP 类型有效
maximum-size: 2000
# 写入后过期时间(单位由 time-unit 指定)
# 默认值为 -1表示永不过期
expire-after-write: -1
# 访问后过期时间(单位由 time-unit 指定)
# 默认值为 -1表示永不过期
expire-after-access: -1
# 时间单位,支持以下值:
# - NANOSECONDS: 纳秒
# - MICROSECONDS: 微秒
# - MILLISECONDS: 毫秒(默认)
# - SECONDS: 秒
# - MINUTES: 分钟
# - HOURS: 小时
# - DAYS: 天
time-unit: MILLISECONDS
# 是否记录缓存统计信息(如命中率、加载次数等)
# 默认值为 false
record-stats: true

View File

@@ -30,6 +30,16 @@
<outputDirectory>${file.separator}bin</outputDirectory>
</fileSet>
<!--拷贝版本记录到jar包的外部版本记录目录下面-->
<fileSet>
<directory>${basedir}/版本记录</directory>
<includes>
<include>*.md</include>
</includes>
<filtered>true</filtered>
<outputDirectory>${file.separator}versions</outputDirectory>
</fileSet>
<!--拷贝lib包到jar包的外部lib下面-->
<fileSet>
<directory>${project.build.directory}/lib</directory>

View File

@@ -1,5 +1,5 @@
chcp 65001
SET JAR=D:/fmt/fmt-1.2.0.jar
SET JAR=D:/fmt/fmt-2.0.0.jar
SET JAR_CONFIG=D:/fmt/config/
SET JAR_LIB=D:/fmt/lib/
java -Dfile.encoding=utf-8 -jar %JAR% --spring.config.location=%JAR_CONFIG% --spring.lib.location=%JAR_LIB%

View File

@@ -1,8 +1,8 @@
#!/bin/bash
JAR=/home/deploy/fmt/fmt-1.2.0.jar
JAR=/home/deploy/fmt/fmt-2.0.0.jar
JAR_CONFIG=/home/deploy/fmt/config/
JAR_LIB=/home/deploy/fmt/lib/
JAVA_HOME=/usr/local/jdk-17.0.7
JAVA_HOME=/usr/local/jdk-21
JAVA=$JAVA_HOME/bin/java
nohup $JAVA -jar $JAR --spring.config.location=$JAR_CONFIG --spring.lib.location=$JAR_LIB -Djava.ext.dirs=$JAVA_HOME/lib &
tail -f nohup.out

View File

@@ -3,11 +3,6 @@
<!-- Spring Boot 默认日志配置 -->
<include resource="org/springframework/boot/logging/logback/defaults.xml"/>
<!-- 定义日志的根目录 -->
<property name="LOG_HOME" value="./logs"/>
<!-- 定义日志文件名称 -->
<property name="APP_NAME" value="fmt-server"/>
<!-- 日志输出到控制台 -->
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<filter class="ch.qos.logback.classic.filter.ThresholdFilter">
@@ -19,43 +14,8 @@
</encoder>
</appender>
<!-- 滚动记录文件,先将日志记录到指定文件,当符合某个条件时,将日志记录到其他文件 -->
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<!-- 指定日志文件的名称 -->
<file>${LOG_HOME}/${APP_NAME}.log</file>
<!--
当发生滚动时,决定 RollingFileAppender 的行为,涉及文件移动和重命名
TimeBasedRollingPolicy 最常用的滚动策略,它根据时间来制定滚动策略,既负责滚动也负责出发滚动。
-->
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<!--
滚动时产生的文件的存放位置及文件名称 %d{yyyy-MM-dd}:按天进行日志滚动
%i当文件大小超过maxFileSize时按照i进行文件滚动
-->
<fileNamePattern>${LOG_HOME}/${APP_NAME}-%d{yyyy-MM-dd}-%i.log</fileNamePattern>
<!--
可选节点,控制保留的归档文件的最大数量,超出数量就删除旧文件。假设设置每天滚动,
且maxHistory是365则只保存最近365天的文件删除之前的旧文件。注意删除旧文件是
那些为了归档而创建的目录也会被删除。
-->
<MaxHistory>365</MaxHistory>
<!--
当日志文件超过maxFileSize指定的大小是根据上面提到的%i进行日志文件滚动 注意此处配置SizeBasedTriggeringPolicy是无法实现按文件大小进行滚动的必须配置timeBasedFileNamingAndTriggeringPolicy
-->
<timeBasedFileNamingAndTriggeringPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedFNATP">
<maxFileSize>100MB</maxFileSize>
</timeBasedFileNamingAndTriggeringPolicy>
</rollingPolicy>
<!-- 日志输出格式: -->
<layout class="ch.qos.logback.classic.PatternLayout">
<pattern>[%d{yyyy-MM-dd HH:mm:ss.SSS}] [%thread] [%-5level] [%logger{50}] -> %msg%n</pattern>
<charset>UTF-8</charset>
</layout>
</appender>
<!-- 指定日志输出级别以及启动的Appender -->
<root level="INFO">
<appender-ref ref="CONSOLE"/>
<appender-ref ref="FILE"/>
</root>
</configuration>

View File

@@ -0,0 +1,459 @@
class LogMonitorAdaptive {
constructor(container, opts = {}) {
this.container = typeof container === 'string'
? document.querySelector(container)
: container;
if (!this.container) throw new Error('容器未找到');
/* ========== 配置项及其含义 ========== */
this.cfg = {
/* --- 尺寸相关 --- */
width: null, // 日志容器宽度。null=100%,可填数字(px)或字符串('80%'/'20rem')
height: null, // 日志容器高度。null=100vh可填数字(px)或字符串('400px'/'50vh')
/* --- 日志条数与滚动 --- */
maxLines: 5000, // 最大保留日志条数,超出后自动删除最旧日志
autoScroll: true, // 出现新日志时是否自动滚动到底部
/* --- 主题与外观 --- */
theme: 'dark', // 主题:'dark' | 'light',控制整体配色
fontSize: 14, // 日志文字大小,单位 px
wordWrap: false, // 日志内容是否自动换行true=换行false=横向滚动)
/* --- 日志内容格式 --- */
showTimestamp: true, // 是否显示时间戳
showLevel: true, // 是否显示日志级别标签
timeFormat: 'HH:mm:ss.SSS', // 时间戳格式,可自定义
/* --- 日志级别 & 过滤 --- */
levels: ['DEBUG', 'INFO', 'WARN', 'ERROR', 'SUCCESS', 'SYSTEM'], // 可用日志级别
// 过滤功能基于 levels如只想显示 info/warn可在这里删减
/* --- 功能开关 --- */
enableFilter: true, // 顶部是否显示级别过滤复选框
enableSearch: true, // 是否启用搜索框与高亮功能
enableExport: true, // 是否允许导出日志文件
enableClear: true, // 是否提供“清空”按钮
enablePause: true, // 是否提供“暂停/继续”按钮
enableThemeToggle: true, // 是否提供“切换主题”按钮
enableFullscreen: true, // 是否提供“全屏”按钮
enableFontSize: true, // 是否提供“字体大小 +/-”按钮
enableWordWrap: true, // 是否提供“换行/不换行”切换按钮
/* --- 暂停/继续 的回调函数 --- */
onTogglePause: () => {},
/* --- 创建完成 的回调函数 --- */
onCreated: () => {},
...opts
};
/* 强制确保 levels 是数组,防止 forEach 报错 */
if (!Array.isArray(this.cfg.levels)) {
this.cfg.levels = ['INFO', 'WARN', 'ERROR'];
}
this.isPaused = false;
this.isFullscreen = false;
this.logs = [];
this.filters = new Set();
this.searchTerm = '';
this.highlightIndex = -1;
this.highlightEls = [];
this.initDOM();
this.bindResize();
this.bindGlobalEvents();
//执行回调函数
if(this.cfg.onCreated && typeof this.cfg.onCreated === 'function'){
this.cfg.onCreated();
}
}
/* ------------------------ 初始化 ------------------------ */
initDOM() {
this.container.innerHTML = '';
this.setContainerSize(); // 宽高自适应
this.applyContainerStyle();
// 顶部导航栏
this.navbar = document.createElement('div');
Object.assign(this.navbar.style, {
display: 'flex',
alignItems: 'center',
padding: '8px 12px',
backgroundColor: this.cfg.theme === 'dark' ? '#2d2d30' : '#f3f3f3',
borderBottom: `1px solid ${this.cfg.theme === 'dark' ? '#555' : '#ccc'}`,
gap: '10px',
fontSize: '14px',
flexWrap: 'wrap'
});
// 按钮组
if (this.cfg.enablePause) {
this.pauseBtn = this.createBtn('⏸ 暂停', () => this.togglePause(), {w: '72px'});
this.navbar.appendChild(this.pauseBtn);
}
if (this.cfg.enableClear) {
this.navbar.appendChild(this.createBtn('🗑 清空', () => this.clear(), {w: '72px'}));
}
if (this.cfg.enableExport) {
this.navbar.appendChild(this.createBtn('💾 导出', () => this.export(), {w: '72px'}));
}
// 主题切换按钮
if (this.cfg.enableThemeToggle) {
this.themeBtn = this.createBtn(
this.cfg.theme === 'dark' ? '☀️ 明亮' : '🌙 暗黑',
() => this.toggleTheme(),
{w: '80px'}
);
this.navbar.appendChild(this.themeBtn);
}
// 字体大小
if (this.cfg.enableFontSize) {
this.navbar.appendChild(this.createBtn('A-', () => this.changeFontSize(-1), {w: '36px'}));
this.navbar.appendChild(this.createBtn('A+', () => this.changeFontSize(1), {w: '36px'}));
}
// 换行
if (this.cfg.enableWordWrap) {
this.wrapBtn = this.createBtn(
this.cfg.wordWrap ? '⏎ 换行' : '⏎ 不换行',
() => this.toggleWordWrap(),
{w: '90px'}
);
this.navbar.appendChild(this.wrapBtn);
}
// 全屏
if (this.cfg.enableFullscreen) {
this.navbar.appendChild(this.createBtn('⛶ 全屏', () => this.toggleFullscreen(), {w: '72px'}));
}
// 过滤复选框
if (this.cfg.enableFilter) {
this.cfg.levels.forEach(lvl => {
this.navbar.appendChild(this.createCheckbox(lvl));
});
}
// 搜索框
if (this.cfg.enableSearch) {
const searchWrap = document.createElement('span');
searchWrap.style.marginLeft = 'auto';
searchWrap.style.display = 'flex';
searchWrap.style.alignItems = 'center';
searchWrap.style.gap = '4px';
this.searchInput = document.createElement('input');
Object.assign(this.searchInput.style, {
padding: '6px 8px',
fontSize: '14px',
border: `1px solid ${this.cfg.theme === 'dark' ? '#555' : '#bbb'}`,
borderRadius: '4px',
backgroundColor: this.cfg.theme === 'dark' ? '#3c3c3c' : '#fff',
color: 'inherit',
width: '260px'
});
this.searchInput.placeholder = '搜索…';
this.searchInput.oninput = () => {
this.searchTerm = this.searchInput.value.trim();
this.renderVisible();
};
searchWrap.appendChild(this.searchInput);
['↑', '↓'].forEach((arr, idx) => {
searchWrap.appendChild(
this.createBtn(arr, () => this.navigateHighlight(idx), {w: '30px'})
);
});
this.navbar.appendChild(searchWrap);
}
// 日志视窗
this.viewport = document.createElement('div');
this.applyViewportStyle();
this.container.appendChild(this.navbar);
this.container.appendChild(this.viewport);
this.renderVisible();
}
/* ------------------------ 样式 ------------------------ */
setContainerSize() {
// 如果用户未指定宽高,则自适应
if (this.cfg.width === null) {
this.container.style.width = '100%';
} else {
this.container.style.width = typeof this.cfg.width === 'number' ? `${this.cfg.width}px` : this.cfg.width;
}
if (this.cfg.height === null) {
//this.container.style.height = '100vh';
const bodyMargin = parseFloat(getComputedStyle(document.body).marginTop || 0) +
parseFloat(getComputedStyle(document.body).marginBottom || 0);
this.container.style.height = `calc(100vh - ${bodyMargin}px - 2px)`;
} else {
this.container.style.height = typeof this.cfg.height === 'number' ? `${this.cfg.height}px` : this.cfg.height;
}
}
applyContainerStyle() {
Object.assign(this.container.style, {
display: 'flex',
flexDirection: 'column',
border: `1px solid ${this.cfg.theme === 'dark' ? '#444' : '#ccc'}`,
borderRadius: '6px',
fontFamily: 'Consolas, "Courier New", monospace',
fontSize: `${this.cfg.fontSize}px`,
lineHeight: 1.45,
backgroundColor: this.cfg.theme === 'dark' ? '#1e1e1e' : '#fafafa',
color: this.cfg.theme === 'dark' ? '#d4d4d4' : '#222',
overflow: 'hidden'
});
}
applyViewportStyle() {
Object.assign(this.viewport.style, {
flex: 1,
overflowY: 'auto',
padding: '8px',
whiteSpace: this.cfg.wordWrap ? 'pre-wrap' : 'pre',
wordBreak: 'break-word'
});
}
/* ------------------------ 工具 ------------------------ */
createBtn(text, handler, {w} = {}) {
const btn = document.createElement('button');
btn.textContent = text;
Object.assign(btn.style, {
padding: '6px 10px',
fontSize: '14px',
backgroundColor: this.cfg.theme === 'dark' ? '#444' : '#e7e7e7',
color: 'inherit',
border: `1px solid ${this.cfg.theme === 'dark' ? '#666' : '#ccc'}`,
borderRadius: '4px',
cursor: 'pointer',
minWidth: w || 'auto'
});
btn.onmouseenter = () => btn.style.backgroundColor = this.cfg.theme === 'dark' ? '#555' : '#d0d0d0';
btn.onmouseleave = () => btn.style.backgroundColor = this.cfg.theme === 'dark' ? '#444' : '#e7e7e7';
btn.onclick = handler;
return btn;
}
createCheckbox(level) {
const lbl = document.createElement('label');
lbl.style.color = this.cfg.theme === 'dark' ? '#ccc' : '#333';
lbl.style.fontSize = '13px';
lbl.style.display = 'flex';
lbl.style.alignItems = 'center';
const chk = document.createElement('input');
chk.type = 'checkbox';
chk.checked = true;
chk.style.margin = '0 4px';
chk.onchange = () => {
if (chk.checked) this.filters.delete(level);
else this.filters.add(level);
this.renderVisible();
};
lbl.appendChild(chk);
lbl.append(level.toUpperCase());
return lbl;
}
/* ------------------------ 日志渲染 ------------------------ */
log(message, level = 'info', ts = this.formatTime(new Date())) {
if (!this.cfg.levels.includes(level)) level = 'info';
if (this.isPaused) return;
ts = this.cfg.showTimestamp ? ts : '';
this.logs.push({message, level, ts, id: Date.now() + Math.random()});
if (this.logs.length > this.cfg.maxLines) this.logs.shift();
this.renderVisible();
}
renderVisible() {
this.viewport.innerHTML = '';
this.highlightEls = [];
this.highlightIndex = -1;
const filtered = this.logs.filter(l => !this.filters.has(l.level));
const frag = document.createDocumentFragment();
const regex = this.searchTerm ? new RegExp(`(${this.searchTerm})`, 'gi') : null;
filtered.forEach(entry => {
const line = document.createElement('div');
line.style.display = 'flex';
line.style.margin = '1px 0';
if (this.cfg.showTimestamp && entry.ts) {
const tsSpan = document.createElement('span');
tsSpan.textContent = entry.ts + ' ';
tsSpan.style.color = this.cfg.theme === 'dark' ? '#6a9955' : '#008000';
tsSpan.style.minWidth = '90px';
line.appendChild(tsSpan);
}
if (this.cfg.showLevel) {
const lvlSpan = document.createElement('span');
lvlSpan.textContent = `[${entry.level.toUpperCase()}]`;
const colors = {
debug: '#9c27b0', info: '#2196f3', warn: '#ff9800',
error: '#f44336', success: '#4caf50', system: '#00bcd4'
};
lvlSpan.style.color = colors[entry.level.toLowerCase()] || colors.info;
lvlSpan.style.minWidth = '70px';
lvlSpan.style.fontWeight = 'bold';
line.appendChild(lvlSpan);
}
const msgSpan = document.createElement('span');
msgSpan.style.flex = 1;
let msg = entry.message;
if (regex) {
if (regex.test(entry.message)) {
line.style.backgroundColor = 'rgba(255,235,59,0.25)';
this.highlightEls.push(line);
}
msg = entry.message.replace(regex, '<mark style="background:#ffeb3b;color:#000;">$1</mark>');
}
msgSpan.innerHTML = msg;
line.appendChild(msgSpan);
frag.appendChild(line);
});
this.viewport.appendChild(frag);
if (this.cfg.autoScroll && !this.isPaused) {
this.viewport.scrollTop = this.viewport.scrollHeight;
}
}
/* ------------------------ 交互 ------------------------ */
togglePause() {
this.isPaused = !this.isPaused;
this.pauseBtn.textContent = this.isPaused ? '▶ 继续' : '⏸ 暂停';
this.pauseBtn.style.backgroundColor = this.isPaused
? (this.cfg.theme === 'dark' ? '#d32f2f' : '#ff5252')
: (this.cfg.theme === 'dark' ? '#444' : '#e7e7e7');
//执行回调函数
if(this.cfg.onTogglePause && typeof this.cfg.onTogglePause === 'function'){
this.cfg.onTogglePause(this.isPaused);
}
}
toggleTheme() {
this.cfg.theme = this.cfg.theme === 'dark' ? 'light' : 'dark';
this.themeBtn.textContent = this.cfg.theme === 'dark' ? '☀️ 明亮' : '🌙 暗黑';
this.applyContainerStyle();
this.navbar.style.backgroundColor = this.cfg.theme === 'dark' ? '#2d2d30' : '#f3f3f3';
this.renderVisible();
}
changeFontSize(delta) {
this.cfg.fontSize = Math.max(10, Math.min(24, this.cfg.fontSize + delta));
this.container.style.fontSize = `${this.cfg.fontSize}px`;
}
toggleWordWrap() {
this.cfg.wordWrap = !this.cfg.wordWrap;
this.wrapBtn.textContent = this.cfg.wordWrap ? '⏎ 不换行' : '⏎ 换行';
this.applyViewportStyle();
}
toggleFullscreen() {
if (!this.isFullscreen) {
if (this.container.requestFullscreen) this.container.requestFullscreen();
else if (this.container.webkitRequestFullscreen) this.container.webkitRequestFullscreen();
else if (this.container.msRequestFullscreen) this.container.msRequestFullscreen();
} else {
if (document.exitFullscreen) document.exitFullscreen();
else if (document.webkitExitFullscreen) document.webkitExitFullscreen();
else if (document.msExitFullscreen) document.msExitFullscreen();
}
this.isFullscreen = !this.isFullscreen;
}
navigateHighlight(dir) {
if (!this.highlightEls.length) return;
this.highlightIndex = (this.highlightIndex + (dir === 0 ? -1 : 1) + this.highlightEls.length) % this.highlightEls.length;
this.highlightEls.forEach((el, idx) => {
el.style.outline = idx === this.highlightIndex ? '2px solid #ffeb3b' : 'none';
});
this.highlightEls[this.highlightIndex].scrollIntoView({block: 'nearest', behavior: 'smooth'});
}
clear() {
this.logs = [];
this.renderVisible();
}
export() {
const lines = this.logs.map(l => `${l.ts} [${l.level.toUpperCase()}] ${l.message}`);
const blob = new Blob([lines.join('\n')], {type: 'text/plain;charset=utf-8'});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `log_${new Date().toISOString().slice(0, 19).replace(/:/g, '-')}.txt`;
a.click();
URL.revokeObjectURL(url);
}
formatTime(date) {
const t = date.toTimeString().slice(0, 8);
const ms = String(date.getMilliseconds()).padStart(3, '0');
return `${t}.${ms}`;
}
bindResize() {
window.addEventListener('resize', () => {
this.setContainerSize(); // 响应窗口变化
});
}
bindGlobalEvents() {
document.addEventListener('keydown', e => {
if (e.key === 'Escape' && this.searchInput) {
this.searchInput.value = '';
this.searchTerm = '';
this.renderVisible();
}
if (e.ctrlKey && e.key === 'k') {
e.preventDefault();
this.clear();
}
});
}
/* 快捷方法 */
info(msg, ts = this.formatTime(new Date())) {
this.log(msg, 'info', ts);
}
warn(msg, ts = this.formatTime(new Date())) {
this.log(msg, 'warn', ts);
}
error(msg, ts = this.formatTime(new Date())) {
this.log(msg, 'error', ts);
}
success(msg, ts = this.formatTime(new Date())) {
this.log(msg, 'success', ts);
}
debug(msg, ts = this.formatTime(new Date())) {
this.log(msg, 'debug', ts);
}
system(msg, ts = this.formatTime(new Date())) {
this.log(msg, 'system', ts);
}
}

View File

@@ -1,251 +0,0 @@
/**
* WebSocket封装工具类
* <pre>
* 代码示例:
* let FmtSocket = new FmtSocket(
* {url:'websocket服务器地址',id:'唯一标识',timeout:心跳检测间隔时间(毫秒)}
* );
* //调用初始化方法
* FmtSocket.init((data) => {
* //初始化完成之后的回调函数 ata为监听到的数据
* //可以在这里做接收到消息后的逻辑处理
* console.log(data);
* });
* //调用发送消息方法 message 消息字符串或对象
* FmtSocket.sendMsg(message, () => {
* //发送消息成功之后的回调函数
* });
* </pre>
*/
class FmtSocket {
id;
#options;
#ws;
#entity;
#deepProxyObj;
#proxyMsg;
/**
* 构造函数
* @param {Object} options
*/
constructor(options) {
this.#options = this.#extend({
url: 'http://127.0.0.1/socket/ws',//后台websocket服务地址
id: this.#uuid(),//如果没传此参数创建一个随机的UUID传给后台服务作为标识
timeout: 30000 //心跳检测间隔时间 默认30秒
}, options);
this.id = this.#options.id;
this.#ws = null;
//send:发送类型 HeartBeat:心跳 Logging:消息
this.#entity = {send: null, data: null};
}
/**
* 初始化websocket
* @param {Function} callback (type, data) 回调函数
*/
init(callback){
if(callback && typeof callback === 'function'){
this.#proxyMsg = [];
this.#deepProxyObj = this.#deepProxy(this.#proxyMsg,callback);
}
this.#createWebSocket();
}
/**
* 对象、数组变化监听(增删改)
* @param {Object} obj
* @param {Function} callback
* @return {Object}
*/
#deepProxy(obj, callback){
if (typeof obj === 'object') {
for (let key in obj) {
if (typeof obj[key] === 'object') {
obj[key] = this.#deepProxy(obj[key], callback);
}
}
}
return new Proxy(obj, {
/**
* @param {Object, Array} target 设置值的对象
* @param {String} key 属性
* @param {any} value 值
* @param {Object} receiver this
*/
set: (target, key, value, receiver)=> {
if (typeof value === 'object') {
value = this.#deepProxy(value, callback);
}
let cbType = target[key] === undefined ? 'create' : 'modify';
//排除数组修改length回调
if (!(Array.isArray(target) && key === 'length')) {
if (cbType === 'create') {
callback(value);
}
}
return Reflect.set(target, key, value, receiver);
},
deleteProperty(target, key) {
return Reflect.deleteProperty(target, key);
}
});
}
#createWebSocket(){
try {
if ('WebSocket' in window) {
this.#ws = new WebSocket((this.#options.url + "?id=" + this.#options.id)
.replace("http", "ws")
.replace("https", "wss"));
} else {
alert('浏览器不支持WebSocket通讯');
return false;
}
this.#onopen();
this.#onmessage();
this.#onerror();
this.#onclose();
//监听窗口关闭事件当窗口关闭时主动去关闭websocket连接防止连接还没断开就关闭窗口server端会抛异常。
window.onbeforeunload = ()=> {
this.#ws.close();
};
} catch (e) {
console.log("WebSocket:链接失败", e);
alert('WebSocket:链接失败');
}
}
/**
* 关闭websocket链接
* @param {Function} callback
*/
closeWebSocket(callback){
if(this.#ws){
this.#ws.close();
}
if(callback && typeof callback === 'function'){
callback();
}
}
/**
* 发送消息
* @param {String, Object} message
* @param {Function} callback
*/
sendMsg(message, callback){
if(!message || typeof message !== 'string' || typeof message !== 'object'){
alert("FmtSocket sendMsg message is not null or message is not a string or object");
return false;
}
if(callback && typeof callback !== 'function'){
alert("FmtSocket sendMsg callback is not a function");
return false;
}
this.#emptyObjectPropertyValues(this.#entity);
this.#entity.send = 'Message';
this.#entity.data = message;
if (this.#ws.readyState === this.#ws.CONNECTING) {
// 正在开启状态等待1s后重新调用
let socket = this;
setTimeout(function () {
socket.#ws.send(JSON.stringify(socket.#entity));
callback();
}, 1000)
}else{
this.#ws.send(JSON.stringify(this.#entity));
callback();
}
}
#onopen(){
this.#ws.onopen = (event) => {
console.log("WebSocket:链接开启");
}
}
#onmessage(){
this.#ws.onmessage = (event) => {
this.#emptyObjectPropertyValues(this.#entity);
this.#entity = JSON.parse(event.data);
//心跳检测
if(this.#entity.send === 'HeartBeat'){
this.#heartBeat();
}
if(this.#deepProxyObj){
this.#deepProxyObj.splice(0);//赋值前先清空,保证消息数组里只有一条最新的数据
let data = JSON.parse(JSON.stringify(this.#entity));
this.#deepProxyObj.push(data);
}
}
}
#onerror(){
this.#ws.onerror = (event) => {
console.log("WebSocket:发生错误", event);
}
}
#onclose(){
this.#ws.onclose = (event) => {
console.log("WebSocket:链接关闭");
}
}
#heartBeat(){
setTimeout(()=> {
this.#emptyObjectPropertyValues(this.#entity);
if (this.#ws.readyState === this.#ws.CLOSING
|| this.#ws.readyState === this.#ws.CLOSED){
alert("WebSocket通讯服务链接已关闭");
return false;
}
this.#entity.send = 'HeartBeat';
this.#entity.data = '心跳检测';
if (this.#ws.readyState === this.#ws.CONNECTING) {
// 正在开启状态等待1s后重新调用
setTimeout(()=> {
this.#ws.send(JSON.stringify(this.#entity));
}, 1000)
}else{
this.#ws.send(JSON.stringify(this.#entity));
}
},this.#options.timeout);
}
/**
* 扩展source相同属性的值去覆盖target, target如果没有这个属性就新增
* @param {Object} target
* @param {Object} source
* @returns {Object}
*/
#extend(target, source){
if(typeof target !== 'object') return null;
if(typeof source !== 'object') return null;
for (const key in source) {
// 使用for in会遍历数组所有的可枚举属性包括原型。
if (Object.prototype.hasOwnProperty.call(source, key)) {
target[key] = source[key];
}
}
return target;
}
#uuid(len, radix){
const chars = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('');
let uuid = [], i;
radix = radix || chars.length;
if (len) {
// Compact form
for (i = 0; i < len; i++) uuid[i] = chars[0 | Math.random() * radix];
} else {
// rfc4122, version 4 form
let r;
// rfc4122 requires these characters
uuid[8] = uuid[13] = uuid[18] = uuid[23] = '-';
uuid[14] = '4';
// Fill in random data. At i==19 set the high bits of clock sequence as
// per rfc4122, sec. 4.1.5
for (i = 0; i < 36; i++) {
if (!uuid[i]) {
r = 0 | Math.random() * 16;
uuid[i] = chars[(i === 19) ? (r & 0x3) | 0x8 : r];
}
}
}
return uuid.join('');
}
#emptyObjectPropertyValues(person){
Object.keys(person).forEach(key => (person[key] = null));
}
}

View File

@@ -40,7 +40,7 @@
<span class="ax-line"></span>
</div>
<div class="ax-item">
<a th:href="@{/logging/index}" class="ax-text">在线日志</a>
<a th:href="@{/logging/index}" target="_blank" class="ax-text">在线日志</a>
<span class="ax-line"></span>
</div>
</div>
@@ -104,7 +104,7 @@
const fileDown = (obj) => {
const path = obj.getAttribute("value");
window.open(Fmt.ctx() + '/download/execute?path=' + encodeURIComponent(path) + '&temp=' + false);
window.open(Fmt.ctx() + '/download/file?path=' + encodeURIComponent(path) + '&temp=' + false);
};
window.onload = () => {
@@ -135,7 +135,7 @@
};
Fmt.axios(options).then((result) => {
message.update({content: '压缩包' + result.filename + "创建完成,准备下载文件......", result: 'success'}).show();
window.open(Fmt.ctx() + '/download/execute?path=' + encodeURIComponent(result.path) + '&temp=' + true);
window.open(Fmt.ctx() + '/download/file?path=' + encodeURIComponent(result.path) + '&temp=' + true);
}).catch((err) => {
console.log(err);
new axMessage({content: err, result: 'error', iconShow: true}).show();

View File

@@ -1,62 +1,67 @@
<!DOCTYPE html>
<html lang="zh-CN" xmlns:th="https://www.thymeleaf.org">
<html lang="zh-CN">
<head>
<meta name="viewport" content="width=device-width,initial-scale=1.0,maximum-scale=1.0,user-scalable=no" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black" />
<meta name="apple-touch-fullscreen" content="yes"/>
<meta name="format-detection" content="email=no" />
<meta name="wap-font-scale" content="no" />
<meta name="viewport" content="user-scalable=no, width=device-width" />
<meta content="telephone=no" name="format-detection" />
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<meta charset="UTF-8">
<title>在线日志</title>
<link th:href="@{/axui-v2.1.1/css/ax.css}" rel="stylesheet" type="text/css" >
<link th:href="@{/axui-v2.1.1/css/ax-response.css}" rel="stylesheet" type="text/css" >
</head>
<body>
<header class="ax-header">
<div class="ax-row">
<div class="ax-col">
<a th:href="@{/download/index}" class="ax-logo">
<img th:src="@{/common/images/logo.png}" alt="File Management"/>
</a>
</div>
<div class="ax-nav">
<div class="ax-item">
<a th:href="@{/download/index}" class="ax-text">文件列表</a>
<span class="ax-line"></span>
</div>
<div class="ax-item">
<a th:href="@{/upload/index}" class="ax-text">文件上传</a>
<span class="ax-line"></span>
</div>
<div class="ax-item ax-selected">
<a th:href="@{/logging/index}" class="ax-text">在线日志</a>
<span class="ax-line"></span>
</div>
</div>
</div>
</header>
<div class="ax-space-header"></div>
<div id="RootDiv" class="ax-border ax-margin">
</div>
<script th:src="@{/axui-v2.1.1/js/ax.js}" type="text/javascript" charset="utf-8"></script>
<!-- 日志容器 -->
<div id="logContainer"></div>
<script th:src="@{/common/js/basic.js}" type="text/javascript" charset="utf-8"></script>
<script th:src="@{/common/js/fmt-socket.js}" type="text/javascript" charset="utf-8"></script>
<script th:src="@{/common/js/LogMonitorAdaptive.js}" type="text/javascript" charset="utf-8"></script>
<script type="text/javascript" th:inline="javascript" charset="utf-8">
window.onload = () => {
const socket = new FmtSocket({url: Fmt.ctx() + '/socket/ws'});
socket.init((data) => {
if (data.send === 'Logging') {
const log = data.data;
const logDiv = document.createElement('div');
logDiv.innerHTML = '<pre><strong>' + log + '</strong></pre>';
document.querySelector('#RootDiv').appendChild(logDiv);
let ws = null;
const logger = new LogMonitorAdaptive('#logContainer', {
theme: 'dark',
maxLines: 10000,
fontSize: 14,
enableFilter: true,
enableSearch: true,
enableExport: true,
enableClear: true,
enablePause: true,
enableThemeToggle: true,
enableFullscreen: true,
enableFontSize: true,
enableWordWrap: true,
showTimestamp: true, // 是否显示时间戳
showLevel: true, // 是否显示日志级别标签
wordWrap: true, // 日志内容是否自动换行true=换行false=横向滚动)
//暂停/继续 回调函数
onTogglePause: (isPaused) => {
},
onCreated: () => {
console.log('日志容器已创建');
}
});
try {
if ('WebSocket' in window) {
ws = new WebSocket((Fmt.ctx() + '/socket/ws').replace("http", "ws").replace("https", "wss"));
} else {
alert('浏览器不支持WebSocket通讯');
return false;
}
ws.onmessage = function (event) {
if(event.data){
let data = JSON.parse(event.data);
let message = `${data.number} --- [ ${data.threadName} ] <span style="color: #198cff;">${data.name}</span> : ${data.message}`;
logger.log(message, data.level, data.timestamp);
}
}
//监听窗口关闭事件当窗口关闭时主动去关闭websocket连接防止连接还没断开就关闭窗口server端会抛异常。
window.onbeforeunload = ()=> {
ws.close();
};
} catch (e) {
alert('WebSocket:链接失败');
}
}
</script>
</body>

View File

@@ -33,7 +33,7 @@
<span class="ax-line"></span>
</div>
<div class="ax-item">
<a th:href="@{/logging/index}" class="ax-text">在线日志</a>
<a th:href="@{/logging/index}" target="_blank" class="ax-text">在线日志</a>
<span class="ax-line"></span>
</div>
</div>

View File

@@ -1,13 +0,0 @@
package cn.somkit.fmt;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
@SpringBootTest
class FmtApplicationTests {
@Test
void contextLoads() {
}
}

View File

@@ -16,4 +16,34 @@
修改日志文件生成及读取位置
上传文件格式新增支持js、css、html、vsdx、dmp、7z、ppt、pptx
AxUI的axAjax方法timeout默认值改为6000000
```
> v2.0.0
```
SpringBoot从3.1.1升级到3.5.3
JDK从17升级到21并开启虚拟线程
删除RocksDB相关配置不再使用该缓存方案
修改文件下载方式使用StreamingResponseBody支持大文件下载
引入metona-cache-spring-boot-starter使用此缓存方案
重构在线日志页面及实现方式,不再使用读取日志文件方式,自定义日志拦截器实时获取日志
不再生成自定义日志文件日志打印从INFO改为DEBUG打印更详细的内容
```
> v2.0.1
常规BUG修复参数名称修改
> v2.1.0
```
引入metona-mq-mini-pro消息队列重构实时日志获取方式
```
> v2.1.1
```
升级metona-mq-mini-pro到2.0.0,重构实时日志获取方式
```
> v2.1.2
```
升级metona-mq-mini-pro到2.0.1
```
> v2.1.3
```
使用ServletContextListener方式获取实时日志
页面日志要素添加进程id和线程名称优化样式
日志打印级别调整为INFO
```