# RobotUnit.version ≠ RobotUnit.snapshot.version — 乐观锁版本号传错坑

**日期**：2026-05-17
**场景**：my-work TaskDetailDrawer 实现"完成并推进 stage"，调 `robotApi.changeStage(id, { toStage, reason, version })`。
**踩坑**：传了 `robot.version` (RobotUnit 顶级 version 字段) 给乐观锁，结果 backend 返 **409 CONFLICT**：

```
[ERROR] POST /api/v1/robot-manager/{id}/change-stage 409
Status: 409
Error Code: CONFLICT
Message: Snapshot 版本冲突（期望 0，实际 1）
```

期望 0 / 实际 1 — 我传了 0（RobotUnit.version 是 0），但 backend 比对的 **snapshot.version** 是 1。

## 根因

RobotUnit 有**两个 version 字段**，语义不同：

```ts
export interface RobotUnit {
  id: string;
  version: number;          // ← RobotUnit 实体本身的乐观锁版本（基础字段改动 increment）
  // ...
  snapshot?: RobotUnitSnapshot;
}

export interface RobotUnitSnapshot {
  robotUnitId: string;
  currentStage: RobotLifecycleStage;
  version: number;          // ← Snapshot 实体的乐观锁版本（状态/位置等改动 increment）
  // ...
}
```

backend `changeStage` 服务比对的是 **snapshot.version**（因为 changeStage 改动的是 snapshot 而不是 RobotUnit 基础字段）：

```ts
return this.lifecycleService.changeStage({
  robotUnitId: id,
  toStage: dto.toStage,
  expectedVersion: dto.version,   // ← 这个 expectedVersion 在 service 内部跟 snapshot.version 比对
});
```

前端调用方按"乐观锁版本号"直觉写 `robot.version`，但**实际应该用 `robot.snapshot.version`**。

## 修复

```diff
  await robotApi.changeStage(robot.id, {
    toStage: next.stage,
    reason: ...,
-   version: robot.version,
+   // 乐观锁版本号用 snapshot.version 不是 RobotUnit.version（backend 比对 snapshot）
+   version: robot.snapshot?.version,
  });
```

## 元根因

**同一聚合根有多个 version 字段**（实体级 + 子实体级 / aggregate root + entity）是 DDD 中常见模式，但前端**接口设计没明确"version 指哪个"**：

- ChangeStageDto.version 类型 `number?`，没注释说明这是 snapshot version
- frontend changeStage 函数签名 `(id, data: ChangeStageDto)` 也没提示

调用方按 RobotUnit.version 顺手就传错，且 ts 编译不报错（都是 `number`）。

## 工程化保险建议

### A. 后端 DTO 加显式字段名

```diff
- export class ChangeStageDto {
-   version?: number;  // 模糊
- }
+ export class ChangeStageDto {
+   snapshotVersion?: number;  // 明确：snapshot 的乐观锁版本
+ }
```

或者注释：
```ts
@ApiProperty({ description: 'RobotUnitSnapshot.version 作乐观锁（不是 RobotUnit.version）' })
version?: number;
```

### B. 前端 API helper 自动从 robot 抽 snapshot.version

```ts
// _lib/api/index.ts
changeStage: (robot: RobotUnit, toStage: RobotLifecycleStage, reason?: string) =>
  post(`/robot-manager/${robot.id}/change-stage`, {
    toStage,
    reason,
    version: robot.snapshot?.version,  // helper 自动取对
  }),
```

调用方不用手动拼 dto。

### C. 状态机 / 命令模式的版本字段都有此风险

任何"聚合根 + 子实体多 version"的接口都要核对：**API 改动哪个实体，version 就传哪个实体的**。命名/注释要明示。

## 验证

修复后 my-work 推进成功，FF-202605-00129 从 `AFTERSALES_UNDER_REPAIR` → `AFTERSALES_REPAIRED`，Drawer 自动关闭，列表自动刷新。E2E 测试通过。

## 类似的"两个相似字段类型相同"陷阱

- User.id (UUID) vs User.externalId (UUID) — 都是 string
- Order.createdAt vs Order.updatedAt — 都是 Date
- 父子表 same-name field — Order.status vs OrderItem.status

通用规则：**类型相同 + 命名相近 + 业务语义不同** = 高危区，必须靠**显式命名 / 注释 / API 设计**消歧。

## 复发：同资源不同 API 用不同 version（2026-05-17 第二次踩）

P18 Phase 3 Drawer inline 编辑时再次踩同样坑，但**方向相反**：

- `POST /:id/change-stage` 用 **snapshot.version**（改 snapshot）→ 上半部分原坑
- `POST /:id/activate-sn` 用 **snapshot.version**（也走 SnapshotProjector）
- **`PUT /:id`（通用 update）用 RobotUnit.version**（改 RobotUnit 直接字段或 metadata）

我写 Drawer V3FieldRow.save 时按 P3 印象传 `robot.snapshot?.version` → 409。

**修正心智模型**：

| API | 改动对象 | version 字段 |
|---|---|---|
| `PUT /:id` | RobotUnit 直接字段 + metadata | `robot.version` |
| `POST /:id/change-stage` | snapshot.currentStage | `robot.snapshot.version` |
| `POST /:id/activate-sn` | placeholder → real SN + snapshot | `robot.snapshot.version` |
| `POST /:id/hold` / `unhold` / `move-location` | snapshot | `robot.snapshot.version` |

**经验法则**：「**改 snapshot → snapshot.version；改 RobotUnit → RobotUnit.version**」。Update API 通用→改基础字段→ RobotUnit.version。

### 工程化保险（增补）

**最稳**：把 version 字段名跟实体绑死，避免歧义：

```ts
// backend dto 命名按实体
class UpdateRobotUnitDto {
  robotUnitVersion?: number;  // 明确改 RobotUnit
}

class ChangeStageDto {
  snapshotVersion?: number;  // 明确改 Snapshot
}
```

或者 frontend helper 把 version 抽掉，调用方传整个 robot：

```ts
robotApi.update(robot, patch) {
  return put(`/${robot.id}`, { ...patch, version: robot.version });
}
robotApi.changeStage(robot, dto) {
  return post(`/${robot.id}/change-stage`, { ...dto, version: robot.snapshot.version });
}
```
