1
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-04-11 15:43:32 +08:00
parent f19e482c8f
commit 0981d6ee1b
78 changed files with 1102 additions and 8510 deletions

View File

@@ -14,12 +14,25 @@ import { requestIdMiddleware } from './middleware/requestId.ts';
import { createAppContext } from './server.ts';
import { httpRequest, type TestRequestInit } from './testHttp.ts';
function createTestConfig(testName: string): AppConfig {
type TestConfigOverrides = Partial<
Omit<AppConfig, 'llm' | 'dashScope' | 'smsAuth' | 'wechatAuth' | 'authSession'>
> & {
llm?: Partial<AppConfig['llm']>;
dashScope?: Partial<AppConfig['dashScope']>;
smsAuth?: Partial<AppConfig['smsAuth']>;
wechatAuth?: Partial<AppConfig['wechatAuth']>;
authSession?: Partial<AppConfig['authSession']>;
};
function createTestConfig(
testName: string,
overrides: TestConfigOverrides = {},
): AppConfig {
const tempRoot = fs.mkdtempSync(
path.join(os.tmpdir(), `genarrative-server-node-${testName}-`),
);
return {
const baseConfig: AppConfig = {
nodeEnv: 'test',
projectRoot: tempRoot,
publicDir: path.join(tempRoot, 'public'),
@@ -99,13 +112,39 @@ function createTestConfig(testName: string): AppConfig {
refreshCookiePath: '/api/auth',
},
};
return {
...baseConfig,
...overrides,
llm: {
...baseConfig.llm,
...overrides.llm,
},
dashScope: {
...baseConfig.dashScope,
...overrides.dashScope,
},
smsAuth: {
...baseConfig.smsAuth,
...overrides.smsAuth,
},
wechatAuth: {
...baseConfig.wechatAuth,
...overrides.wechatAuth,
},
authSession: {
...baseConfig.authSession,
...overrides.authSession,
},
};
}
async function withTestServer<T>(
testName: string,
run: (options: { baseUrl: string }) => Promise<T>,
overrides: TestConfigOverrides = {},
) {
const context = await createAppContext(createTestConfig(testName));
const context = await createAppContext(createTestConfig(testName, overrides));
const app = createApp(context);
const server = await new Promise<import('node:http').Server>((resolve) => {
const nextServer = app.listen(0, '127.0.0.1', () => resolve(nextServer));
@@ -348,6 +387,130 @@ test('auth entry auto-registers, me works, logout invalidates old token', async
});
});
test('login options expose enabled methods without authentication', async () => {
await withTestServer('auth-login-options', async ({ baseUrl }) => {
const response = await httpRequest(`${baseUrl}/api/auth/login-options`);
const payload = (await response.json()) as {
availableLoginMethods: string[];
};
assert.equal(response.status, 200);
assert.deepEqual(payload.availableLoginMethods, ['phone', 'wechat']);
});
});
test('wechat start uses qrconnect for desktop browsers', async () => {
await withTestServer(
'wechat-start-desktop',
async ({ baseUrl }) => {
const response = await httpRequest(
`${baseUrl}/api/auth/wechat/start?redirectPath=${encodeURIComponent('/')}`,
{
headers: {
'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/135.0.0.0 Safari/537.36',
},
},
);
const payload = (await response.json()) as {
authorizationUrl: string;
};
const authorizationUrl = new URL(payload.authorizationUrl);
assert.equal(response.status, 200);
assert.equal(
`${authorizationUrl.origin}${authorizationUrl.pathname}`,
'https://open.weixin.qq.com/connect/qrconnect',
);
assert.equal(authorizationUrl.searchParams.get('scope'), 'snsapi_login');
assert.equal(authorizationUrl.hash, '#wechat_redirect');
},
{
wechatAuth: {
enabled: true,
provider: 'wechat',
appId: 'wx-test-app-id',
appSecret: 'wx-test-app-secret',
},
},
);
});
test('wechat start uses oauth authorize inside wechat browser', async () => {
await withTestServer(
'wechat-start-in-app',
async ({ baseUrl }) => {
const response = await httpRequest(
`${baseUrl}/api/auth/wechat/start?redirectPath=${encodeURIComponent('/')}`,
{
headers: {
'User-Agent':
'Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 Mobile/15E148 MicroMessenger/8.0.54',
},
},
);
const payload = (await response.json()) as {
authorizationUrl: string;
};
const authorizationUrl = new URL(payload.authorizationUrl);
assert.equal(response.status, 200);
assert.equal(
`${authorizationUrl.origin}${authorizationUrl.pathname}`,
'https://open.weixin.qq.com/connect/oauth2/authorize',
);
assert.equal(authorizationUrl.searchParams.get('scope'), 'snsapi_userinfo');
assert.equal(authorizationUrl.hash, '#wechat_redirect');
},
{
wechatAuth: {
enabled: true,
provider: 'wechat',
appId: 'wx-test-app-id',
appSecret: 'wx-test-app-secret',
},
},
);
});
test('wechat start rejects unsupported mobile browsers for real provider', async () => {
await withTestServer(
'wechat-start-mobile-browser',
async ({ baseUrl }) => {
const response = await httpRequest(
`${baseUrl}/api/auth/wechat/start?redirectPath=${encodeURIComponent('/')}`,
{
headers: {
'User-Agent':
'Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 Version/18.0 Mobile/15E148 Safari/604.1',
},
},
);
const payload = (await response.json()) as {
error: {
code: string;
message: string;
};
};
assert.equal(response.status, 400);
assert.equal(payload.error.code, 'BAD_REQUEST');
assert.equal(
payload.error.message,
'当前浏览器请使用手机号登录,或在微信内打开后再使用微信登录',
);
},
{
wechatAuth: {
enabled: true,
provider: 'wechat',
appId: 'wx-test-app-id',
appSecret: 'wx-test-app-secret',
},
},
);
});
test('phone login sends code, creates a user and returns masked profile info', async () => {
await withTestServer('phone-login', async ({ baseUrl }) => {
const sendResult = await sendPhoneCode(baseUrl, '13800138000');

View File

@@ -6,6 +6,7 @@ import type {
AuthAuditLogsResponse,
AuthBindingStatus,
AuthEntryResponse,
AuthLoginOptionsResponse,
AuthLiftRiskBlockResponse,
AuthLoginMethod,
AuthLogoutAllResponse,
@@ -151,6 +152,14 @@ export async function buildAuthMeResponse(
};
}
export function buildAuthLoginOptionsResponse(
context: AppContext,
): AuthLoginOptionsResponse {
return {
availableLoginMethods: resolveAvailableLoginMethods(context),
};
}
async function signUserAuthPayload(
context: AppContext,
user: UserRecord,
@@ -1077,12 +1086,14 @@ export async function startWechatLogin(
context: AppContext,
callbackUrl: string,
redirectPath: string,
requestContext: RefreshSessionRequestContext | null = null,
): Promise<AuthWechatStartResponse> {
const stateRecord = context.wechatAuthStates.create(redirectPath);
return {
authorizationUrl: context.wechatAuthService.buildAuthorizationUrl({
callbackUrl,
state: stateRecord.state,
userAgent: requestContext?.userAgent ?? null,
}),
};
}

View File

@@ -0,0 +1,61 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import { loadConfig } from './config.ts';
function createTempProjectRoot(prefix: string) {
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
}
test('development config auto-enables aliyun sms auth when local credentials are provided', () => {
const projectRoot = createTempProjectRoot('genarrative-config-dev-');
fs.writeFileSync(
path.join(projectRoot, '.env.example'),
'SMS_AUTH_ENABLED=\"false\"\nSMS_AUTH_PROVIDER=\"aliyun\"\n',
'utf8',
);
fs.writeFileSync(
path.join(projectRoot, '.env.local'),
'ALIYUN_SMS_ACCESS_KEY_ID=\"test-ak\"\nALIYUN_SMS_ACCESS_KEY_SECRET=\"test-sk\"\n',
'utf8',
);
const config = loadConfig({
projectRoot,
env: {
NODE_ENV: 'development',
},
});
assert.equal(config.smsAuth.enabled, true);
assert.equal(config.smsAuth.provider, 'aliyun');
assert.equal(config.smsAuth.accessKeyId, 'test-ak');
assert.equal(config.smsAuth.accessKeySecret, 'test-sk');
});
test('development config respects explicit local sms auth overrides', () => {
const projectRoot = createTempProjectRoot('genarrative-config-local-');
fs.writeFileSync(
path.join(projectRoot, '.env.example'),
'SMS_AUTH_ENABLED=\"false\"\nSMS_AUTH_PROVIDER=\"aliyun\"\n',
'utf8',
);
fs.writeFileSync(
path.join(projectRoot, '.env.local'),
'SMS_AUTH_ENABLED=\"false\"\nSMS_AUTH_PROVIDER=\"aliyun\"\nALIYUN_SMS_ACCESS_KEY_ID=\"test-ak\"\nALIYUN_SMS_ACCESS_KEY_SECRET=\"test-sk\"\n',
'utf8',
);
const config = loadConfig({
projectRoot,
env: {
NODE_ENV: 'development',
},
});
assert.equal(config.smsAuth.enabled, false);
assert.equal(config.smsAuth.provider, 'aliyun');
});

View File

@@ -131,14 +131,39 @@ function resolveDefaultProjectRoot() {
: cwd;
}
function readMergedEnv(projectRoot: string, processEnv: NodeJS.ProcessEnv) {
function readMergedEnv(
exampleEnv: Record<string, string>,
localEnv: Record<string, string>,
processEnv: NodeJS.ProcessEnv,
) {
return {
...readEnvFile(path.join(projectRoot, '.env.example')),
...readEnvFile(path.join(projectRoot, '.env.local')),
...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,
@@ -199,33 +224,51 @@ function readBoolean(
export function loadConfig(options: LoadConfigOptions = {}): AppConfig {
const projectRoot = options.projectRoot ?? resolveDefaultProjectRoot();
const env = readMergedEnv(projectRoot, options.env ?? process.env);
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 defaultEditorApiEnabled = readString(env, 'NODE_ENV', 'development') !== 'production';
const nodeEnv = readString(env, 'NODE_ENV', 'development');
const defaultEditorApiEnabled = nodeEnv !== 'production';
const editorApiEnabled = readBoolean(
env,
'EDITOR_API_ENABLED',
defaultEditorApiEnabled,
);
const smsProvider = readString(
const smsProviderFromEnv = readString(
env,
'SMS_AUTH_PROVIDER',
readString(env, 'NODE_ENV', 'development') === 'test' ? 'mock' : 'aliyun',
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);
smsProvider === 'mock' ||
Boolean(smsAccessKeyId && smsAccessKeySecret);
const smsEnabled = readBooleanOverride(
env,
[localEnv, processEnv],
'SMS_AUTH_ENABLED',
defaultSmsEnabled,
);
const wechatProvider = readString(
env,
'WECHAT_AUTH_PROVIDER',
readString(env, 'NODE_ENV', 'development') === 'test' ? 'mock' : 'wechat',
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',
@@ -233,7 +276,7 @@ export function loadConfig(options: LoadConfigOptions = {}): AppConfig {
);
return {
nodeEnv: readString(env, 'NODE_ENV', 'development'),
nodeEnv,
projectRoot,
publicDir: path.join(projectRoot, 'public'),
logsDir,
@@ -295,7 +338,7 @@ export function loadConfig(options: LoadConfigOptions = {}): AppConfig {
),
},
smsAuth: {
enabled: readBoolean(env, 'SMS_AUTH_ENABLED', defaultSmsEnabled),
enabled: smsEnabled,
provider: smsProvider,
endpoint: readString(
env,
@@ -410,7 +453,7 @@ export function loadConfig(options: LoadConfigOptions = {}): AppConfig {
),
},
wechatAuth: {
enabled: readBoolean(env, 'WECHAT_AUTH_ENABLED', defaultWechatEnabled),
enabled: wechatEnabled,
provider: wechatProvider,
appId: wechatAppId,
appSecret: wechatAppSecret,

View File

@@ -73,9 +73,9 @@ function readStringArray(value: unknown) {
function describeWorld(worldType: string) {
switch (worldType) {
case 'WUXIA':
return '武侠';
return '边城模板';
case 'XIANXIA':
return '仙侠';
return '灵潮模板';
case 'CUSTOM':
return '自定义世界';
default:

View File

@@ -65,8 +65,8 @@ function buildAttributeSchema(worldType: 'WUXIA' | 'XIANXIA') {
generatedFrom: {
worldType,
worldName: worldType === 'XIANXIA' ? '云海异境' : '裂潮边城',
settingSummary: worldType === 'XIANXIA' ? '灵潮翻涌的修行世界' : '旧桥与边城交错的武侠世界',
tone: worldType === 'XIANXIA' ? '高危、空灵、失衡' : '冷峻、紧绷、江湖余震',
settingSummary: worldType === 'XIANXIA' ? '灵潮翻涌的高空异境' : '旧桥与边城交错的裂潮地界',
tone: worldType === 'XIANXIA' ? '高危、空灵、失衡' : '冷峻、紧绷、边境余震',
conflictCore: '旧秩序与新威胁正在同时逼近',
},
schemaName: worldType === 'XIANXIA' ? '灵潮六轴' : '边城六轴',
@@ -371,9 +371,10 @@ function buildDeterministicProfile(input: GenerateCustomWorldProfileInput) {
name: worldType === 'XIANXIA' ? `${seed}灵境` : `${seed}边城`,
subtitle: '前路未明',
summary: `这个世界围绕“${setting.slice(0, 28)}”展开,旧秩序与新威胁正在同时逼近。`,
tone: worldType === 'XIANXIA' ? '空灵、危险、失衡' : '冷峻、紧绷、江湖余震',
tone: worldType === 'XIANXIA' ? '空灵、危险、失衡' : '冷峻、紧绷、边境余震',
playerGoal: '查清眼前局势的关键矛盾,并守住仍值得相信的人与事',
templateWorldType: worldType,
compatibilityTemplateWorldType: worldType,
majorFactions: inferMajorFactions(seed),
coreConflicts: inferCoreConflicts(setting),
attributeSchema: buildAttributeSchema(worldType),

View File

@@ -11,9 +11,9 @@ function readNumber(value: unknown, fallback = 0) {
function describeWorld(worldType: string) {
switch (worldType) {
case 'WUXIA':
return '武侠';
return '边城模板';
case 'XIANXIA':
return '仙侠';
return '灵潮模板';
case 'CUSTOM':
return '自定义世界';
default:

View File

@@ -709,7 +709,7 @@ function buildNpcVisualPrompt(
.filter(Boolean)
.join('\n');
return buildMasterPrompt(mergedBrief || '江湖风格角色,服装完整,姿态自然。');
return buildMasterPrompt(mergedBrief || '自定义世界角色,服装完整,姿态自然。');
}
function buildImageSequencePrompt(

View File

@@ -757,9 +757,9 @@ function summarizeIssuerNarrativeProfile(context: QuestGenerationContext) {
function describeWorld(worldType: QuestGenerationContext['worldType']) {
switch (worldType) {
case 'WUXIA':
return '武侠';
return '边城模板';
case 'XIANXIA':
return '仙侠';
return '灵潮模板';
case 'CUSTOM':
return '自定义世界';
default:

View File

@@ -11,6 +11,7 @@ import type {
import { buildAuthRequestContext } from '../auth/authRequestContext.js';
import {
bindWechatPhone,
buildAuthLoginOptionsResponse,
buildAuthMeResponse,
changeUserPhone,
createRefreshSession,
@@ -115,6 +116,14 @@ export function createAuthRoutes(context: AppContext) {
const router = Router();
const requireAuth = requireJwtAuth(context.config, context.userRepository);
router.get(
'/login-options',
routeMeta({ operation: 'auth.login_options' }),
asyncHandler(async (_request, response) => {
sendApiResponse(response, buildAuthLoginOptionsResponse(context));
}),
);
router.post(
'/entry',
routeMeta({ operation: 'auth.entry' }),
@@ -232,6 +241,7 @@ export function createAuthRoutes(context: AppContext) {
request.query.redirectPath,
context.config.wechatAuth.defaultRedirectPath,
);
const requestContext = buildAuthRequestContext(request);
const callbackUrl = new URL(
context.config.wechatAuth.callbackPath,
resolveRequestOrigin(request),
@@ -239,7 +249,12 @@ export function createAuthRoutes(context: AppContext) {
sendApiResponse(
response,
await startWechatLogin(context, callbackUrl, redirectPath),
await startWechatLogin(
context,
callbackUrl,
redirectPath,
requestContext,
),
);
}),
);

View File

@@ -33,6 +33,18 @@ function isAliyunConfigMissing(config: AppConfig['smsAuth']) {
return !config.accessKeyId || !config.accessKeySecret;
}
function assertAliyunRequiredConfig(config: AppConfig['smsAuth']) {
if (!config.signName.trim()) {
throw new Error('ALIYUN_SMS_SIGN_NAME 未配置');
}
if (!config.templateCode.trim()) {
throw new Error('ALIYUN_SMS_TEMPLATE_CODE 未配置');
}
if (!config.templateParamKey.trim()) {
throw new Error('ALIYUN_SMS_TEMPLATE_PARAM_KEY 未配置');
}
}
function buildProviderErrorMessage(prefix: string, message: string) {
const normalizedMessage = message.trim();
return normalizedMessage ? `${prefix}${normalizedMessage}` : prefix;
@@ -48,6 +60,7 @@ class AliyunSmsVerificationService implements SmsVerificationService {
if (isAliyunConfigMissing(config)) {
throw new Error('ALIYUN_SMS_ACCESS_KEY_ID 或 ALIYUN_SMS_ACCESS_KEY_SECRET 未配置');
}
assertAliyunRequiredConfig(config);
const clientConfig = new OpenApiClient.Config({
accessKeyId: config.accessKeyId,

View File

@@ -15,6 +15,7 @@ export type WechatAuthService = {
buildAuthorizationUrl(params: {
callbackUrl: string;
state: string;
userAgent?: string | null;
}): string;
resolveCallbackProfile(params: {
code?: string | null;
@@ -22,12 +23,40 @@ export type WechatAuthService = {
}): Promise<WechatIdentityProfile>;
};
type WechatAuthorizationScene = 'desktop' | 'wechat_in_app';
const WECHAT_IN_APP_AUTHORIZE_ENDPOINT =
'https://open.weixin.qq.com/connect/oauth2/authorize';
function isWechatBrowser(userAgent?: string | null) {
return /MicroMessenger/iu.test(userAgent ?? '');
}
function isMobileBrowser(userAgent?: string | null) {
return /Android|iPhone|iPad|iPod|Mobile/iu.test(userAgent ?? '');
}
function resolveWechatAuthorizationScene(
userAgent?: string | null,
): WechatAuthorizationScene {
if (isWechatBrowser(userAgent)) {
return 'wechat_in_app';
}
if (isMobileBrowser(userAgent)) {
throw badRequest('当前浏览器请使用手机号登录,或在微信内打开后再使用微信登录');
}
return 'desktop';
}
class MockWechatAuthService implements WechatAuthService {
constructor(private readonly config: AppConfig['wechatAuth']) {}
buildAuthorizationUrl(params: {
callbackUrl: string;
state: string;
userAgent?: string | null;
}) {
const callbackUrl = new URL(params.callbackUrl);
callbackUrl.searchParams.set('mock_code', this.config.mockUserId);
@@ -64,12 +93,21 @@ class RealWechatAuthService implements WechatAuthService {
buildAuthorizationUrl(params: {
callbackUrl: string;
state: string;
userAgent?: string | null;
}) {
const url = new URL(this.config.authorizeEndpoint);
const scene = resolveWechatAuthorizationScene(params.userAgent);
const url = new URL(
scene === 'wechat_in_app'
? WECHAT_IN_APP_AUTHORIZE_ENDPOINT
: this.config.authorizeEndpoint,
);
url.searchParams.set('appid', this.config.appId);
url.searchParams.set('redirect_uri', params.callbackUrl);
url.searchParams.set('response_type', 'code');
url.searchParams.set('scope', 'snsapi_login');
url.searchParams.set(
'scope',
scene === 'wechat_in_app' ? 'snsapi_userinfo' : 'snsapi_login',
);
url.searchParams.set('state', params.state);
return `${url.toString()}#wechat_redirect`;
}