# NestJS 全局组件冲突清单

> **沉淀来源**: 2026-05 累计 14 条 `.learnings/` 反复踩坑（多数来自 `internal-app-platform` 模块大冒进期）
> **适用场景**: 写新模块 controller / service / interceptor 时；写 webhook / MCP / 自带鉴权的 endpoint 时；写 L1 集成测试时遇到诡异 401/429/undefined 时
> **配套阅读**: [`nestjs-decorators.md`](./nestjs-decorators.md)（装饰器用法）、[`api-standards.md`](./api-standards.md)（封包规范）

---

## 0. 本项目注册了哪些全局组件？

`backend/src/app.module.ts` 在 `providers` 里注册了 **7 个全局组件**——对**每个 endpoint 都生效**（除非用 opt-out decorator）：

| Type | Class | 作用 | Opt-out 机制 |
|---|---|---|---|
| `APP_GUARD` (1/3) | `JwtAuthGuard` | JWT 验证 | `@Public()` |
| `APP_GUARD` (2/3) | `RegionGuard` | 区域访问控制 | 见 IAM 文档 |
| `APP_GUARD` (3/3) | `PermissionsGuard` | 权限码校验 | 不标 `@RequirePermissions` 即跳过 |
| `APP_FILTER` | `AllExceptionsFilter` | 错误统一格式化 | 无（基础设施） |
| `APP_INTERCEPTOR` (1/3) | `TransformInterceptor` | 响应统一 `{success, data, message}` 封包 | `@SkipTransform()` |
| `APP_INTERCEPTOR` (2/3) | `AuditLogInterceptor` | 自动写 audit_log | 不标 `@Auditable()` 即跳过 |
| `APP_INTERCEPTOR` (3/3) | `DataScopeInterceptor` | Layer 4 数据权限自动注入 WHERE | 不标 `@DataScope` 即跳过 |

外加 `ThrottlerGuard`（用 `ThrottlerModule.forRoot` 注册）：**5 req/30s/IP 全局限流**。

**核心认知**：新模块的 controller / 中间件**默认全套继承上述行为**。"自带鉴权"、"自定义响应格式"、"webhook 签名校验"这类 endpoint 都会撞坑——除非显式 opt-out。

---

## 1. 全局 `APP_GUARD = JwtAuthGuard` 拦截所有 endpoint

### 现象

写一个 webhook controller（走 HMAC 校验）、MCP controller（走 opaque bearer + DB hash 校验）、健康检查端点：

```bash
curl -X POST http://host/api/v1/internal-apps/webhook/gitea \
  -H "X-Gitea-Signature: deadbeef" -d "{}"
# → {"code":"UNAUTHORIZED","message":"Unauthorized","stack":"...JwtAuthGuard.handleRequest..."}
```

HMAC 校验代码连入口都没碰到——`JwtAuthGuard` 先吃掉所有没有有效 JWT 的请求。

### 修法：`@Public()` opt-out

`backend/src/common/decorators/public.decorator.ts` 已存在：

```ts
export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
```

`JwtAuthGuard` 读这个 metadata 直接 return true。**任何自带鉴权 / 真公开的 endpoint 都必须显式 `@Public()`**：

```ts
@Controller('webhook')
export class WebhookController {
  @Post('gitea')
  @Public()                    // ← 关键
  async handleGitea(@Req() req) {
    // 自己实现 HMAC 校验
  }
}
```

**不要用** `@SkipAuth` / `@NoAuth` 等命名——本项目唯一约定 decorator 名是 `@Public()`。

### 安全前提

`@Public()` 只是 opt-out JWT 校验——**不取消其他鉴权义务**。webhook / MCP 必须**自己实现签名 / token 校验**，且失败时返 401（不是 200 with error body，详见 §7）。

---

## 2. 全局 `TransformInterceptor` 包装响应

### 默认行为

`backend/src/common/interceptors/transform.interceptor.ts` 把 controller 任意返回值 `X` 包装成：

```json
{
  "success": true,
  "data": X,
  "message": "success",
  "timestamp": "2026-05-16T...",
  "path": "/api/v1/..."
}
```

**前端 `frontend/src/lib/api-client.ts` 响应拦截器假设所有响应都有这个 envelope**，会自动剥一层：

```ts
return response.data.data;  // 直接返回 envelope 的 .data 字段
```

### 反模式：controller 手工 envelope

```ts
// ❌ 错——会被 TransformInterceptor 再包一层，前端拿到双层嵌套
return { success: true, data: { apps } };
```

实际响应：

```json
{
  "success": true,
  "data": {                   ← TransformInterceptor 包的
    "success": true,
    "data": { "apps": [...] }  ← controller 手工包的
  },
  "timestamp": "...",
  "path": "..."
}
```

前端拿 `response.data.data` = `{success:true, data:{apps:[...]}}`，再访问 `.apps` = undefined → `apps.length` 炸 TypeError。

**症状误导性极强**：常见误诊为 token 失效（被全局 AuthGuard 误判 401 跳登录）、Provider Context 错误等，实际是双层 envelope。

### 修法

```ts
// ✅ 对——直接返业务对象，让 TransformInterceptor 包
return { apps };
```

---

## 3. `@SkipTransform` 与前端 apiClient 解包冲突

### 误用场景

```ts
@Get('/sessions')
@SkipTransform()
async list() {
  return { items: [...] };       // ← skip 后直接返裸结构
}
```

前端通过 `apiClient.get('/sessions')` 调用，crash：

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

因为 apiClient 拦截器仍尝试 `response.data.data`，而后端返的就是 `{items: [...]}` —— `.data` = undefined。

### 正确用法

`@SkipTransform` **只**给以下场景用：

| 场景 | controller 写法 | 前端访问方式 |
|---|---|---|
| 普通 JSON 响应（list / create / update / get） | **不要**加 `@SkipTransform`；直接 `return` 数据对象 | 走 `apiClient` |
| 文件下载 / blob / 二进制 | `@SkipTransform` + `@Res()` 手动写 stream | **不走 apiClient**，用 `fetch` / 直链 |
| 自定义 status code（201 / 204） | `@HttpCode(201)` 装饰器或 `throw HttpException` | 走 `apiClient`（仍保留 envelope） |

参考已落地用例：`backend/src/modules/robot-manager/robot-entity-excel.controller.ts`（Excel 导出走 `@SkipTransform`）。

---

## 4. `app.use(express.json())` 与 `rawBody` 的兼容

### 历史踩坑

NestJS `NestFactory.create(AppModule, { bodyParser: true, rawBody: true })` 期望由 NestJS 自己的 body parser 解析请求体并同步存 `req.rawBody`。如果 `main.ts` 启动后又显式：

```ts
app.use(require('express').json({ limit: '50mb' }));   // ❌ 早期写法
```

Express middleware 先注册先执行——`express.json` 比 NestJS bodyParser 先读流并解析成 JSON，**等 NestJS bodyParser 跑时 body 流已空，`rawBody` 没东西存**。结果：所有 webhook HMAC 校验全断（`req.rawBody` 是 undefined）。

### 当前修法（已落地 main.ts）

保留 `app.use(express.json())` 但**加 `verify` 回调把原始字节挂到 `req.rawBody`**：

```ts
app.use(require('express').json({
  limit: '50mb',
  verify: (req: any, _res: unknown, buf: Buffer) => {
    req.rawBody = buf;
  },
}));
```

这种方式同时满足：
- ✅ 支持大请求体（50mb）
- ✅ webhook HMAC 校验能拿到 `req.rawBody`

**注意成本**：每个 JSON 请求都多持一份 Buffer 到响应完成 + GC。当前吞吐下风险可接受。

### 经验：每个 webhook 必须做的事

写新 webhook controller（Gitea / Stripe / Slack / 钉钉等）时：

1. `@Public()` opt-out JWT
2. 在 service 里读 `req.rawBody`（不是 `req.body`，后者已被解析成 JSON 对象）
3. 用 `crypto.createHmac('sha256', secret).update(rawBody).digest('hex')` 算签名
4. 签名不通过返**真实 HTTP 401**（详见 §7）

---

## 5. 全局 `ThrottlerGuard` 干扰 L1 集成测试

### 现象

跑 L1 集成测试 31 个 case，test 1+2 PASS，test 3+ 全部 401 Unauthorized。单独跑 test 3 时 PASS。

### 根因

`configuration.ts` 全局 `ThrottlerGuard`：

```ts
throttler: {
  ttlMs: process.env.AUTH_THROTTLER_TTL_MS || '30000',
  limit: process.env.AUTH_THROTTLER_LIMIT || '5',  // 5 req / 30s / IP
}
```

注释说"登录限流"，但 `APP_GUARD` 是**全局**的。supertest 所有请求都来自 `127.0.0.1` 一个 IP，IP 维度限流命中。

每个 `beforeEach` 调用 `setupTestContext()` 跑 2 次 login，30 秒窗口内累计 6+ login → throttler 429 → `loginRes.body.data.accessToken` 是 undefined → 后续 `Bearer ${undefined}` = `Bearer ` 空 token → **401（不是 429！这就是误导点）**。

### 修法

L1 测试初始化时必须 disable 或调大 throttler：

```ts
// testing/backend/helpers/app.helper.ts 或模块本地 _helpers.ts
process.env.AUTH_THROTTLER_LIMIT = '10000';
process.env.AUTH_THROTTLER_TTL_MS = '1000';
```

或在 `createTestApp()` 里 override `THROTTLER_OPTIONS` provider。

### 排错口诀

"POST 同 token 工作、DELETE 同 token 401" → JWT 验证逻辑路径相同不可能时好时坏，**先排查 throttler**。

---

## 6. `req.user` 真实 shape

### 不直观

JWT 验证后 `req.user` 的真实 shape（见 `src/modules/organization/auth/strategies/jwt.strategy.ts` 调 `sliceAuthPayload`）：

```ts
{
  userId, username, email, defaultRegion,
  currentOrganizationId,    // ← 来自请求 header X-Organization-Id
  roles, permissions, organizationPermissions, organizationRoles,
  regionPermissions, regionRoles,
  dataScopes, jti
}
```

**没有** `organizationId`、`orgId`、`tenantId` 这些命名。

### 几个关键点

1. `currentOrganizationId` 只在请求带 `X-Organization-Id` header 时才有值
   - 前端 `OrganizationContext` 切组织时由 `api-client.ts` 自动注入
   - **集成测试必须显式发该 header**：
     ```ts
     await request(app.getHttpServer())
       .post('/some/endpoint')
       .set('Authorization', `Bearer ${token}`)
       .set('X-Organization-Id', orgId)   // ← 关键
       .send(payload);
     ```
2. itadmin / 跨 org 用户的 `currentOrganizationId` 可能是 `undefined`——见 §8 的 Prisma `undefined` 静默丢过滤陷阱
3. 不要用 `req.user?.organizationId ?? req.user?.orgId` 当 fallback ——两个字段都不存在，永远 undefined

---

## 7. Placeholder fallback / skeleton auth = prod hazard

### 反模式

骨架代码为了"先让流程跑通"，把 fail 路径降级成 warn + 假数据：

| 反模式 | 后果 |
|---|---|
| `MASTER_KEY` 缺失时只 `logger.warn` + 用 `'placeholder-not-for-prod'` 派生密钥继续加密 | prod 配错时数据被假密钥加密入库，**事后无法解密、对外看起来工作正常** |
| `req.user` 占位 + 注释「Phase 0 接 middleware」 | 忘接 middleware 推 UAT → 攻击者构造请求冒充任意员工 |
| 签名校验失败时返 `{ statusCode: 401 }` JSON body **但 HTTP 真实是 200**（被 `@HttpCode(HttpStatus.OK)` 钉死） | Gitea 看 2xx 不重试，监控看 2xx 不告警，攻击者拿到 200 不知道被拒 |

### 共性根因

骨架/占位代码出错时静默降级，加上以下条件叠加变成真事故：

1. 占位上线前需要"接入真版本"才安全
2. 接入工作流没有强制 gate（没人会忘）
3. 静默降级路径在 prod 与正常路径在外观上无法区分

### 正确模式：构造期硬失败 + 业务入口 assert + env 闸开关默认 off

**已落地参考**：`backend/src/modules/internal-app-platform/services/env-crypto.service.ts`

```ts
@Injectable()
export class InternalAppEnvCryptoService {
  private readonly keyMissing: boolean;

  constructor(private readonly config: ConfigService) {
    const masterKey = this.config.get<string>('INTERNAL_APP_ENV_MASTER_KEY');
    this.keyMissing = !masterKey || masterKey === '__GENERATE_RANDOM__';
    
    if (this.keyMissing) {
      const msg = 'INTERNAL_APP_ENV_MASTER_KEY 未设置或仍是占位 — env 加密能力关闭';
      if (process.env.NODE_ENV === 'production') {
        // 1. 构造期硬失败（不在请求路径上）
        throw new Error(msg);
      }
      this.logger.warn(`${msg}（非生产环境，开发期容忍但 encrypt/decrypt 仍会拒绝服务）`);
    }
  }

  encrypt(plaintext: string) {
    this.assertKeyConfigured();    // 2. 业务入口 assert，非生产也拒服务
    // ...
  }

  private assertKeyConfigured() {
    if (this.keyMissing) {
      throw new Error('env crypto disabled: master key not configured');
    }
  }
}
```

**关键三点**：
- (1) 构造期判定 + prod 抛错——避免请求路径上每次判
- (2) 业务入口 assert + 非生产也拒服务——即使 dev 也别让假密钥写入 DB
- (3) env 闸开关默认 off——`INTERNAL_APP_PLATFORM_ENABLED=true` 才装载模块，避免半成品上线

---

## 8. Prisma `where: { x: undefined }` 静默丢过滤（prod hazard）

> **本节交叉引用** [`database-standards.md`](../../database-main/references/database-standards.md) §"Prisma 实战陷阱"

### 现象

ai-usage dashboard 对 itadmin 用户：

| 接口 | 实现 | 看到的数据 |
|---|---|---|
| `summary` | Prisma 标准 query API | 825M tokens（跨 org 全量） |
| `trend` / `session-stats` / `daily-user-matrix` | raw SQL (`$queryRaw`) | 0 / 空 |

仪表盘**部分图有数据、部分图无数据**——同一个 bug 引发的诡异表象。

### 根因

itadmin 的 `req.user.currentOrganizationId` 是 **undefined**（无当前 org 上下文）。两套 API 对 undefined 的处理截然不同：

```ts
// Prisma 标准 query API：静默丢过滤
prisma.aiUsageEvent.aggregate({
  where: { organizationId: undefined, ts: { gte, lte } },
});
// 等价于
prisma.aiUsageEvent.aggregate({
  where: { ts: { gte, lte } },     // ← 无 org 过滤，跨 org 全量 ❌
});

// raw SQL：undefined → NULL，col = NULL::uuid 永远 FALSE
this.prisma.$queryRaw`
  ... WHERE organization_id = ${scope.organizationId}::uuid AND ...
`;
// → 0 rows
```

### 修法：`orgFilterSql` helper（已在 ai-usage 落地）

`backend/src/modules/ai-usage/services/dashboard.service.ts:82`：

```ts
private orgFilterSql(scope: BaseScope, alias = ''): Prisma.Sql {
  if (!scope.organizationId) return Prisma.empty;
  const col = alias ? `${alias}.organization_id` : 'organization_id';
  return Prisma.sql`${Prisma.raw(col)} = ${scope.organizationId}::uuid AND`;
}
```

并在公共入口前置 `assertScope`：admin 路径（无 userId）必须有 organizationId，否则拒绝。

**适用所有 raw SQL 查询**：不要让 undefined 落到 SQL。

---

## 9. 新模块前置检查清单

写新 controller / service 前，照下面顺序自检：

- [ ] 该 endpoint 走 JWT 还是自带鉴权？自带鉴权要 `@Public()`
- [ ] 响应格式走 envelope 还是裸结构？裸结构要 `@SkipTransform` + **告诉前端不走 apiClient**
- [ ] 有签名校验需求（webhook）？读 `req.rawBody` 不是 `req.body`
- [ ] L1 测试涉及多次 login？disable 或调大 throttler
- [ ] 用 `req.user.organizationId`？改成 `req.user.currentOrganizationId`
- [ ] 集成测试发请求时带了 `X-Organization-Id` header？
- [ ] 有占位 / 骨架代码 / placeholder key？构造期硬失败 + 业务入口 assert + env 闸默认 off
- [ ] raw SQL 拼了 `WHERE col = ${scope.x}`？scope.x 可能 undefined？用 `orgFilterSql` 类 helper

---

## 10. 相关 learning 索引（14 条）

> 原 learning 保留作为复盘历史。新踩坑请先查本文档。

**全局 Guard / @Public**：
- `2026-05-14-app-guard-blocks-custom-auth-controllers.md`

**Transform / SkipTransform / api-client**：
- `2026-05-14-transform-interceptor-double-wrap.md`
- `2026-05-16-skip-transform-vs-api-client-unwrap.md`

**rawBody / webhook 签名**：
- `2026-05-14-express-json-overrides-nest-raw-body.md`

**ThrottlerGuard / L1 测试**：
- `2026-05-15-l1-throttler-trips-on-double-login.md`

**req.user shape**：
- `2026-05-15-jwt-req-user-shape.md`

**Placeholder / prod hazard**：
- `2026-05-14-placeholder-fallback-is-prod-hazard.md`

**Prisma undefined 静默 + orgFilterSql**：
- `2026-05-16-prisma-agg-undefined-org-vs-raw-sql.md`

**配套（不在本文档但相关）**：
- `2026-05-14-clipboard-http-fallback.md`（前端 secure context）
- `2026-05-14-content-type-charset-utf8-required-for-cjk.md`（HTTP 编码）
- `2026-05-14-caddy-auto-https-308-without-dns.md`（反代陷阱）
- `2026-05-14-litestream-yaml-config-needed-for-minio.md`（备份链路）
- `2026-05-13-public-sso-vs-internal-vpn-tradeoff.md`（网络模型决策）
- `2026-05-13-dual-track-network-model.md`（同上）
- `2026-05-13-remote-mcp-cant-read-local-files.md`（MCP 设计前置约束）
