chore(qa): 验收台账与证据入库 + 构建/部署配置同步

- 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 产物装配与部署形态。
This commit is contained in:
2026-09-28 10:48:09 +08:00
parent 6bb7c557ee
commit a0328a623f
128 changed files with 22455 additions and 504 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,22 @@
import { chromium } from 'playwright';
import path from 'path';
const ROOT = '/Users/zhangxiang/Codes/Novalon/novalon-website';
const TAGS = ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'];
// 每条规则各造一个必然违规的最小节点(正对照)+ 一个正常页(负对照)
const cases = {
'autocomplete-valid': '<form><label for=x>n</label><input id=x autocomplete="bogus-value"></form>',
'presentation-role-conflict': '<div role="button" aria-label="x" tabindex="0" style="display:none">h</div><img alt="" role="presentation" tabindex="0" src="data:,"><h1>t</h1><main>m</main>',
'svg-img-alt': '<svg role="img" width="10" height="10"><rect width="10" height="10"></rect></svg><h1>t</h1><main>m</main>',
};
const b = await chromium.launch();
for (const [rule, html] of Object.entries(cases)) {
const p = await b.newPage();
await p.setContent(`<!doctype html><html lang="zh-CN"><head><title>t</title></head><body><main>${html}</main></body></html>`);
await p.addScriptTag({ path: path.join(ROOT, 'node_modules/axe-core/axe.min.js') });
const res = await p.evaluate((tags) => window.axe.run(document, { runOnly: { type: 'tag', values: tags } }), TAGS);
const v = res.violations.find((x) => x.id === rule);
const ran = [...res.violations, ...res.passes, ...res.incomplete].some((x) => x.id === rule);
console.log(`${rule.padEnd(28)} tagIncluded=${ran ? 'YES' : 'NO '} violation=${v ? v.nodes.length + ' nodes' : '0'}`);
await p.close();
}
await b.close();
@@ -0,0 +1,72 @@
#!/bin/bash
# 第七轮(死断言处理后的复验):
# A. axe harness 扩到「tag 通道 + 规则级通道」——先 2 路由 sanity,再全站 34 路由 × 4 组合
# B. Lighthouse 去掉 3 条死断言后重跑,证明 EXIT=0 且 0 warning
set -u
cd /Users/zhangxiang/Codes/Novalon/novalon-website || exit 1
LOG=/tmp/final-chain7.log
HARNESS=docs/acceptance/2026-09-21-axe/axe-contrast-evidence.mjs
CRAWLER=docs/acceptance/2026-09-21-axe/crawl-sitemap.mjs
PORTS() { lsof -nP -iTCP:3000 -iTCP:3100 -sTCP:LISTEN 2>/dev/null | grep -c LISTEN; }
echo "=== chain7 started $(date -u +%FT%TZ) ===" > "$LOG"
echo "PREFLIGHT_LISTENERS=$(PORTS) $(date -u +%FT%TZ)" >> "$LOG"
npx next start -p 3100 > /tmp/axe-server8.log 2>&1 &
SRV=$!
code=000
for i in $(seq 1 40); do
code=$(curl -s -o /dev/null -w '%{http_code}' http://localhost:3100/ 2>/dev/null || true)
[ "$code" = "200" ] && break
sleep 3
done
echo "AXE_SERVER_UP=$code $(date -u +%FT%TZ)" >> "$LOG"
# 全站清单(口径与 chain6 相同:sitemap ∪ 预渲染产物 ∪ 站内链接 BFS)
BASE=http://localhost:3100 SEED=/tmp/axe-sitemap2.xml \
MANIFEST=dist/standalone/dist/server/app OUT=/tmp/axe-sitemap-all5.xml \
node --input-type=module -e "$(cat $CRAWLER)" > /tmp/crawl6.log 2>&1
echo "EXIT_CRAWL=$? $(date -u +%FT%TZ)" >> "$LOG"
grep -a "^crawled" /tmp/crawl6.log >> "$LOG"
# sanity:只用 2 条路由跑新代码路径,避免 14 分钟后才发现探针本身有 bug
python3 - <<'PY'
import re
src = open('/tmp/axe-sitemap-all5.xml', encoding='utf8').read()
blocks = re.findall(r'<url>.*?</url>', src, re.S)
open('/tmp/axe-sitemap-sanity.xml', 'w', encoding='utf8').write(
'<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n'
+ "\n".join(blocks[:2]) + "\n</urlset>\n")
print('sanity urls:', len(blocks[:2]))
PY
echo "EXIT_SANITY_CROP=$?" >> "$LOG"
SITEMAP=/tmp/axe-sitemap-sanity.xml BASE=http://localhost:3100 OUT=/tmp/axe-sanity-out \
node --input-type=module -e "$(cat $HARNESS)" > /tmp/axe-sanity.log 2>&1
echo "EXIT_SANITY=$? $(date -u +%FT%TZ)" >> "$LOG"
sed -e 's/\x1b\[[0-9;]*m//g' /tmp/axe-sanity.log | grep -aE "^(chromium|firefox)/|^PASSED|^SUMMARY|FATAL|Error" | head -14 >> "$LOG"
# 保留 chain6 证据,再产出最终树的新证据
cp docs/acceptance/2026-09-21-axe/axe-evidence.json \
docs/acceptance/2026-09-21-axe/axe-evidence-chain6-tagonly.json 2>/dev/null
SITEMAP=/tmp/axe-sitemap-all5.xml BASE=http://localhost:3100 \
OUT=docs/acceptance/2026-09-21-axe \
node --input-type=module -e "$(cat $HARNESS)" > /tmp/axe-sweep8.log 2>&1
echo "EXIT_AXE_FULL=$? $(date -u +%FT%TZ)" >> "$LOG"
node -e "const j=require('./docs/acceptance/2026-09-21-axe/axe-evidence.json');console.log('AXE routeCount='+j.routeCount+' rows='+j.rows.length+' passed='+j.passed+' extraRules='+JSON.stringify(j.extraRules)+' summary='+JSON.stringify(j.summary))" >> "$LOG" 2>&1
kill "$SRV" 2>/dev/null
pkill -f 'next start -p 3100' 2>/dev/null
sleep 3
echo "POST_AXE_LISTENERS=$(PORTS)" >> "$LOG"
{
echo
echo "===== chain7 lighthouse(移除 3 条死断言后)$(date -u +%FT%TZ) ====="
npm run lighthouse 2>&1
} | sed -e 's/\x1b\[[0-9;]*m//g' >> docs/acceptance/2026-09-21-gates/lighthouse.txt 2>&1
echo "EXIT_LIGHTHOUSE=${PIPESTATUS[0]} $(date -u +%FT%TZ)" >> "$LOG"
# 只统计本轮(chain7 段)残留的死断言,历史段保留作对照
awk '/chain7 lighthouse/{f=1} f' docs/acceptance/2026-09-21-gates/lighthouse.txt \
| grep -ac "not a known audit" | sed 's/^/CHAIN7_DEAD_AUDIT_WARNINGS=/' >> "$LOG"
sleep 3
echo "POST_FINAL_LISTENERS=$(PORTS)" >> "$LOG"
echo "=== chain7 done $(date -u +%FT%TZ) ===" >> "$LOG"
@@ -0,0 +1,60 @@
#!/bin/bash
# 第八轮(补 chain6/7 未覆盖的两项证据):
# A. npm run test:coverage —— A-7 阈值棘轮在最终树上是否仍成立
# B. iPhone SE 口径复跑(含新增的 404 边界页),证据写入新目录,不覆盖 09-21 的旧证据
set -u
cd /Users/zhangxiang/Codes/Novalon/novalon-website || exit 1
LOG=/tmp/final-chain8.log
SE_OUT=docs/acceptance/2026-09-22-iphone-se
PORTS() { lsof -nP -iTCP:3000 -iTCP:3100 -sTCP:LISTEN 2>/dev/null | grep -c LISTEN; }
echo "=== chain8 started $(date -u +%FT%TZ) ===" > "$LOG"
while ! grep -q "chain7 done" /tmp/final-chain7.log 2>/dev/null; do sleep 20; done
echo "CHAIN7_TAIL: $(grep -aE 'EXIT_AXE_FULL|AXE routeCount|EXIT_LIGHTHOUSE|CHAIN7_DEAD|^crawled|EXIT_SANITY' /tmp/final-chain7.log | tr '\n' '|')" >> "$LOG"
echo "PREFLIGHT_LISTENERS=$(PORTS) $(date -u +%FT%TZ)" >> "$LOG"
npm run test:coverage > /tmp/coverage-final8.log 2>&1
echo "EXIT_COVERAGE=$? $(date -u +%FT%TZ)" >> "$LOG"
{
echo "# test:coverage 最终树复跑 $(date -u +%FT%TZ)"
sed -e 's/\x1b\[[0-9;]*m//g' /tmp/coverage-final8.log | grep -aE "All files|Jest:|Tests:|Test Suites:|threshold|% (Stmts|Branch)" | head -20
} > docs/acceptance/2026-09-21-gates/coverage-final-tree.txt 2>&1
sleep 2
echo "POST_COVERAGE_LISTENERS=$(PORTS)" >> "$LOG"
mkdir -p "$SE_OUT"
npx next start -p 3000 > /tmp/se-server.log 2>&1 &
SRV=$!
code=000
for i in $(seq 1 40); do
code=$(curl -s -o /dev/null -w '%{http_code}' http://localhost:3000/ 2>/dev/null || true)
[ "$code" = "200" ] && break
sleep 3
done
echo "SE_SERVER_UP=$code $(date -u +%FT%TZ)" >> "$LOG"
BASE=http://localhost:3000 OUT="$SE_OUT" \
node --input-type=module -e "$(cat docs/acceptance/2026-09-21-iphone-se/iphone-se-audit.mjs)" \
> "$SE_OUT/audit.log" 2>&1
echo "EXIT_SE_AUDIT=$? $(date -u +%FT%TZ)" >> "$LOG"
kill "$SRV" 2>/dev/null
pkill -f 'next start -p 3000' 2>/dev/null
sleep 3
node -e '
const j = require("./'"$SE_OUT"'/measurements.json");
const pending = j.rows.filter((r) => /pending/.test(r.tag));
const overlap = pending.filter((r) => r.bannerOverlapsFooter);
const nf = j.rows.filter((r) => /not-found/.test(r.tag));
const bad = j.rows.filter((r) => r.docScrollW > r.clientW || r.theme === null);
console.log("SE rows=" + j.rows.length
+ " overflow=" + j.rows.filter((r) => r.docScrollW > r.clientW).length
+ " themeNull=" + j.rows.filter((r) => r.theme === null).length
+ " pendingRows=" + pending.length
+ " pendingBannerOverlapFooter=" + overlap.length
+ " notFoundRows=" + nf.length
+ " notFoundGaps=" + JSON.stringify(nf.map((r) => r.footerBottomGap))
+ " footerGaps=" + JSON.stringify([...new Set(j.rows.map((r) => r.footerBottomGap))]));
console.log("SE PASSED =", j.rows.length === 20 && bad.length === 0);
' >> "$LOG" 2>&1
echo "POST_FINAL_LISTENERS=$(PORTS)" >> "$LOG"
echo "=== chain8 done $(date -u +%FT%TZ) ===" >> "$LOG"
@@ -0,0 +1,36 @@
#!/bin/bash
# 第九轮(把 §7 覆盖缺口的 Lighthouse 半边补上后复跑):
# lighthouserc 的 collect.url 由 7 条扩到 9 条(补 /products/erp-upgrade、/about/brand),
# 同时重跑静态门禁,确认本轮改 config/docs/probe 未触碰 src 行为。
set -u
cd /Users/zhangxiang/Codes/Novalon/novalon-website || exit 1
LOG=/tmp/final-chain9.log
PORTS() { lsof -nP -iTCP:3000 -iTCP:3100 -sTCP:LISTEN 2>/dev/null | grep -c LISTEN; }
echo "=== chain9 started $(date -u +%FT%TZ) ===" > "$LOG"
while ! grep -q "chain8 done" /tmp/final-chain8.log 2>/dev/null; do sleep 20; done
echo "CHAIN8_TAIL: $(grep -aE 'EXIT_COVERAGE|SE PASSED|SE rows|EXIT_SE_AUDIT' /tmp/final-chain8.log | tr '\n' '|')" >> "$LOG"
echo "PREFLIGHT_LISTENERS=$(PORTS) $(date -u +%FT%TZ)" >> "$LOG"
for g in type-check lint test:unit check:contrast check:headings check:brand-token; do
npm run "$g" > "/tmp/c9-$(echo "$g" | tr ':' '-').log" 2>&1
echo "EXIT_$(echo "$g" | tr 'a-z-' 'A-Z')=$? $(date -u +%FT%TZ)" >> "$LOG"
done
sed -e 's/\x1b\[[0-9;]*m//g' /tmp/c9-lint.log | grep -a "problems" | tail -1 >> "$LOG"
sleep 2
echo "POST_STATIC_LISTENERS=$(PORTS)" >> "$LOG"
URLS=$(node -e 'const c=require("./config/test/lighthouserc.json");console.log(c.ci.collect.url.length)' 2>/dev/null)
echo "LIGHTHOUSE_URLS=$URLS" >> "$LOG"
{
echo
echo "===== chain9 lighthouse(9 URL × 3 次 = 27 运行,补 erp-upgrade / about-brand)$(date -u +%FT%TZ) ====="
npm run lighthouse 2>&1
} | sed -e 's/\x1b\[[0-9;]*m//g' >> docs/acceptance/2026-09-21-gates/lighthouse.txt 2>&1
echo "EXIT_LIGHTHOUSE=${PIPESTATUS[0]} $(date -u +%FT%TZ)" >> "$LOG"
awk '/chain9 lighthouse/{f=1} f' docs/acceptance/2026-09-21-gates/lighthouse.txt \
| grep -ac "not a known audit" | sed 's/^/CHAIN9_DEAD_AUDIT_WARNINGS=/' >> "$LOG"
awk '/chain9 lighthouse/{f=1} f' docs/acceptance/2026-09-21-gates/lighthouse.txt \
| grep -aE "✗|✓ \[" | tail -12 >> "$LOG"
sleep 3
echo "POST_FINAL_LISTENERS=$(PORTS)" >> "$LOG"
echo "=== chain9 done $(date -u +%FT%TZ) ===" >> "$LOG"
@@ -0,0 +1,404 @@
# check:contrast 与 check:headings —— 空闲机器复跑(无并发 E2E/Lighthouse)
# 生成于 2026-09-21T21:51:32Z
## npm run check:contrast
> ruixin-website-react@1.0.0-phase1 check:contrast
> tsx scripts/utils/check-color-contrast.ts
🎨 LIGHT 主题(:root,246 个令牌)
正文层级 × 页面底色
✅ --color-text-primary on --color-bg-primary — 19.34:1
✅ --color-text-primary on --color-bg-secondary — 18.48:1
✅ --color-text-primary on --color-bg-tertiary — 17.65:1
✅ --color-text-secondary on --color-bg-primary — 10.35:1
✅ --color-text-secondary on --color-bg-secondary — 9.90:1
✅ --color-text-tertiary on --color-bg-primary — 7.58:1
✅ --color-text-muted on --color-bg-primary — 4.76:1
品牌红文字通道(text-brand-ink 及 hover 档)
✅ --color-brand-ink on --color-bg-primary — 5.84:1
✅ --color-brand-ink on --color-bg-secondary — 5.58:1
✅ --color-brand-ink on --color-brand-bg — 5.35:1
✅ --color-brand-ink-hover on --color-bg-primary — 7.87:1
✅ --color-brand-ink-hover on --color-brand-bg — 7.20:1
✅ #FFFFFF on --color-brand — 5.84:1
alpha 修饰正文(带 /80 等修饰类的正文)
✅ --color-text-secondary on --color-bg-primary @80% — 5.74:1
大号装饰文本(text-text-hint)× 各级卡片底色
✅ --color-text-hint on --color-bg-primary — 3.41:1
✅ --color-text-hint on --color-bg-secondary — 3.26:1
✅ --color-text-hint on --color-bg-tertiary — 3.12:1
✅ --color-text-hint on --color-bg-elevated — 3.41:1
深色区块(不随主题翻转的独立 token)
✅ --color-dark-text-primary on --color-dark-bg — 18.48:1
✅ --color-dark-text-secondary on --color-dark-bg — 13.03:1
✅ --color-dark-text-muted on --color-dark-bg — 7.54:1
✅ --color-brand-section-text on --color-brand-section — 8.93:1
🎨 DARK 主题(html[data-theme='dark'],246 个令牌)
正文层级 × 页面底色
✅ --color-text-primary on --color-bg-primary — 18.48:1
✅ --color-text-primary on --color-bg-secondary — 17.69:1
✅ --color-text-primary on --color-bg-tertiary — 16.55:1
✅ --color-text-secondary on --color-bg-primary — 13.03:1
✅ --color-text-secondary on --color-bg-secondary — 12.47:1
✅ --color-text-tertiary on --color-bg-primary — 7.54:1
✅ --color-text-muted on --color-bg-primary — 7.54:1
品牌红文字通道(text-brand-ink 及 hover 档)
✅ --color-brand-ink on --color-bg-primary — 6.99:1
✅ --color-brand-ink on --color-bg-secondary — 6.69:1
✅ --color-brand-ink on --color-brand-bg — 6.27:1
✅ --color-brand-ink-hover on --color-bg-primary — 10.19:1
✅ --color-brand-ink-hover on --color-brand-bg — 9.13:1
✅ #FFFFFF on --color-brand — 5.84:1
alpha 修饰正文(带 /80 等修饰类的正文)
✅ --color-text-secondary on --color-bg-primary @80% — 8.52:1
大号装饰文本(text-text-hint)× 各级卡片底色
✅ --color-text-hint on --color-bg-primary — 7.54:1
✅ --color-text-hint on --color-bg-secondary — 7.22:1
✅ --color-text-hint on --color-bg-tertiary — 6.75:1
✅ --color-text-hint on --color-bg-elevated — 6.75:1
深色区块(不随主题翻转的独立 token)
✅ --color-dark-text-primary on --color-dark-bg — 18.48:1
✅ --color-dark-text-secondary on --color-dark-bg — 13.03:1
✅ --color-dark-text-muted on --color-dark-bg — 7.54:1
✅ --color-brand-section-text on --color-brand-section — 8.93:1
📊 合计 44 组:✅ 44 / ❌ 0 不达标 / ⚠️ 0 令牌缺失
✅ 全部令牌配对满足 WCAG 2.1 AA
## npm run check:headings
> ruixin-website-react@1.0.0-phase1 check:headings
> tsx scripts/utils/check-heading-hierarchy.ts
🚀 启动预览服务...
(node:52518) [DEP0190] DeprecationWarning: Passing args to a child process with shell option true can lead to security vulnerabilities, as the arguments are not escaped, only concatenated.
(Use `node --trace-deprecation ...` to show where the warning was created)
🔍 检查页面标题层级...
✅ 首页 (0 个问题)
✅ 关于我们 (0 个问题)
✅ 产品中心 (0 个问题)
✅ 产品详情-ERP (0 个问题)
✅ 解决方案 (0 个问题)
✅ 解决方案详情-制造 (0 个问题)
✅ 服务列表 (0 个问题)
✅ 服务详情-软件开发 (0 个问题)
✅ 新闻列表 (0 个问题)
✅ 联系我们 (0 个问题)
📊 检查摘要
扫描页面: 10
通过页面: 10
失败页面: 0
问题总数: 0
📝 详细报告已保存: /Users/zhangxiang/Codes/Novalon/novalon-website/heading-hierarchy-report.json
# check:contrast 与 check:headings —— 最终树空闲复跑
# 生成于 2026-09-21T22:47:59Z
## npm run check:contrast
> ruixin-website-react@1.0.0-phase1 check:contrast
> tsx scripts/utils/check-color-contrast.ts
🎨 LIGHT 主题(:root,246 个令牌)
正文层级 × 页面底色
✅ --color-text-primary on --color-bg-primary — 19.34:1
✅ --color-text-primary on --color-bg-secondary — 18.48:1
✅ --color-text-primary on --color-bg-tertiary — 17.65:1
✅ --color-text-secondary on --color-bg-primary — 10.35:1
✅ --color-text-secondary on --color-bg-secondary — 9.90:1
✅ --color-text-tertiary on --color-bg-primary — 7.58:1
✅ --color-text-muted on --color-bg-primary — 4.76:1
品牌红文字通道(text-brand-ink 及 hover 档)
✅ --color-brand-ink on --color-bg-primary — 5.84:1
✅ --color-brand-ink on --color-bg-secondary — 5.58:1
✅ --color-brand-ink on --color-brand-bg — 5.35:1
✅ --color-brand-ink-hover on --color-bg-primary — 7.87:1
✅ --color-brand-ink-hover on --color-brand-bg — 7.20:1
✅ #FFFFFF on --color-brand — 5.84:1
alpha 修饰正文(带 /80 等修饰类的正文)
✅ --color-text-secondary on --color-bg-primary @80% — 5.74:1
大号装饰文本(text-text-hint)× 各级卡片底色
✅ --color-text-hint on --color-bg-primary — 3.41:1
✅ --color-text-hint on --color-bg-secondary — 3.26:1
✅ --color-text-hint on --color-bg-tertiary — 3.12:1
✅ --color-text-hint on --color-bg-elevated — 3.41:1
深色区块(不随主题翻转的独立 token)
✅ --color-dark-text-primary on --color-dark-bg — 18.48:1
✅ --color-dark-text-secondary on --color-dark-bg — 13.03:1
✅ --color-dark-text-muted on --color-dark-bg — 7.54:1
✅ --color-brand-section-text on --color-brand-section — 8.93:1
🎨 DARK 主题(html[data-theme='dark'],246 个令牌)
正文层级 × 页面底色
✅ --color-text-primary on --color-bg-primary — 18.48:1
✅ --color-text-primary on --color-bg-secondary — 17.69:1
✅ --color-text-primary on --color-bg-tertiary — 16.55:1
✅ --color-text-secondary on --color-bg-primary — 13.03:1
✅ --color-text-secondary on --color-bg-secondary — 12.47:1
✅ --color-text-tertiary on --color-bg-primary — 7.54:1
✅ --color-text-muted on --color-bg-primary — 7.54:1
品牌红文字通道(text-brand-ink 及 hover 档)
✅ --color-brand-ink on --color-bg-primary — 6.99:1
✅ --color-brand-ink on --color-bg-secondary — 6.69:1
✅ --color-brand-ink on --color-brand-bg — 6.27:1
✅ --color-brand-ink-hover on --color-bg-primary — 10.19:1
✅ --color-brand-ink-hover on --color-brand-bg — 9.13:1
✅ #FFFFFF on --color-brand — 5.84:1
alpha 修饰正文(带 /80 等修饰类的正文)
✅ --color-text-secondary on --color-bg-primary @80% — 8.52:1
大号装饰文本(text-text-hint)× 各级卡片底色
✅ --color-text-hint on --color-bg-primary — 7.54:1
✅ --color-text-hint on --color-bg-secondary — 7.22:1
✅ --color-text-hint on --color-bg-tertiary — 6.75:1
✅ --color-text-hint on --color-bg-elevated — 6.75:1
深色区块(不随主题翻转的独立 token)
✅ --color-dark-text-primary on --color-dark-bg — 18.48:1
✅ --color-dark-text-secondary on --color-dark-bg — 13.03:1
✅ --color-dark-text-muted on --color-dark-bg — 7.54:1
✅ --color-brand-section-text on --color-brand-section — 8.93:1
📊 合计 44 组:✅ 44 / ❌ 0 不达标 / ⚠️ 0 令牌缺失
✅ 全部令牌配对满足 WCAG 2.1 AA
## npm run check:headings
> ruixin-website-react@1.0.0-phase1 check:headings
> tsx scripts/utils/check-heading-hierarchy.ts
🚀 启动预览服务...
(node:61061) [DEP0190] DeprecationWarning: Passing args to a child process with shell option true can lead to security vulnerabilities, as the arguments are not escaped, only concatenated.
(Use `node --trace-deprecation ...` to show where the warning was created)
🔍 检查页面标题层级...
✅ 首页 (0 个问题)
✅ 关于我们 (0 个问题)
✅ 产品中心 (0 个问题)
✅ 产品详情-ERP (0 个问题)
✅ 解决方案 (0 个问题)
✅ 解决方案详情-制造 (0 个问题)
✅ 服务列表 (0 个问题)
✅ 服务详情-软件开发 (0 个问题)
✅ 新闻列表 (0 个问题)
✅ 联系我们 (0 个问题)
📊 检查摘要
扫描页面: 10
通过页面: 10
失败页面: 0
问题总数: 0
📝 详细报告已保存: /Users/zhangxiang/Codes/Novalon/novalon-website/heading-hierarchy-report.json
## npm run check:brand-token
> ruixin-website-react@1.0.0-phase1 check:brand-token
> tsx scripts/utils/check-brand-text-token.ts
🔴 Two-Channel Red Rule 检查 — 文字通道禁用 --color-brand
📊 检查摘要
扫描目录: src
扫描文件: 414
违规处数: 0
✅ 文字通道全部使用 --color-brand-ink,双通道契约未被违反!
# ===== 最终树空闲复跑(chain3,含门禁脚本进程回收修复后)2026-09-22T02:42:16Z =====
## npm run check:contrast (EXIT=0)
✅ --color-brand-ink on --color-bg-primary — 6.99:1
✅ --color-brand-ink on --color-bg-secondary — 6.69:1
✅ --color-brand-ink on --color-brand-bg — 6.27:1
✅ --color-brand-ink-hover on --color-bg-primary — 10.19:1
✅ --color-brand-ink-hover on --color-brand-bg — 9.13:1
✅ #FFFFFF on --color-brand — 5.84:1
alpha 修饰正文(带 /80 等修饰类的正文)
✅ --color-text-secondary on --color-bg-primary @80% — 8.52:1
大号装饰文本(text-text-hint)× 各级卡片底色
✅ --color-text-hint on --color-bg-primary — 7.54:1
✅ --color-text-hint on --color-bg-secondary — 7.22:1
✅ --color-text-hint on --color-bg-tertiary — 6.75:1
✅ --color-text-hint on --color-bg-elevated — 6.75:1
深色区块(不随主题翻转的独立 token)
✅ --color-dark-text-primary on --color-dark-bg — 18.48:1
✅ --color-dark-text-secondary on --color-dark-bg — 13.03:1
✅ --color-dark-text-muted on --color-dark-bg — 7.54:1
✅ --color-brand-section-text on --color-brand-section — 8.93:1
📊 合计 44 组:✅ 44 / ❌ 0 不达标 / ⚠️ 0 令牌缺失
✅ 全部令牌配对满足 WCAG 2.1 AA
## npm run check:headings (EXIT=0)
(node:58761) [DEP0190] DeprecationWarning: Passing args to a child process with shell option true can lead to security vulnerabilities, as the arguments are not escaped, only concatenated.
(Use `node --trace-deprecation ...` to show where the warning was created)
🔍 检查页面标题层级...
✅ 首页 (0 个问题)
✅ 关于我们 (0 个问题)
✅ 产品中心 (0 个问题)
✅ 产品详情-ERP (0 个问题)
✅ 解决方案 (0 个问题)
✅ 解决方案详情-制造 (0 个问题)
✅ 服务列表 (0 个问题)
✅ 服务详情-软件开发 (0 个问题)
✅ 新闻列表 (0 个问题)
✅ 联系我们 (0 个问题)
📊 检查摘要
扫描页面: 10
通过页面: 10
失败页面: 0
问题总数: 0
📝 详细报告已保存: /Users/zhangxiang/Codes/Novalon/novalon-website/heading-hierarchy-report.json
## npm run check:brand-token (EXIT=0)
> ruixin-website-react@1.0.0-phase1 check:brand-token
> tsx scripts/utils/check-brand-text-token.ts
🔴 Two-Channel Red Rule 检查 — 文字通道禁用 --color-brand
📊 检查摘要
扫描目录: src
扫描文件: 414
违规处数: 0
✅ 文字通道全部使用 --color-brand-ink,双通道契约未被违反!
# ===== 最终树复跑(chain4:404 水印 /80 + global-error 兜底页修复后)2026-09-22T03:19:50Z =====
## npm run check:contrast (EXIT=0)
✅ --color-brand-ink on --color-bg-primary — 6.99:1
✅ --color-brand-ink on --color-bg-secondary — 6.69:1
✅ --color-brand-ink on --color-brand-bg — 6.27:1
✅ --color-brand-ink-hover on --color-bg-primary — 10.19:1
✅ --color-brand-ink-hover on --color-brand-bg — 9.13:1
✅ #FFFFFF on --color-brand — 5.84:1
alpha 修饰正文(带 /80 等修饰类的正文)
✅ --color-text-secondary on --color-bg-primary @80% — 8.52:1
大号装饰文本(text-text-hint)× 各级卡片底色
✅ --color-text-hint on --color-bg-primary — 7.54:1
✅ --color-text-hint on --color-bg-secondary — 7.22:1
✅ --color-text-hint on --color-bg-tertiary — 6.75:1
✅ --color-text-hint on --color-bg-elevated — 6.75:1
深色区块(不随主题翻转的独立 token)
✅ --color-dark-text-primary on --color-dark-bg — 18.48:1
✅ --color-dark-text-secondary on --color-dark-bg — 13.03:1
✅ --color-dark-text-muted on --color-dark-bg — 7.54:1
✅ --color-brand-section-text on --color-brand-section — 8.93:1
📊 合计 44 组:✅ 44 / ❌ 0 不达标 / ⚠️ 0 令牌缺失
✅ 全部令牌配对满足 WCAG 2.1 AA
## npm run check:brand-token (EXIT=0)
> ruixin-website-react@1.0.0-phase1 check:brand-token
> tsx scripts/utils/check-brand-text-token.ts
🔴 Two-Channel Red Rule 检查 — 文字通道禁用 --color-brand
📊 检查摘要
扫描目录: src
扫描文件: 416
违规处数: 0
✅ 文字通道全部使用 --color-brand-ink,双通道契约未被违反!
## npm run check:headings (EXIT=0)
(node:41979) [DEP0190] DeprecationWarning: Passing args to a child process with shell option true can lead to security vulnerabilities, as the arguments are not escaped, only concatenated.
(Use `node --trace-deprecation ...` to show where the warning was created)
🔍 检查页面标题层级...
✅ 首页 (0 个问题)
✅ 关于我们 (0 个问题)
✅ 产品中心 (0 个问题)
✅ 产品详情-ERP (0 个问题)
✅ 解决方案 (0 个问题)
✅ 解决方案详情-制造 (0 个问题)
✅ 服务列表 (0 个问题)
✅ 服务详情-软件开发 (0 个问题)
✅ 新闻列表 (0 个问题)
✅ 联系我们 (0 个问题)
📊 检查摘要
扫描页面: 10
通过页面: 10
失败页面: 0
问题总数: 0
📝 详细报告已保存: /Users/zhangxiang/Codes/Novalon/novalon-website/heading-hierarchy-report.json
# ===== 最终树复跑(chain6:header 函数式 setState + 移动端性能计时修复后)2026-09-22T03:51:41Z =====
## npm run check:contrast (EXIT=0)
✅ --color-brand-ink-hover on --color-brand-bg — 9.13:1
✅ #FFFFFF on --color-brand — 5.84:1
alpha 修饰正文(带 /80 等修饰类的正文)
✅ --color-text-secondary on --color-bg-primary @80% — 8.52:1
大号装饰文本(text-text-hint)× 各级卡片底色
✅ --color-text-hint on --color-bg-primary — 7.54:1
✅ --color-text-hint on --color-bg-secondary — 7.22:1
✅ --color-text-hint on --color-bg-tertiary — 6.75:1
✅ --color-text-hint on --color-bg-elevated — 6.75:1
深色区块(不随主题翻转的独立 token)
✅ --color-dark-text-primary on --color-dark-bg — 18.48:1
✅ --color-dark-text-secondary on --color-dark-bg — 13.03:1
✅ --color-dark-text-muted on --color-dark-bg — 7.54:1
✅ --color-brand-section-text on --color-brand-section — 8.93:1
📊 合计 44 组:✅ 44 / ❌ 0 不达标 / ⚠️ 0 令牌缺失
✅ 全部令牌配对满足 WCAG 2.1 AA
## npm run check:brand-token (EXIT=0)
> ruixin-website-react@1.0.0-phase1 check:brand-token
> tsx scripts/utils/check-brand-text-token.ts
🔴 Two-Channel Red Rule 检查 — 文字通道禁用 --color-brand
📊 检查摘要
扫描目录: src
扫描文件: 416
违规处数: 0
✅ 文字通道全部使用 --color-brand-ink,双通道契约未被违反!
## npm run check:headings (EXIT=0)
✅ 首页 (0 个问题)
✅ 关于我们 (0 个问题)
✅ 产品中心 (0 个问题)
✅ 产品详情-ERP (0 个问题)
✅ 解决方案 (0 个问题)
✅ 解决方案详情-制造 (0 个问题)
✅ 服务列表 (0 个问题)
✅ 服务详情-软件开发 (0 个问题)
✅ 新闻列表 (0 个问题)
✅ 联系我们 (0 个问题)
📊 检查摘要
扫描页面: 10
通过页面: 10
失败页面: 0
问题总数: 0
📝 详细报告已保存: /Users/zhangxiang/Codes/Novalon/novalon-website/heading-hierarchy-report.json
@@ -0,0 +1,10 @@
# test:coverage 最终树复跑 2026-09-22T04:50:44Z
✓ does not refresh when pull distance is below threshold (2 ms)
✓ should call onSwipeLeft when progress exceeds threshold (2 ms)
✓ should call onSwipeRight when progress exceeds threshold
✓ should not trigger callbacks when progress is below threshold (1 ms)
✓ should not call vibrate when progress is below threshold (2 ms)
File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
All files | 77.92 | 84.6 | 77.64 | 77.92 |
Test Suites: 137 passed, 137 total
Tests: 1659 passed, 1659 total
@@ -0,0 +1,68 @@
// 从 chain6 的 allure-results 里取出「本轮真正跑过的用例」按状态分组,
// 目的:给 ② 的 28 skipped 一份**具名**证据(哪些用例、在哪个 project、以什么理由跳过),
// 而不是只引用汇总行里的计数。汇总计数只能证明「数量」,证明不了「没有把失败洗成跳过」。
import { readdirSync, statSync, readFileSync, writeFileSync } from 'fs';
import { resolve } from 'path';
const DIR = resolve(process.cwd() ?? '.', 'e2e/allure-results');
// chain6 功能阶段:03:52:08Z → 04:20:43Z;视觉阶段紧接其后。留出余量按 UTC 当日 03:50–04:25 过滤。
const FROM = Date.parse('2026-09-22T03:50:00Z');
const TO = Date.parse('2026-09-22T04:25:00Z');
const files = readdirSync(DIR).filter((f) => f.endsWith('-result.json'));
const rows = [];
for (const f of files) {
const p = resolve(DIR, f);
const m = statSync(p).mtimeMs;
if (m < FROM || m > TO) continue;
let j;
try {
j = JSON.parse(readFileSync(p, 'utf8'));
} catch {
continue;
}
const labels = {};
for (const l of j.labels || []) labels[l.name] = l.value;
rows.push({
file: f,
mtime: new Date(m).toISOString(),
status: j.status,
reason: j.statusDetails?.message || j.statusDetails?.trace || '',
project: labels.device || labels.suiteParent || '',
suite: labels.suite || '',
fullName: j.fullName || '',
name: j.name || '',
});
}
const byStatus = {};
for (const r of rows) byStatus[r.status] = (byStatus[r.status] || 0) + 1;
const skipped = rows.filter((r) => r.status === 'skipped').sort((a, b) => (a.fullName + a.project).localeCompare(b.fullName + b.project));
const out = {
generatedAt: new Date().toISOString(),
source: 'e2e/allure-results(chain6 最终代码树 `npm run test` 功能+视觉两阶段的 allure 落盘)',
window: { from: new Date(FROM).toISOString(), to: new Date(TO).toISOString() },
totalResults: rows.length,
byStatus,
skippedCount: skipped.length,
// 按 (suite, reason) 聚合,便于核对「每处 test.skip 条件命中了多少条」
skipTally: Object.entries(
skipped.reduce((acc, r) => {
const k = `${r.suite} :: ${r.reason.slice(0, 80)}`;
acc[k] = (acc[k] || 0) + 1;
return acc;
}, {}),
).map(([k, n]) => ({ group: k, count: n })),
skipped,
};
writeFileSync(
resolve(process.cwd() ?? '.', 'docs/acceptance/2026-09-21-gates/skipped-tests-final-tree.json'),
JSON.stringify(out, null, 2),
);
console.log(`window results=${rows.length} byStatus=${JSON.stringify(byStatus)}`);
console.log(`skipped=${skipped.length}`);
for (const t of out.skipTally) console.log(` x${t.count} ${t.group}`);
const nonPass = rows.filter((r) => r.status !== 'passed' && r.status !== 'skipped');
console.log(`otherNonPassed=${nonPass.length}`);
for (const r of nonPass.slice(0, 10)) console.log(` ${r.status} ${r.project} ${r.fullName} :: ${r.reason.slice(0, 120)}`);
@@ -0,0 +1,112 @@
# 验收 §8 放行条件复跑结果(最终代码树)
生成时间:2026-09-22(UTC)。命令在**同一棵最终代码树**上串行执行,每步前后打印 :3000/:3100 端口占用数(0 才可用),
证据链编号:chain1(静态 + Lighthouse + GA4 生产复跑)→ chain2(作废,孤儿服务复用毒化)→ chain3(进程回收修复后复跑)
→ chain4/chain5/chain6(404 水印、`global-error` 兜底页、header 函数式 setState、移动端性能计时修复后,`EXIT_TEST=0` 归零轮)
→ chain7(Lighthouse 死断言处理 + axe 规则级通道)→ chain8(覆盖率棘轮 + iPhone SE 口径复跑)→ chain9(静态门禁与 Lighthouse 的第二次连续全绿)。
复跑脚本随证据存放于本目录:`chain7-dead-audit-and-rule-coverage.sh`、`chain8-coverage-and-iphone-se.sh`、`chain9-lighthouse-9urls.sh`。
## ② E2E 归零(`npm run test` = `test:functional && test:visual:all`)
| 轮次 | 结果 | 说明 |
|---|---|---|
| chain3 | 功能 `1048 passed / 4 failed / 28 skipped`(19.0m),端口预检 0 | 4 例均为**满负载竞态**(隔离复跑 `/tmp/repro-4.log` 33 例全过):p2 hover 下拉链接点击、p3 `isVisible→boundingBox` 两段式解析、uj-11 `scrollIntoViewIfNeeded` 撞 detach、p4 把 `waitForTimeout(1500)` 计入加载耗时 |
| chain4 | 功能 `1049 passed / 3 failed / 28 skipped`(15.7m) | 上述 4 例全过;新暴露同类 3 例:移动端 LCP 2736ms(dev 现场编译计入计时)、`website-acceptance.spec.ts:121` 与 `mobile.spec.ts:101` 找不到 `[data-testid="mobile-navigation"]` |
| chain6 | 功能 `1052 passed / 0 failed / 28 skipped`(17.4m)→ 视觉 `115 passed / 0 failed`(3.7m);`EXIT_TEST=0 2026-09-22T04:20:43Z` | **已归零**。chain4 的 3 例(移动端 LCP、两例 `mobile-navigation`)全部通过;视觉阶段真正执行(`/tmp/e2e-final9.log` 末段逐条列出 `visual-webkit-desktop` 的 115 例,含 L1 13 路由 + L2 组件态 + L3 排版色彩 + 浅色/深色主题各一)。全程未使用 `--update-snapshots`,基线未被改写。`POST_TEST_LISTENERS=0` |
chain4 的移动端两例经**根因定位后按产品缺陷修复**:`src/components/layout/header.tsx:184` 原为 `setIsOpen(!isOpen)`,
读取渲染闭包里的 `isOpen`,与抽屉的 `AnimatePresence` 退出动画(0.2s + 0.25s)叠加时高负载下丢点击 ⇒ 用户连点汉堡按钮可能打不开抽屉。
改为函数式 `setIsOpen((prev) => !prev)`。同文件同类的另外两处(`admin/login/page.tsx:80`、`admin/notifications/page.tsx:167`)
未在失败用例链路上,记为残留项交范围裁定。视觉基线未被改写(全程未使用 `--update-snapshots`)。
## ④ 静态门禁(chain6 最终树)
| 门禁 | 命令 | 结果 |
|---|---|---|
| 类型 | `npm run type-check` | chain6 `EXIT_TYPECHECK=0`(2026-09-22T03:51:14Z,2s)。**2s 触发怀疑,已用正/负对照自证不是空跑**:临时写入 `src/__typecheck-probe.ts`(`const probe: number = "not a number"`)⇒ `tsc` 报 `error TS2322`(11.1s,冷缓存);删除后复跑干净(2.9s,`tsconfig.compilerOptions.incremental=true` 命中 `.tsbuildinfo`)。探针文件已删除,未进入代码树 |
| 单元 | `npm run test:unit` | chain6 `EXIT_UNIT=0`:**137 suites / 1659 tests 全通过**(`Time: 6.936 s`,含新增 `src/app/global-error.test.tsx` 3 例) |
| Lint | `npm run lint` | chain6 `EXIT_LINT=0`:**0 error / 106 warning**(chain4 同为 106)。⚠️ [AGENTS.md](../../../AGENTS.md) §5 记的「实测 105 条」已过期,需同步;warning 是否清零由范围裁定,见 CONTEXT.md 决策表。其中 1 条是 `Unused eslint-disable directive`(死抑制,非真问题但可清) |
| 品牌文本通道 | `npm run check:brand-token` | chain6 `EXIT=0`,双通道契约未违反 |
| 对比度(令牌配对) | `npm run check:contrast` | chain6 **合计 44 组:✅ 44 / ❌ 0 / ⚠️ 0** → `contrast-and-headings.txt`(chain3/4/6 各一段) |
| 标题层级 | `npm run check:headings` | chain6 逐页 **0 问题**,`EXIT=0` |
| 构建 | `npm run build` | chain6 `EXIT_BUILD=0`(8s 增量,路由表与 24 个 html 产物 mtime 新于 03:50 已核对,非空跑) |
| Lighthouse | `npm run lighthouse` | chain6 `EXIT=0`(21 运行,但含 3 条死断言)→ **chain7 `EXIT_LIGHTHOUSE=0`(04:50:08Z)与 chain9 `EXIT_LIGHTHOUSE=0`(05:10:58Z)连续两次:9 URL × 3 次 = 27 运行、0 error、0 条 `not a known audit`**。URL 表已补 `/products/erp-upgrade`、`/about/brand`(§7 缺口的 Lighthouse 半边),两条新路由四类目均满分(perf 100 / a11y 1.0 / bp 1.0 / seo 1.0,LCP 786ms 与 794ms) |
| axe 节点计数(⑤) | 见下节 | `passed=true` |
**chain9 二次确认(05:03–05:11Z)**:处理完死断言、改过 `config/test/lighthouserc.json` 与文档/探针之后,把 `type-check`、`lint`、`test:unit`、`check:contrast`、`check:headings`、`check:brand-token` 全量重跑,**6 项 `EXIT=0`**(lint 仍 0 error / 106 warning),前后 `LISTENERS=0`。chain9 同时是 Lighthouse 在 9 URL 配置下的第二次连续全绿。
### Lighthouse 的 3 条死断言(chain6 新发现,已在最终树处理)
`config/test/lighthouserc.json:95-97` 的 `autocomplete-valid` / `presentation-role-conflict` / `svg-img-alt` 三条 `warn` 断言,
每轮都稳定打印 `"…" is not a known audit. expected: >=1 found: 0`(chain4+chain6 合计 42 条 = 3 条 × 7 URL × 2 轮)。
两个独立信源确认它们是**恒不成立的死断言**,而非「页面恰好没问题」:
1. **运行时报告**:最新 JSON 报告(`lighthouse-reports/contact-2026_09_22_04_25_34.report.json`,`lighthouseVersion 12.6.1`)的 `audits` 键集合里没有这三个 id,而同批断言的 `color-contrast`/`heading-order`/`target-size`/`aria-conditional-attr`/`skip-link` 均在列。
2. **审计注册表**:`@lhci/cli@0.15.1` 自带的是 `lighthouse@12.6.1`(不是顶层的 13.4.1 —— 这层版本裂差本身就是「为什么本地包里没有该审计」的答案),其 `core/audits/accessibility/` 共 64 个审计文件,无此三者。
覆盖率影响并非对等,因此不能简单删掉了事:用与门禁同款的 `runOnly: {type:'tag', values:['wcag2a','wcag2aa','wcag21a','wcag21aa']}`
做正/负对照探针(`docs/acceptance/2026-09-21-axe/probe-axe-rule-coverage.mjs`,每条规则各造一个必然违规的最小节点):
| 规则 | axe 4.11.4 是否在门禁 tag 内 | 探针是否抓到违规 |
|---|---|---|
| `autocomplete-valid` | 在 | ✅ 1 node |
| `svg-img-alt` | 在 | ✅ 1 node |
| `presentation-role-conflict` | **不在**(属 `best-practice` tag) | 规则未参与运行 |
⇒ 前两条的真实兜底是 ⑤ 的 axe 通道(已 34 路由 × 4 组合 = 0 违规);第三条在 axe 通道里也测不到,必须以规则级 `runOnly` 显式补上,
否则「删死断言」会变成「悄悄拆掉一道门」。
**chain7 复验(最终处理)**:`lighthouserc.json` 删掉这 3 条;`axe-contrast-evidence.mjs` 在 tag 通道之外增加第二次
`axe.run(document, {runOnly: {type:'rules', values: EXTRA_RULES}})`,并按页记录 `extraRulesChecked` 与 `extraRuleNodes`,
把 `extraRuleNodes === 0` 与 `extraRulePagesUnderCovered === 0`(即每页三条规则确实都跑了)一并写入 `thresholds` 与 `passed`。
结果:`EXIT_AXE_FULL=0`,`Σ extraRulesChecked = 408 = 34 路由 × 4 组合 × 3 规则`(分母闭合,不是"没测到所以 0")、
`extraRuleNodes = 0`;`EXIT_LIGHTHOUSE=0` 且 chain7 段 `not a known audit` 计数 **0**(chain4+chain6 历史段共 42 条,保留作对照)。
## ⑤ 全站双引擎双主题 axe 节点计数 = 0
**chain7 最终树(`docs/acceptance/2026-09-21-axe/axe-evidence.json`,`generatedAt 2026-09-22T04:43:02Z`,axe-core 4.11.4)**
——同一棵 src 代码树,`EXIT_AXE_FULL=0`;chain6(tag 通道单跑)快照存为 `axe-evidence-chain6-tagonly.json`,chain5 前一刻快照存为 `axe-evidence-chain5-preheader-fix.json`。
| 组合 | pages | contrastNodes | violationNodes | themeMismatch | bgMismatch | extraRuleNodes | extraRulePagesUnderCovered |
|---|---|---|---|---|---|---|---|
| chromium / light | 34 | 0 | 0 | 0 | 0 | 0 | 0 |
| chromium / dark | 34 | 0 | 0 | 0 | 0 | 0 | 0 |
| firefox / light | 34 | 0 | 0 | 0 | 0 | 0 | 0 |
| firefox / dark | 34 | 0 | 0 | 0 | 0 | 0 | 0 |
`routeCount=34 rows=136(= 34 × 4 组合,分母断言)passed=true`;`Σ extraRulesChecked = 408 = 34 × 4 × 3`,
规则级通道覆盖 `autocomplete-valid` / `presentation-role-conflict` / `svg-img-alt`(后一条不在 wcag tag 集合内,只能靠它)。
**口径升级(这是 ⑤ 的关键,不是换实现)**:清单来源从「`/sitemap.xml` 的 29 条」扩为
**sitemap ∪ 预渲染产物(`dist/standalone/dist/server/app/**/*.html`)∪ 站内链接 BFS**(`crawl-sitemap.mjs`,打印 `skippedInternalDocs=1`)。
扩目录首轮(chain3)就**不是 0**:`/_not-found` 的 h1 用 `text-brand-ink` + `opacity-20` ⇒ 双引擎双主题各 1 个 `color-contrast` 节点;
`/_global-error` 是 Next 内置 500 文档(`<html id="__next_error__">` 无 `lang`、深色丢本站令牌)。
两处修复见 `docs/lessons-learned.md §5.24` 与 `CONTEXT.md` 决策行;框架内置文档按**内容**(非名字)排除并留痕,
一旦框架把本站 `global-error.tsx` 预渲染到该路径,条件即不再命中、路由自动回到清单。
历史对照:`axe-evidence-sitemap29.json`(29 页版,`passed=true`,116 行)—— 当时的 0 是真 0,但分母不足以覆盖 404 与兜底页。
## ⑥ 与 A-7 的补充复跑(chain8,最终树)
| 项 | 结果 |
|---|---|
| 覆盖率棘轮(A-7) | `npm run test:coverage` → **`EXIT_COVERAGE=0`**(2026-09-22T04:50:44Z):137 套件 / 1659 例全通过,`All files` = **Stmts 77.92 / Branch 84.6 / Funcs 77.64 / Lines 77.92**,对照 `config/test/jest.config.js` 的 `coverageThreshold`(82 / 75 / 75 / 75)四项均达标。数值已回写 `AGENTS.md` §5(原记 84.71 / 77.55 / 77.89 / 77.89 是 2026-09-21 树,`global-error.tsx` 加入后微移)→ `coverage-final-tree.txt` |
| iPhone SE 口径复跑(⑥ 的仿真部分) | `EXIT_SE_AUDIT=0`(2026-09-22T04:52:07Z),证据写入**新目录** `docs/acceptance/2026-09-22-iphone-se/`(40 图 + `measurements.json` + `audit.log`),不覆盖 09-21 那份 21:07Z 的旧证据。本轮把探针路由从 4 条扩到 5 条(新增 `/no-such-route-404-check`),得 **20 行 = 5 页 × 深/浅 × 同意条 pending/dismissed**:`overflow=0`(`docScrollW == clientW == 375` 全部成立)、`themeNull=0`、`dismissed` 态 `footerBottomGap=64` 一致(R-1 的双安全区空带已消除)。`pendingBannerOverlapFooter=8` 是 `fixed bottom-*` 浮层的既定行为,可关闭后消失,与旧证据同口径。**限制不变**:这是 Playwright 仿真,条件⑥ 要的「真机 iPhone SE + 系统深色」仍只能由交付决策人实机走查 |
**新探针当场挖到的一条观察(不是回归,待裁定)**:404 路由的 `footerBottomGap` 全为 `null` ⇒ 预渲染产物 `dist/standalone/dist/server/app/_not-found.html` 里**没有 `<footer>`**(同一探针下首页有 `<footer data-testid="footer" role="contentinfo">`)。根因不是 bug:`src/app/layout.tsx` 本身不渲染 Header/Footer,页面级外壳由各页自行组合,而 `src/app/not-found.tsx` 只返回 `NotFoundContent`(自带「返回首页 / 搜索」出口)。因此**404 与根错误页无站内导航、无备案页脚**——是否给边界页补齐页头页脚属设计判断,未擅自改动,列入决策清单。
## ① 四项 P0 的回归测试归属(非快照,指向可点开的具体用例)
- A-1/A-2 权限提升与改密鉴权:`src/app/api/admin/users/route.test.ts`(11 例,含「不得借先建号再自我提权绕过」「资料字段不得先落库再被角色校验拒绝」「不得停用/降级超管」)、`src/app/api/admin/roles/route.test.ts`(9 例)、`src/lib/permissions.test.ts`(10 例)。
- A-3 存储型 XSS:`src/lib/sanitize.test.ts`(15 例)。
- A-4 上传白名单 + 魔数:`src/lib/media/upload-policy.test.ts`(扩展名/MIME 申报不符、双扩展名、`.html`/`.svg`/可执行、路径穿越、空与超限)、`src/lib/media/media-service.test.ts:60,69`(**`upload-policy` 未被 mock**,断言「HTML 载荷在落盘前即被拒绝」,因此同时是路由下游的接线回归)、`src/app/api/admin/media/route.test.ts`(12 例:401/403/400/体积/多图)。
## 已知口径限制(不掩饰)
1. **chain2 那次 `805 passed / 263 failed` 作废**:`check:headings`/`check:contrast` 的脚本只 kill 包装进程 ⇒ `next-server` 孤儿占住 :3000,被 Playwright `reuseExistingServer` 静默复用,205 个失败是 `page.goto` 超时而非断言失败。已修(`detached` + 负 PID 杀进程组 + `process.exit` 前显式收服),chain3/4/6 的 `PRE/POST_*_LISTENERS` 均为 0 即为正/负对照。
2. **满负载竞态与"隔离必过"的差别**:本地 `retries: 0`、CI `retries: 2`(`e2e/playwright.config.ts`),且 4 个 project × 多 worker 同机跑 3 个引擎。本轮不做"加 retries 掩盖",而是逐项消除竞态成因(预热、auto-retry 断言、限定 `main` 作用域、函数式 setState)。残留风险:同机并发仍可能出新的竞态例。
3. ③「Jenkins 无 `|| echo` 后连续两次全绿」只能由交付决策人触发;本地已按 Jenkinsfile 原样跑通同一命令序列(`npm run build` + `npm run test:e2e:prod`)。
4. ⑥ 已在最终树上以仿真复跑(chain8,`docs/acceptance/2026-09-22-iphone-se/`,含新增的 404 路由);`measurements.json` 的 `caveat` 仍写明「Playwright 仿真,非真机」,**真机走查只能由交付决策人完成**,本条件不得由本地证据替代。
5. ② 的「提交信息留痕」需一次经授权的提交才能落地;**本轮全部改动(含 `src/app/global-error.tsx`、header setState、4 个 spec 修订、`CONTEXT.md`/`lessons-learned.md`)均未提交**。
6. 28 skipped 的拆解见 §5.23 与下表;其中 16 条 GA4 `@critical` 已在**生产目标**下单独复跑并全部真断言(`ga4-production-run.txt`:`Running 4 tests using 4 workers` → `4 passed (7.2s)`,含 `next start` 与 `output: standalone` 的告警原文)。
7. **覆盖缺口的处理进度**:`/products/erp-upgrade` 与 `/about/brand` 此前既不在 Lighthouse URL 表也不在视觉回归的 13 条路由表里(视觉表有 `/products/erp`,与 `erp-upgrade` 是**不同路由**)——这正是 §5.22 已记过的同型缺口。**Lighthouse 半边已闭合**(chain7 起为 9 URL × 3 = 27 运行,`EXIT=0`);**视觉半边仍未闭合**,把两条路由加进 `e2e/visual-regression.spec.ts` 需要 `--update-snapshots` 重写已审批基线,属破坏性动作,须单独授权。
@@ -0,0 +1,24 @@
=== GA4 生产目标复跑 2026-09-21T22:47:51Z ===
(node:60835) [DEP0205] DeprecationWarning: `module.register()` is deprecated. Use `module.registerHooks()` instead.
(Use `node --trace-deprecation ...` to show where the warning was created)
[WebServer] ⚠ Warning: Next.js ignored package-lock.json in /Users/zhangxiang because it is outside the current Git repository (/Users/zhangxiang/Codes/Novalon/novalon-website).
[WebServer]  To use this directory, set `outputFileTracingRoot` in your Next.js config.
[WebServer] 
[WebServer] ⚠ "next start" does not work with "output: standalone" configuration. Use "node .next/standalone/server.js" instead.
Running 4 tests using 4 workers
(node:60886) [DEP0205] DeprecationWarning: `module.register()` is deprecated. Use `module.registerHooks()` instead.
(Use `node --trace-deprecation ...` to show where the warning was created)
(node:60883) [DEP0205] DeprecationWarning: `module.register()` is deprecated. Use `module.registerHooks()` instead.
(Use `node --trace-deprecation ...` to show where the warning was created)
(node:60885) [DEP0205] DeprecationWarning: `module.register()` is deprecated. Use `module.registerHooks()` instead.
(Use `node --trace-deprecation ...` to show where the warning was created)
(node:60884) [DEP0205] DeprecationWarning: `module.register()` is deprecated. Use `module.registerHooks()` instead.
(Use `node --trace-deprecation ...` to show where the warning was created)
✓ 3 [chromium] › ga4-event-tracking.spec.ts:121:7 › GA4 事件追踪验证 › TC-GA4-001: 页面浏览追踪 - 首屏由应用发出唯一一次 config @analytics @regression @critical (2.2s)
✓ 4 [chromium] › ga4-event-tracking.spec.ts:234:7 › GA4 事件追踪验证 › TC-GA4-004: 页面浏览追踪 - 客户端路由跳转触发带 page_path 的 config @analytics @regression @critical (2.5s)
✓ 1 [chromium] › ga4-event-tracking.spec.ts:141:7 › GA4 事件追踪验证 › TC-GA4-002: 联系表单提交触发 form_submit 和 conversion 事件 @analytics @regression @critical (4.1s)
✓ 2 [chromium] › ga4-event-tracking.spec.ts:199:7 › GA4 事件追踪验证 › TC-GA4-003: 同意横幅「接受所有」点击触发 button_click 事件 @analytics @regression @critical (5.1s)
4 passed (7.2s)
@@ -0,0 +1,564 @@
> ruixin-website-react@1.0.0-phase1 lighthouse
> lhci autorun --config=config/test/lighthouserc.json
✅ .lighthouseci/ directory writable
✅ Configuration file found
✅ Chrome installation found
Healthcheck passed!
Started a web server with "npm run start"...
Running Lighthouse 3 time(s) on http://localhost:3000/
Run #1...done.
Run #2...done.
Run #3...done.
Running Lighthouse 3 time(s) on http://localhost:3000/about
Run #1...done.
Run #2...done.
Run #3...done.
Running Lighthouse 3 time(s) on http://localhost:3000/services
Run #1...done.
Run #2...done.
Run #3...done.
Running Lighthouse 3 time(s) on http://localhost:3000/products
Run #1...done.
Run #2...done.
Run #3...done.
Running Lighthouse 3 time(s) on http://localhost:3000/cases
Run #1...done.
Run #2...done.
Run #3...done.
Running Lighthouse 3 time(s) on http://localhost:3000/news
Run #1...done.
Run #2...done.
Run #3...done.
Running Lighthouse 3 time(s) on http://localhost:3000/contact
Run #1...done.
Run #2...done.
Run #3...done.
Done running Lighthouse!
Checking assertions against 7 URL(s), 21 total run(s)
3 result(s) for http://localhost:3000/ :
⚠️ autocomplete-valid warning for auditRan assertion
"autocomplete-valid" is not a known audit.
expected: >=1
found: 0
all values: 0, 0, 0
⚠️ presentation-role-conflict warning for auditRan assertion
"presentation-role-conflict" is not a known audit.
expected: >=1
found: 0
all values: 0, 0, 0
⚠️ svg-img-alt warning for auditRan assertion
"svg-img-alt" is not a known audit.
expected: >=1
found: 0
all values: 0, 0, 0
3 result(s) for http://localhost:3000/about :
⚠️ autocomplete-valid warning for auditRan assertion
"autocomplete-valid" is not a known audit.
expected: >=1
found: 0
all values: 0, 0, 0
⚠️ presentation-role-conflict warning for auditRan assertion
"presentation-role-conflict" is not a known audit.
expected: >=1
found: 0
all values: 0, 0, 0
⚠️ svg-img-alt warning for auditRan assertion
"svg-img-alt" is not a known audit.
expected: >=1
found: 0
all values: 0, 0, 0
3 result(s) for http://localhost:3000/services :
⚠️ autocomplete-valid warning for auditRan assertion
"autocomplete-valid" is not a known audit.
expected: >=1
found: 0
all values: 0, 0, 0
⚠️ presentation-role-conflict warning for auditRan assertion
"presentation-role-conflict" is not a known audit.
expected: >=1
found: 0
all values: 0, 0, 0
⚠️ svg-img-alt warning for auditRan assertion
"svg-img-alt" is not a known audit.
expected: >=1
found: 0
all values: 0, 0, 0
3 result(s) for http://localhost:3000/products :
⚠️ autocomplete-valid warning for auditRan assertion
"autocomplete-valid" is not a known audit.
expected: >=1
found: 0
all values: 0, 0, 0
⚠️ presentation-role-conflict warning for auditRan assertion
"presentation-role-conflict" is not a known audit.
expected: >=1
found: 0
all values: 0, 0, 0
⚠️ svg-img-alt warning for auditRan assertion
"svg-img-alt" is not a known audit.
expected: >=1
found: 0
all values: 0, 0, 0
3 result(s) for http://localhost:3000/cases :
⚠️ autocomplete-valid warning for auditRan assertion
"autocomplete-valid" is not a known audit.
expected: >=1
found: 0
all values: 0, 0, 0
⚠️ presentation-role-conflict warning for auditRan assertion
"presentation-role-conflict" is not a known audit.
expected: >=1
found: 0
all values: 0, 0, 0
⚠️ svg-img-alt warning for auditRan assertion
"svg-img-alt" is not a known audit.
expected: >=1
found: 0
all values: 0, 0, 0
3 result(s) for http://localhost:3000/news :
⚠️ autocomplete-valid warning for auditRan assertion
"autocomplete-valid" is not a known audit.
expected: >=1
found: 0
all values: 0, 0, 0
⚠️ presentation-role-conflict warning for auditRan assertion
"presentation-role-conflict" is not a known audit.
expected: >=1
found: 0
all values: 0, 0, 0
⚠️ svg-img-alt warning for auditRan assertion
"svg-img-alt" is not a known audit.
expected: >=1
found: 0
all values: 0, 0, 0
3 result(s) for http://localhost:3000/contact :
⚠️ autocomplete-valid warning for auditRan assertion
"autocomplete-valid" is not a known audit.
expected: >=1
found: 0
all values: 0, 0, 0
⚠️ presentation-role-conflict warning for auditRan assertion
"presentation-role-conflict" is not a known audit.
expected: >=1
found: 0
all values: 0, 0, 0
⚠️ svg-img-alt warning for auditRan assertion
"svg-img-alt" is not a known audit.
expected: >=1
found: 0
all values: 0, 0, 0
All results processed!
Dumping 21 reports to disk at /Users/zhangxiang/Codes/Novalon/novalon-website/lighthouse-reports...
Done writing reports to disk.
Done running autorun.
===== chain6 lighthouse(最终树)2026-09-22T04:20:43Z =====
> ruixin-website-react@1.0.0-phase1 lighthouse
> lhci autorun --config=config/test/lighthouserc.json
✅ .lighthouseci/ directory writable
✅ Configuration file found
✅ Chrome installation found
Healthcheck passed!
Started a web server with "npm run start"...
Running Lighthouse 3 time(s) on http://localhost:3000/
Run #1...done.
Run #2...done.
Run #3...done.
Running Lighthouse 3 time(s) on http://localhost:3000/about
Run #1...done.
Run #2...done.
Run #3...done.
Running Lighthouse 3 time(s) on http://localhost:3000/services
Run #1...done.
Run #2...done.
Run #3...done.
Running Lighthouse 3 time(s) on http://localhost:3000/products
Run #1...done.
Run #2...done.
Run #3...done.
Running Lighthouse 3 time(s) on http://localhost:3000/cases
Run #1...done.
Run #2...done.
Run #3...done.
Running Lighthouse 3 time(s) on http://localhost:3000/news
Run #1...done.
Run #2...done.
Run #3...done.
Running Lighthouse 3 time(s) on http://localhost:3000/contact
Run #1...done.
Run #2...done.
Run #3...done.
Done running Lighthouse!
Checking assertions against 7 URL(s), 21 total run(s)
3 result(s) for http://localhost:3000/ :
⚠️ autocomplete-valid warning for auditRan assertion
"autocomplete-valid" is not a known audit.
expected: >=1
found: 0
all values: 0, 0, 0
⚠️ presentation-role-conflict warning for auditRan assertion
"presentation-role-conflict" is not a known audit.
expected: >=1
found: 0
all values: 0, 0, 0
⚠️ svg-img-alt warning for auditRan assertion
"svg-img-alt" is not a known audit.
expected: >=1
found: 0
all values: 0, 0, 0
3 result(s) for http://localhost:3000/about :
⚠️ autocomplete-valid warning for auditRan assertion
"autocomplete-valid" is not a known audit.
expected: >=1
found: 0
all values: 0, 0, 0
⚠️ presentation-role-conflict warning for auditRan assertion
"presentation-role-conflict" is not a known audit.
expected: >=1
found: 0
all values: 0, 0, 0
⚠️ svg-img-alt warning for auditRan assertion
"svg-img-alt" is not a known audit.
expected: >=1
found: 0
all values: 0, 0, 0
3 result(s) for http://localhost:3000/services :
⚠️ autocomplete-valid warning for auditRan assertion
"autocomplete-valid" is not a known audit.
expected: >=1
found: 0
all values: 0, 0, 0
⚠️ presentation-role-conflict warning for auditRan assertion
"presentation-role-conflict" is not a known audit.
expected: >=1
found: 0
all values: 0, 0, 0
⚠️ svg-img-alt warning for auditRan assertion
"svg-img-alt" is not a known audit.
expected: >=1
found: 0
all values: 0, 0, 0
3 result(s) for http://localhost:3000/products :
⚠️ autocomplete-valid warning for auditRan assertion
"autocomplete-valid" is not a known audit.
expected: >=1
found: 0
all values: 0, 0, 0
⚠️ presentation-role-conflict warning for auditRan assertion
"presentation-role-conflict" is not a known audit.
expected: >=1
found: 0
all values: 0, 0, 0
⚠️ svg-img-alt warning for auditRan assertion
"svg-img-alt" is not a known audit.
expected: >=1
found: 0
all values: 0, 0, 0
3 result(s) for http://localhost:3000/cases :
⚠️ autocomplete-valid warning for auditRan assertion
"autocomplete-valid" is not a known audit.
expected: >=1
found: 0
all values: 0, 0, 0
⚠️ presentation-role-conflict warning for auditRan assertion
"presentation-role-conflict" is not a known audit.
expected: >=1
found: 0
all values: 0, 0, 0
⚠️ svg-img-alt warning for auditRan assertion
"svg-img-alt" is not a known audit.
expected: >=1
found: 0
all values: 0, 0, 0
3 result(s) for http://localhost:3000/news :
⚠️ autocomplete-valid warning for auditRan assertion
"autocomplete-valid" is not a known audit.
expected: >=1
found: 0
all values: 0, 0, 0
⚠️ presentation-role-conflict warning for auditRan assertion
"presentation-role-conflict" is not a known audit.
expected: >=1
found: 0
all values: 0, 0, 0
⚠️ svg-img-alt warning for auditRan assertion
"svg-img-alt" is not a known audit.
expected: >=1
found: 0
all values: 0, 0, 0
3 result(s) for http://localhost:3000/contact :
⚠️ autocomplete-valid warning for auditRan assertion
"autocomplete-valid" is not a known audit.
expected: >=1
found: 0
all values: 0, 0, 0
⚠️ presentation-role-conflict warning for auditRan assertion
"presentation-role-conflict" is not a known audit.
expected: >=1
found: 0
all values: 0, 0, 0
⚠️ svg-img-alt warning for auditRan assertion
"svg-img-alt" is not a known audit.
expected: >=1
found: 0
all values: 0, 0, 0
All results processed!
Dumping 21 reports to disk at /Users/zhangxiang/Codes/Novalon/novalon-website/lighthouse-reports...
Done writing reports to disk.
Done running autorun.
===== chain7 lighthouse(移除 3 条死断言后)2026-09-22T04:43:05Z =====
> ruixin-website-react@1.0.0-phase1 lighthouse
> lhci autorun --config=config/test/lighthouserc.json
✅ .lighthouseci/ directory writable
✅ Configuration file found
✅ Chrome installation found
Healthcheck passed!
Started a web server with "npm run start"...
Running Lighthouse 3 time(s) on http://localhost:3000/
Run #1...done.
Run #2...done.
Run #3...done.
Running Lighthouse 3 time(s) on http://localhost:3000/about
Run #1...done.
Run #2...done.
Run #3...done.
Running Lighthouse 3 time(s) on http://localhost:3000/services
Run #1...done.
Run #2...done.
Run #3...done.
Running Lighthouse 3 time(s) on http://localhost:3000/products
Run #1...done.
Run #2...done.
Run #3...done.
Running Lighthouse 3 time(s) on http://localhost:3000/cases
Run #1...done.
Run #2...done.
Run #3...done.
Running Lighthouse 3 time(s) on http://localhost:3000/news
Run #1...done.
Run #2...done.
Run #3...done.
Running Lighthouse 3 time(s) on http://localhost:3000/contact
Run #1...done.
Run #2...done.
Run #3...done.
Running Lighthouse 3 time(s) on http://localhost:3000/products/erp-upgrade
Run #1...done.
Run #2...done.
Run #3...done.
Running Lighthouse 3 time(s) on http://localhost:3000/about/brand
Run #1...done.
Run #2...done.
Run #3...done.
Done running Lighthouse!
Checking assertions against 9 URL(s), 27 total run(s)
All results processed!
Dumping 27 reports to disk at /Users/zhangxiang/Codes/Novalon/novalon-website/lighthouse-reports...
Done writing reports to disk.
Done running autorun.
===== chain9 lighthouse(9 URL × 3 次 = 27 运行,补 erp-upgrade / about-brand)2026-09-22T05:04:19Z =====
> ruixin-website-react@1.0.0-phase1 lighthouse
> lhci autorun --config=config/test/lighthouserc.json
✅ .lighthouseci/ directory writable
✅ Configuration file found
✅ Chrome installation found
Healthcheck passed!
Started a web server with "npm run start"...
Running Lighthouse 3 time(s) on http://localhost:3000/
Run #1...done.
Run #2...done.
Run #3...done.
Running Lighthouse 3 time(s) on http://localhost:3000/about
Run #1...done.
Run #2...done.
Run #3...done.
Running Lighthouse 3 time(s) on http://localhost:3000/services
Run #1...done.
Run #2...done.
Run #3...done.
Running Lighthouse 3 time(s) on http://localhost:3000/products
Run #1...done.
Run #2...done.
Run #3...done.
Running Lighthouse 3 time(s) on http://localhost:3000/cases
Run #1...done.
Run #2...done.
Run #3...done.
Running Lighthouse 3 time(s) on http://localhost:3000/news
Run #1...done.
Run #2...done.
Run #3...done.
Running Lighthouse 3 time(s) on http://localhost:3000/contact
Run #1...done.
Run #2...done.
Run #3...done.
Running Lighthouse 3 time(s) on http://localhost:3000/products/erp-upgrade
Run #1...done.
Run #2...done.
Run #3...done.
Running Lighthouse 3 time(s) on http://localhost:3000/about/brand
Run #1...done.
Run #2...done.
Run #3...done.
Done running Lighthouse!
Checking assertions against 9 URL(s), 27 total run(s)
All results processed!
Dumping 27 reports to disk at /Users/zhangxiang/Codes/Novalon/novalon-website/lighthouse-reports...
Done writing reports to disk.
Done running autorun.
@@ -0,0 +1,21 @@
import { test } from '@playwright/test';
// 只打印、不判定:把「fixture browserName 值」与「真实引擎(userAgent)」两件事分开测,
// 因为跳过条件读的是前者,而能力(Touchscreen API)取决于后者。
test('probe browserName identity', async ({ browserName, page }, testInfo) => {
const ua = await page.evaluate(() => navigator.userAgent);
const touchPoints = await page.evaluate(() => 'ontouchstart' in window ? (navigator.maxTouchPoints ?? 0) : -1);
console.log(
JSON.stringify({
project: testInfo.project.name,
fixtureBrowserName: browserName,
useBrowserName: (testInfo.project.use as { browserName?: string }).browserName ?? null,
skipConditionIsTrue: browserName !== 'chromium',
ua: ua.slice(0, 90),
engine: /AppleWebKit\/.*Version\//.test(ua) && !/Chrome\//.test(ua) ? 'webkit'
: /Firefox\//.test(ua) ? 'firefox'
: /Chrome\//.test(ua) ? 'chromium' : 'unknown',
touchPoints,
}),
);
});
@@ -0,0 +1,33 @@
import { defineConfig, devices } from '@playwright/test';
// 探针:`chromium-mobile` 项目里 browserName 到底是什么、真正跑的是哪个引擎。
// 起因:最终树 28 条 skipped 有 7 条落在 chromium-mobile(cms-workflow ×3 + Touchscreen ×1 + GA4 ×4),
// 而跳过条件写成 `browserName !== 'chromium'` ⇒ 该项目内条件为真。
export default defineConfig({
testDir: __dirname,
fullyParallel: true,
reporter: [['list']],
projects: [
{
name: 'replica-chromium-mobile',
use: {
...devices['iPhone 14'],
viewport: { width: 390, height: 844 },
isMobile: true,
hasTouch: true,
},
},
{
// 正对照:显式写 browserName,看是否与"仅 spread devices"得到不同的 fixture 值
name: 'explicit-chromium-mobile',
browserName: 'chromium',
use: {
...devices['iPhone 14'],
viewport: { width: 390, height: 844 },
isMobile: true,
hasTouch: true,
},
},
{ name: 'plain-desktop', use: { ...devices['Desktop Chrome'] } },
],
});
@@ -0,0 +1,310 @@
{
"generatedAt": "2026-09-22T05:18:05.880Z",
"source": "e2e/allure-results(chain6 最终代码树 `npm run test` 功能+视觉两阶段的 allure 落盘)",
"window": {
"from": "2026-09-22T03:50:00.000Z",
"to": "2026-09-22T04:25:00.000Z"
},
"totalResults": 1195,
"byStatus": {
"passed": 1167,
"skipped": 28
},
"skippedCount": 28,
"skipTally": [
{
"group": "cms-workflow.spec.ts :: CMS workflow test runs only in chromium",
"count": 9
},
{
"group": "ga4-event-tracking.spec.ts :: 构建未注入 GA 脚本(NEXT_PUBLIC_GA_MEASUREMENT_ID 为空)",
"count": 16
},
{
"group": "p3-compatibility.spec.ts :: Touchscreen API only available in Chromium",
"count": 3
}
],
"skipped": [
{
"file": "2219cf08-fad3-4399-8ec7-90aaf2d53ae7-result.json",
"mtime": "2026-09-22T04:07:45.576Z",
"status": "skipped",
"reason": "CMS workflow test runs only in chromium",
"project": "",
"suite": "cms-workflow.spec.ts",
"fullName": "cms-workflow.spec.ts:265:7",
"name": "admin 可完成 创建草稿 → 提交审核 → 发布 → 前台可见"
},
{
"file": "7de4326b-8932-4c95-880a-1e80296196b4-result.json",
"mtime": "2026-09-22T04:13:35.011Z",
"status": "skipped",
"reason": "CMS workflow test runs only in chromium",
"project": "",
"suite": "cms-workflow.spec.ts",
"fullName": "cms-workflow.spec.ts:265:7",
"name": "admin 可完成 创建草稿 → 提交审核 → 发布 → 前台可见"
},
{
"file": "ab09e8f1-e6bf-4936-a6a8-330dbb04fc10-result.json",
"mtime": "2026-09-22T04:03:26.179Z",
"status": "skipped",
"reason": "CMS workflow test runs only in chromium",
"project": "",
"suite": "cms-workflow.spec.ts",
"fullName": "cms-workflow.spec.ts:265:7",
"name": "admin 可完成 创建草稿 → 提交审核 → 发布 → 前台可见"
},
{
"file": "55ec3c0e-1cce-4e62-b0a1-2812f419c924-result.json",
"mtime": "2026-09-22T04:13:35.012Z",
"status": "skipped",
"reason": "CMS workflow test runs only in chromium",
"project": "",
"suite": "cms-workflow.spec.ts",
"fullName": "cms-workflow.spec.ts:279:7",
"name": "非法状态流转会被拦截"
},
{
"file": "625d1024-b16b-4698-9365-bacbbbad6df8-result.json",
"mtime": "2026-09-22T04:03:26.185Z",
"status": "skipped",
"reason": "CMS workflow test runs only in chromium",
"project": "",
"suite": "cms-workflow.spec.ts",
"fullName": "cms-workflow.spec.ts:279:7",
"name": "非法状态流转会被拦截"
},
{
"file": "8ab7a06e-ec1f-4636-a4ec-f83311a5426e-result.json",
"mtime": "2026-09-22T04:07:45.578Z",
"status": "skipped",
"reason": "CMS workflow test runs only in chromium",
"project": "",
"suite": "cms-workflow.spec.ts",
"fullName": "cms-workflow.spec.ts:279:7",
"name": "非法状态流转会被拦截"
},
{
"file": "42921f30-5ef2-402c-a0cd-c75190f16bf1-result.json",
"mtime": "2026-09-22T04:03:26.185Z",
"status": "skipped",
"reason": "CMS workflow test runs only in chromium",
"project": "",
"suite": "cms-workflow.spec.ts",
"fullName": "cms-workflow.spec.ts:297:7",
"name": "多角色权限分离:管理员配置权限 → 编辑创建/提交 → 审核员发布 → 前台可见"
},
{
"file": "52d69dbe-2aeb-4a1c-a5b5-857f2f5cf0f1-result.json",
"mtime": "2026-09-22T04:07:45.579Z",
"status": "skipped",
"reason": "CMS workflow test runs only in chromium",
"project": "",
"suite": "cms-workflow.spec.ts",
"fullName": "cms-workflow.spec.ts:297:7",
"name": "多角色权限分离:管理员配置权限 → 编辑创建/提交 → 审核员发布 → 前台可见"
},
{
"file": "f4d7c0ab-7d86-41bf-9e0c-9dba8af4fcf6-result.json",
"mtime": "2026-09-22T04:13:35.013Z",
"status": "skipped",
"reason": "CMS workflow test runs only in chromium",
"project": "",
"suite": "cms-workflow.spec.ts",
"fullName": "cms-workflow.spec.ts:297:7",
"name": "多角色权限分离:管理员配置权限 → 编辑创建/提交 → 审核员发布 → 前台可见"
},
{
"file": "08dcaad7-65e9-4f8f-ba09-8ccf9998eea6-result.json",
"mtime": "2026-09-22T04:08:01.577Z",
"status": "skipped",
"reason": "构建未注入 GA 脚本(NEXT_PUBLIC_GA_MEASUREMENT_ID 为空)",
"project": "",
"suite": "ga4-event-tracking.spec.ts",
"fullName": "ga4-event-tracking.spec.ts:121:7",
"name": "TC-GA4-001: 页面浏览追踪 - 首屏由应用发出唯一一次 config"
},
{
"file": "19adc24e-76cd-4c67-86e3-6dc3349ad6c0-result.json",
"mtime": "2026-09-22T04:13:53.422Z",
"status": "skipped",
"reason": "构建未注入 GA 脚本(NEXT_PUBLIC_GA_MEASUREMENT_ID 为空)",
"project": "",
"suite": "ga4-event-tracking.spec.ts",
"fullName": "ga4-event-tracking.spec.ts:121:7",
"name": "TC-GA4-001: 页面浏览追踪 - 首屏由应用发出唯一一次 config"
},
{
"file": "3e38da66-8835-4bd0-bcd5-4f25b9632243-result.json",
"mtime": "2026-09-22T04:03:45.432Z",
"status": "skipped",
"reason": "构建未注入 GA 脚本(NEXT_PUBLIC_GA_MEASUREMENT_ID 为空)",
"project": "",
"suite": "ga4-event-tracking.spec.ts",
"fullName": "ga4-event-tracking.spec.ts:121:7",
"name": "TC-GA4-001: 页面浏览追踪 - 首屏由应用发出唯一一次 config"
},
{
"file": "8e30c9be-b79a-463c-959d-3d89ecc8f79b-result.json",
"mtime": "2026-09-22T04:00:03.361Z",
"status": "skipped",
"reason": "构建未注入 GA 脚本(NEXT_PUBLIC_GA_MEASUREMENT_ID 为空)",
"project": "",
"suite": "ga4-event-tracking.spec.ts",
"fullName": "ga4-event-tracking.spec.ts:121:7",
"name": "TC-GA4-001: 页面浏览追踪 - 首屏由应用发出唯一一次 config"
},
{
"file": "0e4356a2-c4fe-4992-8152-7a42b02323a5-result.json",
"mtime": "2026-09-22T04:08:06.884Z",
"status": "skipped",
"reason": "构建未注入 GA 脚本(NEXT_PUBLIC_GA_MEASUREMENT_ID 为空)",
"project": "",
"suite": "ga4-event-tracking.spec.ts",
"fullName": "ga4-event-tracking.spec.ts:141:7",
"name": "TC-GA4-002: 联系表单提交触发 form_submit 和 conversion 事件"
},
{
"file": "19d37331-c2d4-4467-89fe-a3f1979e7a28-result.json",
"mtime": "2026-09-22T04:00:03.245Z",
"status": "skipped",
"reason": "构建未注入 GA 脚本(NEXT_PUBLIC_GA_MEASUREMENT_ID 为空)",
"project": "",
"suite": "ga4-event-tracking.spec.ts",
"fullName": "ga4-event-tracking.spec.ts:141:7",
"name": "TC-GA4-002: 联系表单提交触发 form_submit 和 conversion 事件"
},
{
"file": "29da23ac-66f4-4bed-b9a7-5680ee020b52-result.json",
"mtime": "2026-09-22T04:13:53.581Z",
"status": "skipped",
"reason": "构建未注入 GA 脚本(NEXT_PUBLIC_GA_MEASUREMENT_ID 为空)",
"project": "",
"suite": "ga4-event-tracking.spec.ts",
"fullName": "ga4-event-tracking.spec.ts:141:7",
"name": "TC-GA4-002: 联系表单提交触发 form_submit 和 conversion 事件"
},
{
"file": "d74226e3-b1ee-45e8-8ece-dd756bd2428d-result.json",
"mtime": "2026-09-22T04:03:44.905Z",
"status": "skipped",
"reason": "构建未注入 GA 脚本(NEXT_PUBLIC_GA_MEASUREMENT_ID 为空)",
"project": "",
"suite": "ga4-event-tracking.spec.ts",
"fullName": "ga4-event-tracking.spec.ts:141:7",
"name": "TC-GA4-002: 联系表单提交触发 form_submit 和 conversion 事件"
},
{
"file": "37e77148-e420-4c3f-ba73-e7348fbab00c-result.json",
"mtime": "2026-09-22T04:08:06.810Z",
"status": "skipped",
"reason": "构建未注入 GA 脚本(NEXT_PUBLIC_GA_MEASUREMENT_ID 为空)",
"project": "",
"suite": "ga4-event-tracking.spec.ts",
"fullName": "ga4-event-tracking.spec.ts:199:7",
"name": "TC-GA4-003: 同意横幅「接受所有」点击触发 button_click 事件"
},
{
"file": "868f0726-a517-48d4-953f-8a226ee4b5aa-result.json",
"mtime": "2026-09-22T04:13:52.839Z",
"status": "skipped",
"reason": "构建未注入 GA 脚本(NEXT_PUBLIC_GA_MEASUREMENT_ID 为空)",
"project": "",
"suite": "ga4-event-tracking.spec.ts",
"fullName": "ga4-event-tracking.spec.ts:199:7",
"name": "TC-GA4-003: 同意横幅「接受所有」点击触发 button_click 事件"
},
{
"file": "8be78b7c-be72-4d56-a8a7-e2898c54f1cb-result.json",
"mtime": "2026-09-22T04:00:04.472Z",
"status": "skipped",
"reason": "构建未注入 GA 脚本(NEXT_PUBLIC_GA_MEASUREMENT_ID 为空)",
"project": "",
"suite": "ga4-event-tracking.spec.ts",
"fullName": "ga4-event-tracking.spec.ts:199:7",
"name": "TC-GA4-003: 同意横幅「接受所有」点击触发 button_click 事件"
},
{
"file": "c47b32cd-0f86-46b8-98c1-641664fa84c4-result.json",
"mtime": "2026-09-22T04:03:44.635Z",
"status": "skipped",
"reason": "构建未注入 GA 脚本(NEXT_PUBLIC_GA_MEASUREMENT_ID 为空)",
"project": "",
"suite": "ga4-event-tracking.spec.ts",
"fullName": "ga4-event-tracking.spec.ts:199:7",
"name": "TC-GA4-003: 同意横幅「接受所有」点击触发 button_click 事件"
},
{
"file": "0c13405f-07ca-465f-babe-7694785d1386-result.json",
"mtime": "2026-09-22T04:00:06.235Z",
"status": "skipped",
"reason": "构建未注入 GA 脚本(NEXT_PUBLIC_GA_MEASUREMENT_ID 为空)",
"project": "",
"suite": "ga4-event-tracking.spec.ts",
"fullName": "ga4-event-tracking.spec.ts:234:7",
"name": "TC-GA4-004: 页面浏览追踪 - 客户端路由跳转触发带 page_path 的 config"
},
{
"file": "2a78aee1-234c-4b59-9d03-96e5fb749a93-result.json",
"mtime": "2026-09-22T04:03:48.732Z",
"status": "skipped",
"reason": "构建未注入 GA 脚本(NEXT_PUBLIC_GA_MEASUREMENT_ID 为空)",
"project": "",
"suite": "ga4-event-tracking.spec.ts",
"fullName": "ga4-event-tracking.spec.ts:234:7",
"name": "TC-GA4-004: 页面浏览追踪 - 客户端路由跳转触发带 page_path 的 config"
},
{
"file": "5ce72f00-9339-4ed9-9243-376f97720ea1-result.json",
"mtime": "2026-09-22T04:13:55.190Z",
"status": "skipped",
"reason": "构建未注入 GA 脚本(NEXT_PUBLIC_GA_MEASUREMENT_ID 为空)",
"project": "",
"suite": "ga4-event-tracking.spec.ts",
"fullName": "ga4-event-tracking.spec.ts:234:7",
"name": "TC-GA4-004: 页面浏览追踪 - 客户端路由跳转触发带 page_path 的 config"
},
{
"file": "be9ce18f-0ab5-4016-9697-047f0208eb00-result.json",
"mtime": "2026-09-22T04:08:08.969Z",
"status": "skipped",
"reason": "构建未注入 GA 脚本(NEXT_PUBLIC_GA_MEASUREMENT_ID 为空)",
"project": "",
"suite": "ga4-event-tracking.spec.ts",
"fullName": "ga4-event-tracking.spec.ts:234:7",
"name": "TC-GA4-004: 页面浏览追踪 - 客户端路由跳转触发带 page_path 的 config"
},
{
"file": "2c6ec67e-4473-4796-a81b-d51ec9bef653-result.json",
"mtime": "2026-09-22T04:05:29.786Z",
"status": "skipped",
"reason": "Touchscreen API only available in Chromium",
"project": "",
"suite": "p3-compatibility.spec.ts",
"fullName": "p3-compatibility.spec.ts:226:7",
"name": "触摸滑动流畅(无卡顿)"
},
{
"file": "50fe3c7c-8d10-49b1-800d-cf504f89511f-result.json",
"mtime": "2026-09-22T04:15:12.200Z",
"status": "skipped",
"reason": "Touchscreen API only available in Chromium",
"project": "",
"suite": "p3-compatibility.spec.ts",
"fullName": "p3-compatibility.spec.ts:226:7",
"name": "触摸滑动流畅(无卡顿)"
},
{
"file": "da7b926e-4c87-4ca5-9b21-4b4d9eb0ff4a-result.json",
"mtime": "2026-09-22T04:10:56.093Z",
"status": "skipped",
"reason": "Touchscreen API only available in Chromium",
"project": "",
"suite": "p3-compatibility.spec.ts",
"fullName": "p3-compatibility.spec.ts:226:7",
"name": "触摸滑动流畅(无卡顿)"
}
]
}
@@ -0,0 +1,125 @@
import { chromium } from 'playwright';
import { mkdirSync, writeFileSync } from 'fs';
const BASE = process.env.BASE || 'http://localhost:3000';
const OUT = process.env.OUT || '/tmp/iphone-se-evidence';
mkdirSync(OUT, { recursive: true });
const PAGES = [
['home', '/'],
['news-detail', '/news/company-founded'],
['news-list', '/news'],
['contact', '/contact'],
// 边界页:404 的 h1 由 opacity-20 改为 text-brand-ink/80,需在 375px 窄屏(`text-8xl` 档)复核
['not-found', '/no-such-route-404-check'],
];
const MEASURE = () => {
const footer = document.querySelector('footer, [data-testid="footer"]');
let lastTextBottom = 0;
let label = '';
if (footer) {
footer.querySelectorAll('*').forEach((el) => {
if (el.children.length || !el.textContent.trim()) return;
const b = el.getBoundingClientRect();
if (b.height > 0 && b.bottom > lastTextBottom) {
lastTextBottom = b.bottom;
label = el.textContent.trim().slice(0, 24);
}
});
}
const fr = footer ? footer.getBoundingClientRect() : null;
const banner = document.querySelector('[data-testid="cookie-consent-banner"]');
const br = banner ? banner.getBoundingClientRect() : null;
return {
theme: document.documentElement.getAttribute('data-theme'),
footerBottomGap: fr && lastTextBottom ? Math.round(fr.bottom - lastTextBottom) : null,
lastText: label,
footerBottomVsViewport: fr ? Math.round(fr.bottom - innerHeight) : null,
bannerTop: br ? Math.round(br.top) : null,
bannerOverlapsFooter: br && fr ? br.top < fr.bottom : false,
docScrollW: document.documentElement.scrollWidth,
clientW: document.documentElement.clientWidth,
};
};
const browser = await chromium.launch();
const rows = [];
for (const theme of ['dark', 'light']) {
for (const consent of ['dismissed', 'pending']) {
const ctx = await browser.newContext({
viewport: { width: 375, height: 667 },
deviceScaleFactor: 2,
isMobile: true,
hasTouch: true,
colorScheme: theme,
...(consent === 'dismissed'
? {
storageState: {
cookies: [],
origins: [
{
origin: BASE,
localStorage: [
{
name: 'novalon-cookie-preferences',
value: JSON.stringify({
necessary: true,
analytics: false,
marketing: false,
functionality: true,
timestamp: 1784284000000,
}),
},
],
},
],
},
}
: {}),
});
const page = await ctx.newPage();
for (const [name, path] of PAGES) {
await page.goto(BASE + path, { waitUntil: 'load', timeout: 45000 });
if (consent === 'pending') await page.waitForTimeout(2600);
else await page.waitForTimeout(900);
const tag = `${theme}-${consent}-${name}`;
await page.screenshot({ path: `${OUT}/se-${tag}.png` });
// 逐帧滚到底:首页有入场分级动画,内容高度在滚动后才增长,
// 单次 scrollTo(scrollHeight) 会停在半页,测出的 footer 位置无效。
await page.evaluate(async () => {
let last = -1;
for (let i = 0; i < 12; i += 1) {
window.scrollTo(0, document.body.scrollHeight);
await new Promise((r) => setTimeout(r, 220));
if (Math.round(window.scrollY) === last) break;
last = Math.round(window.scrollY);
}
});
await page.waitForTimeout(700);
await page.screenshot({ path: `${OUT}/se-${tag}-footer.png` });
const m = await page.evaluate(MEASURE);
rows.push({ tag, ...m });
console.log(tag.padEnd(28), JSON.stringify(m));
}
await ctx.close();
}
}
await browser.close();
writeFileSync(
`${OUT}/measurements.json`,
JSON.stringify(
{
generatedAt: new Date().toISOString(),
target: BASE,
device: 'iPhone SE 口径仿真:viewport 375x667 / deviceScaleFactor 2 / isMobile+hasTouch / colorScheme 由 CDP 强制',
caveat: 'Playwright 仿真,非真机;用于复现 R-1/R-3 的布局与叠层判定,不能替代实机验收',
thresholds: { noHorizontalOverflow: 'docScrollW <= clientW', consentClearsFooter: 'bannerOverlapsFooter === false' },
rows,
},
null,
2,
),
);
console.log(`WROTE ${OUT}/measurements.json (${rows.length} rows)`);
@@ -0,0 +1,188 @@
{
"generatedAt": "2026-09-21T21:07:27.236Z",
"target": "http://localhost:3000",
"device": "iPhone SE 口径仿真:viewport 375x667 / deviceScaleFactor 2 / isMobile+hasTouch / colorScheme 由 CDP 强制",
"caveat": "Playwright 仿真,非真机;用于复现 R-1/R-3 的布局与叠层判定,不能替代实机验收",
"thresholds": {
"noHorizontalOverflow": "docScrollW <= clientW",
"consentClearsFooter": "bannerOverlapsFooter === false"
},
"rows": [
{
"tag": "dark-dismissed-home",
"theme": "dark",
"footerBottomGap": 64,
"lastText": "蜀ICP备2026013658号",
"footerBottomVsViewport": -128,
"bannerTop": null,
"bannerOverlapsFooter": false,
"docScrollW": 375,
"clientW": 375
},
{
"tag": "dark-dismissed-news-detail",
"theme": "dark",
"footerBottomGap": 64,
"lastText": "蜀ICP备2026013658号",
"footerBottomVsViewport": -128,
"bannerTop": null,
"bannerOverlapsFooter": false,
"docScrollW": 375,
"clientW": 375
},
{
"tag": "dark-dismissed-news-list",
"theme": "dark",
"footerBottomGap": 64,
"lastText": "蜀ICP备2026013658号",
"footerBottomVsViewport": -128,
"bannerTop": null,
"bannerOverlapsFooter": false,
"docScrollW": 375,
"clientW": 375
},
{
"tag": "dark-dismissed-contact",
"theme": "dark",
"footerBottomGap": 64,
"lastText": "蜀ICP备2026013658号",
"footerBottomVsViewport": -128,
"bannerTop": null,
"bannerOverlapsFooter": false,
"docScrollW": 375,
"clientW": 375
},
{
"tag": "dark-pending-home",
"theme": "dark",
"footerBottomGap": 64,
"lastText": "蜀ICP备2026013658号",
"footerBottomVsViewport": -128,
"bannerTop": 461,
"bannerOverlapsFooter": true,
"docScrollW": 375,
"clientW": 375
},
{
"tag": "dark-pending-news-detail",
"theme": "dark",
"footerBottomGap": 64,
"lastText": "蜀ICP备2026013658号",
"footerBottomVsViewport": -128,
"bannerTop": 461,
"bannerOverlapsFooter": true,
"docScrollW": 375,
"clientW": 375
},
{
"tag": "dark-pending-news-list",
"theme": "dark",
"footerBottomGap": 64,
"lastText": "蜀ICP备2026013658号",
"footerBottomVsViewport": -128,
"bannerTop": 461,
"bannerOverlapsFooter": true,
"docScrollW": 375,
"clientW": 375
},
{
"tag": "dark-pending-contact",
"theme": "dark",
"footerBottomGap": 64,
"lastText": "蜀ICP备2026013658号",
"footerBottomVsViewport": -128,
"bannerTop": 461,
"bannerOverlapsFooter": true,
"docScrollW": 375,
"clientW": 375
},
{
"tag": "light-dismissed-home",
"theme": "light",
"footerBottomGap": 64,
"lastText": "蜀ICP备2026013658号",
"footerBottomVsViewport": -128,
"bannerTop": null,
"bannerOverlapsFooter": false,
"docScrollW": 375,
"clientW": 375
},
{
"tag": "light-dismissed-news-detail",
"theme": "light",
"footerBottomGap": 64,
"lastText": "蜀ICP备2026013658号",
"footerBottomVsViewport": -128,
"bannerTop": null,
"bannerOverlapsFooter": false,
"docScrollW": 375,
"clientW": 375
},
{
"tag": "light-dismissed-news-list",
"theme": "light",
"footerBottomGap": 64,
"lastText": "蜀ICP备2026013658号",
"footerBottomVsViewport": -128,
"bannerTop": null,
"bannerOverlapsFooter": false,
"docScrollW": 375,
"clientW": 375
},
{
"tag": "light-dismissed-contact",
"theme": "light",
"footerBottomGap": 64,
"lastText": "蜀ICP备2026013658号",
"footerBottomVsViewport": -128,
"bannerTop": null,
"bannerOverlapsFooter": false,
"docScrollW": 375,
"clientW": 375
},
{
"tag": "light-pending-home",
"theme": "light",
"footerBottomGap": 64,
"lastText": "蜀ICP备2026013658号",
"footerBottomVsViewport": -128,
"bannerTop": 461,
"bannerOverlapsFooter": true,
"docScrollW": 375,
"clientW": 375
},
{
"tag": "light-pending-news-detail",
"theme": "light",
"footerBottomGap": 64,
"lastText": "蜀ICP备2026013658号",
"footerBottomVsViewport": -128,
"bannerTop": 461,
"bannerOverlapsFooter": true,
"docScrollW": 375,
"clientW": 375
},
{
"tag": "light-pending-news-list",
"theme": "light",
"footerBottomGap": 64,
"lastText": "蜀ICP备2026013658号",
"footerBottomVsViewport": -128,
"bannerTop": 461,
"bannerOverlapsFooter": true,
"docScrollW": 375,
"clientW": 375
},
{
"tag": "light-pending-contact",
"theme": "light",
"footerBottomGap": 64,
"lastText": "蜀ICP备2026013658号",
"footerBottomVsViewport": -128,
"bannerTop": 461,
"bannerOverlapsFooter": true,
"docScrollW": 375,
"clientW": 375
}
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 198 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 199 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

@@ -0,0 +1,232 @@
{
"generatedAt": "2026-09-22T04:52:07.803Z",
"target": "http://localhost:3000",
"device": "iPhone SE 口径仿真:viewport 375x667 / deviceScaleFactor 2 / isMobile+hasTouch / colorScheme 由 CDP 强制",
"caveat": "Playwright 仿真,非真机;用于复现 R-1/R-3 的布局与叠层判定,不能替代实机验收",
"thresholds": {
"noHorizontalOverflow": "docScrollW <= clientW",
"consentClearsFooter": "bannerOverlapsFooter === false"
},
"rows": [
{
"tag": "dark-dismissed-home",
"theme": "dark",
"footerBottomGap": 64,
"lastText": "蜀ICP备2026013658号",
"footerBottomVsViewport": -128,
"bannerTop": null,
"bannerOverlapsFooter": false,
"docScrollW": 375,
"clientW": 375
},
{
"tag": "dark-dismissed-news-detail",
"theme": "dark",
"footerBottomGap": 64,
"lastText": "蜀ICP备2026013658号",
"footerBottomVsViewport": -128,
"bannerTop": null,
"bannerOverlapsFooter": false,
"docScrollW": 375,
"clientW": 375
},
{
"tag": "dark-dismissed-news-list",
"theme": "dark",
"footerBottomGap": 64,
"lastText": "蜀ICP备2026013658号",
"footerBottomVsViewport": -128,
"bannerTop": null,
"bannerOverlapsFooter": false,
"docScrollW": 375,
"clientW": 375
},
{
"tag": "dark-dismissed-contact",
"theme": "dark",
"footerBottomGap": 64,
"lastText": "蜀ICP备2026013658号",
"footerBottomVsViewport": -128,
"bannerTop": null,
"bannerOverlapsFooter": false,
"docScrollW": 375,
"clientW": 375
},
{
"tag": "dark-dismissed-not-found",
"theme": "dark",
"footerBottomGap": null,
"lastText": "",
"footerBottomVsViewport": null,
"bannerTop": null,
"bannerOverlapsFooter": false,
"docScrollW": 375,
"clientW": 375
},
{
"tag": "dark-pending-home",
"theme": "dark",
"footerBottomGap": 64,
"lastText": "蜀ICP备2026013658号",
"footerBottomVsViewport": -128,
"bannerTop": 461,
"bannerOverlapsFooter": true,
"docScrollW": 375,
"clientW": 375
},
{
"tag": "dark-pending-news-detail",
"theme": "dark",
"footerBottomGap": 64,
"lastText": "蜀ICP备2026013658号",
"footerBottomVsViewport": -128,
"bannerTop": 461,
"bannerOverlapsFooter": true,
"docScrollW": 375,
"clientW": 375
},
{
"tag": "dark-pending-news-list",
"theme": "dark",
"footerBottomGap": 64,
"lastText": "蜀ICP备2026013658号",
"footerBottomVsViewport": -128,
"bannerTop": 461,
"bannerOverlapsFooter": true,
"docScrollW": 375,
"clientW": 375
},
{
"tag": "dark-pending-contact",
"theme": "dark",
"footerBottomGap": 64,
"lastText": "蜀ICP备2026013658号",
"footerBottomVsViewport": -128,
"bannerTop": 461,
"bannerOverlapsFooter": true,
"docScrollW": 375,
"clientW": 375
},
{
"tag": "dark-pending-not-found",
"theme": "dark",
"footerBottomGap": null,
"lastText": "",
"footerBottomVsViewport": null,
"bannerTop": 461,
"bannerOverlapsFooter": false,
"docScrollW": 375,
"clientW": 375
},
{
"tag": "light-dismissed-home",
"theme": "light",
"footerBottomGap": 64,
"lastText": "蜀ICP备2026013658号",
"footerBottomVsViewport": -128,
"bannerTop": null,
"bannerOverlapsFooter": false,
"docScrollW": 375,
"clientW": 375
},
{
"tag": "light-dismissed-news-detail",
"theme": "light",
"footerBottomGap": 64,
"lastText": "蜀ICP备2026013658号",
"footerBottomVsViewport": -128,
"bannerTop": null,
"bannerOverlapsFooter": false,
"docScrollW": 375,
"clientW": 375
},
{
"tag": "light-dismissed-news-list",
"theme": "light",
"footerBottomGap": 64,
"lastText": "蜀ICP备2026013658号",
"footerBottomVsViewport": -128,
"bannerTop": null,
"bannerOverlapsFooter": false,
"docScrollW": 375,
"clientW": 375
},
{
"tag": "light-dismissed-contact",
"theme": "light",
"footerBottomGap": 64,
"lastText": "蜀ICP备2026013658号",
"footerBottomVsViewport": -128,
"bannerTop": null,
"bannerOverlapsFooter": false,
"docScrollW": 375,
"clientW": 375
},
{
"tag": "light-dismissed-not-found",
"theme": "light",
"footerBottomGap": null,
"lastText": "",
"footerBottomVsViewport": null,
"bannerTop": null,
"bannerOverlapsFooter": false,
"docScrollW": 375,
"clientW": 375
},
{
"tag": "light-pending-home",
"theme": "light",
"footerBottomGap": 64,
"lastText": "蜀ICP备2026013658号",
"footerBottomVsViewport": -128,
"bannerTop": 461,
"bannerOverlapsFooter": true,
"docScrollW": 375,
"clientW": 375
},
{
"tag": "light-pending-news-detail",
"theme": "light",
"footerBottomGap": 64,
"lastText": "蜀ICP备2026013658号",
"footerBottomVsViewport": -128,
"bannerTop": 461,
"bannerOverlapsFooter": true,
"docScrollW": 375,
"clientW": 375
},
{
"tag": "light-pending-news-list",
"theme": "light",
"footerBottomGap": 64,
"lastText": "蜀ICP备2026013658号",
"footerBottomVsViewport": -128,
"bannerTop": 461,
"bannerOverlapsFooter": true,
"docScrollW": 375,
"clientW": 375
},
{
"tag": "light-pending-contact",
"theme": "light",
"footerBottomGap": 64,
"lastText": "蜀ICP备2026013658号",
"footerBottomVsViewport": -128,
"bannerTop": 461,
"bannerOverlapsFooter": true,
"docScrollW": 375,
"clientW": 375
},
{
"tag": "light-pending-not-found",
"theme": "light",
"footerBottomGap": null,
"lastText": "",
"footerBottomVsViewport": null,
"bannerTop": 461,
"bannerOverlapsFooter": false,
"docScrollW": 375,
"clientW": 375
}
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 195 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,333 @@
# 系统性 Review / 全面测试 / 修复 / 验收 — 第二周期
- 分支:`refactor/optimize-ui`,HEAD `f543e47`(**本周期结束时工作树仍未提交,见 §6**)
- 上一轮基线:`docs/acceptance/2026-09-23-gates/final-verdict.md`(本文件只记**新增**内容,不重复其已闭环条目)
- 方法:subagent 并行 + 主线逐条源码复核(trust-but-verify)。子代理报告中的每一条断言均由主线用独立探针重测后才采信。
## 0. 结论(未完成项显式保留)
### 0.1 验收判定:**未达"可判验收通过"**,阻断点唯一且明确
- **技术上已闭环**:本周期新增审查轴上的 11 项 CONFIRMED 缺陷全部按 RED→GREEN 修复,可在**最终树**上复算的门禁全绿(类型 / 单元 1716 例 / lint 0 error / 真库集成 / 只读 E2E / 安全头 / a11y+perf / axe 四组 / Lighthouse / PR 模板结构),且**无一项是靠放宽断言、加 retry、下调阈值或写基线拿到的**。
- **验收不能判完成的理由不是代码质量,而是交付形态**:344 条改动(含两轮全部修复)仍**只存在于工作树**,HEAD 未动 ⇒ 没有任何评审者、CI 或下一位开发者能复现本报告结论(§6);同时 10 项决策悬置(§8),其中 D-3/D-4/D-9/D-10 是**本轮被授权边界挡住的可测项**,不是"已测通过"。
- 因此本文件的主张仅限于:**"已测的都真测了,未测的都点名了"**;"完成验收"待 D-1 入库 + 至少 D-3/D-4 两项授权后重判。
本周期新增一条**前几轮从未审查过的代码轴**:表现层 / 领域层(`src/components/**`、`src/hooks/**`、`src/app/admin/content/**`)。该轴产出 **11 项 CONFIRMED 缺陷**,其中 4 项具备**数据损毁或整页崩溃**后果,已全部按 RED→GREEN 修复。
| 门禁 | 命令 | 结果 |
|---|---|---|
| 类型检查 | `npm run type-check` | **EXIT=0**(最终树复跑,两轮 0 error) |
| 单元 + 覆盖率 | `npm run test:coverage` | **EXIT=0**,**137 套件 / 1725 例全通过**(同一周期内三次复跑:132/1677 → 136/1716 → 137/1725,末次在 L-1/L-2 修复之后);global **85.85 / 87.35 / 81.1 / 85.85**,阈值 82 / 75 / 75 / 75 **未下调** |
| Lint | `npm run lint` | **EXIT=0**,0 error / **104** warning(`no-console` 仍为 55 ⇒ 本周期**零新增债**;105→104 的唯一归因见 §1.5,**且本节更正了本报告早前一版的误归**) |
| PR 模板结构门禁 | `bash scripts/check-pr-checklist.sh` | **EXIT=0**:`✅ PR 模板结构门禁通过:30 项子项,三节完整`(修 §3.2 的假事实后复测,结构判定未受影响) |
| 构建 + 产物装配 | `npm run build` | **EXIT=0**,`dist/standalone/{server.js,dist/static,public}` 三项齐备(N-9 前提成立) |
| 产物含 Sentry 运行时 | `find dist/standalone -name '*instrumentation*'` | `dist/standalone/dist/server/instrumentation.js` 存在 |
| 真库集成层 | `npm run test:integration:real` | **4 套件 / 23 例全通过**;teardown 守卫实测 `prisma/dev.db`、`dev.db`、`data.db` 的 inode+size+mtime 与运行前完全一致 ⇒ A-1 改动的 `items` GET 查询契约在真库上无回归,且未污染开发库 |
| 真库未被写入 | `sqlite3 -readonly prisma/dev.db` | `ContentItem=38`、`draft=0`,与上轮收尾计数一致 |
| 视觉回归只读比对 | `npm run test:visual:all -- --update-snapshots=none` | **104 通过 / 21 失败 / EXIT=1**,20 条失败实测为 `A snapshot doesn't exist` ⇒ **0 例像素不符**,且跑前跑后基线仍为 85 M / 0 新建(详见 §3.4) |
| E2E(生产目标,写库用例已排除) | 7 个只读 spec × 4 project(`E2E_TARGET=production`) | **115 通过 / 1 失败 / EXIT=1**;唯一失败 = `mobile-user-journeys.spec.ts:360` UJ-10 **firefox**,**隔离复跑 `--retries=0` 1 passed (10.4s)** ⇒ 判为上轮 §8.3 的并发/墙钟族,非本周期回归;未加 retry、未放宽阈值。全程 `prisma/dev.db` 复核 `draft=0 / items=38` 未变 ⇒ 只读边界成立 |
| E2E(生产目标,安全头) | `--grep '@security'` × 4 project | **EXIT=0** |
| E2E(生产目标,a11y+perf 层) | `--grep '@accessibility|@performance'` × 4 project | **EXIT=0**(上轮同层为 124 通过 / **4 webkit 失败**;本轮 4 例未复现,仍归并发族,不当作已根治) |
| `check:a11y` | contrast + headings + brand-token | **EXIT=0** |
| 全站 axe 节点计数 | `check:axe:routes` + `check:axe`(standalone 产物,专用 :3100) | **EXIT=0 / PASSED=true**,34 页,`bgMismatch=0`、`contrastNodes=0`、`violationNodes=0` |
| Lighthouse | `npm run lighthouse` | **EXIT=0**,9 URL × 3 = 27 次运行断言全通过 |
| 新增动效门禁 | `check:motion` / `check:motion:test` | 见 §3.5:自证伪通过,但**真实树实测判红 23 处**⇒ 故意**未并入** `check:a11y`,登记为独立脚本待决策 |
## 1. 新增审查轴的发现与修复(表现层 / 领域层)
判据:CONFIRMED = 读过代码**与**调用点、并能说出具体触发条件。
### 1.1 后台内容编辑器(P1,含数据损毁面)
| ID | 位置 | 缺陷 | 触发条件 | 状态 |
|---|---|---|---|---|
| A-1 | `src/app/admin/content/[modelCode]/[itemId]/page.tsx:306-308` | 以 `getItems({page:1,pageSize:100})` + `find(id)` 定位条目,**条目级无 not-found 分支**(模型级在 :295-296 有) | 列表页 `pageSize=10`,第 101+ 条打不开 ⇒ 表单空白;填入标题后保存即把 `{title, slug, data:{}}` PUT 到真实记录 | **已修** |
| A-2 | 同文件 `:251-260`、`:318-320` | `isInitialMountRef` 被自动保存效果的**挂载期首次执行**消费掉,`:319` 的置位是死代码 | 仅"打开"一个既有条目即被标为未保存,约 3s 后发出多余 PUT ⇒ `version` 上涨并覆盖并发编辑者 | **已修** |
| A-3 | 同文件 `:239-247` | 自动保存链无 in-flight / 优先级约束,旧 formData 快照可后于新快照落库 | 慢网络下丢编辑 | **已修** |
| A-4 | `src/app/admin/content/[modelCode]/page.tsx:59-85` | `search` 进 `fetchItems` deps,无防抖、无序号守卫 | 迟到响应覆盖新查询结果;每字符一发请求 | **已修** |
A-1 的修法经过取证:`api/admin/items/**` **不存在** by-id 端点(只有 `[id]/workflow`),`adminApi` 亦无 `getItem` ⇒ 给既有 GET 增加 `id` 过滤(`route.ts:86,92`),未新建路由、未发明 API。
主线对 A-1 报告的一处**口径更正**:子代理称"空白表单保存会清库",实测 `validate()` 对每条保存路径都要求非空标题 ⇒ 未填标题时不可达;**填入标题后清库成立**。修复仍针对根因(定位窗口 + 早退分支)。
### 1.2 案例详情页与动效层
| ID | 位置 | 缺陷 | 触发条件 | 状态 |
|---|---|---|---|---|
| C-1 | `src/components/sections/case-detail-page.tsx:141,344,565` + `src/app/(marketing)/cases/[slug]/page.tsx:31` | `metrics/timeline/services` 直接 `.slice/.map`、`challenge.substring`,而 `validate-content-data.ts:27-30,60` 对缺失键**刻意跳过**不补默认 | 发布一条缺 `timeline` 的案例 ⇒ TypeError ⇒ **整页错误**;缺 `challenge` ⇒ `generateMetadata` 崩 | **已修**(在 payload 入口按既有 `?? []` 惯例归一,语义与 `standalone/[id]/client.tsx:41-46` 一致) |
| P-1 | `src/hooks/use-swipe-gesture.tsx:106` + `:192` | 触摸监听挂在**无 className 的空 div**(0×0)上,触摸无从冒泡 | 边缘滑动**永不触发**,而"尝试左右滑动切换页面"提示照常渲染 | **已修**(挂 `document`,边缘阈值逻辑保留并被第三例测试证明中段触摸不误触);附带查明 `erp-upgrade-v3` 调用点自身零入向引用(死码),`standalone/[id]` 为活路径(`src/app/layout.tsx:125` 生成导航) |
| P-4 | `src/app/layout.tsx:207` + `cases/[slug]/client.tsx:53` + `case-detail-page.tsx:618` | `/cases/[slug]` 上 `ScrollProgress` 挂载 **3 次**,各带独立 scroll/resize 监听与逐帧 setState | 最重页面上 3 个叠层进度条 | **已修**(保留 layout 一处,删页面级两处;grep 复核全仓仅剩 `layout.tsx:207`) |
| P-5 | `src/components/ui/scroll-progress.tsx` | `scaleX(shouldReduceMotion ? 0 : …)` 把降级动效做成了**宽度 0** | `prefers-reduced-motion` 用户全站失去阅读进度指示 | **已修**(实测现为 `scaleX(progress/100)` + `transition: none`) |
| D-1 | `src/components/detail/detail-trust-section.tsx:32` | `value.replace(/[0-9.]/g,'')` 把**前导符号搬到末尾** | 种子数据确有 `<2h`、`+113%`、`<2小时`、`<1天`、`<6个月`(`prisma/seeds/products.ts:76-255`)⇒ 渲染成 `113+%` | **已修**(**存在真实触发数据**,非假想) |
### 1.3 Sentry 此前为「声明了但从未接线」
`@sentry/nextjs@10.67.0` 在依赖里、三个 `sentry.*.config.ts` 在盘上,但**无 `instrumentation.ts`、`next.config.mjs` 未包 `withSentryConfig`、`src/**` 零 import** ⇒ 生产错误上报**不生效**(上轮 §4.1 记为"明确未做")。本周期接线,关键点是**这版 Next(16.3.0) 默认 Turbopack,`sentry.client.config.ts` 根本不会被加载**(SDK 自身告警 `@sentry/nextjs/build/esm/config/webpack.js:207-213`),故客户端初始化移入 `instrumentation-client.ts` 约定文件,并把旧配置置为空 stub 以防 `--webpack` 路径二次 init。
无 DSN 时的惰性有码路证据:`if (SENTRY_DSN)` 跳过 init、`captureRouterTransitionStart` 无 handler 时空转、`captureRequestError` 在无 client 时丢弃;CSP 在无 DSN 下逐字节不变(追加域与 `worker-src` 仅在 DSN 合法时非空,`next.config.mjs:17-26`)。
### 1.4 A-1 同类残留已闭合(第二波修复,实测确认为真缺陷)
复核确认 `load()` 的单个 `try` 下有**三条**失败分支(`getModels` 拒绝、`models.find` 未命中即 `throw new Error('模型未找到')`、`getItems` 拒绝)都只 `setError` 而 `itemNotFound` 仍为 `false` ⇒ 主 JSX 照样渲染 header 与底部**两组**「保存 / 发布 / 保存并返回」,一次点击即对**从未加载成功的记录**发 PUT/POST。自动保存侧已由 §1.1 的空快照守卫解除,但手工路径当时未覆盖。
修法为**单点结构门**而非五处散判:新增 `canEdit`,仅在 `load()` 成功填充后(新建=模型就绪 / 编辑=条目+快照就绪)的唯一位置置真;渲染侧 `canEdit &&` 恰好包住那两组动作簇;发射侧 `performSave`(手工保存、自动保存、发布前保存的**唯一收口**)在 `!canEdit` 时直接返回 `{ok:false}`,发布与工作流随之不可达;`load()` 顶部 `setCanEdit(false)` 覆盖 params 变更。错误展示(含「模型未找到」)保持不变。
RED 证据(4 例新用例实测失败文本):`TestingLibraryElementError: Found multiple elements with the role "button" and name "保存"`;`expect(received).toBeNull() / Received: <button …>发布</button>`。
GREEN:`npm run test:unit -- admin` **EXIT=0**(14 套件 / **155** 例,含 4 例新增)。
同形状兄弟页**只记录未动刀**:`/admin/roles` 安全(加载失败 ⇒ `isEditable` 假 ⇒ 保存隐藏);`/admin/users` 与 `/admin/content/[modelCode]` 在加载失败时仍渲染「新建」,但那是**创建**而非覆盖,属良性变体;`/admin/media` 失败时显示"暂无媒体文件"而上传仍可用,是误导性空态、无覆盖形状;`/admin/zones`、`/admin/notifications` 干净。
### 1.5 lint 口径的精确归因(105 → 104,本周期零新增债)
以 `npx eslint . -f json` 为唯一权威口径(458 文件):**0 error / 104 warning**,分项
`no-console 55`、`no-explicit-any 25`、`react-hooks/set-state-in-effect 11`、`no-img-element 10`、`no-html-link-for-pages 1`、`no-sync-scripts 1`、死抑制指令 **0**(原 1)。
对上轮记录的 105 条口径,差值**全部可归因且均为改善**,无一项是本周期引入:
| 分项 | 上轮 | 本轮(最终树 `eslint -f json` 实测) | 归因 |
|---|---|---|---|
| `react-hooks/set-state-in-effect` | 12 | **11** | §1.1 A-2 删掉了"挂载期消费守卫"的那个效果 ⇒ **105→104 的唯一来源** |
| `no-console` | 55 | **55** | §1.3 的 `console.log` 经 `git show HEAD:sentry.client.config.ts:72` 证明是**原样搬迁**,非新增 |
| `Unused eslint-disable directive` | 1 | **1**(`no-await-in-loop`,仍在) | 未变 |
| `no-explicit-any` / `no-img-element` / `no-sync-scripts` / `no-html-link-for-pages` | 25 / 10 / 1 / 1 | **25 / 10 / 1 / 1** | 未变 |
> ⚠ **本报告早前一版把差值记成"两处各降 1"(含把死抑制误记为 1→0),已在最终树复测后更正为"仅 `set-state-in-effect` 12→11 一处"**。记此更正本身,是因为它正是本仓反复记录的判读纪律:**分项相加必须等于总数、且计数须产自最终树**——中途树(各代理仍在改文件)测得的 104 与最终树的 104 数值相同但成分不同,不可混用。
⇒ 文档中"实测 105 条"的口径已过期,须以本表为准。
### 1.6 第三条新审查轴:`src/lib/**` + Prisma 数据层(无 P0,2 项 P2)
| ID | 级别 | 位置 | 缺陷 | 用户可见后果 |
|---|---|---|---|---|
| **L-1** | P2 | `src/lib/admin-api.ts:81`(加密分支 `return JSON.parse(...) as T`,**无 `res.ok` 判定**;`:94` 的守卫只管未加密分支)对 `src/lib/api-crypto.ts:76,101-127` | 所有后台写路由都被 `withCrypto` 包裹 ⇒ **400/403/404/500 的响应体同样是加密的** `{data:<enc>}`;客户端解密成功便按 `T` 返回。另注:`:86` 的兜底在 `:76` 已 `res.text()` 消费过 body 之后再 `res.json()`,二次读取必然失败 ⇒ 连"降级通用错误"也拿不到 | 主线逐点复核后的**精确后果**(不用子代理的"崩溃或空"含混说法):① 被守卫拒绝的后台写操作**静默显示"已保存"**;② `src/app/admin/content/[modelCode]/page.tsx:83-84` 写的是 `data?.items \|\| []`,故 403 时 `data` 是 `{error}` 对象(真值)、`.items` 为 `undefined` ⇒ 渲染成**"0 条内容"且无任何报错**,不是崩溃。⚠ 与 §1.1 叠加:B-1/N-4/N-5 把发布/状态写收口到 workflow,而那些路径上的 403 今天正是被 L-1 吞掉的 ⇒ 修完 L-1 才真正拿到权限守卫的可观测性 |
| **L-2** | P2 | `prisma/seed.ts:223-262` 对 `src/lib/permissions.ts:26`(精确相等)+ `items/route.ts:208,285`、`stats:11`、`models:9`、`zones:14,38,108` | 种子 `permissionMatrix` 的 modelCode **过期**:授予不存在的 `team`/`legal`(真实为 `team-page`/`legal-page`),且**漏授** `team-page`/`contact-page`/`legal-page`/`content-model`/`content-zone` | 非 `super_admin`(含种子里的 `e2e_editor`/`e2e_reviewer`)对法务/团队/联系内容**以及后台外壳**(模型、专区、仪表盘)一律 fail-closed 拒绝;`notifications.ts:135` 为这些模型找不到审核人 ⇒ 条目可**永久停在 review** |
| L-3 | P3 | `src/lib/client-ip.ts:14-15` | 头部信任无条件:生产在 nginx 后由边缘覆写故安全,但 `node dist/standalone/server.js` 直暴露时任何客户端可自报 `X-Real-IP` | 本地/CI 下 `api/contact/route.ts:51` 的限流可被完全绕过(N-25 的修复只覆盖 nginx 形态) |
| L-4 | P3 | `src/lib/crypto.ts:36` ↔ `crypto-server.ts:20` | 客户端密钥由**会打进公开 bundle** 的 `NEXT_PUBLIC_ENCRYPTION_SECRET` 派生,且须等于服务端 `ENCRYPTION_SECRET` | 传输"加密"只具混淆性,无机密性/完整性——不得当作安全控制(`Bearer` 仍承担鉴权,故非漏洞,但口径要写清) |
| L-5 | P3 | `src/lib/media/image-processor.ts:75` + `src/lib/media/media-service.ts:8-12,27` | 派生命名原先只有 `image-${Date.now()}`,而原图走带随机后缀的 `generateUniqueFileName` | 同一毫秒内两次上传算出**同名路径** ⇒ `saveDerivative` 互相覆盖 webp/avif/缩略图。**已修**(RED 实测:两次调用同为 `public/uploads/image-1700000000000-thumbnail.webp`;GREEN:`src/lib/media` 4 套件 / 59 例全通过) |
同轴复核为**已真正闭环**的项(附证明行):N-20 停用账号(`permissions.ts:41-45` 读 `User.status`,且确在 `:52` 的 `super_admin` 短路**之前**)、13 处 `requirePermission` 调用点全部走 `if ('response' in permission) return`、N-18 两条 draft 路由共用 `isInternalRedirect`、`layout.tsx:180` 的 `dangerouslySetInnerHTML` 是静态主题脚本而非 CMS 数据、草稿隔离(`data-server.ts:39,50` 过滤 `published`,无状态过滤的 `getItemById:59` 在 `src/app/**` **无调用者**)、schema↔migration 一致(`@@unique([modelCode,slug,locale])` 对齐)、`.env*` 与全部 `.db` 均未跟踪 ⇒ 无提交密钥。
### 1.7 L-1 / L-2 的修复落地与反漂移守卫
**L-1 已修**(`src/lib/admin-api.ts:73-97`):加密分支改为**先解密取 payload,再按 `res.ok` 拒绝**,错误消息优先取服务端的 `error` → `message` → 回落 `请求失败 (${status})`;成功路径返回形状与全部 `adminApi.*` 签名**未变**。RED 实录:`expect(adminApi.createItem(...)).rejects.toThrow('字段校验失败')` ⇒ `Received promise resolved instead of rejected / Resolved to value: {"message":"字段校验失败"}`(3 失败 / 26 通过)。新增 4 例(加密 403、加密 400 带服务端消息、状态码回落、成功仍 resolve)。
顺带修掉主线另行指出的一处缺陷:原先 `catch` 里在 `res.text()` 已消费 body 之后再 `res.json()`,二次读取必然失败 ⇒ 降级路径拿不到任何明文 error;现改为直接按状态码报错并注明原因。
**调用点全量审计**:`src/app/admin/**` 下 media / roles / zones / dashboard(`page.tsx:108`) / notifications / users / 内容列表 / 编辑页**每一个都已自带错误路径**(`try/catch` + `setError`/`toast.error` 或 `.catch`)⇒ 让 L-1 抛错不会引入未处理拒绝,故本轮**未改任何后台页面**。
**L-2 已修(源码侧,故意保持潜伏)**:`prisma/seed.ts` 的 `contentModels` 去掉幽灵码 `team`/`legal`、补入 `team-page`/`legal-page`/`contact-page`;`content-model`/`content-zone` 以新增 `shellReadModels` 条目**只授 read** 给非超管(仪表盘 `getStats` 与编辑器加载 `getModels`/`getZones` 需要读,而 zone 的 update/delete **刻意留给 super_admin**,注释引用判据行 `permissions.ts:24`、`models/route.ts:9`、`stats/route.ts:11`、`zones/route.ts:14,38,108`),`site-config`/`navigation` 维持超管专属。
**未跑 `db:seed`/`db push`** ⇒ 现网 `prisma/dev.db` 内的旧权限行仍是漂移状态,修复**尚未生效于数据**(属 D-1/seed 授权范围,见 §8)。
**反漂移守卫**:新增 `src/lib/seed-permissions.test.ts`(4 例),解析种子数组与 `CONTENT_TYPE_CONFIGS` + 种子自身 `modelConfigs` 比对,以下任一情形即判红:幽灵码重现、真实模型失去授权、**提取匹配为空**(数组被改名/删除时抛错而非静默通过)、shell 码泄进全权限数组。
**最终树复跑**(在此二改之后):`npm run type-check` **EXIT=0**、`npm run test:coverage` **EXIT=0 / 137 套件 / 1725 例**、global **85.85 / 87.35 / 81.1 / 85.85**(阈值未下调)、`npm run lint` **EXIT=0 / 0 error / 104 warning**。
## 2. 主线独立复核(不采信子代理自述的部分)
| 复核项 | 探针 | 结果 |
|---|---|---|
| 文档命令可执行性 | Node 脚本抽出 4 份文档里所有 `npm run X` 与 `package.json` 62 条脚本比对 | **documented-but-missing = 0** |
| `formsubmit.co` 资源提示确为死资源 | `grep -rn formsubmit src/` | 唯一命中 `src/app/api/contact/route.ts:90`(Node 侧调用)⇒ 删除 `dns-prefetch`/`preconnect` 安全 |
| 视觉新选择器有真实 DOM 支撑 | `grep data-testid src/components/sections/bento-grid.tsx` | `bento-grid.tsx:48` 确有 `data-testid={testId}`,与 `[data-testid^="bento-product-card-"]` 对应 |
| 视觉断言未被放宽 | `grep -n "count()\|isVisible()\|try {\|catch {\|\.skip\|soft(" visual-regression.spec.ts` | 命中 3 行全部是**注释里的实测记录**,无活守卫/软断言/skip |
| lint 计数变化归因 | `git show HEAD:sentry.client.config.ts` | `console.log` 位于 **HEAD:72**,属**原样搬迁**而非新增;分项 `no-console 55 / no-explicit-any 25 / no-img-element 10 / no-sync-scripts 1 / no-html-link-for-pages 1` 与上轮口径一致 |
| 产物装配(N-9 前提) | `test -e` 三项 | 全部 OK |
| 真库完整性 | `sqlite3 -readonly` | 38 / 0,未变 |
| **本周期自建的 API 面是否扩大攻击面**(自查 A-1 新增的 `id` 过滤) | 读 `src/app/api/admin/items/route.ts:79-93` | **未扩大授权边界**:`?id=` 且省略 `modelCode` 时 `where={id}`,判权走 `'content-item'` 幻影码——但**改动前**省略 `modelCode` 就已经返回跨全部模型的分页行,我的改动只是把"翻页枚举"变成"按 id 精确取",鉴权关口同一;且编辑器调用**始终带 `modelCode`**(`{modelCode, id, page:1, pageSize:1}`)。⇒ 结论:本周期变更中立,但**N-21 因此从 P2 提到应优先处理**(它同时是这条读取路径的唯一闸口) |
## 3. 测试装配修复(两波)
### 3.1 N-27 E2E 零断言(第二波收尾)
子代理改用 **TypeScript AST 扫描**(提取 test 块 + 沿 if/try/catch 祖先判定 `expect` 可达性),并**先用合成探针自证扫描器**再采信其计数——这是对上轮"grep 计数"口径的方法升级。排除 `visual-regression.spec.ts`。
- 计数结论:上一位代理的收尾已把零断言块降到 **0**;本轮再修掉两处**扫描器看不见**的真实缺陷。
- `e2e/uj-08-service-journey.spec.ts:131-141`:`isVisible().catch(() => false)` 吞掉失败 ⇒ 改为执行路径上的硬断言 `await expect(caseLink).toBeVisible()`(uj-08:137)。
- `e2e/cms-workflow.spec.ts:345,351`:test 3「多角色权限分离」补齐块级 `afterApprove.status === 'published'` 与落地 URL 断言(判据取自 `api/admin/items/[id]/workflow/route.ts`、`api/cms/revalidate/route.ts`、`lib/api-response.ts:69`、`lib/cms/workflow.ts:62`)。**该 spec 真写库,本周期未执行**,仍需 §6 的写库授权。
- 剩余 9 处 `if (await …)` 经逐个读源码判为**受卫动作**(cookie 横幅关闭、面包屑缺失时的 else-goto 兜底、`p1:589` 循环带无条件下限断言),其后均跟无条件断言 ⇒ 不改。
- 实跑:`uj-08-service-journey.spec.ts`(只读、不点提交)跨 chromium / chromium-mobile / firefox / webkit **4 通过 / 0 失败**;`eslint` 两文件与 `npm run type-check` 均 **EXIT=0**;:3000 起停前后均确认无残留监听。
### 3.2 门禁工件自身含假事实(本轮新发现,已修)
上轮 N-31/N-33 把 README / CLAUDE / docs-testing / quality-gates 的数值口径做了机械交叉核对,但**漏掉了 PR 门禁工件本身** `.gitea/PULL_REQUEST_TEMPLATE.md`。其「质量门禁」节原写:
> `npm run test:coverage` 通过 `jest.config.js` 的 `coverageThreshold`(global:branches **30%** / functions **25%** / lines **32%** / statements **30%**)
而真源 `config/test/jest.config.js:32-38` 实为 **branches 82 / functions 75 / lines 75 / statements 75**(根 `jest.config.js` 只是 `module.exports = require('./config/test/jest.config.js')` 的转发)。⇒ 这份模板会**教后来者把阈值当成 30%**,恰是 AGENTS.md §5 反复警示的"假绿口径",且它出现在**验收关口自身的清单上**,比文档写错更值得记。已按"先指真源、再附数值"的写法改正,并复核 `bash scripts/check-pr-checklist.sh`(结构校验:三节完整 + checklist 子项 ≥ 20)仍 **EXIT=0**,即修正未破坏门禁结构判定。
### 3.3 N-15 视觉回归装配(上一轮遗留 21 例判红)
修法只动**装配**(选择器、目标元素),未动参照、未放宽阈值:
- 卡片例:`[class*="card"]` 在三种视口**匹配数恒为 0** ⇒ 改为 `[data-testid^="bento-product-card-"]`(实测 7 个匹配,首个 `bento-product-card-erp` 可见)。
- 输入框例:`input[type="text"]` 的 `.first()` 命中**反垃圾蜜罐**(`contact-content-v3.tsx:431-439`:`display:none` + `tabindex=-1` + `aria-hidden`,`isVisible()=false`)⇒ 改为 `form input:visible`;蜜罐覆盖**没有删除**,改为 6 条属性断言 + `toBeHidden()` 显式断言其存在。
- 窄视口按钮例:判为**装配缺陷而非产品缺陷**——390px 下 4 个匹配仅 1 个可见,`.first()` 落在 `nav.hidden md:flex` 的桌面导航按钮上;真实目标为 `header.tsx:184-196` 的 `data-testid="mobile-menu-button"`(44×44 @ (338,10))。
错误类别变化:`5 element(s) not found + 6 Received: hidden + 10 缺参照` ⇒ **0 + 0 + 20 缺参照 + 1 参照尺寸不符**。即选择器类缺陷**清零**,剩余全部卡在"写参照需授权"(上轮 §5 第 3 项)。
### 3.4 最终树上的视觉只读比对实测(本周期修复无像素外溢)
§1 的全部代码修复落地后重跑 `npm run test:visual:all -- --update-snapshots=none`:**104 通过 / 21 失败 / EXIT=1**(5.9 分钟),且**失败类别实测为 20 条 `Error: A snapshot doesn't exist`**(另 1 条为上轮已记的参照尺寸不符)⇒ **0 例像素不符**。
三重守卫同时成立:
1. `git status --porcelain -uall -- e2e/visual-snapshots` 跑前跑后均为 **85 M / 0 个 `??`** ⇒ 只读比对确实没写任何参照(缺省调用会让 Playwright **静默创建**缺失基线,这条必须验而不是假设);
2. 失败集合与修复前**逐类别同构**(21 例同因)⇒ C-1/P-1/P-4/P-5/D-1/N-14 六项改动未波及任何**有参照**的页面;
3. 判读边界见 §4:本树被参照覆盖的页面只有 21 个检查点名,"104 通过"仅约束这些点,**不构成**对 §1.2 那几项的验收凭据。
⇒ 结合 §0 表,视觉门禁在本周期的口径是:**已覆盖面无回归(实测)**;**未闭环项是装配之外的授权项**(20 例缺参照需 §6 写参照授权;`/products/erp-upgrade`、`/about/brand`、`/cases/[slug]` 三路由从始至终没有基线)。
### 3.5 新增门禁:动效约束此前无人机械校验(N-29 的落地)
`CONTEXT.md` §动效设计四原则为权威口径(**直读原文**,未采用提示里的转述):`:76` 入场 **180–280ms**、hover **150ms**、反馈 **100ms**;`:77` 默认缓动统一 `ease-ink = cubic-bezier(0.22,1,0.36,1)`;`:82` **禁止超过 700ms 的入场动效**;`:78` stagger 步进。新脚本 `scripts/utils/check-motion-constraints.ts` 落 4 条规则,全部**可达**(无 warn 分支),退出码 0/1/2(2 = 扫描集为空 / 目标缺失 / 解析到 0 个令牌 —— 直接针对本仓 N-22/N-24/A-6 的"恒不成立断言"族)。
自证伪证据(双向对照,非"跑一遍看看绿"):植入违规 fixture ⇒ **EXIT=1**(一次命中 R2/R3/R4 共 6 处);移除 ⇒ **EXIT=0** 并打印分母;CSS 令牌 fixture ⇒ `token-band` + `entrance-cap` 双双命中;空扫描 ⇒ **EXIT=2**;配套 `npm run check:motion:test` **19 例**(逐规则正/负对照 + 一条"触发集 == 声明规则集"的元测试)。
**真实树实测判红:`npm run check:motion` ⇒ EXIT=1,23 处 / 13 文件**(分母:262 源文件 · 46 条 CSS 声明 · 290 个时长字面量 · 66 个贝塞尔字面量):
| 规则 | 处数 | 明细 |
|---|---|---|
| R1 令牌档位 | 0 | `instant/fast/normal` 令牌本身合规 |
| R2 入场 ≤700ms | 1 | `globals.css:268` `--transition-gentle: 1000ms` |
| R3 时长须走令牌 | 2 | `globals.css:1457` 裸 `0.6s`、`design-system.ts:76` 裸 `0.3s` |
| R4 缓动曲线白名单 | 20 | `[0.16,1,0.3,1]` × 17、`[0.25,1,0.5,1]` × 3 |
一处**必须如实记录的口径张力**:R4 的白名单是从 CSS 里真实解析 `--ease-*` 令牌得来(不硬编码),而 `CONTEXT.md` 的令牌层**刻意保留 5 条其它曲线**、且 `--ease-out` 只是 `ease-ink` 的别名(`globals.css:282`)⇒ "所有缓动一律 ink"的粗读法会与 `CONTEXT.md` 自身冲突。因此这 20 处究竟是**漂移**还是**已接受的例外**,属设计决策而非机械缺陷。据此:门禁**故意不并入** `check:a11y`(否则永久判红),先以独立脚本 + 真实红灯存在,等 §6 决策后再定"改代码"还是"改口径"。
### 3.6 单元测试可信度专项(N-27 的同类审查首次用到 jest 层)
结论与 Playwright 层**相反**:约 **1712 例中仅 3 例(≈0.2%)结构性装饰**,其余可失败。扫描器为 TS Compiler API AST,**先用合成正/负对照夹具自证 16/16 再采信计数**(过程中还抓出扫描器自身两个缺陷:漏认 `toBeInstanceOf`、把"对字面量数组 for-of"误判为空守卫)。grep 交叉核对 1693 站点 vs AST 1696 块(+8 `it.each` = 1712 运行时)⇒ 无系统性漏采。
| 类别 | 原始命中 | 真实 | 位置 |
|---|---|---|---|
| 零断言 | 3 | **3** | `src/hooks/use-count-up.test.ts:49`、`src/lib/analytics.test.ts:158`、`:241` |
| 空守卫 / 恒真 / 自我证明 / 被 mock 掉被测物 / 吞异常 / skip | 3 标记 | **0** | 唯一 `if` 命中是对**非空导入常量** `METRIC_BASES` 的 for-of(假阳);`GlobalErrorTracker.test.tsx:61` 的 `.catch(noop)` 只压 Node 未处理拒绝告警、不吞断言 |
最危险的一处是 `use-count-up.test.ts:49` —— 用例标题承诺"最终到达 end 值",主体只 `advanceFrames([0..600])` 而**不断言任何事**,一个"停在 99"的回归会静默通过。**本周期已按 TDD 补齐三例的真实判据**(`expect(result.current).toBe(100)`、`expect(window.gtag).toBeUndefined()` + `not.toThrow()`),复跑 **2 套件 / 40 例全通过** ⇒ 被测实现本就正确,缺的只是判据;同时说明覆盖率 85.83% 并非由空测试撑起,`config/test/jest.config.js` 的阈值没有被装饰用例喂出来(`src/app/**` 根本不在 `collectCoverageFrom` 内)。
### 3.7 N-24① 收口:axe 门禁现在**逐页**断言状态码(双向对照实测)
上轮把"axe/标题门禁的 per-route 状态断言"留在决策位(§8.2),本轮查明它**不需要决策、只需要正确的期望集**。结构缺陷:`axe-node-count.mjs` 的聚合保险丝是 `push(s.okResponses > 0, …)`,只有**全部**响应都非 2xx 才炸;而 404/500 页几乎不产生可比对节点 ⇒ `violationNodes` 仍为 0,`s.pages === urls.length` 的分母照加 ⇒ **单条路由退化成 404 会被当"干净"计过**。
不能简单写"全部必须 2xx":实测 136 行 = **132×200 + 4×404**,而这 4 条 404 全部来自 `/_not-found`——它**就是**本站 404 页,返回 404 属正确行为(证据 `docs/acceptance/2026-09-21-axe/axe-evidence.json`)。故实现为**期望集感知**:`EXPECTED_404 = ['/_not-found']`,其余路由必须 2xx/3xx,逐页比对并累计 `unexpectedStatus`;offender 明细**直接写进 failures 文本**而非只打 stdout(本仓曾因 line reporter 不带 stdout 而在并发失败时"查不到规则名",同一陷阱不能二次踩)。
双向对照(都在装配后的 standalone 产物 :3100 上跑,非推断):
- **正控制**(现有 34 路由清单)⇒ **POSITIVE_EXIT=0**,即收紧后不误伤现状;
- **负控制**(同一清单 + 一条故意不存在的 `/__axe-missing-route`,35 路由)⇒ **NEGATIVE_EXIT=1**,且逐条点名 4 个组合:`chromium|firefox × light|dark /__axe-missing-route status=404 expected=2xx/3xx`。
⇒ 这条判据现在**可失败且已证明会失败**,属于本仓 §「门禁断言有效性口径」的正例样本。
### 3.8 变异测试首次拿到实测凭据(A-13/A-14 的长期缺口)
上轮把变异测试列为"须第 4 项授权后再跑",理由是 `--inPlace` 会改写工作树。本轮核实该前提**在默认路径下不成立**:`stryker.config.json` **没有** `inPlace` 键 ⇒ 默认沙箱拷贝到 `.stryker-tmp`,工作树只读;`test:mutation:quick` 亦不带该旗标。据此在最终树上跑通(`MUTATION2=0`):
- **`src/lib/utils.ts` 变异分数 = 97.06**(≥ `break` 阈值 50,且高于 `high` 阈值 80),34 个 mutant;
- 存活样本已定位并如实记录,例如 `[Survived] ConditionalExpression`:`if (timeout) {clearTimeout(timeout)}` → `if (true) {...}`——即"没有 timeout 也要 clearTimeout"这条分支未被测试约束(行为等价类,属可接受的等价变异,但需知道它活着)。
⇒ 口径:**这只是 `utils.ts` 一个目标的凭据**,不是全仓变异分数(全量 `mutate` 覆盖 `src/lib/**`+`src/hooks/**`+七个组件目录,成本与当前磁盘余量不允许)。引用时不得把它当作全站分数。
### 3.9 磁盘耗尽(ENOSPC)事件与恢复证据(环境风险,须记入运行手册)
变异 + 视觉 + 全站 axe + Lighthouse 连跑期间,数据卷冲到 **100% / 可用 <1 Gi**,直接造成两件事:① `lib-prisma-review` 子代理以 `ENOSPC` **失败**(非判断错误),② 首轮 `test:mutation:quick` 与负控制 axe 同时中断。处置与验证:
- 归因:`.stryker-tmp` 实测 **1.7 G**(沙箱拷贝),叠加本仓 `dist/` 2.5 G;机器级还有 `~/Library/Caches` 7.4 G(非本任务产物,**未动**)。
- 恢复:仅删除本会话自建且已被 `.gitignore:280` 覆盖、`git ls-files` 为空的 `.stryker-tmp`(两次),可用回到 8.1 G;随后再次冲到 3.1 G,故已把"变异测试须单独排期并预留 ≥2 G"记为运行约束。
- **完整性复核(ENOSPC 后必做,否则可能留下半截文件)**:`git status --porcelain` 条数与事件前完全一致(**358**)、`node --check` 通过、`npm run type-check` **EXIT=0** ⇒ 无损坏;被中断的两项(负控制、变异)均已重跑并出数(见 §3.7/§3.8)。
- 顺带查实:仓库存在**三个** SQLite 文件,`DATABASE_URL` 为绝对路径指向 `prisma/dev.db`(应用实际打开的就是它),根 `dev.db`(Jul 6)与 `data.db`(0 B,Apr 10)是**陈旧未跟踪漂移**——本周期未删(非本会话创建,删除须授权)。
- **自纠:本周期我自己造成的一次证据损毁(不静默留给下个会话)**。`axe-node-count.mjs` 的证据输出走 `OUT = repoPath(process.env.OUT || 'docs/acceptance/2026-09-21-axe')`,即**所有运行(含 CI,`Jenkinsfile:390-392` 只传 `SITEMAP` 不传 `OUT`)共用同一个固定目录**,且该目录**全未跟踪**(`git status` 为 `?? docs/acceptance/2026-09-21-axe/`,`git ls-files` 为空)。后果两条:① 本轮波次的 `check:axe` **覆盖了上一周期同路径下的 `axe-evidence.json`,无法从 git 恢复**;② 更糟的是,我为 §3.7 跑的**负控制**(35 路由、含故意不存在的 `/__axe-missing-route`、`passed:false`)随后又把默认路径覆盖成"站点有一条坏路由"的**假象证据**——实测该文件现内容 `routeCount: 35 / has bogus route: true / passed: false`,任何按日期读到它的人都会去追一条并不存在的路由。
处置:把正/负两次证据分别写入 `docs/acceptance/2026-09-23-cycle2/axe-positive` 与 `.../axe-negative-control`(互不覆盖),并用正控制重跑默认路径,使留在旧位置的文件重新等于真实站点状态。**已完成的复原(实测)**:`NEG_EXIT=1 / POS_EXIT=0 / DEFAULT_RERUN_EXIT=0`;三份工件核对为 dated-negative `routes=35 passed=false bogus=true`(保留作 §3.7 的负控制凭据)、dated-positive `routes=34 passed=true bogus=false`、默认路径 `routes=34 passed=true bogus=false @ 18:24:54Z` ⇒ 假象证据已消除。**结构性建议(列入 D-11)**:把默认 `OUT` 改为按运行日期分目录,否则"每次验收都在覆盖上一次验收的证据"这一形态会在 CI 上持续复现;本周期未擅自改默认值,因为它同时是 Jenkins 的落盘位置,属共享流水线行为变更。
## 4. 本周期 UI 修复的视觉覆盖真相(对"跑一遍视觉就够"的否定)
基线清单实测:`e2e/visual-snapshots/visual-chromium-desktop/visual-regression.spec.ts/` 下只有 **21 个不同检查点名**(`about-fullpage`、`contact-fullpage`、`home-fullpage`、`product-erp-fullpage`、`services-fullpage`、`solutions-fullpage`、`solution-manufacturing-fullpage`、`service-software-fullpage`、`methodology-fullpage`、`news-fullpage`、`news-detail-fullpage`、`team-fullpage`、`footer-section`、`founder-quote-section`、`header-navigation`、`theme-dark-main`、`theme-light-main`、`button-default/focus/hover`、`products-fullpage`)。按此核对本周期改动:
| 改动 | 会改像素的落点 | 该落点是否有基线 | 结论 |
|---|---|---|---|
| P-4 删两个多余 `ScrollProgress` | `/cases/[slug]` | **无**(清单里没有任何 `cases-*`) | 视觉回归**测不到** |
| C-1 案例页缺键崩溃 | `/cases/[slug]` | **无** | 视觉回归**测不到**(其正确性凭据在单测) |
| D-1 指标前导符号排布 | `products/standalone/[id]`(`erp-upgrade-v3` 为死码) | **无**(`product-erp-fullpage` 是另一路由) | 视觉回归**测不到** |
| P-1 滑动监听挂到 `document` | 交互行为,非静态像素 | 交互不在视觉用例范围 | 视觉回归**测不到** |
| P-5 `scaleX` 降级动效 | 全站 layout 进度条 | 有 fullpage 基线,但基线不在 `reduced-motion` 仿真下拍摄 | 现状口径下**不会变**,亦即该修复同样未被视觉锁定 |
| N-14 删 `formsubmit` 资源提示 | `<head>` 标签,无像素 | — | 不影响基线 |
⇒ **判读纪律**:本周期视觉只读比对即使 104/104 全绿,也**只**说明"未波及被基线覆盖的页面",**不能**当作 C-1/P-1/P-4/D-1 的验收凭据;那四项的凭据只有各自新增的单元用例(§1.2 的 RED→GREEN)。把这句话写进报告,是为了防止下一轮用聚合绿灯替局部证据背书。
## 5. 遗留与待决
### 5.1 对上一轮结论的一处更正(本周期实测,属**在线合规缺口**而非潜伏项)
上轮 §8.2 第 2 条把 `/products/erp-upgrade` 的 `99.2%` / `40%+` / `从5天到1天` 记为"无 `basis` 结构约束"的数字口径问题,且 `3ed3afd` 的提交标题自称"删除详情页虚构佐证"。**复核发现这些数字在一个活的组件里,不在死码里**:
- `src/app/(marketing)/products/erp-upgrade/erp-upgrade-content-v2.tsx:63` `40%+`、`:69` `99.2%`、`:77` `从5天到1天,财务结账效率大幅提升`
- 该文件由 `src/app/(marketing)/products/erp-upgrade/page.tsx:4` 以 `import ErpUpgradeContentV2 from './erp-upgrade-content-v2'` 引入 ⇒ 路由 `/products/erp-upgrade` **渲染这些数字**
- 同一路由又正是 N-15 中"基线从未存在"的两条之一 ⇒ **内容口径与视觉验证同时无覆盖**,这是本周期最值得单独立项的一条交叉风险
因此"删掉即了结"的判断对该页**不成立**:要么给出真实来源并补 `basis`,要么按 AGENTS.md §3「零编造」把这些数字降级为明确的目标口径标注。属业务决策,本周期未替业务作答。
### 5.2 SLA 互斥口径(复核后仍为多条并存,已核实的三处)
`prisma/seed.ts:836` 与 `:840` 「工作日 2 小时内快速响应」 / `prisma/seeds/services.ts:129` 「平均响应时间 <4小时 · 工作日技术支持」 / `src/components/sections/case-detail-page.tsx:595` 「免费咨询,48小时内给出初步方案建议」。上轮另记的 `detail-cta-section.tsx:84` 「<2小时 · 7×24」本轮按 `小时` + 响应/回复/方案/支持 组合检索**未命中**,故不再断言其仍在——需决策的是上述三条互斥口径留哪一个。
### 5.3 其余待决
- **P-2(需产品/设计决策,本周期未动刀)**:`content-types.ts:74-78` 把 news 正文字段声明为 `richtext`,而后台 `renderField`(`…/[itemId]/page.tsx:450-631`)**无 richtext 分支** ⇒ 落 `default:` 单行 `<input type="text">`,读者侧按纯文本渲染。上轮 N-3 已把文档改成"现实描述",故这是**能力缺口**而非文档谎言:要么补 TipTap 分支,要么改字段声明。
- **A-1 同类残留**:模型 not-found 路径(`:295-296`)仍会渲染带保存按钮的空白表单——本周期已另派修复,结果见 §7。
- **N-21 幻影 modelCode:本轮以源码证据推翻上轮定性**(上轮记为"省略 `modelCode` 时以 `'content-item'` 的 read 权限**放行全部模型**")。实测链路:`route.ts:79` 造出 `'content-item'` ⇒ `requirePermission`(`permissions.ts:80-93`)⇒ `checkUserPermission` ⇒ `hasPermission`(`permissions.ts:24-27`)要求 `p.modelCode === modelCode` **精确相等**;而 `'content-item'` 在 `src/` 与 `prisma/` 中**只出现在这一行**,种子里真实 modelCode 为 `hero-banner / stat-item / service / solution / case-study / news`(`prisma/seed.ts:69-107`)⇒ 任何非 `super_admin` 角色都**匹配不到权限行 ⇒ 返回 403,即 fail-closed**。所以它**不是越权读取**,上轮的 P2 安全定性不成立。真实残留风险降为两点:① 该字符串一旦将来被补进 Permission 种子,就会立刻变成跨模型读取的闸口(**潜伏陷阱**,非现网缺陷);② 唯一省略 `modelCode` 的客户端调用点 `src/lib/admin-api.ts:207 getStats()` 在 `src/` 内**无调用者**(同名的 `src/lib/cms/data-server.ts:133` 是另一个服务端函数),故亦无现实功能故障。⇒ 建议修法仍是"缺 `modelCode` 即 400",但优先级按潜伏陷阱处理,不作为本周期阻断项。
- **N-23 安全头门禁仍默认打线上**:`scripts/utils/check-security-headers.ts:34` 无 `--url` 时返回 `https://novalon.cn`,而 `package.json:56` 的 `test:security:headers` 正是不带 `--url` 调用它,并被 `test:all`(`:48`)引用 ⇒ 该门禁验的是线上而非本树产物。
- **`check:a11y` 构成**:`package.json:52` 仍为 `check:contrast` + `check:headings` + `check:brand-token` 三项(动效门禁是否并入见 §7)。
- **L-3 未修,须与部署协同**(本周期刻意不动):把 `src/lib/client-ip.ts` 的头部信任收进 `TRUST_PROXY` 开关看似 1 行,但**生产由 nginx 注入 `X-Real-IP`**,若部署侧没有先设置该开关,限流键会全部塌成 `127.0.0.1` ⇒ 真实访客共享同一个限流桶、互相封禁。这是"改一行换来一次线上 DoS"的典型,须与 `Dockerfile`/nginx/compose 的部署约定一起改,属决策项(并入 D 清单)。
- **L-4 记为口径而非漏洞**:`crypto.ts:36` 与 `crypto-server.ts:20` 的对称密钥由**打进公开 bundle** 的 `NEXT_PUBLIC_ENCRYPTION_SECRET` 派生 ⇒ 该"传输加密"只具混淆性,无机密性与完整性。鉴权仍由 `Bearer` 承担故不构成越权,但**不得把它当安全控制引用**;文档若称"请求体已加密"须限定语义。
- **其余未修纵深项**:服务端 `version` 乐观并发(需改 API 契约,界在 §1.1 的 A-2/A-3 之外)、上轮 §4.2 的审核流缺 `reject`/`archive` 入口(条目提交进 `review` 后,审批者没有驳回路径)。上轮 N-24①(axe 逐页状态断言)**本周期已收口**,故不再列于此(见 §3.7)。
- **扩充后的死码清单(删除需授权,本周期未删)**:上轮 §4.4 的 7 项不完整,另零入向引用者含 `sections/hero-section-v2`、`sections/insight-card`、`sections/industry-grid`、`sections/product-card` + `ui/product-card` + `detail/product-card`、`detail/solution-value`、`detail/service-value`、`layout/breadcrumb`、`ui/loading-state`、`detail/micro-interactions`(`TiltCard`/`HoverLink`),以及 `use-swipe-gesture.tsx` 的 `PullToRefresh`(其 `handleTouchEnd` 里 `await onRefresh()` 无 catch ⇒ 潜在未处理拒绝)。
- **`@sentry/tracing@7.120.4`**:全仓零引用、无包经它解析,README 自述已废弃 ⇒ 建议移除,但**依赖删除须单独批准**,本周期未动 `package.json` 依赖。
## 6. 工作树披露(与上轮同一最高优先事项,仍未解除)
本周期全部修复同样**只存在于工作树**,`git status --porcelain` 约 344 条,HEAD 仍是分支起点 `f543e47`。即上一轮 §7 的"未固化交付"结论对本周期**继续成立且累积**:两轮的可审交付都无法从任何 commit 复现。提交/推送/PR 仍待授权(上轮 §5 第 4 项)。
### 6.1 授权第 4 项的真实代价(本轮实测,须先于批准读取)
以本地 ref 实测(**未执行 `git fetch`**,因改写历史与共享状态须先授权):
| 探针 | 结果 |
|---|---|
| `git merge-base --is-ancestor origin/dev HEAD` | **NOT ancestor** |
| `git rev-list --count origin/dev..HEAD` | **30**(本分支独有) |
| `git rev-list --count HEAD..origin/dev` | **31**(`origin/dev` 独有) |
| `git log -1 %h %s` 两端 | HEAD = `f543e47`,origin/dev = `040951c`,**标题完全相同**(`test(visual): 以规范模式重生成全站视觉基线并补齐 firefox 缺口`)而 hash 不同 |
| 本地 `dev` ref | `040951c`(与 `origin/dev` 同值) |
⇒ 三点必须让批准者知道,而不是等 rebase 时才发现:
1. 这是**双向分叉**(30 ↔ 31),不是"我们落后于 dev"的单向情形;AGENTS.md §5.1 的 `git fetch origin dev && git rebase origin/dev` 在这里**不是平凡的快进整理**,会出现同标题异 hash 的重复提交对撞,需逐笔判定取哪一份。
2. 同标题异 hash 说明**某一侧历史被重写过**(amend / force-push / 另一工作树重提)。本地 `origin/dev` ref 可能已陈旧,真实现状只有 `git fetch` 后才可知——这正是本仓反复记录的"不得用陈旧本地 ref 推断历史事实"。
3. 344 条改动一次入库体积过大(上轮已建议按 N 系列分批);结合本条分叉,**建议的入库顺序**是:先 `git fetch origin dev` 取得真值并出分叉清单 → 由交付决策人裁定重复提交对如何取舍 → 再按"表现层修复 / 后台编辑器 / Sentry 接线 / 测试装配 / 文档口径"分 5 笔提交 → 最后创建 PR。本轮未擅自启动其中任何一步。
## 7. 收尾回填(本周期已完成)
- [x] §0 全部可自主执行的门禁都在**最终树**上出数并回填(中途树与最终树的 104/105 区分见 §1.5)
- [x] 视觉只读比对:104 / 21、0 像素不符、未写任何参照(§3.4)
- [x] 生产目标**只读** E2E:115 / 1,唯一失败隔离复跑通过,判并发族(§0)
- [x] `@security`、`@accessibility|@performance`、`check:a11y`、全站 axe 节点计数、Lighthouse 全 **EXIT=0**
- [x] 真库集成层 23 例 + `prisma/dev.db` 完整性复核(38 / draft 0)
- [x] 动效门禁落地并自证伪(§3.5);单元测试可信度专项(§3.6)
- [x] 文档同步:`quality-gates.md` §1 与 `CONTEXT.md` 的 lint 口径改为 104 实测;`CLAUDE.md` 补「Error Monitoring (Sentry)」与动效门禁两条架构级说明(此前 Sentry 接线在 CLAUDE.md 中**零记载**);PR 模板的假阈值已改指唯一真源(§3.2)
## 8. 交付决策清单(须由交付决策人逐项裁定;本轮未擅自代替裁定)
| # | 事项 | 本轮状态 | 裁定 what |
|---|---|---|---|
| **D-1** | **入库**:344 条改动、两轮修复全部只在工作树,HEAD 仍 `f543e47`;且 `origin/dev` 与 HEAD **双向分叉 30↔31**、两端同标题异 hash(§6.1) | 未 fetch、未 commit、未 push | 是否授权按"表现层修复 / 后台编辑器 / Sentry 接线 / 测试装配 / 文档口径"分 5 笔入库;分叉的重复提交对如何取舍 |
| **D-2** | **动效门禁 23 处判红**(R4 曲线 20 + R3 裸时长 2 + R2 `--transition-gentle:1000ms` 1,§3.5) | 门禁已存在但故意不并入 `check:a11y` | 是"改代码收敛到 ink/令牌"还是"承认这些曲线为设计例外、把它们补进 `--ease-*` 白名单" |
| **D-3** | **视觉参照缺口**:20 例缺参照恒红 + `/products/erp-upgrade`、`/about/brand`、`/cases/[slug]` 三路由**从无任何基线**(§3.3、§4) | 只读比对未写参照 | 是否授权 `--update-snapshots` 写参照(会追认现状,A-9 症结),以及是否为上述三路由补基线 |
| **D-4** | **写库 E2E 11 例**未执行(`cms-workflow.spec.ts`、`user-journey.spec.ts` 各 9 处写调用;本轮已把它们从生产目标集合中显式剔除) | 只读边界经 DB 计数实证 | 是否授权跑完整 `npm run test` / 完整 `test:e2e:prod`(真写 `prisma/dev.db`) |
| **D-5** | **`/products/erp-upgrade` 在线数字口径**:`99.2% / 40%+ / 从5天到1天` 位于**活组件** `erp-upgrade-content-v2.tsx:63,69,77`(被 `page.tsx:4` 引用),且该路由同时无视觉基线(§5.1) | 未替业务作答 | 给真实来源并补 `basis`,还是降级为目标口径标注,还是删除 |
| **D-6** | **SLA 互斥**:工作日 2 小时 / `<4小时 工作日` / 48 小时内 三套并存(§5.2) | 未动 | 留哪一条 |
| **D-7** | **news 正文富文本能力缺口 P-2**:字段声明 `richtext` 而 `renderField` 无该分支(§5.3) | 未动(上轮已把文档改成现实描述) | 补 TipTap 分支,还是把字段降级为纯文本并改声明 |
| **D-8** | **死码删除**:上轮 7 项不完整,本轮扩充为含 `hero-section-v2`、`insight-card`、`industry-grid`、三份 `product-card`、`solution-value`、`service-value`、`breadcrumb`、`loading-state`、`micro-interactions`、`PullToRefresh` 等(§5.3);其中 `loading-state.tsx` 正是 `check:brand-token` 唯一的白名单豁免(N-8) | 未删(删除不可逆且需授权) | 是否授权删除 + 一并收掉白名单豁免 |
| **D-9** | **Sentry 实投递未验证**:接线与惰性有码路证据,但**未用真实 DSN 起服务**验证事件送达与 bundle 上传 | 未验证项已披露 | 是否提供 DSN 并授权一次线上实测;另 `@sentry/tracing@7`(零引用、已废弃)是否授权移除 |
| **D-10** | **变异测试**:上轮把它列为"须第 4 项授权后再跑",理由是 `--inPlace` 会改写工作树;本轮核实 `stryker.config.json` **无 `inPlace` 键** ⇒ 默认沙箱拷贝,前提不成立 | **本周期已执行**:`src/lib/utils.ts` 变异分数 **97.06**(≥ break 50、达 high 80),1 例存活已定位(§3.8);`.stryker-tmp` 峰值 1.7 G 曾把磁盘冲到 100%(§3.9),已在收尾删除 | 剩余裁定项是**要不要跑全量**(`mutate` 覆盖 `src/lib/**`+`src/hooks/**`+七个组件目录,需 ≥2 G 磁盘余量与一段无并发门禁的时间窗) |
| **D-11** | **门禁证据落盘路径共用固定日期目录**(§3.9):`check:axe` 的默认 `OUT` 写死在 `docs/acceptance/2026-09-21-axe`,CI 与本地、负控制与正控制全部互相覆盖,且目录未跟踪 ⇒ 证据不可恢复、可被后一次运行悄悄改写 | 已用 dated 目录自保并复原默认路径 | 是否授权把默认 `OUT` 改为按日期分目录(同步 `Jenkinsfile:390-392`),并决定是否将 `docs/acceptance/**` 纳入版本控制 |
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,207 @@
# 系统性 Review / 全面测试 / 验收 复测报告 — 2026-09-23
- 分支:`refactor/optimize-ui`(工作树含 **341** 条未提交改动(39 条未跟踪),HEAD 仍是 `f543e47` ⇒ 见 §7 的最高优先披露)
- 环境:Next.js 16.3.0 (Turbopack) / React 18.3 / TS 5 strict / Prisma 6.19 + SQLite / Tailwind 3.4 / Jest + Playwright + lhci + Stryker + axe-core 4.11.4
- 方法:subagent 并行分模块审查 + 分文件所有权修复;主代理逐条复核证据;所有门禁数字为**本轮亲跑**,未引用 2026-09-21 报告的历史值
- 上游输入:`ACCEPTANCE_REVIEW_2026-09-21.md`(判定 **REJECTED**,5 类阻断)——本报告是其**闭环复核**
## 0. 验收结论
**有条件通过(CONDITIONAL PASS)。**
2026-09-21 的 5 类阻断中,**4 类已在代码层闭环并有本轮实测证据**(安全提权/存储型 XSS/上传类型/CI 门禁吞错与 a11y 整族对比度)。判「有条件」而非「通过」的原因**只剩两类**(下面第 3 条原为第三条理由,本轮已修复并复测,保留编号以便对照):
1. **五项验收动作需单独授权,本轮未执行**:`db:seed`、写库 E2E(本轮取证后范围已收窄到 `e2e/cms-workflow.spec.ts` 与 `e2e/user-journey.spec.ts` 两个文件,合计 11 例;此前只登记了前者,低估一半)、视觉基线 `--update-snapshots`、`git commit`/push/PR、Stryker 变异测试(`--inPlace` 会改写工作树,而本树 341 条改动未提交 ⇒ 中途崩溃不可恢复)。
**除这五项之外,本轮把所有能测的都实测了**,可交付的测试凭据为:生产目标 write-free E2E **92 例通过 / 0 skipped**(其中 16 个 GA4 用例首次在产物上真实断言)、`@security` **68 通过 + 4 显式 skipped**、**`@accessibility\|@performance` 层 124 通过 / 4 webkit 失败**(该层此前同样不在任何门禁内,见 N-32)、**`@regression` 全功能层 200 例首次运行**(同一代码三轮 194/1、8、5 的抖动样本,定性见 N-28)、视觉**只读比对** 104 通过 / 21 失败且 **0 例像素不符**、`test:coverage` EXIT=0、真库集成 23 例(`prisma/dev.db` 行数复核未变)、axe 四组 `bgMismatch/contrastNodes/violationNodes` 全 0、Lighthouse 27 次运行四类目 99–100。
⇒ 「有条件」的实质含义因此收敛为两件事:**变更尚未固化为提交**(见第 2 条)+**上述五项动作等授权**;不再包含"某类测试没跑过"。
2. **交付物只是工作树,不是提交**:全部修复未进 git(HEAD 仍为分支起点 `f543e47`,**341 条**未提交改动,其中 39 条未跟踪、85 条是 `e2e/visual-snapshots` 下**已跟踪但被修改**的 PNG)⇒ 不可审、不可回滚、不可复现。**这一条对视觉回归的杀伤力最大**:本轮 104 例通过所比的参照本身就不在版本控制里(详见 §7 的基线时间线)。
3. ~~N-9 门禁可信度缺陷待复测~~ → **已修复并复测通过(本轮收尾完成)**:本地 standalone 产物曾缺 `dist/static`,导致 `check:axe` 与 `npm run lighthouse` 在**全站样式 404** 的裸 HTML 上跑(axe 靠 `bgMismatch` 诚实报红,而 Lighthouse 在 CSS 全 404 下仍 exit 0 —— A-11 的第三个实例)。`postbuild` 修复后重跑,**两条门禁现在都在真实装配产物上出数**:axe `PASSED = true`(四组浏览器×主题,`bgMismatch=0`、`contrastNodes=0`、`violationNodes=0`),Lighthouse **a11y 9/9 URL 全部 100 分、对比度失败节点 0、`aria-required-children` 失败节点 0**(上一轮为 92-97 分 + 10 个失败节点)。数值见 §1。
> 附带一条本轮自查抓到的**门禁回红**:`test:coverage` 在补测后一度 EXIT=1(新测试文件自身的 `Object.defineProperty` 缺 `writable` 把全局 `requestAnimationFrame` 变成只读,使 `afterEach` 的 `mockRestore()` 抛错 ⇒ 整套件 failed to run)。已定位根因并修复,最终树 **132 套件 / 1677 例全通过、EXIT=0、0 条阈值告警**。记为 N-12,同时说明「子代理产出必须逐条复跑」这条纪律本轮真实生效(见 §3)。
> 🔴 **收尾追加(`src/app/**` 专项审查,subagent 扫描 + 主代理逐条复核源码)**:覆盖率门禁的 `collectCoverageFrom` **不含 `src/app/**`**,即所有 route handler 与页面都在棘轮之外。针对这一盲区做专项审查后,**确认并修复 3 项 P1 + 1 项 P2 门禁诚实性问题**:N-18 `draft/disable` 开放重定向(无鉴权,修复前实测会向站外发 307)、N-19 `admin/stats` 只验会话即返回跨模型草稿标题(任何零权限登录账号可读)、N-20 停用/删除账号的已签发令牌在 24h 内仍通过全部 guarded 路由(B-7 的状态校验当时只补在 refresh 链路上)、N-17 `security-headers.spec.ts:364` 以裸 `return` 伪装通过。三处修复各有 **RED 实测**(临时移除 guard ⇒ 相应用例判红 ⇒ 恢复后转绿),另有 2 项留决策(N-21:`'content-item'` 幻影 modelCode、`/admin` 无服务端 gating)。修后复跑:`type-check` EXIT=0、`test:unit` **133 套件 / 1691 例 EXIT=0**、`lint` 0 error / 105 warning。**⚠ 口径提醒**:1691 是 `test:unit` 下的数,与上面 1677(`test:coverage`,套件集不同)不可直接相减比较。**这不改变「有条件通过」的判定,但改变了它的含义**:本轮之前对该盲区的唯一保障是"有人看过",现在多了一条可执行的回归测试。
> 🔴 **第三轮收尾追加(四路并行专项:门禁脚本自身 / 部署层 / E2E 断言质量 / 内容零编造 + 设计约束)**。全部为「subagent 扫描 + 主代理逐条读源码复核」,**未照抄任何一条子代理结论**(其中至少三次纠正了子代理的错误建议:它给的替代选择器 `hero-primary-cta` 不存在、它猜的两处 testid 其实存在、它建议的 `.dockerignore` 一行修会打断 `NEXT_PUBLIC_*` 构建期内联)。战绩:
> - **门禁的门禁**(N-22/N-23/N-24):`check-security-headers.ts` 的 cookie 判定 `status` 写死为字面量 `'pass'`、汇总只看 `headerChecks`、`X-XSS-Protection` 两分支相同、**缺 CSP 只 warn** ⇒ 该安全头门禁对两条主判据永不失败;已修并做**双向对照**(无 CSP 的本地服务 ⇒ EXIT=1/4 fail;真实 standalone 产物 ⇒ EXIT=0/0 fail)。顺带由正向对照挖出 **HSTS 只在边缘注入** ⇒ 该门禁结构上无法在分支产物通过,而 `Jenkinsfile` 打印「检查本分支构建产物」是假的(N-23,未修待决策)。
> - **部署层**(N-25/N-26):限流键取 `X-Forwarded-For` 最左项(客户端自报)⇒ 每请求换头即绕过「5 次/小时」,旧逻辑实测 distinct=3;已抽成 `src/lib/client-ip.ts`(放进覆盖率棘轮内)+ 6 例测试。另有 `.env.production` 携 `JWT_SECRET` 等进入 builder 层、CI 先删 lock 再 `npm ci` 致 lockfile 从未生效、`nginx-static-production.conf` 字体/图片块缺 `@nextjs` 回退——三者分别触碰密钥约定/CI/线上 Nginx,未擅动。
> - **E2E 断言质量**(N-27/N-28):145 个真实 test 块里 5 个零断言、至少 14 处断言被包在 `if (count() > 0)` 里。修掉 6 处(含 `expect(a) || expect(b)` 这个真 JS 逻辑 bug、`text=开始合作` 这个全站不存在的死选择器、`.catch(() => {})` 吞掉跳转失败),我自己两次改错均被四 project 实测否掉后才收敛(教训:桌面-only 直觉在移动 project 必误红)。
> - **内容零编造 + 设计约束**(N-29/N-30):`3ed3afd` 的数字口径机制**只是一个单测里的 7 文件白名单**,其 seed 扫描不含 `SERVICES`/about、禁词扫描不含 `src/lib`+`prisma`,故白名单外无人管;删掉两处凭空数字(`效率提升 40%`、`40%`/`核心成果提升`),其中后者**原本被一条绿色测试正向锁定**,已把该断言反转。另记 AGENTS.md §8 把 `CONTEXT.md` 的三档动效口径压成一句「180–280ms」(照字面审会误伤合法的 150/100ms),已按信源改正。
>
> **最终树门禁**:`type-check` EXIT=0、`lint` 0 error / 105 warning、`test:unit` **134 套件 / 1697 例** EXIT=0、`test:coverage` EXIT=0、`check:a11y` EXIT=0(此时已跑在修好的标题门禁与真实产物上)。发现清单 **N-1…N-30** 连续编号,其中本轮新增 N-14…N-30 共 17 项。**判定不变:有条件通过**——理由只剩「未提交」与「五项待授权动作」两条。
## 1. 本轮门禁实测
| 门禁 | 命令 | 本轮实测 | 判定 |
|---|---|---|---|
| 类型检查 | `npm run type-check` | EXIT=0,0 error | 通过 |
| Lint | `npm run lint` | EXIT=0,`✖ 105 problems (0 errors, 105 warnings)`:55 `no-console` / 25 `@typescript-eslint/no-explicit-any` / 12 `react-hooks/set-state-in-effect` / 10 `@next/next/no-img-element` / 各 1 `@next/next/no-sync-scripts`、`@next/next/no-html-link-for-pages`、`Unused eslint-disable directive`(死抑制)。分项相加 = 105 | 通过(0 error);口径与 AGENTS.md §5 已统一 |
| 单元 + 覆盖率 | `npm run test:coverage` | 首轮 **130 套件 / 1608 例**,EXIT=1:`sections` branches 70.87<72、`content` stmts+lines 38.6<52 / funcs 20<46 → 派子代理补**真实测试**(明令禁止降阈值、禁止改配置)。中途一度因新测试自身缺陷回红(N-12,已修)。**最终树:132 套件 / 1677 例全通过,EXIT=0,0 条阈值告警**;global **85.56 stmts / 86.86 branch / 80.59 funcs / 85.56 line**,`components/content` **100/100/100/100**,`components/sections` **72.77 / 87.06 / 73.8 / 72.77**,`lib/cms` 91.9/92.63/92.5 | **通过**。两项门限与首轮判红时完全一致(`sections` branches 72、`content` 52/46),`content` branches 只从 52 **上抬**到 95(实测 100)⇒ 阈值未被下调,棘轮方向正确 |
| 真库集成层 | `npm run test:integration:real` | **4 套件 / 19 例全通过**,EXIT=0;teardown 守卫打印「prisma/dev.db / dev.db / data.db 的 inode+size+mtime 与运行前完全一致」 | 通过(新接入 `test:all`) |
| 可访问性静态三件套 | `npm run check:a11y` | EXIT=0;contrast 0 问题、headings 0 问题、brand-token 扫描 389 文件违规 0(1 处白名单降级,见 N-8) | 通过 |
| 全站 axe 节点计数 | `npm run check:axe:routes` + `check:axe`(专用端口 3100 起**装配后**的 standalone) | 首轮 **EXIT=1 / PASSED=false**(`bgMismatch=34`×四组,样式未加载 ⇒ 对比度「意外达标」)→ 根因 N-9。**postbuild 修复后重跑:`routes-exit=0`、`axe-exit=0`、`PASSED = true`** —— 四组 `chromium`/`firefox` × `light`/`dark` 各 34 页:`themeMismatch=0`、**`bgMismatch=0`**、**`contrastNodes=0`**、**`violationNodes=0`**、`extraRuleNodes=0`、`extraRuleCoveragePairs=102`。light 底 `rgb(255,255,255)`、dark 底 `rgb(10,14,20)` 与令牌一致 ⇒ 样式确已加载。⚠ 每组 `non2xxResponses=1`,逐行核对为 **`/_not-found` 返回 404**(该路径由预渲染产物清单被爬入,404 是其正确语义,且该页同样 0 违规、底色正确)⇒ 非缺陷 | **通过**(本轮真实出数) |
| Lighthouse | `npm run lighthouse`(lhci 自起 :3200 standalone) | 首轮 EXIT=0 **但在 CSS 全 404 的产物上取得,判定不作数**。**修复后重跑:EXIT=0,断言 9 URL / 27 次运行全通过**,逐 URL 中位数:perf **99-100**、**a11y 100(9/9 全部)**、BP 100(9/9)——该列首轮唯一例外 `/contact` 为 **96**,N-11 修复后对同一 URL 用**同一审计**(`inspector-issues`)单独复跑得 score 1 / 0 items / **BP 100**,故 9/9 齐平、FCP **248-336ms**、LCP **795-903ms**、CLS **0.000**、TBT **0ms**。**对比度失败节点累计 0、`aria-required-children` 失败节点累计 0**(上一轮为 a11y 92-97 + 10 个失败节点,其中 7 个来自 Cookie 条) | **通过**(A-11/A-12 至此有了正向实测凭据) |
| E2E(**生产目标**,写库用例已排除) | `E2E_TARGET=production` + `@smoke\|@critical\|@journey` × 4 project,`--grep-invert` 掉 `cms-workflow.spec.ts`/`user-journey.spec.ts` | **EXIT=0,92 通过 / 0 失败 / 0 skipped**(2.3 分钟)。服务由 harness 新起 `node dist/standalone/server.js`(production 目标 `reuseExistingServer:false` ⇒ 必然测当前产物)。**16 个 GA4 用例在生产目标下首次真实断言并通过**,闭合 AGENTS.md 长期声明的「dev 下是 skipped 不是 passed」缺口。**11 例未跑**(两个写库 spec),需 §5 第 2 项授权 | **通过**(限于只读子集;A-10 由「机制已建未执行」升级为「已执行」) |
| E2E(**`@regression` 全量功能层,本轮首跑**) | `--grep '@regression'` + 按文件排除两个写库 spec,chromium project | **194 通过 / 1 失败 / 5 skipped,EXIT=1**(3.0 分钟,14 个 spec、200 例)。此前这 200 例**在任何自动化门禁里都不跑**(`test:e2e:fast` 只 `@smoke\|@critical`;Jenkins 的 `test:e2e:prod` 加 `@journey`;CI 的 `test:visual` 只桌面 chromium 一个 project)⇒ 本轮是它对人的第一次实测。**唯一失败已定性为间歇性**:`website-acceptance.spec.ts:111`「响应式设计正常工作」在 `[data-testid="mobile-navigation"]` 上报 `element(s) not found`(5s 超时);**选择器本身有效**(该 testid 确实存在于 `src/`,且 `mobile.spec.ts` 用同一选择器的多条用例本轮全通过),**隔离复跑 `--retries=0` 直接 passed(13.0s)** ⇒ 判为并发下的时序/超时抖动,与前述 UJ-10 那例同族。**未通过加 timeout/retry 掩盖**,作为已知不稳定项记录;**同一份代码连跑三轮的失败数为 8 → 1 → 5**(三轮仅差我改的 3 条用例),故这一层的失败绝大多数是并发负载抖动而非缺陷。第 2 轮跑的是"本轮改动的回归比对",**我改的 3 条在第 2、3 轮均全 project 通过**,第 3 轮的 5 例失败**没有一条来自我的改动**(`p4:31` 首页完整加载 < 8s、`p4:68` FCP、`p4:408` 跳转链接、`p2:177`/`p2:239` webkit 卡片与方案详情)| **实质通过**(1 例间歇红,待按"降低本体等待成本"处理而非放宽阈值) |
| E2E(**生产目标**,安全头子集) | `E2E_TARGET=production` + `--grep '@security'`(`security-headers.spec.ts` 18 例/project × 4 project) | **EXIT=0,首轮 72 通过 / 0 失败**(32 秒;N-17 修复后复跑为 **68 通过 + 4 显式 skipped**,见下方「一条例外」),日志 `docs/acceptance/2026-09-23-gates/e2e-security-prod.log`。**这次是真在产物上断言响应头**:`X-Frame-Options: DENY`、`X-Content-Type-Options: nosniff`、`Referrer-Policy: strict-origin-when-cross-origin`、`Permissions-Policy: camera=(), microphone=(), geolocation=(), interest-cohort=()`、`X-XSS-Protection: 1; mode=block`、CSP 存在性与指令逐项检查(日志内 ✅ 头断言行 48 条),且表单用例经 `page.route('**/api/contact', fulfill)` 打桩 ⇒ **不产生对外提交**。**一条例外已记为 N-17**:`security-headers.spec.ts:364` 的「静态资源通过 HTTPS 加载」在 `baseURL` 以 `http://localhost` 开头时**裸 `return`**,而 harness 的 `baseURL` 恒为 `http://localhost:3000`(`playwright.config.ts:27`)⇒ 该例(×4 project = 4 例)**在本地与产物目标下都不可能执行任何断言,却计为 passed** | **通过**(除 N-17 那 4 例条件空转;HTTPS-only 断言仍只能靠打线上,属 §5 未决) |
| E2E(**生产目标**,写库用例已排除) | `npm run test` / 完整 `test:e2e:prod` | **未执行——需授权**(`@critical` 含真写库规格) | 无数据 |
| E2E(**重新构建后复跑**,验证层级对齐) | `npm run build` → 同一 write-free 生产目标命令 | `BUILD_EXIT=0` 且 `postbuild` 装配自检通过(`dist/standalone/dist/static`、`dist/standalone/public` 均在);E2E **91 通过 / 1 失败 / 0 skipped**(2.6 分钟)。**失败非回归**:`mobile-user-journeys` 的 UJ-10 在 firefox/mobile 下失败,**隔离复跑(`--retries=0`)通过**(14.5s,日志含 `all articles count: 2 … completed successfully`)⇒ 判定为 4 worker 并发下的**间歇性超时/负载相关**,与本轮 `src/app/**` 改动无因果(该用例不触任何管理端 API)。日志 `docs/acceptance/2026-09-23-gates/e2e-prod-after-authfixes.log` | **实质通过**(1 例待按"概率性超时只能靠降本体成本解决"处理,加 retry 等于把间歇红藏更深,故不这么做) |
| 视觉回归(**只读比对**) | `npm run test:visual:all -- --update-snapshots=none`(规范模式 dev server,5 project × 25 例) | **EXIT=1,21 失败 / 104 通过**(4.9 分钟)。逐条归类后**没有任何一例是像素不符**:① 10 例基线文件根本不存在(`product-erp-upgrade-fullpage` / `about-brand-fullpage` × 5 project)⇒ 只读模式拒绝创建,属**覆盖空洞**;② 10 例在截图**之前**就死在定位断言上(`[class*="card"]` 在 `/products` 匹配 0 个元素;`input[type="text"], input[type="email"]` 的 `.first()` 命中的是**反垃圾蜜罐** `<input type="text" tabindex="-1" name="website" aria-hidden="true" aria-label="honeypot">`,该字段按设计隐藏 ⇒ `Received: hidden`,**5/5 project 恒定失败**);③ 1 例 chromium-mobile 的 `button, a[role="button"]` 首元素在窄视口不可见。日志 `docs/acceptance/2026-09-23-gates/visual-readonly-finaltree.log` | **判红**,但红在测试装配而非页面渲染:**104 例通过**说明当前树对全部现存基线逐像素复现一致,`/contact` 全页比对亦通过 ⇒ **N-11 的 `jitless` 改动未造成任何视觉变化(独立于评分的正向确认)**。21 例失败为**从未绿过**的坏测试与缺失参照(N-15),与本轮修复无因果 |
| 变异测试 / k6 | `test:mutation` / `test:performance` | 未执行(Stryker `--inPlace` 会改写工作树;k6 需本机 CLI) | 无数据 |
> 数字纪律:AGENTS.md §5 与 `config/test/jest.config.js` 的历史数字来自上一轮树(137 套件/1659 例,global 77.92/84.6/77.64/77.92),本轮因删除死代码与测试合并而先变小、补测后变大。**本报告不拿旧数字当现状**;收尾已一次性回填四处活文档(`AGENTS.md` §5、`CLAUDE.md:240`、`README.md:167-168`、`docs/development/quality-gates.md` §3 与 `config/test/jest.config.js` 顶部注释),统一为 **132 套件 / 1677 例 / 85.56 · 86.86 · 80.59 · 85.56**。历史证据目录 `docs/acceptance/2026-09-21-gates/*` 保留原值不改——它们记录的是当时为真。
## 2. 2026-09-21 阻断项闭环对照
只列本轮**有实测或直接代码证据**的项;无新证据的一律标「本轮未复测」,不沿用旧结论。
| 编号 | 原判定 | 本轮状态 | 证据 |
|---|---|---|---|
| A-1 `content_admin` 自主提权 | P0 | **已闭环** | `tests-integration/00-security-boundary.itest.ts` 真库例:授予 `super_admin` ⇒ 403 且库里无新用户、无新 `UserRole`(本轮通过) |
| A-2 重置任意用户口令/停用超管 | P0 | **已闭环** | 同套件:改密/停用超管 ⇒ 403,真实哈希与 `status` 未变(本轮通过);另有「禁用账号换不到 access token」正反向对照 |
| A-3 CMS 富文本未净化直出 | P0 | **已闭环** | `src/app/terms/page.tsx:3,213` 与 `src/app/privacy/page.tsx:3,268` 均 `sanitizeRichText(cmsContent)`;净化器 `src/lib/sanitize.ts` |
| A-4 上传同源分发 + 类型不设限 | P0 | **已闭环** | 新增 `src/lib/media/upload-policy.ts`:扩展名 allowlist + magic-byte 嗅探 + 声明 MIME 必须与嗅探结果一致,`svg` 明确排除;本轮另修正 `docker-compose.server.yml` 落盘挂载路径与真实写盘根 `<cwd>/public/uploads` 对齐 |
| A-5 CI 用 `\|\| echo` 吞掉功能测试 | P0 | **已闭环** | `Jenkinsfile` 现存 7 处 `\|\| echo` 全部位于环境探测块(`:69-74`,Node/npm/git/ssh/rsync/curl 是否存在),`:165` 有注释自述历史吞错已移除;lighthouse 阶段 `:249`、axe 阶段 `:390-392` 已在流水线内 |
| A-6 `@critical` GA4 自证空测 | P0 | **已闭环(逐行复测 + 本轮产物目标实测)** | 自证模式已移除并换成真实拦截:`gotoWithGtag` 用 `page.route` 桩掉 `googletagmanager.com`、`waitForRequest` 抓应用自己发出的 `gtag/js?id=` 并从 **URL 参数**取 `measurementId`(`:44-61`),断言对象是应用推入 dataLayer 的调用(`:128-138` `config` 次数=1、`anonymize_ip:true`;`:183-196` 表单 `form_submit` + `conversion` 的 `event_label`/`value`/`currency`/`transaction_id` 类型;`:257-258` 路由 `page_title`/`page_location`)。`__gtagCalls` 只剩文件头注释(`:7`,作为反例记录)。**`test.skip(measurementId === null)`(`:124,152,202,237`)说明该组仅在 `E2E_TARGET=production` 下真正跑,本轮未执行 E2E** |
| A-7 覆盖率门禁形同虚设 + 三处文档口径互斥 | P0 | **已闭环** | 阈值单一真源 = `config/test/jest.config.js` 的 `coverageThreshold`(根 `jest.config.js` 仅转发);本轮目录级阈值真的把门判红了(§1),反证其可约束性 |
| A-8 单测不接触真实数据层 / `test:integration` 命名误导 | P0 | **已闭环** | 新建 `tests-integration/*.itest.ts` + `config/test/jest.integration.config.js`(一次性临时 SQLite + 项目库 inode 守卫);脚本改名收口:`test:integration:mocked`(原 mock 版)/ `test:integration:real`(真库层),并接入 `test:all`、AGENTS.md §5、CLAUDE.md:81 |
| A-9 视觉基线追认现状 / 近空白基线 | P0 | **部分复测:只读比对已跑(§1 视觉行)** | 实测口径更正:`e2e/visual-snapshots` 共 **107** 张 PNG(5 个 project 目录各 21 张 + 目录外 2 张),`--list` 为 **25 例/project × 5 project = 125 例**(此前记录的「270 例/project × 4 project」不成立)。只读比对结果 **104 通过 / 21 失败 / 0 例像素不符** ⇒ **现存参照与当前树逐像素一致,"基线被无声追认了错误现状"这一原始症结本轮未复现**。但暴露两个更硬的事实:**(a) 磁盘基线 ≠ 已提交基线**——85 张 PNG 相对 HEAD `f543e47` 处于 modified,mtime 全部落在 **2026-09-22 02:37–02:56**,即提交(2026-09-20 11:32)之后两天被重新生成且从未提交,任何人从 HEAD 检出的参照都不是本轮所比的那个(详见 §7);**(b) `f543e47` 的自述与现状矛盾**——该提交标题为「以规范模式重生成**全站**视觉基线并补齐 firefox 缺口」,而 `/products/erp-upgrade` 与 `/about/brand` 两页在**全部 5 个 project** 下都无基线 ⇒ 覆盖空洞在提交时即存在。追认 85 张差异是否合规仍需 `--update-snapshots` 授权 + 人工核看(§5 第 3 项) |
| A-10 E2E 从不验证交付物 | P1 | **已执行并实测通过(写库用例除外)** | 本轮以 `E2E_TARGET=production` 打**装配后的 standalone 产物**跑 `@smoke\|@critical\|@journey` × 4 project,**92 例全通过、EXIT=0、0 例 skipped**(2.3 分钟,日志 `docs/acceptance/2026-09-23-gates/e2e-prod-finaltree.log`)。**关键增量**:AGENTS.md 一直声明「16 个 GA4 用例在 dev 目标下是 skipped 而不是 passed」——本轮在生产目标下这 16 例(TC-GA4-001..004 × chromium/chromium-mobile/firefox/webkit)**逐例真实断言且通过**,日志内 `skipped` 计数为 **0**,故 A-6 的 GA4 覆盖面第一次拿到产物目标凭据。**排除项与代价(如实披露)**:仅跑了 **7 个 spec / 23 例每 project**,按文件路径 `--grep-invert` 排除了 `cms-workflow.spec.ts` 与 `user-journey.spec.ts`——**这两个文件才是真正写库的**(各有 9 处 `request.post/put/delete` 打 `/api/admin/items`、`/api/admin/media`、`/api/auth/login`、`/api/cms/revalidate`),合计 **11 例未执行**,仍需 §5 第 2 项授权。**顺带纠正一处文档假事实**:`README.md:174` 与 `AGENTS.md:158` 称生产目标是「`E2E_TARGET=production` → `npm run start`」,而 `e2e/playwright.config.ts:132-135` 实为 `HOSTNAME=localhost PORT=3000 node dist/standalone/server.js`(`CLAUDE.md:46` 与 `docs/testing.md:276` 写法正确,且后者已注明「非 `next start`」)——standalone 下 `next start` 不受支持,按错误口径执行会测到降级路径 |
| A-11 两道 a11y 门禁双盲 | P0 | **部分闭环,且新增第三实例** | `check:contrast` + 新增 `check:brand-token` 本轮 0 违规;axe 门禁的 `bgMismatch` 判据成功抓到「样式未加载」而非报绿(判据有效),但暴露本地跑法缺陷 N-9 |
| A-12 Cookie 条隐私链接 3.31:1(全站) | P0 | **已闭环** | `grep -rEo 'text-\[var\(--color-brand\)\]' src --include='*.tsx'` = **0 命中**(原 67 处),文字通道统一 `text-brand-ink` |
| A-13 / A-14 `lerp` 恒 `start=0` / `randomBetween` 只断边界 | P1 | **测试数据缺陷已修,变异体未重跑** | 两处致盲根因已消除并留有指名验收号的注释:`lerp` 新增全非零 `start` 用例(`utils.test.ts:156-159`,`lerp(10,20,0.5)=15` —— 原四例 `start` 恒 0,使 `end-start` 与 `end+start` 数值相同不可观测);`randomBetween` 改为 `spyOn(Math,'random')` 钉住线性映射(`:118-127`,`0→1`、`0.5→5.5`、`0.25→…`,算子由 `*` 改 `/` 会立即偏离)。⚠ **未重跑 Stryker**,故「这两个变异体已被杀」是由用例算术推得的结论,非实测;且 `test:mutation*` 带 `--inPlace` 会直改工作树,列为待授权项 |
| A-15 变异门禁排除「零编造」核心 | P1 | **已闭环** | `stryker.config.json:7-13` 的 `mutate` 现为 `src/lib/**/*.ts` 等白名单,未见 `!src/lib/constants/**` 排除 |
| R-1 双重移动端安全区留白 ~190px | P1 | **已闭环** | 组件侧 `pb-[calc(8rem+env(...))]` 已不存在,仅 `src/app/globals.css:1332` 单处 |
| R-2 动效收敛到 300ms 且注释错引契约 | P1 | **已闭环** | `duration-300` **0 命中**(原 108 处),改用 `duration-normal`/`duration-fast` 令牌 |
| R-3 新闻页暗色对比度 | P1 | **已闭环** | 并入 A-12 整族收敛,`--color-brand` 文字通道 0 命中 |
| R-4 蓝色硬编码 + 品牌红光晕 | P1 | **已闭环** | `src/components/detail/solution-service-card.tsx:128-129` 为 `bg-accent-blue-soft` / `text-accent-blue`,`#1d4ed8` 与 `blue-*` 工具类已消失 |
| R-5 `prefers-reduced-motion` 未被 framer-motion 尊重 | P1 | **已闭环** | `src/components/ui/motion-provider.tsx:8` `<MotionConfig reducedMotion="user">`,一处收口 |
| B-1 创建接口绕过发布工作流 | P1 | **已闭环(本轮主修项)** | 见 §3 N-4/N-5/N-6;`test:integration:real` 的「B-1:POST 携带 `status:"published"` 不得绕过工作流」本轮通过 |
| B-3 `basis` 写入侧无校验 → 残余为「枚举拦得住格式、拦不住说谎」 | P1 | **写入侧校验已落地;说谎面如实记录** | 原判定「`basis` 仅类型约定、写入侧无校验」**已不成立**:`metricBasisField` 以 `type:'select'` + 三项 `options`(值恰为 `METRIC_BASES` = `target`/`team-history`/`verified`,`content-types.ts:8-18`)铺进 **8 个**内容类型的指标字段(`:184,207,385,408,572,595,781,1207`),`validateContentData` 会递归进数组元素(`validate-content-data.ts:113-121`),POST/PUT 两侧 `fieldErrors.length > 0` 即 400(`items/route.ts:144-147`、`:228-232`)。负向对照已在库:伪造 `basis:'customer-validated'` ⇒ `path:'metrics[0].basis', rule:'option'`(`validate-content-data.test.ts:73-79`)。**未解决面**:`basis:'verified'` 本身是合法枚举值,写入侧无法证伪「声称有实测出处」—— this 是治理缺口不是格式缺陷,itest 已如实声明,不冒充已修 |
| B-4 脏 `data` 列让读取路径崩溃 | P1 | **已闭环** | `parseCmsData` 收口至 `items`、`items/[id]`、`workflow` 三个路由;itest 脏列例(5 种脏值 + workflow 500)本轮通过 |
| B-7 侧效应:角色标签错显 | P2 | **已闭环** | `src/components/admin/admin-layout.tsx` 模块级 `ROLE_LABELS`,取值对齐 `prisma/seed.ts` 与 `refresh/route.ts:31` 的真实 role code |
## 3. 本轮新增发现
| 编号 | 级别 | 发现 | 状态 |
|---|---|---|---|
| N-1 | P1 | **JSON-LD 未做 `</script>` 闭合防护**:`structured-data.tsx` 7 处 `dangerouslySetInnerHTML={{ __html: JSON.stringify(schema) }}`,schema 含可控文本时可提前闭合 `<script>` 注入 HTML(存储型 XSS 面) | **已修**:新增 `jsonLd()` 把 `<`/`>` 转义为 `\u003c`/`\u003e`(JSON 语义不变);`structured-data.test.tsx` 用 `</script><img src=x onerror=alert(1)>` 载荷断言序列化结果不含裸 `</script>` 且 JSON 可回读(修复前该例为红) |
| N-2 | P2 | 覆盖率配置仍引用已删除目录 `src/components/cms/**`,使该目录的门禁条目永不可约束(假绿条目) | **已修**:条目删除 |
| N-3 | P2 | `docs/cms/api-contract.md` 宣称后台表单支持 richtext 编辑器,实际 `renderField` 无该分支(落 `default:` 文本框),TipTap 与 `.rich-text-editor-content` 为死代码 | **已修**:§10.4 按现实重写,死 CSS 一并移除 |
| N-4 | **P0(功能性)** | **后台编辑器的每一次保存都会 400**:编辑页 `performSave` 总把 `status` 放进 PUT 载荷,而 PUT 拒收任何 `status` 字段 —— 「保存」与「自动保存」在真实环境全部失败 | **已修**:保存载荷不再带 `status`;PUT 只在 status **被改变**时拒绝(原样回传放行)。回归由新建的 `src/app/admin/content/[modelCode]/[itemId]/page.test.tsx`(11 例)钉住:断言 POST/PUT 实际入参对象**不含 `status` 键** |
| N-5 | **P0(功能性)** | **「发布」按钮不可能生效**:UI 走 `updateItem` 直接写 `status:'published'`,既被 PUT 拒绝,又绕过 `publish` 权限校验(与 B-1 同一枚硬币的两面) | **已修**:`adminApi.runWorkflow` 为唯一写状态通道;确认发布后按 draft→`submit`→`approve` 流转,无 `publish` 权限者停在「待审核」并如实提示 |
| N-6 | P1 | 表单里的 `状态` 下拉框(草稿/已发布/已归档)任何改动都必然 400 —— 一个永远不能落库的控件;且缺 `review` 态 | **已修**:改为只读状态展示 + 模块级 `STATUS_LABELS` 四态,附一行说明为何移除;测试断言表单内不存在任何 `select`/`combobox` |
| N-7 | P2 | `approve` 失败时 UI 仍显示「草稿」,而服务端已被 `submit` 推进到 `review`(子代理写测试时实测到的状态回写缺口) | **已修**:`submit` 成功后立即 `setStatus`,403 分支断言「待审核」 |
| N-8 | P2 | `check:brand-token` 对 `src/components/ui/loading-state.tsx` 的裸 `var()/80` 死样式**做了白名单降级**(理由:该组件无页面引用、同族正被清理)。降级本身诚实可见,但门禁留了一处已知豁免 | 保持现状 + 本报告披露;建议随 `loading-*` 死组件族一并删除 |
| N-9 | **P0(门禁可信度)** | `next build` 的 standalone 产物**不含 `dist/static` 与 `public`**(Dockerfile:5-9 已写明需手工拷贝,镜像用 `COPY` 满足),CI 的 axe 阶段自己做了拷贝(`Jenkinsfile:350-355`,注释还写明「缺了它们对比度会意外达标」),但**本地跑法没有任何拷贝步骤**:`npm run lighthouse` 与手工 `check:axe` 直接对着未装配的 standalone 起服务 ⇒ 全站 CSS 404;`check:axe` 靠 `bgMismatch` 报红,而 `npm run lighthouse` 在样式全丢的裸 HTML 上 **exit 0** | **已修**:新增 `postbuild` 脚本,按 Dockerfile:53-57 同构把 `dist/static → dist/standalone/dist/static`、`public → dist/standalone/public`;修复后实测:`curl` 取 `/_next/static/chunks/05m0199uz1s5s.css` 从 404/9 字节变为 **200 / 105607 字节**;axe 与 lighthouse 重跑见 §6。⚠ 复验须换端口——上一版进程仍占 3101 时会给出修复前的假 404(本轮实际踩到) |
| N-10 | P3 | 澄清而非缺陷:`k6@0.0.0` 是 LoadImpact/Grafana 官方「Dummy package for autocompleting k6 scripts」编辑器包(`node_modules/k6/package.json` 自述),非占位错装;`npm run test:performance` 需另装 k6 CLI。`docker-compose` 依赖的 `.env.production` 本机**确实存在**(此前记录的「缺失」不成立) | 记录,无需动作 |
| N-11 | P2 | **`/contact` 有一处 CSP 违规**:修复 N-9 后的 Lighthouse 重跑中,9 个 URL 的 best-practices 全为 100,唯独 `/contact` 为 **96**,唯一失败项是 `inspector-issues`,内容只有 `{"issueType":"Content security policy","subItems":{"items":[]}}` —— **Lighthouse 不给指令名与被拦资源**,无法从报告本身定因。已排除:a11y 无关(该页 100)、与 `'unsafe-eval'` 无关(当前 CSP 已不含该指令)。风险面:若被拦的是表单提交链路上的脚本,则是**静默功能失效**而非仅评分问题 | **已修(源码根因 + 同仪器 A/B 双取证)**:根因不是本站脚本,而是 **Zod 4.4.3 在模块求值时的 eval 能力探测**——`node_modules/zod/v4/core/util.js:145-162` 的 `allowsEval` 用 `new Function("")` 试探,throw 被库自己吞掉,但浏览器仍就地点报一次 `securitypolicyviolation`;库在源码注释里亲自写明这一现象(`:146-147`:「strict CSPs report the caught `new Function` as a `securitypolicyviolation` even though the throw is swallowed」)。命中面与观测完全吻合:全仓仅 3 个文件 import zod,`src/app/api/contact/route.ts` 是服务端(Node 无 CSP),`src/components/ui/form.tsx` 虽是 `'use client'` 但**零引用者**(本轮新增死组件,见 §4.4),故浏览器侧只有 `src/app/(marketing)/contact/contact-content-v3.tsx`(`'use client'` + `:34` 模块级 `z.object`)会求值 ⇒ 9 个 URL 里只有它报。**修法用库认可的开关**:`z.config({ jitless: true })`(公开且有类型:`node_modules/zod/v4/core/core.d.ts:66-70`,注释即「Useful in environments that disallow `eval`」),落在 `contact-content-v3.tsx:32`(schema 之前)。**行为零变化**:`jitless` 只让 `allowsEval` 直接返回 false,而 CSP 下该探测本就抛错返回 false ⇒ 唯一依赖它的 JIT 快路径(`schemas.js:971-972` 的 `fastEnabled = jit && allowsEval.value`)修复前后同样处于关闭状态。**且这一点是实测而非推断**:以 21 组输入(含 CJK/emoji/换行的边界长度、缺键、`null`、错类型、`__proto__` 污染对象)分别过 `jitless:true` 与 `jitless:false` 两份同构 schema,逐条比对 `safeParse` 的成功值与 `issues` 的 `path`+`code`+`message` 序列化结果 ⇒ **21 组全等,diffs=0**。**同仪器 A/B**(Lighthouse `inspector-issues`,也就是当初发现它的审计):修复前 `lighthouse-reports/contact-2026_09_23_04_54_06.report.json` = score 0 / 1 item / best-practices **96**;修复后独立重跑 = score 1 / 0 items / best-practices **100** ⇒ **9/9 URL 全部 100**。复跑门禁:`type-check` EXIT=0、`lint` EXIT=0 且仍是 105 warnings / 0 error(未新增告警)。附带澄清一条误报:GA4 **没有**被本站 CSP 拦——真实浏览器下 `/contact` 的 `gtag.js` 200、三条 `/g/collect` 全部落在已放行的 `https://www.google-analytics.com` 并返回 **204**;此前记录的「疑似 `www.google.com/g/collect` 被 `connect-src` 拦掉」**不成立**(该域在 gtag.js 里出现 17 次,但本次真实加载未向其发 collect;`ad_storage: 'denied'`(`GoogleAnalytics.tsx:97`)压制了 Ads 侧目的地)。**遗留条件风险**:若将来接入 consent 管理并把 `ad_storage` 放开,`www.google.com` 可能成为 hit 目的地而被拦——届时评估扩 `connect-src` 是部署决策,不在本轮顺手放宽。 |
| N-12 | P1(门禁可信度,自查发现) | **子代理补测的测试自身把门禁跑红**:新增的 `src/components/sections/hero-particle-field-engine.test.tsx:373` 在 `finally` 里用 `Object.defineProperty(window,'requestAnimationFrame',{configurable:true,value:savedRaf})` 还原全局 —— **`defineProperty` 未写 `writable` 时默认 false**,于是该属性变成只读;随后 `afterEach` 中 `rafSpy.mockRestore()` 走赋值还原 ⇒ 抛 `TypeError: Cannot assign to read only property 'requestAnimationFrame'`,**整套件判 failed to run、EXIT=1**,而 `Tests:` 行仍显示全部通过(该套件 25 例根本没跑)。定位方式:隔离跑该套件(0 测试通过 + 0.956s ⇒ 证明非并发污染)、再按 `-t` 单例复现(只有该例失败,其余 24 例跳过仍绿) | **已修**:补 `writable: true` 并写明原因。复验:单例 EXIT=0、整档 **25/25 通过**、全量 **132 套件 / 1677 例 EXIT=0**。教训已入 §6 纪律:子代理产物一律逐档隔离复跑,`Tests: N passed` 不等于套件跑全 |
| N-13 | P2(门禁可信度,自查发现) | **同一处 CSP 违规对两个审计是「一显一隐」,且自造浏览器探针无法对它做正向对照**:(a) N-11 那一轮 `/contact` 的 `errors-in-console` 给出 score **1** / items **0**(判为干净),而 `inspector-issues` 给出 score **0** / items **1**——CSP 违规只走 DevTools issue 通道,看控制台的审计看不见它;(b) 我先写的 Playwright 探针(`document` + `window` 双挂 `securitypolicyviolation`)在修复后的页面上采到 **0 违规**,但它的正向对照**失败**:经 CDP 注入的 `page.evaluate(() => (0,eval)('1+1'))` 与 `addScriptTag({content})` 里的 eval **既不抛错也不报违规**(Chromium 不对调试器注入的代码施加 `unsafe-eval` 判定),所以「0」当时无法区分「真干净」与「探针看不见这一类」。校准实验同时证明监听器**对子资源类是有效的**:同一 harness 里 `connect-src` 与 `script-src-elem` 两条违规被 document/window 两个挂点都采到 | **已按此纠正验证方式**:N-11 的结论不建立自造探针的「0 违规」上,而是建立在**发现它的同一仪器**(Lighthouse `inspector-issues`)的前后 A/B 上(96→100、items 1→0)。纪律补两条:① 自建探针的正向对照必须在**同一违规类别**上成立,跨类别采到事件不等于该类可采;② 报告「0 违规/0 问题」前须先证明探针能采到非零,否则该 0 记为「未证」。这也是 A-11「门禁看不见它要保护的东西」家族的第四个实例。**并已补上真正的门禁**:`config/test/lighthouserc.json` 的 `assert.assertions` 新增 `"inspector-issues": ["error", {"minScore": 1}]`——此前唯一相关的断言是 `categories:best-practices >= 0.9`,而 `/contact` 的 96 分**恰好从它下面溜过去**(0.96 > 0.9),等于没有。**该新断言做过存活证明**(避免 §5.25 的「死断言恒绿」):把修复前那份 `contact-2026_09_23_04_54_06.report.json`(`inspector-issues` score 0)单独放进临时 `.lighthouseci/lhr-*.json` 喂给 `lhci assert`,实测输出 `Checking assertions against 1 URL(s), 1 total run(s)` → `✘ inspector-issues failure for minScore assertion / expected: >=1 / found: 0` → **EXIT=1**。⇒ 这条规则是活的,会在同类回归上判红。⚠ 附带工具链坑:`lhci assert --assertions='{...}'` 的**行内 JSON 形式会被错误解析**(把值当成 audit 名,报 `"15" is not a known audit` 且**仍以 0 退出**),必须走 `--config=<file>`;用行内形式做「断言是否生效」的自检会得到假的通过 |
| N-14 | P3(死资源提示 / 误导性装配) | `src/app/layout.tsx:192-193` 对 `//formsubmit.co` 发 `dns-prefetch` + `preconnect`,但**浏览器从不联系该源**:FormSubmit 只在 `src/app/api/contact/route.ts:84` 由 **Node 运行时**调用(CSP 不管服务端),客户端唯一动作是同源 `POST /api/contact`(`contact-content-v3.tsx:181`)。五路独立取证:全仓 `formsubmit` 仅 4 文件 9 行(其余为 GA4 `form_submit` 事件的同名变量与文档);`src` 内两个 `<form>` 均**只有 `onSubmit` 无 `action`**;无任何 `'use client'` 文件 fetch 绝对 URL;无任何 `NEXT_PUBLIC_*FORM*` 常量可拼出该目标;`src/lib/sanitize.ts` 的白名单无 `form`/`action` 且 `allowProtocolRelative:false` ⇒ CMS 注入路亦封死。**且当前 CSP 本就拦得住它**:`connect-src`(`next.config.mjs:31`)不含 formsubmit,`form-action 'self'` ⇒ 即便存在浏览器侧提交也会被拦。git 史确证为迁移残留:`46cff87`(2026-04-21) 浏览器直连 FormSubmit → `f08874f`(2026-05-11) 加这两行提示(当时有效)→ **`415a103`(2026-06-17) 把 formsubmit 一次性搬进 `route.ts`**,提示自此成为孤儿,已腐烂约 4 个月。副作用面:48 个预渲染文档每页都对一个永不使用的第三方源预热连接 | **仅记录,本轮未改**。删除是安全的(Jest/Playwright/Lighthouse 断言中**无任何一处引用 preconnect/dns-prefetch**,故不会因删它而判红)——但**同理也没有任何东西保护它**,静默留着等于无人看管。**刻意不顺手改**:① 它是行为保持型清理而非缺陷修复,与"提交前须逐项过门禁"的收尾阶段无关;② 一旦在只读视觉比对进行中改 `layout.tsx`,dev server 热更新会让那一轮跑在一个我看不见的树上(§3 N-15 的教训正来自"测的还是不是当前代码")。**同一处真正的反向缺口一并记录**:浏览器**确实**全站联系 `www.googletagmanager.com` / `www.google-analytics.com`(Lighthouse 网络日志实证),却**拿不到任何提示**——补这两个提示是独立的性能决策,不应与清理捆绑 |
| N-15 | P1(测试装配可信度,本轮实测新发现) | **视觉回归的 L2/L1 有 21 例是恒定失败的坏测试,其中 20 例从未绿过、也没有参照**:(a) `visual-regression.spec.ts:130` 卡片用例在 `/products` 用 `[class*="card"]` ⇒ `element(s) not found`,该页无此 class 子串,`product-card.png` 从未存在;(b) `:148` 输入框用例的 `input[type="text"], input[type="email"]` 经 `.first()` 命中**反垃圾蜜罐** `<input type="text" tabindex="-1" name="website" aria-hidden="true" aria-label="honeypot">`(按设计隐藏)⇒ `Expected: visible / Received: hidden`,5/5 project 必失败,`input-default.png` 从未存在;(c) `:27-42` 的页面清单含 `/products/erp-upgrade` 与 `/about/brand`,但两页基线在 5 个 project 下**全部缺失** ⇒ 只读模式判红,而 `f543e47` 的提交标题自称「重生成全站视觉基线」。**蜜罐这一条尤其值得记**:测试选中的第一个 `type="text"` 恰好是安全控件,意味着任何用同样宽松选择器写的用例都在断言一个故意不可见的东西——失败是测试错,不是页面坏 | **仅记录 + 给出精确修法,不做无法验证的盲改**。理由:改选择器只会把「断言失败」变成「参照缺失」,仍红 ⇒ 该修法的**唯一验证路径是 `--update-snapshots` 写基线**,而这项未获授权(§5 第 3 项)。在不能复验的前提下改门禁脚本正是本轮 N-13 批评的行为,故不执行。授权后建议一次做完:① `:130` 换成页面上真实存在的卡片选择器(或给组件补 `data-testid`,与 `:154` 创始人语录用例的既有做法一致);② `:148` 排除蜜罐——`input[type="text"]:not([name='website']), input[type="email"]:not([aria-hidden='true'])`;③ 决定两页全页基线是补还是从 `VISUAL_TEST_PAGES` 摘除(当前是"列了但从不比对"的最坏中间态);④ 复跑 `--update-snapshots=none` 确认失败数从 21 归零而非换一个错误类别。**另记一条我自己的 harness 坑(同族复发)**:该轮以后台命令跑时,包装体形如 `{ npm run test:visual:all -- --update-snapshots=none; echo "VISUAL_EXIT=$?"; }`,**命令组的退出码取自最后一条 `date`** ⇒ 系统通知报「exit code 0」,而真实门禁是 **EXIT=1**。若不是日志里显式写了 `VISUAL_EXIT=$?`,这一轮会被读成绿。规则:包装后台命令时,末位必须是 `exit $rc` 或把结果行作为唯一判据,**不得以通知里的 exit code 作为门禁结论** |
| N-16 | P2(交付口径,本轮实测发现) | **`AGENTS.md` 不在版本控制里**:`git ls-files --error-unmatch AGENTS.md` ⇒ `error: pathspec 'AGENTS.md' did not match any file(s) known to git`,`git check-ignore -v` ⇒ 命中 **`.gitignore:312`**(该节注释即 `# AGENTS`,是**有意**忽略而非疏漏)。对照同族文档:`CLAUDE.md` / `README.md` / `CONTEXT.md` / `docs/lessons-learned.md` 全部 `tracked=yes ignored=no`。**后果**:本报告与历轮报告里所有「已回填 AGENTS.md §5 门禁表」的声明**都不进入 diff**——既不可审、不可回滚,也不会随分支交付给下一个人;而 §6「文档同步清单」恰恰要求更新 AGENTS.md,等于要求改一个提交不上去的文件。叠加风险:该文件页脚自述「written and re-added by `next dev`」,**本轮已核实为真**(`node_modules/next/dist/server/lib/generate-agent-files.js:2` 注释即 "Auto-generate AGENTS.md / CLAUDE.md with the managed Next...",并含 `LEGACY_AGENT_RULES_START_MARKER` 标记),故 `next dev` 可能重写其内容 ⇒ 写进其中的门禁口径存在被覆盖丢失的可能 | **仅记录,本轮未改**。三种处置各有代价,且都超出"顺手改"的边界:① **从 `.gitignore` 摘掉 `AGENTS.md`** 使其入库——但它会被 `next dev` 反复改写,一旦入库就会**长期产出脏 diff**(这正是当初忽略它的合理动机);② **保持忽略,把权威口径迁到已跟踪文件**(`CLAUDE.md` 或 `docs/development/quality-gates.md`),`AGENTS.md` 降级为本地便签——与现状最一致,成本是历史声明需逐条搬迁;③ **改为托管块写法**,只把项目自定义内容放在 marker 之外。**本轮采取的动作**:把 N-15 视觉门禁的判读口径**同时写入已跟踪的 `docs/lessons-learned.md` §5.29 与本报告 §1/§3**,确保这份知识在 diff 里存在一份,不受 ①②③ 选择影响;`AGENTS.md` 内的那条仅作本地便签,**不计入交付物** |
| N-17 | P2(门禁可信度,A-6 同族) | `e2e/security-headers.spec.ts:364`「静态资源通过 HTTPS 加载」在 `baseURL` 以 `http://localhost` 开头时**裸 `return`**——而 harness 的 baseURL 恒为 `http://localhost:3000`(`playwright.config.ts:27`,dev 与 production 目标同值)⇒ **该例在本地与产物目标下都不可能执行任何断言,却被计入 passed**(本轮 `@security` 跑 72 passed 中有 4 例是这种条件空转,日志里以 `⏭️ 本地开发环境(HTTP),跳过 HTTPS 资源检查` 自述)。这与 A-6「GA4 自证空测」是同一形状,只是它连 `test.skip` 都不打 | **已修**:改为 `test.skip(condition, '…(N-17)')`,让它显式记为 skipped 而不是伪装通过。判据与实测**:修复前 `@security` 为 **72 passed / 0 skipped**;修复后同命令复跑 ⇒ **68 passed + 4 skipped,EXIT=0**(日志 `docs/acceptance/2026-09-23-gates/e2e-security-prod-after-n17.log`),四条空转如期从"伪装通过"变为"显式跳过"。HTTPS-only 断言本身仍只能打真实 HTTPS 部署,属 §5 之外的对外请求授权 |
| N-18 | **P1(开放重定向,已确认并修复)** | `src/app/api/cms/draft/disable/route.ts` 把 `body.redirect` **原样**交给 `NextResponse.redirect(new URL(redirect, request.url))`,无任何校验;而姊妹路由 `draft/enable` 早在 B-6 就加了 `isInternalRedirect()`——该函数当时定义在 `enable/route.ts:9` 内、**私有不可复用**,于是同一缺陷在姊妹路由中复活。该端点**无鉴权、无 secret**,且第一方代码零调用者(孤儿但对外暴露) | **已修 + 回归测试 + RED 实测**:抽出 `src/lib/cms/internal-redirect.ts` 供两条路由共用;`disable` 采用与 `enable` 相同的顺序——**先校验后产生副作用**(非法请求不再 `draft.disable()`)。新增 `disable/route.test.ts`(7 例:无 redirect→200、站内路径→307、`https://evil`/`//evil`/`/\evil`/`javascript:` 四种载荷→400 且 disable 未被调用)。**RED 探针为实测而非推断**:临时移除该 guard 后复跑,4 条攻击用例全部 `Expected: 400 / Received: 307` 判红,两条合法用例仍绿 ⇒ 修复前确实会向站外 307,且修法无误伤 |
| N-19 | **P1(越权读取,已确认并修复)** | `src/app/api/admin/stats/route.ts:9-10` 只用 `authenticateRequest`(= 「有没有有效会话」),随后 `:49-59` 返回 `prisma.contentItem.findMany({ take: 5, select: { title, modelCode, status } })`——**跨全部模型**的最近内容(含草稿标题与状态)。任何登录账号(包括 `userRole` 为空、零权限的历史账号)都能读到。对照同族:`admin/items` GET 用 `requirePermission(…, 'read')`(`items/route.ts:79-81`)、`admin/models` GET 用 `requirePermission('content-model','read')` ⇒ 这是复制守卫时漏掉权限检查的那一份(全仓 7 个 handler 走内联 role 数组而非 `requirePermission`,stats 是其中掉了角色检查的一个) | **已修**:改为 `requirePermission(request, 'content-model', 'read')`。**与 `admin/models` GET 同权限**是刻意选择:仪表盘既然已经要能列模型,用同一道门就不会新引入 403 面。复验:`type-check` EXIT=0、`test:unit` **133 套件 / 1691 例 EXIT=0**、`lint` 0 error / 105 warning(未新增告警) |
| N-20 | **P1(停用账号仍可用,已确认并修复)** | `src/lib/auth.ts:7` `TOKEN_EXPIRY = '24h'`;`authenticateRequest` 只验签名(`auth.ts:71-81`),`checkUserPermission`(`permissions.ts:33-43`)只查 `userRole` + `permission`,**从不读 `User` 行**。而 B-7 的账号状态校验**只加在了 `/api/auth/refresh`**(`auth/refresh/route.ts:23-25`,注释自述「被禁用/删除的账号可在 7 天内继续换取访问令牌」)。⇒ 通过 `PUT /api/admin/users` 置 `status: 0` 停用某账号后,其**已签发的访问令牌在 24 小时内仍能通过每一个 guarded 路由**(读写皆可),修复只做在了刷新链路上 | **已修**:在 `checkUserPermission` 入口先 `prisma.user.findUnique({ select: { status } })`,`!account \|\| account.status === 0` 即 `return false`,且**位置刻意早于 `roleCodes.includes('super_admin')` 短路**(否则被停用的超管令牌仍畅通)。补 3 条用例(停用+有权角色→false 且不再查角色;账号行不存在→false;停用的 super_admin→false)。**RED 实测**:临时删除该 guard 后 3 条用例全部判红(`3 failed, 20 passed`),恢复后 `23/23` 通过。代价:每个受守卫请求多一次 `findUnique`(SQLite 本地实测无感) |
| N-21 | P2(防御纵深,**未修,留决策**) | 两条相关但未在本轮动刀的:① `items/route.ts:79` `const permissionModelCode = modelCode \|\| 'content-item'`——省略 `modelCode` 时以字符串 `'content-item'` 的 read 权限放行**全部模型**,而该 modelCode 在 `src/` 与 `prisma/` 里**别处不存在**(唯一命中);`roles/route.ts:84-85` 又接受任意 `perm.modelCode` 字符串 ⇒ 一次拼错的授权即等价于全站内容读权限。② 全仓**无 `middleware.ts`**(`find . -name middleware.ts` 无结果),`src/app/admin/layout.tsx:1` 是 `'use client'`,登录跳转发生在客户端(`admin-layout.tsx:151`、`auth-context.tsx:79`)⇒ `/admin` 的 HTML 外壳在无会话时也会先下发。本轮实测:admin 页面数据全部来自已加守卫的 API,故 ② 只暴露外壳、不泄露数据,属防御纵深而非可利用缺陷 | **未修,仅记录**。① 需要决定「跨模型列举」这个动作对应的权限语义(新增 modelCode 还是显式 `'*'` 权限),属产品/权限模型决策,不能顺手改常量;② 加 `middleware.ts` 会改变所有 `/admin/*` 请求的渲染路径与现有 E2E 的登录前置,须独立评估。另记一条 `draft/enable` 设的草稿 cookie **没有任何 `draftMode()` 消费者**(预览链路是死代码,不构成绕过,但属无人验证的攻击面) |
| N-22 | **P1(安全门禁整体失效,已修 + 双向对照实测)** | 派 subagent 审「谁来测门禁」时命中 `scripts/utils/check-security-headers.ts`(该脚本是 `npm run test:all` 的一环)的四处**恒不成立断言**,我逐条读源码复核确认:① `checkCookies` 里 `status: 'pass' as const` 是**字面量**——上面三个 `hasHttpOnly/hasSecure/hasSameSite` 全算完就地丢弃;② 汇总的 `hasFailures` **只看 `headerChecks`**,于是 ① 的 cookie 永不判失败也永不影响退出码(`cookieFailed` 只为打印而存在);③ `X-XSS-Protection` 写的是 `return val ? 'warn' : 'warn'`,**两个分支完全相同**;④ **缺 CSP 只 `warn`**(而同族 `X-Content-Type-Options` 缺失判 `fail`)⇒ 把整条 CSP 删掉也无法让这个"安全检查"变红。合起来:这个安全头门禁对 CSP 与 cookie 两条最重要的判据**永远不会失败** | **已修 ①②③④ 并追加 ⑤**:cookie 状态改为按 flag 推导(会话类 cookie 缺 `HttpOnly`/`SameSite` ⇒ `fail`,其余缺失 ⇒ `warn`),`hasFailures` 纳入 cookie 失败,死三元改为「无该头 ⇒ pass(现代浏览器已不需要,与原 description 自述一致)/ 有但值异常 ⇒ warn」,缺 CSP ⇒ `fail`。**⑤ 是正向对照逼出来的**:首次在本地 standalone 产物上跑修后的门禁 ⇒ `EXIT=1`,唯一红项是 `Strict-Transport-Security` 未设置——**HSTS 是 Nginx/CDN 在边缘注入的**,于是发现该门禁**结构上不可能在分支产物上通过**、只能打线上(与 N-23 互为因果)。据此把 HSTS 改为「仅在 `https:` 目标上缺失才判 fail,http 目标下降为 warn」。**双向对照均为实测**:负向(`python -m http.server` 起的无 CSP 服务)⇒ **EXIT=1、4 项 fail**(修复前同样的输入只会 warn 到 0);正向(真实 standalone 产物 :3601)⇒ **EXIT=0、0 失败、`无 Cookie 设置`**。`type-check` EXIT=0。**残留**:本地匿名请求拿不到会话 cookie ⇒ cookie 分支目前仍是"有数据才有效",需带登录态的目标才能完全激活 |
| N-23 | **P1(门禁测错了对象,未修,需决策)** | `check-security-headers.ts:34` 的默认 URL 是 **`https://novalon.cn`**,而 `package.json:56` 的 `test:security:headers` 不带 `--url` 调用它,并被 `test:all`(`package.json:48`)与 `Jenkinsfile:293-294` 引用;`Jenkinsfile` 那两行上方打印的正是「🔒 检查**本分支构建产物**的安全响应头」。**声明与行为相反**:CI 打的是**已经部署的线上站点**,不是当前分支的产物 ⇒ `next.config.mjs` 里删掉一条头,CI 仍然全绿,直到部署之后才(也许永远不会)暴露。这解释了 N-22 里 HSTS 的现象,也让"这个门禁保护什么"这个问题没有答案 | **未修,留决策**(三项都在等授权或等口径选择):① 改默认行为(CI 下强制要求 `--url` 或默认 `http://localhost:PORT`)会改变 `test:all` 与流水线的语义,属口径变更;② 若要保留打线上,必须**改掉那句误导性打印**并在 `AGENTS.md`/`docs` 里写明"此项验的是线上而非本分支";③ 打线上属**对外请求**,本轮未获授权,故连一次实测都没做,线上下游是否真的注入了 HSTS 亦**未验证**。建议顺序:先 ②(纯文档,零风险),再 ①(独立动作),③ 单独授权 |
| N-24 | P2→**部分已修**(门禁范围性空洞) | 同一轮审计的次级结果,四条均有源码佐证:① `axe-node-count.mjs` 的分母自检很硬,但**逐页不断言状态码**——聚合保险丝 `push(s.okResponses > 0, …)` 只在**全部**响应非 2xx 时才炸,单个页面变 404(standalone 的 not-found 仍套应用布局,背景色与令牌一致、节点数近零)会**计入绿**;② `check-heading-hierarchy.ts:157` 的 `page.goto` 不因 404/500 抛错,Next not-found 只有一个 h1 ⇒ 判"层级正常",且 `waitForServer` **接受 404 作为就绪信号**(`:90` 原为 `if (res.ok 或 res.status === 404) return;`)⇒ 若 :3000 被别的服务占着,它量的是错对象;③ 同文件 `:47` 用 `spawn('npm', ['run','preview'])` 起服务,而 `package.json:16` 的 `preview` 就是 `next start -p 3000` ⇒ **本项目是 `output: 'standalone'`,Next 16 明确告警 `"next start" does not work with "output: standalone"`**,即该门禁一直跑在不受支持的降级路径上;④ `check-pr-checklist.sh` 的 `--pr-dir` 目录不存在时 `exit 0`、目录内无 `*.md` 时 `violations` 为空 ⇒ **空转通过** | **②③④ 已修并双向对照实测**:② `waitForServer` 去掉「404 也算就绪」那半个条件(只认 `res.ok`)+ 逐页 `response.status() >= 400` 即记为失败页并计入 `totalIssues`(给 `HeadingIssue.type` 增加 `"http_error"` 而非塞字符串,保持联合类型闭合);③ 改为**存在 `dist/standalone/server.js` 时直起 standalone**(`HOSTNAME/PORT/NODE_ENV` 显式注入,与 `check:axe`/`Jenkinsfile`/`lighthouserc` 同法),缺失时回退 `preview` **并打印告警**说明该路径不受支持;④ 两处空转都改成仓库既有约定 **`exit 2`=脚本自身/入参故障**(与 `crawl-routes.mjs` 的 `paths.length === 0 → exit 2` 一致),并打印实际扫描文件数。**实测**:④ 四例 `MISSING_DIR=2 / EMPTY_DIR=2 / CLEAN=0 / DIRTY=1`(修复前前两个都是 0);②③ 正向 `10 页全通过 / EXIT=0`,负向(临时往 `PAGES` 塞一条不存在路由)⇒ **`❌ 不存在的页 (/zz-bogus-route): HTTP 404`、失败页面 1、EXIT=1**,探针已复原(`grep -c` 为 0)。`type-check` EXIT=0;`lint` 不受影响(`scripts/**` 在 eslint ignore 内)。**① 未修**(`axe-node-count.mjs` 的 per-route 状态断言),因其判据与 `bgMismatch` 保险丝耦合、改动会影响全站四组扫描的判定口径,留作独立动作 |
| N-25 | **P1(限流可被绕过,已修 + 实测)** | 部署层专项:`src/app/api/contact/route.ts` 的 `getClientIp` 原实现是 `forwarded.split(',')[0]` —— **取 `X-Forwarded-For` 最左项,而那一项由客户端自报**。边缘两侧配置用的是 `$proxy_add_x_forwarded_for`(`nginx-static.conf:101,119`、`nginx-static-production.conf:104`),语义是**追加** `$remote_addr`、**不清除**客户端自带的 XFF ⇒ 脚本每次请求换一个 `X-Forwarded-For: 1.1.1.1 / 2.2.2.2 / …` 就能无限拿到新限流桶,把「5 次/小时」整体绕过;而同时设置的 `X-Real-IP`(`$remote_addr`,Nginx **覆写**、客户端无法伪造)反而**从没被优先读过**。**实测复现**:以旧逻辑喂三条伪造请求 ⇒ 桶为 `["1.1.1.1","2.2.2.2","3.3.3.3"]`,`distinct = 3` | **已修**:抽出 `src/lib/client-ip.ts`(**放这里是刻意的**——`src/app/**` 不在覆盖率棘轮内,留在路由文件里等于没有回归保护),规则改为「优先 `X-Real-IP`;否则取 XFF 链**最右**一项;都缺 ⇒ `unknown`,且空白值等同缺失」。新增 6 例 `client-ip.test.ts`,其中两条直接以攻击者视角写(轮换 XFF ⇒ 桶数必须为 1;X-Real-IP 必须压过伪造 XFF)。复跑:`type-check` EXIT=0、`test:unit` **134 套件 / 1697 例 EXIT=0**、`lint` 0 error / 105 warning(未新增)。**残留(未修,须知)**:`rateLimitMap` 是**进程内 Map** ⇒ 多实例部署下每实例各计一份,实际阈值约为「5×实例数/小时」;真限流要落到共享存储,属独立设计决策 |
| N-26 | **P1(部署层,未修 — 多数须授权或须改部署约定)** | 同一轮部署审计里其余 CONFIRMED 项,逐条我已在源码侧复核:① **密钥进入构建上下文**——`.dockerignore` 只挡了 `.env`/`.env.local`/`.env.*.local`(`:5-7`),**没有挡 `.env.production`**,而该文件实际含 `JWT_SECRET`、`JWT_REFRESH_SECRET`、`CMS_REVALIDATE_SECRET`、`ENCRYPTION_SECRET`(只看变量名,未取值),`Dockerfile:28` 的 `COPY . .` 会把它烘进 **builder 层**(runner 镜像只 `COPY` `dist/standalone`+`dist/static`+`public`,故不在最终镜像里)。**⚠ 显而易见的"一行修复"是错的**:把 `.env.production` 加进 `.dockerignore` 会让 `next build` 拿不到 `NEXT_PUBLIC_GA_MEASUREMENT_ID` / `NEXT_PUBLIC_ENCRYPTION_SECRET`(`NEXT_PUBLIC_*` 在**构建期**内联,见 `Dockerfile:35` 的 `npm run build`)⇒ GA 静默失效,是一次典型"修掉可见症状、制造不可见回归"。正解是把服务端密钥从 `.env.production` 迁到运行期注入、`NEXT_PUBLIC_*` 改走 build-arg,属部署约定变更;② **CI 安装不确定**——`Jenkinsfile:107-110` 先 `rm -rf node_modules package-lock.json` 再 `npm ci`,而 `npm ci` **必须要 lockfile** ⇒ 必然失败并落到 `npm install --legacy-peer-deps`,即 lockfile 在 CI 里从未生效,与 `Dockerfile:25-26`(保留 lock 跑 `npm ci`)解析出的依赖树可以不同。修法是一行(别删 lock,`npm ci` 失败仍有或运算兜底),但**改 CI 属共享状态,未擅动**;③ **字体/图片可能在生产 404 而本地正常**——`nginx-static-production.conf:160-174` 的 `ttf` / `woff2` 与 `svg` / `jpg` 正则块以 `try_files $uri =404` 结尾**没有** `@nextjs` 回退,而同文件的 `/_next/static/`(`:156`)与另一份 `nginx-static.conf:70-90` 都有回退,正则块优先级高于前缀块 ⇒ 磁盘缺文件时直接 404 而不是交给其实持有这些资源的 Node 服务;④ **`/api/` 可能重复 `Cache-Control`**——`nginx-static-production.conf:220` 的 `add_header ... always` 只会追加而非覆盖(该处注释自述知道此事仍保留);⑤ `allowEmptyArchive:true`/`allowMissing:true`(`Jenkinsfile:154,185,193,215,255,399`)⇒ 归档步骤可在无文件时报绿 | **未修,全部留决策/待授权**。理由分组:①②③ 分别触碰**密钥约定、CI 流水线、线上 Nginx**,都在"须单独授权的共享状态"里,且 ① 的错误修法比不修更糟;④ 是已知的边缘行为,改前需确认没有客户端依赖双值;⑤ 是 Jenkins 归档语义,与判定正确性无关但会让人误以为产物存在。**每项都给了最小判据**(不改代码也能证伪):①`grep -n 'env' .dockerignore` 对照 `grep -c 'NEXT_PUBLIC' .env.production`;②读 `Jenkinsfile:107-110` 的先后顺序即可;③`grep 'try_files \$uri =404' nginx-static-production.conf` 对照同文件 `@nextjs` 的落点;⑤对照 `archiveArtifacts` 的 glob 与实际产物目录。**另需真人复验的**:③ 是否已经在生产实际发生(取决于 `/var/www/novalon` 里的同步内容,本轮无部署权限亦不应 `nginx -t`,**未验证**) |
| N-27 | P2(测试装配可信度:无断言用例 + **系统性「条件守卫包住断言」**) | 对 20 个 `e2e/*.spec.ts`(除已审的 visual / security-headers)做逐 test 块扫描:真实 test 块 **145** 个,其中 **5 个块内 `expect(` 计数为 0**。逐个读源码后定性:**① `p4-performance-a11y.spec.ts:343`「焦点管理清晰可见」与 ② `p4:546`「没有混合内容警告」是纯空操作**——前者算出 `hasOutlineStyle` 布尔值后只 `console.log`,后者注释直接写着「只记录不强制断言」,两者**永远不可能失败**(与 N-17 同族);③ `website-acceptance.spec.ts:51` 用 `waitForURL(/\/.+/)` 作隐式断言,有效但极弱(任何路径变化都算过);④ 另两处经复核**不是**空操作:`website-acceptance` 电话用例依赖的 `data-testid="contact-info"` **确实存在**(`contact-content-v3.tsx:316`)、`p4:365` 跳转链接依赖的 `data-skip-to-content` **也确实存在**(`layout.tsx:203`)——我最初的猜测若不做这步核实就会被写成假发现。<br>**更要紧的是系统性模式**:全仓至少 **14 处断言被包在 `if (await X.count() > 0)` / `X.isVisible()` 里**(`p2-functional-e2e.spec.ts` 一个文件就占 11 处:`:64,74,84,114,213,221,247,445,502,527,577,742`;另见 `mobile.spec.ts:334`、`p3-compatibility.spec.ts:42`)。⇒ **选择器一旦失效,用例不是变红而是静默少测**,正是 N-15 蜜罐那族在功能层的翻版 | **①② 已修并实测**:`p4:343` 改为真正断言 `expect(hasOutlineStyle).toBe(true)`(复跑 **passed**,证本站确有焦点指示样式,且今后删掉 `outline`/`box-shadow`/`data-focus-visible` 会判红);`p4:546` 按 N-17 同法处理——`test.skip(baseURL 为 http://localhost, 理由)` + 在 HTTPS 下断言 `expect(mixedContent).toEqual([])`,复跑 **1 skipped / 1 passed**,混合内容判据在本地不再伪装通过。<br>**④ 14 处条件守卫不盲改**:其中相当一部分是**合法**的视口/角色分支(桌面顶栏 vs 移动抽屉、超管才见的元素),一律改成硬断言会制造假红,而逐个判断需要跑完整 E2E 才能确认每个元素在哪个 project 下真的存在 ⇒ 记为**待授权的独立整改**,规则写明:先 `await expect(locator).toBeVisible()` 再断言其内容,让"元素消失"变成失败而非跳过。**③ 留作弱断言清单**,不改判据也不宣称它已失效。<br>**追加已修(同一轮,全部经四 project 实测)**:`p2:82-87` 的死 CTA 门(换成首页真实装配的 `[data-testid="cases-co-creation-cta"]`,去掉 `if(isVisible)` 守卫);`p2:481` 的 `expect(a) \|\| expect(b)` **JS 或运算 bug**(`toContain` 失败会抛错 ⇒ 右侧永不执行,改为 `toMatch(/条款\|服务/)`);`p2:129`「导航链接可点击且跳转正确」原本**零断言 + `.catch(() => {})` 吞跳转失败**,改为复用 `primaryNav()` 取当前视口的导航容器、从链接自身 `href` 推导目标并断言落地;`p4:223`「键盘可访问」的恒真断言 `expect(typeof hasFocus).toBe('boolean')` 改为与引擎无关的 `focus()` + `toBeFocused()`。**过程须如实记:我自己两次改错**——第一版加 `exercised > 0` 守卫在 chromium-mobile 误红(桌面顶栏有「联系我们」、移动抽屉没有,**硬编码链接文案天然不是视口无关的**);第二版用 `waitForURL(/^\/services/)` **四 project 全红**(Playwright 的 URL 匹配跑的是**完整** URL,锚定 `^/path` 永不成立)。两版都被实测否掉后才得到现在这版四 project 全通过。**教训**:给既有测试"补断言"必须跨 project 跑,桌面-only 的直觉在这里就是错的 |
| N-28 | P2(这一层从未被要求过可靠性,故无稳定性预算) | 本轮首次把 `@regression` 功能层(14 spec / 200 例,chromium 单 project 3.0 分钟)跑给人看,随即测得**同一代码三轮失败数 8 / 1 / 5**。失败集中在两类:① **墙钟阈值型用例**——`p4:31`「首页完整加载时间 < 8 秒」、`p4:68`「FCP < 阈值」,它们断言的是绝对毫秒数,而 dev server(未压缩 React + 按需编译)在 4 project × 6 worker 并发下本身就更慢 ⇒ 这类用例在并发门禁里**必然间歇红**;② **webkit / chromium-mobile 下的点击跳转超时**(`p2:177`、`p2:239`、`p4:408`)。根因不是新缺陷:**这些用例从未进入任何自动化门禁**(`test:e2e:fast` 只 `@smoke\|@critical`;Jenkins 的 `test:e2e:prod` 加 `@journey`;CI 的 `test:visual` 只桌面 chromium 一个 project,四个视觉 project 里三个在 CI 从不跑),所以它们从未被要求稳定过,也没有重试预算或隔离策略 | **未修,记为待决策**,因为正确解法需要产品取舍而非改代码:(a) 墙钟阈值型用例不该放在共享并发门禁里——应单独串行跑、或改用相对判据(与站内其他页面对比、或 Lighthouse 那类受控测量,本轮 `npm run lighthouse` 已在产物上给出 FCP 248-336ms / LCP 795-903ms 的稳定口径);(b) 若要并入 CI,需同时决定 `retries` 与 `workers` 策略并明确"允许 flaky 的清单",否则会把并发抖动当成回归追;(c) 三个从不跑的视觉 project 是否纳入 CI(纳入则基线维护成本 ×3)。**本轮明确不做**:不加 `retries` 掩盖(等于把间歇红藏更深)、不放宽 timeout 阈值(同理) |
| N-29 | P3(设计约束无门禁 + 一份自相矛盾的约束摘要) | 查「AGENTS.md §8 记的设计约束到底有没有人验」,实测三条:① **动效时长完全没有门禁**——`check:a11y` 只等于 `check:contrast` + `check:headings` + `check:brand-token`,没有任何脚本管时长或缓动曲线;② **AGENTS.md §8 把约束写错了档**:原文只有一句「动效 180–280ms」,而真正信源 `CONTEXT.md:76` 定义的是**三档**(入场 180–280ms、hover **150ms**、反馈 **100ms**)外加 `:82`「禁止超过 700ms 的入场动效」⇒ 照 AGENTS.md 的字面去审,会把**设计上合法**的 150/100ms 一律误判为违规(实测 `mega-dropdown.tsx:100` 就有一处 `duration: 0.15`,属不属于"入场"需设计判断);③ 令牌层 `globals.css:263-268` 定义了 100/180/280/**450**/**700**/**1000**ms 六档,其中 **`--transition-gentle: 1000ms` 全仓零使用(死令牌)**,而 `fadeInUp`/`fadeInDown`(`globals.css:1225,1229`)**正好压在 700ms 上限**(用 `slower`,未超 ⇒ 合规,但没有门禁阻止它继续加大)。组件侧实测分布健康:`duration: 0.28` 145 处、`0.2` 3 处、`0.25` 1 处、`0.15` 1 处 | **AGENTS.md 那行已按信源改正**(恢复三档 + 700ms 上限,并注明是本轮修正)。**未新增门禁**——理由:按 AGENTS.md 的错误口径写门禁会立刻误伤合法用例,而按正确口径写需要先定义「哪些算入场、hover/反馈、`slow(450)`/`slower(700)` 各自允许用在哪」,那是设计决策不是代码问题。留给设计侧三件小事:`--transition-gentle` 删除或明确用途、`mega-dropdown` 的 150ms 归类、若要设门禁则**先定类别再定阈值** |
| N-30 | **P1(内容「零编造」:机制是白名单式的,白名单外无人管;两处编造数字已删)** | 针对 AGENTS.md §3 的「零编造」与 `3ed3afd`(`fix(metrics): 数字口径 basis 结构强制,删除详情页虚构佐证`)做内容专项。**先查机制再判内容**:`basis?: 'target' \| 'team-history' \| 'verified'`(`src/lib/constants/metrics-basis.ts:13`)+ `MetricsBasisNote`,唯一执行者是 `src/lib/constants/metrics-basis.test.ts` 一个单测,而它有四处结构性盲区:① `REQUIRED_ANNOTATED_SOURCES`(test:27-36)是**硬编码 7 个文件**的白名单 ⇒ 任何第 8 个打印大数字的文件天然免检;② `allSeedMetricBases()`(test:53-64)只走 `PRODUCTS`/`STANDALONE_PRODUCTS`/`SOLUTIONS`,**`SERVICES` 与 about 的 keyMetrics 从不被看**;③ 禁词扫描的 `SOURCE_ROOTS`(test:24)只有 `src/app`+`src/components` ⇒ **`src/lib/**` 与 `prisma/**`(即全部 seed 文案)不在扫描范围**;④ `basis?` 在所有类型里都是**可选**(`products.ts:12`、`services.ts:40`、`solutions.ts:26`),`validate-content-data.ts` 自述「未声明的键一律放行」⇒ 无类型层/运行时层强制。**结论:白名单内是真的,白名单外是 Aspirational。**<br>**本轮已删的两处凭空数字**(实测改前 23 例、改后 23 例通过):`service-detail-content-v4.tsx:16` 无 benefits 时兜底成 `'效率提升 40%'`(改为无数据不渲染该行);`case-detail-page.tsx:95,99` 无 metrics 时兜底 `'40%'` 与 `'核心成果提升'`。**关键:后者原本被一条测试正向锁定**——`case-detail-page.test.tsx:162` 名字就叫 「falls back to the placeholder headline…」并断言 `40%` 出现 ⇒ 错误行为被测试固化,改正行为时该断言必须反转为 `queryByText('40%')` 为 null(已改,注释标明理由)。 | **删兜底已完成**(`type-check` 0、`test:unit` 134 套件 / 1697 例 0、`lint` 0 error / 105 warning)。**余下全部为 NEEDS-PRODUCT-DECISION,未擅自改动**,因为「哪个数字是真的」只有业务能答:① **四条互斥 SLA 同时在线**——`detail-cta-section.tsx:84`「平均响应时间 < 2小时 · 7×24小时技术支持 · 无需预付费用」、`prisma/seeds/services.ts:129`「<4小时(工作日技术支持)」、`case-detail-page.tsx:513`「48小时内给出初步方案建议」、`seed.ts:836`「工作日 2 小时内」,四者不可能同时成立;② `/products/erp-upgrade` 的 `99.2% 数据准确性`、`40%+`、`70%` +「从5天到1天」、`100%` 安全保障(`erp-upgrade-content-v2.tsx:63,69,75-77,81`)**既无 basis 也无 MetricsBasisNote**,读起来像实测而非目标;③ **`3ed3afd` 的机制没够到它自己的目标**——`content-types.ts:1207` 给 keyMetrics spread 了 `metricBasisField`,但 `/about` 读的是 `seed.ts:375-379` 那份**没有 basis 键**的字段定义,于是编辑**永远无法**给 `/about` 那四个数字声明口径;④ `prisma/seeds/services.ts:41-43,127-129,168-170,209-211` 共 12 条 `dataProofs`(含 `系统可用率 99.9%`、`代码质量 A+`、`数据源对接 20+种`)在**所有检查之外**,且当前无渲染方消费(潜伏非在线);⑤ 死文件 `why-us-section.tsx:158,166` 自称「技术伙伴 AWS / Azure」且 `:13,35` 称覆盖「政务」行业——`prisma/seeds/solutions.ts` 里**没有政务方案**、`package.json` 里**没有 AWS/Azure**,该文件零 importer(删或改标签为「技术栈」)。**同时记一条应当保留的诚实基线**:`CASE_STUDIES = []`(`case-studies.ts:11` 注释「暂无对外可验证的客户案例」)、`certifications: []`、`partners: []` 全为空,**未发现任何编造的客户名或 logo**;创始人引言归于「睿新致远创始团队」而非虚构个人;「6 款自研产品 / 6 个行业场景」与 seed 计数**逐一对上**(属已证实,勿改) |
| N-31 | P2(文档与仓库现实不符:测试指南里 7 条命令不可执行 + 一段指向不存在的 CI) | 对 `README/CLAUDE/AGENTS/CONTEXT/docs-testing/quality-gates/deployment` 做机械交叉核对(脚本名对照 `package.json`、相对链接对照磁盘、阈值对照 `config/test/jest.config.js`)。结果:**链接全部可解析(0 缺失)、覆盖率阈值口径一致(82/75/75/75 三方相同)**,但 `docs/testing.md` 有 4 处假事实:① `npm run test:ui` / `test:debug` / `test:headed` **三个脚本在 package.json 里根本不存在**(照做必报 Missing script,正解是 `npx playwright test --ui/--debug/--headed`);② `npm run test:allure` / `:open` / `:serve` 三条同样不存在,且 **`allure-commandline` 既不在 dependencies 也不在 devDependencies、`node_modules/allure-commandline` 也不存在** ⇒ 整个 Allure 报告章节不可执行(仓库只产出 `e2e/allure-results/*.json`);③ 一段 **GitLab-CI 风格 `pipeline:` 片段**写着 `image: node:18-alpine` 并调 `npm run test:ci`(同样不存在),而本仓真实流水线是根目录 `Jenkinsfile`、`.github/workflows/` 不存在、`Dockerfile` 用 node:20 ⇒ 三重过期;④ `AGENTS.md` §5 的覆盖率行停留在 **132 套件 / 1677 例**,与收尾实测不符 | **①②③ 已改 `docs/testing.md`**:换成可执行的原生 flag 写法;Allure 段改为「仓库未定义这些脚本 + CLI 未安装」并给出 `npx -y allure-commandline generate/open` 的真实路径;CI 片段顶部加显著警告说明真实流水线是 `Jenkinsfile`、并把 node 版本更正为 20。**④ 已按 `test:coverage` 收尾实测回填** `AGENTS.md`:134 套件 / 1697 例、global 85.59/87.5/80.67/85.59、目录级 `content` 100×4、`sections` 72.77/**90.79**/73.8/72.77(较上一版分支上升,因本轮改过 `case-detail-page.test.tsx`)、`lib/cms` 91.93/92.77/92.68/91.93,0 条阈值告警。复跑核对脚本:**「引用不存在的 npm script」由 7 降为 0**。方法论:这类检查全部用脚本机械比对,不靠阅读 —— 阅读会漏,且我写的更正注释本身一度被自己的检查器当成第 8 条假命令(把 `npm run test:allure*` 引号内示例改掉才归零) |
| N-32 | P2(本轮首跑的最后一个标签层:webkit axe 报 1 项 serious/critical,而 axe 门禁根本不覆盖 webkit) | 收尾时把**从未在任何门禁里执行过**的 `@accessibility\|@performance` 标签层跑了:`E2E_TARGET=production`(打当前构建产物)× 4 project,**124 通过 / 4 失败,EXIT=1**(2.2 分钟;日志 `docs/acceptance/2026-09-23-gates/e2e-a11y-perf-prod.log`)。4 例失败**全部是 webkit** 的 `mobile-accessibility.spec.ts:41` axe-core 扫描,分别落在 `/`、`/about`、`/services`、`/news`,每页 **1 项** critical/serious 级违规(断言 `criticalSerious.length === 0`)。**关键结构事实:全站 axe 门禁 `check:axe` 只跑 `chromium\|firefox × light\|dark`**(§1 那轮的四组即此),因此 **webkit 的 axe 结果不在任何门禁的观察范围内**——这不是推断,是从门禁配置本身读出来的覆盖面。**本轮定性证据**:单独隔离复跑 `--grep 关于我们 --project=webkit --retries=0` ⇒ **1 passed(10.7s)**,即该违规在低并发下不复现 ⇒ 归入 N-28 的并发/时序族(axe 注入时机与页面未完全稳定相关),**而非已证实的真实缺陷**。同时如实记一条取证不足:失败发生时用例确实 `console.log` 了规则 id 与节点,但 line reporter 未把这批 stdout 带进汇总(`grep -A3 可访问性违规` 只能取到「(1 项)」标题行,取不到 `[impact] id: help`),**故规则名本轮未能确定** | **未修,两项待办分开**:① 若要真拿到 webkit 信号,最小改法是给 `check:axe` / `scripts/accessibility/axe-node-count.mjs` 的浏览器矩阵**加入 webkit**(与现有四组同构,成本是全站扫描时间 ×1.5),这属门禁覆盖面扩张、且需重跑全量出数 ⇒ 未擅动;② 要让这条用例可诊断,应把违规明细写进附件而非 stdout(`testInfo.attach`),否则并发失败时永远查不到规则名。**本轮不给它加 retry、也不放宽 `criticalSerious === 0` 阈值**——那会把一个可能是真的 a11y 信号彻底抹掉 |
| N-33 | P2(文档一致性专项:3 处已修,其中 1 处是**我本轮自己写错的**;另余 8 组数值口径冲突待清) | 第四路 subagent(文档 vs 仓库现实)+ 我逐条复核。**最该记的一条**:`README.md:174` 里「GA4 与生产响应头只在这里真正断言」这句话**是我今天早些时候改正 `npm run start` 时亲手写进去的**——我在纠正一个假事实的同时制造了另一个(生产响应头的断言在 `@security`/`test:security:headers`,不在该 grep 集合)。 | **已修三处**:① `README.md:174` 拆清 GA4 与响应头;② `CLAUDE.md:23-24` 原称「仍留在 `next start` 上的是 `check:headings`」,而我修 N-24③ 后它已直起 standalone ⇒ **这是「缺陷已修但文档仍说它活着」的危险方向**(会让后人重复修一遍或误判门禁无效),已改为描述现状并注 N-24③;③ `docs/development/quality-gates.md:126` 同一处 GA4/响应头混淆已拆。**同轮我自己补测并修掉的第 4 处**:`mobile-performance.spec.ts:145` 把 `largeImageCount`/`oversizedForMobile` 算完只 `console.log`、唯一断言是 `totalImages > 0` ⇒ 在装配产物上以 375px 视口实测 `/`、`/about`、`/products`、`/cases`、`/news` 五页 `naturalWidth > 1920` 均为 **0**(首页最大 480、`/cases` 与 `/news` 最大 1108),据此加 `expect(largeImageCount).toBe(0)` 棘轮并**在 chromium + webkit 上复跑通过**;`>800` 的张数是 1~2 属现状,**不设阈值**(硬设会误伤真实图片)。**余下未清的口径冲突(全部已取证,逐条待授权后一并修)**:`jest.config.js:26` 注释、`README:167`、`CLAUDE:240`、`quality-gates:90` 仍写 **132 套件/1677 例**(实际 134/1697,`--listTests` 已核);`README:251` 写「Lines 73.59」**低于 75 门限**,与 EXIT=0 互相矛盾(实测 85.59);`README` 内部三处例数互斥(:34 1594 / :225 120·1509 / :167 132·1677);`README:175,241,260` 的 `@mobile` 53 个(实测 55)、`README:242` 的 105 张基线(实测 25 主语 × 5 project = 125)、`README:254` 全量 631 passed(证据文件为 1167/1195);`CONTEXT.md:220` 称 lint 106/56 并自称「已按实测改文」(实测 105/55,且改文未发生);`AGENTS.md:226-227` 引 `next.config.mjs:4`/`:8-14` 实为 `:44`/`:58`;`CLAUDE.md:205` 的动效档「fast (150-300ms)」与 `CONTEXT.md:76` 三档冲突。**另有 `docs/allure-report-guide.md`、`docs/test-optimization-guide.md`、`docs/development/IMPLEMENTATION-REPORT.md`、`docs/deployment/DEPLOYMENT.md`、`docs/CDN_QUICK_START.md`、`docs/development/getting-started.md` 里成片的不可执行命令**(`test:tier:*`、`db:push/generate/migrate/studio`、`deploy:cdn`、`test:integration` 旧名),以及 `CONTEXT.md:143`/`README:123,127`/`CLAUDE:234,236` 提到**不存在的目录**(`src/components/effects/`、`contexts/`、`cms/`、`providers/`、`_archive/`)与 `CLAUDE.md:265` 把 `home-content-cms.tsx` 说成现行文件(实际是 `home-content-v15.tsx`,同一文件 `:182` 自己已否定)。**收尾批次已改(本轮最后一轮,全部用实测值回填,不用文档互抄)**:`README.md:167`、`CLAUDE.md:242`、`config/test/jest.config.js:27`、`docs/development/quality-gates.md:90` 的 132 套件/1677 例 ⇒ **134/1697**(`jest --listTests` 实数 134);`README:251` 的 Branches 82.38%/Lines 73.59% ⇒ **87.5%/85.59%**(该 73.59 低于 75 门限却宣称 EXIT=0,本身就是自相矛盾);`README:175` 的 @mobile 53 ⇒ **55**(`--grep @mobile --list` 实测)。改后 `grep` 复核六个文档全部为 0 残留,`type-check`/`lint`/`test:unit` 均 EXIT=0。**仍未批量改**:数量已超一次收尾能复验的范围,且删目录条目需确认历史文档不被引用 ⇒ 列为独立的文档清理动作 |
## 4. 明确未做 / 需决策
1. **Sentry 实际未接线**:`@sentry/nextjs@10` 已装、三个 `sentry.*.config.ts` 存在,但仓库内**无 `instrumentation.ts`、`next.config.mjs` 未用 `withSentryConfig` 包裹、`src/**` 无 `@sentry/nextjs` import** ⇒ 生产错误上报不生效。`@sentry/tracing@7`(v7 已 EOL、全仓无引用)为冗余依赖。接线需决策是否引入 source maps 上传(要 `SENTRY_AUTH_TOKEN`,会改变构建流程)——**已在 `docs/sentry-setup-guide.md` 顶部写明现状**。
2. **审核流缺 `reject` / `archive` 入口**:`/api/admin/items/[id]/workflow` 支持四态全部动作,UI 只做了 `submit`/`approve`。提交进 `review` 的条目对审批者没有「驳回」路径,`archived` 条目无出口。属产品决策 + 设计,不在收尾阶段仓促加按钮。
3. **CSP 去掉 `'unsafe-eval'` 的影响面:本地已闭环,线上头仍未复验**。装配产物 + 真实浏览器下已实测 GA4/GTM 在收紧后的 CSP 中正常工作(`/contact`、`/services`、`/about` 三页:`gtag/js` 200、`https://www.google-analytics.com/g/collect` 全部 **204**、对被放行域零拦截),唯一被该指令影响的是 Zod 的 eval 探测,已按 N-11 处置;**Sentry 因根本未接线(本项第 1 条)而无从验证**——它不是「CSP 拦住了它」,而是「它没在跑」。仍欠的是**线上真实响应头**下的复验(需打 `https://novalon.cn`,属对外请求,本轮未擅自执行)。
4. **死组件族(本轮扩充并逐条取证)**:`src/components/sections/` 下 **7 个**组件在 `src/**` 内**零引用者**——`why-us-section`、`challenge-section`、`methodology-section`、`product-matrix-section`、`question-card`、`service-grid`、`social-proof-section`。取证:`grep -r "challenge-section|why-us-section|social-proof-section" src` ⇒ **No matches found**(即连它们自己的测试也不导入,故覆盖率为 0%,与补测子代理实测一致)。注意 `case-detail-page.tsx:194` 的 `ChallengeSection` 是**该文件内的同名局部定义**,不是这几个文件的引用者,容易误判为「还在用」。另加此前的 `hero-section-v2`、`ui/product-card`、`ui/loading-state.tsx`(N-8 的门禁豁免对象),以及**本轮为定位 N-11 顺手取证的 `src/components/ui/form.tsx`**:它是 `'use client'` 且 import zod,但全仓对它的引用为 **0**(两种写法 `@/components/ui/form` 与相对路径 `./form` 均 No matches found)⇒ 属死组件,也正因如此 /contact 才是唯一在浏览器侧求值 zod 的活页面。删除前需确认视觉基线路由表与 E2E 未以动态方式引用,且这是独立于验收的清理动作 ⇒ **不在收尾阶段批量删**,留作决策。
## 5. 待授权清单(**五项**,均需单独明文批准;本节标题原写「四项」,第 5 项 Stryker 是本轮补登,标题已同步改正)
| # | 动作 | 命令 | 风险 |
|---|---|---|---|
| 1 | 重跑 seed | `npm run db:seed` | 写 `prisma/dev.db`,覆盖现有开发数据 |
| 2 | 写库 E2E | 完整 `npm run test` / 完整 `test:e2e:prod`(**只剩** `e2e/cms-workflow.spec.ts` 与 `e2e/user-journey.spec.ts`,各 9 处 `request.post/put/delete`,合计 11 例) | 真写 `prisma/dev.db`(建草稿/上传媒体/改角色/调 revalidate)。**注意口径已收窄**:本轮取证发现「写库」范围此前被低估——除 `cms-workflow` 外 `user-journey.spec.ts` 同样写库,故它**不在**任何已跑过的子集里。另需更正 AGENTS.md/README 的一句长期表述:**GA4 与「生产响应头」并不是同一件事**——GA4 的 16 例确已在只读子集(`@smoke\|@critical\|@journey`)断言通过,而响应头断言在 `@security`(`security-headers.spec.ts`,18 例/project × 4)里,该标签**不在**上述 grep 集合内,另跑一轮才算数 |
| 3 | 视觉基线更新 | `npx playwright test ... --update-snapshots` | **改写已批准参照**,会追认当前现状(A-9 原始症结),须先人工核看差异。**注意区分**:**只读比对已在本轮跑完**(`--update-snapshots=none`,实测不写任何基线)⇒ 缺的不是"测一遍",而是**修 N-15 那 20 例坏用例后为它们创建参照**,以及**人工裁决 85 张现存基线相对 HEAD `f543e47` 的差异是否追认**。在此授权之前 `test:visual:all` 无法转绿 |
| 4 | 提交 / 推送 / PR | `git add` + `git commit` + `git push` + Gitea PR | 共享状态;须按 AGENTS.md §5.1 PR-First 流程并过 `scripts/check-pr-checklist.sh` |
| 5 | 变异测试复跑(A-13/A-14 的唯一实测凭据) | `npm run test:mutation:quick`(或去掉 `--inPlace` 的沙箱跑法) | ⚠ `--inPlace` 会让 Stryker **直接改写工作树**(其日志自述 "overriding YOUR files")。本树 **341 条改动全部未提交**,中途崩溃即无法用 `git checkout` 复原 ⇒ 在提交(第 4 项)之前跑它属于**丢失工作的实险**。建议顺序:先提交,再以沙箱模式跑 |
## 6. 收尾回填位(本轮已全部完成)
- [x] `test:coverage` 最终一格:`components/sections` **72.77 / 87.06 / 73.8 / 72.77**、`components/content` **100/100/100/100**、global **85.56 / 86.86 / 80.59 / 85.56**,132 套件 / 1677 例,**EXIT=0,0 条阈值告警**(详见 §1;过程中修掉 N-12)
- [x] `check:axe` 与 `npm run lighthouse` 在 `postbuild` 修复后的重跑:**axe `PASSED=true`(四组 `bgMismatch=0`/`contrastNodes=0`/`violationNodes=0`)**;**Lighthouse 27 次运行断言全通过,a11y 9/9 = 100,对比度与 `aria-required-children` 失败节点均为 0**(§1 保留首轮判红/无效记录,未删除,以便对照「修复前后门禁看到的东西完全不同」)
- [x] AGENTS.md §5 与 `config/test/jest.config.js` 的数值口径统一回填(见下)
- [x] **N-11 已闭环**(本节追加项):根因定位到 Zod 4.4.3 的 eval 能力探测(`node_modules/zod/v4/core/util.js:145-162`,库源码注释自述「strict CSPs report the caught `new Function` as a `securitypolicyviolation`」),以库认可的 `z.config({jitless:true})` 修掉,并用**发现它的同一仪器**做前后 A/B(`inspector-issues` 0→1、`/contact` BP 96→100)。过程中另立 N-13:自造 Playwright 探针的正向对照在 eval 类上失效,故其「0 违规」不足以定论——方法学已纠正,结论不依赖该探针。
- [x] **N-11 修复后的 axe 全量复跑**(专用端口 :3400,不与 §1 那轮的 :3100 混用):四组 `chromium|firefox × light|dark` **各 34 页**,`PASSED = true`,逐组 `themeMismatch=0 / bgMismatch=0 / contrastNodes=0 / violationNodes=0 / extraRuleNodes=0`,`extraRuleCoveragePairs=102` ⇒ N-11 的 `jitless` 改动**没有引入任何可访问性回归**。证据落 `docs/acceptance/2026-09-23-gates/axe-evidence.json`。**一条如实记录的次级现象**:每组 `non2xxResponses=1 / okResponses=33`,4 次全部是 `/_not-found` 返回 **404** —— 这是 Next 内部路由被 BFS 爬到的**正确**状态码而非故障,门禁因此不判红;但它说明路由清单混入了内部路由,收紧时可在 `crawl-routes` 侧排除(独立小改,本轮未动)
- [x] **视觉回归只读比对已跑**(`--update-snapshots=none`,规范模式 dev server):**EXIT=1,21 失败 / 104 通过 / 0 例像素不符**,三失败类已在 §1 与 N-15 归类。**判读须分两层**:现存参照层面**全绿**(含 `/contact` 全页),故本轮修复无视觉外溢;装配层面**判红**,且红的是"从未绿过的坏用例 + 从未存在的基线",非本轮引入。转绿的唯一路径是 §5 第 3 项授权
- [x] **Lighthouse 在最终树重跑**(N-11 修复之后,2026-09-23 05:38–05:39Z,EXIT=0):`27 次运行 = 9 URL × 3`,四类目逐 URL 全部 **99–100**(perf 99-100 / **a11y 100 × 9** / **BP 100 × 9** / **SEO 100 × 9**),且**新加的 `inspector-issues` 断言在 27/27 运行中均为 score 1 / 0 items** ⇒ BP 由首轮「8/9 齐平 + `/contact` 96」变为 **9/9 满分**,并证明这条门禁在干净树上不产生误红(存活证明见 N-13)
- [x] **`src/app/**` 盲区专项审查已做(收尾追加,subagent + 逐条源码复核)**:确认并修复 N-18(开放重定向)/ N-19(越权读取)/ N-20(停用账号令牌)三项 P1,另修 N-17 门禁诚实性并把 `@security` 从「72 passed / 0 skipped」如实变为「**68 passed + 4 skipped**」。三处修复各有 **RED 探针实测**(临时移除 guard ⇒ 对应用例判红 ⇒ 恢复转绿),非推断。修后复跑:`type-check` EXIT=0、`test:unit` **133 套件 / 1691 例 EXIT=0**、`lint` 0 error / 105 warning。留决策:N-21(幻影 modelCode、`/admin` 无服务端 gating)。教训已入 `docs/lessons-learned.md` §5.30。**⚠ 这 4 项修复的验证层级要说清**:N-18/N-19/N-20 是**应用代码**,而 E2E 打的是 **13:17 那次 `npm run build` 的产物**(本轮未重新构建)⇒ 上面的 92 例与 68+4 例**并不覆盖这三处修复**;它们的凭据是 `test:unit`(含新增 `disable/route.test.ts` 7 例与 `permissions.test.ts` 新增 3 例)+ 各自的 RED 探针 + `type-check`。**该缺口已于收尾时闭合**:重新 `npm run build`(`BUILD_EXIT=0`、装配自检 OK、且 `find src -newer dist/standalone/dist/BUILD_ID` 为空 ⇒ 产物确含当前全部源码),再以 `PORT=3700 node dist/standalone/server.js` 起真实产物直接探测三条修复:`POST /api/cms/draft/disable` 的 `https://evil.example/p`、`//evil.example`、`/\\evil.example` **全部 400**(修复前同一入口实测返回 307 向外跳转),合法 `/cases` 仍 **307 + `location: http://localhost:3700/cases`**(无误伤);`GET /api/admin/stats` 无令牌 **401**(权限层级由 `permissions.test.ts` + 真库 `03-authorization.itest.ts` 覆盖);`GET /` **200** 且 CSP 头存在 ⇒ 路由改动未破坏站点。
**闭合后的两条复跑证据(都在最终树上)**:① 视觉只读比对再次 **104 通过 / 21 失败 / EXIT=1**,且**失败集合与第一次逐行 diff 完全一致**(两份 55 行清单 `diff` 为空)⇒ N-30 删掉的兜底文案**没有造成任何视觉漂移**,21 例仍是 N-15 的装配缺陷,不新增待追认项;② 生产目标 write-free E2E 在**新产物**上重跑 **92 通过 / EXIT=0**(2.9 分钟)⇒ 前面所有以「92 例」为凭的说法现在对应的是含 N-18/19/20/25/30 改动的当前源码,而不是 13:17 的旧构建。N-17 是**测试代码**,其 68+4 复跑发生在修复之后,直接有效。教训已入 `docs/lessons-learned.md` §5.30
- [x] **「谁来测门禁」专项已做(第二次 subagent 扫描 + 逐条源码复核)**:审 `scripts/**` + `Jenkinsfile` 的恒不成立断言,产出 N-22(`check-security-headers.ts` 的 CSP 与 cookie 两条主判据**永远不会失败**,含 `val ? 'warn' : 'warn'` 这种同分支死三元)与 N-23(该门禁默认打**线上** `https://novalon.cn`,而 Jenkinsfile 打印"检查本分支构建产物"——声明与行为相反)、N-24(per-route 状态码空洞、三个从未接入的坏脚本、`--pr-dir` 空目录空转)。**N-22 已修并做双向对照**:无 CSP 的本地服务 ⇒ `EXIT=1/4 项 fail`;真实 standalone 产物 ⇒ `EXIT=0/0 失败`。过程中由正向对照额外发现 **HSTS 只由边缘注入**,故原门禁结构上无法在分支产物通过(已按 http/https 分档处理)。修后复跑:`type-check` EXIT=0、`lint` 0 error / **105 warning(未新增)**、`test:unit` **133 套件 / 1691 例**、`test:integration:real` **4 套件 / 23 例**(真实临时 SQLite,`dev.db` 未触)
- [x] **重新构建后在产物目标上复跑 write-free E2E**:`BUILD_EXIT=0` + 装配自检 OK,**91 通过 / 1 失败**,失败例隔离复跑通过 ⇒ 判为并发下的间歇性超时而非回归(不靠加 retry 掩盖)。同时把 N-18/N-19/N-20 的验证层级说清:其凭据在单测 + 真库 itest + RED 探针,产物目标 E2E 不触管理端 API
- [x] **N-24 的 ②③④ 已修(门禁自身不再空转)**:`check-heading-hierarchy.ts` 去掉「404 即就绪」+ 加 per-route 状态断言(新增 `"http_error"` 类型)+ 起服务从不受支持的 `next start` 改为直起 standalone;`check-pr-checklist.sh` 的两处空转改为 `exit 2`。**四例 + 双向对照全部实测**:`MISSING_DIR=2 / EMPTY_DIR=2 / CLEAN=0 / DIRTY=1`;标题门禁正向 `10 页 / EXIT=0`、负向(临时塞一条不存在路由)`HTTP 404 / EXIT=1`,探针已复原。修后 `type-check` 0、`lint` 仍 0 error / 105 warning、`test:unit` 1691 例通过。①(`axe-node-count` 的 per-route 断言)留作独立动作
- [x] **部署层专项已做(第三次 subagent 扫描 + 逐条源码复核)**:`Dockerfile` / `Dockerfile.prod` / 两份 nginx conf / `docker-compose*` / `Jenkinsfile` / `.dockerignore` / `scripts/deploy.sh` 交叉核对。**1 项已修(N-25 限流 XFF 伪造绕过,攻击路径实测复现,6 例新测试)**,**5 项记录留决策(N-26)**:密钥进 builder 层(且"加进 .dockerignore"这个显然修法会连带打断 `NEXT_PUBLIC_*` 构建期内联 ⇒ 不该那么修)、CI 先删 lock 再 `npm ci` 导致 lock 从未生效、`nginx-static-production.conf` 字体/图片块缺 `@nextjs` 回退、边缘 `add_header` 可能追加出双份 `Cache-Control`、`allowEmptyArchive` 让归档步骤可空转报绿。**正向结论也记一条**:镜像装配与本地 `postbuild` 拷的目录集**完全一致**(`dist/static`、`public`),`test:e2e:prod` / LHCI / Dockerfile CMD 三者启动方式一致 ⇒ N-9 那族"门禁与交付物不是同一个东西"的问题在装配层已收敛
- [ ] 视觉基线 85 张差异的**人工追认**、N-15 四类修法、`--update-snapshots` 写参照:均需 §5 第 3 项授权,本轮不执行
## 7. 工作树状态披露(不静默留给下个会话)
> 🔴 **最高优先披露:本报告判为「已闭环」的全部修复只存在于工作树,一个 commit 都没有。**
> `git status --porcelain` = **341 条**(39 条未跟踪;其中 85 条是 `e2e/visual-snapshots` 的 PNG),HEAD 仍是分支起点 `f543e47`。
> 也就是说 §2 里所有「已闭环」的行——A-1..A-8、A-11..A-15、R-1..R-5、B-1/B-3/B-4/B-7、N-1..N-9——**任何一条 `git checkout .` / `git reset --hard` 都会连同新增文件被清掉**(未跟踪的 `src/lib/sanitize.ts`、`upload-policy.ts`、`validate-content-data.ts`、`global-error.tsx`、`motion-provider.tsx`、`tests-integration/`、`config/test/itest/`、`scripts/accessibility/`、`page.test.tsx` 等约 30 项需 `clean -f` 才丢)。
> 验收含义:本轮交付的是**未固化的工作树状态**,不是可复现的提交。第 4 项授权(commit/push/PR)未获批准 ⇒ 无法把它变成可审、可回滚、可复测的单元。
- 🔴 **视觉基线:磁盘上的参照不是已提交的参照(A-9 的补充披露,本轮实测)**。`git status --porcelain` 中 **85 个 `.png` 全部是 `M`(已跟踪且被修改),0 个未跟踪**,集中在 5 个 project 目录各 17 张。三件事需要同时说清:
1. **时间线**:HEAD `f543e47` 提交于 **2026-09-20 11:32**,其标题自称「以规范模式重生成全站视觉基线」;而这 85 张的 mtime 全部落在 **2026-09-22 02:37–02:56** ⇒ 它们在提交之后两天被重新生成并停留在工作树,**从未进入任何 commit**。
2. **验收后果**:本轮只读比对(104 通过)比的是**这套 09-22 工作树参照**;任何从 `f543e47` 检出的人、任何 CI、以及下一位开发者比的是**另一套参照**。因此「视觉回归通过」这句话**依赖未提交状态**才成立——这正是 A-9 原始症结(基线被无声追认)的镜像形态,只是这次追认物在工作树而非提交里,且**没人核看过这 85 张的差异**。
3. **可逆性与判据**:`git checkout -- e2e/visual-snapshots` 会**静默丢弃**这 85 张并重回到 `f543e47` 版本(视觉历史将不可复原这 09-22 的现状记录),故本轮**未做任何还原/清理动作**;差异是否追认属 §5 第 3 项授权范围。若要复核差异本身,`git diff --stat HEAD -- '*-snapshots*'` 只给字节数(如 `solutions-fullpage` 1,651,956 → 1,646,257、`theme-light-main` 3,501,526 → 3,502,516),**像素级判读需人工看图**,本轮据实声明:未做人工像素核看,故不宣称「85 张差异合规」。
- ⚠ **本轮对 `AGENTS.md` 的改动不计入交付物**:该文件被 `.gitignore:312` 有意忽略且**从未被 git 跟踪**(`git ls-files` 无匹配),历轮报告里「已回填 AGENTS.md §5」类声明同样**不进入 diff**、不可审不可回滚,并可能被 `next dev` 重写(`node_modules/next/dist/server/lib/generate-agent-files.js:2` 已核实为自动生成的管理文件)。详见 N-16。本报告的门禁口径因此**在已跟踪文件中各存一份**:`docs/lessons-learned.md` §5.29(视觉门禁分层判读)与本文 §1/§3(N-14、N-15、N-16)。
- 索引内已有 1 条**已 staged 的删除**:`config/test/jest.setup.js`(全仓 `config/test/jest.setup` 引用为 0,真实 setup 是根目录 `jest.setup.js` ⇒ 删除安全,但它是**本轮之前**就处于已暂存状态的条目,非本会话动作)
- 未跟踪新增项包括:`tests-integration/`、`config/test/itest/*`、`config/test/jest.integration.config.js`、`scripts/accessibility/`、`scripts/utils/check-brand-text-token.ts`、`e2e/{fixtures,hydrated,primary-nav,touch-targets}.ts`、`src/app/admin/content/[modelCode]/[itemId]/page.test.tsx`、`src/lib/{sanitize,media/upload-policy,cms/validate-content-data}.{ts,test.ts}`、`src/app/global-error.{tsx,test.tsx}`、`src/components/ui/motion-provider.tsx`、`src/app/api/admin/users/route.test.ts`、`src/app/api/auth/{login,refresh}/route.test.ts`、`src/app/api/cms/draft/enable/route.test.ts`、`docs/acceptance/2026-09-2{1,2,3}-*` 证据目录、根目录 `ACCEPTANCE_REVIEW_2026-09-21.md`(上一轮判定报告)、`deliverables/`
- 另有已删除但未提交的死代码:`Dockerfile.static`、`lighthouserc.json`(根目录旧位置)、`src/components/{cms/RichTextEditor,content/testimonials,detail/list-page-hero,layout/page-nav,sections/stats-bar,ui/{accordion,dialog,flip-clock,loading-skeleton,metric-card,milestone-timeline,select,stats-showcase,tabs}}`、`src/lib/{colors,gradients}`、`src/hooks/use-keyboard-shortcuts`、`src/components/examples/ContactFormAnalyticsExample.tsx` 及各自同名测试
- ⚠ SQLite 边车文件 `prisma/dev.db-shm`、`prisma/dev.db-wal` 处于未跟踪且**未被 .gitignore 覆盖**状态(`.gitignore:98` 只忽略 `*.db`)——`git add -A` 会把它们带进提交,提交前必须排除。建议改法(**本轮未擅自改**,因为它会改变 `git add -A` 的捕获范围,与第 4 项提交授权相互影响):在 `.gitignore:98` 旁补 `*.db-shm` 与 `*.db-wal` 两行。另已实测 `.lighthouseci/`、`lighthouse-reports/`(本轮 346 个报告文件)、`coverage/`、`dist/standalone/` **均已忽略** ⇒ 本轮跑门禁没有污染提交面。
- 收尾后计数:`git status --porcelain` = **341 条**(未跟踪含本轮新增源码:`src/lib/client-ip.ts`、`src/lib/client-ip.test.ts`、`src/lib/cms/internal-redirect.ts`、`src/app/api/cms/draft/disable/route.test.ts`)。**更正本行早前的说法**:收尾阶段并非"只新增文档改动"——为修 N-18/N-20 另新增了 **2 个未跟踪源码文件**(`src/lib/cms/internal-redirect.ts`、`src/app/api/cms/draft/disable/route.test.ts`),并修改了 6 个既有文件(`src/lib/permissions.ts`、`src/lib/permissions.test.ts`、`src/app/api/admin/stats/route.ts`、`src/app/api/cms/draft/{enable,disable}/route.ts`、`e2e/security-headers.spec.ts`,这些文件此前就已处于 modified 状态,故条目数增量小于改动文件数)。提交时须与其余修复一起纳入,不可只挑文档。
- **本轮数据库完整性实测**:只读计数 `ContentItem 38(全部 published)/ User 3 / Role 5 / MediaAsset 0`,与 seed 形态一致,无残留 draft/测试条目 ⇒ 真库集成层确实落在 `/tmp` 一次性库。**收尾时(全部 E2E / 门禁 / 集成跑完之后)以 `sqlite3 -readonly prisma/dev.db` 复测,四组数字与 draft=0 完全未变**;此处另记一条方法学:Prisma 客户端在裸脚本上下文下会因缺少 env 装配而 `PrismaClientInitializationError`(`Failed to apply SQLite WAL/busy_timeout pragmas`),**验证只读状态应直接用 `sqlite3 -readonly`,不要为了一次计数去初始化 ORM**。`dev.db` 的 mtime 会因 standalone server 以 WAL 模式**读取**而变动,这不等于写入,故以上述行数为判据。
## 8. 续工交接(本轮到此停止,以下为唯一未推进项与精确入口)
> 本轮所有能自主推进的工作已落地并有实测凭据;剩下的**全部**需要用户单独授权或产品/设计决策。**接手时先读本节,不要重跑 §1 已出数的门禁。**
### 8.1 等待授权的动作(五项,逐项明文批准后才动)
| # | 动作 | 精确命令 | 批准前为什么不能做 |
|---|---|---|---|
| 1 | 重跑 seed | `npm run db:seed` | 覆盖 `prisma/dev.db` 现有开发数据(当前 38/3/5/0 与 seed 形态一致) |
| 2 | 写库 E2E | 完整 `npm run test` 或完整 `test:e2e:prod`;**只差** `e2e/cms-workflow.spec.ts` 与 `e2e/user-journey.spec.ts`(合计 11 例) | 这 11 例真写库(建草稿/上传媒体/改角色/`revalidate`);其余 92 + 68 + 124 例已在只读子集下实测通过 |
| 3 | 视觉基线写入 | `npx playwright test visual-regression.spec.ts --update-snapshots` | 会改写已批准参照;且需先人工核看磁盘上 85 张相对 HEAD `f543e47` 的 modified 差异(§7 时间线) |
| 4 | 提交 / 推送 / PR | `git add`(按路径,勿 `-A`:`.gitignore:98` 未挡 `prisma/dev.db-shm`/`-wal`)+ commit + push + Gitea PR | 共享状态;须过 `scripts/check-pr-checklist.sh`;工作树 341 条改动一次入库体积过大,建议按 N 系列分批 |
| 5 | 变异测试 | `npm run test:mutation:quick`(或去 `--inPlace` 的沙箱跑法) | `--inPlace` 直接改写工作树,而本树全部改动未提交 ⇒ 崩溃不可恢复。**顺序:第 4 项之后再跑** |
### 8.2 等待产品 / 设计决策(代码侧不该替业务回答)
1. **四条互斥 SLA**(N-30):`<2小时 · 7×24`(`detail-cta-section.tsx:84`)/ `<4小时 工作日`(`prisma/seeds/services.ts:129`)/ `48小时内`(`case-detail-page.tsx:513`)/ `工作日 2 小时内`(`prisma/seed.ts:836`)—— 只能留一个。
2. **`/products/erp-upgrade` 的数字口径**(N-30):`99.2%`、`40%+`、`70%`、`100%`、「从5天到1天」既无 `basis` 也无 `MetricsBasisNote`;需业务给出真实来源或降级为目标口径标注。同项下 `SERVICES.dataProofs` 12 条在所有检查之外(潜伏,未在线)。
3. **`/about` 的四个 keyMetrics 永远无法声明口径**(N-30 ③):`seed.ts:375-379` 的字段定义缺 `basis`,覆盖了 `content-types.ts:1207` 的正确定义 —— 修法是改 seed 字段定义,但要确认不引发数据迁移。
4. **webkit 是否纳入 axe 门禁矩阵**(N-32):现矩阵只有 `chromium|firefox`;`@accessibility` 层在 webkit 上报 1 项 serious/critical(4 页,隔离复跑不复现,规则名未取到)。要先让该用例把违规明细写成附件再判真伪。
5. **`/admin` 无服务端 gating + `'content-item'` 幻影 modelCode**(N-21);**`security-headers` 门禁默认打线上**(N-23);**axe/标题门禁的 per-route 状态断言**(N-24①);**N-15 的 21 例坏用例与 N-28 的并发抖动策略**(墙钟型用例不该进共享并发门禁)。
### 8.3 已知不稳定清单(不要通过加 retry/放宽阈值来「修」)
- `mobile-user-journeys` UJ-10(firefox,隔离复跑通过)、`website-acceptance:111` 响应式设计(隔离复跑通过)、`@accessibility` webkit 四例。三者同属**并发/墙钟**族:同一代码三轮全量跑失败数为 8 → 1 → 5。加 retry 或调大 timeout 只会把间歇红藏更深。
### 8.4 收尾时的环境状态(已核对)
本轮自建的探测端口(3600/3601/3700/3710 及 3500/3501 负向对照服务)全部释放,`:3000` 无残留监听;仓库内无遗留临时脚本;他人项目占用的 `5432`/`8080` 全程未被使用。`prisma/dev.db` 以 `sqlite3 -readonly` 复核仍为 `ContentItem 38 / User 3 / Role 5 / MediaAsset 0 / draft 0`。
+244
View File
@@ -0,0 +1,244 @@
# QA 问题跟踪台账(系统性质量保障 · 单一真源)
- 分支:`refactor/optimize-ui` HEAD:`f543e47`(**本台账所引用的全部修复仍只存在于工作树,未提交**)
- 工作树状态(2026-09-25 实测):`git status --porcelain` = **363 条**(47 条未跟踪)
- 上游报告:
- 周期 1:`docs/acceptance/2026-09-23-gates/final-verdict.md`(发现 N-1…N-33;对 2026-09-21 阻断 A-*/R-*/B-* 的闭环)
- 周期 2:`docs/acceptance/2026-09-23-cycle2/final-verdict.md`(新增表现层/领域层轴:A-1…A-4、C-1、P-*、D-1(defect)、L-1…L-5、Sentry 接线;决策清单 D-1…D-11)
- 本台账的作用:把上述**分散编号统一到一处**,作为「问题跟踪机制」的执行面;**不重复**两份报告的证据叙述,只登记**状态 + 归属阶段 + 闭合阻塞项**。
- 编号消歧:周期 2 的缺陷 `D-1`(detail-trust-section)与决策清单 `D-1`(入库)同名,本台账分别记为 **DEF-D1**(缺陷)与 **AUTH-D1**(决策/授权项)。
## 0. 验收判定(对齐 objective 五阶段)
| objective 阶段 | 交付物 | 状态 |
|---|---|---|
| 1 代码审查 | 两份周期报告 + 本台账 §1 | **已达成**(多轴、subagent + 主线逐条复核) |
| 2 测试 | 门禁实测表(周期 1 §1 / 周期 2 §0)+ `docs/testing.md` | **已达成**(可自主执行的门禁全部出数;写库/基线/线上/全量变异等需授权项见 §3) |
| 3 修复 + 跟踪机制 | 本台账(单一真源) | **机制已建立**;修复的**可复现性未达成**(未入库) |
| 4 优化 | 性能(Lighthouse 9/9=100、a11y 节点计数、限流键、死资源清理)+ 安全(提权/XSS/越权/令牌/开放重定向) | **已达成**,余留项见 §2/§3 |
| 5 验收确认 | 本报告 + 门禁凭据 | **未达「可判验收通过」**——唯一阻断为交付形态(AUTH-D1 未入库),非代码质量 |
> **结论不变**(承接周期 1「有条件通过」/周期 2「未达可判验收通过」):技术面已闭环并有实测凭据;验收不能判完成的**唯一实质原因**是 §3 AUTH-D1(约 363 条改动未提交,评审者/CI/下一位开发者无法复现本台账任何结论)。
>
> **2026-09-28 更新**:第三/四/五轮(§4/§5/§7)在此结论上继续累积修复(第五轮再修 S-21/S-22/Q-10②/S-3 两个 P2 + 12 个 P3,全带判别测试与门禁凭据),未提交的工作树现 **394 条**——AUTH-D1 仍是唯一硬阻断。
## 1. 本台账建立时的独立复核(trust-but-verify,2026-09-25)
对**最高后果**的 12 项「已修」声明做了只读源码复核(读当前工作树,未采信报告自述),结论:
| ID | 声明 | 复核结果 | 证据(本次亲读) |
|---|---|---|---|
| N-18 | draft/disable 开放重定向防护 | **CONFIRMED** | `src/lib/cms/internal-redirect.ts:8`;`disable/route.ts:13-21` 先校验后副作用 |
| N-19 | admin/stats 越权读取 | **CONFIRMED** | `src/app/api/admin/stats/route.ts:11` 用 `requirePermission('content-model','read')` |
| N-20 | 停用账号令牌仍可用 | **CONFIRMED** | `src/lib/permissions.ts:41-45` 读 `User.status`,早于 `:52` 的 super_admin 短路 |
| N-25 | 限流 XFF 伪造绕过 | **CONFIRMED** | `src/lib/client-ip.ts:14-23` 优先 `x-real-ip`、XFF 取最右;`contact/route.ts:4,51` 消费 |
| N-22 | 安全头门禁恒不成立 | **CONFIRMED** | `check-security-headers.ts:63-78,101,128-132` cookie 状态按 flag 推导并计入退出码;缺 CSP=fail;HSTS 分 http/https |
| N-1 | JSON-LD `</script>` 注入 | **CONFIRMED** | `src/components/seo/structured-data.tsx:8-9` `jsonLd()` 转义 `<`/`>`,8 个 sink 全用 |
| DEF-A1..A4 | 后台编辑器定位/守卫 | **CONFIRMED** | `admin/content/[modelCode]/[itemId]/page.tsx` `id` 过滤 + `itemNotFound`(:691) + `canEdit`(:252,736,917) 收口 |
| C-1 | 案例页缺键整页崩溃 | **CONFIRMED(边界归一)** | 归一在唯一 importer `(marketing)/cases/[slug]/client.tsx:22,30-35`,恰在未受信任 CMS 强转处;报告所称组件文件为归属笔误,非漏洞(见 §2.2 REG-C1) |
| L-5 | 媒体派生命名冲突 | **CONFIRMED** | `src/lib/media/image-processor.ts:75` 追加 `Math.random()` 后缀 |
| L-1 | admin-api 吞掉 4xx/5xx | **CONFIRMED** | `src/lib/admin-api.ts:85-102` 解密后按 `res.ok` 抛错 |
| N-4/5/6 | 保存/发布状态通道 | **CONFIRMED** | 保存载荷无 `status`;发布走 `runWorkflow`;POST 拒非 draft |
| DEF-D1 | 指标前导符号错位 | **CONFIRMED** | `detail-trust-section.tsx:26-48` 用 `[^0-9.]` 否定类 + prefix/suffix 分列 |
> Sentry 接线亦复核为真:`src/instrumentation.ts`、`src/instrumentation-client.ts` 存在且 import `@sentry/nextjs`;`next.config.mjs:131` `export default withSentryConfig(nextConfig)`。
> 覆盖扫描(非定点复核):`src/`+`scripts/` 内无条件 `status:'pass' as const` = **0**;`\|\| echo` = 12(均在 CI/部署 shell 的信息性回退,非门禁判据);`e2e/*.spec.ts` 内 `if (.count()>0)` 守卫 = 11 处(周期 1/2 已逐条定性为视口/角色合法分支,见 N-27)。
## 2. 缺陷登记(按闭合状态分组)
图例 · 状态:`FIXED-UNCOMMITTED`=已修且§1或原报告有 RED/GREEN 凭据,但未入库;`DECISION`=待产品/设计裁定;`AUTH`=待单独授权动作;`RECORDED`=已知、诚实披露、本轮不动。
### 2.1 已修复(代码在工作树内,含 §1 复核标记)
| ID | 级别 | 类别 | 摘要 | 状态 | 凭据 |
|---|---|---|---|---|---|
| N-18/19/20/25 | P1 | 安全 | 开放重定向 / 越权读取 / 停用账号令牌 / 限流绕过 | FIXED-UNCOMMITTED | §1 全部 CONFIRMED + 原报告 RED 实测 |
| N-22 | P1 | 门禁可信度 | 安全头门禁恒不成立断言 | FIXED-UNCOMMITTED | §1 CONFIRMED + 双向对照 |
| N-1 | P1 | 安全 | JSON-LD 存储型 XSS 面 | FIXED-UNCOMMITTED | §1 CONFIRMED |
| N-4/5/6/7 | P0/P1 | 功能 | 后台保存必 400 / 发布必失效 / 状态死控件 / 状态回写 | FIXED-UNCOMMITTED | page.test.tsx |
| N-9 | P0 | 门禁可信度 | standalone 产物缺 `dist/static`(CSS 404) | FIXED-UNCOMMITTED | postbuild 落地 |
| N-11 | P2 | 性能/BP | `/contact` CSP 违规(Zod eval 探测) | FIXED-UNCOMMITTED | `z.config({jitless})` + Lighthouse A/B |
| N-12 | P1 | 门禁可信度 | 补测自身把 rAF 变只读致套件静默不跑 | FIXED-UNCOMMITTED | 隔离复跑 |
| N-17 | P2 | 测试诚实性 | security-headers 裸 `return` 伪装通过 | FIXED-UNCOMMITTED | 68 passed + 4 skipped |
| N-24②③④ | P2 | 门禁可信度 | 404 即就绪 / 不受支持的 next start / pr-checklist 空转 | FIXED-UNCOMMITTED | 双向对照 |
| N-27(部分)| P2 | 测试装配 | 零断言用例 / `expect()\|\|expect()` JS bug / 死选择器 | FIXED-UNCOMMITTED | 四 project 实测 |
| N-30(删兜底)| P1 | 内容零编造 | 两处凭空 `40%` 兜底已删(含反转被锁死的测试) | FIXED-UNCOMMITTED | 23→23 通过 |
| N-31 / N-33(部分)| P2 | 文档一致性 | 不可执行命令 / 假阈值 / 口径回填 | FIXED-UNCOMMITTED | 机械比对 |
| A-1..A-15, R-1..R-5, B-1/3/4/7 | P0-P2 | 安全/功能/规范/UI | 2026-09-21 阻断项 | FIXED-UNCOMMITTED | 周期 1 §2 |
| DEF-A1..A4, C-1, P-1/4/5, DEF-D1, L-1, L-5 | P1-P2 | 表现/领域层 | 后台编辑器数据损毁 / 案例页崩溃 / 滑动 / 进度条 / 前导符号 / 错误吞没 / 命名冲突 | FIXED-UNCOMMITTED | 周期 2 §1;C-1 见 §2.2 REG-C1 |
| Sentry 接线 | P1 | 可观测性 | 「声明未接线」→ 已接(含 Turbopack 约定文件) | FIXED-UNCOMMITTED | 周期 2 §1.3;§1 复核存在 |
| N-24① | P2 | 门禁可信度 | axe 逐页状态断言(期望集感知) | FIXED-UNCOMMITTED | 周期 2 §3.7 双向对照 |
### 2.2 待产品 / 设计决策(代码侧不该替业务作答)
| ID | 级别 | 事项 | 入口 |
|---|---|---|---|
| REG-C1 | P3(**已复核,非活跃缺陷**) | C-1 归一在 `cases/[slug]/client.tsx:22,30-35`(唯一 importer),位置**正确**:恰在未受信任的 CMS 强转 `item.data as unknown as CaseStudyData` 边界处 `?? ''`/`?? []` 一次归一,`case-detail-page.tsx` 消费已定型 props 可信任(无第二个真实调用方)。周期 2 报告把它记在组件文件属**归属笔误**,非漏洞;**不**在组件内加冗余守卫(违反"不为不可能发生的场景加校验") | 仅更正周期 2 §1.2 的 file:line 归属 |
| DEC-N21 | P2→潜伏 | `'content-item'` 幻影 modelCode:周期 2 §5.3 以源码推翻「越权」定性,实为 **fail-closed**;真风险是将来被补进种子即成跨模型读取闸口。建议「缺 modelCode ⇒ 400」 | `admin/items/route.ts:79` |
| DEC-N21b | P2 | `/admin` 无服务端 gating(仅客户端跳转),HTML 外壳无会话也下发;数据 API 已守卫 | 无 `middleware.ts` |
| DEC-N30 | P1 | 四条互斥 SLA(工作日 2h / <4h / 48h / <2h·7×24)留哪一条 | 周期 1 §8.2 / 周期 2 §5.2 |
| DEC-D5 | P1 | `/products/erp-upgrade` 在线数字(`99.2%`/`40%+`/`从5天到1天`)在**活组件** `erp-upgrade-content-v2.tsx:63,69,77`,无 basis 无 MetricsBasisNote,且该路由无视觉基线 | 周期 2 §5.1 |
| DEC-D7 | P2 | news 正文字段声明 `richtext` 而 `renderField` 无该分支(能力缺口) | 周期 2 §5.3 |
| DEC-P2-motion | P2 | 动效门禁 23 处判红(20 缓动曲线 + 2 裸时长 + `--transition-gentle:1000ms`):改代码 or 承认为设计例外 | 周期 2 §3.5 |
| DEC-L3 | P3 | `client-ip.ts` 头部信任无条件;直暴露(非 nginx)时限流可被 `X-Real-IP` 自报绕过(须与部署协同,勿单独改) | 周期 2 §5.3 |
| DEC-L4 | P3 | 对称加密密钥来自公开 bundle 的 `NEXT_PUBLIC_ENCRYPTION_SECRET` ⇒ 仅混淆无机密性,文档口径不得当安全控制引用 | `crypto.ts:36` |
| DEC-P-2 | P3 | 死码族删除(`loading-state` 是 brand-token 唯一白名单豁免 N-8;另有 hero-section-v2/insight-card/industry-grid/三份 product-card/breadcrumb/micro-interactions/PullToRefresh 等) | 周期 2 §5.3 |
| DEC-N4-N23 | P1 | 安全头门禁默认打**线上** `novalon.cn` 而非本分支产物(声明与行为相反);改法涉 CI/`test:all` 语义 | 周期 1 N-23 |
### 2.3 待授权动作(阻塞验收;须逐项明文批准)
| ID | 动作 | 命令 | 为何阻塞 |
|---|---|---|---|
| **AUTH-D1** | **入库 / PR(验收唯一硬阻断)** | 先 `git fetch origin dev` 取真值 → 裁定双向分叉(周期 1 记 30↔31,两端同标题异 hash)重复提交对 → 按 5 组分笔提交 → Gitea PR(过 `check-pr-checklist.sh`) | 约 363 条改动只在工作树 ⇒ 本台账/两份报告结论均不可复现。**⚠ 分叉非快进整理,须先 fetch 再判** |
| AUTH-D2 | 重跑 seed | `npm run db:seed` | 使 L-2(seed 权限过期)修复对数据生效;现网 `dev.db` 仍为旧权限行 |
| AUTH-D3 | 视觉基线写入 | `npx playwright test visual-regression.spec.ts --update-snapshots` | 20 例缺参照恒红 + `erp-upgrade`/`about/brand`/`cases/[slug]` 三路由从无基线;追认现状需人工核看 85 张差异 |
| AUTH-D4 | 写库 E2E | 完整 `npm run test` / `test:e2e:prod`(差 `cms-workflow`+`user-journey` 共 11 例) | 真写 `prisma/dev.db`;只读边界已实证,此 11 例是唯一未执行的功能层 |
| AUTH-D9 | Sentry 实投递 | 真实 DSN 起服务验证事件与 source map 上传 | 接线惰性有码路证据已足,但投递未线上验证 |
| AUTH-D10 | 全量变异测试 | 去 `--inPlace`(config 默认沙箱)跑 `mutate` 全集 | 成本/磁盘(峰值 1.7G 曾致 ENOSPC,周期 2 §3.9);须 ≥2G 余量与无并发门禁窗口 |
| AUTH-D11 | 门禁证据落盘 | `check:axe` 默认 `OUT` 按日期分目录(同步 Jenkinsfile);`docs/acceptance/**` 是否纳入版本控制 | 现固定目录互相覆盖证据且未跟踪(周期 2 §3.9) |
### 2.4 已知不稳定清单(**禁止用 retry / 放宽阈值「修」**)
- `mobile-user-journeys` UJ-10(firefox,隔离复跑通过)、`website-acceptance:111` 响应式(隔离通过)、`@accessibility` webkit 四例(周期 1 N-32,隔离不复现)。同属**并发 / 墙钟**族(周期 1 N-28:同一代码三轮失败数 8→1→5)。墙钟型用例(`p4:31`、`p4:68`)不该进共享并发门禁。
## 3. 提交前强制提醒(承接 memory 纪律)
- 提交只加自己的路径,**勿 `git add -A`**:`prisma/dev.db-shm`/`-wal` 未被 `.gitignore` 覆盖(周期 1 §7),`git add -A` 会把 SQLite 边车与陈旧 `dev.db`/`data.db`(周期 2 §3.9)扫入。
- 破坏性 / 共享状态动作(fetch/rebase/commit/push/merge/seed/写库 E2E/写基线/force-push)须**单独明文授权**,不夹带在常规变更里。
- 会话结束前披露工作树状态(本台账 §0/§2.3 AUTH-D1 即披露)。
## 4. 第三轮审查轴(2026-09-25 本会话,subagent 并行 + 主线逐条复核)
两条并行 subagent:① 全部 `src/app/api/**` 路由处理器安全/正确性审计;② `src/lib/**`+`src/components/**` 逻辑缺陷狩猎。均要求 CONFIRMED 附 file:line + 触发条件,并给出已知项围栏以防重报。**主线未照抄任何一条子代理结论**:定点复核 → 对拟修复项做 RED→GREEN。
### 4.1 主线已复核确认 + 已修 + RED 测试(工作树内,未提交)
| ID | 级别 | 缺陷 | 触发/影响 | 修复 | RED→GREEN 凭据 |
|---|---|---|---|---|---|
| **Q-1** | **P1** | **登出未清除服务端 httpOnly cookie**:`auth-context.tsx` `logout()` 只清 localStorage;但 login 经 `setTokenCookie` 写 `novalon_token`(24h)/`novalon_refresh`(7d),而 `authenticateRequest`(`auth.ts:74`) 回退读 cookie。`/api/auth/logout` 路由**存在却从无调用者** ⇒ 点「登出」后全站受守卫 API 仍凭 cookie 放行至多 24h/7d | 后台「登出」是**假登出**;共享/被盗机器上会话延续 | `logout()` 先 `POST /api/auth/logout`(触发 `clearTokenCookie`),网络失败也清本地并跳转 | 新增 `auth-context.test.tsx`(2 例):断言 fetch 到 `/api/auth/logout`+POST 与网络失败仍清理;**改前 1 failed(fetch 0 calls)→ 改后 2 passed** |
| **Q-2** | **P2** | **缩略图格式伪标**:`image-processor.ts` thumbnail 声明 ext `'webp'`(`buildDerivative(..., 'webp', ...)`)但缓冲只 `.resize().toBuffer()` 未调 `.webp()` ⇒ PNG/JPEG 上传的 `-thumbnail.webp` 实为原格式字节,Content-Type 与内容不符、零 WebP 压缩收益 | 落盘文件内容 ≠ 扩展名/MIME;`source type` 消费方受影响 | 缩略图管道加 `.webp({quality:80})` | 既有测试只断路径/尺寸、**从不验格式**(覆盖空洞);新增例断 `sharp(thumb.buffer).metadata().format==='webp'`(PNG/JPEG 双输入);**改前 `Received:"png"` → 改后 media 4 套件/60 例全通过** |
| **Q-3** | **P2** | **登录端点无限流**:`contact` 有 IP 限流但 `POST /api/auth/login` 无任何节流 ⇒ 匿名者可无限驱动 cost-10 bcrypt(CPU DoS)+ 离线式撞库 | 凭据填充 / CPU 耗尽 | 抽出可复用 `src/lib/rate-limit.ts`(时钟可注入 + `reset()`,**放进覆盖率棘轮内**),登录按 IP `max:10/15min` 且**判定在 bcrypt 之前**;contact 重构复用同一实现(消除副本) | 新增 `rate-limit.test.ts` 4 例(边界/独立 key/窗口过期/reset)+ 扩展 `login/route.test.ts` 一例:前 10 次 200、第 11 次 429 且 `verifyPassword` **仍只 10 次**(证限流省掉 bcrypt);**4/4 + 全 login/contact/auth 73 例通过**,`type-check` EXIT=0,改动文件 `eslint --quiet` 0 error |
| **Q-4** | **P2** | **停用账号令牌在 6 个内联 role handler 仍全权(N-20 同类,绕开已收口的 `checkUserPermission`)**:`admin/users` GET/POST/PUT/DELETE 与 `admin/roles` GET/PUT 用 `authenticateRequest`(仅验签名,不看 `User.status`)+ 内联 `userRole` 检查。⇒ 被停用但持 ≤24h 有效令牌的 super_admin 仍能列/建/改/删用户、重写权限矩阵 | 停用/离职管理员的残留令牌保留完整后台写权 | 抽出可复用 `authenticateActiveRequest(request)`(`permissions.ts`,`prisma.user.count({id,status:1})` 判活),**6 个 handler 全部改用它**并在角色检查/任何写入前 fail-closed | 在 `users/route.test.ts` 与 `roles/route.test.ts` 各加 2 例(停用超管 → 401 且 `user.create`/`user.update`/`$transaction`/`permission.*` 均未触达);既有 30+ 例在默认存活下保持通过 ⇒ **admin users/roles/permissions 3 套件/54 例全绿**,`type-check` EXIT=0,5 改动文件 `eslint --quiet` 0 error |
| **Q-5** | **P2** | **响应式网格被 `StaggerReveal` 单子吞成单列**:`content/sections.tsx` 的 `TechStackShowcase`/`DataProofSection` 把 `grid md:grid-cols-N` 挂在外层 div,而该 div 的唯一子元素是 `StaggerReveal`(渲染一个 motion.div,再为每卡片包 motion.div)⇒ 网格只有 1 个网格项,卡片在 md/lg 断点堆成单列、`gap`/`space-y` 失效;`FAQSection` 的 `space-y-3` 同理被吞。**这些组件是活的**(`product-detail-content-v3.tsx:335,343,357` 渲染产品详情页)。正确写法见 `l3-signals-slot.tsx:109-119`(网格类挂 StaggerReveal,其注释即记此坑) | 产品详情页技术栈/数据佐证/FAQ 桌面端单列,UX 退化 | 把网格/间距类移到 `StaggerReveal className`,删除多余外层 grid div(FAQ 仅移 `space-y-3`,保留 col-span 外层) | 在 `sections.test.tsx` 加 3 例,断言 `[class*="grid-cols-3"]`/`grid-cols-4`/`space-y-3` 元素的**直接子元素数 == 条目数**(旧结构下恒为 1 ⇒ RED);sections.test **28 例全通过**,`type-check` EXIT=0,`eslint --quiet` 0 error |
| **Q-7** | **P3** | **列表路由分页参数未校验(NaN→500 + 无界 take)**:`admin/items` GET 用 `parseInt(searchParams.get('page') \|\| '1')` ⇒ `?page=abc`→NaN→`skip:NaN`→真库 Prisma 抛错→500(应安全回落);`?pageSize=99999`→无界 `take`(任何 read 权限者可整表拉取大 `data` JSON)。`admin/users` GET 有 `Math.max/min` 夹取但 `Math.max(1,NaN)` 仍是 NaN ⇒ 同类未愈 | 500 崩溃 + 无界读取 | 抽出可复用 `src/lib/pagination.ts` `parsePagination(sp,{defaultPageSize,maxPageSize})`(NaN/0/负数→默认,超上限→夹取,取整),**items + users 两路都接入**(max 100) | `pagination.test.ts` 6 例(缺省/合法/非数字回退/夹取/取整,含"移除即崩"的核心 NaN 面)+ `items/route.test.ts` 1 例(`?page=abc&pageSize=99999` ⇒ 200 且 `findMany({skip:0,take:100})`,旧码为 `skip:NaN/take:NaN` ⇒ RED)。**pagination 6 + items 38 + users 18 全通过**,`type-check` EXIT=0,改动文件 `eslint --quiet` 0 error |
| **Q-8** | **P3** | **内容 ID 存在性枚举 oracle**:`admin/items` PUT/DELETE 与 `admin/items/[id]/workflow` POST 都先 `contentItem.findUnique`→`notFound('内容不存在')` **再** `requirePermission` ⇒ 匿名调用者可用「404=该 ID 存在 vs 401/403=权限屏障」枚举有效内容 ID(无写入先于鉴权,纯信息泄露面) | 未鉴权可枚举内容 ID | 三个 handler 均在存在性探针**之前**加 `if (!authenticateRequest(request)) return unauthorized();`(先认证→再 404→再 per-model 权限),关闭匿名枚举;记残留:已登录但无该模型权限者仍能分辨 404/403(把 notFound 与 forbidden 合流需改契约,未做) | `items` 2 例 + `workflow` 1 例:匿名 PUT/DELETE/workflow ⇒ **401 且 `findUnique` 未被调用**(证探针在认证门之后);两测试文件补 `jest.mock('@/lib/auth')` 默认有效会话使既有授权用例不受影响 ⇒ **items+workflow+pagination 3 套件/47 例全通过**,`type-check` EXIT=0,5 改动文件 `eslint --quiet` 0 error |
| **Q-10a** | **P3** | **`ui/button.tsx` 涟漪 id 用 `Date.now()`**:同毫秒两次点击 ⇒ 重复 React key + 清理 `filter(r=>r.id!==id)` 会一次删掉同 id 的多条涟漪;且 600ms `setTimeout` 卸载时从不清除(setState-after-unmount / 定时器泄漏)。Button 是全站在用组件 | 真实浏览器下重复 key 告警 + 卸载后定时器泄漏 | 涟漪 id 改自增 `useRef` 计数器(保证唯一);定时器收入 `useRef` 数组,`useEffect` 卸载时 `clearTimeout` 全部 | **诚实标注**:真正判别性(重复 key)在本环境不可测——做过 RED 探针(还原 `Date.now()` 后断言仍绿),因 Jest 跑 production React 构建、dev key 告警被剥离,React 18 也不再就 setState-after-unmount 告警。故只补**被改代码路径**的行为用例(点击→涟漪出现→600ms→清除,能捕获"永不清除/不出现"类回归),不谎称它是缺陷锁。**button 21 例通过**,`type-check` EXIT=0,`eslint --quiet` 0 error |
| **Q-10c** | **P3(隐私/同意 fail-open)** | **`analytics.ts` `getStoredPreferences` 把 `JSON.parse(...) as CookiePreferences` 原样透传,无字段校验**:而 `updateConsentDetailed` 用 `preferences.analytics ? 'granted':'denied'` 判定 ⇒ 被篡改/写坏的 `localStorage`(如 `{"analytics":"yes","marketing":1}`,或数组/数字等非对象)会以真值非布尔**意外授予** analytics/ad 同意 | 同意管理可被污染存储绕过(GDPR/隐私面) | 新增 `sanitizePreferences(raw)`:四个开关严格 `=== true` 才算同意(否则 false),`necessary` 恒真,`timestamp` 仅数字保留;`getStoredPreferences` 走它 | 2 例真判别(未修即 RED:旧码原样返回 `'yes'/1`,断言 `false` 必红):truthy 非布尔→false、非对象载荷→全 false 且 necessary 恒真;既有有效对象用例(`{necessary:true,analytics:false,marketing:true,functionality:true}`)经 `toEqual` 仍通过 ⇒ **analytics 31 例全通过**,`type-check` EXIT=0,`eslint --quiet` 0 error |
| **Q-10b** | **P3** | **`api-crypto.ts` 用客户端 `content-length` 判定是否有请求体**:`hasBody = contentLength && parseInt(contentLength) > 0`。加密的 POST/PUT/PATCH 若走**分块传输**(无 `content-length` 头,或该头被省略/伪造为 0)⇒ 被判「无 body」跳过解密,密文 `{data:"..."}` 原样交给 handler(`isEncrypted:true` 但体未解),handler `.json()` 拿到信封而非真实字段 | 加密通道在分块/缺头时静默不解密(功能正确性/健壮性) | 改按 **HTTP 方法**判体存在:`method ∈ {POST,PUT,PATCH}` 即尝试读体+解密(空体自然落回原路径),不再信 content-length | 新 1 例真判别(RED 探针实测:还原 content-length 版 ⇒ 该例判红;恢复 ⇒ 通过):加密 POST **无 content-length** ⇒ 断言 `mockDecrypt` 被调用且 handler 收到解密体;既有 26 例(全部 method+content-length 配对)不受影响 ⇒ **api-crypto 27 例通过**,`type-check` EXIT=0,`eslint --quiet` 0 error |
| **Q-9** | **P3** | **`admin/media` 多文件上传无原子性**:上传循环内 ① 某文件超 10MB 直接 `return validationError`,但**前面文件已 `uploadMedia` 落盘+入库**;② 某文件在 `uploadMedia` 内被 magic-byte/类型策略拒(抛 `UploadPolicyError`,外层 catch 返 400)同样留下前面已写入的文件 ⇒ 请求返回错误却已产生孤儿媒体资产(磁盘 + `MediaAsset` 行),无返回、无清理路径 | 半途失败留下孤儿资产(磁盘/DB 泄漏 + 计费/存储面) | 把尺寸校验提到**任何写入之前**(整批预检);上传循环包 try,失败时对本请求已成功的 `results` 逐个 `deleteMedia` 回滚再返回错误 | 新 2 例真判别(旧码必 RED):① 第二个文件抛 `UploadPolicyError` ⇒ 断言 `mockDeleteMedia('asset-1')` 被调用(旧码无回滚 ⇒ 不调用);② 批次含一个超限文件 ⇒ 断言 `mockUploadMedia` 从未被调用(预检在任何写入前拒整批)。既有单/多文件成功用例仍通过 ⇒ **media route 14 例通过**,`type-check` EXIT=0,`eslint --quiet` 0 error |
### 4.2 子代理上报、**主线尚未逐一复核**(登记为待验证,不当作已确认,不据此宣称已修)
| ID | 级别 | 摘要 | 位置(子代理所引) | 主线状态 |
|---|---|---|---|---|
| Q-4 | ~~P2 待复核~~ | ~~6 个内联 role 数组的 handler~~ | — | **已复核确认为真缺陷并已修**(见 §4.1 Q-4):status 维度缺位属实,role 授予逻辑本身仍正确 |
| Q-5 | ~~P2 待复核~~ | ~~外层 grid/space 被 StaggerReveal 单子吞成单列~~ | `content/sections.tsx` | **已复核确认为真缺陷并已修**(见 §4.1 Q-5):3 处组件为活代码(产品详情页),已把网格类挂回 StaggerReveal 并加 3 例结构断言 |
| Q-10 | P3 | 四条子代理上报:① `api-crypto.ts` `hasBody` 信任客户端 `content-length`;② `workflow.ts:84` `updateMany`+`auditLog.create` 非 `$transaction`;③ `analytics.ts:33` 同意偏好无字段校验;④ `button.tsx` ripple `Date.now()` 键碰撞 + 卸载不清 timeout | 各处 | **④=Q-10a、③=Q-10c、①=Q-10b 均已修**(见 §4.1,各带判别测试)。**仅 ② 未做**:修法需 interactive `$transaction(async tx=>…)`(update→读回→审计有数据依赖),会牵动 workflow 路由测试的 prisma mock 边界,风险高于其 P3 权重,暂缓并附精确配方 |
| Q-6 | P2 | `detail-product-value.tsx:73-91` render 体内 `setDisplayValue + requestAnimationFrame`(StrictMode/重渲染派生多串未取消动画) | 组件 `detail-product-value.tsx` | **待复核**:grep 显示该组件仅被 `products/erp-upgrade-v3/page.tsx` 引用,而周期 2 判定 `erp-upgrade-v3` 为**死路由** ⇒ 若确死则非在线缺陷,仅登记;render 副作用面仍宜在组件复用时修 |
| Q-7 | ~~P3 待复核~~ | ~~`admin/items`/`users` GET 分页未校验~~ | — | **已复核确认为真缺陷并已修**(见 §4.1 Q-7):新增 `parsePagination` 复用件,items + users 两路接入,6+1 例覆盖 |
| Q-8 | ~~P3 待复核~~ | ~~内容 ID 存在性 oracle~~ | — | **已复核确认为真缺陷并已修**(见 §4.1 Q-8):三 handler 认证前置于存在性探针,3 例证 `findUnique` 不被匿名触达 |
| Q-9 | ~~P3 待复核~~ | ~~多文件上传中途失败留孤儿~~ | — | **已复核确认为真缺陷并已修**(见 §4.1 Q-9):整批尺寸预检 + 失败回滚已成功文件,2 例真判别 |
### 4.3 主线**证伪**的子代理结论(不修,防把假发现写进代码/文档)
- **Q-FP-1**:子代理称 `detail-product-value.tsx:69` `parseFloat(value.replace(/[^0-9.]/g,''))` 把 `"1,000+"` 变成 `1`(千倍低估)。**不成立**——`[^0-9.]` 会删掉逗号、保留数字与点,`"1,000+"`→`"1000."`→`parseFloat=1000`,正确。故该解析点**不是缺陷**(其 render-副作用面另见 Q-6,属不同问题,仍待复核)。
> **本轮验证层级诚实说明**:`type-check` 为**全程序** `tsc --noEmit` 且 EXIT=0(覆盖 Q-1/2/3 全部改动);受影响单元套件(auth/login/contact/media/rate-limit)**73 例全通过**;改动文件 `eslint --quiet` **0 error**。但**全量 `test:coverage`(1725 例)本轮未重跑**,且 Q-1(表现层登出)与 Q-3(登录 API 行为)**不在**任何已跑过的 E2E 子集里 ⇒ 其生产目标凭据待 AUTH-D1 入库 + AUTH-D4 后统一复跑。Q-2(media 派生)真 `sharp` 单测覆盖格式,最直接。**Q-4 的凭据层级**:6 个 handler 的存活判定由 `admin/users`+`admin/roles` 路由级 jest(mock prisma.user.count)端到端覆盖(默认存活 30+ 例仍绿 + 停用 4 例判 401/零写入),属**单测层**,非 E2E;其"移除 helper ⇒ 停用令牌被放行"的 RED 逻辑由"旧码走 `authenticateRequest` 不看 status、新 mock 的 `count` 遂成死参数、断言 401 必红"反证。**验收判定不变**:新增修复同样只在主工作树,AUTH-D1 仍是唯一硬阻断。
### 4.4 本会话全量回归证据(2026-09-25,覆盖 Q-1…Q-5/Q-7/Q-8 全部改动之后)
| 门禁 | 命令 | 结果 |
|---|---|---|
| 全量单元 | `npm run test:unit` | **142 套件 / 1760 例全通过,0 失败**(较周期 2 的 137/1725 净增 5 套件 35 例,全为本会话新增 RED 测试;无一处回归) |
| 覆盖率棘轮 | `npm run test:coverage` | **EXIT=0,阈值全达标**:global **85.96 stmts / 87.57 branch / 81.38 funcs / 85.96 lines**(门限 75/82/75/75 **未下调**);本会话新增 `src/lib/pagination.ts`、`src/lib/rate-limit.ts` 各 **100/100/100/100**(在 `collectCoverageFrom` 的 `src/lib/**` 内,随棘轮受约束) |
| 真库集成层 | `npm run test:integration:real` | **EXIT=0,4 套件 / 23 例全通过**;一次性 `/tmp/novalon-itest.db`,teardown 守卫实测 `prisma/dev.db`、`dev.db`、`data.db` 的 inode+size+mtime 与运行前完全一致 ⇒ 本会话对 `permissions.ts`(新增 `authenticateActiveRequest`)与 users/roles/items/workflow 鉴权路由的改动在真库鉴权层无回归,且未污染开发库 |
| 静态 a11y(对比度/令牌) | `npm run check:contrast` + `check:brand-token` | 两者 **EXIT=0**:contrast 44/44 令牌配对满足 WCAG 2.1 AA;brand-token 扫描 406 文件 **0 违规**(1 处已知白名单降级 = `loading-state.tsx` 死组件,见 N-8/D-8)⇒ 本会话改动无对比度/品牌令牌回归 |
| 生产构建 + 装配 | `npm run build`(含 postbuild 拷贝) | **EXIT=0**,`dist/standalone/{server.js,dist/static,public}` 三项齐备(`ASSEMBLY_OK`)⇒ 本会话对 `api-crypto`/`permissions`/users·roles·items·workflow·media 路由的服务端改动可干净构建(N-9 前提成立);产物 `heading-hierarchy-report.json` 等落盘物均被 `.gitignore` 覆盖,不脏提交面 |
| 标题层级(产物上) | `npm run check:headings`(自起 standalone 扫 10 页) | **EXIT=0**:10 页全通过、0 问题 ⇒ 与 contrast/brand-token 合起来 **完整 `check:a11y` 三件套在本会话构建产物上全绿**(不再仅沿用周期 1/2 结论) |
| Lighthouse(产物上) | `npm run lighthouse`(lhci autorun 自起 standalone) | **EXIT=0**:对 9 URL × 3 次运行 = 27 次跑的全部断言通过(含本会话前几轮新加的 `inspector-issues` minScore 断言)⇒ 性能/a11y/BP/SEO 门禁在当前源码产物上仍达标 |
| 类型检查 | `npm run type-check`(全程序 tsc) | **EXIT=0** |
| Lint | `npm run lint` | **0 error / 104 warning**(与周期 2 口径 104 完全一致 ⇒ 本会话新文件 `rate-limit.ts`/`pagination.ts` 等**零新增债**) |
> 说明:**本会话已在含全部 Q 修复 + S-1 修复的最终树上重跑并 EXIT=0**:`test:unit`(1758)、`test:coverage`(阈值棘轮)、`test:integration:real`(23)、`check:contrast`/`check:brand-token`/`check:headings`(完整 check:a11y)、`npm run build`+装配、`npm run lighthouse`;`type-check`/`lint` 全程 EXIT=0。**仍未在本会话重跑**:全站 `check:axe`(需自起 standalone + crawl-routes 清单,端口/残留进程需专管)、生产目标 E2E(**注意 `npm run test:e2e:prod` 不排除写库 spec,跑它即触发 AUTH-D4**)、视觉只读比对(Q-5/S 系列改了布局,需 AUTH-D3 重基线)。周期 1/2 的这三项结论仍登记于各自报告。
## 5. 第四轮审查轴(2026-09-25,2 路并行 subagent:`src/app/**` 页面 + 余下 `src/components/**`)
两条只读 subagent 分别扫前几轮**只 grep 未逐行**读过的面。主线**未照抄任何一条**:S-1 我逐行核实并修复;其余登记为「待主线复核」,其中标注与前序已知项同机制的(Q-5/C-1 新点位)优先,因为修法现成。
### 5.1 主线已核实 + 已修(工作树内,未提交)
| ID | 级别 | 缺陷 | 触发/影响 | 修复 | 凭据 |
|---|---|---|---|---|---|
| **S-1** | **P2** | **首页 CMS 数组未逐条守卫 ⇒ 整页 RSC 崩溃**:`home-content-v15.tsx` 的 `ServicesSection` 只在 `:510` 校验 `services[0].subtitle`,随后 `:546` `service.highlights.map`(**无 `?.`/`Array.isArray`**,而 `:552` metrics 却有 `&&` 守卫);`CasesSection` 的 spotlight `:789` 用 `?.` 但 `restCases` `:828` `caseItem.metrics.slice` **未守卫** | 发布任一缺 `highlights`/`metrics` 的服务或案例 ⇒ 流量最高的首页 500 到 error.tsx | 两处改 `Array.isArray(x) ? x : []` 逐条守卫(与既有 `?.` 惯例一致) | `type-check` EXIT=0、`eslint --quiet` 0 error(改动文件);纯渲染守卫,行为对合法数据不变 |
### 5.2 子代理上报、待主线逐一复核(**不当作已确认/已修**)
| ID | 级别 | 位置 | 摘要 | 复核优先级/备注 |
|---|---|---|---|---|
| S-2 | P2 | `products/product-detail-content-v3.tsx:178,231` + `solutions/solution-detail-content-v3.tsx:121,168,223,278` | grid/`space-y` 挂在外层 div、唯一子为裸 `StaggerReveal` ⇒ `/products/[id]`、`/solutions/[id]` 桌面端塌成单列(**Q-5 同机制的新点位**) | **已复核+已修(6 处)**:同文件 :132 已是正确写法可佐证。安全修法——把网格/间距类移到 `StaggerReveal className`、外层 `<div>` 留作无类包裹(**不删闭合标签**,规避 Q-5 那次改动风险)。content-v3 三套件 25 例通过 + `type-check` EXIT=0 + `eslint --quiet` 0 error。需 AUTH-D3 视觉重基线确认像素 |
| S-6 | ~~P3 误报~~ | `cases/[slug]/page.tsx:27-34` | ~~`generateMetadata` 绕过 C-1 归一~~ | **主线核实为误报,未改**:`generateMetadata` 已用 `data.challenge?.substring` / `data.metrics?.map`,对**缺键**的保护与 `client.tsx` 的 `?? []` 同级,并非"绕过 C-1"。唯一残余是**类型错误值**(如 metrics 存成字符串),但那对 client 路径同样会崩,属通用运行时类型校验缺失(非元数据专属崩溃),不在本轮改 |
| S-3 | P2 | `about/page.tsx`、`contact/page.tsx`、`products/erp-upgrade/page.tsx` | 无 `export const revalidate`,且 `/api/cms/revalidate` 未映射这些模型 ⇒ 构建后发布新内容永不刷新(并含「`revalidateTag` 因 `data-server.ts:37` 用 React `cache()` 而非 `unstable_cache` 实为 no-op」的重断言) | 高但**涉渲染配置/缓存语义**,须逐条核 next 行为再定改否(可能引 ISR/构建变化) |
| S-4 | P2 | `home-content-v15.tsx:32-43`(用于 :510-516) | `FALLBACK_SERVICES` 兜底含 `40%`/`6个月` 等**无 basis 的数字**,N-30 类第三处残留,违背零编造 | 中:修法=删兜底 metrics(与 N-30 一致);属内容口径,宜确认后改 |
| S-5 | ~~P2 待复核~~ | `detail/detail-hero.tsx:171-184` + `micro-interactions.tsx:148-153` | `<a href><PressableButton/></a>`,而 PressableButton 渲 `<motion.button>` ⇒ button 嵌进 a(无效交互嵌套) | **已复核+已修**:根因在 `PressableButton` 的 href 分支本就产 `<a><button>`。改为有 href 时渲单个 `motion.a`(承载按钮样式),`detail-hero` 去掉外层 `<a>` 直接传 `href`;`onClick`/样式保留,导航语义不变。新增 `micro-interactions.test.tsx` 2 例真判别(旧码 `querySelector('button')` 非 null ⇒ RED;且发现 `detail.test.tsx` mock 掉了 PressableButton,故测真组件须单列)|
| S-7 | P3 | `services/[id]`/`solutions/[id]`/`products/[id]`/`news/[slug]` 的 page | `dynamic='force-dynamic'` 与 `generateStaticParams`/revalidate 冲突 ⇒ SSG 产物永不服务,注释与单测给假信心 | 中:涉渲染模式口径,须核 next16 行为再定 |
| S-8 | ~~P3 待复核~~ | `hero-particle-field.tsx:287-290` | `onVisibility` 未判相交态即 `start()` ⇒ 离屏 canvas rAF 空转 | **已复核+已修**:加 `intersecting` 跟踪(IO 回调置位),`onVisibility` 改为「可见且 `intersecting`」才启动,与 :271 契约对齐。凭现有 hero-particle-field 三套件/36 例(含 reduced-motion 降级、unmount 停环)无回归 + `type-check`/lint 绿;**未加专测**(jsdom 下 visibilitychange+IO 离屏组合难做判别性 RED,诚实标注)。首页活组件(home-content-v15) |
| S-9 | ~~P3 待复核~~ | `back-to-top.tsx:12-20`;`detail-trust-section.tsx:18-65` AnimatedCounter | 挂载未初始同步;裸 rAF 计数不查 `useReducedMotion` | **两处均已修**:back-to-top effect 内补 `handleScroll()` 初始同步;AnimatedCounter 在 `shouldReduceMotion` 时直接落终值不跑 rAF(`useReducedMotion` 自绘,因 `MotionConfig` 不管裸 rAF)。back-to-top by-construction(jsdom `scrollY` 只读难判别,未加专测,诚实标注);AnimatedCounter 由 detail.test 27 例 + 全量 `test:unit` 142/1760 + `type-check`/lint 绿佐证无回归 |
| S-10 | P3 | `admin/content/[modelCode]/page.tsx:210` 状态误标(review→「已归档」);`admin/roles/page.tsx:59-79` effect 依赖 `selectedRole` 覆未保存编辑;`admin/users/page.tsx:85` 搜索无防抖/序号守卫(`content/[modelCode]` 已修,此处未);`admin/media/page.tsx:38,131` 单 `fileInputRef` 挂两个 input ⇒ 顶部按钮重选同文件不触发 onChange;`admin/page.tsx:108`/`media/page.tsx:40` 吞错显示「空」非报错;`admin/login/page.tsx` label 无 htmlFor、密码切换钮无 aria-label | 后台多处 P3(错标/竞态/吞错/a11y 标签) | 中:逐条小修,部分与既有模式(防抖/seq)现成可搬 |
> 诚实边界:S-2…S-10 为 subagent 上报,**我尚未逐行独立复核**,不作「已确认/已修」主张;S-1 是我本轮亲自核实并修复的唯一项。子代理另排除了 `cta-section`(零 importer,其「30 分钟内响应」是 DEC-N30 的第 5 条 SLA 变体,仅附记)、Radix 包装件、`slider`(零消费者)等——同样待我复核后再采信。
## 6. 会话收尾与续工交接(2026-09-25,subagent 架构系统性 QA)
> 本文件(`qa-tracker.md`)即 objective 阶段3 要求的「问题跟踪机制」单一真源;§4 为第三轮(API+lib/组件)、§5 为第四轮(src/app 页面 + 余下组件)审查轴。
### 6.1 本会话已完成(均在工作树,**未提交**)
- **已核实并修复的缺陷(含判别性测试):Q-1、Q-2、Q-3、Q-4、Q-5、Q-7、Q-8、Q-9、Q-10a、Q-10b、Q-10c、S-1、S-2(6 点位)、S-5**;**S-8、S-9** 经检查修复(诚实标注:jsdom 无法做判别性 RED,仅 by-construction + 现有套件/类型门禁)。
- **主线证伪、未改的子代理断言**:`1,000+→1` 解析(实为 1000,正确)、C-1 归属笔误、S-6(metadata 已 `?.` 守卫,非绕过 C-1)。
- 自纠:一次 Edit 误删既有测试体(已复原);一次写了不能失败的假测试(已换成诚实版);一次 §4.4 表格被打断+留陈旧说明(已合并修正)。
### 6.2 本会话门禁证据(最终树,全部 EXIT=0,见 §4.4 表)
`test:unit` **142 套件/1760 例** · `test:coverage`(阈值未下调,新 lib 100%)· `test:integration:real`(23,`dev.db` 指纹未变)· 完整 `check:a11y`(contrast 44/44 · headings 10/10 · brand-token 0 违规)· `npm run build`+standalone 装配 · `npm run lighthouse`(9 URL×3)· `type-check` · `lint` 0 error/104 warning。
### 6.3 工作树披露(最高优先,勿静默留给下一个人)
`git status --porcelain` = **378 条**(54 未跟踪),HEAD 仍 `f543e47`。⇒ 两轮 + 本会话全部修复**只在工作树**,未提交/未 PR ⇒ 不可审、不可复现、不可 CI 验证。**这是 objective 阶段5「可交付」未达成的唯一实质原因。**
> **2026-09-28 第五轮后刷新**:`git status --porcelain` = **394 条**(55 未跟踪),HEAD 仍 `f543e47`。本轮净增 16 条改动/新文件(含新测试文件 `src/app/__tests__/cms-page-revalidate.test.ts`)。结论不变。
### 6.4 续工:待授权动作(逐项明文批准后才动;见 §2.3 / 周期报告 §5)
1. **AUTH-D1 入库/PR**(解锁验收):先 `git fetch origin dev` 取真值 → 裁定 `origin/dev`↔HEAD 双向分叉的重复提交对 → 按「表现层/后台编辑器/Sentry/测试装配/文档口径 + 本会话 Q/S」分笔提交 → 过 `scripts/check-pr-checklist.sh` → Gitea PR。**`git add` 按路径,禁 `-A`**:`.gitignore` 未挡 `prisma/dev.db-shm`/`-wal`,`-A` 会带入 SQLite 边车。
2. **AUTH-D4 写库 E2E**(11 例,真写 `dev.db`;⚠ `npm run test:e2e:prod` 不排除写库 spec,跑它即写库)。
3. **AUTH-D3 视觉基线**:Q-5/S-2/S-5 改了布局,产品/方案/案例详情页需人工核看后 `--update-snapshots`。
4. **AUTH-D2 seed**:使周期2 L-2 权限修复对数据生效。
### 6.5 续工:待复核/待决策的登记项(不阻断验收)
S-3(about/contact/erp-upgrade 无 revalidate + revalidateTag no-op,涉渲染配置)、S-7(force-dynamic vs generateStaticParams)、S-4(FALLBACK_SERVICES 无 basis 数字,宜按 N-30 删)、S-10(后台状态误标/搜索竞态/媒体双 input ref 冲突/登录表单 label 关联)、S-9 子项(detail-trust 裸 rAF 未查 reduced-motion)、以及周期1/2 遗留的 D-1…D-11 / DEC-* 决策项。
## 7. 第五轮审查轴(2026-09-27…28 会话,4 路并行 subagent + 主线逐条复核/修复)
承接 §6.5 的续工清单:S-3/S-4/S-7/S-10/Q-6/Q-10② + 未逐行覆盖的余下面(hooks、lib/cms 渲染器、seo/analytics 组件、marketing 残余页面)。主线未照抄任何子代理结论:逐条亲读源码复核后再修,修复均带判别性测试(RED→GREEN 实测)。
### 7.1 主线已复核并修复(工作树内,未提交)
| ID | 级别 | 缺陷(主线核实后口径) | 修复 | RED→GREEN 凭据 |
|---|---|---|---|---|
| **S-21**(=DEC-D7 落地) | **P2** | `renderField` 无 `richtext` 分支 ⇒ news 正文/legal 正文落 default 单行 `input[type=text]`,浏览器 value 消毒剥掉全部 `\n`,编辑一次即**静默损毁段落**(详情页 whitespace-pre-line 渲染) | richtext 走 `textarea`(rows=12);顺带收敛出作用域读写机制 | 新增 3 例读写保真测试;改前 `Expected TEXTAREA, Received INPUT` 判红 → 改后编辑器套件 **22/22** |
| **S-22** | P3 | array/object 子字段编辑器读写**顶层** `formData[field.name]`:编辑写不进数组,却把 `label/value` 垃圾顶层键混进保存载荷(假成功) | array 改整体 JSON 编辑(复用 JsonEditor);object 子字段以 `scope{parent,write}` 读写父对象并合并回写 | 同上:改前 `getByLabelText('指标')` 无 TEXTAREA / `作者` 值为空 判红;改后载荷断言 `metrics` 回写数组、无泄漏顶层键 |
| **Q-10②** | **P2** | `workflow.ts` 状态更新→读回→审计三条独立写:审计失败时**状态已落库但无审计**,调用方收 500 误以为流转未发生 | 交互式 `prisma.$transaction(async tx …)` 包住 CAS→读回→审计;通知保持在事务外(不回滚不上抛的既有语义不变) | 测试 mock 增加带快照回滚的 `$transaction` 桩(数组形态直接判红防退化);升级既有「审计失败 fatal」例为回滚断言:改前 storedItem 停在 published 判红 → 改后 28/28;**真库集成层 `test:integration:real` 23/23 EXIT=0(dev.db 指纹守卫通过)** |
| **S-3** | **P2** | `/about`、`/contact`、`/products/erp-upgrade` 无 `revalidate` 导出且 revalidate 路由无映射 ⇒ 构建后发布的 CMS 内容**永不刷新**(子代理实证:三页在 `prerender-manifest` 为 `static + initialRevalidateSeconds:false`)。附带断言 `revalidateTag` 全程 no-op **成立**(全站无 `unstable_cache`/`'use cache'`/fetch tags 消费点,React `cache()` 仅请求内去重) | 三页加 `export const revalidate = 3600`(与其余 14 页同档)+ 路由补 about-page/contact-page/product→`/products/erp-upgrade` 三条映射(推送即秒级失效,3600 为无推送兜底) | 新增 6 例(3 route 映射 + 3 页 revalidate 导出,新文件 `src/app/__tests__/cms-page-revalidate.test.ts`):改前 6 例全判红 → 改后 25/25;**产物级实证**:重建后 `dist/prerender-manifest.json` 三页 `initialRevalidateSeconds=3600` |
| **S-10 六条** | P3×6 | ①列表状态列把 review/archived 误标「已归档」②roles 的 `fetchRoles` 依赖 `selectedRole`,切角色 refetch 覆盖未保存编辑 ③users 搜索逐键打后端且无 seq 守卫(乱序响应覆盖新结果)④media 双 input 同挂一个 ref(重选同文件不触发 onChange)⑤admin 首页/media/zones 吞错显「空」(假绿)⑥login/zones/users 弹窗 label 无 htmlFor、密码显隐钮无可访问名 | ①四态 STATUS_LABELS 映射 ②`fetchRoles` 依赖清零 + `setSelectedRole(prev=>prev||…)` ③搬 SEARCH_DEBOUNCE_MS+fetchSeqRef 现成模式 ④全页单 input + 程序化 click ⑤error state + 横幅 ⑥补 id/htmlFor + `aria-label` 随状态切换(实施走 general-purpose subagent,主线逐 diff 复核 + `selectedRoleInfo` 等符号存在性核实) | 改动 8 文件 eslint --quiet 0 error;`content/[modelCode]/page.test.tsx`(防抖+乱序 2 例)通过;全量回归见 §7.4 |
| S-11/S-12/S-13/S-15 | P3 | zones 吞错显空 / notifications 翻页无 seq 守卫 / roles 矩阵 super_admin 全显「未授权」与「拥有所有权限」文案矛盾 / zones·users label 关联(S-10 同族扩散) | 与 S-10 一并修(error 横幅、fetchSeqRef、super_admin 整行按已授权呈现、htmlFor) | 同上 |
| **S-23** | P3 | `ui/animated-counter.tsx` `displayValue = start ? count : value`:未入视口直显**终值**,IO 异步首回调后跳回 0 再爬升(先给答案再数) | `displayValue = shouldReduceMotion ? value : count`(useCountUp 未启用期本就停在起点 0) | 新 1 例(IO 桩驱动):改前判红(旧码未相交即显 500)→ 改后 6/6;既有用例改 `startOnView={false}` 语义不变 |
| **S-24** | P3 | `PerformanceTracker` CLS 累加器在 observer 回调**内**:分批到达的 layout-shift 每批只报增量,GA 收到的永不是会话总 CLS | 累加器提到回调外,每批上报累计值 | 新 1 例双批次断言 [≈100, ≈150](旧码第二批报 50 ⇒ RED 由形状显然)→ 改后 17/17 |
| **Q-6** | P3 | `detail-product-value.tsx` MetricCard render 体内 setState+rAF:探针实证**每帧 setState 触发重渲染又 spawn 一条从 0 开始的动画链且从不 cancel**,假时钟下大字永远停在 '0';卸载后帧链继续排队 | 动画收入 `useEffect`,cleanup `cancelAnimationFrame`;NaN 回退同 effect | 新 1 例(faithful rAF 桩:cancel 真撤销排队):改前判红(旧实现卸载后 sched 继续增长)→ 改后 28/28。**注**:第一版测试命中 benefits 同名文本属假绿,已被探针推翻重写(诚实记录) |
### 7.2 主线复核后的定性变更(不改代码,只改台账口径)
| ID | 原登记 | 复核结论 |
|---|---|---|
| Q-6 路由定性 | 「`erp-upgrade-v3` 死路由 ⇒ 若非活码仅登记」 | **定性修正**:不是死路由,是**零入口孤儿路由**(不在 sitemap、src 内零内部链接,但静态可直达且有真实渲染)。缺陷本身属实已修(见 §7.1),影响面限直达 URL |
| S-4 | 「FALLBACK_SERVICES 无 basis 数字,N-30 类残留,宜删」 | **降为 DECISION(DEC-S4)**:子代理实证 fallback 与 CMS 数据走同一渲染 map,`:562` 对每张卡(含 fallback)渲染 `<MetricsBasisNote basis={weakestBasis(…)}>`,未声明 basis 一律保守落 `target` 角注 ⇒ **并非裸奔编造**,与 N-30(虚构佐证)不同类。残余问题:硬编码数字本身无出处 + fallback 触发条件(CMS service 缺失/首条无 subtitle)。是否删数字属内容口径,留业务裁定 |
| S-7 | 「force-dynamic + generateStaticParams 冲突 ⇒ SSG 产物永不服务」 | **CONFIRMED 但判为设计取舍**(子代理引 bundled docs `caching-without-cache-components.md:97` + `prerender-manifest` 实证四路由不在产物中;页内注释表明有意为之:硬 404 + 新内容首请求可见)。不自动改渲染模式,登记为 DECISION(承 DEC-N21b 同级) |
| S-14 | clipboard 无 catch | 随 S-10 批修(`.catch` + toast.error),SUSPECTED 定级不变 |
### 7.3 子代理上报、主线**证伪或不修**(防假发现入库)
- MetricCard `parseFloat('1,000+')→1` 类解析问题在本组件不成立(`[^0-9.]/` 保留数字与点),与 §4.3 Q-FP-1 同族。
- hooks 面(use-focus-trap/use-swipe-gesture/use-reduced-motion/useCountUp)、analytics 其余 tracker、structured-data、lib/cms 其余模块:子代理逐文件扫描后**无新缺陷上报**(含监听清理、capture 分工、XSS sink 检查均通过)。
- 死码新增:`CookieSettingsButton`(零 importer,连带「重开偏好面板不与已存同意同步」的潜在缺陷一并挂账)、`getInitialShowConsent` 三分支恒 false(死但无害)。
### 7.4 本轮收尾门禁(2026-09-28 最终树)
| 门禁 | 结果 |
|---|---|
| type-check | EXIT=0(一次 tsc 报错为 Q-6 测试 mock 类型断言丢失 `.mock`,已修后复跑绿) |
| lint | 0 error / 105 warning(与 AGENTS.md 口径一致 ⇒ 本轮零新增债) |
| test:coverage | EXIT=0,**143 套件 / 1772 例全通过**(较 §6.2 净增 1 套件 12 例,全为本轮判别测试) |
| test:integration:real | EXIT=0,4 套件 / 23 例,dev.db/data.db inode+size+mtime 守卫通过(workflow `$transaction` 改动在真 SQLite 无回归) |
| check:contrast / brand-token / headings | 44/44 · 0 违规 · 10/10 页,均 EXIT=0 |
| build + standalone 装配 | EXIT=0,ASSEMBLY_OK;`prerender-manifest` 实证三页 ISR=3600(S-3 产物级) |
| lighthouse | EXIT=0,9 URL × 3 次 = 27 run 全部断言通过(lhci autorun 自起 standalone :3200) |
+5 -3
View File
@@ -256,17 +256,19 @@ const notoSansSC = Noto_Sans_SC({
});
```
### 4. 静态导出
### 4. 构建输出模式
```tsx
// next.config.ts
// next.config.mjs
const nextConfig = {
output: 'export',
output: 'standalone',
distDir: 'dist',
images: { unoptimized: true },
};
```
`output: 'export'` 已于 2026-08 移除并被 standalone 取代(见 [ADR-0007](adr/0007-hybrid-rendering-for-cms.md)):营销页在构建时预渲染并走 ISR,`/api/*` 与 `/admin/*` 需要 Node 运行时,受支持的启动方式是 `node dist/standalone/server.js`。静态资源由 Nginx + CDN 接管,因此 `images.unoptimized: true` 是部署形态的要求。
## 安全设计
### 1. XSS 防护
+49 -41
View File
@@ -3,7 +3,7 @@
> **版本**:v1.1
> **日期**:2026-07-02
> **技术栈**:Java 21 + Spring Boot WebFlux + PostgreSQL + Flyway
> **前端对接**:Next.js 14 + CMS SDK(`src/lib/cms/client.ts`)
> **前端对接**:Next.js 16(App Router,`output: 'standalone'` 混合渲染)+ 读取层 `src/lib/cms/data-server.ts`
>
> ⚠ **路径时效注(2026-08-31 一致性审计)**:`src/lib/cms/client.ts` / `mock-data.ts` 在当前代码库中不存在(`src/lib/cms/` 现含 `data-server.ts` / `workflow.ts` / `notifications.ts` 等)。本文档 API 契约本身仍有效,前端对接实现以 `src/lib/cms/` 实际代码为准。
@@ -412,6 +412,7 @@ POST /api/cms/items
- 创建时状态默认为 `draft`
- 版本号默认为 1
- 自动创建第一个版本记录
- **本仓库当前实现(`POST /api/admin/items`)**:`status` 只接受缺省或 `draft`,其余取值一律 400 并指向 workflow 接口;落库固定 `status: 'draft'` 且 `publishedAt: null`(`src/app/api/admin/items/route.ts:138-142`、`:163-176`)。创建接口不是发布通道。
**响应 data**:创建后的 ContentItem 对象
@@ -436,7 +437,7 @@ PUT /api/cms/items/{id}
**说明**:
- 更新时版本号 +1
- 自动保存到版本历史表
- 如果当前状态是 published,更新后变为 draft(可选配置)
- 状态**不随更新改变**:任何「把 status 改成别的值」的载荷都被拒绝(400,指向 workflow 接口);原样回传当前状态是被接受的(admin 编辑页每次保存都会带上现有 status)。见 `src/app/api/admin/items/route.ts:222-226`
**响应 data**:更新后的 ContentItem 对象
@@ -455,9 +456,10 @@ POST /api/cms/items/{id}/publish
```
**说明**:
- 将状态从 `draft`/`review` 改为 `published`
- 设置 `publishedAt` 时间
- 版本号不变(发布当前版本)
- 不存在「从 `draft` 一步到 `published`」的通道:`draft` 只能先 `submit` 进入 `review`,再由 `review` `approve` 到 `published`(`src/lib/cms/workflow.ts:20-32`)
- 审批通过时设置 `publishedAt` 时间(`src/lib/cms/workflow.ts:77-79`)
- 每次流转 `version` +1(与内容更新同为 `{ increment: 1 }`,见 `src/lib/cms/workflow.ts:68-75`)
- **本仓库当前实现**:状态变更的唯一入口是 workflow 接口 `POST /api/admin/items/[id]/workflow`(`src/app/api/admin/items/[id]/workflow/route.ts`),请求体 `{ action: 'submit' | 'approve' | 'reject' | 'archive', reason? }`;`approve` / `reject` / `archive` 需要 `publish` 权限,`submit` 只需 `update` 权限(`:31-36`)。非法流转与并发状态冲突返回 400,不会静默忽略
**响应 data**:发布后的 ContentItem 对象
@@ -777,18 +779,20 @@ POST https://website.example.com/api/revalidate
1. **model_code 冗余**:`cms_content_item.model_code` 与 `cms_content_model.code` 保持一致(冗余设计,提升查询性能)
2. **版本号递增**:每次更新内容项,`version` 必须 +1
3. **状态流转**:
- `draft` → `review` → `published` → `archived`
- `published` → `draft`(修改时)
- 任意状态 → `archived`
3. **状态流转**(本仓库实现见 `src/lib/cms/workflow.ts:20-32`,动作即唯一通道):
- `draft` --submit--> `review` --approve--> `published` --archive--> `archived`
- `review` --reject--> `draft`(驳回回草稿,**不是** `published` 直接回退)
- `archived` 为终态,无任何后继动作;`published` 只能归档,不能再改回 `draft`
- 每个状态只允许上述动作,其余组合一律抛错并由路由转为 400(`src/app/api/admin/items/[id]/workflow/route.ts:86-88`)
---
## 九、前端 Mock 数据字段定义(对接参考)
## 九、前端内容字段定义(对接参考)
> **目的**:以下字段定义与前端 Mock 数据保持一致,后端实现时需按此结构创建 `fields_json`。
> **源码位置**:`src/lib/cms/mock-data.ts`(case-study)、`src/lib/cms/content-types.ts`(其余 6 个模型)。
> **字段类型**:`text` | `textarea` | `richtext` | `number` | `boolean` | `date` | `select` | `image` | `array` | `object`
> **目的**:以下字段定义与前端数据层保持一致,后端实现时需按此结构创建 `fields_json`。
> **源码位置**:`src/lib/cms/content-types.ts`(全部模型的 `CONTENT_TYPE_CONFIGS`,`:1981` 起;§九 列出的 7 个模型对应其中 7 个键,case-study 配置在 `:1837`)。同文件 `:1978` 注明「数据模型定义已全部移至 CONTENT_TYPE_CONFIGS」,`mock-data.ts` 已不存在(见文首路径时效注)。
> **字段类型**:以 `src/lib/cms/types.ts:24-40` 的 `FieldType` 为准 —— `text` | `textarea` | `richtext` | `number` | `boolean` | `date` | `datetime` | `image` | `media` | `reference` | `references` | `json` | `array` | `object` | `select` | `dropdown`。其中 admin 编辑页 `renderField` 只为 `text` / `textarea` / `number` / `boolean` / `select` / `dropdown` / `json` / `image` / `array` / `object` 提供专门控件,其余类型(含 `richtext`、`date`、`datetime`、`media`、`reference`、`references`)落入 `default:` 的单行 `<input type="text">`(`src/app/admin/content/[modelCode]/[itemId]/page.tsx:447-628`,见 §10.4)
> **漂移告知(2026-09-23 核对)**:§9.0–§9.7 是早期对接快照,与 `content-types.ts` 已系统性偏离,**不得当作现行字段清单引用**——已知三类差异:① 类型口径:文档写作 `select` 的字段在代码里是带 `options` 的 `text`(`news.category`、`product.categoryId`、`product.status`、`case-study.color`、`stat-item.trendDirection`、`hero-banner.theme` 等,二者语义同义,见 `src/lib/cms/validate-content-data.ts:29-30`);② 文档写作 `array` / `object` 的列表字段多已改为 `json`(如 `service.features`、`solution.painPoints`);③ 字段名与集合有变更(`hero-banner` 的 `primaryCtaText`/`primaryCtaLink` 现为 `ctaLabel`/`ctaHref`,`solution.valueProps` 现为 `challenges`/`solutions`/`valueProposition`,`case-study` 另有 `projectDuration`/`departments`/`dataScale`/`businessProblem`/`verified` 未列出)。逐字段口径请以 `CONTENT_TYPE_CONFIGS` 为准。
### 9.0 模型总览
@@ -828,9 +832,9 @@ POST https://website.example.com/api/revalidate
|--------|------|------|------|------|
| `excerpt` | textarea | 是 | 摘要 | 新闻摘要,用于列表页展示 |
| `date` | date | 是 | 发布日期 | - |
| `category` | select | 是 | 分类 | 选项:company(公司动态) / industry(行业资讯) / tech(技术分享) |
| `category` | text(带 `options`,语义同 `select`) | 是 | 分类 | 代码侧选项为 公司新闻 / 研发动态(`src/lib/cms/content-types.ts:58-66`) |
| `image` | image | 是 | 封面图 | - |
| `content` | richtext | 是 | 正文内容 | 富文本(HTML),通过 TipTap 编辑器产出 |
| `content` | richtext | 是 | 正文内容 | 富文本(HTML);admin 侧无专用编辑器,落 `default:` 单行输入框由作者直接书写 HTML(见 §10.4),渲染端经 `src/lib/sanitize.ts` 净化 |
| `featured` | boolean | 否 | 精选推荐 | 是否在首页精选区展示,默认 false |
### 9.3 service(服务)
@@ -913,40 +917,44 @@ POST https://website.example.com/api/revalidate
---
## 十、前端 Mock 模式架构说明
## 十、前端数据层架构说明
### 10.1 Mock 数据流转
### 10.1 数据流转(现状)
```
lib/constants/*.ts(原始业务数据)
↓ toContentItem<T>()
lib/cms/mock-data.ts(allMockItems,server-safe 同步数据源)
↓ getMockItems(modelCode) / getMockItemBySlug / getMockItemById
lib/cms/client.ts(cmsClient,运行时数据访问层)
↓ getContentItems / getItem / getZone
src/app/(marketing)/*/page.tsx(server 端同步预取 ContentItem)
↓ 传 ContentItem prop 给 client 组件
src/app/(marketing)/*/client.tsx(client 端接收 prop 渲染)
src/lib/constants/*.ts、prisma/seeds/*.ts(原始业务数据)
↓ prisma/seed.ts 写入(contentItem.upsert,`prisma/seed.ts:42`)
Prisma ContentItem(data 列存 JSON 字符串)
↓ getPublishedItems / getPublishedItemBySlug / getZone / getResolvedHomeZones(React cache)
src/lib/cms/data-server.ts(server 端读取层)
↓ 以 props 传入
src/app/(marketing)/**/page.tsx(server 组件)→ client.tsx / home-content-v15.tsx(client 渲染)
```
> **历史口径**:本节曾描述 `lib/cms/mock-data.ts` → `lib/cms/client.ts`(`cmsClient`)的纯 Mock 流转,两个文件均已不存在(文首路径时效注)。「CMS 优先 + 常量兜底」的语义仍成立,但实现分散在读取层与页面里:首页在 zone 未解析到内容时回落到逐模型 `getPublishedItems()`(`src/app/(marketing)/page.tsx:8-33`),结构性文案在 CMS 未配置时由组件回退 `src/lib/constants/*`(见 `CONTEXT.md`「结构性文案 CMS 化」)。
### 10.2 切换到真实 API 的步骤
1. 在 `src/lib/cms/client.ts` 中将 `CMS_MODE` 从 `'mock'` 改为 `'api'`
2. 配置 `CMS_API_BASE_URL` 环境变量指向 Java 后端
3. `cmsClient` 的 `getItems/getItem/getItemBySlug/getZone` 会自动切换为 HTTP 请求
4. 业务层代码(page.tsx、client.tsx)无需任何改动,ContentItem 数据结构保持一致
1. 读取层已落在 `src/lib/cms/data-server.ts`(Prisma + React `cache`),**不存在** `CMS_MODE` 开关与 `src/lib/cms/client.ts`。
2. 接真实后端时改的是 `data-server.ts` 的取数函数(`getPublishedItems` / `getPublishedItemBySlug` / `getZone` / `getResolvedHomeZones`),业务页与 client 组件按 props 契约保持不变。
3. `ContentItem` 的结构定义在 `src/lib/cms/types.ts`,字段声明在 `src/lib/cms/content-types.ts` 的 `CONTENT_TYPE_CONFIGS`。
### 10.3 静态导出兼容性
### 10.3 渲染模式与预渲染兼容性
- Mock 数据源(`mock-data.ts`)不标记 `'use client'`,提供同步函数
- `generateStaticParams` 和 `generateMetadata` 在 server 端同步调用 `getMockItems` / `getMockItemBySlug`
- 静态导出(`output: 'export'`)模式下,构建时预渲染所有页面路径
- CMS API 路由(`/api/cms/draft/*`、`/api/cms/revalidate`)已改为 POST 方法,避免静态导出预渲染时报错(GET + searchParams 在 `output: 'export'` 下不支持)
- 构建产物为 `output: 'standalone'`(`next.config.mjs:44`),`output: 'export'` 早已移除:营销页在构建时预渲染并走 ISR(页面级 `export const revalidate = 3600`,如 `src/app/(marketing)/page.tsx:5`、`src/app/privacy/page.tsx:12`),`/api/*` 与 `/admin/*` 需要 Node 运行时
- 读取层(`data-server.ts`)不标记 `'use client'`;`generateStaticParams` 与 `generateMetadata` 在 server 端调用它
- CMS 通知型路由只导出 POST(`src/app/api/cms/draft/enable/route.ts:13`、`src/app/api/cms/draft/disable/route.ts:6`、`src/app/api/cms/revalidate/route.ts:33`),且密钥为 fail-closed:`CMS_PREVIEW_SECRET`(draft)/ `CMS_REVALIDATE_SECRET`(revalidate)未配置时直接拒绝,不放行
### 10.4 富文本编辑器(TipTap)
### 10.4 富文本字段(`richtext`)
- CMS Studio 的 `richtext` 字段使用 TipTap 富文本编辑器(`src/components/cms/RichTextEditor.tsx`)
- 支持:加粗、斜体、H2/H3、有序/无序列表、引用、链接、撤销/重做
- `immediatelyRender: false` 避免 SSR hydration mismatch
- 编辑器产出的 HTML 存储在 ContentItem.data 的对应字段中(如 news.content)
- 样式定义在 `src/app/globals.css` 的 `.rich-text-editor-content` 选择器下
- admin 编辑页的 `renderField`(`src/app/admin/content/[modelCode]/[itemId]/page.tsx:447` 的 switch)**没有 `richtext` 分支**,该类型落入 `default:`(`:613-627`)⇒ 渲染成单行 `<input type="text">`,作者需直接书写 HTML
- 历史组件 `src/components/cms/RichTextEditor.tsx`(TipTap)与配套 `.rich-text-editor-content` 样式**在生产代码中无任何引用**,已作为死代码删除(`src/components/cms/` 目录整体不存在);恢复富文本编辑需重新接线到 `renderField`。`package.json` 仍留着 `@tiptap/*` 依赖,但不参与任何运行时路径
- `richtext` 字段产出的 HTML 存于 `ContentItem.data` 对应字段(如 `news.content`),公开页经 `src/lib/sanitize.ts` 净化后才进入 `dangerouslySetInnerHTML`(白名单见 `src/lib/sanitize.ts:7-46`,用例见 `src/app/privacy/page.tsx:268`、`src/app/terms/page.tsx:213`)
### 10.5 状态字段:只有 workflow 能改(admin 编辑页只读)
- `ContentStatus` 只有四个取值:`draft` / `review` / `published` / `archived`(`src/lib/cms/types.ts:81`),变更**只能**经 `POST /api/admin/items/[id]/workflow`(动作 `submit` / `approve` / `reject` / `archive`,权限口径见 §3.2.6)
- `POST /api/admin/items`:`status` 传缺省或 `draft` 以外的值即 400;新建条目固定 `status: 'draft'`、`publishedAt: null`(`src/app/api/admin/items/route.ts:138-142`、`:163-176`)
- `PUT /api/admin/items`(`?id=`):载荷**改变** status 即 400;原样回传当前 status 放行 —— 编辑页每次保存(含自动保存)都会带上现有 status,一律拒绝会让所有编辑保存变红(`src/app/api/admin/items/route.ts:222-226`)
- admin 编辑页的「状态」是**只读展示**(`<p>` + 文案「状态通过「发布」提交审核变更,不可在此直接修改」,`src/app/admin/content/[modelCode]/[itemId]/page.tsx:821-828`),不再是早前的 `<select>`;「发布」按钮走 `handlePublishConfirm` → 先落库再依次 `submit`(draft→review)、`approve`(review→published),账号无 publish 权限时 approve 返回 403、条目停在「待审核」,这是审批制的预期结果(同文件 `:407-441`)
- 列表页只做状态展示、不提供改 status 的入口(`src/app/admin/content/[modelCode]/page.tsx:197-204`);客户端唯一的写状态通道是 `adminApi.runWorkflow` → `POST /api/admin/items/[id]/workflow`(`src/lib/admin-api.ts:146-153`),不存在「绕过状态机直接写 status」的路径

Some files were not shown because too many files have changed in this diff Show More