cangku/backend/src/config/database.js

305 lines
10 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 多数据源配置文件
*
* 架构说明:
* ┌─────────────────────────────────────────────────────────┐
* │ 应用服务 │
* ├─────────────────────┬───────────────────────────────────┤
* │ 本地数据库 │ 云数据库 │
* │ (localhost) │ (rm-xxx.rds.aliyuncs.com) │
* │ │ │
* │ ┌──────────────┐ │ ┌────────────────────────────┐ │
* │ │ 用户表 │ │ │ Orders (订单表) │ │
* │ │ 部门表 │ │ │ Products (产品表) │ │
* │ │ 权限表 │ │ │ ... 其他业务表 │ │
* │ │ 配置表 │ │ │ │ │
* │ │ 预警记录 │ │ │ (千万级数据) │ │
* │ └──────────────┘ │ └────────────────────────────┘ │
* │ │ │
* │ 用途: │ 用途: │
* - 用户认证 │ - Cube.js 数据聚合 │
* - 权限管理 │ - 报表查询 │
* - 系统配置 │ - 预警检查 │
* - 预警规则/记录 │ │
* └─────────────────────┴───────────────────────────────────┘
*/
const mysql = require('mysql2/promise');
const { getSystemDbRuntimeConfig } = require('../services/systemDbConfig');
const TRANSIENT_MYSQL_ERROR_CODES = new Set([
'ETIMEDOUT',
'ECONNRESET',
'ECONNREFUSED',
'EPIPE',
'PROTOCOL_CONNECTION_LOST',
'PROTOCOL_ENQUEUE_AFTER_FATAL_ERROR',
'PROTOCOL_ENQUEUE_AFTER_QUIT',
'PROTOCOL_PACKETS_OUT_OF_ORDER',
]);
function parsePositiveInt(value, fallback) {
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
}
function isTransientMysqlError(error) {
if (!error) {
return false;
}
const message = String(error.message || '').toLowerCase();
const code = String(error.code || '');
return (
TRANSIENT_MYSQL_ERROR_CODES.has(code) ||
message.includes('timed out') ||
message.includes('read etimedout') ||
message.includes('connect etimedout') ||
message.includes('connection lost') ||
message.includes('pool is closed') ||
message.includes('closed state') ||
message.includes('socket hang up')
);
}
const LOCAL_DB_CONNECT_TIMEOUT_MS = parsePositiveInt(process.env.LOCAL_DB_CONNECT_TIMEOUT_MS, 5000);
const LOCAL_DB_CONNECTION_LIMIT = parsePositiveInt(process.env.LOCAL_DB_CONNECTION_LIMIT, 6);
const LOCAL_DB_QUEUE_LIMIT = parsePositiveInt(process.env.LOCAL_DB_QUEUE_LIMIT, 50);
const LOCAL_DB_MAX_IDLE = parsePositiveInt(process.env.LOCAL_DB_MAX_IDLE, LOCAL_DB_CONNECTION_LIMIT);
const LOCAL_DB_IDLE_TIMEOUT_MS = parsePositiveInt(process.env.LOCAL_DB_IDLE_TIMEOUT_MS, 60000);
const LOCAL_DB_QUERY_RETRY_COUNT = parsePositiveInt(process.env.LOCAL_DB_QUERY_RETRY_COUNT, 1);
const LOCAL_DB_QUERY_TIMEOUT_MS = parsePositiveInt(process.env.LOCAL_DB_QUERY_TIMEOUT_MS, 60000);
function withLocalQueryTimeout(sql) {
if (!LOCAL_DB_QUERY_TIMEOUT_MS) {
return sql;
}
return {
sql,
timeout: LOCAL_DB_QUERY_TIMEOUT_MS,
};
}
function createCloudPool() {
return mysql.createPool({
host: process.env.CLOUD_DB_HOST,
port: parseInt(process.env.CLOUD_DB_PORT || '3306'),
user: process.env.CLOUD_DB_USER,
password: process.env.CLOUD_DB_PASSWORD,
database: process.env.CLOUD_DB_NAME,
charset: 'utf8mb4',
waitForConnections: true,
connectionLimit: 10,
queueLimit: 0,
ssl: process.env.CLOUD_DB_SSL === 'true' ? { rejectUnauthorized: true } : false,
});
}
function createLocalPool() {
const config = getSystemDbRuntimeConfig();
return mysql.createPool({
host: config.host,
port: parseInt(config.port || '3306', 10),
user: config.username,
password: config.password,
database: config.database,
charset: 'utf8mb4',
waitForConnections: true,
connectionLimit: LOCAL_DB_CONNECTION_LIMIT,
maxIdle: LOCAL_DB_MAX_IDLE,
idleTimeout: LOCAL_DB_IDLE_TIMEOUT_MS,
queueLimit: LOCAL_DB_QUEUE_LIMIT,
connectTimeout: LOCAL_DB_CONNECT_TIMEOUT_MS,
enableKeepAlive: true,
keepAliveInitialDelay: 0,
});
}
let cloudPool = createCloudPool();
let localPool = createLocalPool();
async function resetPool(source) {
const isLocal = source === 'local';
const activePool = isLocal ? localPool : cloudPool;
if (activePool) {
try {
await activePool.end();
} catch (error) {
console.warn(`[database:${source}] 重置连接池时关闭失败: ${error.message}`);
}
}
if (isLocal) {
localPool = createLocalPool();
dataSource.local.pool = localPool;
} else {
cloudPool = createCloudPool();
dataSource.cloud.pool = cloudPool;
}
}
async function runQueryWithRetry(source, runner, options = {}) {
const maxAttempts = options.maxAttempts || 1;
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
try {
const activePool = source === 'local' ? localPool : cloudPool;
return await runner(activePool);
} catch (error) {
const shouldRetry = source === 'local'
&& attempt < maxAttempts
&& isTransientMysqlError(error);
if (!shouldRetry) {
throw error;
}
console.warn(`[database:${source}] 查询失败,重置连接池后重试 (${attempt}/${maxAttempts}): ${error.message}`);
await resetPool(source);
}
}
return [];
}
// ============================================
// 数据源管理器
// ============================================
const dataSource = {
// 云数据库 (业务数据 - 只读为主)
cloud: {
name: 'cloud',
pool: cloudPool,
isReadOnly: true,
// 测试连接
async test() {
try {
const connection = await cloudPool.getConnection();
await connection.ping();
connection.release();
return { success: true, message: '业务数据库连接成功' };
} catch (error) {
return { success: false, message: `连接失败: ${error.message}` };
}
},
// 执行查询
async query(sql, params = []) {
const [rows] = await runQueryWithRetry('cloud', (pool) => pool.execute(sql, params));
return rows;
},
},
// 系统数据库 (系统配置 + 用户数据 + 汇总结果 - 读写)
local: {
name: 'local',
pool: localPool,
isReadOnly: false,
// 测试连接
async test() {
try {
const connection = await runQueryWithRetry(
'local',
(pool) => pool.getConnection(),
{ maxAttempts: LOCAL_DB_QUERY_RETRY_COUNT }
);
await connection.ping();
connection.release();
return { success: true, message: '系统数据库连接成功' };
} catch (error) {
return { success: false, message: `连接失败: ${error.message}` };
}
},
// 执行查询
async query(sql, params = []) {
// 使用 query 而不是 execute,因为 execute 对 LIMIT 参数有限制
const [rows] = await runQueryWithRetry(
'local',
(pool) => pool.query(withLocalQueryTimeout(sql), params),
{ maxAttempts: LOCAL_DB_QUERY_RETRY_COUNT }
);
return rows;
},
// 执行插入 - 支持两种方式:
// 1. insert(sql, params) - 原始 SQL
// 2. insert(tableName, data) - 表名 + 数据对象
async insert(tableOrSql, dataOrParams = []) {
let sql, params;
if (typeof dataOrParams === 'object' && !Array.isArray(dataOrParams)) {
// 表名 + 数据对象方式
const tableName = tableOrSql;
const data = dataOrParams;
const columns = Object.keys(data);
const values = Object.values(data);
const placeholders = columns.map(() => '?').join(', ');
// 对保留字使用反引号
const quotedColumns = columns.map(c => `\`${c}\``).join(', ');
sql = `INSERT INTO ${tableName} (${quotedColumns}) VALUES (${placeholders})`;
params = values;
} else {
// 原始 SQL 方式
sql = tableOrSql;
params = dataOrParams;
}
const [result] = await runQueryWithRetry(
'local',
(pool) => pool.query(withLocalQueryTimeout(sql), params),
{ maxAttempts: LOCAL_DB_QUERY_RETRY_COUNT }
);
return { insertId: result.insertId };
},
// 执行更新 - 支持两种方式:
// 1. update(sql, params) - 原始 SQL
// 2. update(tableName, id, data) - 表名 + ID + 数据对象
async update(tableOrSql, idOrParams, data) {
let sql, params;
if (data !== undefined && typeof data === 'object') {
// 表名 + ID + 数据对象方式
const tableName = tableOrSql;
const id = idOrParams;
const columns = Object.keys(data);
const values = Object.values(data);
// 对保留字使用反引号
const setClause = columns.map(c => `\`${c}\` = ?`).join(', ');
sql = `UPDATE ${tableName} SET ${setClause} WHERE id = ?`;
params = [...values, id];
} else {
// 原始 SQL 方式
sql = tableOrSql;
params = idOrParams || [];
}
const [result] = await runQueryWithRetry(
'local',
(pool) => pool.query(withLocalQueryTimeout(sql), params),
{ maxAttempts: LOCAL_DB_QUERY_RETRY_COUNT }
);
return result.affectedRows;
},
},
// 获取指定数据源
get(source) {
return source === 'cloud' ? this.cloud : this.local;
},
// 关闭所有连接
async close() {
await cloudPool.end();
await localPool.end();
},
};
module.exports = dataSource;