cangku/backend/src/services/redisConfig.js
2026-09-12 17:26:23 +08:00

548 lines
16 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.

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,
};