diff --git a/src/services/authService.test.ts b/src/services/authService.test.ts
index 555f49e7..736e5875 100644
--- a/src/services/authService.test.ts
+++ b/src/services/authService.test.ts
@@ -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
(() => {});
+ 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 () => {
diff --git a/src/services/authService.ts b/src/services/authService.ts
index f2198028..ae32e850 100644
--- a/src/services/authService.ts
+++ b/src/services/authService.ts
@@ -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>,
+) {
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;
+}
+
+async function waitForConnectionWithTimeout(timeoutMs: number) {
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
+ return ensureSpacetimeConnection();
+ }
+
+ const connectionPromise = ensureSpacetimeConnection();
+
+ return Promise.race([
+ connectionPromise,
+ new Promise((_, 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;
}
}
diff --git a/src/spacetime/client.ts b/src/spacetime/client.ts
index 2195c7e3..69faa097 100644
--- a/src/spacetime/client.ts
+++ b/src/spacetime/client.ts
@@ -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
);
}