## [ERR-20260429-012] BadRequestException 裸字符串 vs 结构化对象的契约后果

**日期**: 2026-04-29
**类别**: NestJS 异常 / 前后端契约

### 现象

L1 测试断言 `body.error?.code === 'ALREADY_CHECKED_IN'` 失败，实际值是 `'Bad Request'`。

### 根因

NestJS BadRequestException 接收两种参数：
- `new BadRequestException('ALREADY_CHECKED_IN')` —— 字符串参数当 message，HTTP 响应里 `error: 'Bad Request'` (HTTP status name) + `message: 'ALREADY_CHECKED_IN'`
- `new BadRequestException({code: 'X', message: 'Y'})` —— 对象当响应体，AllExceptionsFilter 里 `body.error.code = 'X'` + `body.error.message = 'Y'`

第一种形式用 message 字段携带"业务错误码"，但前端用 `error.code` 字段读时拿到的
是 HTTP status 字面量，不是业务码。这种"用错字段传契约"的反模式会让前端必须按
message 字符串匹配（如 `errorMap[raw]`），脆弱且无法扩展（带不了 retryAfterSeconds
这种结构化数据）。

### 修法

业务错误统一用对象形式：

```ts
throw new BadRequestException({
  code: SiteAttendanceErrorCodes.ALREADY_CHECKED_IN,
  message: 'Already checked in',
});
```

错误码集中在 `error-codes.ts` enum，给前后端共用。前端按 `errCode = body.error.code`
匹配 i18n，不再用 message 字符串当 key。

### 普适规律

**业务错误必须结构化**：`{code: string, message: string, ...metadata}`。
- code: 机器读、前后端共用、enum 化
- message: 人读、英文兜底（前端 i18n 翻译）
- metadata: 任意补充字段（retryAfterSeconds、conflictField 等）

裸 `throw new BadRequestException('SOME_CODE')` 是个反模式，永远应该用对象形式。
项目级 lint 规则可以加：禁止 `BadRequestException(string-literal)`。

### 关联

- `backend/.../site-attendance/error-codes.ts` 加 4 个 checkin 状态机码
- `backend/.../checkin.service.ts:validateEventOrder` 三处 throw 全部结构化
- `frontend/.../page.tsx` errorMap 改按 errCode 匹配而不是 raw message
