forked from lunanting/cangku
548 lines
16 KiB
JavaScript
548 lines
16 KiB
JavaScript
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,
|
||
};
|