u
This commit is contained in:
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);
|
||||
}
|
||||
}
|
||||
+22
@@ -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;
|
||||
}
|
||||
}
|
||||
+231
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
+21
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
+28
@@ -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;
|
||||
}
|
||||
+17
@@ -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;
|
||||
}
|
||||
}
|
||||
+45
@@ -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,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||
}
|
||||
|
||||
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>
|
||||
Reference in new issue
Block a user