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; 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: { 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>((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, localEnv: Record, processEnv: NodeJS.ProcessEnv, ) { return { ...exampleEnv, ...localEnv, ...processEnv, }; } function hasOwnEnvKey( env: Record, key: string, ) { return Object.prototype.hasOwnProperty.call(env, key); } function readBooleanOverride( env: Record, overrideSources: Array>, 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, key: string, fallback: string, ) { const value = env[key]?.trim(); return value ? value : fallback; } function readPositiveInt( env: Record, key: string, fallback: number, ) { const parsed = Number(env[key]); return Number.isFinite(parsed) && parsed > 0 ? Math.round(parsed) : fallback; } function readIntInRange( env: Record, 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, 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', ); 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: { 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', ), }, }; }