20260830215003

This commit is contained in:
oneao committed 2026-08-30 21:50:03 +08:00
1 parent 47c0bf08aa
commit d6a56eb17f
47 files changed
+682 -351

No files matched your search

@@ -5,6 +5,7 @@ import jakarta.annotation.Resource;
import org.springframework.stereotype.Component;
import java.sql.SQLException;
import java.util.Locale;
@Component
public class OrgDataSourceFactory {
@@ -28,19 +29,33 @@ public class OrgDataSourceFactory {
dataSource.setTestWhileIdle(poolProperties.isTestWhileIdle());
dataSource.setTestOnBorrow(poolProperties.isTestOnBorrow());
dataSource.setTestOnReturn(poolProperties.isTestOnReturn());
// 连接失败快速失败,不做重试:重试没有意义,等下次请求再触发建连
dataSource.setConnectionErrorRetryAttempts(0);
dataSource.setBreakAfterAcquireFailure(true);
dataSource.setFailFast(true);
// 定期 ping 空闲连接,对抗云 NAT/防火墙回收空闲 TCP 会话(默认 120s)
dataSource.setKeepAlive(true);
dataSource.setKeepAliveBetweenTimeMillis(120_000);
dataSource.setTimeBetweenEvictionRunsMillis(
poolProperties.getTimeBetweenEvictionRuns().toMillis()
);
// 建连失败后必须继续重试,绝不能停掉建连线程:
// breakAfterAcquireFailure=true 会让 Druid 的 CreateConnectionThread 直接 return,
// 这个池从此再也建不出连接,failContinuous 也永远不会被重置 -> 只能重启进程
dataSource.setConnectionErrorRetryAttempts(1);
dataSource.setBreakAfterAcquireFailure(false);
dataSource.setTimeBetweenConnectErrorMillis(2_000);
// failFast=true 时,只要 failContinuous 被置位,getConnection 会直接抛
// DataSourceNotAvailableException(缓存的第一次 createError),不再尝试建连,
// 即使网络早已恢复也一直报错。快速失败语义由 initial-size + init() 抛异常保证
dataSource.setFailFast(false);
// 公网链路必须有握手/读超时上限:缺 socketTimeout 时一次卡死的查询会长期占满 maxActive
dataSource.setConnectionProperties(buildConnectionProperties(config.url()));
// 连接最长存活 30 分钟,主动轮换,避开 NAT/防火墙静默回收空闲 TCP 会话
dataSource.setPhyTimeoutMillis(1_800_000);
// 兜底连接泄漏:maxActive 只有 10 条,泄漏几小时就足以拖垮整个机构
dataSource.setRemoveAbandoned(true);
dataSource.setRemoveAbandonedTimeoutMillis(180_000);
dataSource.setLogAbandoned(true);
long timeBetweenEvictionRunsMillis = poolProperties.getTimeBetweenEvictionRuns().toMillis();
dataSource.setTimeBetweenEvictionRunsMillis(timeBetweenEvictionRunsMillis);
dataSource.setMinEvictableIdleTimeMillis(
poolProperties.getMinEvictableIdleTime().toMillis()
);
// 定期 ping 空闲连接,对抗云 NAT/防火墙回收空闲 TCP 会话(需配合 min-idle >= 1 才生效)
// Druid 要求 keepAliveBetweenTimeMillis 严格大于 timeBetweenEvictionRunsMillis
dataSource.setKeepAlive(true);
dataSource.setKeepAliveBetweenTimeMillis(Math.max(120_000L, timeBetweenEvictionRunsMillis * 2));
try {
dataSource.init();
@@ -50,4 +65,27 @@ public class OrgDataSourceFactory {
throw new IllegalStateException("初始化机构数据库连接池失败: " + orgId, exception);
}
}
/**
* SQL Server JDBC 的超时属性单位不统一:connectTimeout / loginTimeout 为秒,socketTimeout 为毫秒。
* 机构配置里已显式声明的属性不覆盖。
*/
private String buildConnectionProperties(String url) {
String lowerUrl = url.toLowerCase(Locale.ROOT);
StringBuilder builder = new StringBuilder();
appendIfAbsent(builder, lowerUrl, "loginTimeout", "loginTimeout=10");
appendIfAbsent(builder, lowerUrl, "connectTimeout", "connectTimeout=10");
appendIfAbsent(builder, lowerUrl, "socketTimeout", "socketTimeout=60000");
return builder.toString();
}
private void appendIfAbsent(StringBuilder builder, String lowerUrl, String key, String property) {
if (lowerUrl.contains(key.toLowerCase(Locale.ROOT))) {
return;
}
if (builder.length() > 0) {
builder.append(';');
}
builder.append(property);
}
}
@@ -452,7 +452,7 @@ public class DataSaveService {
for (int index = 0; index < rows.size(); index++) {
try {
switch (action) {
case "delete" -> dbUtils.delete(connection, table, keyColumn, rows.get(index));
case "delete" -> dbUtils.delete(connection, table, rows.get(index));
case "update" -> dbUtils.update(connection, table, keyColumn, rows.get(index));
case "insert" -> dbUtils.insert(connection, table, keyColumn, rows.get(index));
default -> throw new IllegalArgumentException("不支持的保存操作: " + action);
@@ -316,17 +316,26 @@ public class DbUtils {
return executeWrite(connection, sql, parameters, "修改");
}
/**
* 按行内全部列值删除:列值组合为 AND 条件({b_id: x} 即按主键删,
* {b_module_id: m} 即按外键批量删)。值为 null 的条件按 SQL 语义匹配不到任何行。
* 行内没有表中的列时拒绝执行,避免无 WHERE 条件的全表删除。
*/
public int delete(
Connection connection,
TableMetadata table,
ColumnMetadata keyColumn,
Map<String, Object> row
) throws SQLException {
Map<String, Object> normalizedRow = normalizeRow(row);
Object keyValue = requiredKeyValue(normalizedRow, keyColumn, table.requestedName());
String sql = "DELETE FROM " + table.quotedName()
+ " WHERE " + quoteColumn(keyColumn) + " = ?";
return executeDelete(connection, sql, List.of(keyValue));
LinkedHashMap<ColumnMetadata, Object> conditions = writableValues(table, normalizedRow, null);
if (conditions.isEmpty()) {
throw new BusinessException("删除条件不能为空: " + table.requestedName());
}
String whereSql = conditions.keySet().stream()
.map(column -> quoteColumn(column) + " = ?")
.collect(Collectors.joining(" AND "));
String sql = "DELETE FROM " + table.quotedName() + " WHERE " + whereSql;
return executeDelete(connection, sql, new ArrayList<>(conditions.values()));
}
private int executeDelete(
@@ -20,15 +20,19 @@ fms:
pool:
# 建池时先建 1 条物理连接:配置指向不可达的库时快速失败,保留旧池不切换
initial-size: 1
min-idle: 0
# 保留 1 条温连接:min-idle=0 时 keepAlive 无连接可保活,空闲连接被回收后每次请求都要跨公网重新建连
min-idle: 1
max-active: 10
max-wait: 5000
# 公网建连本身可能超过 5s(握手上限 10s),池空时需留够等待时间
max-wait: 10000
validation-query: SELECT 1
test-while-idle: true
test-on-borrow: true
# 每次借连接都做一次跨公网 SELECT 1,既慢又放大失败面,由 testWhileIdle + keepAlive 兜底
test-on-borrow: false
test-on-return: false
time-between-eviction-runs: 30s
min-evictable-idle-time: 30s
time-between-eviction-runs: 60s
# 原 30s:业务稍一空闲连接就被销毁,每个请求都要重新建连,公网建连超时概率被大幅放大
min-evictable-idle-time: 10m
file:
storage-type: local
local:
@@ -140,6 +140,43 @@ class DataSaveServiceTests {
verify(fixture.connection).commit();
}
@Test
void deleteCombinesRowColumnsIntoAndConditions() throws Exception {
JdbcFixture fixture = new JdbcFixture("b_id", "b_module_id");
DataSaveService service = fixture.createService();
PreparedStatement andDeleteStatement = mock(PreparedStatement.class);
when(fixture.connection.prepareStatement(
"DELETE FROM [b_user] WHERE [b_id] = ? AND [b_module_id] = ?"
)).thenReturn(andDeleteStatement);
when(andDeleteStatement.executeUpdate()).thenReturn(2);
service.save(List.of(Map.of(
"table", "b_user",
"key_field", "b_id",
"deletes", List.of(Map.of("b_id", "D001", "b_module_id", "M001"))
)));
verify(andDeleteStatement).setObject(1, "D001");
verify(andDeleteStatement).setObject(2, "M001");
verify(fixture.connection).commit();
}
@Test
void deleteRejectsRowWithoutTableColumns() throws Exception {
JdbcFixture fixture = new JdbcFixture("b_id", "b_name");
DataSaveService service = fixture.createService();
assertThatThrownBy(() -> service.save(List.of(Map.of(
"table", "b_user",
"key_field", "b_id",
"deletes", List.of(Map.of("b_unknown", "x"))
)))).isInstanceOf(BusinessException.class)
.hasMessageContaining("删除条件不能为空");
verify(fixture.connection).rollback();
verify(fixture.connection, org.mockito.Mockito.never()).commit();
}
private static final class JdbcFixture {
private final DataSource dataSource = mock(DataSource.class);