Files
Genarrative/server-node/src/config.ts
2026-04-21 10:30:12 +08:00

548 lines
15 KiB
TypeScript

import fs from 'node:fs';
import path from 'node:path';
export type AppConfig = {
nodeEnv: string;
projectRoot: string;
publicDir: string;
logsDir: string;
dataDir: string;
rawEnv: Record<string, string>;
databaseUrl: string;
serverAddr: string;
logLevel: 'fatal' | 'error' | 'warn' | 'info' | 'debug' | 'trace' | 'silent';
editorApiEnabled: boolean;
assetsApiEnabled: boolean;
jwtSecret: string;
jwtExpiresIn: string;
jwtIssuer: string;
llm: {
baseUrl: string;
apiKey: string;
model: string;
};
dashScope: {
baseUrl: string;
apiKey: string;
imageModel: string;
requestTimeoutMs: number;
};
smsAuth: {
enabled: boolean;
provider: 'aliyun' | 'mock';
endpoint: string;
accessKeyId: string;
accessKeySecret: string;
signName: string;
templateCode: string;
templateParamKey: string;
countryCode: string;
schemeName: string;
codeLength: number;
codeType: number;
validTimeSeconds: number;
intervalSeconds: number;
duplicatePolicy: number;
caseAuthPolicy: number;
returnVerifyCode: boolean;
mockVerifyCode: string;
maxSendPerPhonePerDay: number;
maxSendPerIpPerHour: number;
maxVerifyFailuresPerPhonePerHour: number;
maxVerifyFailuresPerIpPerHour: number;
captchaTtlSeconds: number;
captchaTriggerVerifyFailuresPerPhone: number;
captchaTriggerVerifyFailuresPerIp: number;
blockPhoneFailureThreshold: number;
blockIpFailureThreshold: number;
blockPhoneDurationMinutes: number;
blockIpDurationMinutes: number;
};
wechatAuth: {
enabled: boolean;
provider: 'wechat' | 'mock';
appId: string;
appSecret: string;
authorizeEndpoint: string;
accessTokenEndpoint: string;
userInfoEndpoint: string;
callbackPath: string;
defaultRedirectPath: string;
mockUserId: string;
mockUnionId: string;
mockDisplayName: string;
mockAvatarUrl: string;
};
authSession: {
accessCookieName: string;
accessCookieTtlSeconds: number;
accessCookieSecure: boolean;
accessCookieSameSite: 'Lax' | 'Strict' | 'None';
accessCookiePath: string;
refreshCookieName: string;
refreshSessionTtlDays: number;
refreshCookieSecure: boolean;
refreshCookieSameSite: 'Lax' | 'Strict' | 'None';
refreshCookiePath: string;
};
};
type LoadConfigOptions = {
env?: NodeJS.ProcessEnv;
projectRoot?: string;
};
function parseEnvContents(contents: string) {
return contents
.split(/\r?\n/u)
.reduce<Record<string, string>>((envMap, rawLine) => {
const line = rawLine.trim();
if (!line || line.startsWith('#')) {
return envMap;
}
const separatorIndex = line.indexOf('=');
if (separatorIndex < 0) {
return envMap;
}
const key = line.slice(0, separatorIndex).trim();
let value = line.slice(separatorIndex + 1).trim();
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1);
}
envMap[key] = value;
return envMap;
}, {});
}
function readEnvFile(filePath: string) {
if (!fs.existsSync(filePath)) {
return {};
}
return parseEnvContents(fs.readFileSync(filePath, 'utf8'));
}
function resolveDefaultProjectRoot() {
const cwd = process.cwd();
return path.basename(cwd) === 'server-node'
? path.resolve(cwd, '..')
: cwd;
}
function readMergedEnv(
exampleEnv: Record<string, string>,
localEnv: Record<string, string>,
processEnv: NodeJS.ProcessEnv,
) {
return {
...exampleEnv,
...localEnv,
...processEnv,
};
}
function hasOwnEnvKey(
env: Record<string, string | undefined>,
key: string,
) {
return Object.prototype.hasOwnProperty.call(env, key);
}
function readBooleanOverride(
env: Record<string, string | undefined>,
overrideSources: Array<Record<string, string | undefined>>,
key: string,
fallback: boolean,
) {
const hasOverride = overrideSources.some((source) => hasOwnEnvKey(source, key));
if (!hasOverride) {
return fallback;
}
return readBoolean(env, key, fallback);
}
function readString(
env: Record<string, string | undefined>,
key: string,
fallback: string,
) {
const value = env[key]?.trim();
return value ? value : fallback;
}
function readPositiveInt(
env: Record<string, string | undefined>,
key: string,
fallback: number,
) {
const parsed = Number(env[key]);
return Number.isFinite(parsed) && parsed > 0 ? Math.round(parsed) : fallback;
}
function readIntInRange(
env: Record<string, string | undefined>,
key: string,
fallback: number,
options: {
min: number;
max: number;
},
) {
const parsed = Number(env[key]);
if (!Number.isFinite(parsed)) {
return fallback;
}
const rounded = Math.round(parsed);
if (rounded < options.min || rounded > options.max) {
return fallback;
}
return rounded;
}
function readBoolean(
env: Record<string, string | undefined>,
key: string,
fallback: boolean,
) {
const value = env[key]?.trim().toLowerCase();
if (!value) {
return fallback;
}
if (value === '1' || value === 'true' || value === 'yes' || value === 'on') {
return true;
}
if (value === '0' || value === 'false' || value === 'no' || value === 'off') {
return false;
}
return fallback;
}
export function loadConfig(options: LoadConfigOptions = {}): AppConfig {
const projectRoot = options.projectRoot ?? resolveDefaultProjectRoot();
const exampleEnv = readEnvFile(path.join(projectRoot, '.env.example'));
const localEnv = readEnvFile(path.join(projectRoot, '.env.local'));
const processEnv = options.env ?? process.env;
const env = readMergedEnv(exampleEnv, localEnv, processEnv);
const logsDir = path.join(projectRoot, 'server-node', 'logs');
const dataDir = path.join(projectRoot, 'server-node', 'data');
const nodeEnv = readString(env, 'NODE_ENV', 'development');
const defaultEditorApiEnabled = nodeEnv !== 'production';
const editorApiEnabled = readBoolean(
env,
'EDITOR_API_ENABLED',
defaultEditorApiEnabled,
);
const smsProviderFromEnv = readString(
env,
'SMS_AUTH_PROVIDER',
nodeEnv === 'test' ? 'mock' : 'aliyun',
) as AppConfig['smsAuth']['provider'];
const smsAccessKeyId = readString(env, 'ALIYUN_SMS_ACCESS_KEY_ID', '');
const smsAccessKeySecret = readString(env, 'ALIYUN_SMS_ACCESS_KEY_SECRET', '');
const smsProvider = smsProviderFromEnv;
const defaultSmsEnabled =
smsProvider === 'mock' ||
Boolean(smsAccessKeyId && smsAccessKeySecret);
const smsEnabled = readBooleanOverride(
env,
[localEnv, processEnv],
'SMS_AUTH_ENABLED',
defaultSmsEnabled,
);
const wechatProvider = readString(
env,
'WECHAT_AUTH_PROVIDER',
nodeEnv === 'test' ? 'mock' : 'wechat',
) as AppConfig['wechatAuth']['provider'];
const wechatAppId = readString(env, 'WECHAT_APP_ID', '');
const wechatAppSecret = readString(env, 'WECHAT_APP_SECRET', '');
const defaultWechatEnabled =
wechatProvider === 'mock' || Boolean(wechatAppId && wechatAppSecret);
const wechatEnabled = readBooleanOverride(
env,
[localEnv, processEnv],
'WECHAT_AUTH_ENABLED',
defaultWechatEnabled,
);
const refreshSameSite = readString(
env,
'AUTH_REFRESH_COOKIE_SAME_SITE',
'Lax',
);
const accessSameSite = readString(
env,
'AUTH_ACCESS_COOKIE_SAME_SITE',
'Lax',
);
return {
nodeEnv,
projectRoot,
publicDir: path.join(projectRoot, 'public'),
logsDir,
dataDir,
rawEnv: Object.fromEntries(
Object.entries(env).flatMap(([key, value]) =>
typeof value === 'string' ? [[key, value]] : [],
),
),
databaseUrl: readString(
env,
'DATABASE_URL',
'postgresql://postgres:postgres@127.0.0.1:5432/genarrative',
),
serverAddr: readString(env, 'NODE_SERVER_ADDR', ':8081'),
logLevel: readString(env, 'LOG_LEVEL', 'info') as AppConfig['logLevel'],
editorApiEnabled,
assetsApiEnabled: readBoolean(
env,
'ASSETS_API_ENABLED',
editorApiEnabled,
),
jwtSecret: readString(env, 'JWT_SECRET', 'genarrative-dev-secret'),
jwtExpiresIn: readString(env, 'JWT_EXPIRES_IN', '2h'),
jwtIssuer: readString(env, 'JWT_ISSUER', 'genarrative-server-node'),
llm: {
baseUrl: readString(
env,
'LLM_BASE_URL',
'https://ark.cn-beijing.volces.com/api/v3',
),
apiKey:
env.LLM_API_KEY?.trim() ||
env.ARK_API_KEY?.trim() ||
env.VITE_LLM_API_KEY?.trim() ||
'',
model: readString(
env,
'LLM_MODEL',
readString(
env,
'VITE_LLM_MODEL',
'doubao-1-5-pro-32k-character-250715',
),
),
},
dashScope: {
baseUrl: readString(
env,
'DASHSCOPE_BASE_URL',
'https://dashscope.aliyuncs.com/api/v1',
),
apiKey: env.DASHSCOPE_API_KEY?.trim() || '',
imageModel: readString(env, 'DASHSCOPE_IMAGE_MODEL', 'wan2.2-t2i-flash'),
requestTimeoutMs: readPositiveInt(
env,
'DASHSCOPE_IMAGE_REQUEST_TIMEOUT_MS',
150000,
),
},
smsAuth: {
enabled: smsEnabled,
provider: smsProvider,
endpoint: readString(
env,
'ALIYUN_SMS_ENDPOINT',
'dypnsapi.aliyuncs.com',
),
accessKeyId: smsAccessKeyId,
accessKeySecret: smsAccessKeySecret,
signName: readString(
env,
'ALIYUN_SMS_SIGN_NAME',
'速通互联验证码',
),
templateCode: readString(env, 'ALIYUN_SMS_TEMPLATE_CODE', '100001'),
templateParamKey: readString(
env,
'ALIYUN_SMS_TEMPLATE_PARAM_KEY',
'code',
),
countryCode: readString(env, 'ALIYUN_SMS_COUNTRY_CODE', '86'),
schemeName: readString(env, 'ALIYUN_SMS_SCHEME_NAME', ''),
codeLength: readIntInRange(env, 'ALIYUN_SMS_CODE_LENGTH', 6, {
min: 4,
max: 8,
}),
codeType: readIntInRange(env, 'ALIYUN_SMS_CODE_TYPE', 1, {
min: 1,
max: 7,
}),
validTimeSeconds: readPositiveInt(
env,
'ALIYUN_SMS_VALID_TIME_SECONDS',
300,
),
intervalSeconds: readPositiveInt(
env,
'ALIYUN_SMS_INTERVAL_SECONDS',
60,
),
duplicatePolicy: readIntInRange(
env,
'ALIYUN_SMS_DUPLICATE_POLICY',
1,
{ min: 1, max: 2 },
),
caseAuthPolicy: readIntInRange(
env,
'ALIYUN_SMS_CASE_AUTH_POLICY',
1,
{ min: 1, max: 2 },
),
returnVerifyCode: readBoolean(
env,
'ALIYUN_SMS_RETURN_VERIFY_CODE',
false,
),
mockVerifyCode: readString(env, 'SMS_AUTH_MOCK_VERIFY_CODE', '123456'),
maxSendPerPhonePerDay: readPositiveInt(
env,
'SMS_AUTH_MAX_SEND_PER_PHONE_PER_DAY',
20,
),
maxSendPerIpPerHour: readPositiveInt(
env,
'SMS_AUTH_MAX_SEND_PER_IP_PER_HOUR',
30,
),
maxVerifyFailuresPerPhonePerHour: readPositiveInt(
env,
'SMS_AUTH_MAX_VERIFY_FAILURES_PER_PHONE_PER_HOUR',
12,
),
maxVerifyFailuresPerIpPerHour: readPositiveInt(
env,
'SMS_AUTH_MAX_VERIFY_FAILURES_PER_IP_PER_HOUR',
24,
),
captchaTtlSeconds: readPositiveInt(
env,
'SMS_AUTH_CAPTCHA_TTL_SECONDS',
180,
),
captchaTriggerVerifyFailuresPerPhone: readPositiveInt(
env,
'SMS_AUTH_CAPTCHA_TRIGGER_VERIFY_FAILURES_PER_PHONE',
3,
),
captchaTriggerVerifyFailuresPerIp: readPositiveInt(
env,
'SMS_AUTH_CAPTCHA_TRIGGER_VERIFY_FAILURES_PER_IP',
5,
),
blockPhoneFailureThreshold: readPositiveInt(
env,
'SMS_AUTH_BLOCK_PHONE_FAILURE_THRESHOLD',
6,
),
blockIpFailureThreshold: readPositiveInt(
env,
'SMS_AUTH_BLOCK_IP_FAILURE_THRESHOLD',
10,
),
blockPhoneDurationMinutes: readPositiveInt(
env,
'SMS_AUTH_BLOCK_PHONE_DURATION_MINUTES',
30,
),
blockIpDurationMinutes: readPositiveInt(
env,
'SMS_AUTH_BLOCK_IP_DURATION_MINUTES',
30,
),
},
wechatAuth: {
enabled: wechatEnabled,
provider: wechatProvider,
appId: wechatAppId,
appSecret: wechatAppSecret,
authorizeEndpoint: readString(
env,
'WECHAT_AUTHORIZE_ENDPOINT',
'https://open.weixin.qq.com/connect/qrconnect',
),
accessTokenEndpoint: readString(
env,
'WECHAT_ACCESS_TOKEN_ENDPOINT',
'https://api.weixin.qq.com/sns/oauth2/access_token',
),
userInfoEndpoint: readString(
env,
'WECHAT_USER_INFO_ENDPOINT',
'https://api.weixin.qq.com/sns/userinfo',
),
callbackPath: readString(
env,
'WECHAT_CALLBACK_PATH',
'/api/auth/wechat/callback',
),
defaultRedirectPath: readString(env, 'WECHAT_REDIRECT_PATH', '/'),
mockUserId: readString(env, 'WECHAT_MOCK_USER_ID', 'mock_wechat_user'),
mockUnionId: readString(env, 'WECHAT_MOCK_UNION_ID', 'mock_wechat_union'),
mockDisplayName: readString(env, 'WECHAT_MOCK_DISPLAY_NAME', '微信旅人'),
mockAvatarUrl: readString(env, 'WECHAT_MOCK_AVATAR_URL', ''),
},
authSession: {
accessCookieName: readString(
env,
'AUTH_ACCESS_COOKIE_NAME',
'genarrative_access_session',
),
accessCookieTtlSeconds: readPositiveInt(
env,
'AUTH_ACCESS_COOKIE_TTL_SECONDS',
7200,
),
accessCookieSecure: readBoolean(
env,
'AUTH_ACCESS_COOKIE_SECURE',
readString(env, 'NODE_ENV', 'development') === 'production',
),
accessCookieSameSite:
accessSameSite === 'None' || accessSameSite === 'Strict'
? (accessSameSite as AppConfig['authSession']['accessCookieSameSite'])
: 'Lax',
accessCookiePath: readString(
env,
'AUTH_ACCESS_COOKIE_PATH',
'/',
),
refreshCookieName: readString(
env,
'AUTH_REFRESH_COOKIE_NAME',
'genarrative_refresh_session',
),
refreshSessionTtlDays: readPositiveInt(
env,
'AUTH_REFRESH_SESSION_TTL_DAYS',
30,
),
refreshCookieSecure: readBoolean(
env,
'AUTH_REFRESH_COOKIE_SECURE',
readString(env, 'NODE_ENV', 'development') === 'production',
),
refreshCookieSameSite:
refreshSameSite === 'None' || refreshSameSite === 'Strict'
? (refreshSameSite as AppConfig['authSession']['refreshCookieSameSite'])
: 'Lax',
refreshCookiePath: readString(
env,
'AUTH_REFRESH_COOKIE_PATH',
'/api/auth',
),
},
};
}