# 用户与组织架构管理 - 数据模型文档

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

---

## 📋 变更记录

| 版本 | 日期 | 修改人 | 修改内容 |
|------|------|--------|---------|
| v2.4 | 2026-05-19 | FFOA Team | Entra ID SSO 登录：扩展 `externalId` / `externalSource` 字段语义、新增 env 变量约定、明确本期无 schema 变更，issue #334 |
| v2.1.29 | 2026-03-13 | FFOA Team | 明确本地年假释放计划表中 `adjustment_days` 与 `not_count_days` 的业务语义 |
| v2.1.28 | 2026-03-11 | FFOA Team | 新增本地年假释放计划表，替代宜搭年假释放中间表 |
| v2.1.27 | 2026-03-11 | FFOA Team | 新增钉钉假期余额快照表 |
| 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 | 新增 organizations、organization_regions 表 |
| v2.1 | 2025-12-26 | FFOA Team | user_role_rel 表新增 organization_id 字段 |
| v2.1.1 | 2025-12-26 | FFOA Team | 完善身份源、密码策略、登录安全的数据约束说明 |
| v2.1.25 | 2026-01-05 | FFOA Team | 同步 PRD v2.1.25：身份源说明更新，优化字段说明 |

---

## 📋 概述

### Schema 信息

- **Schema 名称**: `platform_iam` (用户、角色、权限) + `corp_hr` (组织架构)
- **业务域**: 身份认证与访问控制、组织架构管理
- **核心表数量**: 15 个（v2.0 新增 2个，v2.1 更新 1个）
- **数据库**: PostgreSQL 14+

### v2.0 架构变更

**核心变更**:
- ✨ 新增 `organizations` 表（独立的组织实体）
- ✨ 新增 `organization_regions` 表（组织区域关联）
- ⚡ `departments` 表添加 `organization_id` 字段
- ⚡ `user_departments` 表添加 `organization_id` 冗余字段
- 🗑️ 移除 `department_regions` 表（架构简化）

### v2.1 权限架构变更 ⭐️

**核心变更**:
- ✨ `user_role_rel` 表新增 `organization_id` 字段（组织级权限隔离）
- 🔄 `region` 字段标记为 `@deprecated`（向后兼容，v3.0 移除）
- ✅ 支持全局角色（`organizationId = NULL`）
- ✅ 支持组织级角色（`organizationId = {uuid}`）

**迁移影响**:
- 权限查询逻辑需要同时考虑全局角色和组织角色
- 前端需要支持组织上下文切换
- API 需要接收 `organizationId` 参数（可选）

### 设计原则

1. **多Schema分离**: IAM认证数据与HR组织数据物理隔离，逻辑关联
2. **独立组织表**: Organization 是一等公民，支持组织特有属性（v2.0）
3. **多区域支持**: 支持CN/US/UAE多区域组织架构
4. **多部门归属**: 用户可同时属于多个部门，每个归属独立管理
5. **性能优化**: organizationId 冗余字段避免递归查询（v2.0）
6. **软删除**: 关键表使用 `deletedAt` 支持软删除
7. **审计完整**: 所有表包含创建/更新时间戳
8. **UUID主键**: 使用UUID避免ID冲突
9. **外键级联**: 合理使用级联删除保证数据一致性

### 钉钉同步扩展表

钉钉相关表与规则已迁移至独立模块文档维护，见 [docs/modules/dingtalk/06-data-model.md](../dingtalk/06-data-model.md)。

---

## 🗄️ 核心表结构

### 1. platform_iam Schema

#### 1.1 users（用户表）⭐️

##### 基本信息

- **表名**: `platform_iam.users`
- **说明**: 系统用户主表，存储用户基本信息和认证数据
- **主键**: `id` (UUID)
- **外键**: 无（被其他表引用）

##### 表结构

```sql
CREATE TABLE platform_iam.users (
  -- 主键
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  
  -- 基本信息
  username VARCHAR(255) NOT NULL UNIQUE,
  email VARCHAR(255) NOT NULL UNIQUE,
  password_hash VARCHAR(255),
  display_name VARCHAR(255) NOT NULL,
  avatar VARCHAR(500),
  phone VARCHAR(50),
  
  -- 状态
  status VARCHAR(50) NOT NULL DEFAULT 'ACTIVE',
  source VARCHAR(50) NOT NULL DEFAULT 'LOCAL',
  
  -- 外部同步字段
  ldap_dn VARCHAR(500),
  employee_id VARCHAR(100) UNIQUE,
  ldap_synced_at TIMESTAMPTZ,
  external_id VARCHAR(255),
  external_source VARCHAR(100),
  
  -- 多租户/多区域
  tenant_id VARCHAR(100),
  region VARCHAR(10) NOT NULL DEFAULT 'CN',
  
  -- 时间戳
  hired_at TIMESTAMPTZ,
  terminated_at TIMESTAMPTZ,
  metadata JSONB DEFAULT '{}',
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  deleted_at TIMESTAMPTZ,
  
  -- 约束
  CONSTRAINT check_status CHECK (status IN ('ACTIVE', 'INACTIVE', 'SUSPENDED', 'TERMINATED')),
  CONSTRAINT check_source CHECK (source IN ('LOCAL', 'LDAP')),
  CONSTRAINT check_region CHECK (region IN ('CN', 'US', 'UAE'))
);
```

##### 字段详细说明（v2.1.1 更新）

| 字段名 | 类型 | 必需 | 默认值 | 说明 |
|--------|------|------|--------|------|
| `id` | UUID | 是 | gen_random_uuid() | 用户唯一标识 |
| `username` | VARCHAR(255) | 是 | - | 用户名，唯一，用于登录 |
| `email` | VARCHAR(255) | 是 | - | 邮箱，唯一，用于登录和通知 |
| `password_hash` | VARCHAR(255) | 否 | NULL | 密码哈希（LOCAL用户必填，LDAP用户为空） |
| `display_name` | VARCHAR(255) | 是 | - | 显示名称 |
| `avatar` | VARCHAR(500) | 否 | NULL | 头像URL |
| `phone` | VARCHAR(50) | 否 | NULL | 电话号码 |
| `status` | VARCHAR(50) | 是 | 'ACTIVE' | 用户状态 |
| `source` | VARCHAR(50) | 是 | 'LOCAL' | 用户来源（身份源） |
| `ldap_dn` | VARCHAR(500) | 否 | NULL | LDAP Distinguished Name（LDAP用户必填） |
| `employee_id` | VARCHAR(100) | 否 | NULL | 员工编号（唯一） |
| `ldap_synced_at` | TIMESTAMPTZ | 否 | NULL | 最后同步时间 |
| `external_id` | VARCHAR(255) | 否 | NULL | 外部系统ID（如 Entra ID Object ID），v2.4 起兼作 SSO 登录绑定键 |
| `external_source` | VARCHAR(100) | 否 | NULL | 外部系统标识（用于同步），v2.4 起新增取值 `'entra'` 表示 SSO 首次登录回填 |
| `tenant_id` | VARCHAR(100) | 否 | NULL | 租户ID（多租户场景） |
| `region` | VARCHAR(10) | 是 | 'CN' | 用户默认区域（冗余字段，用于性能优化；如果用户有部门归属，应从部门所属组织继承；如果用户没有部门归属，使用此默认值） |
| `hired_at` | TIMESTAMPTZ | 否 | NULL | 入职时间 |
| `terminated_at` | TIMESTAMPTZ | 否 | NULL | 离职时间 |
| `metadata` | JSONB | 是 | '{}' | 扩展元数据 |
| `created_at` | TIMESTAMPTZ | 是 | NOW() | 创建时间 |
| `updated_at` | TIMESTAMPTZ | 是 | NOW() | 更新时间 |
| `deleted_at` | TIMESTAMPTZ | 否 | NULL | 软删除时间 |

##### SSO 相关字段语义扩展（v2.4 起）

**背景**：Entra ID SSO 登录复用现有 `externalId` / `externalSource` 两个字段，**不新增列、不改用户表 schema**（本期 schema 增量仅在 `platform_audit.AuditAction` enum 加 5 个值 + 一次性 email lower-case backfill SQL，详见底部 §「本期 schema 增量声明」）。下表锁定双通道（LDAP 同步 + SSO 登录回填）共用同一对字段的语义边界。

**`external_id`（Prisma: `externalId`）**:

- **旧用途**（v2.4 之前）：LDAP/Entra Connect 同步用户的标识，部分场景写 LDAP DN
- **新用途**（v2.4 起）：SSO 登录成功后回填 Entra Object ID（OIDC ID token 的 `oid` claim）
- **索引**：`@@index([externalId])` 已存在（用于反查"某个 Entra 账号对应哪个本地 User"）
- **唯一性**：当前 schema **不是** unique。SSO 通道发现 `externalId` 已存在但与当前 token `oid` 不同时，由 service 层显式判定（见下方"冲突处理"）

**`external_source`（Prisma: `externalSource`）**:

| 取值      | 含义                                                          | 何时写入                                 |
| --------- | ------------------------------------------------------------- | ---------------------------------------- |
| `'ldap'`  | LDAP/AD/Entra Connect 同步过来的（旧值，沿用）                | 外部同步流程                             |
| `'entra'` | 首次 SSO 登录回填 / LDAP 自动升级（**v2.4 新增**）            | `GET /auth/sso/callback` 命中 User 时    |
| `NULL`    | 纯本地账号 / `externalId` 未填                                 | 本地注册或未发起过 SSO                   |

**`source`（Prisma: `source`，枚举 `UserSource`）语义不变（v2.4 重申）**:

- `source` 表示**用户记录的创建/同步来源**，**不**表示"当前登录方式"
- SSO 登录命中已有 User 时，**不修改** `source`（即使该用户是 LOCAL，仍保持 LOCAL）
- 仅当 SSO 走 JIT 流程**新建** User 时，`source = ENTRA`
- 实际 Prisma enum 定义为 `enum UserSource { LOCAL, ENTRA, LDAP }`（与本文 §Prisma Schema 段落代码示例存在历史漂移，以 `backend/prisma/schema/platform_iam.prisma` 为准）

**回填 / 冲突处理（v2.4 三分支）**:

| 当前 `externalSource` | 当前 `externalId` vs 新 `oid` | 行为 | Audit |
|---|---|---|---|
| NULL | `externalId` 为 NULL | CAS UPDATE 回填（`WHERE id=$1 AND externalId IS NULL`），受影响行 0 时重查 fallback | `SSO_BINDING_FILLED` |
| `'entra'` | 相等 | 幂等，不重复写 | `SSO_LOGIN_SUCCESS` (`path: existing`) |
| `'entra'` | 不等 | **真冲突**，返 409 `SSO_BINDING_CONFLICT`，不覆盖 | `SSO_BINDING_CONFLICT` |
| `'ldap'` | 相等 | 视为 LDAP 同步早期已写 oid，幂等 | `SSO_LOGIN_SUCCESS` (`path: existing`) |
| `'ldap'` | 不等（典型：LDAP DN ≠ oid） | **允许覆盖** → `externalId = oid` + `externalSource = 'entra'` | `SSO_BINDING_UPGRADED_FROM_LDAP` + `SSO_LOGIN_SUCCESS` (`path: ldap_upgraded`) |

**email 大小写归一化策略（v2.4 新增）**:

- 应用层 lower-case 写入 + 查询；**不**动 DB schema collation / 不引入 citext
- v2.4 migration 含一次性 backfill SQL（幂等）：`UPDATE platform_iam.users SET email = LOWER(email) WHERE email != LOWER(email);`
- 后续所有 email 写入路径（密码登录注册 / SSO JIT / 邀请） 应用层统一 lower-case；密码登录查询同样 lower-case 输入再查
- 唯一约束 `UNIQUE (email)` 保持不变；归一化后约束含义即 "lower-case 唯一"

##### 枚举值定义（v2.1.1 更新）

> **业务定义**: 详见 [PRD - 用户管理](./01-prd.md#功能1-用户管理)  
> 本节仅列出数据库枚举值，完整的业务规则和使用场景请参考 PRD

###### status（用户状态）

| 值 | 可登录 | 数据库约束 |
|----|--------|-----------|
| `ACTIVE` | ✅ | 默认值 |
| `INACTIVE` | ❌ | - |
| `SUSPENDED` | ❌ | - |
| `TERMINATED` | ❌ | terminated_at 必填 |

**状态流转规则**: 详见 [状态机文档](./04-state-machine.md)

###### source（用户来源 / 身份源）（v2.4 更新）

| 值 | 说明 | 数据库约束 | 认证方式 | 密码管理 |
|----|------|-----------|---------|---------|
| `LOCAL` | 本地创建 | password_hash 必填 | 本地验证 password_hash | 可修改密码 |
| `LDAP` | LDAP/AD | ldap_dn 必填；password_hash 为空 | LDAP/AD 验证 | ❌ 通过 LDAP/AD 修改 |
| `ENTRA` | SSO JIT 自动建号（v2.4） | password_hash 为空；externalId / externalSource='entra' 必填 | Entra OIDC | ❌ 由 Entra 管理 |

**重要说明**:
- **LOCAL 用户**: 创建时必须提供密码，使用 bcrypt 加密存储，可以通过系统修改密码
- **LDAP 用户**: 可以登录系统，认证由 LDAP/AD 服务器处理，不能通过系统修改密码
- **ENTRA 用户**: 仅通过 SSO 通道登录，无本地密码；passwordHash 始终为 NULL

**身份源不可变**: `source` 字段创建后不可修改，如需更改身份源，需重新创建用户

**同步规则**: 详见 [PRD - 外部同步](./01-prd.md#功能7-外部同步entra-id)

##### 索引

```sql
-- 主键索引（自动创建）
-- PRIMARY KEY (id)

-- 唯一索引（自动创建）
-- UNIQUE (username)
-- UNIQUE (email)
-- UNIQUE (employee_id)

-- 状态索引
CREATE INDEX idx_users_status ON platform_iam.users(status) WHERE deleted_at IS NULL;

-- 外部ID索引（用于同步查询）
CREATE INDEX idx_users_external_id ON platform_iam.users(external_id) WHERE external_id IS NOT NULL;

-- 区域索引
CREATE INDEX idx_users_region ON platform_iam.users(region);

-- 组合索引（常用查询）
CREATE INDEX idx_users_status_region ON platform_iam.users(status, region) WHERE deleted_at IS NULL;
```

##### 业务约束（v2.1.1 更新）

1. **唯一性约束**: username、email、employee_id必须唯一
2. **密码约束**: 
   - LOCAL用户必须有password_hash（创建时必填）
   - LDAP用户 password_hash 为空，ldap_dn 必填
3. **状态约束**: status只能是4个预定义值之一
4. **删除约束**: 不允许物理删除，必须使用软删除（设置deleted_at）
5. **身份源约束**: 
   - source 字段创建后不可修改
   - LOCAL 用户可以修改密码
   - LDAP 用户通过 LDAP/AD 认证，不能修改密码
6. **密码策略**（仅 LOCAL 用户）:
   - 最小长度：8位
   - 字符类型：至少包含大小写字母、数字、特殊字符中的2种
   - password_hash 使用 bcrypt 算法存储
7. **登录安全**:
   - 当前未启用失败锁定机制
   - LDAP/AD 账户锁定由外部目录服务处理

---

#### 1.2 roles（角色表）

##### 基本信息

- **表名**: `platform_iam.roles`
- **说明**: 系统角色定义，支持RBAC权限模型
- **主键**: `id` (UUID)

##### 表结构

```sql
CREATE TABLE platform_iam.roles (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  name VARCHAR(255) NOT NULL UNIQUE,
  code VARCHAR(100) NOT NULL UNIQUE,
  description TEXT,
  is_built_in BOOLEAN NOT NULL DEFAULT FALSE,
  enabled BOOLEAN NOT NULL DEFAULT TRUE,
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
```

##### 字段说明

| 字段名 | 类型 | 必需 | 说明 |
|--------|------|------|------|
| `id` | UUID | 是 | 角色ID |
| `name` | VARCHAR(255) | 是 | 角色名称（如"系统管理员"） |
| `code` | VARCHAR(100) | 是 | 角色代码（如"Administrator"） |
| `description` | TEXT | 否 | 角色描述 |
| `is_built_in` | BOOLEAN | 是 | 是否内置角色（不可删除） |
| `enabled` | BOOLEAN | 是 | 是否启用 |
| `created_at` | TIMESTAMPTZ | 是 | 创建时间 |
| `updated_at` | TIMESTAMPTZ | 是 | 更新时间 |

##### 预定义角色

| Code | Name | Description |
|------|------|-------------|
| `Administrator` | 系统管理员 | 拥有所有权限 |
| `Employee` | 普通员工 | 基础权限 |

---

#### 1.3 permissions（权限表）

##### 表结构

```sql
CREATE TABLE platform_iam.permissions (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  resource VARCHAR(100) NOT NULL,
  action VARCHAR(100) NOT NULL,
  description TEXT,
  module VARCHAR(100),
  is_built_in BOOLEAN NOT NULL DEFAULT TRUE,
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  
  CONSTRAINT uq_permission_resource_action UNIQUE (resource, action)
);
```

##### 字段说明

| 字段名 | 类型 | 说明 |
|--------|------|------|
| `resource` | VARCHAR(100) | 资源类型（如user, department, role） |
| `action` | VARCHAR(100) | 操作类型（如read, create, update, delete） |
| `description` | TEXT | 权限描述 |
| `module` | VARCHAR(100) | 所属模块 |
| `is_built_in` | BOOLEAN | 是否内置权限 |

##### 权限命名规范

```
格式: {resource}:{action}

示例:
- user:read - 查看用户
- user:create - 创建用户
- user:update - 更新用户
- user:delete - 删除用户
- department:read - 查看部门
- role:manage - 管理角色
```

---

#### 1.4 user_role_rel（用户角色关联表）⭐️ v2.1 更新

##### 基本信息

- **表名**: `platform_iam.user_role_rel`
- **说明**: 用户角色关联表，支持组织级角色隔离（v2.1）
- **主键**: `id` (UUID)
- **外键**: 
  - `user_id` → `users(id)`
  - `role_id` → `roles(id)`
  - `organization_id` → `organizations(id)` (v2.1 新增)

##### 表结构

```sql
CREATE TABLE platform_iam.user_role_rel (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id UUID NOT NULL,
  role_id UUID NOT NULL,
  organization_id UUID,  -- v2.1: 组织级角色隔离，null 表示全局角色
  region VARCHAR(10),    -- DEPRECATED: 保留用于向后兼容，v3.0 将移除
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  
  CONSTRAINT fk_user_role_user FOREIGN KEY (user_id) 
    REFERENCES platform_iam.users(id) ON DELETE CASCADE,
  CONSTRAINT fk_user_role_role FOREIGN KEY (role_id) 
    REFERENCES platform_iam.roles(id) ON DELETE CASCADE,
  CONSTRAINT fk_user_role_organization FOREIGN KEY (organization_id)
    REFERENCES corp_hr.organizations(id) ON DELETE CASCADE,
  CONSTRAINT uq_user_role_org UNIQUE (user_id, role_id, organization_id)
);

-- 索引
CREATE INDEX idx_user_role_org ON platform_iam.user_role_rel(user_id, organization_id);
CREATE INDEX idx_user_role_region ON platform_iam.user_role_rel(user_id, region);  -- 向后兼容，v3.0 移除
```

##### 字段说明

| 字段名 | 类型 | 必需 | 说明 |
|--------|------|------|------|
| `user_id` | UUID | 是 | 用户ID |
| `role_id` | UUID | 是 | 角色ID |
| `organization_id` | UUID | 否 | 组织ID（v2.1 新增），null 表示全局角色 |
| `region` | VARCHAR(10) | 否 | 区域代码（已废弃，v3.0 将移除）|

##### v2.1 核心变更：组织级角色隔离

**变更前（v2.0）**：
```sql
-- 按区域隔离角色
INSERT INTO user_role_rel (user_id, role_id, region) 
VALUES ('user-001', 'role-admin', 'CN');

-- 问题：FF China 和其他中国公司都在 CN 区域
-- → 权限会泄露到其他 CN 区域的组织 ❌
```

**变更后（v2.1）**：
```sql
-- 按组织隔离角色
INSERT INTO user_role_rel (user_id, role_id, organization_id) 
VALUES ('user-001', 'role-admin', 'org-ff-china');

-- 用户在 FF China 是管理员，在 FF USA 是普通员工
INSERT INTO user_role_rel (user_id, role_id, organization_id) 
VALUES ('user-001', 'role-employee', 'org-ff-usa');

-- 全局角色（系统管理员）
INSERT INTO user_role_rel (user_id, role_id, organization_id) 
VALUES ('admin-001', 'role-sysadmin', NULL);  -- ✅ 在所有组织生效
```

##### 业务规则（v2.1）

1. **组织级角色隔离**：用户在不同组织可以有不同角色
2. **全局角色支持**：`organization_id = NULL` 表示全局角色（如系统管理员）
3. **唯一性约束**：同一用户在同一组织只能被分配一次相同角色
4. **级联删除**：删除用户/角色/组织时级联删除关联
5. **向后兼容**：保留 `region` 字段，逐步废弃

##### Prisma Model

```prisma
model UserRole {
  id             String        @id @default(uuid()) @db.Uuid
  userId         String        @map("user_id") @db.Uuid
  roleId         String        @map("role_id") @db.Uuid
  organizationId String?       @map("organization_id") @db.Uuid
  region         String?       @deprecated  // DEPRECATED: v3.0 将移除
  createdAt      DateTime      @default(now()) @map("created_at") @db.Timestamptz(3)
  
  role         Role          @relation(fields: [roleId], references: [id], onDelete: Cascade)
  user         User          @relation(fields: [userId], references: [id], onDelete: Cascade)
  organization Organization? @relation(fields: [organizationId], references: [id], onDelete: Cascade)

  @@unique([userId, roleId, organizationId])
  @@index([userId, organizationId])
  @@map("user_role_rel")
  @@schema("platform_iam")
}
```

##### 权限查询示例

```typescript
// 查询用户在特定组织的权限
async function getUserPermissions(userId: string, organizationId?: string) {
  const userRoles = await prisma.userRole.findMany({
    where: {
      userId,
      OR: [
        { organizationId: null },           // 全局角色
        { organizationId: organizationId }  // 组织角色
      ]
    },
    include: {
      role: {
        include: { permissions: { include: { permission: true } } }
      }
    }
  });
  
  // 返回：全局权限 + 组织权限的并集
  return userRoles.flatMap(ur => 
    ur.role.permissions.map(p => p.permission.code)
  );
}

// 示例：
getUserPermissions('user-001', 'org-ff-china')
// → ["user:read:organization", "user:update:organization", ...]

getUserPermissions('admin-001', 'org-ff-china')
// → ["user:read", "user:update", ...]  (全局权限)
```

---

#### 1.5 role_permission_rel（角色权限关联表）

##### 表结构

```sql
CREATE TABLE platform_iam.role_permission_rel (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  role_id UUID NOT NULL,
  permission_id UUID NOT NULL,
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  
  CONSTRAINT fk_role_perm_role FOREIGN KEY (role_id) 
    REFERENCES platform_iam.roles(id) ON DELETE CASCADE,
  CONSTRAINT fk_role_perm_perm FOREIGN KEY (permission_id) 
    REFERENCES platform_iam.permissions(id) ON DELETE CASCADE,
  CONSTRAINT uq_role_permission UNIQUE (role_id, permission_id)
);
```

---

#### 1.6 workflow_roles（流程角色表）

##### 表结构

```sql
CREATE TABLE platform_iam.workflow_roles (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  name VARCHAR(255) NOT NULL UNIQUE,
  code VARCHAR(100) NOT NULL UNIQUE,
  description TEXT,
  rule_type VARCHAR(50) NOT NULL,
  rule_config JSONB NOT NULL DEFAULT '{}',
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  
  CONSTRAINT check_rule_type CHECK (rule_type IN (
    'ORGANIZATION_RELATION',
    'SYSTEM_ROLE_MAPPING',
    'FIXED_USERS',
    'DYNAMIC_SCRIPT'
  ))
);
```

##### 字段说明

| 字段名 | 类型 | 说明 |
|--------|------|------|
| `name` | VARCHAR(255) | 流程角色名称 |
| `code` | VARCHAR(100) | 流程角色代码 |
| `rule_type` | VARCHAR(50) | 规则类型 |
| `rule_config` | JSONB | 规则配置（JSON） |

##### 规则类型说明

| 类型 | 说明 | 配置示例 |
|------|------|----------|
| `ORGANIZATION_RELATION` | 组织关系 | `{"relationType": "DIRECT_MANAGER"}` |
| `SYSTEM_ROLE_MAPPING` | 系统角色映射 | `{"systemRole": "Administrator"}` |
| `FIXED_USERS` | 固定用户列表 | `{"userIds": ["uuid1", "uuid2"]}` |
| `DYNAMIC_SCRIPT` | 动态脚本 | `{"script": "..."}` |

---

#### 1.7 workflow_role_user_rel（流程角色用户关联表）

##### 基本信息

- **表名**: `platform_iam.workflow_role_user_rel`
- **说明**: 流程角色与用户的关联表，用于固定用户类型的流程角色（FIXED_USERS）
- **主键**: `id` (UUID)
- **外键**: 
  - `workflow_role_id` → `workflow_roles(id)`
  - `user_id` → `users(id)`

##### 表结构

```sql
CREATE TABLE platform_iam.workflow_role_user_rel (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  workflow_role_id UUID NOT NULL,
  user_id UUID NOT NULL,
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  
  CONSTRAINT fk_wf_role_user_wf FOREIGN KEY (workflow_role_id) 
    REFERENCES platform_iam.workflow_roles(id) ON DELETE CASCADE,
  CONSTRAINT fk_wf_role_user_user FOREIGN KEY (user_id) 
    REFERENCES platform_iam.users(id) ON DELETE CASCADE,
  CONSTRAINT uq_wf_role_user UNIQUE (workflow_role_id, user_id)
);
```

##### 字段详细说明

| 字段名 | 类型 | 必需 | 默认值 | 说明 |
|--------|------|------|--------|------|
| `id` | UUID | 是 | gen_random_uuid() | 关联记录唯一标识 |
| `workflow_role_id` | UUID | 是 | - | 流程角色 ID，外键关联 `workflow_roles.id` |
| `user_id` | UUID | 是 | - | 用户 ID，外键关联 `users.id` |
| `created_at` | TIMESTAMPTZ | 是 | NOW() | 关联创建时间 |

##### 索引

| 索引名 | 类型 | 字段 | 说明 |
|--------|------|------|------|
| `uq_wf_role_user` | UNIQUE | `(workflow_role_id, user_id)` | 确保同一流程角色不会重复关联同一用户 |
| `idx_wf_role_user_role` | INDEX | `workflow_role_id` | 查询流程角色的所有用户（自动创建） |
| `idx_wf_role_user_user` | INDEX | `user_id` | 查询用户的所有流程角色（自动创建） |

##### 业务规则

1. **唯一性约束**: 同一流程角色不能重复关联同一用户
2. **级联删除**: 
   - 删除流程角色时，自动删除所有关联记录
   - 删除用户时，自动删除所有关联记录
3. **使用场景**: 
   - 仅用于 `ruleType = 'FIXED_USERS'` 类型的流程角色
   - 其他类型的流程角色（如 `ORGANIZATION_RELATION`）通过规则动态解析，不使用此表
4. **数据一致性**: 
   - 关联的用户必须是 `ACTIVE` 状态
   - 关联的流程角色必须是 `enabled = true`

##### 关系说明

- **多对多关系**: 一个流程角色可以关联多个用户，一个用户可以关联多个流程角色
- **与 WorkflowRole 的关系**: `workflow_role_user_rel.workflow_role_id` → `workflow_roles.id` (CASCADE DELETE)
- **与 User 的关系**: `workflow_role_user_rel.user_id` → `users.id` (CASCADE DELETE)

---

### 2. corp_hr Schema

#### 2.1 organizations（组织表）⭐️ v2.0 新增

##### 基本信息

- **表名**: `corp_hr.organizations`
- **说明**: 独立的组织实体表，支持组织特有属性（法人信息、财务配置等）
- **主键**: `id` (UUID)
- **外键**: `primary_region_id` → `regions(id)`

##### 表结构

```sql
CREATE TABLE corp_hr.organizations (
  -- 主键
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  
  -- 基本信息
  code VARCHAR(100) NOT NULL UNIQUE,
  name VARCHAR(255) NOT NULL,
  display_name VARCHAR(255),
  name_en VARCHAR(255),
  name_zh VARCHAR(255),
  
  -- 法人信息
  legal_name VARCHAR(255),
  legal_representative VARCHAR(255),
  registration_number VARCHAR(100),
  tax_id VARCHAR(100),
  
  -- 联系信息
  address TEXT,
  phone VARCHAR(50),
  email VARCHAR(255),
  website VARCHAR(255),
  
  -- 区域关联
  primary_region_id UUID,
  
  -- 组织配置
  settings JSONB DEFAULT '{}',
  financial_config JSONB DEFAULT '{}',
  compliance_config JSONB DEFAULT '{}',
  
  -- 状态
  status VARCHAR(50) NOT NULL DEFAULT 'ACTIVE',
  is_active BOOLEAN NOT NULL DEFAULT TRUE,
  
  -- 排序
  "order" INT DEFAULT 0,
  
  -- 元数据
  metadata JSONB DEFAULT '{}',
  
  -- 时间戳
  established_at DATE,
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  deleted_at TIMESTAMPTZ,
  
  -- 外键
  CONSTRAINT fk_org_region FOREIGN KEY (primary_region_id) 
    REFERENCES corp_hr.regions(id) ON DELETE SET NULL,
  
  -- 约束
  CONSTRAINT check_org_status CHECK (status IN ('ACTIVE', 'INACTIVE', 'SUSPENDED', 'DISSOLVED'))
);

-- 索引
CREATE INDEX idx_organizations_code ON corp_hr.organizations(code);
CREATE INDEX idx_organizations_status ON corp_hr.organizations(status);
CREATE INDEX idx_organizations_region ON corp_hr.organizations(primary_region_id);
```

##### 字段详细说明

| 字段名 | 类型 | 必需 | 默认值 | 说明 |
|--------|------|------|--------|------|
| `id` | UUID | 是 | gen_random_uuid() | 组织唯一标识 |
| `code` | VARCHAR(100) | 是 | - | 组织代码，唯一 |
| `name` | VARCHAR(255) | 是 | - | 组织名称 |
| `display_name` | VARCHAR(255) | 否 | NULL | 显示名称 |
| `name_en` | VARCHAR(255) | 否 | NULL | 英文名称 |
| `name_zh` | VARCHAR(255) | 否 | NULL | 中文名称 |
| `legal_name` | VARCHAR(255) | 否 | NULL | 法定名称 |
| `legal_representative` | VARCHAR(255) | 否 | NULL | 法人代表 |
| `registration_number` | VARCHAR(100) | 否 | NULL | 注册号/统一社会信用代码 |
| `tax_id` | VARCHAR(100) | 否 | NULL | 税号 |
| `address` | TEXT | 否 | NULL | 地址 |
| `phone` | VARCHAR(50) | 否 | NULL | 电话 |
| `email` | VARCHAR(255) | 否 | NULL | 邮箱 |
| `website` | VARCHAR(255) | 否 | NULL | 网站 |
| `primary_region_id` | UUID | 否 | NULL | 主要运营区域 |
| `settings` | JSONB | 是 | '{}' | 组织配置 |
| `financial_config` | JSONB | 是 | '{}' | 财务配置 |
| `compliance_config` | JSONB | 是 | '{}' | 合规配置 |
| `status` | VARCHAR(50) | 是 | 'ACTIVE' | 组织状态 |
| `is_active` | BOOLEAN | 是 | TRUE | 是否激活 |
| `order` | INT | 是 | 0 | 排序 |
| `metadata` | JSONB | 是 | '{}' | 扩展元数据 |
| `established_at` | DATE | 否 | NULL | 成立日期 |
| `created_at` | TIMESTAMPTZ | 是 | NOW() | 创建时间 |
| `updated_at` | TIMESTAMPTZ | 是 | NOW() | 更新时间 |
| `deleted_at` | TIMESTAMPTZ | 否 | NULL | 软删除时间 |

##### Prisma Model

```prisma
model Organization {
  id              String   @id @default(uuid()) @db.Uuid
  code            String   @unique
  name            String
  displayName     String?  @map("display_name")
  nameEn          String?  @map("name_en")
  nameZh          String?  @map("name_zh")
  legalName       String?  @map("legal_name")
  legalRepresentative String? @map("legal_representative")
  registrationNumber  String? @map("registration_number")
  taxId           String?  @map("tax_id")
  address         String?
  phone           String?
  email           String?
  website         String?
  primaryRegionId String?  @map("primary_region_id") @db.Uuid
  settings        Json     @default("{}")
  financialConfig Json     @default("{}") @map("financial_config")
  complianceConfig Json    @default("{}") @map("compliance_config")
  status          String   @default("ACTIVE")
  isActive        Boolean  @default(true) @map("is_active")
  order           Int      @default(0)
  metadata        Json     @default("{}")
  establishedAt   DateTime? @map("established_at") @db.Date
  createdAt       DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
  updatedAt       DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
  deletedAt       DateTime? @map("deleted_at") @db.Timestamptz(3)
  
  primaryRegion        Region?              @relation("OrgPrimaryRegion", fields: [primaryRegionId], references: [id])
  departments          Department[]
  userDepartments      UserDepartment[]
  organizationRegions  OrganizationRegion[]
  formDefinitions      FormDefinition[]     @relation("FormOrganization")
  formWebhooks         FormWebhook[]        @relation("FormWebhook")
  approvalDefinitions  ApprovalDefinition[] @relation("ApprovalOrganization")
  
  @@index([code])
  @@index([status])
  @@map("organizations")
  @@schema("corp_hr")
}
```

---

#### 2.2 organization_regions（组织区域关联表）⭐️ v2.0 新增

##### 基本信息

- **表名**: `corp_hr.organization_regions`
- **说明**: 组织与区域的多对多关联，表示组织的运营区域
- **主键**: `id` (UUID)
- **外键**: 
  - `organization_id` → `organizations(id)`
  - `region_id` → `regions(id)`

##### 表结构

```sql
CREATE TABLE corp_hr.organization_regions (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  organization_id UUID NOT NULL,
  region_id UUID NOT NULL,
  is_default BOOLEAN DEFAULT FALSE,
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  
  CONSTRAINT fk_org_reg_org FOREIGN KEY (organization_id) 
    REFERENCES corp_hr.organizations(id) ON DELETE CASCADE,
  CONSTRAINT fk_org_reg_reg FOREIGN KEY (region_id) 
    REFERENCES corp_hr.regions(id) ON DELETE CASCADE,
  CONSTRAINT uq_org_region UNIQUE (organization_id, region_id)
);

CREATE INDEX idx_org_regions_org ON corp_hr.organization_regions(organization_id);
```

##### Prisma Model

```prisma
model OrganizationRegion {
  id             String   @id @default(uuid()) @db.Uuid
  organizationId String   @map("organization_id") @db.Uuid
  regionId       String   @map("region_id") @db.Uuid
  isDefault      Boolean  @default(false) @map("is_default")
  createdAt      DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
  
  organization   Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
  region         Region       @relation("OrgRegions", fields: [regionId], references: [id], onDelete: Cascade)
  
  @@unique([organizationId, regionId])
  @@index([organizationId])
  @@map("organization_regions")
  @@schema("corp_hr")
}
```

---

#### 2.3 regions（区域表）

##### 表结构

```sql
CREATE TABLE corp_hr.regions (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  
  -- 基础信息
  code VARCHAR(10) NOT NULL UNIQUE,
  name VARCHAR(255) NOT NULL,
  name_en VARCHAR(255),
  name_zh VARCHAR(255),
  
  -- 配置
  timezone VARCHAR(50),
  currency VARCHAR(10),
  locale VARCHAR(20),
  is_active BOOLEAN NOT NULL DEFAULT TRUE,
  "order" INT NOT NULL DEFAULT 0,
  
  -- 扩展
  metadata JSONB DEFAULT '{}',
  
  -- 时间戳
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  deleted_at TIMESTAMPTZ
);
```

##### 预定义区域

| Code | Name | Timezone | Currency |
|------|------|----------|----------|
| `CN` | China | Asia/Shanghai | CNY |
| `US` | United States | America/Los_Angeles | USD |
| `UAE` | United Arab Emirates | Asia/Dubai | AED |

---

#### 2.4 departments（部门表）⭐️ v2.0 更新

##### 基本信息

- **表名**: `corp_hr.departments`
- **说明**: 组织架构核心表，支持树形结构，所有部门归属组织
- **主键**: `id` (UUID)
- **层级**: 通过 `parent_id` 自关联实现
- **组织归属**: 通过 `organization_id` 归属组织（v2.0 新增）

##### 表结构

```sql
CREATE TABLE corp_hr.departments (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  
  -- v2.0: 组织归属（必需）
  organization_id UUID NOT NULL,
  
  name VARCHAR(255) NOT NULL,
  code VARCHAR(100) NOT NULL,
  parent_id UUID,
  head_id UUID,
  description TEXT,
  "order" INT DEFAULT 0,
  
  -- 多租户
  tenant_id VARCHAR(100),
  
  -- 元数据
  metadata JSONB DEFAULT '{}',
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  deleted_at TIMESTAMPTZ,
  
  -- 外键
  CONSTRAINT fk_dept_organization FOREIGN KEY (organization_id)
    REFERENCES corp_hr.organizations(id) ON DELETE CASCADE,
  CONSTRAINT fk_dept_parent FOREIGN KEY (parent_id) 
    REFERENCES corp_hr.departments(id) ON DELETE RESTRICT,
  
  -- 唯一约束：部门代码在组织内唯一（v2.0）
  CONSTRAINT uq_dept_code_per_org UNIQUE (organization_id, code)
);

-- 索引
CREATE INDEX idx_departments_org ON corp_hr.departments(organization_id);
CREATE INDEX idx_departments_org_parent ON corp_hr.departments(organization_id, parent_id);
```

##### 字段说明

| 字段名 | 类型 | 说明 |
|--------|------|------|
| `organization_id` | UUID | 归属组织ID（v2.0 新增，必需） |
| `name` | VARCHAR(255) | 部门名称 |
| `code` | VARCHAR(100) | 部门代码（组织内唯一） |
| `parent_id` | UUID | 父部门ID（null=顶级部门） |
| `head_id` | UUID | 部门主管用户ID |
| `order` | INT | 排序序号 |

##### 业务规则（v2.0 更新）

1. **组织归属**: 所有部门必须归属一个组织（organization_id 必填）
2. **顶级部门**: `parent_id = null` 的部门是组织的顶级部门
3. **代码唯一性**: 部门代码在组织内唯一（可跨组织重复）
4. **循环引用检查**: 不允许部门层级形成循环
5. **删除保护**: 有子部门的部门不能删除（RESTRICT）
6. **区域继承**: 部门的区域信息从组织继承（v2.0 架构简化）

##### Prisma Model

```prisma
model Department {
  id              String   @id @default(uuid()) @db.Uuid
  organizationId  String   @map("organization_id") @db.Uuid
  name            String
  code            String
  parentId        String?  @map("parent_id") @db.Uuid
  headId          String?  @map("head_id") @db.Uuid
  description     String?
  order           Int      @default(0)
  tenantId        String?  @map("tenant_id")
  metadata        Json     @default("{}")
  createdAt       DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
  updatedAt       DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
  deletedAt       DateTime? @map("deleted_at") @db.Timestamptz(3)
  
  organization    Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
  parent          Department?  @relation("DepartmentHierarchy", fields: [parentId], references: [id], onDelete: Restrict)
  children        Department[] @relation("DepartmentHierarchy")
  userMemberships UserDepartment[]
  
  @@unique([organizationId, code])
  @@index([organizationId])
  @@index([organizationId, parentId])
  @@map("departments")
  @@schema("corp_hr")
}
```

---

#### 2.5 positions（岗位表）

##### 表结构

```sql
CREATE TABLE corp_hr.positions (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  name VARCHAR(255) NOT NULL,
  code VARCHAR(100) NOT NULL UNIQUE,
  level INT DEFAULT 0,
  description TEXT,
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  deleted_at TIMESTAMPTZ
);
```

##### 示例数据

| Code | Name | Level |
|------|------|-------|
| `CEO` | 首席执行官 | 10 |
| `CTO` | 首席技术官 | 9 |
| `DIRECTOR` | 总监 | 8 |
| `MANAGER` | 经理 | 7 |
| `SENIOR` | 高级工程师 | 6 |
| `ENGINEER` | 工程师 | 5 |

---

#### 2.6 user_departments（用户部门关联表）⭐️ v2.0 更新

##### 基本信息

- **表名**: `corp_hr.user_departments`
- **说明**: 用户多部门归属核心表，支持用户同时属于多个部门
- **主键**: `id` (UUID)
- **v2.0 新增**: `organization_id` 冗余字段，用于性能优化

##### 表结构

```sql
CREATE TABLE corp_hr.user_departments (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id UUID NOT NULL,
  
  -- v2.0: 组织冗余字段（性能优化）
  organization_id UUID NOT NULL,
  
  department_id UUID NOT NULL,
  position_id UUID,
  manager_id UUID,
  is_primary BOOLEAN DEFAULT FALSE,
  title VARCHAR(255),
  joined_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  left_at TIMESTAMPTZ,
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  
  CONSTRAINT fk_ud_user FOREIGN KEY (user_id) 
    REFERENCES platform_iam.users(id) ON DELETE CASCADE,
  CONSTRAINT fk_ud_organization FOREIGN KEY (organization_id)
    REFERENCES corp_hr.organizations(id) ON DELETE CASCADE,
  CONSTRAINT fk_ud_dept FOREIGN KEY (department_id) 
    REFERENCES corp_hr.departments(id) ON DELETE CASCADE,
  CONSTRAINT fk_ud_pos FOREIGN KEY (position_id) 
    REFERENCES corp_hr.positions(id) ON DELETE SET NULL,
  CONSTRAINT fk_ud_manager FOREIGN KEY (manager_id) 
    REFERENCES platform_iam.users(id) ON DELETE SET NULL,
  CONSTRAINT uq_user_dept UNIQUE (user_id, department_id)
);
```

##### 字段说明

| 字段名 | 类型 | 说明 |
|--------|------|------|
| `user_id` | UUID | 用户ID |
| `organization_id` | UUID | 组织ID（v2.0 冗余字段，性能优化） |
| `department_id` | UUID | 部门ID |
| `position_id` | UUID | 岗位ID |
| `manager_id` | UUID | 直属上级ID（必须是该部门成员） |
| `is_primary` | BOOLEAN | 是否主部门 |
| `title` | VARCHAR(255) | 职位头衔 |
| `joined_at` | TIMESTAMPTZ | 加入时间 |
| `left_at` | TIMESTAMPTZ | 离开时间（null=在职） |

##### 索引

```sql
CREATE INDEX idx_ud_user ON corp_hr.user_departments(user_id);
CREATE INDEX idx_ud_dept ON corp_hr.user_departments(department_id);
CREATE INDEX idx_ud_organization ON corp_hr.user_departments(organization_id);
CREATE INDEX idx_ud_user_org ON corp_hr.user_departments(user_id, organization_id);
CREATE INDEX idx_ud_primary ON corp_hr.user_departments(is_primary) WHERE left_at IS NULL;
CREATE INDEX idx_ud_manager ON corp_hr.user_departments(manager_id);
CREATE INDEX idx_ud_active ON corp_hr.user_departments(user_id, department_id) WHERE left_at IS NULL;
```

##### v2.0 性能优化说明

**冗余字段 `organization_id` 的作用**:
```sql
-- v1.0: 需要 JOIN departments 表才能查询组织
SELECT * FROM user_departments ud
JOIN departments d ON ud.department_id = d.id
WHERE d.organization_id = 'org-xxx';

-- v2.0: 直接查询，性能提升 10倍+
SELECT * FROM user_departments
WHERE organization_id = 'org-xxx';
```

##### Prisma Model

```prisma
model UserDepartment {
  id           String    @id @default(uuid()) @db.Uuid
  userId       String    @map("user_id") @db.Uuid
  organizationId String  @map("organization_id") @db.Uuid
  departmentId String    @map("department_id") @db.Uuid
  positionId   String?   @map("position_id") @db.Uuid
  managerId    String?   @map("manager_id") @db.Uuid
  isPrimary    Boolean   @default(false) @map("is_primary")
  title        String?
  joinedAt     DateTime  @default(now()) @map("joined_at") @db.Timestamptz(3)
  leftAt       DateTime? @map("left_at") @db.Timestamptz(3)
  createdAt    DateTime  @default(now()) @map("created_at") @db.Timestamptz(3)
  updatedAt    DateTime  @updatedAt @map("updated_at") @db.Timestamptz(3)
  
  user         User         @relation(fields: [userId], references: [id], onDelete: Cascade)
  organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
  department   Department   @relation(fields: [departmentId], references: [id], onDelete: Cascade)
  position     Position?    @relation(fields: [positionId], references: [id])
  manager      User?        @relation("DepartmentManager", fields: [managerId], references: [id])
  
  @@unique([userId, departmentId])
  @@index([userId])
  @@index([departmentId])
  @@index([organizationId])
  @@index([userId, organizationId])
  @@map("user_departments")
  @@schema("corp_hr")
}
```

##### 业务规则

1. **唯一性**: 同一用户在同一部门只能有一个归属记录
2. **主部门**: 每个用户必须有且只有一个主部门（`is_primary = true` 且 `left_at IS NULL`）
3. **上级约束**: `manager_id` 必须是该部门的成员
4. **状态管理**: 通过 `left_at` 字段管理在职/离职状态

---

## 📊 表关系图

### 核心ER图（v2.1）

```mermaid
erDiagram
    %% IAM Schema
    USERS ||--o{ USER_ROLE_REL : has
    USERS ||--o{ USER_DEPARTMENTS : "belongs to"
    USERS ||--o{ WORKFLOW_ROLE_USER_REL : assigned
    
    ROLES ||--o{ USER_ROLE_REL : "assigned to"
    ROLES ||--o{ ROLE_PERMISSION_REL : has
    
    PERMISSIONS ||--o{ ROLE_PERMISSION_REL : "granted to"
    
    WORKFLOW_ROLES ||--o{ WORKFLOW_ROLE_USER_REL : has
    
    %% v2.1: 组织级权限隔离
    ORGANIZATIONS ||--o{ USER_ROLE_REL : "scopes roles"
    
    %% HR Schema (v2.0)
    ORGANIZATIONS ||--o{ DEPARTMENTS : contains
    ORGANIZATIONS ||--o{ USER_DEPARTMENTS : "has members"
    ORGANIZATIONS ||--o{ ORGANIZATION_REGIONS : "operates in"
    
    DEPARTMENTS ||--o{ DEPARTMENTS : "contains"
    DEPARTMENTS ||--o{ USER_DEPARTMENTS : "has members"
    
    REGIONS ||--o{ ORGANIZATION_REGIONS : "hosts"
    REGIONS ||--o{ ORGANIZATIONS : "primary region"
    
    POSITIONS ||--o{ USER_DEPARTMENTS : used
    
    USERS {
        UUID id PK
        string username UK
        string email UK
        string status
        string source
        string region
    }
    
    ROLES {
        UUID id PK
        string code UK
        string name
        boolean enabled
    }
    
    PERMISSIONS {
        UUID id PK
        string resource
        string action
    }
    
    USER_ROLE_REL {
        UUID id PK
        UUID user_id FK
        UUID role_id FK
        UUID organization_id FK "v2.1: 组织级隔离"
        string region "DEPRECATED"
    }
    
    ORGANIZATIONS {
        UUID id PK "v2.0: 独立组织表"
        string code UK
        string name
        UUID primary_region_id FK
        string status
    }
    
    DEPARTMENTS {
        UUID id PK
        UUID organization_id FK "v2.0: 归属组织"
        string code
        UUID parent_id FK
    }
    
    USER_DEPARTMENTS {
        UUID id PK
        UUID user_id FK
        UUID organization_id FK "v2.0: 冗余字段"
        UUID department_id FK
        UUID position_id FK
        UUID manager_id FK
        boolean is_primary
        datetime left_at
    }
    
    ORGANIZATION_REGIONS {
        UUID id PK "v2.0: 组织区域关联"
        UUID organization_id FK
        UUID region_id FK
        boolean is_default
    }
```

### 关系说明

| 主表 | 关系 | 从表 | 说明 | 版本 |
|------|------|------|------|------|
| `platform_iam.users` | 1:N | `platform_iam.user_role_rel` | 用户可以有多个角色 | v1.0 |
| `platform_iam.roles` | 1:N | `platform_iam.user_role_rel` | 角色可以分配给多个用户 | v1.0 |
| `corp_hr.organizations` | 1:N | `platform_iam.user_role_rel` | 组织级权限隔离 | v2.1 ⭐️ |
| `platform_iam.roles` | N:M | `platform_iam.permissions` | 角色和权限多对多 | v1.0 |
| `corp_hr.organizations` | 1:N | `corp_hr.departments` | 组织包含多个部门 | v2.0 |
| `corp_hr.organizations` | N:M | `corp_hr.regions` | 组织运营多个区域 | v2.0 |
| `corp_hr.departments` | 1:N | `corp_hr.departments` | 部门树形结构 | v1.0 |
| `platform_iam.users` | N:M | `corp_hr.departments` | 用户多部门归属 | v1.0 |
| `corp_hr.organizations` | 1:N | `corp_hr.user_departments` | 冗余字段优化查询 | v2.0 |
| `corp_hr.positions` | 1:N | `corp_hr.user_departments` | 岗位关联 | v1.0 |

### v2.1 核心变更：权限隔离架构

```mermaid
graph LR
    A[用户] -->|v2.1| B[UserRole]
    B -->|organizationId| C[组织A]
    B -->|organizationId| D[组织B]
    B -->|organizationId=NULL| E[全局角色]
    
    C --> F[角色: 管理员]
    D --> G[角色: 普通员工]
    E --> H[角色: 系统管理员]
    
    style B fill:#ff6b6b,color:#fff
    style E fill:#4ecdc4,color:#fff
```

**说明**:
- v2.1 之前：角色按 `region` 隔离（FF China 和其他 CN 组织会权限泄露）
- v2.1 之后：角色按 `organizationId` 隔离（每个组织独立）
- 全局角色：`organizationId = NULL`（如系统管理员，跨所有组织）

---

## 🔧 Prisma Schema

### 完整 Schema 定义

#### platform_iam.prisma

```prisma
// backend/prisma/schema/platform_iam.prisma

model User {
  id                     String                @id @default(uuid()) @db.Uuid
  username               String                @unique @db.VarChar(255)
  email                  String                @unique @db.VarChar(255)
  passwordHash           String?               @map("password_hash") @db.VarChar(255)
  displayName            String                @map("display_name") @db.VarChar(255)
  avatar                 String?               @db.VarChar(500)
  phone                  String?               @db.VarChar(50)
  
  status                 UserStatus            @default(ACTIVE)
  source                 UserSource            @default(LOCAL)
  
  ldapDn                 String?               @map("ldap_dn") @db.VarChar(500)
  employeeId             String?               @unique @map("employee_id") @db.VarChar(100)
  ldapSyncedAt           DateTime?             @map("ldap_synced_at") @db.Timestamptz(3)
  externalId             String?               @map("external_id") @db.VarChar(255)
  externalSource         String?               @map("external_source") @db.VarChar(100)
  
  tenantId               String?               @map("tenant_id") @db.VarChar(100)
  defaultRegion          String                @default("CN") @map("region") @db.VarChar(10)
  
  hiredAt                DateTime?             @map("hired_at") @db.Timestamptz(3)
  terminatedAt           DateTime?             @map("terminated_at") @db.Timestamptz(3)
  metadata               Json                  @default("{}") @db.JsonB
  createdAt              DateTime              @default(now()) @map("created_at") @db.Timestamptz(3)
  updatedAt              DateTime              @updatedAt @map("updated_at") @db.Timestamptz(3)
  deletedAt              DateTime?             @map("deleted_at") @db.Timestamptz(3)
  
  // 关联
  roles                  UserRole[]
  workflowRoles          WorkflowRoleUser[]
  departmentMemberships  UserDepartment[]
  managedUsers           UserDepartment[]      @relation("DepartmentManager")
  
  @@index([status])
  @@index([externalId])
  @@index([defaultRegion])
  @@index([status, defaultRegion])
  @@map("users")
  @@schema("platform_iam")
}

model Role {
  id          String           @id @default(uuid()) @db.Uuid
  name        String           @unique @db.VarChar(255)
  code        String           @unique @db.VarChar(100)
  description String?          @db.Text
  isBuiltIn   Boolean          @default(false) @map("is_built_in")
  enabled     Boolean          @default(true)
  createdAt   DateTime         @default(now()) @map("created_at") @db.Timestamptz(3)
  updatedAt   DateTime         @updatedAt @map("updated_at") @db.Timestamptz(3)
  
  permissions RolePermission[]
  users       UserRole[]

  @@map("roles")
  @@schema("platform_iam")
}

model Permission {
  id          String           @id @default(uuid()) @db.Uuid
  resource    String           @db.VarChar(100)
  action      String           @db.VarChar(100)
  description String?          @db.Text
  module      String?          @db.VarChar(100)
  isBuiltIn   Boolean          @default(true) @map("is_built_in")
  createdAt   DateTime         @default(now()) @map("created_at") @db.Timestamptz(3)
  updatedAt   DateTime         @updatedAt @map("updated_at") @db.Timestamptz(3)
  
  roles       RolePermission[]

  @@unique([resource, action])
  @@map("permissions")
  @@schema("platform_iam")
}

model UserRole {
  id             String        @id @default(uuid()) @db.Uuid
  userId         String        @map("user_id") @db.Uuid
  roleId         String        @map("role_id") @db.Uuid
  organizationId String?       @map("organization_id") @db.Uuid  // v2.1: 组织级隔离
  region         String?       @deprecated  // DEPRECATED: v3.0 将移除
  createdAt      DateTime      @default(now()) @map("created_at") @db.Timestamptz(3)
  
  role         Role          @relation(fields: [roleId], references: [id], onDelete: Cascade)
  user         User          @relation(fields: [userId], references: [id], onDelete: Cascade)
  organization Organization? @relation(fields: [organizationId], references: [id], onDelete: Cascade)

  @@unique([userId, roleId, organizationId])
  @@index([userId, organizationId])
  @@index([userId, region])  // 向后兼容，v3.0 移除
  @@map("user_role_rel")
  @@schema("platform_iam")
}

model RolePermission {
  id           String     @id @default(uuid()) @db.Uuid
  roleId       String     @map("role_id") @db.Uuid
  permissionId String     @map("permission_id") @db.Uuid
  createdAt    DateTime   @default(now()) @map("created_at") @db.Timestamptz(3)
  
  role       Role       @relation(fields: [roleId], references: [id], onDelete: Cascade)
  permission Permission @relation(fields: [permissionId], references: [id], onDelete: Cascade)

  @@unique([roleId, permissionId])
  @@map("role_permission_rel")
  @@schema("platform_iam")
}

model WorkflowRole {
  id          String             @id @default(uuid()) @db.Uuid
  name        String             @unique @db.VarChar(255)
  code        String             @unique @db.VarChar(100)
  description String?            @db.Text
  ruleType    String             @map("rule_type") @db.VarChar(50)
  ruleConfig  Json               @default("{}") @map("rule_config") @db.JsonB
  createdAt   DateTime           @default(now()) @map("created_at") @db.Timestamptz(3)
  updatedAt   DateTime           @updatedAt @map("updated_at") @db.Timestamptz(3)
  
  users       WorkflowRoleUser[]

  @@map("workflow_roles")
  @@schema("platform_iam")
}

model WorkflowRoleUser {
  id             String       @id @default(uuid()) @db.Uuid
  workflowRoleId String       @map("workflow_role_id") @db.Uuid
  userId         String       @map("user_id") @db.Uuid
  createdAt      DateTime     @default(now()) @map("created_at") @db.Timestamptz(3)
  
  workflowRole   WorkflowRole @relation(fields: [workflowRoleId], references: [id], onDelete: Cascade)
  user           User         @relation(fields: [userId], references: [id], onDelete: Cascade)

  @@unique([workflowRoleId, userId])
  @@map("workflow_role_user_rel")
  @@schema("platform_iam")
}

enum UserStatus {
  ACTIVE
  INACTIVE
  SUSPENDED
  TERMINATED

  @@schema("platform_iam")
}

// LEGACY — see schema for actual enum (includes ENTRA)
enum UserSource {
  LOCAL
  LDAP

  @@schema("platform_iam")
}

> [!WARNING]
> 上方代码块为**历史漂移**的简化片段，仅作演进上下文。**真值以 `backend/prisma/schema/platform_iam.prisma` 为准**——实际 enum 含 `LOCAL` / `ENTRA` / `LDAP` 三个值（`ENTRA` 早于 v2.4 已存在，由 v2.4 SSO JIT 路径正式启用为新建账号来源）。

// ==================== AI 工具授权（v2.3 全量 per-user 控制）====================

/// AI 工具角色级授权（主维度）
/// v2.3 语义：Workspace → OpenClaw 同步脚本写 agents[].tools.allow（白名单），能加能减
/// 角色表中的每一行代表"该角色拥有此工具"
model AIToolGrant {
  id        String   @id @default(uuid()) @db.Uuid
  roleId    String   @map("role_id") @db.Uuid
  toolName  String   @map("tool_name") @db.VarChar(128)
  createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
  updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
  createdBy String?  @map("created_by") @db.Uuid

  role Role @relation(fields: [roleId], references: [id], onDelete: Cascade)

  @@unique([roleId, toolName])
  @@index([toolName])
  @@map("ai_tool_grants")
  @@schema("platform_iam")
}

/// AI 工具用户级授权（例外维度）
/// v2.3 变更：新增 effect 字段，支持 grant（默认，在角色基线上额外开通）和 revoke（用户显式取消基线工具）
/// reason DB 层 nullable，API/UI 层强制要求填入便于审计
model AIToolGrantUser {
  id        String   @id @default(uuid()) @db.Uuid
  userId    String   @map("user_id") @db.Uuid
  toolName  String   @map("tool_name") @db.VarChar(128)
  effect    String   @default("grant") @db.VarChar(10)   // "grant" | "revoke"
  reason    String?  @db.VarChar(500)
  createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
  updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
  createdBy String?  @map("created_by") @db.Uuid

  user User @relation("AIToolGrantUser", fields: [userId], references: [id], onDelete: Cascade)

  @@unique([userId, toolName])
  @@index([toolName])
  @@map("ai_tool_grants_user")
  @@schema("platform_iam")
}
```

##### AI 工具授权设计要点

| 决策点 | v2.3 选择 | 理由 |
|---|---|---|
| `AIToolGrantUser.effect` 字段 | ✅ `"grant"` / `"revoke"` | v2.3 支持用户级显式取消基线工具（revoke），不再只有加法 |
| 是否有 `organizationId` | ❌ 无 | Role 本身全局共享，工具授权也全局 |
| 是否有软删 `deletedAt` | ❌ 无 | 遵循 platform_iam 域不软删约定，审计走 platform_audit |
| `reason` 字段 | ✅ AIToolGrantUser 独有 | 用户级授权是例外，记录"为什么开小灶 / 为什么关"便于审计；API 层强制必填，DB 层 nullable |
| 与 OpenClaw `tools` 的映射 | `tools.allow`（白名单） | **v2.3 变更**：从 `alsoAllow`（加法）切换到 `allow`（白名单过滤），支持收紧。同步脚本硬编码 LOCKED_SET 兜底 |
| Role 删除时的 grant 处理 | `onDelete: Cascade` | 角色删除自动清理对应授权 |
| User 删除时的 grant 处理 | `onDelete: Cascade` | 用户删除自动清理对应直接授权 |
| LOCKED_SET 是否存表 | ❌ | 代码常量，不持久化。后端保存时硬编码合入，前端渲染时从 `available-tools` 的 `locked=true` 取 |

##### 索引设计

- `ai_tool_grants(role_id, tool_name)` UNIQUE — 同一角色不允许重复授权同一工具
- `ai_tool_grants(tool_name)` INDEX — 支持反向查询
- `ai_tool_grants_user(user_id, tool_name)` UNIQUE — 同一用户同一工具只有一条记录（effect=grant 或 revoke）
- `ai_tool_grants_user(tool_name)` INDEX — 反向查询

##### v2.3 迁移

需要一个 Prisma migration：`AIToolGrantUser` 新增 `effect VARCHAR(10) DEFAULT 'grant'` 列。现有行全部默认 `grant`，无破坏性。

##### v2.3.1 角色 seed 不变量

- `RolesService.create()` 成功后必须自动种入 `EMPLOYEE_BASELINE_TOOLS`（24 项）到 `AIToolGrant`，`code='SyncBot'` 例外
- 种子来源单一：`available-tools.config.ts` 的 `EMPLOYEE_BASELINE_TOOLS` 常量；`prisma/seeds/ai-tool-grants.seed.ts` 与之同步
- 老环境通过 `20260427000000_backfill_ai_tool_grants_for_existing_roles` 一次性 backfill；之后所有新角色靠 hook 自动种入

##### 最终生效公式

```
角色级工具集 = ⋃ 用户所有角色的 AIToolGrant.toolName
用户级添加   = AIToolGrantUser WHERE effect='grant'
用户级取消   = AIToolGrantUser WHERE effect='revoke'

最终生效工具 = LOCKED_SET
             ∪ (角色级工具集 - 用户级取消 + 用户级添加)
```

LOCKED_SET = `[session_status, sessions_history, sessions_list, sessions_send]`，在任何计算之后强制并入。

#### corp_hr.prisma

```prisma
// backend/prisma/schema/corp_hr.prisma

model Organization {
  id                   String   @id @default(uuid()) @db.Uuid
  code                 String   @unique @db.VarChar(100)
  name                 String   @db.VarChar(255)
  displayName          String?  @map("display_name") @db.VarChar(255)
  nameEn               String?  @map("name_en") @db.VarChar(255)
  nameZh               String?  @map("name_zh") @db.VarChar(255)
  
  legalName            String?  @map("legal_name") @db.VarChar(255)
  legalRepresentative  String?  @map("legal_representative") @db.VarChar(255)
  registrationNumber   String?  @map("registration_number") @db.VarChar(100)
  taxId                String?  @map("tax_id") @db.VarChar(100)
  
  address              String?  @db.Text
  phone                String?  @db.VarChar(50)
  email                String?  @db.VarChar(255)
  website              String?  @db.VarChar(255)
  
  primaryRegionId      String?  @map("primary_region_id") @db.Uuid
  
  settings             Json     @default("{}") @db.JsonB
  financialConfig      Json     @default("{}") @map("financial_config") @db.JsonB
  complianceConfig     Json     @default("{}") @map("compliance_config") @db.JsonB
  
  status               String   @default("ACTIVE") @db.VarChar(50)
  isActive             Boolean  @default(true) @map("is_active")
  order                Int      @default(0)
  metadata             Json     @default("{}") @db.JsonB
  
  establishedAt        DateTime? @map("established_at") @db.Date
  createdAt            DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
  updatedAt            DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
  deletedAt            DateTime? @map("deleted_at") @db.Timestamptz(3)
  
  primaryRegion        Region?              @relation("OrgPrimaryRegion", fields: [primaryRegionId], references: [id])
  departments          Department[]
  userDepartments      UserDepartment[]
  organizationRegions  OrganizationRegion[]
  userRoles            UserRole[]  // v2.1: 反向关联
  
  @@index([code])
  @@index([status])
  @@index([primaryRegionId])
  @@map("organizations")
  @@schema("corp_hr")
}

model OrganizationRegion {
  id             String   @id @default(uuid()) @db.Uuid
  organizationId String   @map("organization_id") @db.Uuid
  regionId       String   @map("region_id") @db.Uuid
  isDefault      Boolean  @default(false) @map("is_default")
  createdAt      DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
  
  organization   Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
  region         Region       @relation("OrgRegions", fields: [regionId], references: [id], onDelete: Cascade)
  
  @@unique([organizationId, regionId])
  @@index([organizationId])
  @@map("organization_regions")
  @@schema("corp_hr")
}

model Region {
  id              String              @id @default(uuid()) @db.Uuid
  code            String              @unique @db.VarChar(10)
  name            String              @db.VarChar(255)
  nameEn          String?             @map("name_en") @db.VarChar(255)
  nameZh          String?             @map("name_zh") @db.VarChar(255)
  
  timezone        String?             @db.VarChar(50)
  currency        String?             @db.VarChar(10)
  locale          String?             @db.VarChar(20)
  isActive        Boolean             @default(true) @map("is_active")
  order           Int                 @default(0)
  metadata        Json                @default("{}") @db.JsonB
  
  createdAt       DateTime            @default(now()) @map("created_at") @db.Timestamptz(3)
  updatedAt       DateTime            @updatedAt @map("updated_at") @db.Timestamptz(3)
  deletedAt       DateTime?           @map("deleted_at") @db.Timestamptz(3)
  
  primaryOrganizations  Organization[]       @relation("OrgPrimaryRegion")
  organizationRegions   OrganizationRegion[] @relation("OrgRegions")

  @@map("regions")
  @@schema("corp_hr")
}

model Department {
  id              String   @id @default(uuid()) @db.Uuid
  organizationId  String   @map("organization_id") @db.Uuid  // v2.0: 归属组织
  name            String   @db.VarChar(255)
  code            String   @db.VarChar(100)
  parentId        String?  @map("parent_id") @db.Uuid
  headId          String?  @map("head_id") @db.Uuid
  description     String?  @db.Text
  order           Int      @default(0)
  tenantId        String?  @map("tenant_id") @db.VarChar(100)
  metadata        Json     @default("{}") @db.JsonB
  createdAt       DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
  updatedAt       DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
  deletedAt       DateTime? @map("deleted_at") @db.Timestamptz(3)
  
  organization    Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
  parent          Department?  @relation("DepartmentHierarchy", fields: [parentId], references: [id], onDelete: Restrict)
  children        Department[] @relation("DepartmentHierarchy")
  userMemberships UserDepartment[]
  
  @@unique([organizationId, code])  // v2.0: 组织内唯一
  @@index([organizationId])
  @@index([organizationId, parentId])
  @@map("departments")
  @@schema("corp_hr")
}

model Position {
  id          String   @id @default(uuid()) @db.Uuid
  name        String   @db.VarChar(255)
  code        String   @unique @db.VarChar(100)
  level       Int      @default(0)
  description String?  @db.Text
  createdAt   DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
  updatedAt   DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
  deletedAt   DateTime? @map("deleted_at") @db.Timestamptz(3)
  
  userDepartments UserDepartment[]
  
  @@map("positions")
  @@schema("corp_hr")
}

model UserDepartment {
  id           String    @id @default(uuid()) @db.Uuid
  userId       String    @map("user_id") @db.Uuid
  organizationId String  @map("organization_id") @db.Uuid  // v2.0: 冗余字段
  departmentId String    @map("department_id") @db.Uuid
  positionId   String?   @map("position_id") @db.Uuid
  managerId    String?   @map("manager_id") @db.Uuid
  isPrimary    Boolean   @default(false) @map("is_primary")
  title        String?   @db.VarChar(255)
  joinedAt     DateTime  @default(now()) @map("joined_at") @db.Timestamptz(3)
  leftAt       DateTime? @map("left_at") @db.Timestamptz(3)
  createdAt    DateTime  @default(now()) @map("created_at") @db.Timestamptz(3)
  updatedAt    DateTime  @updatedAt @map("updated_at") @db.Timestamptz(3)
  
  user         User         @relation(fields: [userId], references: [id], onDelete: Cascade)
  organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
  department   Department   @relation(fields: [departmentId], references: [id], onDelete: Cascade)
  position     Position?    @relation(fields: [positionId], references: [id])
  manager      User?        @relation("DepartmentManager", fields: [managerId], references: [id])
  
  @@unique([userId, departmentId])
  @@index([userId])
  @@index([departmentId])
  @@index([organizationId])
  @@index([userId, organizationId])
  @@index([isPrimary])
  @@index([managerId])
  @@map("user_departments")
  @@schema("corp_hr")
}
```

### TypeScript 类型定义

#### 从 Prisma 生成的基础类型

```typescript
// 从 @prisma/client 导入
import { 
  User, 
  Role, 
  Permission, 
  UserRole, 
  Organization, 
  Department, 
  UserDepartment,
  UserStatus,
  UserSource
} from '@prisma/client';

// 用户状态枚举
export enum UserStatus {
  ACTIVE = 'ACTIVE',
  INACTIVE = 'INACTIVE',
  SUSPENDED = 'SUSPENDED',
  TERMINATED = 'TERMINATED'
}

// 用户来源枚举（LEGACY 示例 — 真值以 @prisma/client 生成的 UserSource 为准，含 ENTRA）
export enum UserSource {
  LOCAL = 'LOCAL',
  ENTRA = 'ENTRA',
  LDAP = 'LDAP'
}
```

#### DTO 类型定义

```typescript
// dto/users/create-user.dto.ts
export interface CreateUserDto {
  username: string;
  email: string;
  displayName: string;
  password?: string;
  phone?: string;
  departmentId: string;
  organizationId: string;  // v2.0
  positionId: string;
  managerId?: string;
  defaultRegion?: string;
}

// dto/users/update-user.dto.ts
export interface UpdateUserDto {
  displayName?: string;
  email?: string;
  phone?: string;
  avatar?: string;
  metadata?: Record<string, any>;
}

// dto/users/query-users.dto.ts
export interface QueryUsersDto {
  page?: number;
  limit?: number;
  status?: UserStatus;
  search?: string;
  departmentId?: string;
  organizationId?: string;  // v2.0
  region?: string;
}

// dto/roles/assign-role.dto.ts (v2.1)
export interface RoleAssignmentDto {
  roleId: string;
  organizationId?: string | null;  // v2.1: 支持组织级角色
}

export interface AssignRoleDto {
  roleAssignments: RoleAssignmentDto[];
}
```

#### 查询结果类型（含关联）

```typescript
// types/user.types.ts

// 用户完整信息（含部门、岗位、角色）
export type UserWithDetails = User & {
  departmentMemberships: (UserDepartment & {
    department: Department & {
      organization: Organization;
    };
    position: Position | null;
    manager: User | null;
  })[];
  roles: (UserRole & {
    role: Role & {
      permissions: (RolePermission & {
        permission: Permission;
      })[];
    };
    organization: Organization | null;  // v2.1
  })[];
};

// 用户列表项（简化版）
export type UserListItem = Pick<
  User,
  'id' | 'username' | 'email' | 'displayName' | 'avatar' | 'status' | 'source'
> & {
  primaryDepartment?: {
    id: string;
    name: string;
    organization: {
      id: string;
      name: string;
    };
  };
  positionName?: string;
};

// 用户详情
export type UserDetail = User & {
  departmentMemberships: (UserDepartment & {
    department: Department;
    position: Position | null;
  })[];
  roles: (UserRole & {
    role: Role;
    organization: Organization | null;  // v2.1
  })[];
};
```

#### 业务逻辑类型

```typescript
// types/permission.types.ts

// v2.1: 权限查询参数
export interface GetPermissionsParams {
  userId: string;
  organizationId?: string;  // v2.1: 组织上下文
}

// 权限列表
export interface UserPermissions {
  userId: string;
  organizationId?: string;
  permissions: string[];  // 如: ["user:read:organization", "user:update:own"]
  roles: {
    roleId: string;
    roleName: string;
    organizationId: string | null;  // v2.1: null = 全局角色
  }[];
}

// 组织树节点
export interface DepartmentTreeNode {
  id: string;
  name: string;
  code: string;
  organizationId: string;  // v2.0
  parentId: string | null;
  headId: string | null;
  order: number;
  children: DepartmentTreeNode[];
  userCount?: number;
}

// 多部门归属信息
export interface UserDepartmentInfo {
  departmentId: string;
  departmentName: string;
  organizationId: string;  // v2.0
  organizationName: string;
  positionId: string | null;
  positionName: string | null;
  managerId: string | null;
  managerName: string | null;
  isPrimary: boolean;
  joinedAt: Date;
  leftAt: Date | null;
}
```

#### API 响应类型

```typescript
// types/api-response.types.ts

// 分页响应
export interface PaginatedResponse<T> {
  items: T[];
  total: number;
  page: number;
  limit: number;
  totalPages: number;
}

// 标准响应
export interface ApiResponse<T = any> {
  success: boolean;
  data?: T;
  error?: {
    code: string;
    message: string;
    details?: any;
  };
  timestamp: string;
}

// 用户列表响应
export type UsersListResponse = ApiResponse<PaginatedResponse<UserListItem>>;

// 用户详情响应
export type UserDetailResponse = ApiResponse<UserDetail>;

// 权限查询响应 (v2.1)
export type UserPermissionsResponse = ApiResponse<UserPermissions>;
```

---

## 📈 数据量预估

### 容量规划（3年）

| 表名 | 初期 | 1年后 | 3年后 | 增长率 |
|------|------|-------|-------|--------|
| `users` | 500 | 1,000 | 2,000 | 60%/年 |
| `departments` | 50 | 100 | 150 | 30%/年 |
| `user_departments` | 600 | 1,300 | 2,500 | 50%/年 |
| `user_role_rel` | 500 | 1,000 | 2,000 | 60%/年 |
| `roles` | 10 | 20 | 30 | 50%/年 |
| `permissions` | 50 | 80 | 120 | 20%/年 |

### 性能考虑

- **查询性能**: 已优化高频查询索引
- **写入性能**: 用户创建和更新频繁，索引数量需控制
- **存储空间**: 预估 10GB/3年（包含审计日志）

---

## 🔄 迁移策略

### 初始创建

```bash
# 生成迁移文件
npx prisma migrate dev --name init_organization

# 应用迁移
npx prisma migrate deploy

# 生成 Prisma Client
npx prisma generate
```

### 数据迁移示例

```sql
-- 示例：批量导入用户
INSERT INTO platform_iam.users (
  id, username, email, display_name, status, source, region
)
SELECT 
  gen_random_uuid(),
  lower(name),
  email,
  name,
  'ACTIVE',
  'LOCAL',
  'CN'
FROM temp_import_users;

-- 示例：设置主部门
UPDATE corp_hr.user_departments
SET is_primary = TRUE
WHERE id IN (
  SELECT DISTINCT ON (user_id) id
  FROM corp_hr.user_departments
  WHERE left_at IS NULL
  ORDER BY user_id, joined_at ASC
);
```

---

## ⚠️ 注意事项

### 数据完整性

1. **软删除**: 关键表必须使用 `deleted_at` 字段
2. **外键约束**: 确保级联规则正确（CASCADE vs RESTRICT）
3. **事务处理**: 多表操作必须在事务中完成
4. **主部门唯一性**: 通过应用层逻辑保证

### 性能优化

1. **索引优化**: 根据实际查询模式调整索引
2. **分页查询**: 大数据量查询必须分页
3. **N+1问题**: 使用 Prisma include 避免
4. **缓存策略**: 角色权限可缓存，用户信息谨慎缓存

### 安全考虑

1. **敏感字段**: `password_hash`, `ldap_dn` 需加密存储
2. **权限控制**: 数据访问需要权限校验
3. **审计日志**: 关键操作记录到 `platform_audit` schema

---

## 🔐 env 变量约定（v2.4 起）

> Entra ID SSO 登录新增的 env 变量约定。**所有新增变量必须同步写入 `.env.example`**（CI 强制）。

| 变量                    | 用途                                                                 | 默认                                              | 缺失症状                                          |
| ----------------------- | -------------------------------------------------------------------- | ------------------------------------------------- | ------------------------------------------------- |
| `AZURE_TENANT_ID`       | Entra tenant GUID（已有，sync + SSO 共用）                            | —                                                 | SSO 无法 discovery，503 `SSO_PROVIDER_UNAVAILABLE` |
| `AZURE_CLIENT_ID`       | Entra App Registration client id（已有）                              | —                                                 | OIDC authorize 失败                                |
| `AZURE_CLIENT_SECRET`   | App client secret（已有）                                             | —                                                 | token exchange 失败                                |
| `AZURE_REDIRECT_URI`    | OIDC 回调 URL（**v2.4 新增**）                                        | `${BASE_URL}/api/v1/auth/sso/callback`            | callback 收到但 Entra 拒绝（redirect_uri_mismatch）|
| `SSO_ALLOWED_DOMAINS`   | JIT 建账号 email 域名白名单，逗号分隔（**v2.4 新增**）                | —                                                 | JIT 全部 403 `SSO_DOMAIN_NOT_ALLOWED`              |
| `SSO_JIT_DEFAULT_ORG_ID`| JIT 建账号的默认组织 UUID（**v2.4 新增**）；`SSO_ALLOWED_DOMAINS` 非空时为必填 | —                                                 | 启动期 fail-fast 校验；JIT 触发时拒绝建号           |

**约束**:

- `AZURE_REDIRECT_URI` 在 Entra App Registration 中**必须预先登记**完全一致的 URL（含 scheme + host + path），任何不一致都会被 Entra 拒绝
- `SSO_ALLOWED_DOMAINS` 留空 = 所有 email 域名都会被拒，等价于关闭 JIT 通道（仅允许已存在 User 通过 SSO 登录）
- `SSO_JIT_DEFAULT_ORG_ID` 必须是有效的 Organization UUID 且对应记录存在；启动期校验失败立即 exit non-zero（不允许"启动成功但 JIT 全部失败"的悬空配置）
- 三个 `AZURE_*` 变量复用既有 Entra Connect 同步通道的配置，**不重复登记**

---

## 📌 本期 schema 增量声明（v2.4）

**推翻 v2.4 早期"无 schema 变更"的声明**——本期 PR 含 1 个 prisma migration（不改 `platform_iam` 的 User 表，但 enum 增量 + 一次性 backfill SQL）：

### 1. `platform_audit.AuditAction` enum 新增 5 个值

```sql
ALTER TYPE platform_audit."AuditAction" ADD VALUE 'SSO_LOGIN_SUCCESS';
ALTER TYPE platform_audit."AuditAction" ADD VALUE 'SSO_JIT_CREATED';
ALTER TYPE platform_audit."AuditAction" ADD VALUE 'SSO_BINDING_FILLED';
ALTER TYPE platform_audit."AuditAction" ADD VALUE 'SSO_BINDING_UPGRADED_FROM_LDAP';
ALTER TYPE platform_audit."AuditAction" ADD VALUE 'SSO_BINDING_CONFLICT';
```

> PostgreSQL 注意：`ALTER TYPE ... ADD VALUE` 在 9.1+ 不能在事务里执行；prisma migration 文件需用单独的 statement 文件或 `--create-only` 模式生成后手工调整。

同步更新 `backend/prisma/schema/platform_audit.prisma` 的 `enum AuditAction` 段。

### 2. 一次性 email 大小写归一化 backfill SQL（幂等）

```sql
UPDATE platform_iam.users
SET email = LOWER(email)
WHERE email != LOWER(email);
```

幂等：再跑一次 `WHERE` 0 行，无副作用。配合应用层全量 lower-case 写入，向后不再产生大小写漂移。

### 3. `lastSsoLoginAt` 反查索引（**无需新增**）

v2.4 用户详情页"最近 SSO 登录时间"走 AuditLog 反查（`WHERE userId=$1 AND action='SSO_LOGIN_SUCCESS' ORDER BY when DESC LIMIT 1`）。已查 `backend/prisma/schema/platform_audit.prisma` 现状：复合索引 `@@index([tenantId, userId, when])` 已存在，PostgreSQL B-tree 可降序扫描，足以承载该查询；**不**新增 `(actor, action, timestamp DESC)` 索引（即使决策卡按通用名义提及，FFOA 实际字段名是 `userId` / `action` / `when`，且现有索引可复用）。

### 4. `platform_iam.users` 表 schema 字段不动

现有 `passwordHash?` / `source UserSource` / `externalId?` / `externalSource?` 四个字段足以承载 SSO 流程的全部数据需求；现有 `enum UserSource { LOCAL, ENTRA, LDAP }` 已包含 `ENTRA` 值。

### 5. `SSO_JIT_DEFAULT_ORG_ID` 启动期 + 运行时双层校验

- **启动期**：`SsoConfigValidator` Provider 实现 `OnApplicationBootstrap`：
  - `SSO_ALLOWED_DOMAINS` 非空 → `SSO_JIT_DEFAULT_ORG_ID` 必填
  - `prisma.organization.findUnique({where:{id:env.SSO_JIT_DEFAULT_ORG_ID, deletedAt:null}})` 必须命中（`Organization.deletedAt IS NULL`）
  - `AZURE_TENANT_ID` 必须 GUID（regex 校验）
  - 任一失败 → `process.exit(1)`
- **运行时**：JIT 触发时再查一次（`WHERE deletedAt IS NULL`），缺失 → 503 `SSO_PROVIDER_UNAVAILABLE`（不引入新错误码）

**理由**：启动期校验防止"启动成功但 JIT 全部失败"悬空配置；运行时校验防止默认 org 被运营软删后悄悄进 JIT 失败。两层都不可省。

---

## 🔗 相关文档

- [架构设计](./03-architecture.md) - 技术架构详解
- [API 文档](./07-api.md) - 数据操作接口
- [状态机](./04-state-machine.md) - 用户状态流转
- [测试场景](./09-test-scenarios.md) - 数据测试黄老师用例
