# SkipTransform + 手写 res.json() 会跟 frontend api-client 自动解包冲突

**Date**: 2026-05-16
**Surfaced in**: PR2 agent sessions API（slot-5 session 6）

## 现象

后端用了 `@SkipTransform()` + `@Res() res: Response` + `res.status(200).json({items: [...]})` 直接返回。前端通过 `apiClient.get('/agent/sessions')` 调用，frontend crash with：

```
Cannot read properties of undefined (reading 'data')
```

抛在前端 Provider Context 里，被全局 AuthGuard 误判成"token 失效"，整页 bounce 回 /login。表现成"明明 login 成功，token 在 localStorage 也有效，但每次访问受保护页都被踢回登录"。**症状误导性极强**——查了 1 小时 token / cookie / org context 都没发现根因。

## 根因

`frontend/src/lib/api-client.ts` 响应拦截器写了一行：

```ts
return response.data.data;
```

它**假设所有后端响应都被全局 `TransformInterceptor` 包装成** `{success, data, message}` 形态。

后端控制器**有两种选择**：
1. **不加 `@SkipTransform`** → return 普通对象 → TransformInterceptor 包装 → api-client 拦截器解 `data.data` → 调用方拿到原始对象 ✓
2. **加 `@SkipTransform` + 自己 `res.json()`** → 后端返回的就是 `{items:[]}` 这种裸结构 → api-client 拦截器仍尝试 `response.data.data` → **`{items:[]}.data` = undefined → crash** ✗

`@SkipTransform` 是给二进制下载 / 自定义 status / 手动控制响应头的场景准备的——**普通 JSON 响应不该用它**，否则要么 frontend crash，要么 frontend 调用方需要写两套解包逻辑（用 / 不用 SkipTransform）。

## 规则

| 场景 | 控制器写法 |
|---|---|
| 普通 JSON 响应（list / create / update / get） | **不要**加 `@SkipTransform`；直接 `return` 数据对象，让 TransformInterceptor 包装 |
| 文件下载 / blob / 二进制 | 加 `@SkipTransform` + 用 `@Res()` 手动写 stream，但前端**不要走 apiClient**，用 fetch / 直链 |
| 需要自定义 status code（如 201 / 204） | `@HttpCode(201)` 装饰器或 throw `HttpException`；仍 return 数据让全局 interceptor 工作 |

## 怎么早点发现

跟现有 meeting-attendance / approval 等模块对比一眼就能发现：那些控制器**全是 `@SkipTransform()` + `res.json()`**，能跑是因为它们的前端入口**不走 apiClient**（用了独立 fetch / 不同的 service 类），所以没踩到这个雷。新模块如果想走 apiClient（最方便、有 token 注入 / 401 refresh / Org header 注入等全套），就**必须**走 TransformInterceptor 流程。

## 修复手法

```diff
- @Controller('agent/sessions')
- @SkipTransform()
- export class AgentSessionsController {
-   @Get()
-   async listSessions(@Query() query, @Request() req, @Res() res: Response) {
-     const items = await this.svc.list(...);
-     return res.status(200).json({ items });
-   }
- }

+ @Controller('agent/sessions')
+ export class AgentSessionsController {
+   @Get()
+   async listSessions(@Query() query, @Request() req) {
+     const items = await this.svc.list(...);
+     return { items };
+   }
+ }
```

frontend agent client 不要 `res.data`：

```diff
- const res = await apiClient.get('/agent/sessions');
- return res.data as { items: AgentSession[] };

+ return (await apiClient.get('/agent/sessions')) as unknown as { items: AgentSession[] };
```

## 相关
- `frontend/src/lib/api-client.ts:298-310` 响应拦截器（关键行 `return response.data.data`）
- `backend/src/common/interceptors/transform.interceptor.ts` 包装逻辑
- `backend/src/common/decorators/skip-transform.decorator.ts` 跳过开关
