Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 32 additions & 1 deletion docs/quality/regression-matrix.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,9 +89,40 @@
- 症状:`_cache_warmup_task` 非 CancelledError 异常会中断后续全部清理步骤;jieba 预热 await 无超时且 `to_thread` 不可取消,可挂死停机;整体停机无 deadline。
- 回归测试:`tests/test_shutdown_prewarm.py`(异常不外抛且留痕 / jieba 超时不挂死 / 正常与已取消路径)。owner:#73(Parent #6,修复 PR #77:`_shutdown_prewarm_tasks`)。

### 管理后台批量操作静默失败 — #88 🔧 修复完成待合并(2026-09-29)
- 症状:`sources/page.tsx` `handleBatchToggle` 逐条 `sourcesApi.update` 的 `catch` 只写 `console.error`,循环结束后**无条件** `setSelectedIds(new Set())` + `fetchSources()`。批量停用 30 个信源、其中 5 个失败时,UI 表现为「全部处理完」,失败的 5 个仍按原状态继续采集并污染内容池,而操作者无从察觉。
- 环境/前提:生产数据,仅管理员账号;`PATCH /api/v1/sources/{id}` 任一请求失败(网络抖动 / 404 / 5xx)即可触发。
- 复现:信源管理页多选 ≥2 个信源 → 批量停用 → 中途阻断网络或使其中之一更新失败 → 观察 UI 与浏览器控制台对照。
- 期望 vs 实际:期望逐条失败上抛 UI、成功项与失败项分别反馈;实际只有 console 记录,UI 无任何差异。
- 边界:仅前端 `sources/page.tsx`;**不改写入语义**,只改失败可见性与选择态保留。
- 严重度:P0(数据污染且不可察觉);**复现性**:conditional(依赖批量中至少一条更新失败)。
- 回归测试:`npx vitest run src/app/admin/sources/_batch-utils.test.ts`(11 条)。选择态重算与结果文案已抽为纯函数 `_batch-utils.ts`,`.tsx` 只做调用;已用变异测试验证——把实现改回旧行为(失败进 console + 无条件清空)后 3 条转红,其中包含「中途新勾选项被静默丢弃」这条。
- owner:#88。

### Webhook 日志页读数口径撒谎 — #88 🔧 修复完成待合并(2026-09-29)
- 症状:`webhook-logs/page.tsx` 的 `successCount` / `failCount` 只统计**当前页** 30 行,却与全局 `total` 并排渲染成同款 Badge。读者会把「本页 2 失败」除以「共 1240 条」读成 0.16% 失败率,真相是第 5 页可能还躺着 40 条。更严重的是失败徽章以 `failCount > 0` 为条件渲染——翻到失败为 0 的分页时「失败」整枚徽章消失,**徽章的缺席本身制造错误信念**。
- 环境/前提:任何有 ≥1 页推送日志的生产数据。
- 复现:`/admin/webhook-logs` 翻页,观察任意分页顶部的 Badge 组。
- 期望 vs 实际:期望页内计数带明确口径标注、失败数显式渲染;实际两者视觉同层且口径未标注,失败为 0 时徽章缺席。
- 边界:仅前端展示层;**不改数值来源**,不改 API。
- 严重度:P1(对交付健康度给出错误读数,不直接损坏数据);**复现性**:always。
- 回归测试:`npx vitest run src/app/admin/webhook-logs/_scope-utils.test.ts`(15 条)。徽章**构造**(口径标注 + 无条件渲染)已抽为 `buildSummaryBadges` 纯函数并被锁住。
- **覆盖边界(独立复核 M3 实证,勿夸大)**:修复前 `summarizeLogPage` 的 6 个公式与修复后逐字相同,只测它**证明不了**本缺陷;真正的修复是把「渲染成什么」下沉成 `buildSummaryBadges`,其断言(失败徽章 0 时仍存在且为中性色、全部徽章带「本页」/「全部」口径词、空页只留 1 枚)才真正对应缺陷本身。JSX 渲染层仍无组件测试(见下方遗留项)。
- owner:#88。

### 管理后台面包屑缺 4 项映射 — #88 🔧 修复完成待合并(2026-09-29)
- 症状:`AdminTopBar.tsx` 的 `ADMIN_PAGE_LABELS` 只有 10 条,侧边栏 `ADMIN_NAV_ITEMS` 有 15 项。`prompts` / `scoring-dashboard` / `evidence` / `webhook-logs` 四页无映射,`findPageLabel` 回退显示兜底文案「管理」,与侧边栏自相矛盾。(另经核实:概览卡片 13 张、面包屑 10 条、侧边栏 15 项三份目录互相矛盾,说明分类从未被写下来过。)
- 环境/前提:always。
- 复现:访问上述四页之一,观察顶栏面包屑第二段。
- 期望 vs 实际:期望显示真实页面名;**实际显示「概览」**(独立复核更正:实现者原记为「管理」有误——旧实现里 `/admin` 本身是前缀匹配键,`/admin/prompts` 先命中 `/admin/` 落成「概览」,压根走不到兜底文案。真实症状比原记录更严重:不是泛化文案,而是谎称在概览页)。
- 边界:前端。实际改动含 `AdminTopBar.tsx` + `AdminSidebar.tsx` 重构 + 新增 `src/lib/admin-nav.ts` 单一事实源(实现者原记为「仅 AdminTopBar.tsx」,有误)。
- 严重度:P2(说错话的廉价困惑,不改变任何决定的对错);**复现性**:always。
- 回归测试:`npx vitest run src/lib/__tests__/admin-nav.test.ts`(13 条)。导航清单与面包屑映射合并到 `src/lib/admin-nav.ts` 单一事实源,映射由清单派生;`nav-checklist` 等价断言(壳内每项都有映射、label 与清单逐项一致)已在其中,漏项即红。顺带修掉一个被测试抓出的旧行为:`/admin/任何未知路径` 曾因壳根前缀匹配被判成「概览」,现已回退兜底。
- owner:#88。

## 三、关键流程基线(9 项)

状态标记:✅ = 2026-09-27 在 main @ 7203847 新鲜复跑通过;📋 = 现有套件覆盖、未逐项复跑(跑全量即覆盖)。
状态标记:✅ = 2026-09-27 在 main @ 7203847 新鲜复跑通过;📋 = 现有套件覆盖、未逐项复跑(跑全量即覆盖);🔧 = 修复完成待合并(PR 已开、CI 绿、独立复核非 pass 尚未全部处置)。

| # | 关键流程 | 验证命令(backend/ 下,遵守§一) | 状态 |
|---|---|---|---|
Expand Down
161 changes: 161 additions & 0 deletions frontend/src/app/admin/sources/_batch-utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import { describe, it, expect } from 'vitest';
import {
recomputeSelectionAfterBatch,
describeBatchResult,
runBatchToggle,
} from './_batch-utils';

/**
* 独立复核 M4/M4b:删掉 `.tsx` 里的 `failedIds.push(id)` 或整段横幅渲染后,
* 179 条测试全绿存活——因为「失败不可见」的执行层原本写在组件里。
* 以下断言直接锁住执行与收集本身。
*/
describe('runBatchToggle — 缺陷 #1 的真修复(#88)', () => {
const makeUpdate = (failIds: number[]) => {
const calls: number[] = [];
const update = async (id: number) => {
calls.push(id);
if (failIds.includes(id)) throw new Error(`boom ${id}`);
};
return { update, calls };
};

it('全部成功时返回空失败列表', async () => {
const { update, calls } = makeUpdate([]);
expect(await runBatchToggle([1, 2, 3], update)).toEqual([]);
expect(calls).toEqual([1, 2, 3]);
});

it('收集每一条失败的 id,顺序与入参一致(#88 核心修复)', async () => {
const { update } = makeUpdate([2, 5]);
expect(await runBatchToggle([1, 2, 3, 4, 5], update)).toEqual([2, 5]);
});

it('单条失败不得中断整批——后续条目仍会被执行', async () => {
const { update, calls } = makeUpdate([1]);
const failed = await runBatchToggle([1, 2, 3], update);
expect(calls).toEqual([1, 2, 3]);
expect(failed).toEqual([1]);
});

it('每条失败都触发 onError 回调(console 留痕等副作用)', async () => {
const { update } = makeUpdate([1, 3]);
const seen: number[] = [];
await runBatchToggle([1, 2, 3], update, (id) => seen.push(id));
expect(seen).toEqual([1, 3]);
});

it('成功条目不触发 onError', async () => {
const { update } = makeUpdate([2]);
const seen: number[] = [];
await runBatchToggle([1, 2, 3], update, (id) => seen.push(id));
expect(seen).toEqual([2]);
});

it('onError 自身抛错不得影响失败收集结果', async () => {
const { update } = makeUpdate([1, 2]);
const failed = await runBatchToggle([1, 2, 3], update, () => {
throw new Error('callback exploded');
});
expect(failed).toEqual([1, 2]);
});

it('空批次不调用 update', async () => {
const { update, calls } = makeUpdate([]);
expect(await runBatchToggle([], update)).toEqual([]);
expect(calls).toEqual([]);
});

it('重复 id 的失败会被如实计入两次(不去重,避免谎报成功数)', async () => {
const { update } = makeUpdate([1]);
expect(await runBatchToggle([1, 1], update)).toEqual([1, 1]);
});
});

/**
* #88 回归:批量启停的失败可见性与选择态重算。
*
* 旧实现(修复前):逐条 catch 只 console.error,循环后无条件
* setSelectedIds(new Set()),UI 报告「全部处理完」而实际有 N 条未改。
*/

describe('recomputeSelectionAfterBatch', () => {
it('全部成功时清空选择态', () => {
const prev = new Set([1, 2, 3]);
const next = recomputeSelectionAfterBatch(prev, [1, 2, 3], []);
expect(next.size).toBe(0);
});

it('失败项保持选中,便于直接重试(#88 核心行为)', () => {
const prev = new Set([1, 2, 3, 4, 5]);
const next = recomputeSelectionAfterBatch(prev, [1, 2, 3, 4, 5], [2, 4]);
expect([...next].sort((a, b) => a - b)).toEqual([2, 4]);
});

it('执行期间用户新勾选的项不得被静默丢弃(自审发现的回归)', () => {
// 批量是逐条 await,期间复选框未锁定:用户中途勾了 99(本批快照内没有)
const prev = new Set([1, 2, 3, 99]);
const batchIds = [1, 2, 3];
const next = recomputeSelectionAfterBatch(prev, batchIds, [2]);

// 失败项 2 保留
expect(next.has(2)).toBe(true);
// 成功项 1、3 清除
expect(next.has(1)).toBe(false);
expect(next.has(3)).toBe(false);
// 中途新勾选的 99 绝不能丢
expect(next.has(99)).toBe(true);
});

it('中途取消勾选失败项后,它仍会被重新勾上——这是有意的重试入口,不是 bug', () => {
// 独立复核指出:批量期间复选框未锁定,用户中途取消勾选一个注定失败的项,
// 执行结束后它会被无条件加回。批量按钮虽 disabled={batchProcessing},
// 行复选框没有,所以这个状态确实可达。
// 契约选择:失败项一律重新入选,保证「失败即可重试」不因用户误操作丢失。
const prev = new Set([1, 3]); // 2 已被用户取消勾选
const next = recomputeSelectionAfterBatch(prev, [1, 2, 3], [2]);
expect(next.has(2)).toBe(true);
});

it('不修改传入的 prevSelection(原 Set 不被就地改写)', () => {
const prev = new Set([1, 2, 3]);
recomputeSelectionAfterBatch(prev, [1, 2, 3], [2]);
expect([...prev].sort((a, b) => a - b)).toEqual([1, 2, 3]);
});

it('空批次返回空选择态', () => {
expect(recomputeSelectionAfterBatch(new Set(), [], []).size).toBe(0);
});
});

describe('describeBatchResult', () => {
it('全部成功 → teal,报告处理条数', () => {
const r = describeBatchResult(30, [], false);
expect(r.tone).toBe('teal');
expect(r.text).toContain('已停用 30 个信源');
});

it('全部成功时动词随 enabled 变化', () => {
expect(describeBatchResult(3, [], true).text).toContain('已启用 3 个信源');
});

it('部分失败 → red,列出失败数与失败 id(#88 核心行为)', () => {
const r = describeBatchResult(30, [12, 47], true);
expect(r.tone).toBe('red');
expect(r.text).toContain('已启用 28 个信源');
expect(r.text).toContain('2 个失败');
expect(r.text).toContain('12、47');
});

it('部分失败时成功数 = 总数 - 失败数', () => {
const r = describeBatchResult(5, [1, 2, 3, 4], false);
expect(r.text).toContain('已停用 1 个信源');
expect(r.text).toContain('4 个失败');
});

it('全部失败时成功数为 0,且不为 teal', () => {
const r = describeBatchResult(2, [7, 8], false);
expect(r.tone).toBe('red');
expect(r.text).toContain('已停用 0 个信源');
});
});
114 changes: 114 additions & 0 deletions frontend/src/app/admin/sources/_batch-utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
/**
* 信源批量启停:选择态重算与结果反馈(#88)。
*
* 抽成纯函数而不是就地写进 `.tsx` 的原因:本项目 vitest 为
* `environment: 'node'` + `include` 限定 `src` 下的 `.test.ts`,
* `.tsx` 组件行为完全不在覆盖内(CI 五 job 也无 eslint)。而批量选择态的
* 重算恰恰是 #88 修复中最微妙的一段逻辑——必须能回归,否则「中途新勾选项
* 被静默丢弃」这类错误下次还会回来,且没有任何机器能发现。
*/

/** 批量操作的可见结果。tone 决定横幅配色。 */
export interface BatchResult {
tone: 'teal' | 'red';
text: string;
}

/**
* 批量执行完成后重算选择态。
*
* 规则(缺一不可):
* 1. 本批失败项**保持选中**,便于操作者直接重试,不必回列表里重新勾选。
* 2. 本批成功项**清除选中**。
* 3. 执行期间(批量是逐条 await,期间 UI 未锁定复选框)用户新勾选的项,
* 若不在本批快照内,必须**保留**——它们从未参与本批操作,无权被这次
* 重算静默丢弃。
*
* @param prevSelection 批量结束瞬间的当前选择态
* @param batchIds 本批操作的 id 快照(循环开始时捕获)
* @param failedIds 本批中更新失败的 id
*/
export function recomputeSelectionAfterBatch(
prevSelection: ReadonlySet<number>,
batchIds: readonly number[],
failedIds: readonly number[],
): Set<number> {
const batch = new Set(batchIds);
const failed = new Set(failedIds);
const next = new Set<number>();

for (const id of failed) {
next.add(id);
}
for (const id of prevSelection) {
if (!batch.has(id)) next.add(id);
}
return next;
}

/**
* 逐条执行批量启停并收集失败项——**#88 缺陷 #1 的真修复就在这里**。
*
* 此前「catch 里记 failedIds」这段写在 `.tsx` 的 for 循环里,`.tsx` 在本仓库
* 结构性不可测(无 jsdom/testing-library),独立复核的变异测试 M4 删掉
* `failedIds.push(id)` 后 179 条测试全绿存活。把执行与收集也变成纯函数,
* 「失败不可见」这个缺陷才真正被钉住。
*
* 关键语义:**单条失败不得中断整批**——修复前的循环正是这么吞掉的,
* UI 报告「全部处理完」而实际有 N 条未改。
*
* @param ids 本批 id 快照(循环开始时捕获,不受执行期间用户操作影响)
* @param update 单条更新操作,抛错即视为该条失败
* @param onError 失败回调(用于 console 留痕等副作用),不影响返回值
* @returns 失败 id 列表,顺序与 `ids` 一致
*/
export async function runBatchToggle(
ids: readonly number[],
update: (id: number) => Promise<unknown>,
onError?: (id: number, err: unknown) => void,
): Promise<number[]> {
const failedIds: number[] = [];
for (const id of ids) {
try {
await update(id);
} catch (err) {
// onError 是留痕用的旁路,自身抛错绝不能中断整批——否则一个日志回调
// 就能让「收集失败」退化成「整批中止」,反而比原缺陷更糟。
try {
onError?.(id, err);
} catch {
// 留痕失败不重抛:调用方需要的是失败 id 列表,不是异常
}
failedIds.push(id);
}
}
return failedIds;
}

/**
* 生成批量操作的结果反馈文案。
*
* 全部成功给 teal;只要有一条失败就给 red 并逐条列出失败 id——失败不可见
* 是 #88 的原始缺陷:旧实现只 `console.error`,UI 报告「全部处理完」,
* 实际失败的信源仍按原状态继续采集并污染内容池。
*/
export function describeBatchResult(
total: number,
failedIds: readonly number[],
enabled: boolean,
): BatchResult {
const verb = enabled ? '启用' : '停用';
const failedCount = failedIds.length;

if (failedCount === 0) {
return { tone: 'teal', text: `已${verb} ${total} 个信源。` };
}

const succeededCount = total - failedCount;
return {
tone: 'red',
text:
`已${verb} ${succeededCount} 个信源,${failedCount} 个失败并保持选中:` +
`${failedIds.join('、')}。请检查网络或该信源状态后重试。`,
};
}
30 changes: 20 additions & 10 deletions frontend/src/app/admin/sources/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import { SourceMapCard, SourceMapView } from './_components';
import { AddSourceModal, BatchImportModal, EditSourceModal } from './_modals';
import { EvidenceProfileModal } from '@/components/EvidenceProfileModal';
import { RSSHubManager, SourceListPanel, FeatureFlagsPanel } from './_panels';
import { describeBatchResult, recomputeSelectionAfterBatch, runBatchToggle } from './_batch-utils';

// ─── Page Component ───

Expand Down Expand Up @@ -426,6 +427,8 @@ export default function SourcesPage() {
// 批量选择:多选 + 批量启用/暂停
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
const [batchProcessing, setBatchProcessing] = useState(false);
// 批量操作结果反馈(成功 / 部分失败)。失败必须可见——见 handleBatchToggle。
const [batchResult, setBatchResult] = useState<{ tone: 'teal' | 'red'; text: string } | null>(null);
const handleSelectSource = (source: BackendSource, checked: boolean) => {
setSelectedIds((prev) => {
const next = new Set(prev);
Expand All @@ -445,19 +448,22 @@ export default function SourcesPage() {
const handleBatchToggle = async (enabled: boolean) => {
if (selectedIds.size === 0 || batchProcessing) return;
setBatchProcessing(true);
setBatchResult(null);
const ids = Array.from(selectedIds);
try {
// 循环调 update 端点(已存在,单 source 调)
const ids = Array.from(selectedIds);
for (const id of ids) {
try {
await sourcesApi.update(id, { enabled });
} catch (err) {
console.error(`Batch toggle source ${id} failed:`, err);
}
}
setSelectedIds(new Set());
// 逐条执行与失败收集见 _batch-utils.ts(纯函数,有单测覆盖)。
// 单条失败不中断整批,但必须被收集并上抛到 UI。
const failedIds = await runBatchToggle(
ids,
(id) => sourcesApi.update(id, { enabled }),
(id, err) => console.error(`Batch toggle source ${id} failed:`, err),
);
// 执行完成后重建选择态:失败项保持选中以便直接重试。
setSelectedIds((prev) => recomputeSelectionAfterBatch(prev, ids, failedIds));
await fetchSources();
await fetchSourceMap();

setBatchResult(describeBatchResult(ids.length, failedIds, enabled));
} finally {
setBatchProcessing(false);
}
Expand Down Expand Up @@ -852,6 +858,10 @@ export default function SourcesPage() {
<AdminNoticeBanner tone="red" onClose={() => setError(null)}>{error}</AdminNoticeBanner>
)}

{batchResult && (
<AdminNoticeBanner tone={batchResult.tone} onClose={() => setBatchResult(null)}>{batchResult.text}</AdminNoticeBanner>
)}

{/* Status filter tabs — applies to all source list views */}
<div className="mb-3 flex flex-wrap items-center gap-2">
<span className="text-[12px] text-gray-500">状态筛选:</span>
Expand Down
Loading
Loading