This commit is contained in:
2026-05-09 19:56:03 +08:00
parent 052dbc248b
commit 7c8aa1e124
12 changed files with 483 additions and 59 deletions

View File

@@ -13,6 +13,7 @@ const authMocks = vi.hoisted(() => ({
authEntry: vi.fn(),
changePassword: vi.fn(),
ensureStoredAccessToken: vi.fn(),
getStoredAccessToken: vi.fn(),
refreshStoredAccessToken: vi.fn(),
getAuthLoginOptions: vi.fn(),
getCurrentAuthUser: vi.fn(),
@@ -29,6 +30,7 @@ const authMocks = vi.hoisted(() => ({
vi.mock('../../services/apiClient', () => ({
AUTH_STATE_EVENT: 'genarrative-auth-state-changed',
ensureStoredAccessToken: authMocks.ensureStoredAccessToken,
getStoredAccessToken: authMocks.getStoredAccessToken,
refreshStoredAccessToken: authMocks.refreshStoredAccessToken,
}));
@@ -96,6 +98,7 @@ beforeEach(() => {
window.history.replaceState(null, '', '/');
authMocks.consumeAuthCallbackResult.mockReturnValue(null);
authMocks.ensureStoredAccessToken.mockResolvedValue('jwt-existing-token');
authMocks.getStoredAccessToken.mockReturnValue('');
authMocks.refreshStoredAccessToken.mockResolvedValue('jwt-refreshed-token');
authMocks.getCurrentAuthUser.mockResolvedValue({
user: null,
@@ -231,10 +234,37 @@ test('auth gate waits for refresh cookie rotation before exposing restored user
expect(await screen.findByText('应用内容')).toBeTruthy();
expect(authMocks.refreshStoredAccessToken).toHaveBeenCalledTimes(1);
expect(authMocks.refreshStoredAccessToken).toHaveBeenCalledWith({
clearOnFailure: true,
});
expect(authMocks.ensureStoredAccessToken).not.toHaveBeenCalled();
expect(authMocks.getCurrentAuthUser).toHaveBeenCalledTimes(1);
});
test('auth gate keeps a valid local token login when refresh rotation fails after reload', async () => {
authMocks.getStoredAccessToken.mockReturnValue('jwt-existing-token');
authMocks.refreshStoredAccessToken.mockRejectedValue(
new Error('refresh cookie 失效'),
);
authMocks.getCurrentAuthUser.mockResolvedValue({
user: mockUser,
availableLoginMethods: ['phone'],
});
render(
<AuthGate>
<LogoutStateProbe />
</AuthGate>,
);
expect(await screen.findByText('当前用户:测试玩家')).toBeTruthy();
expect(screen.getByText('私有数据:可读取')).toBeTruthy();
expect(authMocks.refreshStoredAccessToken).toHaveBeenCalledWith({
clearOnFailure: false,
});
expect(authMocks.getCurrentAuthUser).toHaveBeenCalledTimes(1);
});
test('auth gate does not auto-create a guest account when dev guest switch is not explicitly enabled', async () => {
authMocks.getAuthLoginOptions.mockResolvedValue({
availableLoginMethods: [],

View File

@@ -10,6 +10,7 @@ import {
import { useGameSettings } from '../../hooks/useGameSettings';
import {
AUTH_STATE_EVENT,
getStoredAccessToken,
refreshStoredAccessToken,
} from '../../services/apiClient';
import {
@@ -18,6 +19,7 @@ import {
authEntry,
type AuthLoginMethod,
type AuthRiskBlockSummary,
type AuthSessionSnapshot,
type AuthSessionSummary,
type AuthUser,
bindWechatPhone,
@@ -81,6 +83,18 @@ function normalizeAvailableLoginMethods(
: FALLBACK_LOGIN_METHODS;
}
type AuthHydrateSessionResult =
| {
kind: 'authenticated';
session: AuthSessionSnapshot & {
user: AuthUser;
};
}
| {
kind: 'guest';
session: AuthSessionSnapshot | null;
};
export function AuthGate({ children }: AuthGateProps) {
const [status, setStatus] = useState<AuthStatus>('checking');
const [user, setUser] = useState<AuthUser | null>(null);
@@ -163,6 +177,56 @@ export function AuthGate({ children }: AuthGateProps) {
setError('');
}, []);
const restoreAuthSession = useCallback(async () => {
const hadLocalAccessToken = Boolean(getStoredAccessToken());
if (hadLocalAccessToken) {
try {
const session = await getCurrentAuthUser();
if (session.user) {
const confirmedUser = session.user;
// 中文注释:已有 access token 能确认当前账号时refresh 只作为续期和每日登录埋点补强。
// refresh cookie 临时失效或代理抖动不能反向抹掉这次已确认的登录态。
void refreshStoredAccessToken({ clearOnFailure: false }).catch(
() => undefined,
);
return {
kind: 'authenticated',
session: {
...session,
user: confirmedUser,
},
} satisfies AuthHydrateSessionResult;
}
return {
kind: 'guest',
session,
} satisfies AuthHydrateSessionResult;
} catch {
// 本地 token 可能已过期或被吊销,再尝试通过 refresh cookie 补票。
}
}
await refreshStoredAccessToken({ clearOnFailure: true });
const session = await getCurrentAuthUser();
if (session.user) {
const confirmedUser = session.user;
return {
kind: 'authenticated',
session: {
...session,
user: confirmedUser,
},
} satisfies AuthHydrateSessionResult;
}
return {
kind: 'guest',
session,
} satisfies AuthHydrateSessionResult;
}, []);
const logoutCurrentSession = useCallback(async () => {
clearLocalAuthenticatedState();
try {
@@ -316,26 +380,21 @@ export function AuthGate({ children }: AuthGateProps) {
}
try {
// 中文注释:打开已登录页面也要主动轮换 refresh cookie。
// 后端只在 refresh/session 成功续期时写每日登录埋点;如果本地 access token 尚未过期,
// 仅调用 /auth/me 不会进入续期链路,导致“打开网页”没有登录埋点。
await refreshStoredAccessToken();
const restoredSession = await restoreAuthSession();
if (!isCurrentHydrate()) {
return;
}
const nextSession = await getCurrentAuthUser();
if (!isCurrentHydrate()) {
return;
}
if (!nextSession.user) {
if (restoredSession.kind === 'guest') {
setAvailableLoginMethods(
normalizeAvailableLoginMethods(nextSession.availableLoginMethods),
normalizeAvailableLoginMethods(
restoredSession.session?.availableLoginMethods,
),
);
await resolveGuestFallback();
return;
}
const nextSession = restoredSession.session;
setUser(nextSession.user);
setAvailableLoginMethods(
normalizeAvailableLoginMethods(nextSession.availableLoginMethods),
@@ -368,7 +427,7 @@ export function AuthGate({ children }: AuthGateProps) {
isActive = false;
window.removeEventListener(AUTH_STATE_EVENT, handleAuthStateChange);
};
}, [activateReadyUser]);
}, [restoreAuthSession]);
useEffect(() => {
if (!readyUser) {