---
date: 2026-05-08
severity: MEDIUM
tags: [audit, frontend, contract-drift, runtime-error]
---

# audit 完整性验证页 substring 崩溃 = 后端/前端/文档三方契约漂移

## 现象

`/audit/integrity` 页面在 `verifyResult.failures` 非空时抛 `Cannot read properties of undefined (reading 'substring')`，定位到 `failure.expectedHash.substring(0, 32)`。

## 根因（三处不一致）

| 来源 | failure 字段实际形态 |
|---|---|
| `docs/modules/audit-system/07-api.md` | `{logId, type, message, expectedHash?, actualHash?}` |
| `backend/.../hash-chain.service.ts` 返回 | `{index, logId, type, message}`，**根本没产出 expectedHash/actualHash** |
| `frontend/services/api/audit.ts` 类型 + `integrity/page.tsx` 渲染 | `{logId, expectedHash, actualHash, reason}`，把 expectedHash/actualHash 当**必填**用 |

后端实现里 `verifyHash()` 内部算了 `calculatedHash` 但只返回 boolean，没把它透出来；前端按文档"画饼"了字段，于是 `.substring` 直接吃到 undefined。

## 解决（"以文档契约为准，改后端 + 同步前端"）

1. `hash-chain.service.ts`: `verifyHashChain` 的 failure push 增补 `expectedHash`/`actualHash`：
   - HASH_CHAIN_BROKEN → expected = `prevLog.currentHash`, actual = `log.previousHash`
   - HASH_MISMATCH → 改用 `generateHash()` 自己拿 `recalculatedHash`，expected = recalculated, actual = `log.currentHash`
   - INVALID_GENESIS → expected = `'GENESIS'`, actual = `log.previousHash`
   - SIGNATURE_INVALID → 不附加 hash 字段
2. `audit.service.ts:1023` 的 map 把这两个字段透传出去
3. 前端 `IntegrityVerifyResponse.failures`：把 `expectedHash`/`actualHash` 改成可选，新增 `type`/`message`，去掉不存在的 `reason`
4. `integrity/page.tsx`：渲染 `failure.message`，对 expected/actual 做条件渲染（不存在就不显示）
5. 文档 enum 补齐后端实际产出的 4 种 type（`INVALID_GENESIS` / `SIGNATURE_INVALID` 之前漏写）

## 教训

- "前端类型 + 文档"双方一致 ≠ 契约一致 —— 后端实现可能两边都没对齐过。**契约面冲突至少要看三处：docs / backend impl / frontend type**，单看其中两处会得出错误结论。
- `.substring` / `.toLowerCase()` 这类直接挂在 string 字段上的链式调用是契约漂移的高发崩点；可选字段在前端应永远走条件渲染或 `?.` + fallback。
- 后端 service 内部"算出来但没返回"的中间值，文档承诺透出时容易漏 —— review 后端时要交叉对照 docs 里的 response schema，不能只看 service 自己的 return type。
