Merge branch 'master' of http://82.157.175.59:3000/GenarrativeAI/Genarrative
Some checks failed
CI / verify (push) Has been cancelled
Some checks failed
CI / verify (push) Has been cancelled
This commit is contained in:
@@ -54,6 +54,7 @@ ALIYUN_SMS_SIGN_NAME="速通互联验证码"
|
|||||||
ALIYUN_SMS_TEMPLATE_CODE="100001"
|
ALIYUN_SMS_TEMPLATE_CODE="100001"
|
||||||
ALIYUN_SMS_TEMPLATE_PARAM_KEY="code"
|
ALIYUN_SMS_TEMPLATE_PARAM_KEY="code"
|
||||||
ALIYUN_SMS_COUNTRY_CODE="86"
|
ALIYUN_SMS_COUNTRY_CODE="86"
|
||||||
|
ALIYUN_SMS_SCHEME_NAME=""
|
||||||
ALIYUN_SMS_CODE_LENGTH="6"
|
ALIYUN_SMS_CODE_LENGTH="6"
|
||||||
ALIYUN_SMS_CODE_TYPE="1"
|
ALIYUN_SMS_CODE_TYPE="1"
|
||||||
ALIYUN_SMS_VALID_TIME_SECONDS="300"
|
ALIYUN_SMS_VALID_TIME_SECONDS="300"
|
||||||
@@ -74,6 +75,7 @@ SMS_AUTH_BLOCK_PHONE_DURATION_MINUTES="30"
|
|||||||
SMS_AUTH_BLOCK_IP_DURATION_MINUTES="30"
|
SMS_AUTH_BLOCK_IP_DURATION_MINUTES="30"
|
||||||
|
|
||||||
# 仅开发环境:允许本地开发测试自动走游客账号。
|
# 仅开发环境:允许本地开发测试自动走游客账号。
|
||||||
|
# 一旦你已经启用手机号/微信登录,建议改成 `false`,这样会直接进入真实登录界面。
|
||||||
VITE_AUTH_ALLOW_DEV_GUEST="true"
|
VITE_AUTH_ALLOW_DEV_GUEST="true"
|
||||||
|
|
||||||
# 微信登录配置。
|
# 微信登录配置。
|
||||||
|
|||||||
22
.env.local
22
.env.local
@@ -10,10 +10,26 @@ WECHAT_AUTH_ENABLED="false"
|
|||||||
WECHAT_AUTH_PROVIDER="mock"
|
WECHAT_AUTH_PROVIDER="mock"
|
||||||
|
|
||||||
SMS_AUTH_ENABLED="true"
|
SMS_AUTH_ENABLED="true"
|
||||||
SMS_AUTH_PROVIDER="mock"
|
SMS_AUTH_PROVIDER="aliyun"
|
||||||
SMS_AUTH_MOCK_VERIFY_CODE="123456"
|
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"
|
LLM_DEBUG_LOG="true"
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ npm install
|
|||||||
- 复制 `.env.example` 为 `.env.local`
|
- 复制 `.env.example` 为 `.env.local`
|
||||||
- 填入 `LLM_API_KEY` / `ARK_API_KEY`
|
- 填入 `LLM_API_KEY` / `ARK_API_KEY`
|
||||||
- 按需设置 `VITE_LLM_MODEL`
|
- 按需设置 `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`
|
- 如需打印完整 prompt/output,可把 `VITE_LLM_DEBUG_LOG` 设为 `true`
|
||||||
|
|
||||||
启动开发环境:
|
启动开发环境:
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ const bundledNodePath = path.join(
|
|||||||
const runtimeNodePath = fs.existsSync(bundledNodePath)
|
const runtimeNodePath = fs.existsSync(bundledNodePath)
|
||||||
? bundledNodePath
|
? bundledNodePath
|
||||||
: process.execPath;
|
: 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 webBuildPath = path.join(repoRoot, 'dist', 'index.html');
|
||||||
const publicRoot = path.join(repoRoot, 'public');
|
const publicRoot = path.join(repoRoot, 'public');
|
||||||
const proxyPort = 18080;
|
const proxyPort = 18080;
|
||||||
@@ -34,7 +34,7 @@ type ManagedChild = {
|
|||||||
function assertBuildArtifacts() {
|
function assertBuildArtifacts() {
|
||||||
if (!fs.existsSync(serverBuildPath)) {
|
if (!fs.existsSync(serverBuildPath)) {
|
||||||
throw new Error(
|
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(saveResponse.status, 200);
|
||||||
assert.equal(savePayload.ok, true);
|
assert.equal(savePayload.ok, true);
|
||||||
assert.equal(savePayload.data.gameState.chapter, 2);
|
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);
|
assert.ok(savePayload.meta.requestId);
|
||||||
console.log('[smoke:proxy] proxied runtime save ok');
|
console.log('[smoke:proxy] proxied runtime save ok');
|
||||||
|
|
||||||
|
|||||||
@@ -40,6 +40,59 @@ function createSmokeConfig(): AppConfig {
|
|||||||
imageModel: 'test-image-model',
|
imageModel: 'test-image-model',
|
||||||
requestTimeoutMs: 1000,
|
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',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,10 +4,13 @@ await esbuild.build({
|
|||||||
entryPoints: ['src/server.ts'],
|
entryPoints: ['src/server.ts'],
|
||||||
bundle: true,
|
bundle: true,
|
||||||
platform: 'node',
|
platform: 'node',
|
||||||
format: 'esm',
|
format: 'cjs',
|
||||||
target: 'node22',
|
target: 'node22',
|
||||||
outfile: 'dist/server.js',
|
outfile: 'dist/server.cjs',
|
||||||
sourcemap: true,
|
sourcemap: true,
|
||||||
packages: 'external',
|
packages: 'external',
|
||||||
tsconfig: 'tsconfig.json',
|
tsconfig: 'tsconfig.json',
|
||||||
|
define: {
|
||||||
|
'import.meta.url': 'undefined',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ module.exports = {
|
|||||||
apps: [
|
apps: [
|
||||||
{
|
{
|
||||||
name: 'genarrative-server',
|
name: 'genarrative-server',
|
||||||
script: 'dist/server.js',
|
script: 'dist/server.cjs',
|
||||||
cwd: __dirname,
|
cwd: __dirname,
|
||||||
instances: 1,
|
instances: 1,
|
||||||
exec_mode: 'fork',
|
exec_mode: 'fork',
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "tsx watch src/server.ts",
|
"dev": "tsx watch src/server.ts",
|
||||||
"build": "node build.mjs",
|
"build": "node build.mjs",
|
||||||
"start": "node dist/server.js",
|
"start": "node dist/server.cjs",
|
||||||
"test": "node test.mjs",
|
"test": "node test.mjs",
|
||||||
"db:migrate": "tsx src/migrate.ts"
|
"db:migrate": "tsx src/migrate.ts"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ import type { AppConfig } from './config.js';
|
|||||||
|
|
||||||
const LOG_RETENTION_DAYS = 7;
|
const LOG_RETENTION_DAYS = 7;
|
||||||
|
|
||||||
|
function shouldUseTransport(config: AppConfig) {
|
||||||
|
return config.nodeEnv !== 'test' && config.logLevel !== 'silent';
|
||||||
|
}
|
||||||
|
|
||||||
function cleanupExpiredLogs(logsDir: string) {
|
function cleanupExpiredLogs(logsDir: string) {
|
||||||
if (!fs.existsSync(logsDir)) {
|
if (!fs.existsSync(logsDir)) {
|
||||||
return;
|
return;
|
||||||
@@ -28,6 +32,14 @@ function cleanupExpiredLogs(logsDir: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function createLogger(config: AppConfig): Logger {
|
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 });
|
fs.mkdirSync(config.logsDir, { recursive: true });
|
||||||
cleanupExpiredLogs(config.logsDir);
|
cleanupExpiredLogs(config.logsDir);
|
||||||
|
|
||||||
|
|||||||
@@ -151,9 +151,23 @@ async function main() {
|
|||||||
process.on('SIGTERM', shutdown);
|
process.on('SIGTERM', shutdown);
|
||||||
}
|
}
|
||||||
|
|
||||||
const isEntryPoint =
|
function isEntryModule() {
|
||||||
typeof process.argv[1] === 'string' &&
|
if (typeof process.argv[1] !== 'string') {
|
||||||
import.meta.url === pathToFileURL(process.argv[1]).href;
|
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) {
|
if (isEntryPoint) {
|
||||||
void main().catch((error) => {
|
void main().catch((error) => {
|
||||||
|
|||||||
53
server-node/src/services/smsVerificationService.test.ts
Normal file
53
server-node/src/services/smsVerificationService.test.ts
Normal 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');
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import crypto from 'node:crypto';
|
import crypto from 'node:crypto';
|
||||||
|
|
||||||
import DypnsClient, {
|
import DypnsApiModule, {
|
||||||
CheckSmsVerifyCodeRequest,
|
CheckSmsVerifyCodeRequest,
|
||||||
SendSmsVerifyCodeRequest,
|
SendSmsVerifyCodeRequest,
|
||||||
} from '@alicloud/dypnsapi20170525';
|
} from '@alicloud/dypnsapi20170525';
|
||||||
@@ -29,6 +29,30 @@ export type SmsVerificationService = {
|
|||||||
): Promise<void>;
|
): 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']) {
|
function isAliyunConfigMissing(config: AppConfig['smsAuth']) {
|
||||||
return !config.accessKeyId || !config.accessKeySecret;
|
return !config.accessKeyId || !config.accessKeySecret;
|
||||||
}
|
}
|
||||||
@@ -74,6 +98,7 @@ class AliyunSmsVerificationService implements SmsVerificationService {
|
|||||||
async sendLoginCode(phoneNumber: NormalizedPhoneNumber) {
|
async sendLoginCode(phoneNumber: NormalizedPhoneNumber) {
|
||||||
const templateParam = JSON.stringify({
|
const templateParam = JSON.stringify({
|
||||||
[this.config.templateParamKey]: '##code##',
|
[this.config.templateParamKey]: '##code##',
|
||||||
|
"min": this.config.validTimeSeconds,
|
||||||
});
|
});
|
||||||
const request = new SendSmsVerifyCodeRequest({
|
const request = new SendSmsVerifyCodeRequest({
|
||||||
phoneNumber: phoneNumber.nationalNumber,
|
phoneNumber: phoneNumber.nationalNumber,
|
||||||
|
|||||||
86
src/components/auth/AuthGate.test.tsx
Normal file
86
src/components/auth/AuthGate.test.tsx
Normal 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();
|
||||||
|
});
|
||||||
@@ -121,6 +121,45 @@ export function AuthGate({ children }: AuthGateProps) {
|
|||||||
return options;
|
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();
|
const callbackResult = consumeAuthCallbackResult();
|
||||||
if (callbackResult?.error && isActive) {
|
if (callbackResult?.error && isActive) {
|
||||||
setError(callbackResult.error);
|
setError(callbackResult.error);
|
||||||
@@ -128,31 +167,7 @@ export function AuthGate({ children }: AuthGateProps) {
|
|||||||
|
|
||||||
const token = getStoredAccessToken();
|
const token = getStoredAccessToken();
|
||||||
if (!token) {
|
if (!token) {
|
||||||
if (allowDevGuestAutoAuth) {
|
await resolveGuestFallback();
|
||||||
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');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -182,27 +197,7 @@ export function AuthGate({ children }: AuthGateProps) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (allowDevGuestAutoAuth) {
|
await resolveGuestFallback();
|
||||||
await ensureAutoUser();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setUser(null);
|
|
||||||
try {
|
|
||||||
await loadLoginOptions();
|
|
||||||
} catch (optionsError) {
|
|
||||||
if (!isActive) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setAvailableLoginMethods([]);
|
|
||||||
setError(
|
|
||||||
optionsError instanceof Error
|
|
||||||
? optionsError.message
|
|
||||||
: '读取登录方式失败,请稍后再试。',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
setStatus('unauthenticated');
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user