fix: trigger login tracking on session restore

This commit is contained in:
2026-05-08 14:36:56 +08:00
parent 91d993dc6b
commit e694c6605a
5 changed files with 28 additions and 8 deletions

View File

@@ -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 后,异步尝试写入每日登录埋点。 在用户认证成功并创建 refresh session / access token 后,异步尝试写入每日登录埋点。
@@ -58,6 +66,7 @@ record_daily_login_tracking_event_after_auth_success(
- `cargo check -p spacetime-client` - `cargo check -p spacetime-client`
- `npm run check:encoding` - `npm run check:encoding`
- `git diff --check` - `git diff --check`
- `npm run test -- AuthGate.test.tsx`
## 注意 ## 注意

View File

@@ -520,7 +520,7 @@ pub fn get_profile_task_center(
ctx: &mut ProcedureContext, ctx: &mut ProcedureContext,
input: RuntimeProfileTaskCenterGetInput, input: RuntimeProfileTaskCenterGetInput,
) -> RuntimeProfileTaskCenterProcedureResult { ) -> 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(record) => RuntimeProfileTaskCenterProcedureResult {
ok: true, ok: true,
record: Some(record), record: Some(record),

View File

@@ -13,6 +13,7 @@ const authMocks = vi.hoisted(() => ({
authEntry: vi.fn(), authEntry: vi.fn(),
changePassword: vi.fn(), changePassword: vi.fn(),
ensureStoredAccessToken: vi.fn(), ensureStoredAccessToken: vi.fn(),
refreshStoredAccessToken: vi.fn(),
getAuthLoginOptions: vi.fn(), getAuthLoginOptions: vi.fn(),
getCurrentAuthUser: vi.fn(), getCurrentAuthUser: vi.fn(),
loginWithPhoneCode: vi.fn(), loginWithPhoneCode: vi.fn(),
@@ -28,6 +29,7 @@ const authMocks = vi.hoisted(() => ({
vi.mock('../../services/apiClient', () => ({ vi.mock('../../services/apiClient', () => ({
AUTH_STATE_EVENT: 'genarrative-auth-state-changed', AUTH_STATE_EVENT: 'genarrative-auth-state-changed',
ensureStoredAccessToken: authMocks.ensureStoredAccessToken, ensureStoredAccessToken: authMocks.ensureStoredAccessToken,
refreshStoredAccessToken: authMocks.refreshStoredAccessToken,
})); }));
vi.mock('../../services/authService', () => ({ vi.mock('../../services/authService', () => ({
@@ -94,6 +96,7 @@ beforeEach(() => {
window.history.replaceState(null, '', '/'); window.history.replaceState(null, '', '/');
authMocks.consumeAuthCallbackResult.mockReturnValue(null); authMocks.consumeAuthCallbackResult.mockReturnValue(null);
authMocks.ensureStoredAccessToken.mockResolvedValue('jwt-existing-token'); authMocks.ensureStoredAccessToken.mockResolvedValue('jwt-existing-token');
authMocks.refreshStoredAccessToken.mockResolvedValue('jwt-refreshed-token');
authMocks.getCurrentAuthUser.mockResolvedValue({ authMocks.getCurrentAuthUser.mockResolvedValue({
user: null, user: null,
availableLoginMethods: ['phone'], availableLoginMethods: ['phone'],
@@ -204,12 +207,12 @@ test('auth gate keeps platform content visible when phone login is available', a
expect(screen.queryByText('先登录账号,再同步你的冒险进度。')).toBeNull(); 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; let resolveToken!: (token: string) => void;
const tokenPromise = new Promise<string>((resolve) => { const tokenPromise = new Promise<string>((resolve) => {
resolveToken = resolve; resolveToken = resolve;
}); });
authMocks.ensureStoredAccessToken.mockReturnValue(tokenPromise); authMocks.refreshStoredAccessToken.mockReturnValue(tokenPromise);
authMocks.getCurrentAuthUser.mockResolvedValue({ authMocks.getCurrentAuthUser.mockResolvedValue({
user: mockUser, user: mockUser,
availableLoginMethods: ['phone'], availableLoginMethods: ['phone'],
@@ -224,10 +227,11 @@ test('auth gate waits for access token refresh before exposing restored user con
expect(screen.getByText('正在校验登录状态...')).toBeTruthy(); expect(screen.getByText('正在校验登录状态...')).toBeTruthy();
expect(authMocks.getCurrentAuthUser).not.toHaveBeenCalled(); expect(authMocks.getCurrentAuthUser).not.toHaveBeenCalled();
resolveToken('jwt-restored-token'); resolveToken('jwt-refreshed-token');
expect(await screen.findByText('应用内容')).toBeTruthy(); 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); 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<string>((resolve) => { const tokenPromise = new Promise<string>((resolve) => {
resolveToken = resolve; resolveToken = resolve;
}); });
authMocks.ensureStoredAccessToken.mockReturnValueOnce(tokenPromise); authMocks.refreshStoredAccessToken.mockReturnValueOnce(tokenPromise);
act(() => { act(() => {
window.dispatchEvent(new Event('genarrative-auth-state-changed')); window.dispatchEvent(new Event('genarrative-auth-state-changed'));

View File

@@ -10,7 +10,7 @@ import {
import { useGameSettings } from '../../hooks/useGameSettings'; import { useGameSettings } from '../../hooks/useGameSettings';
import { import {
AUTH_STATE_EVENT, AUTH_STATE_EVENT,
ensureStoredAccessToken, refreshStoredAccessToken,
} from '../../services/apiClient'; } from '../../services/apiClient';
import { import {
type AuthAuditLogEntry, type AuthAuditLogEntry,
@@ -311,7 +311,10 @@ export function AuthGate({ children }: AuthGateProps) {
} }
try { try {
await ensureStoredAccessToken(); // 中文注释:打开已登录页面也要主动轮换 refresh cookie。
// 后端只在 refresh/session 成功续期时写每日登录埋点;如果本地 access token 尚未过期,
// 仅调用 /auth/me 不会进入续期链路,导致“打开网页”没有登录埋点。
await refreshStoredAccessToken();
const nextSession = await getCurrentAuthUser(); const nextSession = await getCurrentAuthUser();
if (!isActive) { if (!isActive) {
return; return;

View File

@@ -513,6 +513,10 @@ export async function ensureStoredAccessToken() {
return refreshAccessToken(); return refreshAccessToken();
} }
export async function refreshStoredAccessToken() {
return refreshAccessToken();
}
export async function fetchWithApiAuth( export async function fetchWithApiAuth(
input: string, input: string,
init: RequestInit = {}, init: RequestInit = {},