Commit 6e42a683 by luoqi

fix(auth): code 由 TTL 控失效(去消费即删)+ 嵌入首开失效诊断/企微告警

- exchange-code 由 getDel(消费一次即删)改为 get:code 在 60s TTL 内可重复换、
  返同一票,到点自然失效 → 根治宿主重载/双开的「登录已过期」(旧逻辑消费即删
  + 20s 宽限,超窗重载即 MISS)。去掉 grace 副本。
- 新增 bootstrap 全分支诊断埋点(前端 client-diag 信标 → 服务端 [client-diag] 日志),
  带存储/环境探针 storageWritable/isEmbedded/hostOrigin/isReload/ua。
- 会话失效(no-credentials/refresh-fail)推企微监控群(复用 AlertService,不去重):
  四类根因自动判(存储分区/重载复用/旧码/全新码到手即死),含 code 足迹
  (最新那张/已消费过/code 龄);全码只落服务端 MISS 日志(脱敏,不进群)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
parent d5ee4979
Pipeline #3356 failed in 0 seconds
...@@ -10,6 +10,7 @@ import { ...@@ -10,6 +10,7 @@ import {
} from '../../common/decorators/tenant-scope.decorator'; } from '../../common/decorators/tenant-scope.decorator';
import { AuthService } from './auth.service'; import { AuthService } from './auth.service';
import { import {
ClientDiagRequestDto,
ExchangeCodeRequestDto, ExchangeCodeRequestDto,
ExchangeCodeResponseDto, ExchangeCodeResponseDto,
MockLoginRequestDto, MockLoginRequestDto,
...@@ -55,6 +56,17 @@ export class AuthController { ...@@ -55,6 +56,17 @@ export class AuthController {
} }
@Public() @Public()
@Post('client-diag')
@ApiOperation({
summary:
'前端 bootstrap 诊断信标 — 把嵌入首开鉴权走的分支(有无 code / store 有无 token / 结局)落一行服务端日志,定位「登录已过期」',
})
clientDiag(@Body() dto: ClientDiagRequestDto) {
this.auth.clientDiag(dto);
return { ok: true };
}
@Public()
@Post('mock-login') @Post('mock-login')
@ZodResponse({ status: 200, type: MockLoginResponseDto }) @ZodResponse({ status: 200, type: MockLoginResponseDto })
@ApiOperation({ @ApiOperation({
......
...@@ -20,16 +20,24 @@ import { BizError } from '../../common/errors/biz-error'; ...@@ -20,16 +20,24 @@ import { BizError } from '../../common/errors/biz-error';
import { randomCode, verifySecret } from '@pac/utils'; import { randomCode, verifySecret } from '@pac/utils';
import { PrismaService } from '../../prisma/prisma.service'; import { PrismaService } from '../../prisma/prisma.service';
import { RedisService } from '../../redis/redis.service'; import { RedisService } from '../../redis/redis.service';
import { AlertService } from '../../common/alerting/alert.service';
import { parseDurationToSeconds } from './duration'; import { parseDurationToSeconds } from './duration';
import { loadMockUsers } from './mock-users'; import { loadMockUsers } from './mock-users';
const CODE_PREFIX = 'pac:exchange-code:'; const CODE_PREFIX = 'pac:exchange-code:';
/// 幂等副本:code 首次消费后短暂保留结果,让"同一 code 首屏被换两次"的竞态 /// code 失效策略:不再"消费一次即删",改由 TTL(EXCHANGE_CODE_TTL_SECONDS)控制 ——
/// (宿主预取 / iframe 双渲染 / 首个加载被打断但已消费)第二次换仍返回同一票,不报 10102。 /// TTL 内可重复换、返回同一票(宿主重载/双次加载都不误报「登录已过期」),到点自然失效。
const CODE_RESULT_PREFIX = 'pac:exchange-code:result:';
/// 幂等宽限窗(秒)—— 只需覆盖首屏两次加载的间隔;过窗即真失效,把可重放窗口压到最小。
const EXCHANGE_GRACE_SECONDS = 20;
const REFRESH_PREFIX = 'pac:refresh:'; const REFRESH_PREFIX = 'pac:refresh:';
/// 诊断反查:code 首次消费时记 codeTag→{身份, 时刻},供 client-diag 判"是否复用已消费 code"。
/// TTL 15 分钟 —— 覆盖"首开成功→稍后重载失败"的时间跨度,又不长期驻留。
const CODE_SEEN_PREFIX = 'pac:diag:code-seen:';
const CODE_SEEN_TTL_SECONDS = 15 * 60;
/// 诊断:mint 时记 codeTag→{userId, 签发时刻}(判失效 code 的身份与"有没有被消费过")。
const CODE_ISSUED_PREFIX = 'pac:diag:code-issued:';
/// 诊断:userId→最新签发的 codeTag(判失效时用的 code 是不是"最新那张",还是宿主拿了旧的)。
const LATEST_CODE_PREFIX = 'pac:diag:latest-code:';
const DIAG_CODE_TTL_SECONDS = 15 * 60;
// 注:不做告警去重 —— 每次会话失效都推企微。宿主若复用 code 刷爆群,正好是排查宿主的信号。
/** /**
* Refresh token payload(W3 末 A 方案降级版) * Refresh token payload(W3 末 A 方案降级版)
...@@ -53,6 +61,35 @@ interface RefreshPayload { ...@@ -53,6 +61,35 @@ interface RefreshPayload {
jti: string; jti: string;
} }
/** 一个 code 的签发/消费足迹(判"最新那张 / 消费过没 / 签发到现在多久")。 */
interface CodeTrace {
userId: string | null;
hostId: string | null;
tenantId: string | null;
issuedAt: string | null;
/** 签发到"现在"的秒数 —— 直接量出 mint→验证的时间差(> code TTL 即过期而死)。 */
ageSeconds: number | null;
usedBefore: boolean;
consumedAt: string | null;
isLatest: boolean | null;
}
/** 前端 bootstrap 诊断信标载荷(与 web use-auth-bootstrap.ts 的 reportDiag 对齐)。 */
interface ClientDiagPayload {
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;
}
@Injectable() @Injectable()
export class AuthService { export class AuthService {
private readonly logger = new Logger(AuthService.name); private readonly logger = new Logger(AuthService.name);
...@@ -62,6 +99,7 @@ export class AuthService { ...@@ -62,6 +99,7 @@ export class AuthService {
private readonly redis: RedisService, private readonly redis: RedisService,
private readonly jwt: JwtService, private readonly jwt: JwtService,
private readonly config: ConfigService, private readonly config: ConfigService,
private readonly alert: AlertService,
) {} ) {}
/** access token TTL(秒)— 进程内固定,memoize 一次(收口多处 parseDurationToSeconds) */ /** access token TTL(秒)— 进程内固定,memoize 一次(收口多处 parseDurationToSeconds) */
...@@ -109,6 +147,12 @@ export class AuthService { ...@@ -109,6 +147,12 @@ export class AuthService {
JSON.stringify({ accessToken, refreshToken }), JSON.stringify({ accessToken, refreshToken }),
codeTtl, codeTtl,
); );
// 诊断:记 codeTag→身份/签发时刻 + userId→最新 codeTag(供失败时判"是不是最新那张、消费过没")。
void this.recordCodeIssued(code.slice(0, 8), req.user.userId, host.id, req.user.tenantId).catch(
() => {
/* 诊断落库失败静默 */
},
);
const expiresIn = this.accessExpiresInSeconds; const expiresIn = this.accessExpiresInSeconds;
...@@ -124,23 +168,29 @@ export class AuthService { ...@@ -124,23 +168,29 @@ export class AuthService {
async exchangeCode(code: string): Promise<ExchangeCodeResponse> { async exchangeCode(code: string): Promise<ExchangeCodeResponse> {
const expiresIn = this.accessExpiresInSeconds; const expiresIn = this.accessExpiresInSeconds;
const tag = code.slice(0, 8); // 埋点用前缀(不泄全码),相邻日志可对同一 code 关联 const tag = code.slice(0, 8); // 埋点用前缀(不泄全码),相邻日志可对同一 code 关联
const raw = await this.redis.getDel(CODE_PREFIX + code); // 用 get(不删)—— code 在 TTL 内可重复换、返回同一票;到点由 TTL 自然失效。
if (raw) { // 这样宿主重载 / 双次加载(TTL 内)都换得到,不再误报「登录已过期」。
// 首次消费:落一份短期幂等副本,覆盖宿主首屏"同一 code 被换两次"的竞态。 const raw = await this.redis.get(CODE_PREFIX + code);
await this.redis.setEx(CODE_RESULT_PREFIX + code, raw, EXCHANGE_GRACE_SECONDS); if (!raw) {
this.logger.log(`[exchange-code] consumed(首次) code=${tag}…`); // 不存在 = TTL 已过期,或未知 / 伪造 code。反查足迹(最新那张? 消费过? code 龄?)。
} else { const trace = await this.traceCode(tag);
// 已被消费:宽限窗内同一 code 返回同一票(幂等,不报 10102);过窗 → 真失效。 // 全码只落服务端日志(便于人工核对;已失效不可换),不进企微。
const cached = await this.redis.get(CODE_RESULT_PREFIX + code); this.logger.warn(
if (!cached) { `[exchange-code] MISS 失效(TTL 过期或未知 code) code=${tag}… fullCode=${code} ${this.fmtTrace(trace)}`,
// 真失效:code 既不在(未消费池)也不在(幂等副本)= 过期 / 未知 code(非双消费)。 );
this.logger.warn(`[exchange-code] MISS 失效(过期或未知,非双消费) code=${tag}…`);
throw new BizError(ApiCode.AUTH_INVALID_CODE); throw new BizError(ApiCode.AUTH_INVALID_CODE);
} }
// ⭐ 关键证据:同一 code 在宽限窗内被换第二次 = 宿主首屏「双消费」实锤。 // 首次换 vs TTL 内重复换:靠 code-seen 区分(仅日志/诊断用,两者都正常返同一票)。
this.logger.warn(`[exchange-code] IDEMPOTENT 幂等命中(同一 code 重复换=首屏双消费) code=${tag}…`); const firstTime = !(await this.redis.get(CODE_SEEN_PREFIX + tag));
const p = JSON.parse(cached) as { accessToken: string; refreshToken: string }; if (firstTime) {
return { accessToken: p.accessToken, refreshToken: p.refreshToken, expiresIn }; this.logger.log(`[exchange-code] consumed(首次) code=${tag}…`);
// 诊断反查:记 codeTag→{身份, 首次换时刻},供 client-diag 判"复用/消费过"(不阻塞主流程)。
void this.recordCodeSeen(tag, raw).catch(() => {
/* 诊断落库失败静默 */
});
} else {
// TTL 内被再次换 = 宿主重载/双次加载,现在正常返票(不再是 MISS)。
this.logger.log(`[exchange-code] 重复换(TTL 内,幂等返同一票) code=${tag}…`);
} }
const parsed = JSON.parse(raw) as { accessToken: string; refreshToken: string }; const parsed = JSON.parse(raw) as { accessToken: string; refreshToken: string };
return { return {
...@@ -151,6 +201,205 @@ export class AuthService { ...@@ -151,6 +201,205 @@ export class AuthService {
} }
/** /**
* 前端 bootstrap 诊断信标 —— 落一行服务端日志;失败事件额外推企微监控群。
* 关键证据:嵌入首开重载时 exchange code 失败后,store 里到底有没有 token
* (storedAccess/storedRefresh)+ 存储能不能用(storageWritable)—— 把「登录已过期」
* 最后一环(iframe localStorage 读不到)钉死。codeTag 与相邻 [exchange-code] 埋点关联。
*/
clientDiag(d: ClientDiagPayload): void {
const b = (v: boolean | undefined) => (v === undefined ? '?' : v ? 'Y' : 'N');
this.logger.log(
`[client-diag] ev=${d.ev ?? '?'} code=${d.codeTag ?? '-'} hadCode=${b(d.hadCode)} ` +
`storedAccess=${b(d.storedAccess)} storedRefresh=${b(d.storedRefresh)} accessValid=${b(d.storedAccessValid)} ` +
`storageWritable=${b(d.storageWritable)} embedded=${b(d.isEmbedded)} reload=${b(d.isReload)}` +
(d.hostOrigin ? ` host=${d.hostOrigin}` : '') +
(d.err ? ` err=${d.err}` : ''),
);
// 只在"用户真失效"事件推企微(no-credentials / refresh-fail);成功/中间态只落日志不推。
if (d.ev === 'no-credentials' || d.ev === 'refresh-fail') {
void this.alertSessionExpiry(d).catch((e) =>
this.logger.warn(`[client-diag] 告警推送失败: ${e instanceof Error ? e.message : String(e)}`),
);
}
}
/** 首次消费时记 codeTag→{身份, 时刻}(供 client-diag 反查是否复用已消费 code)。脱敏:只存 opaque id。 */
private async recordCodeSeen(tag: string, raw: string): Promise<void> {
try {
const { accessToken } = JSON.parse(raw) as { accessToken: string };
const decoded = this.jwt.decode(accessToken) as {
sub?: string;
hostId?: string;
tenantId?: string;
} | null;
await this.redis.setEx(
CODE_SEEN_PREFIX + tag,
JSON.stringify({
at: new Date().toISOString(),
sub: decoded?.sub ?? null,
hostId: decoded?.hostId ?? null,
tenantId: decoded?.tenantId ?? null,
}),
CODE_SEEN_TTL_SECONDS,
);
} catch {
/* 诊断落库失败静默 */
}
}
/** mint 时记 codeTag→{身份, 签发时刻} + userId→最新 codeTag(供失败时判"最新那张 / 消费过没")。 */
private async recordCodeIssued(
tag: string,
userId: string,
hostId: string,
tenantId: string,
): Promise<void> {
try {
const at = new Date().toISOString();
await this.redis.setEx(
CODE_ISSUED_PREFIX + tag,
JSON.stringify({ userId, hostId, tenantId, at }),
DIAG_CODE_TTL_SECONDS,
);
// 同一 user 每次 mint 覆盖 → 永远指向最新签发的 code
await this.redis.setEx(LATEST_CODE_PREFIX + userId, tag, DIAG_CODE_TTL_SECONDS);
} catch {
/* 诊断落库失败静默 */
}
}
/**
* 反查一个 codeTag 的签发/消费足迹:
* usedBefore = 是否已被成功消费(重载复用死 code 的实锤);
* isLatest = 是不是该 user 最新签发的那张(false=宿主拿了旧 code;null=查不到);
* issuedAt/consumedAt/userId 等身份与时刻。
*/
private async traceCode(tag: string): Promise<CodeTrace> {
const empty: CodeTrace = {
userId: null,
hostId: null,
tenantId: null,
issuedAt: null,
ageSeconds: null,
usedBefore: false,
consumedAt: null,
isLatest: null,
};
try {
const [issuedRaw, seenRaw] = await Promise.all([
this.redis.get(CODE_ISSUED_PREFIX + tag),
this.redis.get(CODE_SEEN_PREFIX + tag),
]);
const t: CodeTrace = { ...empty };
if (issuedRaw) {
const p = JSON.parse(issuedRaw) as {
userId?: string;
hostId?: string;
tenantId?: string;
at?: string;
};
t.userId = p.userId ?? null;
t.hostId = p.hostId ?? null;
t.tenantId = p.tenantId ?? null;
t.issuedAt = p.at ?? null;
if (p.at) {
const ms = Date.now() - Date.parse(p.at);
if (!Number.isNaN(ms)) t.ageSeconds = Math.round(ms / 1000);
}
}
if (seenRaw) {
t.usedBefore = true;
try {
const s = JSON.parse(seenRaw) as { at?: string; sub?: string; hostId?: string; tenantId?: string };
t.consumedAt = s.at ?? null;
t.userId = t.userId ?? s.sub ?? null; // issued 没记到身份时从 seen 补
t.hostId = t.hostId ?? s.hostId ?? null;
t.tenantId = t.tenantId ?? s.tenantId ?? null;
} catch {
/* noop */
}
}
if (t.userId) {
const latest = await this.redis.get(LATEST_CODE_PREFIX + t.userId);
if (latest) t.isLatest = latest === tag;
}
return t;
} catch {
return empty;
}
}
/** codeTrace → 一行紧凑日志。 */
private fmtTrace(t: CodeTrace): string {
const y = (b: boolean | null) => (b === null ? '?' : b ? '是' : '否');
return (
`[足迹 签发=${t.userId ?? '-'}@${t.issuedAt ?? '-'} ` +
`=${t.ageSeconds ?? '?'}s 最新那张=${y(t.isLatest)} ` +
`已消费过=${y(t.usedBefore)}${t.consumedAt ? '@' + t.consumedAt : ''}]`
);
}
/**
* 会话失效告警 → 企微监控群(复用 AlertService)。
* 护栏:①server 端发(webhook URL 是密钥,不进前端);②去重窗仅收敛重复企微(不改鉴权、不丢日志);
* ③脱敏 —— 只发 codeTag(前 8)、布尔、opaque id,绝不带完整 code / token(全码只在服务端 MISS 日志)。
* 富信息:反查失效 code 的足迹 —— 是不是最新签发那张、有没有被消费过、签发身份与时刻。
*/
private async alertSessionExpiry(d: ClientDiagPayload): Promise<void> {
// 反查失效 code 足迹:最新那张? 消费过? 龄(mint→失败时间差)? 身份?
const trace = d.codeTag ? await this.traceCode(d.codeTag) : null;
const usedBefore = trace?.usedBefore ?? false;
// 不做去重:每次会话失效都推(宿主复用刷爆群 = 排查宿主的信号)。
// 根因推断:分清"存储分区" / "重载复用死码" / "旧码" / "全新最新码到手即死(时间差/淘汰)"
const storageBlocked = d.storageWritable === false;
const freshButDead =
!usedBefore && trace?.isLatest !== false && d.isReload !== true && d.storedRefresh !== true;
const rootCause = storageBlocked
? '存储不可写(iframe 第三方存储被分区/ITP/隐私模式拦)'
: usedBefore
? '重载复用已消费 code + store 无 token(疑似存储读不到)'
: trace?.isLatest === false
? '用了旧 code(非最新签发)+ store 无 token'
: freshButDead
? `全新最新 code 到手即死(疑似 mint→验证超时/Redis 淘汰;code ${trace?.ageSeconds ?? '?'}s)`
: '无凭据(可能首次访问未带 code,或 code 已过期)';
const y = (b: boolean | null | undefined) => (b === null || b === undefined ? '?' : b ? '是' : '否');
await this.alert.send({
level: 'critical',
title: '嵌入首开会话失效(登录已过期)',
body: [
`事件: ${d.ev} 根因推断: ${rootCause}`,
`失效code: ${d.codeTag ?? '-'} 最新那张: ${y(trace?.isLatest)} 已消费过: ${y(usedBefore)}${trace?.consumedAt ? `(${trace.consumedAt})` : ''}`,
`code : ${trace?.ageSeconds ?? '?'}s(签发→失败间隔;> code TTL 即过期而死)`,
`签发: ${trace?.userId ?? '-'} @ ${trace?.issuedAt ?? '-'} (全码见服务端 MISS 日志)`,
`store: access=${y(d.storedAccess)} refresh=${y(d.storedRefresh)} 存储可写: ${y(d.storageWritable)}`,
`嵌入: ${y(d.isEmbedded)} 重载: ${y(d.isReload)} 宿主: ${d.hostOrigin ?? '-'}`,
].join('\n'),
context: {
ev: d.ev,
codeTag: d.codeTag ?? null,
codeIsLatest: trace?.isLatest ?? null,
codeUsedBefore: usedBefore,
codeAgeSeconds: trace?.ageSeconds ?? null,
codeIssuedAt: trace?.issuedAt ?? null,
codeConsumedAt: trace?.consumedAt ?? null,
storedAccess: Boolean(d.storedAccess),
storedRefresh: Boolean(d.storedRefresh),
storageWritable: d.storageWritable ?? null,
isEmbedded: Boolean(d.isEmbedded),
isReload: Boolean(d.isReload),
hostOrigin: d.hostOrigin ?? null,
ua: d.ua ?? null,
hostId: trace?.hostId ?? null,
tenantId: trace?.tenantId ?? null,
userId: trace?.userId ?? null,
},
});
}
/**
* Refresh access token(W3 末 A 方案 — 本地降级,无 host SSO 回调) * Refresh access token(W3 末 A 方案 — 本地降级,无 host SSO 回调)
* *
* 流程: * 流程:
......
import { createZodDto } from 'nestjs-zod'; import { createZodDto } from 'nestjs-zod';
import { z } from 'zod';
import { import {
ExchangeCodeRequestSchema, ExchangeCodeRequestSchema,
ExchangeCodeResponseSchema, ExchangeCodeResponseSchema,
...@@ -22,3 +23,27 @@ export class MockLoginRequestDto extends createZodDto(MockLoginRequestSchema) {} ...@@ -22,3 +23,27 @@ export class MockLoginRequestDto extends createZodDto(MockLoginRequestSchema) {}
export class MockLoginResponseDto extends createZodDto(MockLoginResponseSchema) {} export class MockLoginResponseDto extends createZodDto(MockLoginResponseSchema) {}
export class MockUsersResponseDto extends createZodDto(MockUsersResponseSchema) {} export class MockUsersResponseDto extends createZodDto(MockUsersResponseSchema) {}
export class SessionResponseDto extends createZodDto(SessionResponseSchema) {} export class SessionResponseDto extends createZodDto(SessionResponseSchema) {}
/**
* 前端 bootstrap 诊断信标(嵌入首开鉴权路径定位用)。
* 全字段可选 + passthrough:只为落一行服务端日志,不做业务校验,尽量不因字段挑剔而丢诊断。
*/
export const ClientDiagRequestSchema = z
.object({
phase: z.string().max(40).optional(),
ev: z.string().max(40).optional(),
hadCode: z.boolean().optional(),
codeTag: z.string().max(16).nullable().optional(),
storedAccess: z.boolean().optional(),
storedRefresh: z.boolean().optional(),
storedAccessValid: z.boolean().optional(),
err: z.string().max(120).nullable().optional(),
// 环境/存储探针
storageWritable: z.boolean().optional(),
isEmbedded: z.boolean().optional(),
hostOrigin: z.string().max(200).nullable().optional(),
isReload: z.boolean().optional(),
ua: z.string().max(200).optional(),
})
.passthrough();
export class ClientDiagRequestDto extends createZodDto(ClientDiagRequestSchema) {}
...@@ -3,9 +3,110 @@ ...@@ -3,9 +3,110 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { useAuthStore } from '@/stores/auth-store'; import { useAuthStore } from '@/stores/auth-store';
import { exchangeCode, refreshToken } from '@/lib/auth-api'; import { exchangeCode, refreshToken } from '@/lib/auth-api';
import { env } from '@/lib/env';
const REFRESH_LEAD_MS = 60_000; const REFRESH_LEAD_MS = 60_000;
/** 当前 store 的 token 快照(反水合后)—— 诊断嵌入重载时"localStorage 到底读没读到" */
function storeSnap() {
const s = useAuthStore.getState();
return {
storedAccess: Boolean(s.accessToken),
storedRefresh: Boolean(s.refreshToken),
storedAccessValid: Boolean(s.accessToken && s.expiresAt && Date.now() < s.expiresAt),
};
}
/**
* 环境/存储探针 —— 定位「登录已过期」根因的关键。
* storedRefresh=N 分不清"真没登录过"(正常)vs"存储被 iframe 分区/ITP 清了"(bug);
* 这几个字段才能证死:storageWritable(存储到底能不能用)+ isEmbedded(是否在 iframe)
* + hostOrigin(哪个宿主)+ isReload(是不是重载)+ ua(Safari→ITP)。
*/
function envSnap() {
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 };
}
/**
* bootstrap 诊断信标 —— 同时打 console(本地/开 devtools 可看)+ 打服务端日志
* (navigator.sendBeacon,远端对接方复现时我们 grep pac-service 日志即可,不用他们开 devtools)。
* 纯诊断,失败静默,绝不影响鉴权主流程。
*/
function reportDiag(payload: {
ev: string;
hadCode?: boolean;
codeTag?: string | null;
storedAccess?: boolean;
storedRefresh?: boolean;
storedAccessValid?: boolean;
err?: string | null;
// 环境/存储探针(失败事件带上,server 据此判"存储被清 vs 真没登录 + 哪个宿主/浏览器")
storageWritable?: boolean;
isEmbedded?: boolean;
hostOrigin?: string | null;
isReload?: boolean;
ua?: string;
}) {
try {
// eslint-disable-next-line no-console
console.info('[auth-bootstrap]', 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 不允许)。fetch 默认 same-origin 凭据 → 跨源即不带,与 api-client 一致。
// keepalive 保证即使随后页面跳转/卸载,这条诊断也能发出。
void fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body,
keepalive: true,
credentials: 'omit',
}).catch(() => {
/* 诊断失败静默 */
});
} catch {
/* noop */
}
}
/** /**
* 鉴权启动 — 三路兜底: * 鉴权启动 — 三路兜底:
* 1. URL 有 ?code= → 优先 exchange 拿新 token(host 刚跳来 / 用户手动重开) * 1. URL 有 ?code= → 优先 exchange 拿新 token(host 刚跳来 / 用户手动重开)
...@@ -30,6 +131,11 @@ export function useAuthBootstrap() { ...@@ -30,6 +131,11 @@ export function useAuthBootstrap() {
const url = new URL(window.location.href); const url = new URL(window.location.href);
const code = url.searchParams.get('code'); const code = url.searchParams.get('code');
const codeTag = code ? code.slice(0, 8) : null;
const envInfo = envSnap(); // 存储/环境探针,一次算好,失败事件带上
// 入口快照:URL 有没有 code + 反水合后 store 有没有 token + 环境(嵌入首开路径起点证据)
reportDiag({ ev: 'start', hadCode: Boolean(code), codeTag, ...storeSnap(), ...envInfo });
const run = async () => { const run = async () => {
// ─── 1. URL 有 code → 优先(新人接管,覆盖旧 token)─── // ─── 1. URL 有 code → 优先(新人接管,覆盖旧 token)───
...@@ -40,12 +146,21 @@ export function useAuthBootstrap() { ...@@ -40,12 +146,21 @@ export function useAuthBootstrap() {
setTokens(r); setTokens(r);
url.searchParams.delete('code'); url.searchParams.delete('code');
window.history.replaceState({}, '', url.toString()); window.history.replaceState({}, '', url.toString());
reportDiag({ ev: 'code-ok', codeTag });
setStatus('ready'); setStatus('ready');
return; return;
} catch { } catch (e) {
// code 失效(过期 / 重放) — 不一定要崩,localStorage 可能还有效 // code 失效(过期 / 重放) — 不一定要崩,localStorage 可能还有效
url.searchParams.delete('code'); url.searchParams.delete('code');
window.history.replaceState({}, '', url.toString()); window.history.replaceState({}, '', url.toString());
// ⭐ 关键证据:换 code 失败瞬间,store 里到底有没有 token(有→能兜住;无→必弹登录已过期)
reportDiag({
ev: 'code-fail',
codeTag,
err: e instanceof Error ? e.message.slice(0, 100) : String(e).slice(0, 100),
...storeSnap(),
...envInfo,
});
// fall through 试 store // fall through 试 store
} }
} }
...@@ -53,6 +168,7 @@ export function useAuthBootstrap() { ...@@ -53,6 +168,7 @@ export function useAuthBootstrap() {
// ─── 2. localStorage 反水合的 access token 还活着 → ready ─── // ─── 2. localStorage 反水合的 access token 还活着 → ready ───
const s = useAuthStore.getState(); const s = useAuthStore.getState();
if (s.accessToken && s.expiresAt && Date.now() < s.expiresAt) { if (s.accessToken && s.expiresAt && Date.now() < s.expiresAt) {
reportDiag({ ev: 'access-ok', codeTag, ...storeSnap() });
setStatus('ready'); setStatus('ready');
return; return;
} }
...@@ -63,16 +179,25 @@ export function useAuthBootstrap() { ...@@ -63,16 +179,25 @@ export function useAuthBootstrap() {
try { try {
const r = await refreshToken(s.refreshToken); const r = await refreshToken(s.refreshToken);
setTokens(r); setTokens(r);
reportDiag({ ev: 'refresh-ok', codeTag });
setStatus('ready'); setStatus('ready');
return; return;
} catch { } catch {
reportDiag({ ev: 'refresh-fail', codeTag, ...storeSnap(), ...envInfo });
clear(); clear();
setStatus('error'); setStatus('error');
return; return;
} }
} }
// ─── 4. 全没了 ─── // ─── 4. 全没了 → 弹「登录已过期」───
reportDiag({
ev: 'no-credentials',
codeTag,
hadCode: Boolean(code),
...storeSnap(),
...envInfo,
});
setStatus('error'); setStatus('error');
}; };
......
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