const os = require('os'); const Redis = require('ioredis'); const database = require('../config/database'); const DEFAULT_REDIS_CONFIG = { host: '', port: 6379, db: 0, hasPassword: false, enabled: false, source: 'default', }; const REDIS_PROFILE_KEYS = { AUTO: 'stored', DEFAULT: 'default', CHANGZHOU: 'changzhou', ENV: 'env', }; const REDIS_PROFILE_ALIASES = { changzhou_local: REDIS_PROFILE_KEYS.CHANGZHOU, stored_auto: REDIS_PROFILE_KEYS.AUTO, }; const REDIS_RUNTIME_PROFILES = { [REDIS_PROFILE_KEYS.AUTO]: { key: REDIS_PROFILE_KEYS.AUTO, label: '自动选择系统库 Redis', description: '系统库保存默认/常州两套 Redis;检测到常州服务器优先常州,否则优先默认,失败后再使用 .env。', }, [REDIS_PROFILE_KEYS.DEFAULT]: { key: REDIS_PROFILE_KEYS.DEFAULT, label: '默认缓存库', description: '强制优先使用系统库中的默认 Redis 配置。', }, [REDIS_PROFILE_KEYS.CHANGZHOU]: { key: REDIS_PROFILE_KEYS.CHANGZHOU, label: '常州缓存库', description: '强制优先使用系统库中的常州 Redis 配置。', }, [REDIS_PROFILE_KEYS.ENV]: { key: REDIS_PROFILE_KEYS.ENV, label: '.env 兜底 Redis', description: '只使用 .env 中的 Redis 配置,通常仅作为系统库两套配置都不可用时的兜底。', }, }; const STORED_PROFILE_LABELS = { [REDIS_PROFILE_KEYS.DEFAULT]: '默认缓存库', [REDIS_PROFILE_KEYS.CHANGZHOU]: '常州缓存库', }; const ENV_MAPPING = { host: 'REDIS_HOST', port: 'REDIS_PORT', username: 'REDIS_USERNAME', password: 'REDIS_PASSWORD', db: 'REDIS_DB', tls: 'REDIS_TLS', tlsServerName: 'REDIS_TLS_SERVER_NAME', }; const DEFAULT_CHANGZHOU_SERVER_IP = '192.168.150.234'; const REDIS_PROFILE_FAILURE_COOLDOWN_MS = parsePositiveInt( process.env.REDIS_PROFILE_FAILURE_COOLDOWN_MS, 30000 ); const REDIS_PROFILE_CONNECT_TIMEOUT_MS = parsePositiveInt( process.env.REDIS_PROFILE_CONNECT_TIMEOUT_MS || process.env.REDIS_CONNECT_TIMEOUT_MS, 800 ); let runtimeRedisProfile = resolveInitialRuntimeProfile(); let redisProfileFailureUntil = new Map(); function parsePositiveInt(value, fallback) { const parsed = Number.parseInt(value, 10); return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; } function normalizePort(value) { const port = Number(value || DEFAULT_REDIS_CONFIG.port); return Number.isFinite(port) && port > 0 ? port : DEFAULT_REDIS_CONFIG.port; } function normalizeDb(value) { const db = Number(value || DEFAULT_REDIS_CONFIG.db); return Number.isFinite(db) && db >= 0 ? db : DEFAULT_REDIS_CONFIG.db; } function normalizeBoolean(value) { return [true, 1, '1', 'true', 'yes', 'on'].includes( typeof value === 'string' ? value.toLowerCase() : value ); } function normalizeRuntimeProfile(value) { const requestedProfile = String(value || '').trim(); if (!requestedProfile) { return REDIS_PROFILE_KEYS.AUTO; } const normalized = REDIS_PROFILE_ALIASES[requestedProfile] || requestedProfile; return REDIS_RUNTIME_PROFILES[normalized] ? normalized : REDIS_PROFILE_KEYS.AUTO; } function normalizeStoredProfileKey(value) { const normalized = normalizeRuntimeProfile(value); if (normalized === REDIS_PROFILE_KEYS.CHANGZHOU) { return REDIS_PROFILE_KEYS.CHANGZHOU; } return REDIS_PROFILE_KEYS.DEFAULT; } function resolveInitialRuntimeProfile() { const requestedProfile = String( process.env.REDIS_RUNTIME_PROFILE || process.env.SHUJUKU_REDIS_RUNTIME_PROFILE || '' ).trim(); const normalizedProfile = normalizeRuntimeProfile(requestedProfile); if (requestedProfile && normalizedProfile === REDIS_PROFILE_KEYS.AUTO && requestedProfile !== REDIS_PROFILE_KEYS.AUTO) { console.warn(`[RedisConfig] REDIS_RUNTIME_PROFILE=${requestedProfile} 不支持,回退到自动选择系统库 Redis`); } return normalizedProfile; } function getConfiguredChangzhouServerIps() { const raw = [ process.env.CHANGZHOU_REDIS_SERVER_IPS, process.env.CHANGZHOU_REDIS_SERVER_IP, DEFAULT_CHANGZHOU_SERVER_IP, ] .filter(Boolean) .join(','); return raw .split(',') .map((item) => item.trim()) .filter(Boolean); } function getLocalIpAddresses() { const manualIps = [ process.env.REDIS_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 isChangzhouServer() { const serverIps = getLocalIpAddresses(); const changzhouIps = new Set(getConfiguredChangzhouServerIps()); return serverIps.some((ip) => changzhouIps.has(ip)); } function getAutoStoredProfileOrder() { return isChangzhouServer() ? [REDIS_PROFILE_KEYS.CHANGZHOU, REDIS_PROFILE_KEYS.DEFAULT] : [REDIS_PROFILE_KEYS.DEFAULT, REDIS_PROFILE_KEYS.CHANGZHOU]; } function getStoredProfileOrder() { if (runtimeRedisProfile === REDIS_PROFILE_KEYS.DEFAULT) { return [REDIS_PROFILE_KEYS.DEFAULT, REDIS_PROFILE_KEYS.CHANGZHOU]; } if (runtimeRedisProfile === REDIS_PROFILE_KEYS.CHANGZHOU) { return [REDIS_PROFILE_KEYS.CHANGZHOU, REDIS_PROFILE_KEYS.DEFAULT]; } return getAutoStoredProfileOrder(); } function mapConfig(config = {}, { includePassword = false, source = 'database' } = {}) { const host = String(config.host || '').trim(); const password = config.password || ''; const explicitEnabled = config.enabled ?? config.is_enabled ?? config.isDefault ?? config.is_default; const enabled = explicitEnabled === undefined ? Boolean(host) : ![false, 0, '0', 'false'].includes(explicitEnabled) && Boolean(host); const profileKey = config.profileKey || config.profile_key || config.key || source; return { ...(config.id ? { id: config.id } : {}), profileKey, profileName: config.profileName || config.profile_name || STORED_PROFILE_LABELS[profileKey] || profileKey, host, port: normalizePort(config.port), username: String(config.username || '').trim(), db: normalizeDb(config.db), tls: normalizeBoolean(config.tls), tlsServerName: String(config.tlsServerName || config.tls_server_name || '').trim(), hasPassword: Boolean(password), enabled, source, ...(includePassword ? { password } : {}), }; } function getEnvRedisConfig(options = {}) { return mapConfig({ profileKey: REDIS_PROFILE_KEYS.ENV, profileName: '.env Redis', host: process.env[ENV_MAPPING.host] || '', port: process.env[ENV_MAPPING.port], username: process.env[ENV_MAPPING.username] || '', db: process.env[ENV_MAPPING.db], password: process.env[ENV_MAPPING.password] || '', tls: process.env[ENV_MAPPING.tls], tlsServerName: process.env[ENV_MAPPING.tlsServerName] || '', enabled: Boolean(String(process.env[ENV_MAPPING.host] || '').trim()), }, { includePassword: Boolean(options.includePassword), source: REDIS_PROFILE_KEYS.ENV, }); } async function getStoredRedisRows() { try { return await database.local.query( `SELECT id, profile_key, profile_name, host, port, password, db, is_default, enabled FROM redis_configs ORDER BY FIELD(profile_key, 'default', 'changzhou'), is_default DESC, id ASC` ); } catch (error) { if (!['ER_NO_SUCH_TABLE', 'ER_BAD_FIELD_ERROR'].includes(error.code)) { console.warn('[RedisConfig] 读取系统库 Redis 配置失败,回退到 .env:', error.message); } if (error.code !== 'ER_BAD_FIELD_ERROR') { return []; } try { const legacyRows = await database.local.query( `SELECT id, host, port, password, db, is_default FROM redis_configs WHERE is_default = 1 ORDER BY id ASC LIMIT 1` ); return legacyRows.map((row) => ({ ...row, profile_key: REDIS_PROFILE_KEYS.DEFAULT, profile_name: STORED_PROFILE_LABELS[REDIS_PROFILE_KEYS.DEFAULT], enabled: row.is_default, })); } catch (legacyError) { if (!['ER_NO_SUCH_TABLE', 'ER_BAD_FIELD_ERROR'].includes(legacyError.code)) { console.warn('[RedisConfig] 读取旧 Redis 配置失败,回退到 .env:', legacyError.message); } return []; } } } async function getStoredRedisConfigs(options = {}) { const includePassword = Boolean(options.includePassword); const rows = await getStoredRedisRows(); const seen = new Set(); return rows.reduce((profiles, row) => { const profileKey = normalizeStoredProfileKey(row.profile_key); if (seen.has(profileKey)) { return profiles; } seen.add(profileKey); profiles.push(mapConfig({ ...row, profileKey, profileName: row.profile_name || STORED_PROFILE_LABELS[profileKey], enabled: row.enabled, }, { includePassword, source: 'database', })); return profiles; }, []); } async function getStoredRedisConfig(profileKey = REDIS_PROFILE_KEYS.DEFAULT, options = {}) { const requestedProfileKey = normalizeStoredProfileKey(profileKey); const profiles = await getStoredRedisConfigs(options); return profiles.find((profile) => profile.profileKey === requestedProfileKey) || null; } async function getRedisConfig(options = {}) { const profiles = await getStoredRedisConfigs(options); const profileMap = new Map(profiles.map((profile) => [profile.profileKey, profile])); const selectedProfile = getStoredProfileOrder() .map((profileKey) => profileMap.get(profileKey)) .find(Boolean); if (selectedProfile) { return { ...selectedProfile, storedProfiles: profiles, serverProfile: getRedisServerProfileInfo(), }; } return { ...getEnvRedisConfig(options), storedProfiles: profiles, serverProfile: getRedisServerProfileInfo(), }; } async function buildRuntimeRedisCandidates(options = {}) { const includePassword = Boolean(options.includePassword); const envConfig = getEnvRedisConfig({ includePassword }); if (runtimeRedisProfile === REDIS_PROFILE_KEYS.ENV) { return [envConfig]; } const profiles = await getStoredRedisConfigs({ includePassword }); const profileMap = new Map(profiles.map((profile) => [profile.profileKey, profile])); const candidates = getStoredProfileOrder() .map((profileKey) => profileMap.get(profileKey)) .filter(Boolean); return [...candidates, envConfig]; } async function getEffectiveRedisConfig(options = {}) { const candidates = await buildRuntimeRedisCandidates(options); const selected = candidates.find((config) => config.enabled) || getEnvRedisConfig(options); return { ...selected, runtimeProfile: runtimeRedisProfile, fallbackOrder: candidates.map((config) => config.profileKey || config.source), serverProfile: getRedisServerProfileInfo(), }; } function getRedisServerProfileInfo() { return { isChangzhouServer: isChangzhouServer(), serverIps: getLocalIpAddresses(), changzhouServerIps: getConfiguredChangzhouServerIps(), autoOrder: getAutoStoredProfileOrder(), }; } function getRedisFailureKey(config) { return `${config.profileKey || config.source}:${config.host}:${config.port}:${config.db}`; } async function probeRedisConnection(config) { const failureKey = getRedisFailureKey(config); const failureUntil = redisProfileFailureUntil.get(failureKey) || 0; if (failureUntil > Date.now()) { return false; } const client = new Redis({ host: config.host, port: normalizePort(config.port), username: config.username || undefined, password: config.password || undefined, db: normalizeDb(config.db), tls: config.tls ? { servername: config.tlsServerName || config.host } : undefined, lazyConnect: true, connectTimeout: REDIS_PROFILE_CONNECT_TIMEOUT_MS, maxRetriesPerRequest: 1, enableOfflineQueue: false, enableReadyCheck: false, retryStrategy: () => null, }); try { await client.connect(); await client.ping(); redisProfileFailureUntil.delete(failureKey); return true; } catch (error) { redisProfileFailureUntil.set(failureKey, Date.now() + REDIS_PROFILE_FAILURE_COOLDOWN_MS); console.warn(`[RedisConfig] ${config.profileName || config.profileKey || config.source} 不可用,尝试下一套: ${error.message}`); return false; } finally { client.disconnect(); } } async function getRuntimeRedisConfig() { const candidates = await buildRuntimeRedisCandidates({ includePassword: true }); const envCandidate = candidates.find((config) => config.source === REDIS_PROFILE_KEYS.ENV); for (const candidate of candidates) { if (!candidate.enabled) { continue; } if (candidate.source === REDIS_PROFILE_KEYS.ENV) { return { host: candidate.host, port: candidate.port, username: candidate.username || undefined, password: candidate.password || undefined, db: candidate.db, tls: candidate.tls, tlsServerName: candidate.tlsServerName, source: candidate.source, }; } if (await probeRedisConnection(candidate)) { return { host: candidate.host, port: candidate.port, username: candidate.username || undefined, password: candidate.password || undefined, db: candidate.db, tls: candidate.tls, tlsServerName: candidate.tlsServerName, source: candidate.profileKey || candidate.source, }; } } if (envCandidate?.enabled) { return { host: envCandidate.host, port: envCandidate.port, username: envCandidate.username || undefined, password: envCandidate.password || undefined, db: envCandidate.db, tls: envCandidate.tls, tlsServerName: envCandidate.tlsServerName, source: envCandidate.source, }; } return null; } async function testRedisConnection(input) { if (!input.host) { throw new Error('请输入 Redis 主机地址'); } const profileKey = normalizeStoredProfileKey(input.profileKey || input.profile); const current = await getStoredRedisConfig(profileKey, { includePassword: true }) || await getRedisConfig({ includePassword: true }); const password = typeof input.password === 'string' && input.password !== '' ? input.password : current.password || ''; const client = new Redis({ host: input.host, port: normalizePort(input.port), password: password || undefined, db: normalizeDb(input.db), lazyConnect: true, connectTimeout: 10000, maxRetriesPerRequest: 1, }); try { await client.connect(); await client.ping(); } finally { try { await client.quit(); } catch (_error) { client.disconnect(); } } } async function saveRedisConfig(input) { const profileKey = normalizeStoredProfileKey(input.profileKey || input.profile); const current = await getStoredRedisConfig(profileKey, { includePassword: true }); const host = typeof input.host === 'string' ? input.host.trim() : ''; const password = typeof input.password === 'string' && input.password !== '' ? input.password : current?.password || ''; const payload = { profile_key: profileKey, profile_name: STORED_PROFILE_LABELS[profileKey], host, port: normalizePort(input.port), password, db: normalizeDb(input.db), enabled: host ? 1 : 0, is_default: profileKey === REDIS_PROFILE_KEYS.DEFAULT ? 1 : 0, }; const existingRows = await database.local.query( 'SELECT id FROM redis_configs WHERE profile_key = ? ORDER BY id ASC LIMIT 1', [profileKey] ); if (existingRows[0]) { await database.local.update('redis_configs', existingRows[0].id, payload); } else { await database.local.insert('redis_configs', payload); } redisProfileFailureUntil = new Map(); return getStoredRedisConfig(profileKey); } function getRedisRuntimeProfiles() { return Object.values(REDIS_RUNTIME_PROFILES).map((profile) => ({ ...profile })); } function getRedisRuntimeProfile() { return runtimeRedisProfile; } async function setRedisRuntimeProfile(profileKey = REDIS_PROFILE_KEYS.AUTO) { runtimeRedisProfile = normalizeRuntimeProfile(profileKey); redisProfileFailureUntil = new Map(); return getEffectiveRedisConfig(); } module.exports = { DEFAULT_REDIS_CONFIG, REDIS_PROFILE_KEYS, getEnvRedisConfig, getEffectiveRedisConfig, getRedisConfig, getRedisRuntimeProfile, getRedisRuntimeProfiles, getRedisServerProfileInfo, getRuntimeRedisConfig, getStoredRedisConfig, getStoredRedisConfigs, saveRedisConfig, setRedisRuntimeProfile, testRedisConnection, };