# 用户与组织架构管理 - 错误码文档

> **版本**: v2.4  
> **架构**: 独立 Organization 表 + 组织级权限隔离 + Entra ID SSO 登录（v2.4）  
> **最后更新**: 2026-05-19  
> **维护者**: FFOA 后端团队

---

## 📝 文档变更记录

| 版本 | 日期 | 修改人 | 修改内容 |
|------|------|--------|---------|
| v2.4 | 2026-05-19 | FFOA Team | 新增 Entra ID SSO 登录错误码分组（**8 个**：5 个核心 SSO_* + 3 个 Entra error query 映射码），issue #334 |
| v2.1.26 | 2026-01-25 | FFOA Team | 预定义角色 code 对齐 Administrator/Employee |
| v1.0 | 2024-11-01 | FFOA Team | 初始版本，基础错误码定义 |
| v2.0 | 2025-12-20 | FFOA Team | 新增组织管理相关错误码 |
| v2.1 | 2025-12-26 | FFOA Team | 新增权限隔离相关错误码 |
| v2.1.1 | 2025-12-26 | FFOA Team | 补充身份源、登录安全相关错误码 |
| v2.1.25 | 2026-01-05 | FFOA Team | 错误码对齐实现（IAM 前缀） |

---

## 📋 概述

本文档定义组织架构模块的所有错误码、错误消息和处理建议。

### 错误码规范

- **格式**: `IAM_{NAME}`
- **来源**: `backend/src/modules/organization/exceptions/iam.exceptions.ts`
- **示例**: `IAM_USERNAME_EXISTS`（用户名已存在）

### HTTP 状态码映射

| HTTP 状态码 | 含义 | 使用场景 |
|-----------|------|---------|
| **400** | Bad Request | 客户端请求参数错误、业务规则校验失败 |
| **401** | Unauthorized | 未认证、Token 无效/过期 |
| **403** | Forbidden | 已认证但权限不足 |
| **404** | Not Found | 请求的资源不存在 |
| **409** | Conflict | 资源冲突（如唯一键冲突） |
| **422** | Unprocessable Entity | 业务逻辑错误（如状态流转不合法） |
| **500** | Internal Server Error | 服务器内部错误 |
| **503** | Service Unavailable | 服务暂时不可用 |

---

## 🎯 错误码分类

| 分类 | 说明 |
|------|------|
| **认证与账号** | 登录、Token、账号状态 |
| **认证 / SSO** | Entra ID OIDC 登录错误（v2.4 新增） |
| **用户与组织** | 用户/组织唯一性与状态 |
| **部门与角色** | 部门、角色、权限相关 |
| **多部门归属** | 用户与部门归属关系 |
| **同步与区域** | 同步、区域配置 |

---

## 🔐 认证相关错误 (AUTH - 100)

### IAM_INVALID_CREDENTIALS: 用户名或密码错误

**HTTP状态码**: 401 Unauthorized

**错误消息**: 
- 中文: "用户名或密码错误"
- 英文: "Invalid username or password"

**触发场景**:
- 登录时用户名不存在
- 登录时密码不正确
- 用户状态不是 ACTIVE

**处理建议**:
- 前端：提示用户检查用户名和密码
- 不要暴露具体是用户名还是密码错误（安全考虑）
- 可提供"忘记密码"链接

**示例响应**:
```json
{
  "success": false,
  "error": {
    "code": "IAM_INVALID_CREDENTIALS",
    "message": "用户名或密码错误",
    "details": null
  }
}
```

---

### IAM_TOKEN_EXPIRED: Token 已过期

**HTTP状态码**: 401 Unauthorized

**错误消息**:
- 中文: "登录已过期，请重新登录"
- 英文: "Token expired, please login again"

**触发场景**:
- JWT Token 超过有效期
- Refresh Token 已过期

**处理建议**:
- 前端：自动跳转到登录页
- 清除本地存储的 token
- 可先尝试使用 refresh token 刷新

---

### IAM_TOKEN_REVOKED: Token 无效

**HTTP状态码**: 401 Unauthorized

**错误消息**:
- 中文: "登录凭证无效"
- 英文: "Invalid token"

**触发场景**:
- Token 格式错误
- Token 签名验证失败
- Token 被篡改

**处理建议**:
- 前端：跳转到登录页
- 清除本地 token
- 记录安全日志

---

### IAM_USER_SUSPENDED: 账号已停用

**HTTP状态码**: 403 Forbidden

**错误消息**:
- 中文: "您的账号已被停用，请联系管理员"
- 英文: "Your account has been disabled, please contact administrator"

**触发场景**:
- 用户状态为 INACTIVE
- 用户状态为 SUSPENDED

**处理建议**:
- 提示用户联系管理员
- 提供管理员联系方式
- 记录登录尝试

---

### IAM_USER_TERMINATED: 账号已离职

**HTTP状态码**: 403 Forbidden

**错误消息**:
- 中文: "您的账号已离职"
- 英文: "Your account has been terminated"

**触发场景**:
- 用户状态为 TERMINATED

**处理建议**:
- 友好提示用户
- 可提供 HR 联系方式
- 完全阻止登录

---

## 🔐 认证 / SSO 错误码 (AUTH-SSO - 110)

> **新增于 v2.4**：覆盖 Entra ID OIDC SSO 登录链路（`GET /auth/sso/start` + `GET /auth/sso/callback`）的全部失败分支。错误码全部以 `SSO_` 前缀区别于本地登录的 `IAM_` 前缀，便于审计与告警分组。**本期新增 8 个**：5 个核心 SSO_* + 3 个 Entra error query 映射码。**用户文案与 [05-ui-interaction-spec.md - 错误状态](./05-ui-interaction-spec.md#错误状态v24-新增--sso-错误码) 严格一致**（UI 文档是用户视角真相源）。
>
> **复用现有错误码**：`IAM_USER_SUSPENDED` （详见本文 IAM 段）用于"User 命中但 status != ACTIVE"分支，**不**新增 `AUTH_USER_DISABLED`。

### 错误码总览

| 错误码                     | HTTP | 中文描述                       | 触发场景                                                                                     | 修复建议                                  |
| -------------------------- | ---- | ------------------------------ | -------------------------------------------------------------------------------------------- | ----------------------------------------- |
| `SSO_DOMAIN_NOT_ALLOWED`   | 403  | SSO 登录的邮箱域名不在白名单    | JIT 建账号时邮箱域名不在 `SSO_ALLOWED_DOMAINS`                                                | 联系 IT 申请加白名单 / 改用密码登录       |
| `SSO_TOKEN_INVALID`        | 401  | OIDC token 校验失败            | ID token signature/iss/aud/nonce/exp 任一不通过；state/code_verifier 不匹配；Entra `invalid_grant`（code 已用 / 过期） | 重新发起 SSO 登录；持续失败联系 IT        |
| `SSO_EMAIL_MISSING`        | 400  | OIDC token 缺 email claim       | Entra app registration 未授予 email scope，或用户 Entra 账号无 email（B2B guest / 纯 UPN 账号）| IT 在 Entra app 上勾 email scope          |
| `SSO_BINDING_CONFLICT`     | 409  | SSO 账号绑定冲突               | `externalSource='entra'` 且 `externalId` ≠ token `oid`（LDAP 来源不触发此错误，走 LDAP 升级路径） | 联系 itadmin 检查 `User.externalId`       |
| `SSO_PROVIDER_UNAVAILABLE` | 503  | Entra ID 不可用                | OIDC discovery / token endpoint 5xx / 超时（5s）；DB 事务失败；运行时 `SSO_JIT_DEFAULT_ORG_ID` 缺失 | 用密码登录暂避；持续超过 5 分钟联系 IT    |
| `SSO_USER_CANCELLED`       | 403  | 用户取消 Microsoft 登录        | Entra callback `query.error=access_denied`                                                    | 重试，或选择密码通道                       |
| `SSO_CONSENT_REQUIRED`     | 403  | Microsoft 要求重新授权         | Entra callback `query.error=consent_required` 或 `interaction_required`                       | 重试 SSO 登录                              |
| `SSO_PROVIDER_REJECTED`    | 502  | Microsoft 登录被拒             | Entra callback `query.error` 为其它值（provider 端拒绝）                                       | 联系 IT 检查 Entra app 配置 / 用户状态     |

---

### SSO_DOMAIN_NOT_ALLOWED: SSO 登录的邮箱域名不在白名单

**HTTP状态码**: 403 Forbidden

**错误消息**（与 [05-ui-interaction-spec.md](./05-ui-interaction-spec.md) 一致）:
- 中文: "你的邮箱域名暂未授权使用 SSO 登录，请联系 IT 或改用密码登录"
- 英文: "Your email domain is not authorized for SSO sign-in. Contact IT or use password sign-in."

**触发场景**:
- callback 阶段未命中本地 User，进入 JIT 流程，但邮箱域名不在 `SSO_ALLOWED_DOMAINS` env 列表中

**处理建议**:
- 提示用户联系 IT 申请加白名单，或改用本地账号密码登录
- 不要透露完整白名单内容（仅返回"域名不在白名单"）
- 服务端写一条 audit 记录（`status=FAILED`，`why='SSO_DOMAIN_NOT_ALLOWED'`，actor=email）便于运营排查

---

### SSO_TOKEN_INVALID: OIDC token 校验失败

**HTTP状态码**: 401 Unauthorized

**错误消息**（与 [05-ui-interaction-spec.md](./05-ui-interaction-spec.md) 一致）:
- 中文: "SSO 登录校验失败，请重试"
- 英文: "SSO verification failed. Please try again."

**触发场景**:
- `query.state` ≠ cookie `sso_state`（CSRF / cookie 丢失 / cookie TTL 过期 15min）
- ID token 的 `iss` / `aud` / `exp` / `nonce` / signature 任一校验失败（包括 issuer `tid` 替换比对失败、clock skew > ±5min）
- Entra token endpoint 返回 `invalid_grant`（code 已被使用过 / code 已过期）
- token endpoint 返回不合法的 token 结构

**处理建议**:
- 前端：清掉 SSO 相关 cookie 后引导用户重新发起 `/auth/sso/start`
- 持续失败（5 分钟内 ≥3 次）→ 提示用户联系 IT 检查 Entra app 配置
- 写安全审计日志，包含 user-agent、IP、失败原因

---

### SSO_EMAIL_MISSING: OIDC token 缺 email claim

**HTTP状态码**: 400 Bad Request

**错误消息**（与 [05-ui-interaction-spec.md](./05-ui-interaction-spec.md) 一致）:
- 中文: "你的微软账号未配置邮箱，请联系 IT"
- 英文: "Your Microsoft account is missing an email. Contact IT."

**触发场景**:
- Entra App Registration 未授予 `email` scope
- 用户的 Entra 账号本身没有 email 属性（如纯设备账号 / B2B guest / 纯 UPN 账号）

**处理建议**:
- 提示用户联系 IT：让管理员在 Entra Portal 的 App Registration 中勾选 `email` scope
- 阻止登录，不进入 JIT 流程（因为无法可靠定位用户）
- **不**使用 `preferred_username` / `upn` 作为 fallback（PRD Out-of-scope 决策）

---

### SSO_BINDING_CONFLICT: SSO 账号绑定冲突

**HTTP状态码**: 409 Conflict

**错误消息**（与 [05-ui-interaction-spec.md](./05-ui-interaction-spec.md) 一致）:
- 中文: "SSO 账号绑定冲突，请联系 itadmin"
- 英文: "SSO binding conflict. Contact itadmin."

**触发场景**:
- callback 阶段 lower-case email 命中本地 User，且该 User 的 `externalSource='entra'` 且 `externalId` 已存在且 ≠ ID token 的 `oid` claim
- 说明同一个邮箱在 Entra 侧被换了一个 Object ID（如租户迁移、账号重建），需要人工介入
- **不包含 LDAP 来源**：`externalSource='ldap'` 且 `externalId ≠ oid` → 走自动升级路径，触发 `SSO_BINDING_UPGRADED_FROM_LDAP` audit，**不**返此错误

**处理建议**:
- 提示用户联系 itadmin 检查 `User.externalId` 与当前 Entra Object ID 是否需要对齐
- 不允许自动覆盖：服务层显式拒绝以防误绑定到错的 Entra 账号
- audit 中记录 `existingExternalId` + `attemptedExternalId` + `entraTid` 三个值便于运营核对

---

### SSO_PROVIDER_UNAVAILABLE: Entra ID 不可用

**HTTP状态码**: 503 Service Unavailable

**错误消息**（与 [05-ui-interaction-spec.md](./05-ui-interaction-spec.md) 一致）:
- 中文: "Microsoft 登录暂时不可用，请改用密码登录"
- 英文: "Microsoft sign-in unavailable. Please use password sign-in."

**触发场景**:
- `/auth/sso/start` 拉 OIDC discovery 文档失败（Entra 5xx 或超时）
- `/auth/sso/callback` 调 token endpoint 5xx 或超过 5s 超时
- DB 事务（JIT / 回填 + audit）失败
- 运行时 `SSO_JIT_DEFAULT_ORG_ID` 对应 Organization 不存在 / 已软删（启动期校验通过后被运营误删）

**处理建议**:
- 前端：提示用户切回密码登录，或 5 分钟后重试
- 持续超过 5 分钟 → 触发监控告警，IT 检查 Entra status page / 默认 org 状态
- 不要把异常细节透传给前端（避免泄露内部依赖结构）

---

### SSO_USER_CANCELLED: 用户取消 Microsoft 登录（v2.4 新增）

**HTTP状态码**: 403 Forbidden

**错误消息**（与 [05-ui-interaction-spec.md](./05-ui-interaction-spec.md) 一致）:
- 中文: "你已取消 Microsoft 登录"
- 英文: "You cancelled Microsoft sign-in."

**触发场景**:
- Entra callback `query.error=access_denied`（用户在 Microsoft 域内主动点取消）

**处理建议**:
- 前端：展示 toast 后引导用户重新点击「使用 Microsoft 登录」或切密码通道
- 不计入"失败重试"安全策略（用户主动行为）
- audit `status=FAILED`，`why='SSO_USER_CANCELLED'`，actor=IP / user-agent

---

### SSO_CONSENT_REQUIRED: Microsoft 要求重新授权（v2.4 新增）

**HTTP状态码**: 403 Forbidden

**错误消息**（与 [05-ui-interaction-spec.md](./05-ui-interaction-spec.md) 一致）:
- 中文: "Microsoft 要求重新授权，请重试登录"
- 英文: "Microsoft requires re-consent. Please sign in again."

**触发场景**:
- Entra callback `query.error=consent_required`（用户 / 管理员撤回了对该 app 的 consent）
- Entra callback `query.error=interaction_required`（Entra 要求用户重新交互，如重新输密码 / 重新 MFA）

**处理建议**:
- 前端：展示 toast 后引导用户重新点击「使用 Microsoft 登录」
- 持续触发（同 IP 5 分钟内 ≥3 次）→ 提示 IT 检查 Entra app consent 配置
- audit `status=FAILED`，`why='SSO_CONSENT_REQUIRED'`

---

### SSO_PROVIDER_REJECTED: Microsoft 登录被拒（v2.4 新增）

**HTTP状态码**: 502 Bad Gateway

**错误消息**（与 [05-ui-interaction-spec.md](./05-ui-interaction-spec.md) 一致）:
- 中文: "Microsoft 登录被拒绝，请联系 IT"
- 英文: "Microsoft sign-in was rejected. Contact IT."

**触发场景**:
- Entra callback `query.error` 为上述 3 个之外的任意值（`invalid_request` / `unauthorized_client` / `unsupported_response_type` / `server_error` / `temporarily_unavailable` / 等）
- 一般属于 provider 端配置错误或租户级策略拒绝

**处理建议**:
- 前端：展示 toast 后引导用户联系 IT
- audit 记录原始 `query.error` + `query.error_description`，便于 IT 排查
- 持续触发 → 告警，IT 检查 Entra app 配置、租户条件访问策略

---

### IAM_USERNAME_EXISTS: 用户名已存在

**HTTP状态码**: 409 Conflict

**错误消息**:
- 中文: "用户名 '{username}' 已被使用"
- 英文: "Username '{username}' already exists"

**触发场景**:
- 创建用户时用户名重复
- 更新用户时用户名与其他用户冲突

**处理建议**:
- 前端：实时校验用户名可用性
- 提供用户名建议
- 允许查看冲突的用户（如已删除）

**示例响应**:
```json
{
  "success": false,
  "error": {
    "code": "IAM_USERNAME_EXISTS",
    "message": "用户名 'zhangsan' 已被使用",
    "details": {
      "field": "username",
      "value": "zhangsan",
      "suggestions": ["zhangsan01", "zhangsan_2024"]
    }
  }
}
```

---

### IAM_USER_EMAIL_EXISTS: 邮箱已存在

**HTTP状态码**: 409 Conflict

**错误消息**:
- 中文: "邮箱 '{email}' 已被使用"
- 英文: "Email '{email}' already exists"

**触发场景**:
- 创建用户时邮箱重复
- 更新用户时邮箱冲突

**处理建议**:
- 前端：实时校验邮箱可用性
- 如邮箱属于已删除用户，提供恢复选项

---

### IAM_EMPLOYEE_ID_EXISTS: 员工编号已存在

**HTTP状态码**: 409 Conflict

**错误消息**:
- 中文: "员工编号 '{employeeId}' 已被使用"
- 英文: "Employee ID '{employeeId}' already exists"

---

### IAM_USER_NOT_FOUND: 用户不存在

**HTTP状态码**: 404 Not Found

**错误消息**:
- 中文: "用户不存在或已被删除"
- 英文: "User not found or deleted"

**触发场景**:
- 查询、更新、删除不存在的用户
- 用户已被软删除

**处理建议**:
- 前端：提示用户不存在
- 检查用户ID是否正确
- 如是软删除，管理员可恢复

---

### IAM_USER_ALREADY_ACTIVE: 无法停用最后一个管理员

**HTTP状态码**: 400 Bad Request

**错误消息**:
- 中文: "无法停用最后一个管理员账号"
- 英文: "Cannot disable the last administrator account"

**触发场景**:
- 尝试停用系统中唯一的活跃管理员

**处理建议**:
- 阻止操作
- 提示先创建其他管理员
- 系统保护机制

---

### IAM_DEPARTMENT_CODE_EXISTS: 部门代码已存在

**HTTP状态码**: 409 Conflict

**错误消息**:
- 中文: "部门代码 '{code}' 已被使用"
- 英文: "Department code '{code}' already exists"

---

### IAM_DEPARTMENT_HAS_CHILDREN: 无法删除有子部门的部门

**HTTP状态码**: 400 Bad Request

**错误消息**:
- 中文: "该部门有 {count} 个子部门，请先删除或转移子部门"
- 英文: "This department has {count} child departments, please remove them first"

---

### IAM_DEPARTMENT_HAS_USERS: 无法删除有员工的部门

**HTTP状态码**: 400 Bad Request

**错误消息**:
- 中文: "该部门有 {count} 名员工，请先转移员工"
- 英文: "This department has {count} employees, please transfer them first"

---

## 👥 用户部门关联错误 (USER-DEPT - 400)

### IAM_USER_ALREADY_IN_DEPARTMENT: 用户已属于该部门

**HTTP状态码**: 409 Conflict

**错误消息**:
- 中文: "用户已属于该部门"
- 英文: "User is already a member of this department"

---

### IAM_MANAGER_NOT_IN_DEPARTMENT: 上级不在该部门

**HTTP状态码**: 400 Bad Request

**错误消息**:
- 中文: "上级 '{managerName}' 不在部门 '{deptName}' 中"
- 英文: "Manager '{managerName}' is not a member of department '{deptName}'"

**触发场景**:
- 设置汇报关系时，上级不是该部门的成员

**处理建议**:
- 显示该部门可选择的上级列表
- 提供两种解决方案：
  1. 选择该部门内的其他上级
  2. 为上级添加该部门的归属

**示例响应**:
```json
{
  "success": false,
  "error": {
    "code": "IAM_MANAGER_NOT_IN_DEPARTMENT",
    "message": "上级 '李四' 不在部门 '技术部' 中",
    "details": {
      "managerId": "user-2",
      "managerName": "李四",
      "departmentId": "dept-1",
      "departmentName": "技术部",
      "availableManagers": [
        {"id": "user-3", "name": "王经理"},
        {"id": "user-4", "name": "张总监"}
      ]
    }
  }
}
```

---

### IAM_CANNOT_REMOVE_PRIMARY_DEPARTMENT: 无法删除主部门归属

**HTTP状态码**: 400 Bad Request

**错误消息**:
- 中文: "不能删除主部门归属，请先将其他部门设为主部门"
- 英文: "Cannot remove primary department, please set another department as primary first"

---

### IAM_SYSTEM_ROLE_PROTECTED: 无法删除内置角色

**HTTP状态码**: 400 Bad Request

**错误消息**:
- 中文: "无法删除内置角色 '{roleName}'"
- 英文: "Cannot delete built-in role '{roleName}'"

**触发场景**:
- 尝试删除 Administrator、Employee 等内置角色

---

### IAM_ROLE_HAS_USERS: 无法删除已分配的角色

**HTTP状态码**: 400 Bad Request

**错误消息**:
- 中文: "该角色已分配给 {count} 个用户，请先取消分配"
- 英文: "This role is assigned to {count} users, please unassign them first"

---

### IAM_FORBIDDEN: 权限不足

**HTTP状态码**: 403 Forbidden

**错误消息**:
- 中文: "您没有执行此操作的权限"
- 英文: "Permission denied"

**触发场景**:
- 用户没有所需的权限
- v2.1: 用户在当前组织没有所需权限

**处理建议**:
- 显示所需权限
- 提供申请权限的入口
- 显示当前用户的权限
- v2.1: 提示切换组织或申请组织权限

**示例响应**:
```json
{
  "success": false,
  "error": {
    "code": "IAM_FORBIDDEN",
    "message": "您没有执行此操作的权限",
    "details": {
      "requiredPermission": "user:delete",
      "currentPermissions": ["user:read", "user:update"],
      "organizationId": "org-001",  // v2.1: 组织上下文
      "suggestion": "请联系管理员申请 'user:delete' 权限"
    }
  }
}
```

---

### IAM_ORGANIZATION_CODE_EXISTS: 组织代码已存在

**HTTP状态码**: 409 Conflict

**错误消息**:
- 中文: "组织代码 '{code}' 已被使用"
- 英文: "Organization code '{code}' already exists"

**触发场景**:
- 创建组织时代码重复
- 更新组织时代码与其他组织冲突

**处理建议**:
- 前端：实时校验组织代码可用性
- 提供代码建议（如添加年份后缀）
- 显示冲突的组织信息（如已删除）

**示例响应**:
```json
{
  "success": false,
  "error": {
    "code": "IAM_ORGANIZATION_CODE_EXISTS",
    "message": "组织代码 'FF_CN' 已被使用",
    "details": {
      "field": "code",
      "value": "FF_CN",
      "existingOrganization": {
        "id": "org-001",
        "name": "Foo & Friends China",
        "status": "ACTIVE"
      },
      "suggestions": ["FF_CN_2025", "FF_CHINA", "FF_CN_NEW"]
    }
  }
}
```

---

### IAM_ORGANIZATION_NAME_EXISTS: 组织名称已存在

**HTTP状态码**: 409 Conflict

**错误消息**:
- 中文: "组织名称 '{name}' 已被使用"
- 英文: "Organization name '{name}' already exists"

**触发场景**:
- 创建组织时名称重复
- 更新组织时名称与其他组织冲突

**处理建议**:
- 前端：实时校验组织名称可用性
- 提供名称建议（如添加地域后缀）
- 显示冲突的组织信息（如已删除）

---

### IAM_SYNC_IN_PROGRESS: 同步任务进行中

**HTTP状态码**: 409 Conflict

**错误消息**:
- 中文: "已有同步任务正在进行"
- 英文: "A sync task is already in progress"

**触发场景**:
- 同一时刻重复触发同步任务

---

### IAM_EXTERNAL_ID_MISMATCH: 同步冲突

**HTTP状态码**: 409 Conflict

**错误消息**:
- 中文: "外部ID '{externalId}' 与现有记录冲突"
- 英文: "External ID '{externalId}' conflicts with existing record"

**触发场景**:
- 外部系统同步用户时，externalId 与现有记录冲突

**处理建议**:
- 提供冲突解决选项：
  1. 跳过该用户
  2. 更新用户来源
  3. 合并用户信息

---

### 标准错误响应

```typescript
interface ErrorResponse {
  success: false;
  error: {
    code: string;              // 错误码：IAM_*（或通用错误码）
    message: string;           // 用户友好的错误消息
    details?: any;             // 详细信息（可选）
    timestamp?: string;        // 错误发生时间
    path?: string;             // 请求路径
    traceId?: string;          // 追踪ID（用于日志关联）
  };
}
```

### 通用错误码（非 IAM）

以下错误码来自框架或通用异常，可能在部分接口中直接返回：

| 错误码 | HTTP | 说明 |
|--------|------|------|
| `BAD_REQUEST` | 400 | 请求参数或业务规则错误 |
| `VALIDATION_ERROR` | 400 | 参数验证失败 |
| `CANNOT_CHANGE_PASSWORD` | 400 | 非 LOCAL 用户不允许修改密码 |
| `UNAUTHORIZED` | 401 | 未认证或凭证无效 |
| `FORBIDDEN` | 403 | 权限不足 |
| `NOT_FOUND` | 404 | 资源不存在 |
| `CONFLICT` | 409 | 资源冲突 |
| `INTERNAL_ERROR` | 500 | 服务器内部错误 |

### 示例 1: 简单错误

```json
{
  "success": false,
  "error": {
    "code": "IAM_USER_NOT_FOUND",
    "message": "用户不存在或已被删除",
    "timestamp": "2025-12-26T10:30:00Z",
    "path": "/api/v1/users/123",
    "traceId": "abc-def-ghi"
  }
}
```

### 示例 2: 复杂错误（带详情）

```json
{
  "success": false,
  "error": {
    "code": "IAM_MANAGER_NOT_IN_DEPARTMENT",
    "message": "上级 '李四' 不在部门 '技术部' 中",
    "details": {
      "managerId": "user-2",
      "managerName": "李四",
      "departmentId": "dept-1",
      "departmentName": "技术部",
      "availableManagers": [
        {"id": "user-3", "name": "王经理"},
        {"id": "user-4", "name": "张总监"}
      ]
    },
    "timestamp": "2025-12-26T10:30:00Z",
    "path": "/api/v1/users/user-1/departments",
    "traceId": "abc-def-ghi"
  }
}
```

### 示例 3: 验证错误（多字段）

```json
{
  "success": false,
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "数据验证失败",
    "details": {
      "validationErrors": [
        {
          "field": "email",
          "message": "邮箱格式不正确",
          "value": "invalid-email"
        },
        {
          "field": "username",
          "message": "用户名长度必须在3-20个字符之间",
          "value": "ab"
        }
      ]
    },
    "timestamp": "2025-12-26T10:30:00Z"
  }
}
```

---

## 🔍 错误处理最佳实践

### 后端实现

```typescript
// NestJS 异常过滤器示例
@Catch()
export class OrganizationExceptionFilter implements ExceptionFilter {
  catch(exception: any, host: ArgumentsHost) {
    const ctx = host.switchToHttp();
    const response = ctx.getResponse();
    const request = ctx.getRequest();
    
    let statusCode = 500;
    let errorCode = 'INTERNAL_ERROR';
    let message = '系统错误';
    let details = null;
    
    if (exception instanceof IamException) {
      statusCode = exception.getStatus();
      errorCode = exception.code;
      message = exception.message;
      details = exception.details;
    }
    
    response.status(statusCode).json({
      success: false,
      error: {
        code: errorCode,
        message,
        details,
        timestamp: new Date().toISOString(),
        path: request.url,
        traceId: request.headers['x-trace-id']
      }
    });
  }
}
```

### 前端处理

```typescript
// API Client 错误处理
async function handleApiError(error: any) {
  if (error.response) {
    const { code, message, details } = error.response.data.error;
    
    switch (code) {
      case 'IAM_INVALID_CREDENTIALS':
      case 'IAM_TOKEN_EXPIRED':
      case 'IAM_TOKEN_REVOKED':
        // 认证错误：跳转登录
        router.push('/login');
        break;
        
      case 'IAM_FORBIDDEN':
        // 权限不足：显示友好提示
        showError('权限不足', details?.suggestion);
        break;
        
      case 'IAM_USERNAME_EXISTS':
      case 'IAM_USER_EMAIL_EXISTS':
        // 冲突错误：显示建议
        showError(message, details?.suggestions);
        break;
        
      default:
        // 通用错误处理
        showError(message);
    }
  }
}
```

---

## 📊 错误码统计

错误码数量与分类以 `backend/src/modules/organization/exceptions/iam.exceptions.ts` 为准。

---

## 🎨 前端错误处理

### 统一错误处理函数

```typescript
// utils/error-handler.ts
import { ApiClientError } from '@/types/api';
import { toast } from '@/components/ui/toast';
import { router } from 'next/navigation';

export function handleApiError(error: ApiClientError) {
  const { code, message, details, statusCode } = error;
  
  // 1. 认证错误：自动跳转登录
  switch (code) {
    case 'IAM_INVALID_CREDENTIALS':
    case 'IAM_TOKEN_EXPIRED':
    case 'IAM_TOKEN_REVOKED':
      toast.error('登录已过期，请重新登录');
      router.push('/login');
      return;
      
    case 'IAM_USER_SUSPENDED':
      toast.error('您的账号已被停用，请联系管理员');
      router.push('/login');
      return;
      
    case 'IAM_USER_TERMINATED':
      toast.error('您的账号已离职');
      router.push('/login');
      return;
      
  }
  
  // 3. 唯一性冲突：显示建议
  if (statusCode === 409) {
    showConflictError(message, details?.suggestions);
    return;
  }
  
  // 4. 资源不存在：返回列表
  if (statusCode === 404) {
    toast.error(message, {
      action: {
        label: '返回列表',
        onClick: () => router.back()
      }
    });
    return;
  }
  
  // 5. 业务规则错误：显示详细信息
  if (statusCode === 400) {
    showBusinessError(message, details);
    return;
  }
  
  // 6. 服务器错误：显示通用提示
  if (statusCode >= 500) {
    toast.error('系统异常，请稍后重试', {
      description: details?.traceId ? `追踪ID: ${details.traceId}` : undefined,
      action: {
        label: '重试',
        onClick: () => window.location.reload()
      }
    });
    return;
  }
  
  // 默认处理
  toast.error(message);
}

// 冲突错误处理
function showConflictError(message: string, suggestions?: string[]) {
  toast.error(message, {
    description: suggestions ? `建议: ${suggestions.join(', ')}` : undefined
  });
}

// 业务规则错误处理
function showBusinessError(message: string, details?: any) {
  if (details?.subordinates) {
    // 有下属的用户删除
    toast.error(message, {
      description: `下属列表: ${details.subordinates.map((s: any) => s.name).join(', ')}`,
      action: {
        label: '查看详情',
        onClick: () => console.log('Show subordinates')
      }
    });
  } else {
    toast.error(message, {
      description: details?.explanation || details?.suggestion
    });
  }
}
```

### 错误信息国际化

```typescript
// i18n/error-messages.ts
export const errorMessages = {
  'zh-CN': {
    // 认证错误
    'IAM_INVALID_CREDENTIALS': '用户名或密码错误',
    'IAM_TOKEN_EXPIRED': '登录已过期，请重新登录',
    'IAM_TOKEN_REVOKED': '登录凭证无效',
    'IAM_USER_SUSPENDED': '您的账号已被停用',
    'IAM_USER_TERMINATED': '您的账号已离职',
    
    // 用户管理
    'IAM_USERNAME_EXISTS': '用户名已被使用',
    'IAM_USER_EMAIL_EXISTS': '邮箱已被使用',
    'IAM_USER_NOT_FOUND': '用户不存在或已被删除',
    'IAM_EMPLOYEE_ID_EXISTS': '员工编号已被使用',
    'IAM_USER_ALREADY_ACTIVE': '用户已处于激活状态',
    'IAM_TERMINATED_USER_CANNOT_ACTIVATE': '离职用户无法通过此接口激活',
    
    // 部门与角色
    'IAM_DEPARTMENT_CODE_EXISTS': '部门编码已存在',
    'IAM_DEPARTMENT_HAS_CHILDREN': '部门下有子部门，无法删除',
    'IAM_DEPARTMENT_HAS_USERS': '部门下有成员，无法删除',
    'IAM_USER_ALREADY_IN_DEPARTMENT': '用户已属于该部门',
    'IAM_MANAGER_NOT_IN_DEPARTMENT': '上级不是该部门成员',
    'IAM_CANNOT_REMOVE_PRIMARY_DEPARTMENT': '不能移除主部门，需先设置其他部门为主部门',
    'IAM_SYSTEM_ROLE_PROTECTED': '内置角色不可删除',
    'IAM_ROLE_HAS_USERS': '角色下有关联用户，无法删除',
    'CANNOT_CHANGE_PASSWORD': '当前用户类型不支持修改密码',
    
    // 组织管理（v2.0）
    'IAM_ORGANIZATION_CODE_EXISTS': '组织代码已被使用',
    'IAM_ORGANIZATION_NAME_EXISTS': '组织名称已被使用',
    'IAM_REGION_CODE_EXISTS': '区域编码已存在',
    'IAM_REGION_HAS_DEPARTMENTS': '区域下有关联组织，无法删除',
    'IAM_ONLY_ROOT_DEPARTMENT_CAN_BE_DEFAULT': '只有顶级部门才能设为默认组织',
  },
  'en-US': {
    'IAM_INVALID_CREDENTIALS': 'Invalid username or password',
    'IAM_TOKEN_EXPIRED': 'Token expired, please login again',
    'IAM_TOKEN_REVOKED': 'Invalid token',
    'IAM_USER_SUSPENDED': 'Your account has been disabled',
    'IAM_USER_TERMINATED': 'Your account has been terminated',
    'IAM_ORGANIZATION_CODE_EXISTS': 'Organization code already exists',
    // ...
  }
};

export function getErrorMessage(code: string, locale: string = 'zh-CN'): string {
  return errorMessages[locale]?.[code] || code;
}
```

### 表单验证错误处理

```typescript
// hooks/useFormErrors.ts
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';

export function useFormErrors() {
  const form = useForm({
    resolver: zodResolver(schema)
  });
  
  // 处理 API 返回的字段错误
  const handleFieldErrors = (errors: Array<{ field: string; message: string }>) => {
    errors.forEach(({ field, message }) => {
      form.setError(field as any, {
        type: 'manual',
        message
      });
    });
  };
  
  // 处理业务规则错误
  const handleBusinessError = (error: ApiClientError) => {
    if (error.details?.validationErrors) {
      handleFieldErrors(error.details.validationErrors);
    } else if (error.details?.field) {
      form.setError(error.details.field, {
        type: 'manual',
        message: error.message
      });
    } else {
      toast.error(error.message);
    }
  };
  
  return { form, handleFieldErrors, handleBusinessError };
}
```

### 错误恢复操作

```typescript
// components/ErrorBoundary.tsx
export function ErrorRecovery({ error, onRetry, onGoBack }: Props) {
  const getRecoveryActions = (errorCode: string) => {
    switch (errorCode) {
      case 'IAM_USER_NOT_FOUND': // 用户不存在
        return [
          { label: '返回列表', onClick: onGoBack },
          { label: '搜索用户', onClick: () => router.push('/users/search') }
        ];
        
      case 'INTERNAL_ERROR': // 系统错误
        return [
          { label: '重试', onClick: onRetry },
          { label: '刷新页面', onClick: () => window.location.reload() }
        ];
        
      default:
        return [
          { label: '重试', onClick: onRetry },
          { label: '返回', onClick: onGoBack }
        ];
    }
  };
  
  return (
    <div className="error-recovery">
      <h3>{error.message}</h3>
      {error.details?.suggestion && (
        <p className="suggestion">{error.details.suggestion}</p>
      )}
      <div className="actions">
        {getRecoveryActions(error.code).map(action => (
          <Button key={action.label} onClick={action.onClick}>
            {action.label}
          </Button>
        ))}
      </div>
    </div>
  );
}
```

### API 客户端集成

```typescript
// lib/api-client.ts
import axios from 'axios';
import { handleApiError } from '@/utils/error-handler';

export const apiClient = axios.create({
  baseURL: '/api/v1',
  timeout: 10000
});

// 响应拦截器
apiClient.interceptors.response.use(
  response => response.data,
  error => {
    if (error.response) {
      const apiError: ApiClientError = {
        code: error.response.data.error?.code || 'UNKNOWN_ERROR',
        message: error.response.data.error?.message || '未知错误',
        details: error.response.data.error?.details,
        statusCode: error.response.status,
        traceId: error.response.data.error?.traceId
      };
      
      // 统一错误处理
      handleApiError(apiError);
      
      return Promise.reject(apiError);
    }
    
    // 网络错误
    handleApiError({
      code: 'NETWORK_ERROR',
      message: '网络连接失败，请检查网络',
      statusCode: 0
    });
    
    return Promise.reject(error);
  }
);
```

---

## 🤖 AI 工具授权错误码 (AI-TOOL - 1100, v2.2 权限 MVP)

| 错误码 | HTTP | 场景 | 说明 |
|---|---|---|---|
| `IAM_AI_TOOL_GRANT_ROLE_EXISTS` | 409 | POST /api/v1/ai-tools/grants | (roleId, toolName) 已存在重复授权 |
| `IAM_AI_TOOL_GRANT_USER_EXISTS` | 409 | POST /api/v1/ai-tools/user-grants | (userId, toolName) 已存在重复授权 |
| `IAM_AI_TOOL_GRANT_NOT_FOUND` | 404 | DELETE / 创建时校验 | 授权 id / role / user 不存在或已软删 |
| `IAM_AI_TOOL_UNKNOWN` | 400 | 创建/批量创建 | 工具名不在「可用工具清单」中 |

异常类定义见 `backend/src/modules/organization/exceptions/iam.exceptions.ts` 的 "AI Tool Grant Exceptions" 段落。

> **v2.2.2 变更**：移除了 `IAM_AI_TOOL_SYNC_NOT_IMPLEMENTED`。`POST /ai-tools/sync` 改为信息性返回（201 + 提示等待 5 分钟自动同步），不再抛 501。

---

## 🔗 相关文档

- [API 文档](./07-api.md) - API 错误响应定义
- [架构设计](./03-architecture.md) - 异常处理架构
- [测试场景](./09-test-scenarios.md) - 错误场景测试用例

---

**最后更新**: 2026-05-19  
**维护者**: FFOA 后端团队  
**版本**: v2.4（Entra ID SSO 登录）  
**错误码总数**: 59 个（v2.0 新增 5 个，v2.1 新增 1 个，v2.1.1 新增 4 个，v2.2 新增 5 个，**v2.4 新增 8 个 SSO**：5 个核心 + 3 个 Entra error query 映射 = `SSO_DOMAIN_NOT_ALLOWED` / `SSO_TOKEN_INVALID` / `SSO_EMAIL_MISSING` / `SSO_BINDING_CONFLICT` / `SSO_PROVIDER_UNAVAILABLE` / `SSO_USER_CANCELLED` / `SSO_CONSENT_REQUIRED` / `SSO_PROVIDER_REJECTED`）

**v2.1.1 更新内容**:
- ✅ 预留 IAM_USER_LOCKED（当前未启用）
- ✅ 错误码统一以 IAM 前缀为准
- ✅ 补充详细的错误响应示例
- ✅ 更新前端错误处理示例代码
