# 固定地点签到（Site Attendance）- 错误码

> **module**: site-attendance
> **doc_type**: ErrorCodes
> **status**: Draft
> **owner**: 待定
> **upstream_docs**: `01-prd.md`, `07-api.md`
> **last_verified**: 2026-04-17

---

## 约定

- 错误码定义在 `backend/src/modules/site-attendance/error-codes.ts`
- API 响应格式：`{ statusCode, message, error, code?: string }`
- `code` 字段承载本表的 error code，便于前端匹配具体文案
- 本表只列 v1.5 新增的错误码；v1.0 错误沿用 NestJS HttpException 默认（参考 `07-api.md`）

---

## QR Token 相关（v1.5）

| Code | HTTP | 触发场景 | 前端文案 |
|------|------|---------|---------|
| `QR_TOKEN_MALFORMED` | 400 | `?t=` 参数格式错误（不是 `<bucket>.<mac>` 或 `permanent.<mac>`） | 二维码格式无效，请重新扫码 |
| `QR_TOKEN_INVALID` | 401 | HMAC 签名不匹配（伪造或 secret 不一致） | 二维码无效，请扫描前台最新二维码 |
| `QR_TOKEN_EXPIRED` | 401 | 时间桶超出当前 bucket + 宽限期 | 二维码已过期，请扫描前台最新二维码 |
| `QR_TOKEN_MISSING` | 401 | `accessMode=SIGNED` 但请求既没 `?t=` 也没 `?ticket=` | 请通过前台二维码签到 |

---

## Ticket 相关（v1.5）

| Code | HTTP | 触发场景 | 前端文案 |
|------|------|---------|---------|
| `TICKET_MALFORMED` | 400 | `?ticket=` 参数格式错误（无法 base64 解析载荷） | 签到链接无效 |
| `TICKET_INVALID` | 401 | HMAC 签名不匹配 | 签到链接无效 |
| `TICKET_EXPIRED` | 401 | `now - ts > TICKET_TTL_SECONDS * 1000` | 签到链接已过期，请重新扫码 |
| `TICKET_ALREADY_USED` | 409 | nonce 已在 `SharedCheckinTicketUsage` 里 | 签到链接已被使用，请重新扫码 |
| `TICKET_TARGET_MISMATCH` | 400 | 载荷 `targetCode` 与当前访问的 checkpoint code 不匹配 | 签到链接与当前签到点不匹配 |
| `TICKET_ORIGIN_NOT_ALLOWED` | 403 | `dispatchOrigin` 的 hostname 不在白名单 | 签到链接来源不可信 |

---

## 分诊相关（v1.5）

| Code | HTTP | 触发场景 | 前端文案 |
|------|------|---------|---------|
| `DISPATCH_NOT_ENABLED` | 404 | 访问 `/shared/:code` 但目标 checkpoint `sharedCheckinEnabled=false` | 该签到点未启用共享签到 |
| `DISPATCH_CHECKPOINT_NOT_FOUND` | 404 | `/shared/:code` 的 code 不存在 | 签到点不存在 |
| `DISPATCH_CHECKPOINT_INACTIVE` | 404 | 目标 checkpoint `isActive=false` | 签到点已停用 |
| `DISPATCH_CHOICE_INVALID` | 400 | POST /dispatch 时 `choice.companyId` 既不是 self 也不在 Partners 里 | 所选公司无效 |
| `DISPATCH_PARTNER_INACTIVE` | 400 | 选中的 Partner `isActive=false` | 该合作伙伴已停用 |

---

## Partner 配置相关（v1.5）

| Code | HTTP | 触发场景 | 前端文案 |
|------|------|---------|---------|
| `PARTNER_NOT_FOUND` | 404 | PATCH/DELETE 时 partner id 不存在 | 合作伙伴不存在 |
| `PARTNER_TARGETURL_INVALID` | 400 | `targetUrl` 不是合法 http(s) URL | 签到页 URL 格式错误 |
| `PARTNER_TARGETURL_HOST_NOT_ALLOWED` | 400 | `targetUrl` hostname 不在 env 白名单 | 目标地址不在白名单内，请联系运维添加 |
| `PARTNER_COMPANYID_INVALID` | 400 | `companyId` 不匹配 `[a-z0-9_-]+` | 公司标识只能包含小写字母、数字、横线、下划线 |
| `PARTNER_CHECKPOINT_NOT_SHARED` | 400 | 为未开启共享的 checkpoint 添加 partner | 请先启用该签到点的共享签到 |

---

## Checkpoint 配置扩展（v1.5）

| Code | HTTP | 触发场景 | 前端文案 |
|------|------|---------|---------|
| `CHECKPOINT_QR_ROTATION_INVALID` | 400 | `qrRotationSeconds` 非 null 且 < 60 | 轮换周期最低 60 秒 |
| `CHECKPOINT_QR_GRACE_INVALID` | 400 | `qrGraceSeconds < 0` 或 > `qrRotationSeconds` | 宽限期必须在 0 与轮换周期之间 |
| `CHECKPOINT_SHARED_COMPANY_MISSING` | 400 | `sharedCheckinEnabled=true` 但缺 `sharedCompanyId` 或 `sharedCompanyLabel` | 启用共享签到时必须填写公司标识和名称 |
| `CHECKPOINT_SHARED_COMPANYID_INVALID` | 400 | `sharedCompanyId` 不匹配 `[a-z0-9_-]+` | 公司标识只能包含小写字母、数字、横线、下划线 |
| `CHECKPOINT_ACCESSMODE_INVALID` | 400 | `accessMode` 不是枚举值 | 访问模式无效 |

---

## 环境配置相关（v1.5）

服务端启动时校验，失败则抛异常阻止启动（fail-fast）。不是 API 响应错误码，是启动日志告警。

| Code | 触发场景 | 运维动作 |
|------|---------|---------|
| `SHARED_CHECKIN_SECRET_MISSING` | 任意 checkpoint `accessMode=SIGNED` 或 `sharedCheckinEnabled=true`，但 env 未设 secret | 生成 secret 并注入 env |
| `SHARED_CHECKIN_SECRET_TOO_SHORT` | secret 长度 < 32（希望至少 16 字节 hex = 32 字符） | 重新生成 `openssl rand -hex 32` |
| `SHARED_CHECKIN_ALLOWED_HOSTS_MISSING` | 任意 Partner 存在但 env `SHARED_CHECKIN_ALLOWED_HOSTS` 为空 | 补白名单 env |

---

## 错误处理原则

1. **签到页准入失败**：
   - 不闪退，不 404 白屏
   - 居中显示错误提示 + "重新扫码" 按钮 + 返回首页链接
   - 文案按本表"前端文案"列
2. **Ticket 一次性用掉后的边界**：
   - 用户通过 ticket 进入签到页，完成签到后刷新 → 再次校验会命中 `TICKET_ALREADY_USED`
   - 解决方式：前端首次校验通过后把"签到会话"存在 sessionStorage，刷新时跳过 ticket 再校验
3. **QR 过期 vs 无效的文案区分**：
   - `EXPIRED` → 暗示用户去扫新码（宽限期内旧码仍可用）
   - `INVALID` → 暗示扫到的不是合法码（可能是伪造或域名错）
4. **运维态错误**：
   - `SHARED_CHECKIN_SECRET_MISSING` 等启动校验失败，日志需给出明确下一步操作提示
