## [ERR-20260429-011] 30s 防重 + 多周期签到逻辑冲突错杀合法操作

**日期**: 2026-04-29
**类别**: site-attendance / 业务逻辑互动 bug

### 现象

UAT 实操：CHECK_IN(20:36:12) → CHECK_OUT(20:36:15) → CHECK_IN(20:36:18) 第三步报 400
"Duplicate submission. Please wait 30 seconds before trying again."。等满 79 秒后才能再签到。

### 根因

`checkDuplicateEvent` 实现是查"30s 内**同 type** 最近事件"：
```ts
const recent = await eventRepo.findLatestEvent(checkpointId, userId, eventType, thirtySecondsAgo);
if (recent) throw Duplicate;
```

用户多周期场景下，30s 内确实有上次的同 type CHECK_IN，被误判 dup。但中间已经隔
了一条 CHECK_OUT（合法的"出去吃午饭再回来"）。

### 修法

改成"查 30s 内**最近一条事件不限 type**，仅当跟当前 type 相同才拦"：

```ts
const recent = await prisma.findFirst({
  where: { ..., timestamp: { gte: thirtySecondsAgo } },
  orderBy: { timestamp: 'desc' },
});
if (recent && recent.eventType === eventType) throw Duplicate;
```

### 普适规律

**单元各自正确，组合行为出 bug**——业界叫 interaction bug。两个保护逻辑：
- `validateEventOrder`：状态机邻接（last 必须不同 type）
- `checkDuplicateEvent`：30s 防重

各自独立测都过，组合在 IN→OUT→IN within 30s 这条具体序列上就出问题。**单元测试
难抓，必须用真实场景级集成测试**——而本地开发测节奏（点一下→看 UI→想下一步）
天然不会触发 30s 时间窗。

教训：写"防重保护"时要明确**保护对象**（防误点 vs 防 race condition vs 防恶意刷），
不同对象保护范围不同。30s 防重原意是防"用户手抖连点"，那只该看"最近一条"是不是
同 type，不该按 type 划窗。

### 关联

- `backend/.../checkin.service.ts:checkDuplicateEvent`
- `testing/backend/integration/site-attendance/checkin-duration.api.test.ts` 加 IN→OUT→IN within 30s 用例
- 修复 PR: bugfix/checkin-multi-cycle

---
