添加短信验证服务
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-04-18 08:48:04 +00:00
parent 7ce61e9879
commit 680b9a3e1c
14 changed files with 321 additions and 60 deletions

View File

@@ -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"
# 微信登录配置。

View File

@@ -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"

View File

@@ -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`
启动开发环境:

View File

@@ -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');

View File

@@ -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',
},
};
}

View File

@@ -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',
},
});

View File

@@ -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',

View File

@@ -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"
},

View File

@@ -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);

View File

@@ -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) => {

View File

@@ -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');
});

View File

@@ -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<void>;
};
type DypnsClientInstance = InstanceType<typeof DypnsApiModule>;
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,

View File

@@ -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: () => <div></div>,
}));
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(
<AuthGate>
<div></div>
</AuthGate>,
);
expect(await screen.findByText('账号登录')).toBeTruthy();
expect(screen.getByText('手机号')).toBeTruthy();
expect(authMocks.ensureAutoAuthUser).not.toHaveBeenCalled();
});

View File

@@ -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();
}
};