From 680b9a3e1cf3dc0c7dcb57c0e5e4717237404f7e Mon Sep 17 00:00:00 2001 From: kdletters Date: Sat, 18 Apr 2026 08:48:04 +0000 Subject: [PATCH] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E7=9F=AD=E4=BF=A1=E9=AA=8C?= =?UTF-8?q?=E8=AF=81=E6=9C=8D=E5=8A=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 2 + .env.local | 22 ++++- README.md | 2 + scripts/smoke-same-origin-stack.ts | 6 +- scripts/smoke-server-node.ts | 53 +++++++++++ server-node/build.mjs | 7 +- server-node/ecosystem.config.cjs | 2 +- server-node/package.json | 2 +- server-node/src/logging.ts | 12 +++ server-node/src/server.ts | 20 ++++- .../services/smsVerificationService.test.ts | 53 +++++++++++ .../src/services/smsVerificationService.ts | 27 +++++- src/components/auth/AuthGate.test.tsx | 86 ++++++++++++++++++ src/components/auth/AuthGate.tsx | 87 +++++++++---------- 14 files changed, 321 insertions(+), 60 deletions(-) create mode 100644 server-node/src/services/smsVerificationService.test.ts create mode 100644 src/components/auth/AuthGate.test.tsx diff --git a/.env.example b/.env.example index bc09527a..ed357201 100644 --- a/.env.example +++ b/.env.example @@ -54,6 +54,7 @@ ALIYUN_SMS_SIGN_NAME="速通互联验证码" ALIYUN_SMS_TEMPLATE_CODE="100001" ALIYUN_SMS_TEMPLATE_PARAM_KEY="code" ALIYUN_SMS_COUNTRY_CODE="86" +ALIYUN_SMS_SCHEME_NAME="" ALIYUN_SMS_CODE_LENGTH="6" ALIYUN_SMS_CODE_TYPE="1" ALIYUN_SMS_VALID_TIME_SECONDS="300" @@ -74,6 +75,7 @@ SMS_AUTH_BLOCK_PHONE_DURATION_MINUTES="30" SMS_AUTH_BLOCK_IP_DURATION_MINUTES="30" # 仅开发环境:允许本地开发测试自动走游客账号。 +# 一旦你已经启用手机号/微信登录,建议改成 `false`,这样会直接进入真实登录界面。 VITE_AUTH_ALLOW_DEV_GUEST="true" # 微信登录配置。 diff --git a/.env.local b/.env.local index c026869b..4dcfe5e5 100644 --- a/.env.local +++ b/.env.local @@ -10,10 +10,26 @@ WECHAT_AUTH_ENABLED="false" WECHAT_AUTH_PROVIDER="mock" SMS_AUTH_ENABLED="true" -SMS_AUTH_PROVIDER="mock" -SMS_AUTH_MOCK_VERIFY_CODE="123456" +SMS_AUTH_PROVIDER="aliyun" +ALIYUN_SMS_ACCESS_KEY_ID="LTAI5tM6VjoixveLUNQ7x6z9" +ALIYUN_SMS_ACCESS_KEY_SECRET="w8Z8JlQKI1juGPSeirWwlvJfHp9frD" +ALIYUN_SMS_ENDPOINT="dypnsapi.aliyuncs.com" +ALIYUN_SMS_SIGN_NAME="速通互联验证码" +ALIYUN_SMS_TEMPLATE_CODE="100001" +ALIYUN_SMS_TEMPLATE_PARAM_KEY="code" +ALIYUN_SMS_COUNTRY_CODE="86" +ALIYUN_SMS_SCHEME_NAME="" +ALIYUN_SMS_CODE_LENGTH="6" +ALIYUN_SMS_CODE_TYPE="1" +ALIYUN_SMS_VALID_TIME_SECONDS="300" +ALIYUN_SMS_INTERVAL_SECONDS="60" +ALIYUN_SMS_DUPLICATE_POLICY="1" +ALIYUN_SMS_CASE_AUTH_POLICY="1" +ALIYUN_SMS_RETURN_VERIFY_CODE="false" -VITE_AUTH_ALLOW_DEV_GUEST="true" +VITE_AUTH_ALLOW_DEV_GUEST="false" + +DATABASE_URL="postgresql://postgres:postgres@127.0.0.1:5432/genarrative" # 启用服务端大模型调试日志(记录所有输入输出) LLM_DEBUG_LOG="true" diff --git a/README.md b/README.md index a21e17ef..98cd591f 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,8 @@ npm install - 复制 `.env.example` 为 `.env.local` - 填入 `LLM_API_KEY` / `ARK_API_KEY` - 按需设置 `VITE_LLM_MODEL` +- 如需启用阿里云短信验证码登录,填写 `ALIYUN_SMS_ACCESS_KEY_ID`、`ALIYUN_SMS_ACCESS_KEY_SECRET`,并确认 `SMS_AUTH_PROVIDER="aliyun"` +- 本地联调短信登录时,建议将 `VITE_AUTH_ALLOW_DEV_GUEST` 设为 `false`,避免开发模式自动进入游客账号而跳过登录页 - 如需打印完整 prompt/output,可把 `VITE_LLM_DEBUG_LOG` 设为 `true` 启动开发环境: diff --git a/scripts/smoke-same-origin-stack.ts b/scripts/smoke-same-origin-stack.ts index fdf49c06..b7f08795 100644 --- a/scripts/smoke-same-origin-stack.ts +++ b/scripts/smoke-same-origin-stack.ts @@ -18,7 +18,7 @@ const bundledNodePath = path.join( const runtimeNodePath = fs.existsSync(bundledNodePath) ? bundledNodePath : process.execPath; -const serverBuildPath = path.join(repoRoot, 'server-node', 'dist', 'server.js'); +const serverBuildPath = path.join(repoRoot, 'server-node', 'dist', 'server.cjs'); const webBuildPath = path.join(repoRoot, 'dist', 'index.html'); const publicRoot = path.join(repoRoot, 'public'); const proxyPort = 18080; @@ -34,7 +34,7 @@ type ManagedChild = { function assertBuildArtifacts() { if (!fs.existsSync(serverBuildPath)) { throw new Error( - 'server-node/dist/server.js 不存在,请先运行 npm run server-node:build', + 'server-node/dist/server.cjs 不存在,请先运行 npm run server-node:build', ); } @@ -399,7 +399,7 @@ async function main() { assert.equal(saveResponse.status, 200); assert.equal(savePayload.ok, true); assert.equal(savePayload.data.gameState.chapter, 2); - assert.equal(savePayload.meta.operation, 'PUT /api/runtime/save/snapshot'); + assert.equal(savePayload.meta.operation, 'runtime.snapshot.put'); assert.ok(savePayload.meta.requestId); console.log('[smoke:proxy] proxied runtime save ok'); diff --git a/scripts/smoke-server-node.ts b/scripts/smoke-server-node.ts index 65bbf396..736f3ddb 100644 --- a/scripts/smoke-server-node.ts +++ b/scripts/smoke-server-node.ts @@ -40,6 +40,59 @@ function createSmokeConfig(): AppConfig { imageModel: 'test-image-model', requestTimeoutMs: 1000, }, + smsAuth: { + enabled: true, + provider: 'mock', + endpoint: 'dypnsapi.aliyuncs.com', + accessKeyId: '', + accessKeySecret: '', + signName: 'Test Sign', + templateCode: '100001', + templateParamKey: 'code', + countryCode: '86', + schemeName: '', + codeLength: 6, + codeType: 1, + validTimeSeconds: 300, + intervalSeconds: 60, + duplicatePolicy: 1, + caseAuthPolicy: 1, + returnVerifyCode: false, + mockVerifyCode: '123456', + maxSendPerPhonePerDay: 20, + maxSendPerIpPerHour: 30, + maxVerifyFailuresPerPhonePerHour: 12, + maxVerifyFailuresPerIpPerHour: 24, + captchaTtlSeconds: 180, + captchaTriggerVerifyFailuresPerPhone: 3, + captchaTriggerVerifyFailuresPerIp: 5, + blockPhoneFailureThreshold: 6, + blockIpFailureThreshold: 10, + blockPhoneDurationMinutes: 30, + blockIpDurationMinutes: 30, + }, + wechatAuth: { + enabled: true, + provider: 'mock', + appId: '', + appSecret: '', + authorizeEndpoint: 'https://open.weixin.qq.com/connect/qrconnect', + accessTokenEndpoint: 'https://api.weixin.qq.com/sns/oauth2/access_token', + userInfoEndpoint: 'https://api.weixin.qq.com/sns/userinfo', + callbackPath: '/api/auth/wechat/callback', + defaultRedirectPath: '/', + mockUserId: 'mock_wechat_user', + mockUnionId: 'mock_wechat_union', + mockDisplayName: '微信旅人', + mockAvatarUrl: '', + }, + authSession: { + refreshCookieName: 'genarrative_refresh_session', + refreshSessionTtlDays: 30, + refreshCookieSecure: false, + refreshCookieSameSite: 'Lax', + refreshCookiePath: '/api/auth', + }, }; } diff --git a/server-node/build.mjs b/server-node/build.mjs index f34a2f84..2b8674ac 100644 --- a/server-node/build.mjs +++ b/server-node/build.mjs @@ -4,10 +4,13 @@ await esbuild.build({ entryPoints: ['src/server.ts'], bundle: true, platform: 'node', - format: 'esm', + format: 'cjs', target: 'node22', - outfile: 'dist/server.js', + outfile: 'dist/server.cjs', sourcemap: true, packages: 'external', tsconfig: 'tsconfig.json', + define: { + 'import.meta.url': 'undefined', + }, }); diff --git a/server-node/ecosystem.config.cjs b/server-node/ecosystem.config.cjs index dc686455..bc73b332 100644 --- a/server-node/ecosystem.config.cjs +++ b/server-node/ecosystem.config.cjs @@ -2,7 +2,7 @@ module.exports = { apps: [ { name: 'genarrative-server', - script: 'dist/server.js', + script: 'dist/server.cjs', cwd: __dirname, instances: 1, exec_mode: 'fork', diff --git a/server-node/package.json b/server-node/package.json index e93b8928..b1e933a7 100644 --- a/server-node/package.json +++ b/server-node/package.json @@ -6,7 +6,7 @@ "scripts": { "dev": "tsx watch src/server.ts", "build": "node build.mjs", - "start": "node dist/server.js", + "start": "node dist/server.cjs", "test": "node test.mjs", "db:migrate": "tsx src/migrate.ts" }, diff --git a/server-node/src/logging.ts b/server-node/src/logging.ts index e20c5e29..a1de9197 100644 --- a/server-node/src/logging.ts +++ b/server-node/src/logging.ts @@ -7,6 +7,10 @@ import type { AppConfig } from './config.js'; const LOG_RETENTION_DAYS = 7; +function shouldUseTransport(config: AppConfig) { + return config.nodeEnv !== 'test' && config.logLevel !== 'silent'; +} + function cleanupExpiredLogs(logsDir: string) { if (!fs.existsSync(logsDir)) { return; @@ -28,6 +32,14 @@ function cleanupExpiredLogs(logsDir: string) { } export function createLogger(config: AppConfig): Logger { + if (!shouldUseTransport(config)) { + return pino({ + level: config.logLevel, + timestamp: pino.stdTimeFunctions.isoTime, + base: undefined, + }); + } + fs.mkdirSync(config.logsDir, { recursive: true }); cleanupExpiredLogs(config.logsDir); diff --git a/server-node/src/server.ts b/server-node/src/server.ts index fce3a7f7..e23d87f7 100644 --- a/server-node/src/server.ts +++ b/server-node/src/server.ts @@ -151,9 +151,23 @@ async function main() { process.on('SIGTERM', shutdown); } -const isEntryPoint = - typeof process.argv[1] === 'string' && - import.meta.url === pathToFileURL(process.argv[1]).href; +function isEntryModule() { + if (typeof process.argv[1] !== 'string') { + return false; + } + + const entryHref = pathToFileURL(process.argv[1]).href; + if (typeof import.meta.url === 'string' && import.meta.url === entryHref) { + return true; + } + + return ( + typeof __filename === 'string' && + pathToFileURL(__filename).href === entryHref + ); +} + +const isEntryPoint = isEntryModule(); if (isEntryPoint) { void main().catch((error) => { diff --git a/server-node/src/services/smsVerificationService.test.ts b/server-node/src/services/smsVerificationService.test.ts new file mode 100644 index 00000000..6d9280da --- /dev/null +++ b/server-node/src/services/smsVerificationService.test.ts @@ -0,0 +1,53 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import pino from 'pino'; + +import type { AppConfig } from '../config.js'; +import { createSmsVerificationService } from './smsVerificationService.js'; + +function createAliyunSmsConfig(): AppConfig { + return { + smsAuth: { + enabled: true, + provider: 'aliyun', + endpoint: 'dypnsapi.aliyuncs.com', + accessKeyId: 'test-access-key-id', + accessKeySecret: 'test-access-key-secret', + signName: '测试签名', + templateCode: 'SMS_100001', + templateParamKey: 'code', + countryCode: '86', + schemeName: '', + codeLength: 6, + codeType: 1, + validTimeSeconds: 300, + intervalSeconds: 60, + duplicatePolicy: 1, + caseAuthPolicy: 1, + returnVerifyCode: false, + mockVerifyCode: '123456', + maxSendPerPhonePerDay: 20, + maxSendPerIpPerHour: 30, + maxVerifyFailuresPerPhonePerHour: 12, + maxVerifyFailuresPerIpPerHour: 24, + captchaTtlSeconds: 180, + captchaTriggerVerifyFailuresPerPhone: 3, + captchaTriggerVerifyFailuresPerIp: 5, + blockPhoneFailureThreshold: 6, + blockIpFailureThreshold: 10, + blockPhoneDurationMinutes: 30, + blockIpDurationMinutes: 30, + }, + } as AppConfig; +} + +test('createSmsVerificationService initializes aliyun sdk client under tsx esm runtime', () => { + const service = createSmsVerificationService( + createAliyunSmsConfig(), + pino({ enabled: false }), + ); + + assert.equal(typeof service.sendLoginCode, 'function'); + assert.equal(typeof service.verifyLoginCode, 'function'); +}); diff --git a/server-node/src/services/smsVerificationService.ts b/server-node/src/services/smsVerificationService.ts index f32d08a8..c1b01e9c 100644 --- a/server-node/src/services/smsVerificationService.ts +++ b/server-node/src/services/smsVerificationService.ts @@ -1,6 +1,6 @@ import crypto from 'node:crypto'; -import DypnsClient, { +import DypnsApiModule, { CheckSmsVerifyCodeRequest, SendSmsVerifyCodeRequest, } from '@alicloud/dypnsapi20170525'; @@ -29,6 +29,30 @@ export type SmsVerificationService = { ): Promise; }; +type DypnsClientInstance = InstanceType; +type DypnsClientConstructor = new ( + config: OpenApiClient.Config, +) => DypnsClientInstance; + +function resolveDypnsClientConstructor(): DypnsClientConstructor { + const directExport = DypnsApiModule as unknown; + if (typeof directExport === 'function') { + return directExport as DypnsClientConstructor; + } + + // 兼容 CommonJS SDK 在 ESM/tsx 运行时被包一层 default 的情况。 + const nestedDefault = ( + DypnsApiModule as unknown as { default?: unknown } + ).default; + if (typeof nestedDefault === 'function') { + return nestedDefault as DypnsClientConstructor; + } + + throw new Error('阿里云短信 SDK Client 导出异常'); +} + +const DypnsClient = resolveDypnsClientConstructor(); + function isAliyunConfigMissing(config: AppConfig['smsAuth']) { return !config.accessKeyId || !config.accessKeySecret; } @@ -74,6 +98,7 @@ class AliyunSmsVerificationService implements SmsVerificationService { async sendLoginCode(phoneNumber: NormalizedPhoneNumber) { const templateParam = JSON.stringify({ [this.config.templateParamKey]: '##code##', + "min": this.config.validTimeSeconds, }); const request = new SendSmsVerifyCodeRequest({ phoneNumber: phoneNumber.nationalNumber, diff --git a/src/components/auth/AuthGate.test.tsx b/src/components/auth/AuthGate.test.tsx new file mode 100644 index 00000000..b9feb8d3 --- /dev/null +++ b/src/components/auth/AuthGate.test.tsx @@ -0,0 +1,86 @@ +/* @vitest-environment jsdom */ + +import { render, screen } from '@testing-library/react'; +import { beforeEach, expect, test, vi } from 'vitest'; + +import type { AuthUser } from '../../services/authService'; +import { AuthGate } from './AuthGate'; + +const authMocks = vi.hoisted(() => ({ + getStoredAccessToken: vi.fn(), + ensureAutoAuthUser: vi.fn(), + getAuthLoginOptions: vi.fn(), + consumeAuthCallbackResult: vi.fn(), +})); + +vi.mock('../../services/apiClient', () => ({ + AUTH_STATE_EVENT: 'genarrative-auth-state-changed', + getStoredAccessToken: authMocks.getStoredAccessToken, +})); + +vi.mock('../../services/authService', () => ({ + bindWechatPhone: vi.fn(), + changePhoneNumber: vi.fn(), + consumeAuthCallbackResult: authMocks.consumeAuthCallbackResult, + ensureAutoAuthUser: authMocks.ensureAutoAuthUser, + getAuthAuditLogs: vi.fn(), + getAuthLoginOptions: authMocks.getAuthLoginOptions, + getAuthRiskBlocks: vi.fn(), + getAuthSessions: vi.fn(), + getCaptchaChallengeFromError: vi.fn(() => null), + getCurrentAuthUser: vi.fn(), + liftAuthRiskBlock: vi.fn(), + loginWithPhoneCode: vi.fn(), + logoutAllAuthSessions: vi.fn(), + logoutAuthUser: vi.fn(), + revokeAuthSession: vi.fn(), + sendPhoneLoginCode: vi.fn(), + startWechatLogin: vi.fn(), +})); + +vi.mock('./AccountModal', () => ({ + AccountModal: () => null, +})); + +vi.mock('./BindPhoneScreen', () => ({ + BindPhoneScreen: () =>
绑定手机号
, +})); + +const mockUser: AuthUser = { + id: 'user-1', + username: 'tester', + displayName: '测试玩家', + phoneNumberMasked: '138****8000', + loginMethod: 'phone', + bindingStatus: 'active', + wechatBound: false, +}; + +beforeEach(() => { + vi.clearAllMocks(); + authMocks.getStoredAccessToken.mockReturnValue(null); + authMocks.consumeAuthCallbackResult.mockReturnValue(null); + authMocks.ensureAutoAuthUser.mockResolvedValue({ + user: mockUser, + credentials: { + username: 'guest_tester', + password: 'auto_password', + }, + }); +}); + +test('auth gate prefers login screen when phone login is available', async () => { + authMocks.getAuthLoginOptions.mockResolvedValue({ + availableLoginMethods: ['phone'], + }); + + render( + +
应用内容
+
, + ); + + expect(await screen.findByText('账号登录')).toBeTruthy(); + expect(screen.getByText('手机号')).toBeTruthy(); + expect(authMocks.ensureAutoAuthUser).not.toHaveBeenCalled(); +}); diff --git a/src/components/auth/AuthGate.tsx b/src/components/auth/AuthGate.tsx index e51f4111..f0d76fd6 100644 --- a/src/components/auth/AuthGate.tsx +++ b/src/components/auth/AuthGate.tsx @@ -121,6 +121,45 @@ export function AuthGate({ children }: AuthGateProps) { return options; }; + const resolveGuestFallback = async () => { + try { + const options = await loadLoginOptions(); + if (!isActive) { + return; + } + + if ( + allowDevGuestAutoAuth && + options && + options.availableLoginMethods.length === 0 + ) { + await ensureAutoUser(); + return; + } + + setUser(null); + setStatus('unauthenticated'); + } catch (optionsError) { + if (!isActive) { + return; + } + + if (allowDevGuestAutoAuth) { + await ensureAutoUser(); + return; + } + + setAvailableLoginMethods([]); + setUser(null); + setError( + optionsError instanceof Error + ? optionsError.message + : '读取登录方式失败,请稍后再试。', + ); + setStatus('unauthenticated'); + } + }; + const callbackResult = consumeAuthCallbackResult(); if (callbackResult?.error && isActive) { setError(callbackResult.error); @@ -128,31 +167,7 @@ export function AuthGate({ children }: AuthGateProps) { const token = getStoredAccessToken(); if (!token) { - if (allowDevGuestAutoAuth) { - await ensureAutoUser(); - return; - } - - if (!isActive) { - return; - } - - setUser(null); - try { - await loadLoginOptions(); - } catch (optionsError) { - if (!isActive) { - return; - } - - setAvailableLoginMethods([]); - setError( - optionsError instanceof Error - ? optionsError.message - : '读取登录方式失败,请稍后再试。', - ); - } - setStatus('unauthenticated'); + await resolveGuestFallback(); return; } @@ -182,27 +197,7 @@ export function AuthGate({ children }: AuthGateProps) { return; } - if (allowDevGuestAutoAuth) { - await ensureAutoUser(); - return; - } - - setUser(null); - try { - await loadLoginOptions(); - } catch (optionsError) { - if (!isActive) { - return; - } - - setAvailableLoginMethods([]); - setError( - optionsError instanceof Error - ? optionsError.message - : '读取登录方式失败,请稍后再试。', - ); - } - setStatus('unauthenticated'); + await resolveGuestFallback(); } };