修复报错

This commit is contained in:
2026-04-20 05:14:50 +00:00
parent d2a059d57a
commit 2d0a267729
12 changed files with 390 additions and 39 deletions

View File

@@ -72,6 +72,7 @@ test('auth gate renders app content after spacetime auth session is ready', asyn
authMocks.getCurrentAuthUser.mockResolvedValue({
user: activeUser,
availableLoginMethods: ['phone'],
recoveryNotice: null,
});
render(
@@ -90,6 +91,7 @@ test('auth gate opens phone verification modal for pending sms verification user
bindingStatus: 'pending_bind_phone',
},
availableLoginMethods: ['phone'],
recoveryNotice: null,
});
render(
@@ -100,3 +102,23 @@ test('auth gate opens phone verification modal for pending sms verification user
expect(await screen.findByText('完成短信验证')).toBeTruthy();
});
test('auth gate shows login expired notice after anonymous fallback recovery', async () => {
authMocks.getCurrentAuthUser.mockResolvedValue({
user: activeUser,
availableLoginMethods: ['phone'],
recoveryNotice: {
code: 'login_expired',
message: '登录已过期,已切换为匿名账号。',
},
});
render(
<AuthGate>
<div></div>
</AuthGate>,
);
expect(await screen.findByText('应用内容')).toBeTruthy();
expect(await screen.findByText('登录已过期,已切换为匿名账号。')).toBeTruthy();
});

View File

@@ -1,13 +1,6 @@
import { type ReactNode, useEffect, useMemo, useState } from 'react';
import {
SPACETIME_KICK_EVENT,
SPACETIME_SESSION_REVOKED_EVENT,
SPACETIME_VERIFICATION_REQUIRED_EVENT,
type KickEventDetail,
type SessionRevokedDetail,
type VerificationRequiredDetail,
} from '../../spacetime/client';
import { AUTH_STATE_EVENT } from '../../services/apiClient';
import {
type AuthAuditLogEntry,
type AuthCaptchaChallenge,
@@ -26,7 +19,14 @@ import {
revokeAuthSession,
sendPhoneLoginCode,
} from '../../services/authService';
import { AUTH_STATE_EVENT } from '../../services/apiClient';
import {
type KickEventDetail,
type SessionRevokedDetail,
SPACETIME_KICK_EVENT,
SPACETIME_SESSION_REVOKED_EVENT,
SPACETIME_VERIFICATION_REQUIRED_EVENT,
type VerificationRequiredDetail,
} from '../../spacetime/client';
import { AccountModal } from './AccountModal';
import { AuthUiContext } from './AuthUiContext';
import { PhoneVerificationModal } from './PhoneVerificationModal';
@@ -41,6 +41,7 @@ export function AuthGate({ children }: AuthGateProps) {
const [status, setStatus] = useState<AuthStatus>('checking');
const [user, setUser] = useState<AuthUser | null>(null);
const [error, setError] = useState('');
const [notice, setNotice] = useState('');
const [sendingCode, setSendingCode] = useState(false);
const [verifyingPhone, setVerifyingPhone] = useState(false);
const [showAccountModal, setShowAccountModal] = useState(false);
@@ -80,11 +81,13 @@ export function AuthGate({ children }: AuthGateProps) {
setUser(nextSession.user);
setStatus('ready');
setError('');
if (nextSession.user.bindingStatus === 'pending_bind_phone') {
setNotice(nextSession.recoveryNotice?.message ?? '');
const nextUser = nextSession.user;
if (nextUser.bindingStatus === 'pending_bind_phone') {
setShowVerificationModal(true);
setVerificationPrompt((current) =>
current ?? {
phoneNumberMasked: nextSession.user.phoneNumberMasked,
phoneNumberMasked: nextUser.phoneNumberMasked,
title: '完成短信验证',
detail: '验证手机号后,才能继续进行需要服务端状态写入的操作。',
},
@@ -97,6 +100,7 @@ export function AuthGate({ children }: AuthGateProps) {
setUser(null);
setStatus('error');
setNotice('');
setError(
hydrateError instanceof Error
? hydrateError.message
@@ -166,6 +170,20 @@ export function AuthGate({ children }: AuthGateProps) {
};
}, [user?.phoneNumberMasked]);
useEffect(() => {
if (!notice) {
return;
}
const timeoutId = window.setTimeout(() => {
setNotice('');
}, 5000);
return () => {
window.clearTimeout(timeoutId);
};
}, [notice]);
useEffect(() => {
if (!showAccountModal || status !== 'ready') {
return;
@@ -290,6 +308,26 @@ export function AuthGate({ children }: AuthGateProps) {
return (
<AuthUiContext.Provider value={authUiValue}>
<div className="relative">
{notice ? (
<div
className="pointer-events-none fixed inset-x-0 top-3 z-[60] flex justify-center px-4"
style={{
paddingTop: 'calc(env(safe-area-inset-top, 0px) + 0.25rem)',
}}
>
<div className="pointer-events-auto flex w-full max-w-md items-start gap-3 rounded-2xl border border-amber-300/30 bg-[rgba(84,48,12,0.92)] px-4 py-3 text-sm text-amber-50 shadow-[0_18px_50px_rgba(0,0,0,0.35)] backdrop-blur">
<div className="min-w-0 flex-1 leading-5">{notice}</div>
<button
type="button"
className="shrink-0 rounded-full border border-amber-100/20 px-3 py-1 text-[11px] text-amber-50 transition hover:border-amber-100/40 hover:bg-white/8"
onClick={() => setNotice('')}
>
</button>
</div>
</div>
) : null}
{showGlobalAccountActions ? (
<div className="pointer-events-none fixed right-3 top-3 z-50 flex justify-end">
<div className="pointer-events-auto flex items-center gap-2 rounded-full border border-white/10 bg-black/45 px-3 py-2 text-xs text-zinc-200 backdrop-blur">

View File

@@ -1,11 +1,16 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ApiClientError, clearStoredAccessToken, clearStoredAutoAuthCredentials } from './apiClient';
import {
ApiClientError,
clearStoredAccessToken,
clearStoredAutoAuthCredentials,
setStoredAccessToken,
} from './apiClient';
import {
createAutoAuthCredentials,
getCaptchaChallengeFromError,
getAuthRiskBlocks,
getAuthSessions,
getCaptchaChallengeFromError,
getCurrentAuthUser,
liftAuthRiskBlock,
loginWithPhoneCode,
@@ -240,6 +245,43 @@ describe('authService with SpacetimeDB', () => {
expect(session.user?.displayName).toBe('游客阿青');
expect(session.user?.loginMethod).toBe('phone');
expect(session.availableLoginMethods).toEqual(['phone', 'wechat']);
expect(session.recoveryNotice).toBeNull();
});
it('falls back to anonymous auth when stored token connection stalls', async () => {
vi.useFakeTimers();
setStoredAccessToken('expired-token', { emit: false });
const stalledConnection = new Promise<never>(() => {});
spacetimeMocks.ensureSpacetimeConnection
.mockImplementationOnce(() => stalledConnection)
.mockResolvedValueOnce(
createConnection({
authRows: [
createAuthStateRow({
displayName: '匿名玩家',
}),
],
}),
);
try {
const sessionPromise = getCurrentAuthUser();
await vi.advanceTimersByTimeAsync(3500);
const session = await sessionPromise;
expect(session.user?.displayName).toBe('匿名玩家');
expect(session.recoveryNotice).toEqual({
code: 'login_expired',
message: '登录已过期,已切换为匿名账号。',
});
expect(spacetimeMocks.disconnectSpacetimeConnection).toHaveBeenCalled();
expect(window.localStorage.getItem('genarrative.auth.access-token.v1')).toBeNull();
} finally {
vi.useRealTimers();
}
});
it('sends phone login code through spacetime procedure', async () => {

View File

@@ -1,9 +1,9 @@
import type {
AuthAuditLogEntry,
AuthCaptchaChallenge,
AuthLiftRiskBlockResponse,
AuthLoginMethod,
AuthLoginOptionsResponse,
AuthLiftRiskBlockResponse,
AuthLogoutAllResponse,
AuthPhoneChangeResponse,
AuthPhoneLoginResponse,
@@ -14,17 +14,16 @@ import type {
AuthWechatBindPhoneResponse,
LogoutResponse,
} from '../../packages/shared/src/contracts/auth';
import {
ApiClientError,
clearStoredAccessToken,
clearStoredAutoAuthCredentials,
getStoredAccessToken,
} from './apiClient';
import {
disconnectSpacetimeConnection,
ensureSpacetimeConnection,
getCurrentSpacetimeSessionId,
} from '../spacetime/client';
import type {
ClientAppConfigView,
RequestMeta,
SmsAuthScene,
} from '../spacetime/generated/types';
import {
mapAuditLogEntry,
mapAuthRiskBlock,
@@ -32,11 +31,12 @@ import {
mapAuthUser,
mapAvailableLoginMethods,
} from '../spacetime/mappers';
import type {
ClientAppConfigView,
RequestMeta,
SmsAuthScene,
} from '../spacetime/generated/types';
import {
ApiClientError,
clearStoredAccessToken,
clearStoredAutoAuthCredentials,
getStoredAccessToken,
} from './apiClient';
export type { AuthUser } from '../../packages/shared/src/contracts/auth';
export type { AuthLoginMethod } from '../../packages/shared/src/contracts/auth';
@@ -49,6 +49,10 @@ export type AutoAuthCredentials = {
export type AuthSessionSnapshot = {
user: import('../../packages/shared/src/contracts/auth').AuthUser | null;
availableLoginMethods: AuthLoginMethod[];
recoveryNotice: {
code: 'login_expired';
message: string;
} | null;
};
export type { AuthSessionSummary };
export type { AuthCaptchaChallenge };
@@ -66,6 +70,8 @@ let pendingAutoAuthUser: Promise<{
credentials: AutoAuthCredentials;
}> | null = null;
const TOKEN_RECOVERY_TIMEOUT_MS = 3500;
function buildRandomSegment(length: number) {
const alphabet = 'abcdefghijklmnopqrstuvwxyz0123456789';
const cryptoApi = globalThis.crypto;
@@ -101,8 +107,10 @@ function buildRequestMeta(): RequestMeta {
return {
clientType: 'web',
userAgent:
typeof navigator !== 'undefined' ? navigator.userAgent.trim() || null : null,
ip: null,
typeof navigator !== 'undefined'
? navigator.userAgent.trim() || undefined
: undefined,
ip: undefined,
};
}
@@ -121,6 +129,12 @@ function mapSmsScene(
async function readCurrentSessionFromConnection() {
const connection = await ensureSpacetimeConnection();
return readCurrentSessionSnapshot(connection);
}
function readCurrentSessionSnapshot(
connection: Awaited<ReturnType<typeof ensureSpacetimeConnection>>,
) {
const authRow = getSingleRow(connection.db.my_auth_state.iter());
const configRow = getSingleRow(
connection.db.client_app_config.iter(),
@@ -129,20 +143,64 @@ async function readCurrentSessionFromConnection() {
return {
user: authRow ? mapAuthUser(authRow) : null,
availableLoginMethods: mapAvailableLoginMethods(configRow),
} satisfies AuthSessionSnapshot;
} satisfies Omit<AuthSessionSnapshot, 'recoveryNotice'>;
}
async function waitForConnectionWithTimeout(timeoutMs: number) {
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
return ensureSpacetimeConnection();
}
const connectionPromise = ensureSpacetimeConnection();
return Promise.race([
connectionPromise,
new Promise<never>((_, reject) => {
const timeoutId = globalThis.setTimeout(() => {
reject(new Error('账号连接超时,请稍后重试'));
}, timeoutMs);
void connectionPromise.finally(() => {
globalThis.clearTimeout(timeoutId);
});
}),
]);
}
async function readCurrentSessionWithConnectionTimeout(timeoutMs: number | null) {
const connection =
typeof timeoutMs === 'number'
? await waitForConnectionWithTimeout(timeoutMs)
: await ensureSpacetimeConnection();
return readCurrentSessionSnapshot(connection);
}
async function readCurrentSessionWithRetry() {
const hasStoredToken = Boolean(getStoredAccessToken());
try {
return await readCurrentSessionFromConnection();
const session = await readCurrentSessionWithConnectionTimeout(
hasStoredToken ? TOKEN_RECOVERY_TIMEOUT_MS : null,
);
return {
...session,
recoveryNotice: null,
} satisfies AuthSessionSnapshot;
} catch (error) {
if (!getStoredAccessToken()) {
if (!hasStoredToken) {
throw error;
}
disconnectSpacetimeConnection();
clearStoredAccessToken({ emit: false });
return readCurrentSessionFromConnection();
const session = await readCurrentSessionFromConnection();
return {
...session,
recoveryNotice: {
code: 'login_expired',
message: '登录已过期,已切换为匿名账号。',
},
} satisfies AuthSessionSnapshot;
}
}

View File

@@ -3,6 +3,9 @@ import type { Identity } from 'spacetimedb';
import { AUTH_STATE_EVENT, clearStoredAccessToken, getStoredAccessToken, setStoredAccessToken } from '../services/apiClient';
import { DbConnection } from './generated';
const DEFAULT_SPACETIME_URI = 'wss://maincloud.spacetimedb.com';
const DEFAULT_SPACETIME_DATABASE_NAME = 'xushi-p4wfr';
export const SPACETIME_VERIFICATION_REQUIRED_EVENT =
'genarrative-spacetime-verification-required';
export const SPACETIME_KICK_EVENT = 'genarrative-spacetime-kick';
@@ -52,7 +55,7 @@ function emitAuthStateChange() {
function normalizeSpacetimeUri(rawValue: string) {
const trimmed = rawValue.trim();
if (!trimmed) {
return 'ws://127.0.0.1:3000';
return DEFAULT_SPACETIME_URI;
}
if (trimmed.startsWith('ws://') || trimmed.startsWith('wss://')) {
@@ -72,13 +75,14 @@ function normalizeSpacetimeUri(rawValue: string) {
function resolveSpacetimeUri() {
return normalizeSpacetimeUri(
import.meta.env.VITE_SPACETIME_URI?.trim() || 'ws://127.0.0.1:3000',
import.meta.env.VITE_SPACETIME_URI?.trim() || DEFAULT_SPACETIME_URI,
);
}
function resolveDatabaseName() {
return (
import.meta.env.VITE_SPACETIME_DATABASE_NAME?.trim() || 'xushi-p4wfr'
import.meta.env.VITE_SPACETIME_DATABASE_NAME?.trim() ||
DEFAULT_SPACETIME_DATABASE_NAME
);
}