cangku/backend/src/config/cloudDatabase.js
lunanting b3f4d1af64 feat: 客退数据监控五仓看板与本地运行稳定性加固
功能:
- 退货库存预警:横林二仓/苏北/睢宁/山东青岗集/河南洛宁 五张看板,
  按各仓客退上架仓对应的自定义库存位取数(各仓位不同:横林/苏北/睢宁 c1,山东 c2)
- 支持导入聚水潭「箱及仓位库存」导出文件覆盖创建时间、件数、供应商
  (副本只有仓库级数量,没有箱级维度,真实上架时间只能靠导入)
- 退货入库核销与每日库存监控:状态落本地系统库,含定时任务与差异清单
- 服务端导出(xlsx/csv),不受列表分页与展示上限约束

稳定性:
- 前端幂等请求在后端重启窗口内自动退避重连,不再直接抛 500
- 后端补齐统一 JSON 错误兜底与 /api JSON 404,异常不再返回 HTML
- 隧道 ssh 改为绝对路径解析并隐藏窗口,避免非交互式启动 ENOENT 与黑窗弹窗
- 业务库 DATETIME 按北京时间解析(useUTC: false),修复时间显示整体 +8 小时

合并:与上游「隧道断线自愈 + 配置负缓存」为同一问题的两份实现,
本分支采用上游方案(带 TTL 的负缓存 + 统一失效函数 + 单测)。
2026-09-23 11:32:16 +08:00

1161 lines
40 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.

/**
* 云业务数据库连接模块(MSSQL)
* 支持本地 / 公网双配置与自动回退。
*/
const os = require('os');
const sql = require('mssql');
const mysql = require('mysql2/promise');
const { getSystemDbRuntimeConfig } = require('../services/systemDbConfig');
const NOT_CONFIGURED_MESSAGE = '业务数据库尚未配置,请先在系统设置 -> 业务数据库中完成配置';
const TRANSIENT_ERROR_CODES = new Set(['ESOCKET', 'ECONNRESET', 'ETIMEOUT', 'ENOTOPEN', 'ECONNCLOSED']);
const CLOUD_DATABASE_CONFIG_NAME = 'cloud_database';
const CLOUD_DATABASE_PUBLIC_CONFIG_NAME = 'cloud_database_public';
const DEFAULT_CLOUD_LOCAL_SERVER_IP = '192.168.150.234';
const DEFAULT_REPORT_REPLICA_DATABASE = 'JSReportReplica';
const DEFAULT_REPORT_REPLICA_SCHEMA = 'rpt';
const DEFAULT_REPORT_REPLICA_APP_SCHEMA = 'app';
let pool = null;
let activeCloudConfig = null;
let cachedCloudConfigCandidates = null;
// 负缓存(失败态)的过期时间戳。0 表示没有失败态缓存。
let cachedCloudConfigExpiresAt = 0;
function isPoolUsable(activePool) {
return Boolean(activePool && activePool.connected && activePool.healthy !== false);
}
const parsePositiveInt = (value, fallback) => {
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
};
const DEFAULT_CONNECT_TIMEOUT_MS = parsePositiveInt(process.env.CLOUD_DB_CONNECT_TIMEOUT_MS, 30000);
// 原先默认 600000ms(10 分钟),配合 maxAttempts=2 最坏可挂 20 分钟,用户体感就是「卡死」。
// 收敛到 60s:本页最重的冷查实测 16~42s,仍有充足余量。
const DEFAULT_REQUEST_TIMEOUT_MS = parsePositiveInt(process.env.CLOUD_DB_REQUEST_TIMEOUT_MS, 60000);
// 解析「系统库配置」失败时的负缓存存活时间。到期后自动重试,使隧道恢复后无需重启后端。
const NEGATIVE_CONFIG_CACHE_TTL_MS = parsePositiveInt(process.env.CLOUD_DB_CONFIG_NEGATIVE_TTL_MS, 30000);
const CLOUD_DB_POOL_MAX = parsePositiveInt(process.env.CLOUD_DB_POOL_MAX, 10);
const CLOUD_DB_POOL_IDLE_TIMEOUT_MS = parsePositiveInt(process.env.CLOUD_DB_POOL_IDLE_TIMEOUT_MS, 30000);
const LOCAL_CONFIG_DB_CONNECT_TIMEOUT_MS = parsePositiveInt(process.env.LOCAL_DB_CONNECT_TIMEOUT_MS, 5000);
const LOCAL_CONFIG_DB_CONNECTION_LIMIT = parsePositiveInt(process.env.LOCAL_CONFIG_DB_CONNECTION_LIMIT, 2);
const DEFAULT_ENV_KEYS = Object.freeze({
driver: ['CLOUD_DB_TYPE', 'APP_DB_TYPE', 'SQLSERVER_DRIVER'],
host: ['CLOUD_DB_HOST', 'APP_DB_HOST', 'SQLSERVER_HOST'],
port: ['CLOUD_DB_PORT', 'APP_DB_PORT', 'SQLSERVER_PORT'],
database: ['CLOUD_DB_NAME', 'APP_DB_NAME', 'SQLSERVER_DATABASE'],
user: ['CLOUD_DB_USER', 'APP_DB_USER', 'SQLSERVER_USERNAME'],
password: ['CLOUD_DB_PASSWORD', 'APP_DB_PASSWORD', 'SQLSERVER_PASSWORD'],
ssl: ['CLOUD_DB_SSL', 'SQLSERVER_ENCRYPT'],
});
const PUBLIC_ENV_KEYS = Object.freeze({
driver: ['CLOUD_DB_PUBLIC_TYPE', 'CLOUD_DB_TYPE', 'APP_DB_TYPE', 'SQLSERVER_DRIVER'],
host: ['CLOUD_DB_PUBLIC_HOST', 'CLOUD_DB_HOST', 'APP_DB_HOST', 'SQLSERVER_HOST'],
port: ['CLOUD_DB_PUBLIC_PORT', 'CLOUD_DB_PORT', 'APP_DB_PORT', 'SQLSERVER_PORT'],
database: ['CLOUD_DB_PUBLIC_NAME', 'CLOUD_DB_PUBLIC_DATABASE', 'CLOUD_DB_NAME', 'APP_DB_NAME', 'SQLSERVER_DATABASE'],
user: ['CLOUD_DB_PUBLIC_USER', 'CLOUD_DB_USER', 'APP_DB_USER', 'SQLSERVER_USERNAME'],
password: ['CLOUD_DB_PUBLIC_PASSWORD', 'CLOUD_DB_PASSWORD', 'APP_DB_PASSWORD', 'SQLSERVER_PASSWORD'],
ssl: ['CLOUD_DB_PUBLIC_SSL', 'CLOUD_DB_SSL', 'SQLSERVER_ENCRYPT'],
});
function isTransientError(error) {
if (!error) return false;
const message = String(error.message || error.originalError?.message || '').toLowerCase();
const code = error.code || error.originalError?.code;
return (
TRANSIENT_ERROR_CODES.has(code) ||
message.includes('connection lost') ||
message.includes('socket hang up') ||
message.includes('connection is closed') ||
message.includes('unable to process incoming packet') ||
message.includes('timed out')
);
}
function isTruthy(value) {
return value === true || value === 1 || String(value || '').toLowerCase() === 'true' || String(value || '') === '1';
}
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 getFirstEnvValue(source, keys = [], fallback = '') {
for (const key of keys) {
const value = source[key];
if (value !== undefined && value !== null && String(value).trim() !== '') {
return String(value).trim();
}
}
return fallback;
}
function normalizeIdentifierText(value) {
return String(value || '').replace(/[\[\]]/g, '').trim();
}
function quoteMssqlIdentifier(value) {
return `[${normalizeIdentifierText(value).replace(/]/g, ']]')}]`;
}
function escapeRegExp(value) {
return String(value || '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function getReportReplicaDatabaseName() {
return normalizeIdentifierText(process.env.CLOUD_DB_REPORT_DATABASE || DEFAULT_REPORT_REPLICA_DATABASE);
}
function getReportReplicaSchemaName() {
return normalizeIdentifierText(process.env.CLOUD_DB_REPORT_SCHEMA || DEFAULT_REPORT_REPLICA_SCHEMA) || DEFAULT_REPORT_REPLICA_SCHEMA;
}
function getReportReplicaAppSchemaName() {
return normalizeIdentifierText(process.env.CLOUD_DB_APP_SCHEMA || DEFAULT_REPORT_REPLICA_APP_SCHEMA) || DEFAULT_REPORT_REPLICA_APP_SCHEMA;
}
function isReportReplicaRuntime(runtimeInfo = {}) {
const mode = String(process.env.CLOUD_DB_REPORT_REWRITE || 'auto').trim().toLowerCase();
if (['0', 'false', 'off', 'disabled'].includes(mode)) {
return false;
}
if (['1', 'true', 'on', 'enabled', 'force'].includes(mode)) {
return true;
}
return normalizeIdentifierText(runtimeInfo.database).toLowerCase() === getReportReplicaDatabaseName().toLowerCase();
}
function rewriteReportReplicaObjectName(match, tableName) {
return `${quoteMssqlIdentifier(getReportReplicaSchemaName())}.${tableName.startsWith('[') ? tableName : quoteMssqlIdentifier(tableName)}`;
}
const REPORT_REPLICA_UNQUALIFIED_SOURCE_TABLES = Object.freeze([
'customer_service_evaluation',
'fys_shopInfo',
'fys_SkuDataLibrary',
'fys_SkuDataLibraryNew',
'fys_SkuDataLibrary_SpuConfig',
'jst_goods_profit_kpi_items_afs',
'jst_inventoryInfo',
'jst_order_express_details',
'jst_qmcust_refundlist',
'jst_refund_pdd_fz_view',
'jst_returnAndRefundInfo',
'jst_sku',
'jst_skumap',
'jst_skumap_pdd',
'jst_skumap_qmcust_add',
'jst_warehouseInfo',
'op_GoodsIdBarcodeAnalysisDaily',
'op_LeafSkuFreight30dDaily',
'op_TargetManagement',
'pdd_DataGoods',
'pdd_Evaluation',
'pdd_ReportGoodsSpread',
'pc_kfChatLog',
'TmallImportBatch',
'TmallSrcBundle',
'TmallSrcBusinessRoi',
'TmallSrcOverall',
]);
function buildReportReplicaProductCodeExpression(alias) {
return `
CASE
WHEN ${alias}.other_1 IS NULL OR LTRIM(RTRIM(${alias}.other_1)) = N'' THEN NULL
WHEN CHARINDEX(N'K', ${alias}.other_1) > 0 THEN LEFT(${alias}.other_1, CHARINDEX(N'K', ${alias}.other_1) - 1)
ELSE ${alias}.other_1
END`;
}
function buildReportReplicaSaleStatusExpression(alias, costAlias = null) {
const fallbackStatus = `
CASE
WHEN ISNULL(${alias}.enabled, 0) = 1 AND ISNULL(${alias}.stock_disabled, 0) = 0 THEN N'销售'
WHEN ISNULL(${alias}.enabled, 0) = -1 OR ISNULL(${alias}.stock_disabled, 0) = 1 THEN N'停用'
ELSE N'未配置'
END`;
if (!costAlias) {
return fallbackStatus;
}
return `
COALESCE(
NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), ${costAlias}.skuStatus))), N''),
${fallbackStatus}
)`;
}
function buildSkuDataLibraryCompatSql() {
const schema = quoteMssqlIdentifier(getReportReplicaSchemaName());
const productCodeSql = buildReportReplicaProductCodeExpression('s');
const saleStatusSql = buildReportReplicaSaleStatusExpression('s', 'c');
return `(
SELECT
ROW_NUMBER() OVER (ORDER BY s.sku_id) AS Id,
s.sku_id AS SkuCode,
s.name AS SkuName,
s.supplier_name AS Supplier,
${productCodeSql} AS ItemNum,
s.created AS CreationTime,
s.created AS created,
${saleStatusSql} AS ProStatus,
${saleStatusSql} AS Status,
COALESCE(NULLIF(LTRIM(RTRIM(s.pic_big)), N''), NULLIF(LTRIM(RTRIM(s.pic)), N''), NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(500), c.pictureSkucode))), N'')) AS Picture,
s.category AS CategoryOne,
s.category AS CategoryTwo,
COALESCE(NULLIF(LTRIM(RTRIM(s.other_10)), N''), NULLIF(LTRIM(RTRIM(s.other_2)), N'')) AS materialcode,
COALESCE(
NULLIF(TRY_CONVERT(DECIMAL(18, 4), c.cleanCost), 0),
NULLIF(TRY_CONVERT(DECIMAL(18, 4), c.costPrice), 0),
NULLIF(TRY_CONVERT(DECIMAL(18, 4), s.cost_price), 0),
NULLIF(TRY_CONVERT(DECIMAL(18, 4), s.purchase_price), 0)
) AS CostPrice,
COALESCE(NULLIF(TRY_CONVERT(DECIMAL(18, 4), s.purchase_price), 0), NULLIF(TRY_CONVERT(DECIMAL(18, 4), s.cost_price), 0), NULLIF(TRY_CONVERT(DECIMAL(18, 4), c.costPrice), 0)) AS ErpPrice,
COALESCE(NULLIF(TRY_CONVERT(DECIMAL(18, 4), s.sale_price), 0), NULLIF(TRY_CONVERT(DECIMAL(18, 4), s.market_price), 0)) AS ActualPrice,
s.color AS Color,
s.properties_value AS SkuSize,
CAST(NULL AS NVARCHAR(255)) AS Model,
s.unit AS PackSize,
s.l AS Length,
s.w AS Width,
s.h AS Height,
COALESCE(NULLIF(TRY_CONVERT(DECIMAL(18, 4), c.volume), 0), TRY_CONVERT(DECIMAL(18, 4), s.pack_volume)) AS Volume,
COALESCE(NULLIF(TRY_CONVERT(DECIMAL(18, 4), c.weight), 0), TRY_CONVERT(DECIMAL(18, 4), s.weight)) AS Weight,
s.other_3 AS PurchaseType
FROM ${schema}.${quoteMssqlIdentifier('jst_sku')} s
OUTER APPLY (
SELECT TOP 1
costPrice,
volume,
weight,
skuStatus,
createTime,
cleanCost,
pictureSkucode
FROM ${schema}.${quoteMssqlIdentifier('fys_skuCostPriceUpdate')} c
WHERE UPPER(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), c.sku)))) = UPPER(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), s.sku_id))))
ORDER BY c.createTime DESC
) c
)`;
}
function buildSkuDimensionCompatSql() {
const schema = quoteMssqlIdentifier(getReportReplicaSchemaName());
const productCodeSql = buildReportReplicaProductCodeExpression('s');
const saleStatusSql = buildReportReplicaSaleStatusExpression('s');
return `(
SELECT
s.sku_id AS skucode,
${productCodeSql} AS procode,
s.name AS skuname,
COALESCE(NULLIF(LTRIM(RTRIM(s.pic_big)), N''), NULLIF(LTRIM(RTRIM(s.pic)), N'')) AS pic_url,
${saleStatusSql} AS pro_status,
COALESCE(NULLIF(TRY_CONVERT(DECIMAL(18, 4), s.cost_price), 0), NULLIF(TRY_CONVERT(DECIMAL(18, 4), s.purchase_price), 0)) AS cost_price,
s.created AS created,
s.supplier_name AS supplier,
${productCodeSql} AS spu,
s.category AS category,
1 AS status
FROM ${schema}.${quoteMssqlIdentifier('jst_sku')} s
)`;
}
function buildSpuPrincipalCompatSql() {
const schema = quoteMssqlIdentifier(getReportReplicaSchemaName());
return `(
SELECT
v.spu,
v.designer,
v.purchaser,
v.oper,
CAST(NULL AS DATETIME) AS created
FROM ${schema}.${quoteMssqlIdentifier('V_caterAndOper')} v
)`;
}
function buildEmptyBasicIdRankCompatSql() {
return `(
SELECT
CAST(NULL AS DATE) AS stat_date,
CAST(NULL AS NVARCHAR(255)) AS shop,
CAST(NULL AS NVARCHAR(255)) AS item_id,
CAST(NULL AS DECIMAL(38, 6)) AS payment,
CAST(NULL AS DECIMAL(38, 6)) AS pay_buyer_num,
CAST(NULL AS DECIMAL(38, 6)) AS item_visitor_num,
CAST(NULL AS DECIMAL(38, 6)) AS add_purchase_num,
CAST(NULL AS DECIMAL(38, 6)) AS search_guide_views_num
WHERE 1 = 0
)`;
}
function buildEmptyBasicIdPromoteCompatSql() {
return `(
SELECT
CAST(NULL AS DATE) AS stat_date,
CAST(NULL AS NVARCHAR(255)) AS shopname,
CAST(NULL AS NVARCHAR(255)) AS item_id,
CAST(NULL AS DECIMAL(38, 6)) AS cost_charge,
CAST(NULL AS DECIMAL(38, 6)) AS sup_charge,
CAST(NULL AS DECIMAL(38, 6)) AS strategy_ids,
CAST(NULL AS DECIMAL(38, 6)) AS strategy_keywords,
CAST(NULL AS DECIMAL(38, 6)) AS strategy_person,
CAST(NULL AS DECIMAL(38, 6)) AS strategy_scene
WHERE 1 = 0
)`;
}
function rewriteSourceObject(sqlText, objectNames, replacementSql, defaultAlias) {
const names = Array.isArray(objectNames) ? objectNames : [objectNames];
const tablePattern = names.map((name) => escapeRegExp(name)).join('|');
const schemaPattern = escapeRegExp(quoteMssqlIdentifier(getReportReplicaSchemaName()));
const sourcePattern = new RegExp(
`\\b(FROM|JOIN)\\s+(?:${schemaPattern}\\s*\\.\\s*)?\\[(?:${tablePattern})\\](\\s+(?!(?:WITH|ON|WHERE|INNER|LEFT|RIGHT|FULL|CROSS|JOIN|GROUP|ORDER|UNION|HAVING|OUTER|APPLY)\\b)(?:AS\\s+)?[A-Za-z_][\\w$#]*)?(\\s+WITH\\s*\\([^)]*\\))?`,
'giu'
);
const bareSourcePattern = new RegExp(
`\\b(FROM|JOIN)\\s+(?:${tablePattern})(\\s+(?!(?:WITH|ON|WHERE|INNER|LEFT|RIGHT|FULL|CROSS|JOIN|GROUP|ORDER|UNION|HAVING|OUTER|APPLY)\\b)(?:AS\\s+)?[A-Za-z_][\\w$#]*)?(\\s+WITH\\s*\\([^)]*\\))?`,
'giu'
);
const replaceSource = (_match, keyword, aliasText = '') => `${keyword} ${replacementSql}${aliasText || ` AS ${defaultAlias}`}`;
return sqlText.replace(sourcePattern, replaceSource).replace(bareSourcePattern, replaceSource);
}
function rewriteUnqualifiedReportReplicaSourceObjects(sqlText) {
const tablePattern = REPORT_REPLICA_UNQUALIFIED_SOURCE_TABLES
.map((name) => escapeRegExp(name))
.join('|');
if (!tablePattern) {
return sqlText;
}
const sourcePattern = new RegExp(
`\\b(FROM|JOIN)\\s+(${tablePattern})(?=\\s|$|;|,)`,
'giu'
);
return sqlText.replace(sourcePattern, (_match, keyword, tableName) => (
`${keyword} ${quoteMssqlIdentifier(getReportReplicaSchemaName())}.${quoteMssqlIdentifier(tableName)}`
));
}
function rewriteReportReplicaWarehouseSchemaSources(sqlText) {
const sourcePattern = /\b(FROM|JOIN)\s+\[?(?:dws|ads|dim|ods|dwm|dwd)\]?\s*\.\s*(\[[^\]]+\]|[A-Za-z_][\w$#]*)(?=\s|$|;|,)/giu;
return sqlText.replace(sourcePattern, (_match, keyword, tableName) => (
`${keyword} ${rewriteReportReplicaObjectName(null, tableName)}`
));
}
function applyReportReplicaCompatibilitySources(sqlText) {
let rewritten = sqlText;
rewritten = rewriteSourceObject(
rewritten,
['fys_SkuDataLibraryNew', 'fys_SkuDataLibrary'],
buildSkuDataLibraryCompatSql(),
'skuLibraryCompat'
);
rewritten = rewriteSourceObject(
rewritten,
'dim_ope_sku_data_df',
buildSkuDimensionCompatSql(),
'skuDimensionCompat'
);
rewritten = rewriteSourceObject(
rewritten,
['fys_SkuDataLibrary_SpuConfig', 'dim_ope_spu_data_df', 'op_spuPrincipal_log'],
buildSpuPrincipalCompatSql(),
'spuPrincipalCompat'
);
rewritten = rewriteSourceObject(
rewritten,
'dwd_ope_basic_id_rank_di',
buildEmptyBasicIdRankCompatSql(),
'basicIdRankCompat'
);
rewritten = rewriteSourceObject(
rewritten,
'dwd_ope_basic_id_promote_di',
buildEmptyBasicIdPromoteCompatSql(),
'basicIdPromoteCompat'
);
return rewritten;
}
function rewriteReportReplicaSql(sqlText, runtimeInfo = {}) {
if (!isReportReplicaRuntime(runtimeInfo) || typeof sqlText !== 'string') {
return sqlText;
}
let rewritten = sqlText;
rewritten = rewriteReportReplicaWarehouseSchemaSources(rewritten);
rewritten = rewriteUnqualifiedReportReplicaSourceObjects(rewritten);
rewritten = rewritten.replace(
/\[(?:JianSun|JSDataMiddlePlatform|JSDataWarehouse)\]\s*\.\s*\[(?:dbo|dws|ads|dim|ods|dwm|dwd)\]\s*\.\s*(\[[^\]]+\])/giu,
rewriteReportReplicaObjectName
);
rewritten = rewritten.replace(
/\b(?:JianSun|JSDataMiddlePlatform|JSDataWarehouse)\s*\.\s*(?:dbo|dws|ads|dim|ods|dwm|dwd)\s*\.\s*(\[[^\]]+\]|[A-Za-z_][\w$#]*)/giu,
rewriteReportReplicaObjectName
);
rewritten = rewritten.replace(
/\[(?:JianSun|JSDataMiddlePlatform|JSDataWarehouse)\]\s*\.\s*INFORMATION_SCHEMA\s*\.\s*COLUMNS/giu,
'INFORMATION_SCHEMA.COLUMNS'
);
rewritten = rewritten.replace(
/\b(?:JianSun|JSDataMiddlePlatform|JSDataWarehouse)\s*\.\s*INFORMATION_SCHEMA\s*\.\s*COLUMNS/giu,
'INFORMATION_SCHEMA.COLUMNS'
);
rewritten = rewritten.replace(
/\[(?:dbo)\]\s*\.\s*(\[[^\]]+\])/giu,
rewriteReportReplicaObjectName
);
rewritten = rewritten.replace(
/\bdbo\s*\.\s*(\[[^\]]+\]|[A-Za-z_][\w$#]*)/giu,
rewriteReportReplicaObjectName
);
rewritten = rewritten.replace(
/\[(?:dws|ads|dim|ods)\]\s*\.\s*(\[(?:dws|ads|dim|ods)_[^\]]+\])/giu,
rewriteReportReplicaObjectName
);
rewritten = rewritten.replace(
/\b(?:dws|ads|dim|ods)\s*\.\s*((?:dws|ads|dim|ods)_[A-Za-z_][\w$#]*|\[(?:dws|ads|dim|ods)_[^\]]+\])/giu,
rewriteReportReplicaObjectName
);
rewritten = rewritten.replace(
/(TABLE_SCHEMA\s*=\s*)N?(['"])(?:dbo|dws|ads|dim|ods|dwm|dwd)\2/giu,
(_match, prefix, quote) => `${prefix}${quote}${getReportReplicaSchemaName()}${quote}`
);
return applyReportReplicaCompatibilitySources(rewritten);
}
function normalizeRuntimeProfile(value) {
const profile = String(value || process.env.CLOUD_DB_RUNTIME_PROFILE || 'auto').trim().toLowerCase();
if (['local', 'public', 'auto'].includes(profile)) {
return profile;
}
return 'auto';
}
function getConfiguredLocalServerIps() {
return [
process.env.CLOUD_DB_LOCAL_SERVER_IPS,
process.env.CLOUD_DB_LOCAL_SERVER_IP,
DEFAULT_CLOUD_LOCAL_SERVER_IP,
]
.filter(Boolean)
.join(',')
.split(',')
.map((item) => item.trim())
.filter(Boolean);
}
function getLocalIpAddresses() {
const manualIps = [
process.env.CLOUD_DB_SERVER_IP,
process.env.SERVER_PRIVATE_IP,
process.env.SERVER_IP,
process.env.HOST_IP,
]
.filter(Boolean)
.flatMap((item) => String(item).split(','))
.map((item) => item.trim())
.filter(Boolean);
const networkIps = Object.values(os.networkInterfaces() || {})
.flat()
.filter((item) => item && item.family === 'IPv4')
.map((item) => item.address)
.filter(Boolean);
return Array.from(new Set([...manualIps, ...networkIps]));
}
function isCloudLocalServer() {
const localServerIps = new Set(getConfiguredLocalServerIps());
return getLocalIpAddresses().some((ip) => localServerIps.has(ip));
}
function getCloudProfileOrder() {
const runtimeProfile = normalizeRuntimeProfile();
if (runtimeProfile === 'local') {
return ['local', 'public'];
}
if (runtimeProfile === 'public') {
return ['public', 'local'];
}
return isCloudLocalServer() ? ['local', 'public'] : ['public', 'local'];
}
function normalizeMssqlConfig(rawConfig, meta = {}) {
if (!rawConfig) {
return null;
}
const driver = String(rawConfig.driver || 'mssql').trim().toLowerCase();
if (driver && driver !== 'mssql') {
throw new Error('业务数据库当前仅支持 SQL Server');
}
const host = String(rawConfig.host || '').trim();
const databaseName = String(rawConfig.database || rawConfig.database_name || '').trim();
const user = String(rawConfig.user || rawConfig.username || '').trim();
if (!host || !databaseName || !user) {
return null;
}
const port = Number.parseInt(rawConfig.port || 1433, 10) || 1433;
const ssl = parseBoolean(rawConfig.ssl, false);
return {
mssqlConfig: {
server: host,
port,
database: databaseName,
user,
password: rawConfig.password || '',
options: {
encrypt: ssl,
trustServerCertificate: true,
connectTimeout: DEFAULT_CONNECT_TIMEOUT_MS,
requestTimeout: DEFAULT_REQUEST_TIMEOUT_MS,
// 业务库的 DATETIME 存的是北京时间(无时区标记)。mssql 驱动默认 useUTC=true 会把它
// 当成 UTC 解析,而本项目所有时间格式化都是用本地 getter 还原墙上时间,两者叠加会让
// 显示值整体 +8 小时(例如库里 16:56:07 被显示成次日 00:56:07)。按本地时间解析才对得上。
useUTC: false,
},
pool: {
max: CLOUD_DB_POOL_MAX,
min: 0,
idleTimeoutMillis: CLOUD_DB_POOL_IDLE_TIMEOUT_MS,
},
},
runtimeInfo: {
...meta,
driver,
host,
port,
database: databaseName,
username: user,
ssl,
},
};
}
function getEnvBusinessDatabaseConfig(source = process.env, profile = 'default') {
const envKeys = profile === 'public' ? PUBLIC_ENV_KEYS : DEFAULT_ENV_KEYS;
return {
driver: getFirstEnvValue(source, envKeys.driver, 'mssql'),
host: getFirstEnvValue(source, envKeys.host),
port: Number.parseInt(getFirstEnvValue(source, envKeys.port, '1433'), 10) || 1433,
database: getFirstEnvValue(source, envKeys.database),
user: getFirstEnvValue(source, envKeys.user),
password: getFirstEnvValue(source, envKeys.password),
ssl: parseBoolean(getFirstEnvValue(source, envKeys.ssl), false),
};
}
function hasCompleteEnvConfig(source = process.env, profile = 'default') {
const config = getEnvBusinessDatabaseConfig(source, profile);
return Boolean(config.host && config.database && config.user);
}
function getEnvBusinessDatabaseSummary(source = process.env, profile = 'default') {
const config = getEnvBusinessDatabaseConfig(source, profile);
return {
available: hasCompleteEnvConfig(source, profile),
driver: config.driver,
host: config.host || '',
port: config.port || 1433,
database: config.database || '',
username: config.user || '',
ssl: Boolean(config.ssl),
};
}
function buildMssqlPoolConfigFromEnv(source = process.env, profile = 'default') {
return normalizeMssqlConfig(getEnvBusinessDatabaseConfig(source, profile), {
source: 'env',
configName: profile === 'public' ? CLOUD_DATABASE_PUBLIC_CONFIG_NAME : CLOUD_DATABASE_CONFIG_NAME,
selectedProfile: profile === 'public' ? 'public' : 'local',
});
}
async function createSystemConfigPool() {
const systemDbConfig = getSystemDbRuntimeConfig();
return mysql.createPool({
host: systemDbConfig.host || 'localhost',
port: parseInt(systemDbConfig.port || '3306', 10),
user: systemDbConfig.username || 'root',
password: systemDbConfig.password,
database: systemDbConfig.database || 'shujuku2_system',
charset: 'utf8mb4',
waitForConnections: true,
connectionLimit: LOCAL_CONFIG_DB_CONNECTION_LIMIT,
maxIdle: LOCAL_CONFIG_DB_CONNECTION_LIMIT,
idleTimeout: 60000,
queueLimit: 10,
connectTimeout: LOCAL_CONFIG_DB_CONNECT_TIMEOUT_MS,
enableKeepAlive: true,
keepAliveInitialDelay: 0,
});
}
async function loadStoredConfigFromDb(localPool, configName) {
let rows = [];
if (configName === CLOUD_DATABASE_CONFIG_NAME) {
[rows] = await localPool.query(
'SELECT name, host, port, database_name, username, password, `ssl`, driver, use_env_config, is_default FROM database_configs WHERE name = ? AND status = ? LIMIT 1',
[configName, 'active']
);
if (rows.length === 0) {
[rows] = await localPool.query(
'SELECT name, host, port, database_name, username, password, `ssl`, driver, use_env_config, is_default FROM database_configs WHERE is_default = 1 AND status = ? LIMIT 1',
['active']
);
}
} else {
[rows] = await localPool.query(
'SELECT name, host, port, database_name, username, password, `ssl`, driver, use_env_config, is_default FROM database_configs WHERE name = ? AND status = ? LIMIT 1',
[configName, 'active']
);
}
const config = rows[0];
if (!config) {
return null;
}
return normalizeMssqlConfig(config, {
source: 'system_database',
configName: config.name || configName,
selectedProfile: configName === CLOUD_DATABASE_PUBLIC_CONFIG_NAME ? 'public' : 'local',
useEnvConfig: isTruthy(config.use_env_config),
isDefault: isTruthy(config.is_default),
});
}
async function loadCandidateConfig(localPool, profile) {
if (profile === 'local') {
const stored = await loadStoredConfigFromDb(localPool, CLOUD_DATABASE_CONFIG_NAME);
if (stored?.runtimeInfo?.useEnvConfig) {
if (hasCompleteEnvConfig(process.env, 'default')) {
console.log('[CloudDB] 使用 .env 业务数据库配置(CLOUD_DB_*)');
return buildMssqlPoolConfigFromEnv(process.env, 'default');
}
console.warn('[CloudDB] 已开启 .env 业务数据库配置,但 CLOUD_DB_* 不完整,继续使用 database_configs 表配置');
}
return stored || buildMssqlPoolConfigFromEnv(process.env, 'default');
}
const stored = await loadStoredConfigFromDb(localPool, CLOUD_DATABASE_PUBLIC_CONFIG_NAME);
if (stored?.runtimeInfo?.useEnvConfig) {
if (hasCompleteEnvConfig(process.env, 'public')) {
console.log('[CloudDB] 使用 .env 公网业务数据库配置(CLOUD_DB_PUBLIC_*)');
return buildMssqlPoolConfigFromEnv(process.env, 'public');
}
console.warn('[CloudDB] 已开启 .env 公网业务数据库配置,但 CLOUD_DB_PUBLIC_* 不完整,继续使用 database_configs 表配置');
}
return stored || buildMssqlPoolConfigFromEnv(process.env, 'public');
}
async function resolveCloudConfigCandidates() {
// 缓存有效判据:**非 null** 且在有效期内。
//
// 旧实现是 `if (cachedCloudConfigCandidates)`,而 [] 在 JS 里是 truthy —— 一旦系统库
// 读取失败一次(隧道抖动),就会把空数组当作合法缓存永久返回,之后永不重试,
// 直接抛「业务数据库尚未配置」500,直到重启后端。这是典型的「永久负缓存」反模式。
//
// 现在:成功态 expiresAt = Infinity(靠 resetPool 显式失效);失败态带 30s TTL,
// 到期自动重新解析,隧道恢复后无需人工干预即可自愈。
if (cachedCloudConfigCandidates !== null && Date.now() < cachedCloudConfigExpiresAt) {
return cachedCloudConfigCandidates;
}
let localPool;
try {
localPool = await createSystemConfigPool();
const profileOrder = getCloudProfileOrder();
const candidates = [];
const seen = new Set();
for (const profile of profileOrder) {
const candidate = await loadCandidateConfig(localPool, profile);
if (!candidate?.mssqlConfig) {
continue;
}
const dedupeKey = [
candidate.runtimeInfo.selectedProfile,
candidate.runtimeInfo.source,
candidate.runtimeInfo.host,
candidate.runtimeInfo.port,
candidate.runtimeInfo.database,
candidate.runtimeInfo.username,
].join('::');
if (seen.has(dedupeKey)) {
continue;
}
seen.add(dedupeKey);
candidates.push(candidate);
}
cachedCloudConfigCandidates = candidates;
cachedCloudConfigExpiresAt = Number.POSITIVE_INFINITY;
return cachedCloudConfigCandidates;
} catch (error) {
console.warn('[CloudDB] 无法从数据库读取配置:', error.message);
} finally {
if (localPool) {
await localPool.end().catch((error) => {
console.warn('[CloudDB] 关闭本地配置库临时连接池失败:', error.message);
});
}
}
// 失败态:按需回退到环境变量配置,并把结果记为「带 TTL 的负缓存」。
const fallbacks = [];
if (hasCompleteEnvConfig(process.env, 'default')) {
console.log('[CloudDB] 使用环境变量配置(CLOUD_DB_*)');
fallbacks.push(buildMssqlPoolConfigFromEnv(process.env, 'default'));
}
if (hasCompleteEnvConfig(process.env, 'public')) {
console.log('[CloudDB] 使用环境变量配置(CLOUD_DB_PUBLIC_*)');
fallbacks.push(buildMssqlPoolConfigFromEnv(process.env, 'public'));
}
cachedCloudConfigCandidates = fallbacks;
cachedCloudConfigExpiresAt = Date.now() + NEGATIVE_CONFIG_CACHE_TTL_MS;
console.warn(
`[CloudDB] 系统库配置暂不可用(${fallbacks.length > 0 ? '暂用环境变量兜底' : '且无环境变量兜底'}),`
+ `${NEGATIVE_CONFIG_CACHE_TTL_MS}ms 后自动重试`
);
return cachedCloudConfigCandidates;
}
async function connectWithCandidate(candidate) {
const nextPool = new sql.ConnectionPool(candidate.mssqlConfig);
await nextPool.connect();
pool = nextPool;
activeCloudConfig = candidate;
return pool;
}
async function queryWithCandidateConnection(candidate, sqlText, params = {}, options = {}) {
const nextPool = new sql.ConnectionPool(candidate.mssqlConfig);
try {
await nextPool.connect();
const resolvedSqlText = rewriteReportReplicaSql(sqlText, candidate.runtimeInfo);
const request = nextPool.request();
const requestTimeout = parsePositiveInt(options.requestTimeout, 0);
if (requestTimeout > 0) {
request.timeout = requestTimeout;
}
if (params && typeof params === 'object') {
Object.entries(params).forEach(([key, value]) => {
request.input(key, value);
});
}
const result = await request.query(resolvedSqlText);
return result.recordset;
} finally {
await nextPool.close().catch((error) => {
console.warn('[CloudDB] 关闭临时连接失败:', error.message);
});
}
}
function prioritizeCandidates(candidates = [], preferredProfiles = []) {
if (!Array.isArray(candidates) || candidates.length <= 1 || !preferredProfiles.length) {
return candidates;
}
const preferred = [];
const fallback = [];
const normalizedProfiles = preferredProfiles
.map((profile) => String(profile || '').trim().toLowerCase())
.filter(Boolean);
for (const candidate of candidates) {
const profile = String(candidate?.runtimeInfo?.selectedProfile || '').trim().toLowerCase();
if (normalizedProfiles.includes(profile)) {
preferred.push(candidate);
} else {
fallback.push(candidate);
}
}
return [...preferred, ...fallback];
}
async function verifyCandidate(candidate, options = {}) {
if (!options.verifySql) {
return;
}
const rows = await queryWithCandidateConnection(
candidate,
options.verifySql,
options.verifyParams || {},
options.verifyOptions || {}
);
if (typeof options.verifyResult === 'function') {
const verification = await options.verifyResult(rows, candidate.runtimeInfo);
if (verification === false) {
throw new Error(options.verifyFailureMessage || '业务数据库候选验证失败');
}
if (typeof verification === 'string' && verification.trim()) {
throw new Error(verification.trim());
}
}
}
function createQueryExecutorForCandidate(candidate) {
let candidatePool = null;
const ensureCandidatePool = async () => {
if (isPoolUsable(candidatePool)) {
return candidatePool;
}
if (candidatePool) {
try {
await candidatePool.close();
} catch (error) {
console.warn('[CloudDB] 关闭固定候选连接池失败:', error.message);
} finally {
candidatePool = null;
}
}
candidatePool = new sql.ConnectionPool(candidate.mssqlConfig);
await candidatePool.connect();
return candidatePool;
};
const closeCandidatePool = async () => {
if (!candidatePool) {
return;
}
try {
await candidatePool.close();
} catch (error) {
console.warn('[CloudDB] 关闭固定候选连接池失败:', error.message);
} finally {
candidatePool = null;
}
};
return {
runtimeInfo: candidate.runtimeInfo,
async query(sqlText, params = {}, options = {}) {
const maxAttempts = parsePositiveInt(options.maxAttempts, 2);
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
try {
const activePool = await ensureCandidatePool();
const resolvedSqlText = rewriteReportReplicaSql(sqlText, candidate.runtimeInfo);
const request = activePool.request();
const requestTimeout = parsePositiveInt(options.requestTimeout, 0);
if (requestTimeout > 0) {
request.timeout = requestTimeout;
}
if (params && typeof params === 'object') {
Object.entries(params).forEach(([key, value]) => {
request.input(key, value);
});
}
const result = await request.query(resolvedSqlText);
return result.recordset;
} catch (error) {
const shouldRetry = attempt < maxAttempts && isTransientError(error);
if (!shouldRetry) {
error.cloudDbRuntime = candidate.runtimeInfo;
throw error;
}
console.warn(
`[CloudDB] 固定候选查询失败,准备重连后重试 (${attempt}/${maxAttempts}): ${error.message}`
);
await closeCandidatePool();
}
}
return [];
},
async close() {
await closeCandidatePool();
},
};
}
async function createPinnedQueryClient(options = {}) {
const candidates = prioritizeCandidates(
await resolveCloudConfigCandidates(),
[
options.preferredProfile,
...(Array.isArray(options.preferredProfiles) ? options.preferredProfiles : []),
]
);
if (!Array.isArray(candidates) || candidates.length === 0) {
throw new Error(NOT_CONFIGURED_MESSAGE);
}
const errors = [];
for (const candidate of candidates) {
try {
await verifyCandidate(candidate, options);
return createQueryExecutorForCandidate(candidate);
} catch (error) {
errors.push(error);
console.warn(
`[CloudDB] 固定候选 ${candidate.runtimeInfo.selectedProfile} 验证失败: ${error.message}`
);
}
}
const lastError = errors[errors.length - 1];
throw lastError || new Error(NOT_CONFIGURED_MESSAGE);
}
/**
* 获取连接池
*/
const getPool = async () => {
if (isPoolUsable(pool)) {
return pool;
}
if (pool) {
console.warn('[CloudDB] 检测到失效连接池,准备重建连接');
await resetPool();
}
const candidates = await resolveCloudConfigCandidates();
if (!Array.isArray(candidates) || candidates.length === 0) {
throw new Error(NOT_CONFIGURED_MESSAGE);
}
const errors = [];
for (const candidate of candidates) {
try {
console.log(
`[CloudDB] 尝试连接 ${candidate.runtimeInfo.selectedProfile} 配置: `
+ `${candidate.runtimeInfo.host}:${candidate.runtimeInfo.port}/${candidate.runtimeInfo.database}`
);
await connectWithCandidate(candidate);
console.log('[CloudDB] MSSQL 云数据库连接成功');
return pool;
} catch (error) {
errors.push(error);
console.warn(`[CloudDB] ${candidate.runtimeInfo.selectedProfile} 配置连接失败: ${error.message}`);
}
}
const lastError = errors[errors.length - 1];
console.error('[CloudDB] MSSQL 连接失败:', lastError?.message || NOT_CONFIGURED_MESSAGE);
throw lastError || new Error(NOT_CONFIGURED_MESSAGE);
};
/**
* 统一失效「云配置候选」缓存。
*
* 抽成单点:数组与负缓存 TTL 时间戳必须同时清,只清数组会让陈旧的 expiresAt 残留,
* 下次读取时 `Date.now() < expiresAt` 仍可能成立 → 又退化回「负缓存不失效」。
*/
function invalidateCloudConfigCache() {
cachedCloudConfigCandidates = null;
cachedCloudConfigExpiresAt = 0;
}
const resetPool = async () => {
if (!pool) {
activeCloudConfig = null;
invalidateCloudConfigCache();
return;
}
try {
await pool.close();
} catch (error) {
console.warn('[CloudDB] 关闭异常连接池失败:', error.message);
} finally {
pool = null;
activeCloudConfig = null;
invalidateCloudConfigCache();
}
};
/**
* 执行 SQL 查询
* @param {string} sqlText - SQL 语句
* @param {object} params - 命名参数对象
*/
const query = async (sqlText, params = {}, options = {}) => {
const maxAttempts = parsePositiveInt(options.maxAttempts, 2);
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
try {
const activePool = await getPool();
const resolvedSqlText = rewriteReportReplicaSql(sqlText, activeCloudConfig?.runtimeInfo);
const request = activePool.request();
const requestTimeout = parsePositiveInt(options.requestTimeout, 0);
if (requestTimeout > 0) {
request.timeout = requestTimeout;
}
if (params && typeof params === 'object') {
Object.entries(params).forEach(([key, value]) => {
request.input(key, value);
});
}
const result = await request.query(resolvedSqlText);
return result.recordset;
} catch (error) {
const shouldRetry = attempt < maxAttempts && isTransientError(error);
if (!shouldRetry) {
throw error;
}
console.warn(`[CloudDB] 查询失败,准备重连后重试 (${attempt}/${maxAttempts}): ${error.message}`);
await resetPool();
}
}
return [];
};
const queryWithFreshConnection = async (sqlText, params = {}, options = {}) => {
const maxAttempts = parsePositiveInt(options.maxAttempts, 2);
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
const candidates = await resolveCloudConfigCandidates();
if (!Array.isArray(candidates) || candidates.length === 0) {
// 候选为空 = 本轮配置解析失败,且没有任何环境变量兜底。
//
// 这里刻意**不**清缓存:失败态已由 resolveCloudConfigCandidates 写成「带 TTL 的负缓存」。
// TTL 内的后续请求快速失败(不必每个请求都去撞系统库的慢超时),到期自动重新解析 ——
// 隧道恢复后无需重启后端。旧实现把这次失败永久固化,正是「必须重启后端」的根因。
//
// 注:getPool() 里的同款分支行为与此保持一致,两条路径统一由 TTL 负责恢复;
// 「坏连接池」由 query() 的 catch → resetPool() 负责,各司其职。
throw new Error(NOT_CONFIGURED_MESSAGE);
}
let lastError = null;
for (const candidate of candidates) {
try {
return await queryWithCandidateConnection(candidate, sqlText, params, options);
} catch (error) {
lastError = error;
}
}
const shouldRetry = attempt < maxAttempts && isTransientError(lastError);
if (!shouldRetry) {
throw lastError || new Error(NOT_CONFIGURED_MESSAGE);
}
}
return [];
};
/**
* 测试连接
*/
const test = async () => {
try {
// 「测试连接」是用户在系统设置里验证配置的入口,必须读最新配置:
// 先显式失效,避免 30s 内的失败态负缓存让用户看到「刚改好却连不上」。
invalidateCloudConfigCache();
const candidates = await resolveCloudConfigCandidates();
if (!Array.isArray(candidates) || candidates.length === 0) {
return { success: false, message: NOT_CONFIGURED_MESSAGE };
}
const activePool = await getPool();
const result = await activePool.request().query('SELECT 1 as test');
return {
success: true,
message: '业务数据库连接成功',
data: result.recordset,
runtime: activeCloudConfig?.runtimeInfo || null,
};
} catch (error) {
return { success: false, message: `连接失败: ${error.message}` };
}
};
/**
* 关闭连接
*/
const close = async () => {
if (pool) {
await pool.close();
console.log('[CloudDB] 连接已关闭');
}
pool = null;
activeCloudConfig = null;
invalidateCloudConfigCache();
};
/**
* 重新加载配置
*/
const reload = async () => {
await resetPool();
return getPool();
};
module.exports = {
getPool,
query,
queryWithFreshConnection,
createPinnedQueryClient,
test,
close,
reload,
getEnvBusinessDatabaseConfig,
getEnvBusinessDatabaseSummary,
hasCompleteEnvConfig,
rewriteReportReplicaSql,
getReportReplicaDatabaseName,
getReportReplicaSchemaName,
getReportReplicaAppSchemaName,
CLOUD_DATABASE_CONFIG_NAME,
CLOUD_DATABASE_PUBLIC_CONFIG_NAME,
};