This commit is contained in:
2026-04-10 15:37:02 +08:00
parent 161cd32277
commit f19e482c8f
233 changed files with 43987 additions and 5127 deletions

View File

@@ -7,9 +7,12 @@ export type AppConfig = {
publicDir: string;
logsDir: string;
dataDir: string;
sqlitePath: 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;
@@ -24,6 +27,59 @@ export type AppConfig = {
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 = {
@@ -101,11 +157,80 @@ function readPositiveInt(
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 env = readMergedEnv(projectRoot, options.env ?? process.env);
const logsDir = path.join(projectRoot, 'server-node', 'logs');
const dataDir = path.join(projectRoot, 'server-node', 'data');
const defaultEditorApiEnabled = readString(env, 'NODE_ENV', 'development') !== 'production';
const editorApiEnabled = readBoolean(
env,
'EDITOR_API_ENABLED',
defaultEditorApiEnabled,
);
const smsProvider = readString(
env,
'SMS_AUTH_PROVIDER',
readString(env, 'NODE_ENV', 'development') === '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 defaultSmsEnabled =
smsProvider === 'mock' || Boolean(smsAccessKeyId && smsAccessKeySecret);
const wechatProvider = readString(
env,
'WECHAT_AUTH_PROVIDER',
readString(env, 'NODE_ENV', 'development') === '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 refreshSameSite = readString(
env,
'AUTH_REFRESH_COOKIE_SAME_SITE',
'Lax',
);
return {
nodeEnv: readString(env, 'NODE_ENV', 'development'),
@@ -113,15 +238,26 @@ export function loadConfig(options: LoadConfigOptions = {}): AppConfig {
publicDir: path.join(projectRoot, 'public'),
logsDir,
dataDir,
sqlitePath: readString(
rawEnv: Object.fromEntries(
Object.entries(env).flatMap(([key, value]) =>
typeof value === 'string' ? [[key, value]] : [],
),
),
databaseUrl: readString(
env,
'SQLITE_PATH',
path.join(dataDir, 'genarrative.sqlite'),
'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', '7d'),
jwtExpiresIn: readString(env, 'JWT_EXPIRES_IN', '2h'),
jwtIssuer: readString(env, 'JWT_ISSUER', 'genarrative-server-node'),
llm: {
baseUrl: readString(
@@ -158,5 +294,177 @@ export function loadConfig(options: LoadConfigOptions = {}): AppConfig {
150000,
),
},
smsAuth: {
enabled: readBoolean(env, 'SMS_AUTH_ENABLED', defaultSmsEnabled),
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: readBoolean(env, 'WECHAT_AUTH_ENABLED', defaultWechatEnabled),
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',
),
},
};
}