This commit is contained in:
oneao committed 2025-10-10 21:43:09 +08:00
1 parent 5bc05923f2
commit 2db3eb2408
109 files changed
+6089 -16689

No files matched your search

@@ -0,0 +1,16 @@
package com.link;
import com.alibaba.druid.spring.boot.autoconfigure.DruidDataSourceAutoConfigure;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration;
@SpringBootApplication(exclude = {
DataSourceAutoConfiguration.class,
DruidDataSourceAutoConfigure.class ,
})
public class LinkApplication {
public static void main(String[] args) {
SpringApplication.run(LinkApplication.class,args);
}
}
@@ -0,0 +1,33 @@
package com.link.api.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import org.springframework.web.filter.CorsFilter;
@Configuration
public class WebCorsConfig {
@Bean
public CorsFilter corsFilter() {
// 1.创建 CORS 配置对象
CorsConfiguration config = new CorsConfiguration();
// 支持域
config.addAllowedOriginPattern("*");
// 是否发送 Cookie
config.setAllowCredentials(true);
// 支持请求方式
config.addAllowedMethod("*");
// 允许的原始请求头部信息
config.addAllowedHeader("*");
// 暴露的头部信息
config.addExposedHeader("*");
// 有效期 1800秒,设置预检请求(OPTIONS请求)的缓存时间,1800秒意味着客户端可以缓存这个设置1800秒
config.setMaxAge(1800L);
// 2.添加地址映射
UrlBasedCorsConfigurationSource corsConfigurationSource = new UrlBasedCorsConfigurationSource();
corsConfigurationSource.registerCorsConfiguration("/**", config);
// 3.返回 CorsFilter 对象
return new CorsFilter(corsConfigurationSource);
}
}
@@ -0,0 +1,22 @@
package com.link.api.config.datasource;
import lombok.*;
import java.util.Map;
@Data
@AllArgsConstructor
@NoArgsConstructor
public class DataSourceConfig {
private Map<String, DbConfig> datasource;
@Setter
@Getter
public static class DbConfig {
// getters & setters
private String url;
private String username;
private String password;
private String driverClassName;
}
}
@@ -0,0 +1,231 @@
package com.link.api.config.datasource;
import com.link.api.context.DataSourceContextHolder;
import com.alibaba.druid.filter.Filter;
import com.alibaba.druid.pool.DruidDataSource;
import com.alibaba.druid.wall.WallConfig;
import com.alibaba.druid.wall.WallFilter;
import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;
import org.springframework.core.io.Resource;
import org.springframework.core.io.ResourceLoader;
import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource;
import org.springframework.stereotype.Component;
import org.yaml.snakeyaml.Yaml;
import java.io.InputStream;
import java.sql.DriverManager;
import java.sql.SQLException;
import java.util.*;
@Component
public class DataSourceManager extends AbstractRoutingDataSource {
private static final String EXTERNAL_CONFIG_PATH = "file:config/datasource.yml";
private static final String CLASSPATH_CONFIG_PATH = "classpath:datasource.yml";
@javax.annotation.Resource
private ResourceLoader resourceLoader;
private final Map<Object, Object> targetDataSourceMap = new HashMap<>();
public DataSourceManager() {
super.setTargetDataSources(targetDataSourceMap);
}
@Override
protected Object determineCurrentLookupKey() {
return DataSourceContextHolder.getDataSourceKey();
}
public void loadDatasource() {
try {
// 从配置文件中解析出数据源配置对象
DataSourceConfig config = resolveConfigResource();
Map<String, DataSourceConfig.DbConfig> newDsMap = config.getDatasource();
Set<String> newKeys = newDsMap.keySet();
// 关闭并移除已经被删除(配置文件中不再存在)的旧数据源
Set<Object> existingKeys = new HashSet<>(targetDataSourceMap.keySet());
for (Object key : existingKeys) {
// 如果旧数据源中有但新配置中没有,说明该数据源被删除了
if (!newKeys.contains((String) key)) {
// 移除旧数据源并关闭连接池资源
DruidDataSource ds = (DruidDataSource) targetDataSourceMap.remove(key);
ds.close();
System.out.println("移除并关闭旧数据源 [" + key + "]");
}
}
// 遍历新配置,进行新增或更新处理
for (Map.Entry<String, DataSourceConfig.DbConfig> entry : newDsMap.entrySet()) {
String key = entry.getKey();
DataSourceConfig.DbConfig newConfig = entry.getValue();
// 获取已有的数据源实例(如果存在)
DruidDataSource existingDS = (DruidDataSource) targetDataSourceMap.get(key);
// 如果已有数据源配置和新配置相同,则跳过初始化
if (existingDS != null && isSameConfig(existingDS, newConfig)) {
System.out.println("数据源 [" + key + "] 配置未变更,跳过初始化");
continue;
}
// 如果已有数据源但配置不同,关闭旧数据源
if (existingDS != null) {
existingDS.close();
System.out.println("关闭已变更数据源 [" + key + "]");
}
try {
// 预先验证驱动和连接是否可用,防止错误配置
Class.forName(newConfig.getDriverClassName());
DriverManager.getConnection(newConfig.getUrl(), newConfig.getUsername(), newConfig.getPassword()).close();
// 创建并初始化新的 DruidDataSource 实例
// DruidDataSource newDS = buildDruidDataSource(newConfig);
// 创建并初始化新的 HikariDataSource 实例
HikariDataSource newDS = buildHikariDataSource(newConfig);
// 放入数据源映射中,替换旧数据源(或新增)
targetDataSourceMap.put(key, newDS);
System.out.println("数据源 [" + key + "] 初始化成功");
} catch (Exception e) {
// 初始化失败时输出错误日志,不影响其他数据源加载
System.err.printf("数据源 [%s] 初始化失败:%s%n", key, e.getMessage());
}
}
// 通知父类更新数据源路由配置
super.setTargetDataSources(targetDataSourceMap);
super.afterPropertiesSet();
} catch (Exception e) {
// 捕获整个过程中的异常,打印错误日志
System.err.println("加载数据源配置异常:" + e.getMessage());
}
}
private boolean isSameConfig(DruidDataSource oldDS, DataSourceConfig.DbConfig newConfig) {
return Objects.equals(oldDS.getUrl(), newConfig.getUrl()) &&
Objects.equals(oldDS.getUsername(), newConfig.getUsername()) &&
Objects.equals(oldDS.getPassword(), newConfig.getPassword()) &&
Objects.equals(oldDS.getDriverClassName(), newConfig.getDriverClassName());
}
private DataSourceConfig resolveConfigResource() throws Exception {
Resource resource = resourceLoader.getResource(EXTERNAL_CONFIG_PATH);
if (!resource.exists()) {
resource = resourceLoader.getResource(CLASSPATH_CONFIG_PATH);
}
if (!resource.exists()) {
throw new Exception("配置文件未找到:" + EXTERNAL_CONFIG_PATH + " 或 " + CLASSPATH_CONFIG_PATH);
}
try (InputStream in = resource.getInputStream()) {
return new Yaml().loadAs(in, DataSourceConfig.class);
}
}
private HikariDataSource buildHikariDataSource(DataSourceConfig.DbConfig config){
HikariConfig hikariConfig = new HikariConfig();
hikariConfig.setDriverClassName(config.getDriverClassName());
hikariConfig.setJdbcUrl(config.getUrl());
hikariConfig.setUsername(config.getUsername());
hikariConfig.setPassword(config.getPassword());
hikariConfig.setConnectionTestQuery("SELECT 1"); // SQL Server 可执行语句
return new HikariDataSource(hikariConfig);
}
private DruidDataSource buildDruidDataSource(DataSourceConfig.DbConfig config) throws Exception {
DruidDataSource ds = getDefaultDruidDataSource();
ds.setDriverClassName(config.getDriverClassName());
ds.setUrl(config.getUrl());
ds.setUsername(config.getUsername());
ds.setPassword(config.getPassword());
ds.init();
return ds;
}
private DruidDataSource getDefaultDruidDataSource() throws SQLException {
DruidDataSource dataSource = new DruidDataSource();
// 启动程序时,在连接池中初始化多少个连接(10-50已足够)
dataSource.setInitialSize(5);
// 回收空闲连接时,将保证至少有 minIdle 个连接(与 initialSize 相同)
dataSource.setMinIdle(5);
// 连接池中最多支持多少个活动会话
dataSource.setMaxActive(100);
// 程序向连接池中请求连接时,超过 maxWait 的值后,认为本次请求失败,即连接池,没有可用连接,单位毫秒,设置 -1 时表示无限等待(建议值为100)
dataSource.setMaxWait(100);
/*
缓存通过以下两个方法发起的 SQL:
public PreparedStatement prepareStatement(String sql)
public PreparedStatement prepareStatement(String sql, int resultSetType, int resultSetConcurrency)
(建议值为 true)
*/
dataSource.setPoolPreparedStatements(true);
// 每个连接最多缓存多少个 SQL(建议值为 20)
dataSource.setMaxPoolPreparedStatementPerConnectionSize(20);
// 检查空闲连接的频率,单位毫秒,非正整数时表示不进行检查(建议值:2000)
dataSource.setTimeBetweenEvictionRunsMillis(2000);
// 连接池中某个连接的空闲时长达到 N 毫秒后, 连接池在下次检查空闲连接时,将回收该连接,要小于防火墙超时设置 net.netfilter.nf_conntrack_tcp_timeout_established 的设置
dataSource.setMinEvictableIdleTimeMillis(600000);
// 配置一个连接在池中最大生存的时间,单位是毫秒
dataSource.setMaxEvictableIdleTimeMillis(900000);
// 程序没有 close 连接且空闲时长超过 minEvictableIdleTimeMillis,则会执行 validationQuery 指定的 SQL,以保证该程序连接不会池 kill 掉,其范围不超过 minIdle 指定的连接个数(建议值为 true)
dataSource.setKeepAlive(true);
// 检查池中的连接是否仍可用的 SQL 语句,druid 会连接到数据库执行该 SQL,如果正常返回,则表示连接可用,否则表示连接不可用
dataSource.setValidationQuery("SELECT 1");
// 当程序请求连接,池在分配连接时,是否先检查该连接是否有效(高效,并且保证安全性;建议值为 true)
dataSource.setTestWhileIdle(true);
// 程序申请连接时,进行连接有效性检查(低效,影响性能;建议值为 false)
dataSource.setTestOnBorrow(false);
// 程序返还连接时,进行连接有效性检查(低效,影响性能;建议值为 false)
dataSource.setTestOnReturn(false);
// 物理连接初始化的时候执行的 sql
// Collection<String> connectionInitSqls = new ArrayList<>(10);
// connectionInitSqls.add("SELECT 1 FROM DUAL");
// dataSource.setConnectionInitSqls(connectionInitSqls);
/*
这里配置的是插件,常用的插件有:
监控统计:stat
日志监控:log4j2
防御 SQL 注入:wall
*/
dataSource.setFilters("stat");
// 是否合并多个 DruidDataSource 的监控数据
dataSource.setUseGlobalDataSourceStat(true);
// 监控统计
// 是否启用慢 SQL 记录
dataSource.addConnectionProperty("druid.stat.logSlowSql", "true");
// 执行时间超过 slowSqlMillis 的就是慢,单位毫秒(建议值 500)
dataSource.addConnectionProperty("druid.stat.slowSqlMillis", "500");
// 要求程序从池中 get 到连接后,N 秒后必须 close,否则 druid 会强制回收该连接,不管该连接中是活动还是空闲,以防止进程不会进行 close 而霸占连接(建议值为 false,当发现程序有未正常 close 连接时设置为 true)
// dataSource.setRemoveAbandoned(false);
// 设置 druid 强制回收连接的时限,当程序从池中 get 到连接开始算起,超过此值后,druid将强制回收该连接,单位秒(应大于业务运行最长时间)
// dataSource.setRemoveAbandonedTimeout();
// 当 druid 强制回收连接后,是否将 stack trace 记录到日志中(建议值为 true)
// dataSource.setLogAbandoned(true);
// 连接属性。比如设置一些连接池统计方面的配置 druid.stat.mergeSql=true;druid.stat.slowSqlMillis=5000
// dataSource.setConnectProperties();
// 防御 SQL 注入
WallFilter wallFilter = new WallFilter();
WallConfig config = new WallConfig();
// 是否允许执行 DELETE 语句(建议值为 false)
config.setDeleteAllow(false);
// 是否允许删除表(建议值为 false)
config.setDropTableAllow(false);
wallFilter.setConfig(config);
// 插件代理
List<Filter> proxyFilters = new ArrayList<>(10);
proxyFilters.add(wallFilter);
dataSource.setProxyFilters(proxyFilters);
return dataSource;
}
}
@@ -0,0 +1,67 @@
package com.link.api.config.datasource;
import com.alibaba.druid.support.http.StatViewServlet;
import com.alibaba.druid.support.http.WebStatFilter;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.boot.web.servlet.ServletRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.util.*;
@Configuration
public class DruidConfig {
@Bean
public ServletRegistrationBean<StatViewServlet> statViewServlet() {
// 监控信息显示页面
StatViewServlet statViewServlet = new StatViewServlet();
// 访问监控信息显示页面的 url 路径(建议值为 /druid/* )
String urlPattern = "/druid/*";
ServletRegistrationBean<StatViewServlet> bean = new ServletRegistrationBean<>(statViewServlet, urlPattern);
Map<String, String> initParams = new HashMap<>();
// 是否允许清空统计数据
initParams.put("resetEnable", "false");
// 登录监控信息显示页面的用户名
initParams.put("loginUsername", "admin");
// 登录监控信息显示页面的密码
initParams.put("loginPassword", "admin");
// 允许访问控制(格式:ip地址、ip地址/子网掩码位数)逗号分隔多个地址
initParams.put("allow", "127.0.0.1");
// 拒绝访问控制(格式:ip地址、ip地址/子网掩码位数)逗号分隔多个地址
// initParams.put("deny", "");
bean.setInitParameters(initParams);
return bean;
}
@Bean
public FilterRegistrationBean<WebStatFilter> webStatFilter() {
FilterRegistrationBean<WebStatFilter> bean = new FilterRegistrationBean<>();
// 网络监控过滤器(用于采集 web-jdbc 关联监控的数据)
bean.setFilter(new WebStatFilter());
// 过滤所有的 url 路径
Collection<String> urlPatterns = new ArrayList<>();
urlPatterns.add("/*");
bean.setUrlPatterns(urlPatterns);
Map<String, String> initParams = new HashMap<>();
// 排除不必要采集的 url 路径,以逗号“,”分割
initParams.put("exclusions", "*.js,*.gif,*.jpg,*.png,*.css,*.ico,*.map,/druid/*");
// 是否使用 session 监控功能
initParams.put("sessionStatEnable", "true");
// 是否使用 session 监控最大数量(默认是1000)
initParams.put("sessionStatMaxCount", "1000");
// 使得 druid 能够知道当前的 session 的用户是谁,根据需要,把改值修改为你 user 信息保存在 session 中的 sessionName
initParams.put("principalSessionName", "session_user_key");
// 如果你的 user 信息保存在 cookie 中,你可以配置 principalCookieName,使得 druid 知道当前的 user 是谁,根据需要,把该值修改为你 user 信息保存在 cookie 中的 cookieName
initParams.put("principalCookieName", "cookie_user_key");
// 是否监控单个 url 调用的 sql 列表
initParams.put("profileEnable", "true");
bean.setInitParameters(initParams);
return bean;
}
}
@@ -0,0 +1,21 @@
package com.link.api.config.datasource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
@Slf4j
@Component
public class LoadDataSourceRunner implements CommandLineRunner {
@Resource
private DataSourceManager dataSourceManager;
@Override
public void run(String... args) throws Exception {
dataSourceManager.loadDatasource();
}
}
@@ -0,0 +1,28 @@
package com.link.api.constants;
public class ResponseStatusConstants {
/**
* 操作成功
*/
public static final Integer SUCCESS = 200;
/**
* 系统内部错误
*/
public static final Integer ERROR = 500;
/**
* 缺少参数,参数格式有误,请求路径有误等
*/
public static final Integer BAD_REQUEST = 400;
/**
* 未找到资源,或者该资源已丢失
*/
public static final Integer NOT_FOUND = 404;
/**
* 登录失效或已过期
*/
public static final Integer UNAUTHORIZED = 401;
}
@@ -0,0 +1,17 @@
package com.link.api.context;
public class DataSourceContextHolder {
private static final ThreadLocal<String> contextHolder = new ThreadLocal<>();
public static void setDataSourceKey(String dataSourceKey) {
contextHolder.set(dataSourceKey);
}
public static String getDataSourceKey() {
return contextHolder.get();
}
public static void clearDataSourceKey() {
contextHolder.remove();
}
}
@@ -0,0 +1,20 @@
package com.link.api.controller;
import com.link.api.config.datasource.DataSourceManager;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.annotation.Resource;
@RestController
@RequestMapping("/config")
public class ConfigController {
@Resource
private DataSourceManager dataSourceManager;
@GetMapping("/refreshDb")
public void refreshDb(){
dataSourceManager.loadDatasource();
}
}
@@ -0,0 +1,53 @@
package com.link.api.controller;
import com.link.api.context.DataSourceContextHolder;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.annotation.Resource;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/test")
public class TestController {
@Resource
JdbcTemplate jdbcTemplate;
@GetMapping
public String test() {
return "test";
}
@GetMapping("/test1")
public List<Map<String, Object>> test1() {
// 切换数据源为 db1
DataSourceContextHolder.setDataSourceKey("db1");
String sql = "SELECT * FROM user_account";
// 执行查询,返回List<Map>,每个Map代表一行记录,列名-值对
List<Map<String, Object>> result = jdbcTemplate.queryForList(sql);
// 查询完后可以清理上下文,防止影响后续操作
DataSourceContextHolder.clearDataSourceKey();
return result;
}
@GetMapping("/test2")
public List<Map<String, Object>> test2() {
// 切换数据源为 db1
DataSourceContextHolder.setDataSourceKey("db2");
String sql = "SELECT * FROM sys_admin";
// 执行查询,返回List<Map>,每个Map代表一行记录,列名-值对
List<Map<String, Object>> result = jdbcTemplate.queryForList(sql);
// 查询完后可以清理上下文,防止影响后续操作
DataSourceContextHolder.clearDataSourceKey();
return result;
}
}
@@ -0,0 +1,18 @@
package com.link.api.enums;
import com.link.api.constants.ResponseStatusConstants;
import lombok.AllArgsConstructor;
import lombok.Getter;
@AllArgsConstructor
@Getter
public enum ResponseEnum {
SUCCESS(ResponseStatusConstants.SUCCESS,"请求成功"),
ERROR(ResponseStatusConstants.ERROR,"服务器错误,请联系管理人员"),
AUTH_SUCCESS(ResponseStatusConstants.SUCCESS,"登录成功"),
AUTH_ERROR(ResponseStatusConstants.ERROR,"登录失败"),
AUTH_EXPIRE(ResponseStatusConstants.UNAUTHORIZED,"尚未登录或登录已过期,请重新登录");
private final Integer code; //响应码
private final String message; //相应信息
}
@@ -0,0 +1,59 @@
package com.link.api.exception;
import com.link.api.enums.ResponseEnum;
import lombok.Data;
import lombok.EqualsAndHashCode;
@EqualsAndHashCode(callSuper = true)
@Data
public class BaseException extends RuntimeException {
private final boolean showDetail;
private final String message;
private final Integer code;
private final ResponseEnum responseEnum;
public BaseException(String message) {
this(message, false, null, null);
}
public BaseException(String message, Integer code) {
this(message, false, code, null);
}
public BaseException(Integer code, String message) {
this(message, false, code, null);
}
// 3. 传 ResponseEnum,默认 showDetail=false
public BaseException(ResponseEnum responseEnum) {
super(responseEnum.getMessage());
this.message = responseEnum.getMessage();
this.showDetail = false;
this.code = responseEnum.getCode();
this.responseEnum = responseEnum;
}
public BaseException(String message, boolean showDetail) {
this(message, showDetail, null, null);
}
public BaseException(String message, Integer code, boolean showDetail) {
this(message, showDetail, code, null);
}
public BaseException(ResponseEnum responseEnum, boolean showDetail) {
super(responseEnum.getMessage());
this.message = responseEnum.getMessage();
this.showDetail = showDetail;
this.code = responseEnum.getCode();
this.responseEnum = responseEnum;
}
private BaseException(String message, boolean showDetail, Integer code, ResponseEnum responseEnum) {
super(message);
this.message = message;
this.showDetail = showDetail;
this.code = code;
this.responseEnum = responseEnum;
}
}
@@ -0,0 +1,45 @@
package com.link.api.exception;
import com.link.api.model.Result;
import com.link.api.exception.auth.AuthException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import javax.validation.ConstraintViolation;
import javax.validation.ConstraintViolationException;
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(ConstraintViolationException.class)
public Result<Object> handleConstraintViolationException(ConstraintViolationException ex) {
// 取第一个错误消息
String errorMessage = ex.getConstraintViolations().stream()
.map(ConstraintViolation::getMessage)
.findFirst()
.orElse("参数校验失败");
return Result.error(errorMessage);
}
@ExceptionHandler(AuthException.class)
public Result<?> handleAuthException(AuthException ex){
return Result.error(ex.getMessage());
}
// @ExceptionHandler(Exception.class)
// public Result<ExceptionDetailInfo> handleException(Exception ex, HttpServletRequest request) {
// ExceptionDetailInfo info = ExceptionUtils.extractExceptionInfo(ex, request, "com.email");
// log.error("异常发生在 {}#{}({}:{}),ID: {},信息: {}",
// info.getClassName(),
// info.getMethodName(),
// info.getFileName(),
// info.getLineNumber(),
// info.getId(),
// info.getSummary());
// ex.printStackTrace();
// return Result.error(info).withShowDetail(true);
// }
}
@@ -0,0 +1,19 @@
package com.link.api.exception.auth;
import com.link.api.enums.ResponseEnum;
import com.link.api.exception.BaseException;
public class AuthException extends BaseException {
public AuthException() {
super(ResponseEnum.AUTH_EXPIRE); // 调用父类的默认构造函数
}
public AuthException(ResponseEnum responseEnum){
super(responseEnum);
}
public AuthException(Integer code,String message){
super(code,message);
}
}
@@ -0,0 +1,45 @@
package com.link.api.model;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
import lombok.Builder;
import lombok.Data;
import java.util.Date;
@Data
@Builder
public class ExceptionEntity {
/** 异常记录的唯一ID */
@JsonSerialize(using = ToStringSerializer.class)
private Long id;
/** 异常发生时间 */
private Date time;
/** 请求的URI地址 */
private String uri;
/** HTTP请求方法,如GET、POST等 */
private String httpMethod;
/** 抛出异常的类名 */
private String className;
/** 抛出异常的方法名 */
private String methodName;
/** 抛出异常的文件名 */
private String fileName;
/** 抛出异常的代码行号 */
private Integer lineNumber;
/** 异常的简短信息 */
private String summary;
/** 异常的详细堆栈信息 */
private String detailMessage;
/** 请求参数(可能为JSON字符串等) */
private String params;
}
@@ -0,0 +1,126 @@
package com.link.api.model;
import com.link.api.enums.ResponseEnum;
import lombok.NonNull;
import lombok.Builder;
import lombok.Getter;
@Getter
@Builder(toBuilder = true)
public class Result<T> {
private final Integer code;
private final String message;
private final T data;
private final Boolean showExceptionDetail;
// 工厂方法建议保留,简化构造调用
public static <T> Result<T> success() {
return Result.<T>builder()
.code(ResponseEnum.SUCCESS.getCode())
.message(ResponseEnum.SUCCESS.getMessage())
.showExceptionDetail(false)
.build();
}
public static <T> Result<T> success(String message) {
return Result.<T>builder()
.code(ResponseEnum.SUCCESS.getCode())
.message(message)
.showExceptionDetail(false)
.build();
}
public static <T> Result<T> success(@NonNull T data) {
return Result.<T>builder()
.code(ResponseEnum.SUCCESS.getCode())
.message(ResponseEnum.SUCCESS.getMessage())
.data(data)
.showExceptionDetail(false)
.build();
}
public static <T> Result<T> success(@NonNull T data, String message) {
return Result.<T>builder()
.code(ResponseEnum.SUCCESS.getCode())
.message(message)
.data(data)
.showExceptionDetail(false)
.build();
}
public static <T> Result<T> success(ResponseEnum responseEnum) {
return Result.<T>builder()
.code(responseEnum.getCode())
.message(responseEnum.getMessage())
.showExceptionDetail(false)
.build();
}
public static <T> Result<T> success(ResponseEnum responseEnum, @NonNull T data) {
return Result.<T>builder()
.code(responseEnum.getCode())
.message(responseEnum.getMessage())
.data(data)
.showExceptionDetail(false)
.build();
}
public static <T> Result<T> error() {
return Result.<T>builder()
.code(ResponseEnum.ERROR.getCode())
.message(ResponseEnum.ERROR.getMessage())
.showExceptionDetail(false)
.build();
}
public static <T> Result<T> error(String message) {
return Result.<T>builder()
.code(ResponseEnum.ERROR.getCode())
.message(message)
.showExceptionDetail(false)
.build();
}
public static <T> Result<T> error(Integer code, String message) {
return Result.<T>builder()
.code(code)
.message(message)
.showExceptionDetail(false)
.build();
}
public static <T> Result<T> error(ResponseEnum responseEnum) {
return Result.<T>builder()
.code(responseEnum.getCode())
.message(responseEnum.getMessage())
.showExceptionDetail(false)
.build();
}
public static <T> Result<T> error(T data) {
return Result.<T>builder()
.code(ResponseEnum.ERROR.getCode())
.message(ResponseEnum.ERROR.getMessage())
.data(data)
.showExceptionDetail(false)
.build();
}
public static <T> Result<T> error(T data, String message) {
return Result.<T>builder()
.code(ResponseEnum.ERROR.getCode())
.message(message)
.data(data)
.showExceptionDetail(false)
.build();
}
public Result<T> withShowExceptionDetail(boolean show) {
return this.toBuilder().showExceptionDetail(show).build();
}
public boolean isSuccess() {
return ResponseEnum.SUCCESS.getCode().equals(this.code);
}
}
@@ -0,0 +1,98 @@
package com.link.api.utils;
import com.link.api.model.ExceptionEntity;
import com.link.api.exception.BaseException;
import javax.servlet.http.HttpServletRequest;
import java.util.Date;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.Enumeration;
import java.util.HashMap;
import java.util.Map;
public class ExceptionUtils {
private static final ObjectMapper objectMapper = new ObjectMapper();
/**
* 根据异常和请求信息,生成统一的异常信息对象 ExceptionDetailInfo
* @param ex 异常对象
* @param request HttpServletRequest,用来获取请求路径和方法等
* @param projectBasePackage 项目基础包名,如 "com.error",用来定位业务代码异常栈帧
* @return ExceptionDetailInfo 封装的异常信息
*/
public static ExceptionEntity extractExceptionInfo(Exception ex, HttpServletRequest request, String projectBasePackage) {
StackTraceElement[] stackTrace = ex.getStackTrace();
StackTraceElement source = null;
if (stackTrace != null) {
for (StackTraceElement element : stackTrace) {
if (element.getClassName().startsWith(projectBasePackage)) {
source = element;
break;
}
}
if (source == null && stackTrace.length > 0) {
source = stackTrace[0];
}
}
String briefMessage;
if (ex instanceof BaseException) {
BaseException be = (BaseException) ex;
briefMessage = be.getMessage();
} else {
if (ex.getMessage() != null) {
String[] lines = ex.getMessage().split("\n");
// 遍历每一行,找到第一行“非空、非符号”的有效信息
String usefulLine = null;
for (String line : lines) {
String trimmed = line.trim();
if (!trimmed.isEmpty()) {
usefulLine = trimmed;
break;
}
}
// 如果找到有效行,就用它;否则仅用异常类名
if (usefulLine != null) {
briefMessage = ex.getClass().getSimpleName() + ": " + usefulLine;
} else {
briefMessage = ex.getClass().getSimpleName();
}
} else {
briefMessage = ex.getClass().getSimpleName();
}
}
String paramJson = null;
if (request != null) {
Map<String, String> paramMap = new HashMap<>();
Enumeration<String> paramNames = request.getParameterNames();
while (paramNames.hasMoreElements()) {
String paramName = paramNames.nextElement();
paramMap.put(paramName, request.getParameter(paramName));
}
try {
paramJson = JsonUtils.toJson(paramMap);
} catch (Exception e) {
paramJson = "参数序列化失败: " + e.getMessage();
}
}
return ExceptionEntity.builder()
.id(SnowflakeIdUtils.nextId())
.uri(request != null ? request.getRequestURI() : null)
.httpMethod(request != null ? request.getMethod() : null)
.className(source != null ? source.getClassName() : null)
.methodName(source != null ? source.getMethodName() : null)
.fileName(source != null ? source.getFileName() : null)
.lineNumber(source != null ? source.getLineNumber() : null)
.summary(briefMessage)
.detailMessage(org.apache.commons.lang3.exception.ExceptionUtils.getStackTrace(ex))
.time(new Date())
.params(paramJson)
.build();
}
}
@@ -0,0 +1,40 @@
package com.link.api.utils;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
public class JsonUtils {
private static final ObjectMapper objectMapper = new ObjectMapper();
// 判断字符串是否为有效的 JSON 格式
public static boolean isTypeJSON(String msgText) {
try {
// 尝试将字符串解析为 JsonNode,如果能解析,说明是有效的 JSON
JsonNode node = objectMapper.readTree(msgText);
return node != null;
} catch (Exception e) {
return false; // 如果抛出异常,说明不是有效的 JSON
}
}
// 将 JSON 字符串转换为指定类型的 Java 对象
public static <T> T toBean(String json, Class<T> clazz) {
try {
// 使用 ObjectMapper 将 JSON 字符串转换为指定类型的对象
return objectMapper.readValue(json, clazz);
} catch (JsonProcessingException e) {
e.printStackTrace(); // 打印异常信息
return null; // 如果转换失败,返回 null
}
}
// 将对象转换为 JSON 字符串
public static String toJson(Object obj) {
try {
return objectMapper.writeValueAsString(obj); // 将对象转换为 JSON 字符串
} catch (JsonProcessingException e) {
System.out.println(e.getMessage());
return null; // 如果出现异常,返回 null
}
}
}
@@ -0,0 +1,89 @@
package com.link.api.utils;
import lombok.extern.slf4j.Slf4j;
@Slf4j
public class SnowflakeIdUtils {
// 起始时间戳(建议设置为项目上线时间)
private static final long START_TIMESTAMP = 1288834974657L;
// 各部分位数
private static final long SEQUENCE_BITS = 12L;
private static final long MACHINE_BITS = 5L;
private static final long DATACENTER_BITS = 5L;
// 最大值
private static final long MAX_SEQUENCE = ~(-1L << SEQUENCE_BITS);
private static final long MAX_MACHINE_ID = ~(-1L << MACHINE_BITS);
private static final long MAX_DATACENTER_ID = ~(-1L << DATACENTER_BITS);
// 左移位数
private static final long MACHINE_LEFT = SEQUENCE_BITS;
private static final long DATACENTER_LEFT = SEQUENCE_BITS + MACHINE_BITS;
private static final long TIMESTAMP_LEFT = SEQUENCE_BITS + MACHINE_BITS + DATACENTER_BITS;
// 参数及状态(静态)
private static long datacenterId;
private static long machineId;
private static long sequence = 0L;
private static long lastTimestamp = -1L;
private static boolean initialized = false;
// 初始化方法(只能调用一次)
public static synchronized void init(long dcId, long mId) {
if (initialized) {
throw new IllegalStateException("SnowflakeIdGenerator has already been initialized.");
}
if (dcId > MAX_DATACENTER_ID || dcId < 0) {
throw new IllegalArgumentException("datacenterId must be between 0 and " + MAX_DATACENTER_ID);
}
if (mId > MAX_MACHINE_ID || mId < 0) {
throw new IllegalArgumentException("machineId must be between 0 and " + MAX_MACHINE_ID);
}
datacenterId = dcId;
machineId = mId;
initialized = true;
}
// 生成 ID(线程安全)
public static synchronized long nextId() {
if (!initialized) {
throw new IllegalStateException("SnowflakeIdGenerator not initialized. Call init() first.");
}
long currentTimestamp = timeGen();
if (currentTimestamp < lastTimestamp) {
throw new RuntimeException("Clock moved backwards. Refusing to generate id.");
}
if (currentTimestamp == lastTimestamp) {
sequence = (sequence + 1) & MAX_SEQUENCE;
if (sequence == 0) {
currentTimestamp = waitNextMillis(currentTimestamp);
}
} else {
sequence = 0L;
}
lastTimestamp = currentTimestamp;
return ((currentTimestamp - START_TIMESTAMP) << TIMESTAMP_LEFT)
| (datacenterId << DATACENTER_LEFT)
| (machineId << MACHINE_LEFT)
| sequence;
}
private static long waitNextMillis(long currentTimestamp) {
while (currentTimestamp <= lastTimestamp) {
currentTimestamp = timeGen();
}
return currentTimestamp;
}
private static long timeGen() {
return System.currentTimeMillis();
}
}
@@ -0,0 +1,157 @@
package com.link.api.utils;
import org.springframework.aop.framework.AopContext;
import org.springframework.beans.BeansException;
import org.springframework.beans.factory.NoSuchBeanDefinitionException;
import org.springframework.beans.factory.config.BeanFactoryPostProcessor;
import org.springframework.beans.factory.config.ConfigurableListableBeanFactory;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationContextAware;
import org.springframework.stereotype.Component;
/**
* spring工具类 方便在非spring管理环境中获取bean
*
* @author webserver
*/
@Component
public final class SpringUtils implements BeanFactoryPostProcessor, ApplicationContextAware
{
/** Spring应用上下文环境 */
private static ConfigurableListableBeanFactory beanFactory;
private static ApplicationContext applicationContext;
@Override
public void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory) throws BeansException
{
SpringUtils.beanFactory = beanFactory;
}
@Override
public void setApplicationContext(ApplicationContext applicationContext) throws BeansException
{
SpringUtils.applicationContext = applicationContext;
}
/**
* 获取对象
*
* @param name
* @return Object 一个以所给名字注册的bean的实例
* @throws org.springframework.beans.BeansException
*
*/
@SuppressWarnings("unchecked")
public static <T> T getBean(String name) throws BeansException
{
return (T) beanFactory.getBean(name);
}
/**
* 获取类型为requiredType的对象
*
* @param clz
* @return
* @throws org.springframework.beans.BeansException
*
*/
public static <T> T getBean(Class<T> clz) throws BeansException
{
T result = (T) beanFactory.getBean(clz);
return result;
}
/**
* 如果BeanFactory包含一个与所给名称匹配的bean定义,则返回true
*
* @param name
* @return boolean
*/
public static boolean containsBean(String name)
{
return beanFactory.containsBean(name);
}
/**
* 判断以给定名字注册的bean定义是一个singleton还是一个prototype。 如果与给定名字相应的bean定义没有被找到,将会抛出一个异常(NoSuchBeanDefinitionException)
*
* @param name
* @return boolean
* @throws org.springframework.beans.factory.NoSuchBeanDefinitionException
*
*/
public static boolean isSingleton(String name) throws NoSuchBeanDefinitionException
{
return beanFactory.isSingleton(name);
}
/**
* @param name
* @return Class 注册对象的类型
* @throws org.springframework.beans.factory.NoSuchBeanDefinitionException
*
*/
public static Class<?> getType(String name) throws NoSuchBeanDefinitionException
{
return beanFactory.getType(name);
}
/**
* 如果给定的bean名字在bean定义中有别名,则返回这些别名
*
* @param name
* @return
* @throws org.springframework.beans.factory.NoSuchBeanDefinitionException
*
*/
public static String[] getAliases(String name) throws NoSuchBeanDefinitionException
{
return beanFactory.getAliases(name);
}
/**
* 获取aop代理对象
*
* @param invoker
* @return
*/
@SuppressWarnings("unchecked")
public static <T> T getAopProxy(T invoker)
{
return (T) AopContext.currentProxy();
}
/**
* 获取当前的环境配置,无配置返回null
*
* @return 当前的环境配置
*/
public static String[] getActiveProfiles()
{
return applicationContext.getEnvironment().getActiveProfiles();
}
/**
* 获取当前的环境配置,当有多个环境配置时,只获取第一个
*
* @return 当前的环境配置
*/
public static String getActiveProfile()
{
final String[] activeProfiles = getActiveProfiles();
return activeProfiles.length > 0 ? activeProfiles[0] : null;
}
/**
* 获取配置文件中的值
*
* @param key 配置文件的key
* @return 当前的配置文件的值
*
*/
public static String getRequiredProperty(String key)
{
return applicationContext.getEnvironment().getRequiredProperty(key);
}
}
@@ -0,0 +1,90 @@
package com.link.im;
import io.netty.bootstrap.ServerBootstrap;
import io.netty.channel.*;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.SocketChannel;
import io.netty.channel.socket.nio.NioServerSocketChannel;
import io.netty.handler.codec.http.HttpObjectAggregator;
import io.netty.handler.codec.http.HttpServerCodec;
import io.netty.handler.stream.ChunkedWriteHandler;
import io.netty.handler.codec.http.websocketx.WebSocketServerProtocolHandler;
import io.netty.handler.timeout.IdleStateHandler;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import javax.annotation.PostConstruct;
import javax.annotation.PreDestroy;
import java.util.concurrent.TimeUnit;
/**
* WebSocket + Netty IM 服务端,随 Spring Boot 启动
*/
@Component
public class ImServer {
@Value("${netty.port:9000}")
private int port;
@Value("${netty.path:/ws}")
private String websocketPath;
private EventLoopGroup bossGroup;
private EventLoopGroup workerGroup;
@PostConstruct
public void start() {
new Thread(this::runServer, "Netty-ImServer-Thread").start();
}
private void runServer() {
bossGroup = new NioEventLoopGroup(1);
workerGroup = new NioEventLoopGroup();
try {
ServerBootstrap bootstrap = new ServerBootstrap();
bootstrap.group(bossGroup, workerGroup)
.channel(NioServerSocketChannel.class)
.option(ChannelOption.SO_BACKLOG, 128)
.childOption(ChannelOption.SO_KEEPALIVE, true)
.childHandler(new ChannelInitializer<SocketChannel>() {
@Override
protected void initChannel(SocketChannel ch) {
ChannelPipeline pipeline = ch.pipeline();
// ===== HTTP 协议支持 =====
pipeline.addLast(new HttpServerCodec());
pipeline.addLast(new HttpObjectAggregator(65536));
pipeline.addLast(new ChunkedWriteHandler());
// ===== 心跳检测 =====
pipeline.addLast(new IdleStateHandler(60, 0, 0, TimeUnit.SECONDS));
// ===== WebSocket 协议支持 =====
pipeline.addLast(new WebSocketServerProtocolHandler(websocketPath, null, true, 65536));
// ===== 自定义消息处理 =====
pipeline.addLast(new ImWebSocketHandler());
}
});
ChannelFuture future = bootstrap.bind(port).sync();
System.out.println("🚀 Netty WebSocket Server 已启动,端口:" + port + ",路径:" + websocketPath);
future.channel().closeFuture().sync();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} catch (Exception e) {
e.printStackTrace();
} finally {
stop();
}
}
@PreDestroy
public void stop() {
if (bossGroup != null) bossGroup.shutdownGracefully();
if (workerGroup != null) workerGroup.shutdownGracefully();
System.out.println("🛑 Netty WebSocket Server 已关闭");
}
}
@@ -0,0 +1,120 @@
package com.link.im;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.netty.channel.*;
import io.netty.handler.codec.http.websocketx.TextWebSocketFrame;
import io.netty.handler.timeout.IdleStateEvent;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
@ChannelHandler.Sharable
public class ImWebSocketHandler extends SimpleChannelInboundHandler<TextWebSocketFrame> {
private static final ConcurrentHashMap<String, Channel> CLIENTS = new ConcurrentHashMap<>();
private static final ConcurrentHashMap<String, String> USER_CHANNEL_MAP = new ConcurrentHashMap<>();
private static final ConcurrentHashMap<String, Set<String>> GROUPS = new ConcurrentHashMap<>();
private static final ObjectMapper MAPPER = new ObjectMapper();
@Override
public void handlerAdded(ChannelHandlerContext ctx) {
String channelId = ctx.channel().id().asShortText();
CLIENTS.put(channelId, ctx.channel());
System.out.println("✅ 客户端连接: " + channelId);
}
@Override
public void handlerRemoved(ChannelHandlerContext ctx) {
String channelId = ctx.channel().id().asShortText();
CLIENTS.remove(channelId);
USER_CHANNEL_MAP.entrySet().removeIf(entry -> entry.getValue().equals(channelId));
GROUPS.values().forEach(set -> set.remove(channelId));
System.out.println("❌ 客户端断开: " + channelId);
}
@Override
protected void channelRead0(ChannelHandlerContext ctx, TextWebSocketFrame msg) throws Exception {
String text = msg.text();
System.out.println("📩 收到消息: " + text);
try {
JsonNode json = MAPPER.readTree(text);
String type = json.path("type").asText();
switch (type) {
case "bind": { // 绑定用户ID到连接
String userId = json.path("userId").asText();
USER_CHANNEL_MAP.put(userId, ctx.channel().id().asShortText());
ctx.writeAndFlush(new TextWebSocketFrame("{\"type\":\"system\",\"msg\":\"绑定成功\"}"));
break;
}
case "ping": { // 心跳
ctx.writeAndFlush(new TextWebSocketFrame("{\"type\":\"pong\"}"));
break;
}
case "single": { // 单聊
String to = json.path("to").asText();
String content = json.path("content").asText();
String from = json.path("from").asText();
sendToUser(to, String.format("{\"type\":\"single\",\"from\":\"%s\",\"content\":\"%s\"}", from, content));
break;
}
case "group": { // 群聊
String groupId = json.path("groupId").asText();
String from = json.path("from").asText();
String content = json.path("content").asText();
broadcastGroup(groupId, String.format("{\"type\":\"group\",\"from\":\"%s\",\"content\":\"%s\"}", from, content));
break;
}
default:
ctx.writeAndFlush(new TextWebSocketFrame("{\"type\":\"error\",\"msg\":\"未知消息类型\"}"));
}
} catch (Exception e) {
ctx.writeAndFlush(new TextWebSocketFrame("{\"type\":\"error\",\"msg\":\"JSON格式错误\"}"));
}
}
private void sendToUser(String userId, String message) {
String channelId = USER_CHANNEL_MAP.get(userId);
if (channelId != null) {
Channel ch = CLIENTS.get(channelId);
if (ch != null && ch.isActive()) {
ch.writeAndFlush(new TextWebSocketFrame(message));
}
}
}
private void broadcastGroup(String groupId, String message) {
Set<String> members = GROUPS.get(groupId);
if (members == null) return;
for (String channelId : members) {
Channel ch = CLIENTS.get(channelId);
if (ch != null && ch.isActive()) {
ch.writeAndFlush(new TextWebSocketFrame(message));
}
}
}
@Override
public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception {
// 如果仍保留 IdleStateHandler,可以检测心跳超时
if (evt instanceof IdleStateEvent) {
System.out.println("💤 连接超时,关闭: " + ctx.channel().id().asShortText());
ctx.close();
} else {
super.userEventTriggered(ctx, evt);
}
}
@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
cause.printStackTrace();
ctx.close();
}
}
@@ -0,0 +1,25 @@
server:
servlet:
context-path: / # 应用的访问路径
tomcat:
uri-encoding: UTF-8 # URI编码
accept-count: 1000 # 连接数满后的排队数
threads:
max: 800 # 最大线程数
min-spare: 100 # 初始化的线程数
port: 8089
netty:
port: 8090
spring:
config:
name: application
location: classpath:/,file:./config/
jackson:
date-format: yyyy-MM-dd HH:mm:ss
time-zone: Asia/Shanghai
servlet:
multipart:
max-file-size: -1
max-request-size: -1
@@ -0,0 +1,22 @@
#datasource:
# db1:
# url: jdbc:jtds:sqlserver://127.0.0.1:1433;databaseName=test1
# username: sa
# password: sa
# driverClassName: net.sourceforge.jtds.jdbc.Driver
# db2:
# url: jdbc:jtds:sqlserver://127.0.0.1:1433;databaseName=test2
# username: sa
# password: sa
# driverClassName: net.sourceforge.jtds.jdbc.Driver
datasource:
db1:
url: jdbc:jtds:sqlserver://192.168.1.176:1433;databaseName=G3-Email
username: sa
password: sa
driverClassName: net.sourceforge.jtds.jdbc.Driver
db2:
url: jdbc:jtds:sqlserver://192.168.1.176:1433;databaseName=G3-Website
username: sa
password: sa
driverClassName: net.sourceforge.jtds.jdbc.Driver
@@ -0,0 +1,288 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>WebSocket Demo — Netty</title>
<style>
:root{--bg:#f6f8fa;--card:#ffffff;--muted:#6b7280;--accent:#2563eb}
body{font-family:system-ui,-apple-system,Segoe UI,Roboto,"Helvetica Neue",Arial; background:var(--bg); margin:0;padding:24px;color:#0f172a}
.wrap{max-width:980px;margin:0 auto}
h1{font-size:20px;margin:0 0 16px}
.card{background:var(--card);padding:14px;border-radius:10px;box-shadow:0 1px 4px rgba(2,6,23,0.06);margin-bottom:12px}
.row{display:flex;gap:8px;align-items:center}
input[type="text"], input[type="number"], textarea, select {
padding:8px;border:1px solid #e6e9ef;border-radius:6px;font-size:13px;
}
button{background:var(--accent);color:#fff;border:0;padding:8px 12px;border-radius:8px;cursor:pointer}
button.secondary{background:#e6eefc;color:#08306b}
button.warn{background:#ef4444}
.controls{display:flex;gap:8px;flex-wrap:wrap}
.col{flex:1;min-width:180px}
.log{height:320px;overflow:auto;padding:8px;border:1px solid #eef2ff;border-radius:6px;background:#fbfdff;font-family:monospace;font-size:13px}
.msg-in{color:#0b5; margin-bottom:6px}
.msg-out{color:#2563eb; margin-bottom:6px}
.meta{color:var(--muted);font-size:12px}
label{font-size:13px;color:#111827}
.small{font-size:12px;color:var(--muted)}
.flex-between{display:flex;justify-content:space-between;align-items:center}
.right{margin-left:auto}
.footer{font-size:12px;color:var(--muted);margin-top:8px}
</style>
</head>
<body>
<div class="wrap">
<h1>WebSocket Demo — Netty</h1>
<div class="card">
<div class="row" style="gap:12px;">
<div class="col">
<label>WebSocket URL</label>
<input id="url" type="text" value="ws://localhost:8090/ws" />
</div>
<div style="width:120px">
<label>Heartbeat (s)</label>
<input id="heartbeatSec" type="number" min="0" value="0" />
</div>
<div style="width:160px">
<label>Auto reconnect</label>
<select id="autoReconnect">
<option value="0">关闭</option>
<option value="2000">2s</option>
<option value="5000" selected>5s</option>
<option value="10000">10s</option>
</select>
</div>
<div style="width:140px">
<label>JSON mode</label>
<select id="jsonMode">
<option value="false" selected>文本</option>
<option value="true">JSON (send as {"msg":...})</option>
</select>
</div>
</div>
<div class="row" style="margin-top:10px;">
<div class="controls">
<button id="btnConnect">Connect</button>
<button id="btnDisconnect" class="secondary" disabled>Disconnect</button>
<button id="btnClear" class="secondary">Clear Log</button>
</div>
<div class="right small meta" id="status">状态: <strong>未连接</strong></div>
</div>
</div>
<div class="card">
<div class="row" style="gap:10px;margin-bottom:8px">
<div style="flex:1">
<label>Message</label>
<textarea id="message" rows="3" style="width:100%"></textarea>
</div>
<div style="width:180px">
<label>Target (可选)</label>
<input id="target" type="text" placeholder="目标ID (若服务端支持)" />
</div>
</div>
<div class="row" style="margin-top:8px">
<button id="btnSend">Send</button>
<button id="btnSendJson" class="secondary">Send Example JSON</button>
<div class="right small meta">Sends: <span id="sentCount">0</span>, Received: <span id="recvCount">0</span></div>
</div>
</div>
<div class="card">
<div class="flex-between" style="margin-bottom:8px">
<div><strong>Message Log</strong></div>
<div class="small meta">Time is local browser time</div>
</div>
<div id="log" class="log"></div>
<div class="footer small">提示: 如果你的 Netty 服务要求子协议或 token,请把 URL 改为类似: <code>ws://host:port/ws?token=xxx</code></div>
</div>
</div>
<script>
(function () {
let ws = null;
let reconnectTimer = null;
let sent = 0, recv = 0;
let heartbeatTimer = null;
const $ = id => document.getElementById(id);
const logEl = $('log');
const statusEl = $('status');
const sentCountEl = $('sentCount');
const recvCountEl = $('recvCount');
function log(msg, type='info') {
const time = new Date().toLocaleTimeString();
const div = document.createElement('div');
div.className = type === 'in' ? 'msg-in' : (type === 'out' ? 'msg-out' : '');
div.innerHTML = `<span class="small meta">[${time}]</span> ${escapeHtml(msg)}`;
logEl.appendChild(div);
logEl.scrollTop = logEl.scrollHeight;
}
function setStatus(text, color) {
statusEl.innerHTML = '状态: <strong style="color:' + (color||'#111') + '">' + text + '</strong>';
}
function escapeHtml(s) {
return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
function connect() {
const url = $('url').value.trim();
if (!url) { alert('请输入 WebSocket URL'); return; }
try {
ws = new WebSocket(url);
} catch (e) {
log('创建 WebSocket 失败: ' + e.message);
return;
}
setStatus('连接中...', '#f59e0b');
$('btnConnect').disabled = true;
$('btnDisconnect').disabled = false;
ws.onopen = () => {
setStatus('已连接', '#10b981');
log('CONNECTED → ' + url);
if (heartbeatEnabled()) startHeartbeat();
};
ws.onmessage = (evt) => {
recv++;
recvCountEl.textContent = recv;
log(evt.data, 'in');
};
ws.onclose = (evt) => {
setStatus('已断开', '#ef4444');
log('CLOSED (' + (evt.code || '') + ')');
stopHeartbeat();
if (shouldReconnect()) scheduleReconnect();
$('btnConnect').disabled = false;
$('btnDisconnect').disabled = true;
};
ws.onerror = (err) => {
log('ERROR: ' + (err.message || JSON.stringify(err)));
};
}
function disconnect() {
if (ws) {
stopReconnect();
ws.close();
ws = null;
setStatus('已断开', '#ef4444');
$('btnConnect').disabled = false;
$('btnDisconnect').disabled = true;
}
}
function sendMessage(payload) {
if (!ws || ws.readyState !== WebSocket.OPEN) {
alert('WebSocket 未连接');
return;
}
ws.send(payload);
sent++;
sentCountEl.textContent = sent;
log(payload, 'out');
}
function startHeartbeat() {
stopHeartbeat();
const sec = parseInt($('heartbeatSec').value, 10);
if (!sec || sec <= 0) return;
heartbeatTimer = setInterval(() => {
if (ws && ws.readyState === WebSocket.OPEN) {
const hb = JSON.stringify({type:'ping', ts: Date.now()});
ws.send(hb);
log('(heartbeat) ' + hb, 'out');
}
}, sec * 1000);
}
function stopHeartbeat() {
if (heartbeatTimer) { clearInterval(heartbeatTimer); heartbeatTimer = null; }
}
function heartbeatEnabled() {
const sec = parseInt($('heartbeatSec').value, 10);
return sec && sec > 0;
}
function shouldReconnect() {
return parseInt($('autoReconnect').value, 10) > 0;
}
function scheduleReconnect() {
stopReconnect();
const delay = parseInt($('autoReconnect').value, 10);
if (!delay) return;
log('尝试在 ' + (delay/1000) + 's 后重连...');
reconnectTimer = setTimeout(() => {
log('重连中...');
connect();
}, delay);
}
function stopReconnect() {
if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
}
function clearLog() {
logEl.innerHTML = '';
}
// events
$('btnConnect').addEventListener('click', () => { connect(); });
$('btnDisconnect').addEventListener('click', () => { disconnect(); });
$('btnClear').addEventListener('click', () => { clearLog(); });
$('btnSend').addEventListener('click', () => {
const jsonMode = $('jsonMode').value === 'true';
const target = $('target').value.trim();
const text = $('message').value;
if (!text) return alert('请输入要发送的消息');
if (jsonMode) {
const obj = { type: 'message', msg: text };
if (target) obj.to = target;
sendMessage(JSON.stringify(obj));
} else {
// 如果有 target,也拼一个简单的前缀,取决于服务端如何解析
if (target) {
// 约定: "to:TARGET|payload"
sendMessage('to:' + target + '|' + text);
} else {
sendMessage(text);
}
}
});
$('btnSendJson').addEventListener('click', () => {
const sample = { type: 'message', msg: 'hello from web demo', ts: Date.now() };
$('message').value = JSON.stringify(sample, null, 2);
});
// close on page unload
window.addEventListener('beforeunload', () => {
try { if (ws) ws.close(); } catch(e){ }
});
// helper: update heartbeat when changed
$('heartbeatSec').addEventListener('change', () => {
if (ws && ws.readyState === WebSocket.OPEN) {
if (heartbeatEnabled()) startHeartbeat(); else stopHeartbeat();
}
});
})();
</script>
</body>
</html>