Commit fcd77227 by luoqi

fix(auth): 修运行期会话掉线("登录已过期")—— 治竞态 + 补埋点告警

根因(对接方反复报的"用着用着登录已过期"):
- 竞态:bootstrap 的"过期前 60s 自动刷"定时器直接调 refreshToken(),绕过 api-client 的
  单飞锁 → 定时器与请求 401 兜底刷可能同时拿同一个一次性 refresh token 去换 → 一个成功
  轮换(del 旧 jti),另一个拿已作废 jti → 10103 revoked → 误清会话。
- 盲区:api-client 运行期 clear() 路径无 client-diag 信标 → 服务端无日志、不触发企微告警。

改动:
- lib/auth-diag.ts:抽出共享诊断信标(bootstrap 与 api-client 共用)。
- api-client.ts:两个 clear 点(refresh 被拒 / 受保护请求鉴权失败)补运行期掉线信标。
- use-auth-bootstrap.ts:过期定时器改走共享单飞 tryRefresh,消除双刷竞态。
- auth.service.ts:clientDiag 告警门控纳入 runtime-refresh-fail / runtime-auth-clear,
  alertSessionExpiry 增运行期根因文案与标题。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
parent 607d7f7f
Pipeline #3366 failed in 0 seconds
......@@ -217,7 +217,13 @@ export class AuthService {
);
// 只对"真·嵌入/换票失败"推企微:①带 code 的失败(换票没成),或 ②在宿主 iframe 内失败。
// 直接浏览器打开 /plans(无 code、非嵌入)= 正常走 mock/SSO,不是故障,只落日志不推(否则误报刷屏)。
const isFailure = d.ev === 'no-credentials' || d.ev === 'refresh-fail';
// 运行期掉线(用着用着 access 过期 + refresh 被拒 → api-client clear)也纳入:
// runtime-refresh-fail(refresh 被拒)/ runtime-auth-clear(受保护请求鉴权失败清会话)。
const isFailure =
d.ev === 'no-credentials' ||
d.ev === 'refresh-fail' ||
d.ev === 'runtime-refresh-fail' ||
d.ev === 'runtime-auth-clear';
const worthAlert = isFailure && (d.hadCode === true || d.isEmbedded === true);
if (worthAlert) {
void this.alertSessionExpiry(d).catch((e) =>
......@@ -363,7 +369,13 @@ export class AuthService {
trace?.isLatest !== false &&
d.isReload !== true &&
d.storedRefresh !== true;
const rootCause = storageBlocked
// 运行期掉线(登录后用着用着失效)—— 与"首开换票失败"根因不同,单独判。
const isRuntime = d.ev === 'runtime-refresh-fail' || d.ev === 'runtime-auth-clear';
const rootCause = isRuntime
? d.ev === 'runtime-refresh-fail'
? 'refresh 被拒(refresh token 轮换/过期,或定时刷与请求刷竞态双刷互相作废)→ 运行期掉线'
: `受保护请求鉴权失败清会话(${d.err ?? '?'}) 运行期掉线`
: storageBlocked
? '存储不可写(iframe 第三方存储被分区/ITP/隐私模式拦)'
: usedBefore
? '重载复用已消费 code + store 无 token(疑似存储读不到)'
......@@ -378,7 +390,7 @@ export class AuthService {
await this.alert.send({
level: 'critical',
title: '嵌入首开会话失效(登录已过期)',
title: isRuntime ? '运行期会话掉线(登录已过期)' : '嵌入首开会话失效(登录已过期)',
body: [
`事件: ${d.ev} 根因推断: ${rootCause}`,
`失效code: ${d.codeTag ?? '-'} 最新那张: ${y(trace?.isLatest)} 已消费过: ${y(usedBefore)}${trace?.consumedAt ? `(${trace.consumedAt})` : ''}`,
......
......@@ -3,6 +3,7 @@
import { useEffect, useRef, useState } from 'react';
import { useAuthStore } from '@/stores/auth-store';
import { exchangeCode, refreshToken } from '@/lib/auth-api';
import { tryRefresh } from '@/lib/api-client';
import { env } from '@/lib/env';
const REFRESH_LEAD_MS = 60_000;
......@@ -204,20 +205,19 @@ export function useAuthBootstrap() {
void run();
}, [setTokens, clear]);
// 过期前 60s 自动 silent refresh
// 过期前 60s 自动 silent refresh —— 走 api-client 的共享单飞 tryRefresh,
// 与请求 401 兜底刷共用同一个"在途 refresh"。否则定时器和请求兜底可能同时拿
// 同一个一次性 refresh token 去换:一个成功轮换(del 旧 jti),另一个拿已作废 jti
// → 10103 revoked → 误清会话 =「用着用着登录已过期」的竞态根因。
// tryRefresh 内部成功即 setTokens、失败即 clear + 发运行期掉线信标,故此处不再手动处理。
useEffect(() => {
if (!expiresAt || !refresh) return;
const delay = Math.max(0, expiresAt - Date.now() - REFRESH_LEAD_MS);
const timer = setTimeout(async () => {
try {
const res = await refreshToken(refresh);
setTokens(res);
} catch {
clear();
}
const timer = setTimeout(() => {
void tryRefresh();
}, delay);
return () => clearTimeout(timer);
}, [expiresAt, refresh, setTokens, clear]);
}, [expiresAt, refresh]);
return { status };
}
......@@ -3,6 +3,7 @@
import { ApiCode, type ApiResponse } from '@pac/types';
import { env } from './env';
import { useAuthStore } from '@/stores/auth-store';
import { reportAuthDiag, authStoreSnap, authEnvSnap } from './auth-diag';
/**
* PAC API client.
......@@ -111,6 +112,14 @@ async function request<T>(path: string, opts: RequestOptions = {}): Promise<T> {
payload.code < 10200 &&
payload.code !== ApiCode.AUTH_PERMISSION_DENIED
) {
// 运行期掉线埋点 —— 这条路径过去没信标,导致「用着用着登录已过期」在服务端无迹可寻、
// 也不触发企微告警。补上(clear 前取快照,否则字段全空)。
reportAuthDiag({
ev: 'runtime-auth-clear',
err: `code=${payload.code}`,
...authStoreSnap(),
...authEnvSnap(),
});
useAuthStore.getState().clear();
}
......@@ -166,6 +175,13 @@ async function doRefresh(): Promise<boolean> {
return true;
}
// refresh 也失败 → 清 store,引导上层走"需要重新登录"
// 埋点:refresh 被拒(常见 10103 revoked = refresh token 被轮换/过期)是运行期掉线主因,必须可见+可告警。
reportAuthDiag({
ev: 'runtime-refresh-fail',
err: `code=${payload.code}`,
...authStoreSnap(),
...authEnvSnap(),
});
store.clear();
return false;
} catch {
......
'use client';
import { env } from './env';
import { useAuthStore } from '@/stores/auth-store';
/**
* 鉴权诊断信标(共享)—— bootstrap 首屏路径 + api-client 运行期掉线路径共用。
* 同时打 console(本地/开 devtools 可见)+ 打服务端日志(远端对接方复现时 grep pac-service
* 即可,不用他们开 devtools)。失败事件里的 embedded/storageWritable/storedRefresh 是服务端
* 判「真掉线 vs 存储被 iframe 分区」+ 触发企微告警的依据。纯诊断,失败静默,绝不影响鉴权主流程。
*/
/** 当前 store 的 token 快照(反水合后)—— 诊断嵌入重载/掉线时"token 到底还在不在" */
export function authStoreSnap() {
const s = useAuthStore.getState();
return {
storedAccess: Boolean(s.accessToken),
storedRefresh: Boolean(s.refreshToken),
storedAccessValid: Boolean(s.accessToken && s.expiresAt && Date.now() < s.expiresAt),
};
}
/** 环境/存储探针 —— storageWritable(存储能不能用)+ isEmbedded(是否 iframe)+ 宿主/重载/UA。 */
export function authEnvSnap() {
let storageWritable = false;
try {
const k = '__pac_probe__';
localStorage.setItem(k, '1');
storageWritable = localStorage.getItem(k) === '1';
localStorage.removeItem(k);
} catch {
storageWritable = false; // 抛错 = 存储被禁/分区(隐私模式 / 第三方 iframe 被拦)
}
let isEmbedded = false;
try {
isEmbedded = window.self !== window.top;
} catch {
isEmbedded = true; // 跨源访问 window.top 抛错 = 确实被跨源嵌入
}
let hostOrigin: string | null = null;
try {
const anc = window.location.ancestorOrigins;
hostOrigin =
(anc && anc.length ? anc[0] : null) ??
(document.referrer ? new URL(document.referrer).origin : null);
} catch {
hostOrigin = null;
}
let isReload = false;
try {
const nav = performance.getEntriesByType('navigation')[0] as
| PerformanceNavigationTiming
| undefined;
isReload = nav?.type === 'reload';
} catch {
/* noop */
}
const ua = typeof navigator !== 'undefined' ? navigator.userAgent.slice(0, 160) : '';
return { storageWritable, isEmbedded, hostOrigin, isReload, ua };
}
export interface AuthDiagPayload {
ev: string;
hadCode?: boolean;
codeTag?: string | null;
storedAccess?: boolean;
storedRefresh?: boolean;
storedAccessValid?: boolean;
err?: string | null;
storageWritable?: boolean;
isEmbedded?: boolean;
hostOrigin?: string | null;
isReload?: boolean;
ua?: string;
}
export function reportAuthDiag(payload: AuthDiagPayload) {
try {
// eslint-disable-next-line no-console
console.info('[auth-diag]', payload);
} catch {
/* noop */
}
try {
const url = new URL('/pac/v1/auth/client-diag', env.apiBaseUrl).toString();
const body = JSON.stringify({ phase: 'bootstrap', ...payload });
// 用 fetch(不用 sendBeacon):sendBeacon 强制带凭据,遇服务端 CORS 通配 origin 会被拦。
// keepalive 保证即使随后页面跳转/卸载,这条诊断也能发出。
void fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body,
keepalive: true,
credentials: 'omit',
}).catch(() => {
/* 诊断失败静默 */
});
} catch {
/* noop */
}
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment