diff --git a/docs/technical/DAILY_LOGIN_TRACKING_AUTH_CLOSURE_2026-05-08.md b/docs/technical/DAILY_LOGIN_TRACKING_AUTH_CLOSURE_2026-05-08.md index 8305bdda..82d4018a 100644 --- a/docs/technical/DAILY_LOGIN_TRACKING_AUTH_CLOSURE_2026-05-08.md +++ b/docs/technical/DAILY_LOGIN_TRACKING_AUTH_CLOSURE_2026-05-08.md @@ -9,6 +9,14 @@ 但认证成功链路还没有调用该方法,因此当前只完成了“任务中心读取不污染登录埋点”,没有完成“用户真实登录写入每日登录埋点”。 +## 现象 + +用户已经登录、cookie 未过期时,直接打开网页并不会触发每日登录埋点。原因是前端恢复登录态只读取 `/api/auth/me`,这条链路不会主动走 refresh cookie 续期,因此后端新的埋点写入点不会被触发。 + +## 修复思路 + +在 `AuthGate` 恢复已登录会话时,先主动调用一次 refresh 接口轮换 refresh cookie,再调用 `/api/auth/me` 读取当前会话。这样无论本地 access token 是否仍然有效,打开页面都会进入 refresh 续期链路,从而触发后端的 `daily_login` 埋点写入。 + ## 目标 在用户认证成功并创建 refresh session / access token 后,异步尝试写入每日登录埋点。 @@ -58,6 +66,7 @@ record_daily_login_tracking_event_after_auth_success( - `cargo check -p spacetime-client` - `npm run check:encoding` - `git diff --check` +- `npm run test -- AuthGate.test.tsx` ## 注意 diff --git a/server-rs/crates/spacetime-module/src/runtime/profile.rs b/server-rs/crates/spacetime-module/src/runtime/profile.rs index 80811159..e9ff09d5 100644 --- a/server-rs/crates/spacetime-module/src/runtime/profile.rs +++ b/server-rs/crates/spacetime-module/src/runtime/profile.rs @@ -520,7 +520,7 @@ pub fn get_profile_task_center( ctx: &mut ProcedureContext, input: RuntimeProfileTaskCenterGetInput, ) -> RuntimeProfileTaskCenterProcedureResult { - match ctx.try_with_tx(|tx| get_profile_task_center_snapshot(tx, input.clone(), true)) { + match ctx.try_with_tx(|tx| get_profile_task_center_snapshot(tx, input.clone(), false)) { Ok(record) => RuntimeProfileTaskCenterProcedureResult { ok: true, record: Some(record), diff --git a/src/components/auth/AuthGate.test.tsx b/src/components/auth/AuthGate.test.tsx index 0a743656..42c4411e 100644 --- a/src/components/auth/AuthGate.test.tsx +++ b/src/components/auth/AuthGate.test.tsx @@ -13,6 +13,7 @@ const authMocks = vi.hoisted(() => ({ authEntry: vi.fn(), changePassword: vi.fn(), ensureStoredAccessToken: vi.fn(), + refreshStoredAccessToken: vi.fn(), getAuthLoginOptions: vi.fn(), getCurrentAuthUser: vi.fn(), loginWithPhoneCode: vi.fn(), @@ -28,6 +29,7 @@ const authMocks = vi.hoisted(() => ({ vi.mock('../../services/apiClient', () => ({ AUTH_STATE_EVENT: 'genarrative-auth-state-changed', ensureStoredAccessToken: authMocks.ensureStoredAccessToken, + refreshStoredAccessToken: authMocks.refreshStoredAccessToken, })); vi.mock('../../services/authService', () => ({ @@ -94,6 +96,7 @@ beforeEach(() => { window.history.replaceState(null, '', '/'); authMocks.consumeAuthCallbackResult.mockReturnValue(null); authMocks.ensureStoredAccessToken.mockResolvedValue('jwt-existing-token'); + authMocks.refreshStoredAccessToken.mockResolvedValue('jwt-refreshed-token'); authMocks.getCurrentAuthUser.mockResolvedValue({ user: null, availableLoginMethods: ['phone'], @@ -204,12 +207,12 @@ test('auth gate keeps platform content visible when phone login is available', a expect(screen.queryByText('先登录账号,再同步你的冒险进度。')).toBeNull(); }); -test('auth gate waits for access token refresh before exposing restored user content', async () => { +test('auth gate waits for refresh cookie rotation before exposing restored user content', async () => { let resolveToken!: (token: string) => void; const tokenPromise = new Promise((resolve) => { resolveToken = resolve; }); - authMocks.ensureStoredAccessToken.mockReturnValue(tokenPromise); + authMocks.refreshStoredAccessToken.mockReturnValue(tokenPromise); authMocks.getCurrentAuthUser.mockResolvedValue({ user: mockUser, availableLoginMethods: ['phone'], @@ -224,10 +227,11 @@ test('auth gate waits for access token refresh before exposing restored user con expect(screen.getByText('正在校验登录状态...')).toBeTruthy(); expect(authMocks.getCurrentAuthUser).not.toHaveBeenCalled(); - resolveToken('jwt-restored-token'); + resolveToken('jwt-refreshed-token'); expect(await screen.findByText('应用内容')).toBeTruthy(); - expect(authMocks.ensureStoredAccessToken).toHaveBeenCalledTimes(1); + expect(authMocks.refreshStoredAccessToken).toHaveBeenCalledTimes(1); + expect(authMocks.ensureStoredAccessToken).not.toHaveBeenCalled(); expect(authMocks.getCurrentAuthUser).toHaveBeenCalledTimes(1); }); @@ -440,7 +444,7 @@ test('auth state refresh keeps mounted platform content and local tab state', as const tokenPromise = new Promise((resolve) => { resolveToken = resolve; }); - authMocks.ensureStoredAccessToken.mockReturnValueOnce(tokenPromise); + authMocks.refreshStoredAccessToken.mockReturnValueOnce(tokenPromise); act(() => { window.dispatchEvent(new Event('genarrative-auth-state-changed')); diff --git a/src/components/auth/AuthGate.tsx b/src/components/auth/AuthGate.tsx index 99be0b9c..a853a157 100644 --- a/src/components/auth/AuthGate.tsx +++ b/src/components/auth/AuthGate.tsx @@ -10,7 +10,7 @@ import { import { useGameSettings } from '../../hooks/useGameSettings'; import { AUTH_STATE_EVENT, - ensureStoredAccessToken, + refreshStoredAccessToken, } from '../../services/apiClient'; import { type AuthAuditLogEntry, @@ -311,7 +311,10 @@ export function AuthGate({ children }: AuthGateProps) { } try { - await ensureStoredAccessToken(); + // 中文注释:打开已登录页面也要主动轮换 refresh cookie。 + // 后端只在 refresh/session 成功续期时写每日登录埋点;如果本地 access token 尚未过期, + // 仅调用 /auth/me 不会进入续期链路,导致“打开网页”没有登录埋点。 + await refreshStoredAccessToken(); const nextSession = await getCurrentAuthUser(); if (!isActive) { return; diff --git a/src/services/apiClient.ts b/src/services/apiClient.ts index c1b470a8..714625bb 100644 --- a/src/services/apiClient.ts +++ b/src/services/apiClient.ts @@ -513,6 +513,10 @@ export async function ensureStoredAccessToken() { return refreshAccessToken(); } +export async function refreshStoredAccessToken() { + return refreshAccessToken(); +} + export async function fetchWithApiAuth( input: string, init: RequestInit = {},