Skip to content

Commit 852ad2a

Browse files
committed
feat(classroom): 网关健康软阻断与应用内确认对话框
- 新增 useGatewayHealth 健康检查 hook,启动会话前软阻断(含探针代际保护,评审 F 项修复) - 新增 ConfirmDialog/useConfirm 替换课堂模块 window.confirm - gatewayHealthCache 抽取 verifyHealthResponse 统一 /health/quick 响应体校验口径 - 删除独立 probeGateway,探针统一走缓存口径
1 parent 05576ff commit 852ad2a

5 files changed

Lines changed: 437 additions & 19 deletions

File tree

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
/**
2+
* ConfirmDialog — 课堂模块应用内确认对话框(替代 window.confirm)
3+
*
4+
* @ai-context: 组装共享 Modal(Radix Dialog)+ 确认/取消按钮。Modal 有意
5+
* 屏蔽 Escape(ui/Modal.tsx),故本组件在 open 期间自挂 window Escape
6+
* keydown 监听触发取消(卸载/关闭时移除);确认按钮挂载即聚焦,Enter
7+
* 由原生 button 行为触发确认。
8+
* @ai-context: In-app confirm dialog replacing window.confirm. Modal blocks
9+
* Escape by design, so this component attaches its own Escape listener to
10+
* cancel; the confirm button auto-focuses so Enter confirms natively.
11+
*/
12+
import { useEffect, useRef } from 'react';
13+
import { Modal } from '@/components/ui/Modal';
14+
15+
export interface ConfirmDialogProps {
16+
open: boolean;
17+
title: string;
18+
description?: string;
19+
confirmLabel?: string;
20+
cancelLabel?: string;
21+
onConfirm: () => void;
22+
onCancel: () => void;
23+
}
24+
25+
/**
26+
* 应用内确认对话框
27+
* @param props - ConfirmDialogProps
28+
* @returns React 对话框元素
29+
*/
30+
export function ConfirmDialog({
31+
open, title, description, confirmLabel = '确认', cancelLabel = '取消',
32+
onConfirm, onCancel,
33+
}: ConfirmDialogProps) {
34+
const confirmRef = useRef<HTMLButtonElement>(null);
35+
36+
// Modal 屏蔽 Escape(有意设计,不改共享组件):此处自挂监听触发取消
37+
useEffect(() => {
38+
if (!open) return;
39+
const onKey = (e: KeyboardEvent) => {
40+
if (e.key === 'Escape') onCancel();
41+
};
42+
window.addEventListener('keydown', onKey);
43+
return () => window.removeEventListener('keydown', onKey);
44+
}, [open, onCancel]);
45+
46+
// 确认按钮 auto-focus:Radix 默认聚焦首个可聚焦元素(关闭按钮),
47+
// 故打开后显式聚焦确认按钮,保证 Enter 直接触发确认
48+
useEffect(() => {
49+
if (open) confirmRef.current?.focus();
50+
}, [open]);
51+
52+
return (
53+
<Modal
54+
open={open}
55+
onClose={onCancel}
56+
title={title}
57+
description={description}
58+
size="sm"
59+
footer={
60+
<>
61+
<button
62+
onClick={onCancel}
63+
className="px-4 py-2 rounded-kb-lg text-b3 font-medium text-text-secondary bg-bg-secondary hover:bg-bg-tertiary active:scale-[0.98] transition-all"
64+
>
65+
{cancelLabel}
66+
</button>
67+
<button
68+
ref={confirmRef}
69+
onClick={onConfirm}
70+
className="px-4 py-2 rounded-kb-lg text-b3 font-semibold text-white bg-brand-600 hover:bg-brand-700 active:scale-[0.98] transition-all"
71+
>
72+
{confirmLabel}
73+
</button>
74+
</>
75+
}
76+
>
77+
{/* 内容已由 Modal description 承载,此处留空占位保持 Modal 契约 */}
78+
<div />
79+
</Modal>
80+
);
81+
}
82+
83+
export default ConfirmDialog;
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
/**
2+
* useConfirm — Promise 化应用内确认对话框状态管理
3+
*
4+
* @ai-context: askConfirm(req, opts?) 返回 Promise<boolean>,供异步流程
5+
* await(替代同步 window.confirm)。resolver 默认挂 60s 超时兜底(网关
6+
* down 等场景防 Promise 永久挂起);opts.timeout = 0 表示不挂超时,
7+
* 供停止收尾等"入口不可丢"的决策使用。组件卸载时清理定时器并置空
8+
* resolver,防止悬空定时器触发已卸载组件的 setState。
9+
* @ai-context: Promise-based confirm flow. Default timeout (60s) resolves
10+
* false as a safety net; opts.timeout = 0 disables the timeout for
11+
* decisions whose entry must never silently expire. Unmount cleanup clears
12+
* the pending timer and drops the resolver.
13+
*/
14+
import { useState, useCallback, useRef, useEffect } from 'react';
15+
16+
export interface ConfirmRequest {
17+
title: string;
18+
description?: string;
19+
confirmLabel?: string;
20+
}
21+
22+
/** resolver 超时兜底(毫秒):超时视为取消 */
23+
const CONFIRM_TIMEOUT_MS = 60_000;
24+
25+
export function useConfirm() {
26+
const [request, setRequest] = useState<ConfirmRequest | null>(null);
27+
const resolverRef = useRef<((ok: boolean) => void) | null>(null);
28+
const timerRef = useRef<number | null>(null);
29+
30+
/** 结束当前对话框:resolve Promise 并清理超时定时器 */
31+
const settle = useCallback((ok: boolean) => {
32+
if (timerRef.current !== null) { clearTimeout(timerRef.current); timerRef.current = null; }
33+
resolverRef.current?.(ok);
34+
resolverRef.current = null;
35+
setRequest(null);
36+
}, []);
37+
38+
/**
39+
* 请求确认,返回用户选择。
40+
* opts.timeout:超时毫秒数,缺省 60s 兜底(超时视为取消);
41+
* 传 0 表示不挂超时(停止收尾决策等入口不可静默丢失的场景)。
42+
*/
43+
const askConfirm = useCallback((req: ConfirmRequest, opts?: { timeout?: number }): Promise<boolean> => {
44+
// 已有未决请求时先视为取消,避免 resolver 泄漏
45+
if (resolverRef.current) settle(false);
46+
setRequest(req);
47+
const timeout = opts?.timeout ?? CONFIRM_TIMEOUT_MS;
48+
return new Promise<boolean>((resolve) => {
49+
resolverRef.current = resolve;
50+
timerRef.current = timeout > 0 ? window.setTimeout(() => settle(false), timeout) : null;
51+
});
52+
}, [settle]);
53+
54+
// 卸载兜底:清理悬空超时定时器并置空 resolver,
55+
// 防止组件卸载后定时器仍触发 setState/resolve
56+
useEffect(() => () => {
57+
if (timerRef.current !== null) { clearTimeout(timerRef.current); timerRef.current = null; }
58+
resolverRef.current = null;
59+
}, []);
60+
61+
return {
62+
request,
63+
askConfirm,
64+
handleConfirm: useCallback(() => settle(true), [settle]),
65+
handleCancel: useCallback(() => settle(false), [settle]),
66+
};
67+
}
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
/**
2+
* useGatewayHealth 单元测试(P0-4 网关健康软阻断状态机)
3+
*
4+
* @ai-context: mock fetch 覆盖 ok / down / down→恢复放行三态,另含
5+
* 网关 URL 未配置视为 down(不发探针)、缓存优先、卸载清定时器、
6+
* 卸载后迟到探针响应不写共享缓存。
7+
* 每个用例 resetModules 取全新模块注册表,隔离 gatewayHealthCache 的
8+
* 模块级缓存,避免用例间互相污染。
9+
* @ai-context: Covers ok/down/recovery transitions with mocked fetch,
10+
* empty-URL short-circuit, cache-first path, interval cleanup and the
11+
* unmount guard (late probe responses must not write the shared cache);
12+
* fresh module registry per test isolates the shared health cache.
13+
*/
14+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
15+
import { renderHook, act } from '@testing-library/react';
16+
17+
// jsdom 部分版本缺少 AbortSignal.timeout:补齐以免探针构造参数时抛错
18+
if (typeof (AbortSignal as { timeout?: unknown }).timeout !== 'function') {
19+
(AbortSignal as unknown as { timeout: (ms: number) => AbortSignal }).timeout =
20+
() => new AbortController().signal;
21+
}
22+
23+
/** 重置模块注册表后装配全新 store + hook(健康缓存随之归零) */
24+
async function renderFresh(gatewayUrl: string) {
25+
vi.resetModules();
26+
const { useSettingsStore } = await import('@/stores/useSettingsStore');
27+
useSettingsStore.setState((s) => ({
28+
aiConfig: { ...s.aiConfig, gatewayUrl },
29+
}));
30+
const { useGatewayHealth } = await import('./useGatewayHealth');
31+
// 预检的异步 setState 可能落在微任务里:整体包进 act 抑制告警
32+
return await act(async () => renderHook(() => useGatewayHealth()));
33+
}
34+
35+
/** 冲刷微任务,让探针 Promise 落定 */
36+
const flush = () => act(async () => {});
37+
38+
beforeEach(() => {
39+
vi.useFakeTimers();
40+
});
41+
42+
afterEach(() => {
43+
vi.useRealTimers();
44+
vi.unstubAllGlobals();
45+
vi.restoreAllMocks();
46+
});
47+
48+
describe('useGatewayHealth', () => {
49+
it('网关可达:预检后进入 ok', async () => {
50+
const fetchMock = vi.fn().mockResolvedValue({ ok: true });
51+
vi.stubGlobal('fetch', fetchMock);
52+
const { result } = await renderFresh('http://gateway.test');
53+
await flush();
54+
expect(result.current.status).toBe('ok');
55+
expect(fetchMock).toHaveBeenCalledWith(
56+
'http://gateway.test/health/quick',
57+
expect.objectContaining({ method: 'GET' }),
58+
);
59+
});
60+
61+
it('网关不可达:进入 down 并挂 15s 自动复检定时器', async () => {
62+
const fetchMock = vi.fn().mockRejectedValue(new TypeError('Failed to fetch'));
63+
vi.stubGlobal('fetch', fetchMock);
64+
const intervalSpy = vi.spyOn(window, 'setInterval');
65+
const { result } = await renderFresh('http://gateway.test');
66+
await flush();
67+
expect(result.current.status).toBe('down');
68+
expect(intervalSpy).toHaveBeenCalledWith(expect.any(Function), 15_000);
69+
});
70+
71+
it('down→恢复:复检命中后自动放行并清除定时器', async () => {
72+
const fetchMock = vi.fn()
73+
.mockRejectedValueOnce(new TypeError('Failed to fetch'))
74+
.mockResolvedValue({ ok: true });
75+
vi.stubGlobal('fetch', fetchMock);
76+
const clearSpy = vi.spyOn(window, 'clearInterval');
77+
const { result } = await renderFresh('http://gateway.test');
78+
await flush();
79+
expect(result.current.status).toBe('down');
80+
81+
// 推进 15s 触发复检:探针恢复 → 放行
82+
await act(async () => { vi.advanceTimersByTime(15_000); });
83+
await flush();
84+
expect(result.current.status).toBe('ok');
85+
expect(fetchMock).toHaveBeenCalledTimes(2);
86+
expect(clearSpy).toHaveBeenCalled();
87+
});
88+
89+
it('网关 URL 未配置:视为 down 且不发起探针', async () => {
90+
const fetchMock = vi.fn();
91+
vi.stubGlobal('fetch', fetchMock);
92+
const { result } = await renderFresh(' ');
93+
await flush();
94+
expect(result.current.status).toBe('down');
95+
expect(fetchMock).not.toHaveBeenCalled();
96+
});
97+
98+
it('缓存命中:直接采用缓存结果,不发探针', async () => {
99+
const fetchMock = vi.fn();
100+
vi.stubGlobal('fetch', fetchMock);
101+
vi.resetModules();
102+
const { useSettingsStore } = await import('@/stores/useSettingsStore');
103+
useSettingsStore.setState((s) => ({
104+
aiConfig: { ...s.aiConfig, gatewayUrl: 'http://gateway.test' },
105+
}));
106+
const cache = await import('@/hooks/gatewayHealthCache');
107+
cache.writeHealthCache({ status: 'online', latency: 10 });
108+
const { useGatewayHealth } = await import('./useGatewayHealth');
109+
const { result } = await act(async () => renderHook(() => useGatewayHealth()));
110+
await flush();
111+
expect(result.current.status).toBe('ok');
112+
expect(fetchMock).not.toHaveBeenCalled();
113+
});
114+
115+
it('组件卸载:清除复检定时器', async () => {
116+
const fetchMock = vi.fn().mockRejectedValue(new TypeError('Failed to fetch'));
117+
vi.stubGlobal('fetch', fetchMock);
118+
const clearSpy = vi.spyOn(window, 'clearInterval');
119+
const { result, unmount } = await renderFresh('http://gateway.test');
120+
await flush();
121+
expect(result.current.status).toBe('down');
122+
unmount();
123+
expect(clearSpy).toHaveBeenCalled();
124+
});
125+
126+
it('组件卸载:迟到探针响应不写共享缓存', async () => {
127+
// fetch 挂起:探针在途时卸载,随后才 resolve 的响应必须被代际保护丢弃
128+
let resolveFetch!: (v: unknown) => void;
129+
const pending = new Promise((r) => { resolveFetch = r; });
130+
const fetchMock = vi.fn().mockReturnValue(pending);
131+
vi.stubGlobal('fetch', fetchMock);
132+
const { result, unmount } = await renderFresh('http://gateway.test');
133+
// 必须在 renderFresh(内部 resetModules)之后导入,才是 hook 共用的缓存实例
134+
const cache = await import('@/hooks/gatewayHealthCache');
135+
await flush();
136+
expect(result.current.status).toBe('checking');
137+
expect(cache.readHealthCache()).toBeNull();
138+
unmount();
139+
// 卸载后响应才到达:不得写共享缓存(否则陈旧结果扩散给其他消费方)
140+
resolveFetch({ ok: true, json: async () => ({ status: 'ok' }) });
141+
await flush();
142+
expect(cache.readHealthCache()).toBeNull();
143+
});
144+
});

0 commit comments

Comments
 (0)