- docs/acceptance/qa-tracker.md:跨周期缺陷单一真源台账(§7=第五轮)。 - 周期 1/2 + iPhone SE/axe 验收证据目录、ACCEPTANCE_REVIEW 快照入库。 - 同步 README/CONTEXT/CLAUDE/DESIGN/testing/deployment/lessons-learned 口径; next.config/Dockerfile/nginx/Jenkinsfile/docker-compose/sentry/prisma 对齐 standalone 产物装配与部署形态。
45 KiB
系统性 Code Review / 全面测试 / 验收报告 — 2026-09-21
- 分支:
refactor/optimize-ui(领先origin/main30 个提交;src/改动 120 文件,+3248 / −3959) - 环境:Next.js 16.3.0 (Turbopack) / React 18.3 / TypeScript 5 strict / Prisma 6.19 + SQLite / Tailwind 3.4
- 方法:4 路并行模块审查(API+鉴权 / lib+hooks / 组件+页面 / 数据+配置+测试基建),所有结论由主代理逐条复核证据后定级;无法证实的假设已撤回(见 §7)
- 实测门禁:type-check / lint / 单测+覆盖率 / build / E2E 三浏览器全量 / check:contrast / check:headings / 安全响应头 / Lighthouse 7 URL / 变异测试(quick 作用域) —— 全部由本次亲跑取回原始输出,未引用任何历史报告(详见 §2)
1. 验收结论
不通过验收(REJECTED)。
阻断项 5 类:①生产级安全漏洞(权限提升 ×2、存储型 XSS ×2);②E2E 92 例失败(3 浏览器一致复现,含 6 例 @critical);③CI 用 || echo 吞掉全部功能性测试,绿色徽章不证明任何行为正确性;④本分支引入 2 处用户可见 UI 回归;⑤可访问性门禁双重失效(A-11)导致一个全站 62 页的 WCAG AA 对比度失败(A-12 Cookie 条隐私政策链接 3.31:1)在 check:contrast 与 Lighthouse 两道门禁下同时报绿。
关键判据不是「失败数」而是「门禁是否可信」。当前
test:all与 Jenkins 绿灯所覆盖的范围,与 AGENTS.md §5 声称的质量门禁之间存在实质落差;本次实测中两道 a11y 门禁双双通过,却被两个独立引擎各测出 critical 级失败,即为该落差的最强证据。
2. 门禁执行结果(本次实测,非引用)
| 门禁 | 命令 | 结果 | 判定 |
|---|---|---|---|
| 类型检查 | npm run type-check |
0 error,EXIT=0 | 通过 |
| Lint | npm run lint |
0 error / 128 warning | 不达 AGENTS.md「无警告」 |
| 单元测试 | npm run test:unit |
132 套件 / 1594 例全通过,23.9s | 通过 |
| 覆盖率 | npm run test:coverage |
stmts 75.9 / branch 84.28 / func 75.46 / line 75.9 | 通过(但门禁形同虚设,见 §3.3) |
| 生产构建 | npm run build |
EXIT=0,62 静态页生成 | 通过 |
| E2E 三浏览器 | playwright test --project=chromium,firefox,webkit |
710 通过 / 92 失败 / 8 跳过,17.2min,EXIT=1 | 失败 |
| 色彩对比度 | npm run check:contrast |
7/7 通过 | 通过(但仅 7 组硬编码浅色对,盲区见 §3.4) |
| 标题层级 | npm run check:headings |
10/10 页 0 问题 | 通过 |
| 安全响应头 | npm run test:security:headers |
6 通过 / 2 警告 | 通过(但打的是线上 novalon.cn,不验证本分支) |
| Lighthouse | npx lighthouse@13(对 lighthouserc.json 全部 7 个 URL,desktop preset) |
性能全绿(perf 99-100 / FCP 246-250ms / LCP 790-896ms / CLS 0.000 / TBT 0ms / SI 248-414ms);a11y 92-97、7 页全部含 contrast 失败节点,共 10 节点 | 断言 0 违规 → 门禁"通过",但门禁太松看不见真实 a11y 缺陷,见 §3.4 |
| 变异测试 | npx stryker run --inPlace --mutate 'src/lib/utils.ts'(test:mutation:quick) |
91.18%(31 killed / 3 survived / 0 no-cov / 0 error),≥ break:50;全量作用域未跑(外推需数小时) |
通过(阈值达标),但暴露 2 处真实断言缺陷 + 作用域排除「零编造」核心,见 §3.5 |
| 压测 | npm run test:performance |
不可运行:k6 是 v0.0.0 占位包(node_modules/k6/package.json:"Dummy package for autocompleting k6 scripts"),无 node_modules/.bin/k6 |
失效 |
Lint 128 warning 构成:no-explicit-any 49 · react-hooks/set-state-in-effect 12 · next/no-img-element 10 · 其余 57。
3. 阻断级问题(P0)
3.1 权限提升与账号接管(已逐行复核)
A-1 content_admin → super_admin 自主提权 — src/app/api/admin/users/route.ts:90(POST)/ :166(PUT)放行 content_admin;:135 const rolesToAssign = body.roleCodes?.length ? body.roleCodes : ['readonly'] 直接取请求体,:139/:214 仅校验「角色是否存在于 DB」,不校验「调用者是否有权授予」。:201-208 只阻止移除自己的 super_admin,授予方向无任何 allowlist。
攻击:POST /api/admin/users {username,password,roleCodes:["super_admin"]}。
修复:两个 handler 均将 body.roleCodes 与调用者角色集求交,非 super_admin 不得授予 super_admin。
A-2 content_admin 可重置任意用户密码 → 接管超管 — users/route.ts:189-191 if (body.password) updateData.password = await hashPassword(body.password) → :193 prisma.user.update({ where: { id: userId } }),userId 来自 :172 攻击者可控的 query 参数,未校验目标是否持有 super_admin。同理 :188 可将超管 status 置 0 致其失联。
攻击:PUT /api/admin/users?id=<superadmin_cuid> {password:"x"} 后登录。
修复:改密/停用他人须 super_admin;目标持有 super_admin 时同样要求调用者为 super_admin。
架构成因:src/proxy.ts:104 matcher: ['/admin/:path*'] 且 :81 显式 !pathname.startsWith('/api/') —— proxy 完全不覆盖 /api/*,每个 route 必须自守卫。(Next 16 已将 middleware.ts 更名 proxy.ts,此处用法正确。)
3.2 存储型 XSS ×2(已验证数据通路)
A-3 CMS 富文本未净化直出公开页 — src/app/terms/page.tsx:212 与 src/app/privacy/page.tsx:267:dangerouslySetInnerHTML={{ __html: cmsContent }},cmsContent 来自 getPublishedItems('legal-page') → item.data.content(terms/page.tsx:181)。字段类型 richtext,描述即「支持 HTML 标签」(content-types.ts:1563)。全仓无净化器(DOMPurify / sanitize-html 检索 0 命中);RichTextEditor.tsx:38-46 的 TipTap Link 未配置 protocols。
影响:任何可编辑 legal-page 的账号(或经 A-1 提权者)即可在 /terms、/privacy 对全部访客注入脚本。
修复:渲染端统一 sanitize-html(白名单标签/属性/协议),并在 uploadMedia/items 写入侧再校验一次。
A-4 上传文件同源分发且安全头丢失 — src/app/api/admin/media/route.ts:79-84 将客户端可控的 file.type/file.name 原样传入;src/lib/media/media-service.ts:36-87 uploadMedia 无扩展名/MIME 白名单(:44 isImage() 只门控缩略图,不门控落盘),generateUniqueFileName 保留原扩展名 → evil.html 存入 public/uploads/。nginx-static-production.conf:163-168 的 location /uploads/ 自带 add_header,按 nginx 语义不再继承 server 级 X-Content-Type-Options: nosniff(:42)与 CSP(:128)→ 以 text/html 同源渲染。叠加 CSP 含 script-src 'unsafe-inline' 'unsafe-eval'(next.config.mjs:40),失去兜底。
修复:uploadMedia 增白名单 + magic-byte 校验;/uploads/ 内重新 add_header nosniff/CSP 并对非图片强制 Content-Disposition: attachment;移除 unsafe-eval。
3.3 测试基建不可信(最高价值发现)
A-5 CI 功能性测试全部被 || echo 吞掉 — Jenkinsfile:179 playwright test --grep "@smoke|@critical" || echo "⚠️ …继续执行";:181 journey、:214 视觉回归、:236 npm audit、:238 安全头 同法吞没。真门禁仅 lint(:129)/type-check(:136)/coverage(:148)/build(:174,254)。AGENTS.md §5 列为必须的 lighthouse、check:contrast、check:headings、mutation、k6、integration 在流水线中完全缺席。
结论:「CI 绿」当前只证明 lint + tsc + 「覆盖率≥30%」+ build 成功,不证明任何用户行为。
修复:去掉功能性 stage 的 || echo,把缺失门禁纳入流水线。
A-6 @critical GA4 测试是自证式空测 — e2e/ga4-event-tracking.spec.ts:47,75,165,235(4 例,标签 @critical):beforeEach:24-31 注入 mock window.gtag,测试体 :56-62 自行调用 gtag('config','G-TEST123',…),再于 :65-70 断言 __gtagCalls 中存在该调用。TC-GA4-003 :184-205 甚至不点击按钮,只判可见性后自调用;且 if (isCtaVisible) {} else {} 两分支均自调用 → CTA 不存在也通过。断言的 G-TEST123 由测试自己提供,与应用无关。
影响:npm run test:critical 对分析埋点的绿灯为 0 信息量。
修复:改为拦截真实网络请求(gtag/js 的 page_path/事件参数),或让 trackEvent 走可注入 sink 并断言应用调用。
A-7 覆盖率门禁形同虚设 + 三处文档口径互斥 — 实际生效门禁用 jest.config.js global branches 30 / functions 25 / lines 32 / statements 30,而实测为 84.28 / 75.46 / 75.9 / 75.9,低于实测约 45 个百分点;目录级阈值多数不可约束:seo branches 门限 0(实测 100)、content branches 门限 4(实测 100)、ui functions 门限 10(实测 81)。文档互斥:CLAUDE.md:39,196 称阈值 80% 且路径写作 config/test/jest.config.js(路径错误,真实配置在仓库根;config/test/jest.config.js 仅 Stryker 使用);docs/development/quality-gates.md:82-85 称四项均 ≥70%。
修复:阈值上调至「实测 −5pp」的棘轮值;统一三处文档并修正配置路径。
A-8 单测不接触真实数据层 — jest.setup.js:12-27 全局 mock PrismaClient(findMany → []),:29-50 整体 mock @/lib/cms/data-server。因此 §4 所有数据层缺陷(无事务、TOCTOU、JSON.parse 崩溃)没有任何测试能发现。npm run test:integration(--testPathPatterns='src/app/api/')跑的仍是 mock 版 route.test.ts(如 items/route.test.ts:14 mock @/lib/db),命名误导。
另有空洞断言:src/lib/db.test.ts:6-9 expect(prisma).toBeDefined() 对全局 mock 永真;colors.test.ts(≈18)、constants.test.ts(≈24)、design-system.test.ts(21) 大量 toBeDefined() 静态常量。
A-9 视觉基线已「追认现状」,且 9 张近空白 — 提交 f543e47 自述「87 例失败…均为尺寸级不匹配」后重生成 86/115 基线 ⇒ 重生时点已存在的回归被固化为参照,该套件此后无法再发现它。基线总数 107(5 project × 21,齐全),但 visual-{chromium,firefox,webkit}-desktop/…/button-{default,hover,focus}-*.png 为 912 / 1111 / 1961–2030 字节,日期 Jul 26 与 Jul 6(在 9 月重生成之外)⇒ 近空白区域仍算「比对通过」。容差偏松:playwright.config.ts:38-41 maxDiffPixels:200、maxDiffPixelRatio:0.005、threshold:0.3。部分断言条件化(visual-regression.spec.ts:73,91,101,111,130,182 元素缺失即 0 断言通过)或为永真(:199 color||fontFamily、:217 brand||ink||bg toBeTruthy())。
A-10 E2E 从不验证交付物 — playwright.config.ts:130 command: 'npm run dev' + :132 reuseExistingServer: true,全部规格跑在 dev server(甚至可能是上一轮残留进程)而非 output:'standalone' 构建产物。构建期才暴露的问题(预渲染、ISR、production header)永不被测。
修复:新增 --project=production 指向 npm run start 的 webServer。
3.4 可访问性门禁的双重失效与已证实的全站缺陷
A-11 两个对比度门禁同时看不见同一个真实违规 — 两条独立通路各自漏检:
check:contrast静态漏检 —scripts/utils/check-color-contrast.ts:10-18的criticalColorPairs是 7 组硬编码十六进制(全部#FFFFFF底),既不读tailwind.config.js/globals.css,也不含任何暗色模式配对,更不覆盖 alpha 修饰类。而本分支主题提交(4b8500a、846585a)恰好把暗色模式与--color-brand双通道拆分作为主战场 —— 门禁与其要保护的对象完全脱节,令牌一旦改动它仍对着陈旧色值报绿。- Lighthouse 阈值漏检 —
lighthouserc.json对 accessibility 断言 ≥0.9,而含 axe critical 失败的实际得分是 92-97:products92 分(7 个aria-required-parentcritical 节点)、其余 6 页 96-97 分(每页 1-4 个color-contrast节点)。critical 级 WCAG 失败被折算成分数后落在门禁线之上,故npm run lighthouse会显示全绿。
A-12 Cookie 同意条的「隐私政策」链接在全部 62 页对比度不达标(已双引擎证实) — src/components/analytics/CookieConsent.tsx:152 text-[var(--color-brand)](#C41E3A)落在同文件 :141 的 bg-[var(--color-bg-primary)](暗色 #0A0E14)上:
- Lighthouse/axe 实测 3.3:1(15.75px normal,要求 4.5:1);我按
globals.css:23/416令牌值独立算得 3.31:1,两法吻合。 - 该组件挂在根布局
src/app/layout.tsx:223⇒ 7/7 被测页各命中 1 次,即全站每一页都失败(10 个失败节点中的 7 个来自此处,余 3 个见 §4.3)。 - 根因是违反已写明的设计契约:DESIGN.md:154「The Two-Channel Red Rule. 底色用
--color-brand(暗黑不翻),文字用--color-brand-ink(暗黑翻至 #F87171)。合并成一条是 bug 的源头」、DESIGN.md:232「用text-brand-ink写红色文字…永不混用」。此处的合规写法应为text-brand-ink。 - 讽刺点:这是隐私/Cookie 同意 UI里指向隐私政策的链接,属合规可见路径。
同类面:全仓 67 处 text-[var(--color-brand)](23 个文件,含 CookieConsent、error.tsx、not-found-content.tsx、cta-section、hero-section-v2、product-card、service-card 等)对比 233 处合规的 text-brand-ink。这些站点在浅色面(#FFFFFF 上 5.84:1)偶然达标,一旦位于暗色面即跌到 3.31:1(--color-brand-bg #2A1418 上更仅 2.97:1)—— 而本分支暗色为默认。修复应整族收敛而非逐点打补丁:以 text-brand-ink 替换全部 67 处,并加一条 grep 门禁禁止 text-[var(--color-brand)]。
本项由 Playwright+axe(移动)与 Lighthouse+axe(桌面)两个独立引擎分别复现,非源码推断 —— 这是本次验收中证据强度最高的一类结论。
3.5 变异测试(本次实跑,唯一真正量化「测试有没有断言」的门禁)
npm run test:mutation:quick(作用域 src/lib/utils.ts):91.18%(31 killed / 3 survived / 0 no-coverage / 0 error,均值 14.97 tests/mutant,74s)≥ stryker.config.json 的 break: 50 ⇒ 阈值达标。但 3 个存活变异体经逐个复核后,2 个是本项目的真实测试缺陷:
A-13 lerp 的全部测试用例都以 start = 0 输入 ⇒ start 偏移量从未被检验 — src/lib/utils.ts:49 start + (end - start) * t 被改为 start + (end + start) * t 后仍全绿。原因(src/lib/utils.test.ts:120-128 四例逐一验算):lerp(0,10,0.5)、lerp(0,100,0.25)、lerp(0,10,0)、lerp(0,10,1) —— start 恒为 0,而 end - 0 与 end + 0 数值相同,故该变异在数学上不可观测。这是测试数据选择缺陷:函数唯一独有的参数(start)恰好是唯一没被非零值覆盖的那个。
A-14 randomBetween 只断言边界,检不出算子错误 — utils.ts:45 Math.random() * (max - min) + min 改为 / 后仍全绿。test.ts:106-115 只做 toBeGreaterThanOrEqual(1) / toBeLessThanOrEqual(10);变异实现给出 rand/9 + 1 ∈ [1, 1.89],负数例给出 ∈ [-1, -0.89] —— 均落在断言区间内。分布被压到区间一端 11% 的长度而测试无法察觉,因为没有任何一例检验取值是否覆盖全区或分布是否均匀。
(反向校准)第 3 个存活体不是缺陷 — utils.ts:25 的 if (timeout) → if (true):clearTimeout(null) 在 Node/浏览器均为合法 no-op,故该变异体与原实现语义等价,存活属正常,不计入测试质量问题。列出以示本次定级未把噪声当发现。
A-15 变异门禁的作用域把「零编造」核心排除在外 — stryker.config.json:20 的 mutate 含排除项 "!src/lib/constants/**",而 src/lib/constants/metrics-basis.ts(resolveMetricBasis / weakestBasis / FORBIDDEN_PROOF_PHRASES,即项目 AGENTS.md §3「零编造」原则的唯一机械载体)正在该目录内。后果:那个「未声明口径必须保守回落到 target」的回落逻辑,若被改坏(例如回落到 verified)不会有任何变异测试发现 —— 与 B-3「basis 仅类型约定、写入侧无校验」构成同一处治理缺口的两半。
门禁自身的两点风险(实测记录):
npm run test:mutation与:quick均带--inPlace,Stryker 会直接改写工作树(其日志自述 "In place mode is enabled, Stryker will be overriding YOUR files")。本次运行期间git status一度显示 10+ 个文件为M(Stryker 为注入覆盖率而临时修补 jest/babel 配置),结束后由.stryker-tmp/backup-*复原,我已核实工作树恢复到运行前状态(仅本报告与deliverables/两个未跟踪项)。但这意味着 CI 中一旦该 job 中途崩溃,仓库将留下被变异过的源码且无git checkout提示 —— 建议 CI 改用沙箱模式(去掉--inPlace)。- 顺带证伪了一个可疑点:我曾怀疑 Stryker 用的
config/test/jest.config.js与根jest.config.js存在测试发现差异(其 dry-run 报 "Ran 668 tests" 而test:unit为 1594)。实测npx jest --config config/test/jest.config.js⇒ 132 套件 / 1594 例全通过,两配置的testMatch/roots/setupFilesAfterEnv一致,无结果分歧,故不列为缺陷(668 与 ✘ 标记是 Stryker perTest 覆盖分析的呈现方式,✘ = 该测试未覆盖当前变异体,不是失败)。唯一真实差异仍是 A-7 已记的阈值不同(此配置 global 为 70/55/55/55,根配置为 30/25/32/30)—— 即两份 jest 配置近重复却配着互不相同的门禁值。
4. 高危问题(P1)
4.1 本分支引入的 UI 回归(验收主要风险)
R-1 双重移动端安全区留白 ≈190px — globals.css:1321-1323(本分支审计修复 152eef9 新增)footer { padding-bottom: calc(64px + env(safe-area-inset-bottom,0px)) } 叠加 src/components/layout/footer.tsx:171 的 pb-[calc(8rem+env(safe-area-inset-bottom,0px))] ⇒ iPhone SE/15 上备案行下方 128+64+2×inset ≈ 192–226px 空深色带,全部 31 页。
修复:二选一(建议只保留 CSS 侧,并删除组件 pb-[…])。
R-2 动效时长收敛到错误基准 — CONTEXT.md:76 与 DESIGN.md:233 规定入场 180–280ms,--transition-normal: 280ms(globals.css:252);分支却收敛到 300ms:src/components/ui/scroll-reveal.tsx:73 duration = 0.3 且注释错误引用契约为「200-300ms」;另有 duration-300 ×108、duration: 0.3 ×179 未令牌化。
修复:以 duration-normal/fast 令牌替换字面量,修正注释;或正式修订 CONTEXT.md。同类:--stagger-* 令牌(globals.css:272-276)零采用(var(--stagger 检索 0 命中),实散为 detail-trust-section.tsx:108 index*0.06、header.tsx:230 index*0.05、contact-content-v3.tsx:262 delay: 1.2(1200ms,远超 150ms 段间上限)。
R-3 暗色模式新闻页对比度不达标 — src/app/(marketing)/news/[slug]/NewsDetailClient.tsx:47 bg-[var(--color-brand-bg)] text-[var(--color-brand)](另 :34、:124)误用设计上不翻转的 --color-brand #C41E3A 作文字色,违反 DESIGN.md:154 双通道红规则。暗色实测 3.31:1(--color-bg-primary #0A0E14 上)与 2.97:1(--color-brand-bg #2A1418 上)。news-detail-content-v3.tsx:29,105、news-content-v3.tsx:37 同病。同处 hover:bg-[var(--color-brand)]/20、text-[var(--color-brand)]/30 在构建产物中不生成任何 CSS(对照 border-brand/30 可正常编译)。
本项是 §3.4 A-12 全站缺陷的一个局部实例 —— 同一契约违反在全仓共 67 处,故修复须按 A-12 整族收敛,只改新闻页会留下 Cookie 条等仍在失败。
修复:文字改用 text-brand-ink。同族缺陷另见 content-unavailable.tsx:38(本分支新增,暗色 3.31:1)。
R-4 审计 §10.1 修复 #4 只落一半 — src/components/detail/solution-service-card.tsx:128 shadow-blue-500/20 → shadow-brand 已做,同行保留 bg-gradient-to-br from-[var(--color-accent-blue)] to-[#1d4ed8](硬编码 hex + 双色渐变),:127 hover:border-blue-300、:134 group-hover:text-blue-600 绕过 accent-blue 令牌且暗色不翻转 ⇒ 蓝色块配品牌红光晕,违反 DESIGN.md:150/153「One Voice」与 CONTEXT.md:45「红与强调色不同卡」。
R-5 prefers-reduced-motion 未被 framer-motion 尊重 — 全仓 0 处 MotionConfig(grep MotionConfig src 无命中),globals.css:755-764 的 CSS 守卫(animation/transition-duration: 0.01ms !important)管不到 framer-motion 的 JS-rAF 内联样式。仅部分文件单独调用 useReducedMotion(),未接线者(如 detail-trust-section.tsx:80-83 的 y:24→0、detail-hero.tsx、product-detail-content-v3.tsx)在暗色+动效默认开启的本分支上,对前庭敏感用户仍产生大位移。与 DESIGN.md:122「reduced-motion 全链路守卫」不符。
修复:根布局包 <MotionConfig reducedMotion="user">,一处收口。
4.2 逻辑与安全(非本分支引入,但在验收范围内)
B-1 创建接口绕过发布工作流 — src/app/api/admin/items/route.ts:130 status: status || 'draft' + :135 publishedAt: status==='published' ? new Date() : null,仅 :107 requirePermission(…,'create') 守卫;PUT 侧 :184-186 明确拒改 status 并要求走 workflow 接口 —— 即 POST {status:'published'} 可跳过 submit/approve 与 publish 权限直接上线。且任意 status 字符串可入库,之后 workflow.ts:42 对所有动作返回 false,条目永久卡死且无反馈。
B-2 登录接口用户枚举 + 零限流 — src/app/api/auth/login/route.ts:20-22 在 :24 verifyPassword 之前返回 '账号已被禁用,请联系管理员',未知用户则为 :17 '用户名或密码错误';即便消息相同,user 不存在时不跑 bcrypt 也留下可靠的时间侧信道。全仓唯一限流在 src/app/api/contact/route.ts:14,登录裸奔(nginx-static-production.conf:177 rate=100r/s 仍允许约 860 万次/日)。修复:禁用检查移到验密之后;对 /api/auth/login 加 per-IP+per-username 计数或上游独立 limit_req。
B-3 basis 仅类型约定,非结构强制(直接对应「零编造」原则)— 类型侧全部可选:products.ts:15、services.ts:40、solutions.ts:27、sections.tsx:323、about-content-v4.tsx:39 皆 basis?: MetricBasis;运行侧 items/route.ts:131 JSON.stringify(data || {}) 完全不按 FieldDefinition 校验(cms/types.ts:50-55 的 validation {min,max,pattern} 全仓从未执行,zod 只在 api/contact/route.ts 使用)。故 POST {data:{metrics:[{value:'99.9%',basis:'verified'}]}} 会渲染「已有可核验的实测出处」。机械门禁 metrics-basis.test.ts:53-64,170-182 读的是导入的 seed 字面量、只扫 src/app+src/components,prisma/ 与 /admin 编辑后的库内容从不复检。
缓解事实:resolveMetricBasis/weakestBasis(metrics-basis.ts:34-47)保守回落 + content-types.ts:8-18 的 metricBasisField 明示「留空按目标口径处理」—— 兜底方向正确,FORBIDDEN_PROOF_PHRASES 扫描也确属严格(含 length>40 防空跑)。真正的漏洞在写入侧无校验,以及 home-content-v15.tsx:510,552,555,562 用 Record<string, any> / as any 把 CMS 载荷从类型系统里放行。
B-4 data:null 写库即打挂整页 — items/route.ts:197 if (data !== undefined) updateData.data = JSON.stringify(data)(PUT 缺 POST 那样的 || {} 兜底)→ data-server.ts:20 JSON.parse(item.data) 得 null → terms/page.tsx:180 items.find(i => i.data.pageType === 'terms') 抛 TypeError。同类:data-server.ts:20,75-77,89-91,106 的 JSON.parse 无 try/catch(对照 media-service.ts:133-139 parseDerivatives 已正确守卫),单个脏列即可中断页面/SSG。
B-5 角色权限重写无事务 — src/app/api/admin/roles/route.ts:81 先 deleteMany 全部权限再 :85-97 循环 create,全仓应用代码零 $transaction。中途失败即留下权限为空/半权限的角色(提权/降权双向风险)。同因:cms/workflow.ts:59→66→80 version: item.version+1 读-改-写在事务外(items/route.ts:192 却用了正确的 { increment: 1 },同规则两实现)→ 并发审批丢更新 + status TOCTOU;workflow.ts:80→90→102 update/audit/notify 三连 await 无原子性,通知失败会把成功审批变成 500。SQLite 侧 src/lib/db.ts:7 未配 journal_mode=WAL/busy_timeout(DATABASE_URL 无查询参数),默认 rollback journal + busy_timeout=0 下并发写直接 SQLITE_BUSY。
B-6 /api/cms/draft/enable 密钥缺失即放行 + 开放重定向 — route.ts:15 if (expectedSecret && secret !== expectedSecret) 为 fail-open;CMS_PREVIEW_SECRET 经全仓检索不在 .env / .env.local / .env.production / .env.example 任何一处,故线上恒为未配置,任意请求可 draft.enable(),并直达 :25-27 NextResponse.redirect(new URL(redirect, request.url)),redirect 取请求体、协议相对形式 //evil.com 即跳出站外。(内容面影响当前为零:draftMode() 除这两个路由外无人读取,data-server.ts:38,49,195,219 硬过滤 status:'published'。对照 /api/cms/revalidate/route.ts:40-45 未配置即 500,写法正确 —— 应统一为 fail-closed。)
B-7 刷新令牌无轮换/吊销 — auth/refresh/route.ts:16 仅验签名,:21-25 直接用 payload 重签,不加载用户 ⇒ 被禁用/删除的用户 7 天内持续换取访问令牌,绕过 login/route.ts:20 的禁用拦截;:24 role: payload.role 沿用旧 claim,降权用户保留高权标识(permissions.ts:38-56 按 UserRole 实查故服务端不破,但 admin-layout.tsx:241、auth/me/route.ts:15 会显示陈旧角色)。logout/route.ts:5-10 只清 cookie,prisma/schema.prisma 无 jti/tokenVersion 表 ⇒ 失窃 refresh token 登出后仍有效。
B-8 Bento 网格 ARIA 角色无父(3 页 × 3 浏览器 = 9 例失败的真实根因) — src/components/sections/bento-grid.tsx:23 把 role="list" 放在 BentoGrid、:47 子项 role="listitem";但 src/app/(marketing)/products/products-content-v3.tsx:9 只 import 了 BentoItem,卡片直接落在无 role="list" 的 div.grid lg:grid-cols-2 gap-px(:~193)上 ⇒ axe aria-required-parent(critical)。
已由 Lighthouse 桌面端独立复现并逐节点确认:/products 命中 7 个 critical 节点,data-testid 分别为 bento-product-card-{erp,crm,cms,bi,sds,oa,novavis} —— 与我按源码推断的「7 张 BentoItem 卡」精确一致,故根因不是假设。该页 a11y 得分因此降至 92(仍高于 0.9 门禁,见 A-11)。
修复:改用 BentoGrid 包裹,或去掉 BentoItem 的 role="listitem"。
B-9 tracking-tightest 是空类名,10 个 H1 丢失展示级字距 — tailwind.config.js:157-165 的 letterSpacing 仅 tighter/tight/normal/wide/wider/widest/eyebrow,无 tightest;构建产物 dist/static/chunks/0o3c09ni2y1ao.css 内只有 tracking-tighter/tracking-tight 两条规则,不存在 .tracking-tightest ⇒ 该类在 10 处标题(contact-content-v3.tsx:249、about-content-v4.tsx:70、cases-content-v3.tsx:146、team-content-v3.tsx:168 等)静默失效,全部大字标题以 0em 字距呈现,与 DESIGN.md:28 的 −0.03em 相悖。与 config 自述(:130-133)已发生过的 font-calligraphy 同族缺陷。
B-10 mega 下拉键盘不可关闭(WCAG 1.4.13 / 2.1.2) — src/components/layout/mega-dropdown.tsx:37 onMouseEnter 开、:28-30 仅 onMouseLeave 关,无 Escape 路径;header.tsx:28-32 的全局 Escape 只看 isOpen(移动抽屉),从不看 openDropdown。键盘用户在「产品」上回车后必须动鼠标才能关闭。
4.3 E2E 真实失败分类(92 = 约 30 例 × 3 浏览器,非抖动)
| 失败簇 | 例数 | 定性 |
|---|---|---|
ga4-event-tracking TC-GA4-001..004 @critical |
12 | 测试自身缺陷(A-6 空测)+ 断言环境不成立 |
mobile-accessibility axe aria-required-parent /products |
3 | 真实缺陷 B-8(Lighthouse 桌面端独立复现同样 7 节点,见 B-8) |
mobile-accessibility axe color-contrast /about、/team |
6 | 需产品裁决:节点为 about-content-v4.tsx:217、team-content-v3.tsx:85,118 的 `text-text-muted/30 |
触摸目标 ≥44px(首页,@accessibility+@performance) |
6 | 真实移动可用性风险,需按元素定位 |
p1-brand-visual-audit「不应出现可见 novalon」× 首页/全站 |
6 | 测试过期(见 §7),CONTEXT.md:194 已批准该文案 |
uj-11b 共创旅程 @critical |
6 | 真实可测性缺陷:uj-11-home-conversion.spec.ts:117 依赖 data-testid="early-access-banner",而首页 HTML 中该 testid 0 命中;:131 期望文案「成为首批共创客户」HTML 亦 0 命中(「首批客户共创中」「共创进行时」均存在)。实现未落测试钩子 |
5. 中危(P2)摘要
header.tsx:187aria-controls="mobile-menu"指向{isOpen && …}内才存在的id(:219)——关闭时 AT 解析落空。contact-content-v3.tsx:111setErrors(prev => ({…prev,[field]:undefined}))不删键 ⇒:376Object.keys(errors).length与:379「请修正以下 N 项」长期错误,字段全修完后仍残留带空<li>的role="alert"红框。header.tsx:167<div className="hidden md:flex">使ThemeToggle桌面专属,移动抽屉(:224-261)从不渲染 ⇒ 系统深色下的手机用户无法切浅色。本分支暗色默认开启,影响放大。header.tsx:87高度 80/64 动画 vslayout.tsx:18固定pt-16(64px) ⇒scrollY===0时首屏顶部 16px 内容压在固定头下方。api-crypto.ts:49-50以content-length判定有无请求体,chunked/H2 下静默跳过解密并把{data:"<base64>"}交给 handler(:93),管理端保存即写入密文;admin-api.ts:44-49catch {}在非安全上下文(crypto.subtle不可用)降级明文;:85将e.message回显客户端。密钥为NEXT_PUBLIC_*且盐硬编码 ⇒ 属混淆而非加密,无任何逻辑把它当鉴权用(此点正确)。analytics.ts:178-188trackOutboundLink同时发outbound_click与click,外跳统计双计;:172value || 1吞掉 0;:18-23默认analytics: true与:29-42无形状合并的JSON.parse可能违背用户实际同意。use-focus-trap.ts:43-46Escape 恒preventDefault并归还焦点却不通知持有者(焦点可逃出仍开启的 trap);:13的[tabindex]:not([tabindex="-1"])只约束末项,<button tabIndex={-1}>/<input type=hidden>被当可聚焦;:64无条件overflow='unset',层叠弹窗互相解锁滚动。use-keyboard-shortcuts.ts:34-36preventDefault()后调可能未接线的onSkipToContent⇒ Tab 键对键盘用户死路。media-service.ts:113-117先删文件后删 DB 记录,DB 失败即留下指向已删文件的资产;:109-111空catch {}使畸形 derivatives 的文件永不被删。media/storage.ts:29fs.unlink未做resolve+前缀校验(当前deleteMedia只接受库内路径)。- 死代码(grep 复核零引用):
ui/loading-skeleton.tsx、cms/RichTextEditor.tsx、examples/ContactFormAnalyticsExample.tsx、content/testimonials.tsx、lib/gradients.ts(+仅被它引用的lib/colors.ts;且getGlowStyle把primary映射为调色板中不存在的蓝'0, 94, 184')、ui/metric-card.tsx、ui/stats-showcase.tsx、sections/stats-bar.tsx。汇总桶ui/index.ts(201 行)、sections/index.ts、layout/index.ts无任何 importer ⇒ 经其可达的metric-card/stats-showcase/milestone-timeline/flip-clock/page-nav/list-page-hero/accordion/tabs/dialog/select全为死接线。这解释了 §4 中若干「渲染缺陷」实为潜伏缺陷。 products/[id]/page.tsx:9-14(及solutions/[id]、services/[id]、news/[slug])同时声明generateStaticParams与export const dynamic='force-dynamic'—— 后者胜出 ⇒ 前者是死码,且这 4 类详情页退出全站revalidate=3600ISR 策略。next.config.mjs:4实为output:'standalone',而:10注释仍以「静态导出限制」为unoptimized:true辩护,AGENTS.md/CLAUDE.md 亦仍称静态导出。- 配置/文档矛盾:
Dockerfile与docker-compose.yml把dist当静态 HTML 根拷贝、Dockerfile.static拷贝不存在的html/⇒ 用基础 Dockerfile 部署即白屏,仅Dockerfile.prod(COPY dist/standalone+node server.js)与配置模式相符。next.config.mjs:36X-Frame-Options: DENY与 nginxSAMEORIGIN(:40,124,140,182,194)双重且互斥,并产生重复 CSP —— 这正是test:security:headers报 2 警告、且X-Frame-Options实际值为"DENY, SAMEORIGIN"的来由。Sentry 未用withSentryConfig包裹 ⇒ 不上传 source map,线上堆栈为压缩态。tsconfig.json仍排除已不存在的src/app/(marketing)/_archive(AGENTS.md/CLAUDE.md 亦仍描述_archive/约定)。 data-server.ts:182-210只解析zi.itemId、忽略ContentZoneItem.item,且条目全部未发布的 zone 不写 key(返回undefined而非[])。workflow.ts:94把submit/reject一律记为action:'update'(驳回与编辑在审计日志中不可分),且'approve'|'archive'越出cms/types.ts:196联合类型,仅靠:50的as unknown as通过编译。
6. 已核实为正确(避免无谓返工)
$queryRaw/$executeRaw 应用层零使用(仅 Prisma 生成类型含);Prisma 写入无 ...body 质量赋值;media-service.ts:9 path.basename 先于 storage.ts:19 path.join,遍历已消;通知路由按会话 userId 收口(notifications.ts:80,90),无 IDOR;roles/route.ts:18,59 正确限 super_admin 且 :73 保护该角色自身;revalidate fail-closed;cookie HttpOnly; SameSite=Lax + 条件 Secure;auth.ts:10-15 缺密钥即抛(无硬编码兜底);三个 .env* 均已 gitignore(git ls-files 仅 .env.example 占位)。
迁移无漂移(5 个 migration 与 schema.prisma 对齐,RBAC 表已在 prisma/dev.db 落地);seed 中 55 处 basis 一律取最弱 'target'、无一处声称 'verified'。
verifyAccessTokenEdge(proxy.ts:34-67)以 HMAC 重算比对,alg:none 无法伪造;color-contrast.ts:21-28 WCAG sRGB 亮度与 0.03928 阈值正确;use-reduced-motion.ts、animated-counter.tsx:59、stats-showcase.tsx:48 等 observer/listener 清理齐备;theme-toggle.tsx 是 SSR 安全实现范本;static-link.tsx 外链 rel="noopener noreferrer" 正确;footer.tsx:195 暗色对比度实测 7.54:1(审计 P1-2 确已修复);webpackBuildWorker 未出现在 experimental(符合项目铁律)。
Jest 侧无 .only / .skip / .todo / xit / 盲写快照;playwright.config.ts forbidOnly:!!CI、retries: CI?2:0 配置正确,仅 2 处合理的按浏览器条件跳过。
性能预算实测达标且余量充足(7/7 页,desktop preset,lighthouserc.json 全部 URL):performance 99-100、FCP 246-250ms(预算 ≤2000)、LCP 790-896ms(预算 ≤3000)、CLS 0.000(预算 ≤0.1)、TBT 0ms(预算 ≤300)、SI 248-414ms(预算 ≤3000)、best-practices 100、SEO 100。lighthouserc.json 的 6 项性能断言 + 4 项分类断言零违规,且本分支未引入性能退化 —— 这是少数几个「AGENTS.md §5 声称的门禁经实测确实成立」的项。故上表把 Lighthouse 判为「门禁太松」仅指 accessibility 子项的 0.9 阈值容下了 critical 失败(A-11-2),性能维度本身可信。
7. 复核中主动撤回的判断(保持报告可信)
「首页服务区忽略 CMS,恒渲染兜底数据」—— 我据content-types.ts:88的serviceFields未声明subtitle/highlights/href推断该假设,随后以三重证据否证:prisma/dev.db的 service 行实测含subtitle/href/highlights(4)/metrics,且首页 HTML 中 CMS 文案(「战略咨询」「数字化成熟度评估」)命中 3/2/2 次、兜底专属串(「Strategy Consulting」「数字化转型战略咨询」)命中 0 次。CMS 通路正常。「seed 指标缺——basis,违反结构强制」content-types.ts:8-18的metricBasisField.description明示「留空按目标口径处理并自动标注」,metrics-basis.ts:33-38亦为刻意保守回落。这是设计而非缺陷。「—— 我误查了仓库根的陈旧残留prisma/dev.db与 schema 漂移,缺 RBAC 表」./dev.db(Jul 6) 与 0 字节./data.db;真实库.env:8指向prisma/dev.db,RBAC 四表与 5 个 migration 俱在。「(6 例 E2E 失败的定性)——Novalon 创始团队是品牌一致性回归」CONTEXT.md:194(✅ 2026-08-31 确认)在「零编造」条款下明确批准 FounderQuote 兜底署名「Novalon 创始团队」,prisma/seed.ts:961、home-content-v15.tsx:115与单测home-content-v15.test.tsx:181一致。故p1-brand-visual-audit.spec.ts:30的FORBIDDEN_TEXT=/novalon/i前提已过期,应改测试而非改文案。stats-bar.tsx:88parseInt("99.9")→99(99.9% 永久显示 99%)与:71运行期插值类lg:grid-cols-${items.length}(Tailwind 不生成,布局静默停在md:grid-cols-3)、animated-counter.tsx:62先显终值再跳 0 起算 —— 缺陷成立但当前不可达(组件经无 importer 的死汇总桶暴露,§5 死代码清单);列为修复时必须一并删除的死码,而非线上问题。animated-counter.test.tsx:5-12mockuseCountUp→500且value={500},两分支同值故永不能观测此问题。「两份 jest 配置测试发现不一致,变异门禁只看到 42% 的用例」—— 我据 Stryker dry-run 报 "Ran 668 tests"(而test:unit为 1594)提出该怀疑,实测npx jest --config config/test/jest.config.js得 132 套件 / 1594 例全通过,两配置testMatch/roots/setupFilesAfterEnv完全一致 ⇒ 不成立。同时纠正了对 Stryker 报告的误读:其 "All tests" 树中的 ✘ 标记含义是「该测试未覆盖当前变异体」(后缀(covered 0)),不是测试失败(✓=killed、~=covered 但未杀)。真实差异只有阈值数值不同,已归入 A-7。「Stryker 报告的 3 个存活变异体全为测试缺陷」—— 其中utils.ts:25if (timeout)→if (true)因clearTimeout(null)本身是 no-op 而语义等价,存活属正常。仅 A-13、A-14 两项计为缺陷。
8. 修复优先级与放行条件
| 顺序 | 动作 | 理由 |
|---|---|---|
| 1 | A-1 + A-2 角色 allowlist 与改密鉴权;A-3 渲染端净化;A-4 上传白名单 + /uploads/ 头 |
生产可利用,且 A-1→A-3/A-4 构成完整提权-存储 XSS 链 |
| 2 | A-5 摘掉 Jenkins ` | |
| 3 | A-12 + R-3 + B-9 一并处理:67 处 text-[var(--color-brand)] → text-brand-ink,并新增 grep 门禁禁该模式 |
全站 62 页的确定性合规失败,且已有明文契约(DESIGN.md:154/232)背书;逐点修必然漏 |
| 4 | R-1 双安全区;R-2 动效回到 280ms;R-5 MotionConfig;B-8 BentoGrid;B-10 下拉 Escape |
用户可见 / 本分支核心意图 |
| 5 | A-6 GA4 重写为真实拦截;A-9 基线在干净参照点重采 + 删 9 张近空白基线;A-11 check:contrast 改为读令牌表并补暗色/alpha 组,Lighthouse a11y 断言由 0.9 改为「critical 失败数 = 0」 |
恢复测试信号有效性;否则第 3 步修完仍无守卫 |
| 6 | A-7 阈值棘轮 + 三文档统一;A-8 加真库集成层;B-1/B-5 写入校验与 $transaction+WAL |
结构性 |
| 7 | B-2/B-6/B-7、§4.2 其余、§5 P2、死码清理 | 常规 |
| 8 | A-13/A-14 补 lerp 非零 start 用例与 randomBetween 分布断言;A-15 把 src/lib/constants/** 从 stryker.config.json 的 mutate 排除中移出;test:mutation 去 --inPlace |
变异门禁是本次唯一直接度量「断言是否有内容」的手段,且已实测可在 74s 内跑完单文件,成本极低 |
放行条件(全部满足方可验收通过):①§3.1/§3.2 四项 P0 安全关闭并有回归测试;②E2E 92 → 0(若按 §7-4 判定品牌测试过期,须同时修订 CONTEXT.md 或测试并在提交信息留痕);③Jenkins 无 || echo 后连续两次全绿;④AGENTS.md §5 列出的 lighthouse / contrast / headings 至少各执行一次并留结果;⑤A-12 全站对比度关闭后,以两个引擎各复跑一次并留 axe 节点计数 = 0 的证据;⑥R-1/R-3 在真机(iPhone SE + 系统深色)截图复核。
注:本次性能门禁的 0 违规不构成本项豁免——性能维度已实测达标(§6),但 a11y 维度是「断言通过而实际失败」,二者性质不同。
9. 本次未覆盖(诚实标注)
- 未做真机/真浏览器人工走查:R-1 双安全区、触摸目标 <44px 的具体元素,均由构建产物 CSS、axe 节点选择器与 HTML 计数推断,未经肉眼截图确认。(A-12 / B-8 例外 —— 二者已由 axe 引擎在真浏览器中直接报出节点,非推断。)
npm run lighthouse(lhci autorun)未直接执行,原因有两条,均为独立缺陷:config/test/lighthouserc.json配upload.target: "temporary-public-storage"⇒ 一旦运行即把含站点结构的结果上传至第三方公共存储(Lighthouse 官方示例报告库),对未发布站点属外泄风险。故本次改为逐 URL 调npx lighthouse@13复现同一断言集。建议改为upload: {target:'filesystem'}。- lighthouserc 的
startServerCommand: "npm run start"与output:'standalone'不兼容 —— 本次以next start启动 :3100 时 Next 自身告警"next start" does not work with "output: standalone" configuration. Use "node .next/standalone/server.js" instead.。即package.json的start/preview与 lighthouse 的起服命令在 standalone 模式下是半失效的,与 §5 中 Dockerfile 仍假设静态导出同源。这解释了 Jenkins 为何从未跑 lighthouse —— 跑不起来。
- k6 四个性能脚本未执行(依赖为占位包),仓库内
tests/performance/*-summary.json(Aug 12)是手工产物而非 CI 结果。 - 变异测试仅跑了
quick作用域(src/lib/utils.ts,34 变异体 / 74s);stryker.config.json声明的全量作用域(src/lib/**、src/hooks/**及 7 个组件目录)未执行 —— 按单文件外推需数小时,且--inPlace在全量下会长时间改写整个工作树,不宜在验收轮次内冒险。故 §3.5 的 91.18% 只代表 utils.ts 一个文件,不可作为全仓变异得分引用。 - 渗透测试 / 授权验证矩阵:仅静态审计 A-1/A-2/B-1,未真实构造请求验证提权链可用性。
- Lighthouse 仅测 desktop preset、每 URL 单次运行(
lighthouserc.json配numberOfRuns: 3);未测移动网络节流下的性能,也未做 3 次取中位以消除抖动。 - 运维观察(实测泄漏,已在本报告提交前清理):
scripts/utils/check-heading-hierarchy.ts:47与scripts/accessibility-test.js:31均以spawn('npm', ['run','preview'])起服,而accessibility-test.js:7的自述是「扫描完成后关闭服务」。实测本次跑完check:headings后,:3000 上仍残留一个 PPID=1 的next-server (v16.3.0)(父为已 orphan 的npm run preview)—— 因为脚本终止的是npm包装进程,真正 LISTEN 的next-server孙进程被 init 收养而继续占端口,且存活 39 分钟。这不是环境噪声而是门禁脚本的进程回收缺陷:它与 A-10 的reuseExistingServer: true叠加后,会让后续 E2E / Lighthouse 静默复用一个陈旧构建的服务,从而使「测的是当前代码」这一前提失效。- 附带发现:
CLAUDE.md:15称npm run preview是「Serve dist/ on port 3000 (npx serve)」,实际package.json为next start -p 3000;CLAUDE.md:197称 Playwright「Auto-startsnpm run preview」,实际playwright.config.ts:130用npm run dev。两处文档与实现相反,属 §5 配置/文档矛盾族。
- 附带发现:
- 仍有 2 处歧义未能闭合:
mega-dropdown在md:(768px) 的w-[640px]是否于真实平板宽度溢出;useFocusTrap的offsetParent过滤对position:fixed抽屉是否如预期工作。