cangku/backend/src/services/marketReplicaDatabase.js

305 lines
8.9 KiB
JavaScript

const path = require('path');
const dotenv = require('dotenv');
const mysql = require('mysql2/promise');
const planningDb = require('./planningDatabase');
dotenv.config();
dotenv.config({ path: path.resolve(__dirname, '../../.env') });
dotenv.config({ path: path.resolve(__dirname, '../.env') });
function parsePositiveInt(value, fallback) {
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
}
function parseBoolean(value, fallback = false) {
if (value === undefined || value === null || value === '') return fallback;
const normalized = String(value).trim().toLowerCase();
if (['1', 'true', 'yes', 'on'].includes(normalized)) return true;
if (['0', 'false', 'no', 'off'].includes(normalized)) return false;
return fallback;
}
function getDedicatedConfig() {
if (parseBoolean(process.env.MARKET_REPLICA_DB_DISABLED, false)) {
return null;
}
const host = String(process.env.MARKET_REPLICA_DB_HOST || process.env.MARKET_SYNC_SOURCE_HOST || '').trim();
const database = String(
process.env.MARKET_REPLICA_DB_NAME
|| process.env.MARKET_REPLICA_DB_DATABASE
|| process.env.MARKET_SYNC_SOURCE_DATABASE
|| ''
).trim();
const user = String(process.env.MARKET_REPLICA_DB_USER || process.env.MARKET_SYNC_SOURCE_USER || '').trim();
if (!host || !database || !user) {
return null;
}
return {
host,
port: parsePositiveInt(process.env.MARKET_REPLICA_DB_PORT || process.env.MARKET_SYNC_SOURCE_PORT, 3306),
user,
password: process.env.MARKET_REPLICA_DB_PASSWORD || process.env.MARKET_SYNC_SOURCE_PASSWORD || '',
database,
charset: process.env.MARKET_REPLICA_DB_CHARSET || 'utf8mb4',
supportBigNumbers: true,
bigNumberStrings: true,
waitForConnections: true,
connectionLimit: parsePositiveInt(process.env.MARKET_REPLICA_DB_CONNECTION_LIMIT, 3),
maxIdle: parsePositiveInt(process.env.MARKET_REPLICA_DB_MAX_IDLE, 2),
idleTimeout: parsePositiveInt(process.env.MARKET_REPLICA_DB_IDLE_TIMEOUT_MS, 30000),
queueLimit: parsePositiveInt(process.env.MARKET_REPLICA_DB_QUEUE_LIMIT, 20),
connectTimeout: parsePositiveInt(process.env.MARKET_REPLICA_DB_CONNECT_TIMEOUT_MS, 8000),
enableKeepAlive: true,
keepAliveInitialDelay: 0,
ssl: parseBoolean(process.env.MARKET_REPLICA_DB_SSL, false) ? { rejectUnauthorized: false } : false,
};
}
let dedicatedPool = null;
let dedicatedConfig = null;
let dedicatedUnavailableUntil = 0;
let dedicatedLastError = null;
function isDedicatedFallbackEnabled() {
return parseBoolean(process.env.MARKET_REPLICA_DB_FALLBACK_TO_PLANNING, true);
}
function getFallbackCooldownMs() {
return parsePositiveInt(process.env.MARKET_REPLICA_DB_FALLBACK_COOLDOWN_MS, 60000);
}
function isDedicatedTemporarilyUnavailable() {
return dedicatedUnavailableUntil > Date.now();
}
function markDedicatedUnavailable(error) {
dedicatedLastError = error;
dedicatedUnavailableUntil = Date.now() + getFallbackCooldownMs();
}
function isMissingSchemaObjectError(error) {
const code = String(error?.code || '').trim().toUpperCase();
const errno = Number(error?.errno || 0);
const message = String(error?.message || '').toLowerCase();
return code === 'ER_NO_SUCH_TABLE'
|| errno === 1146
|| message.includes("doesn't exist")
|| message.includes('no such table');
}
function isDedicatedConnectionError(error) {
const code = String(error?.code || '').trim().toUpperCase();
const message = String(error?.message || '').toLowerCase();
return [
'ECONNREFUSED',
'ECONNRESET',
'EHOSTUNREACH',
'ENETUNREACH',
'ENOTFOUND',
'ETIMEDOUT',
'ER_CON_COUNT_ERROR',
'ER_TOO_MANY_USER_CONNECTIONS',
'PROTOCOL_CONNECTION_LOST',
'PROTOCOL_ENQUEUE_AFTER_FATAL_ERROR',
'PROTOCOL_SEQUENCE_TIMEOUT',
].includes(code)
|| message.includes('connection is closed')
|| message.includes('connection lost')
|| message.includes('connect etimedout')
|| message.includes('too many connections')
|| message.includes('max_user_connections');
}
async function closeDedicatedPool() {
if (dedicatedPool) {
await dedicatedPool.end().catch(() => {});
}
dedicatedPool = null;
}
function getPool() {
if (isDedicatedTemporarilyUnavailable()) {
return null;
}
if (!dedicatedConfig) {
dedicatedConfig = getDedicatedConfig();
}
if (!dedicatedConfig) {
return null;
}
if (!dedicatedPool) {
dedicatedPool = mysql.createPool(dedicatedConfig);
}
return dedicatedPool;
}
function withQueryTimeout(sql, options = {}) {
const timeout = parsePositiveInt(
options.requestTimeout || process.env.MARKET_REPLICA_DB_QUERY_TIMEOUT_MS,
8000
);
return timeout ? { sql, timeout } : sql;
}
async function runWithDedicatedFallback(operation, fallbackOperation) {
const pool = getPool();
if (!pool) {
return fallbackOperation();
}
try {
return await operation(pool);
} catch (error) {
if (!isDedicatedFallbackEnabled()) {
throw error;
}
if (isMissingSchemaObjectError(error)) {
console.warn(`[MarketReplicaDB] 专用市场库缺少当前查询对象,仅本次回退企划库: ${error.message}`);
return fallbackOperation();
}
console.warn(`[MarketReplicaDB] 专用市场库不可用,${Math.round(getFallbackCooldownMs() / 1000)} 秒内回退企划库: ${error.message}`);
markDedicatedUnavailable(error);
await closeDedicatedPool();
return fallbackOperation();
}
}
async function query(sql, params = [], options = {}) {
return runWithDedicatedFallback(
async (pool) => {
const [rows] = await pool.query(withQueryTimeout(sql, options), params);
return rows;
},
() => planningDb.query(sql, params, options)
);
}
async function queryDedicated(sql, params = [], options = {}) {
const pool = getPool();
if (!pool) {
const error = new Error(
dedicatedLastError
? `市场专用数据库暂不可用: ${dedicatedLastError.message}`
: '市场专用数据库未配置'
);
error.code = 'MARKET_REPLICA_DEDICATED_UNAVAILABLE';
throw error;
}
try {
const [rows] = await pool.query(withQueryTimeout(sql, options), params);
return rows;
} catch (error) {
if (isDedicatedConnectionError(error)) {
markDedicatedUnavailable(error);
}
throw error;
}
}
async function execute(sql, params = [], options = {}) {
return runWithDedicatedFallback(
async (pool) => {
const [result] = await pool.query(withQueryTimeout(sql, options), params);
return result;
},
() => planningDb.execute(sql, params, options)
);
}
async function getRuntimeInfo() {
const pool = getPool();
if (!pool) {
const runtime = await planningDb.getRuntimeInfo();
return {
...runtime,
marketReplicaDedicated: false,
marketReplicaFallback: 'planning_database',
marketReplicaLastError: dedicatedLastError?.message || '',
marketReplicaRetryAt: dedicatedUnavailableUntil || null,
};
}
return {
runtimeProfile: 'market-replica-dedicated',
selectedProfile: 'market-replica',
source: 'env',
configName: 'market_replica_database',
host: dedicatedConfig.host,
port: dedicatedConfig.port,
database: dedicatedConfig.database,
charset: dedicatedConfig.charset,
ssl: Boolean(dedicatedConfig.ssl),
marketReplicaDedicated: true,
fallbackOrder: ['market-replica', 'planning_database'],
};
}
async function test() {
const pool = getPool();
if (!pool) {
const fallbackResult = await planningDb.test();
return {
...fallbackResult,
message: fallbackResult.success
? '市场洞察专用库当前不可用,已回退企划数据库'
: fallbackResult.message,
runtime: await getRuntimeInfo().catch(() => fallbackResult.runtime),
};
}
try {
const [rows] = await pool.query('SELECT 1 AS test');
return {
success: true,
message: '市场洞察专用数据库连接成功',
data: rows,
runtime: await getRuntimeInfo(),
};
} catch (error) {
markDedicatedUnavailable(error);
await closeDedicatedPool();
const fallbackResult = await planningDb.test().catch((fallbackError) => ({
success: false,
message: fallbackError.message || '企划数据库连接失败',
}));
return {
success: Boolean(fallbackResult.success),
message: fallbackResult.success
? `市场洞察专用库连接失败,已回退企划数据库: ${error.message}`
: error.message || '市场洞察专用数据库连接失败',
runtime: await getRuntimeInfo().catch(() => null),
};
}
}
async function close() {
await closeDedicatedPool();
dedicatedConfig = null;
dedicatedUnavailableUntil = 0;
dedicatedLastError = null;
}
async function reload() {
await close();
return getPool();
}
module.exports = {
query,
queryDedicated,
execute,
getRuntimeInfo,
test,
close,
reload,
_test: {
isDedicatedConnectionError,
isMissingSchemaObjectError,
},
};