# 用户与组织架构管理 - 架构设计文档

> **版本**: v2.4
> **状态**: Approved
> **创建日期**: 2024-11-01
> **最后更新**: 2026-05-19
> **架构师**: FFOA 开发团队

---

## 📋 文档变更记录

| 版本 | 日期 | 修改人 | 修改内容 |
|------|------|--------|---------|
| v2.4 | 2026-05-19 | FFOA Team | 推翻 v2.1.25「认证降级策略」段，改写为「v2.4 SSO 登录架构（Entra ID OIDC）」段，含 OIDC sequenceDiagram / JIT 流程 / 双通道并存说明 / 与现有 ROPC 的差异；关联 issue #334 |
| v2.2.0 | 2026-03-14 | FFOA Team | 新增数据权限子系统（DataScope），实现功能权限与数据权限分离，支持自动化数据范围过滤 |
| v2.1.30 | 2026-03-13 | FFOA Team | 新增本地年假释放计划参数编辑能力，明确 `adjustmentDays` 与 `notCountDays` 的职责边界 |
| v2.1.29 | 2026-03-11 | FFOA Team | 钉钉假期余额页改为读取本地快照，并新增快照刷新能力 |
| v2.1.28 | 2026-03-11 | FFOA Team | 补充钉钉定时调度窗口规则，考勤类任务按固定半小时整点窗口执行 |
| v2.1.27 | 2026-03-10 | FFOA Team | 新增钉钉年假洞察查询服务 |
| v2.1.26 | 2026-03-10 | FFOA Team | 新增钉钉同步人工修复服务 |
| v1.0 | 2024-11-01 | FFOA Team | 初始版本，基于区域划分组织 |
| v2.0 | 2025-12-20 | FFOA Team | 架构升级：新增独立 Organization 表 |
| v2.1 | 2025-12-26 | FFOA Team | 组织级权限隔离，按模板重构文档 |
| v2.1.1 | 2025-12-26 | FFOA Team | 补充Scope系统、流程角色、岗位管理、身份源处理、安全策略 |
| v2.1.25 | 2026-01-05 | FFOA Team | 同步 PRD v2.1.25：登录安全简化、身份源优化、定时同步 |

---

## 🎯 架构概述

### 系统定位

本模块是整个企业应用的**基础IAM（身份与访问管理）模块**，负责：
- 用户身份认证与授权
- 组织架构管理（多组织、多部门、多区域）
- RBAC + PBAC 权限控制
- 与外部身份系统（LDAP、Microsoft Entra ID）集成

### 设计目标

- **目标1: 高性能** - 权限查询 < 50ms，支持 10,000+ 用户并发
- **目标2: 高扩展性** - 支持多组织、多区域、矩阵式管理
- **目标3: 安全可靠** - 完整的审计日志、权限隔离、Token 管理
- **目标4: 易维护** - 模块化设计、清晰的职责划分、标准化 API

### 核心特性

- ✅ **v2.1 组织级权限隔离** - 角色和权限按组织隔离，支持全局角色
- ✅ **v2.2 数据权限子系统** - 功能权限与数据权限分离，自动化数据范围过滤
- ✅ **多部门归属** - 用户可同时属于多个部门，每个归属有独立岗位和汇报关系
- ✅ **双层角色架构** - 系统角色（功能权限） + 流程角色（审批人解析）
- ✅ **区域多维度支持** - primaryRegion（归属） + operatingRegions（运营覆盖）
- ✅ **外部同步** - 支持 LDAP 认证、Entra ID 用户同步
- ✅ **完整审计** - 所有敏感操作自动记录审计日志

---

## 🏗️ 系统架构

### 分层架构

```
┌─────────────────────────────────────────────────────────────────┐
│                      Presentation Layer                         │
│         (Next.js Frontend / API Gateway / Admin Portal)         │
│  ┌──────────────┐  ┌──────────────┐  ┌──────────────┐          │
│  │   Web App    │  │  Mobile App  │  │   Admin UI   │          │
│  └──────────────┘  └──────────────┘  └──────────────┘          │
└─────────────────────────────────────────────────────────────────┘
                  │ REST API (JWT Token)
                  ▼
┌─────────────────────────────────────────────────────────────────┐
│                     Application Layer                            │
│                (NestJS Controllers + Guards)                     │
│  ┌──────────────────────────────────────────────────────────┐   │
│  │  Guards & Interceptors                                   │   │
│  │  ┌───────────┐ ┌──────────────┐ ┌───────────────────┐   │   │
│  │  │JwtAuthGua.│ │PermissionsGu.│ │RolesGuard(v2.1)   │   │   │
│  │  └───────────┘ └──────────────┘ └───────────────────┘   │   │
│  └──────────────────────────────────────────────────────────┘   │
│                                                                  │
│  ┌──────────────────────────────────────────────────────────┐   │
│  │  Controllers                                             │   │
│  │  • AuthController      • UsersController                 │   │
│  │  • DepartmentsController  • RolesController              │   │
│  │  • PermissionsController  • OrganizationsController      │   │
│  └──────────────────────────────────────────────────────────┘   │
└─────────────────────────────────────────────────────────────────┘
                  │ Service Layer
                  ▼
┌─────────────────────────────────────────────────────────────────┐
│                      Domain Layer                                │
│               (Business Logic / Services)                        │
│  ┌──────────────────────────────────────────────────────────┐   │
│  │  Core Services                                           │   │
│  │  • AuthService         • UsersService                    │   │
│  │  • OrganizationsService • DepartmentsService             │   │
│  │  • RolesService        • PermissionsService              │   │
│  │  • WorkflowRolesService                                  │   │
│  └──────────────────────────────────────────────────────────┘   │
│                                                                  │
│  ┌──────────────────────────────────────────────────────────┐   │
│  │  External Integration Services                           │   │
│  │  • LdapService         • EntraService                    │   │
│  │  • SyncService         • DingtalkRepairService           │   │
│  │  • AnnualLeaveInsightService                             │   │
│  └──────────────────────────────────────────────────────────┘   │
└─────────────────────────────────────────────────────────────────┘
                  │ Prisma ORM
                  ▼
┌─────────────────────────────────────────────────────────────────┐
│                    Infrastructure Layer                          │
│                (Database / Cache / External)                     │
│  ┌──────────────────┐  ┌──────────────────┐  ┌──────────────┐  │
│  │   PostgreSQL     │  │      Redis       │  │  LDAP / AD   │  │
│  │  platform_iam    │  │   (Permission    │  │   Server     │  │
│  │  corp_hr         │  │    Cache)        │  └──────────────┘  │
│  └──────────────────┘  └──────────────────┘                     │
│  ┌──────────────────┐  ┌──────────────────┐                     │
│  │  Entra ID API    │  │  Audit System    │                     │
│  │  (Graph API)     │  │  (Global Module) │                     │
│  └──────────────────┘  └──────────────────┘                     │
└─────────────────────────────────────────────────────────────────┘
```

### 核心组件

#### 组件X: DingtalkRepairService

**职责**:
- 按同步类型读取宜搭审批单，提取可能受影响的工作日
- 在钉钉考勤侧扫描审批记录，并按审批号模式过滤
- 提供 dry-run 预览与实际撤销两种执行模式

**关键规则**:
- 仅支持出差、外勤、加班三类考勤同步
- 审批号支持精确匹配与 `*` 通配匹配
- 撤销实际执行前需先做命中记录去重

#### 组件Z: DingtalkSchedulerService

**职责**:
- 统一托管钉钉同步任务的 Cron 调度、执行记录和任务状态检查
- 对手动触发与定时触发分别施加不同的时间窗口规则

**关键规则**:
- 手动触发的考勤类任务最大查询窗口为最近 `60` 天
- 定时触发的考勤类任务与 Python 原脚本一致，默认按固定半小时整点窗口执行
- 定时窗口按北京时间 `Asia/Shanghai` 计算：
  - `:15` 对应上一小时 `30:00 ~ 当前小时 00:00`
  - `:45` 对应当前小时 `00:00 ~ 30:00`

#### 组件Y: AnnualLeaveInsightService

**职责**:
- 拉取钉钉考勤侧各员工的假期余额并写入本地快照表
- 读取本地余额快照，按假期类型输出总额、已用和剩余
- 读取宜搭年假释放表，展开为“员工-释放日期-释放天数”的可视数据
- 为前端年假余额总览页和年假释放计划页提供统一查询接口

**关键规则**:
- 假期余额页默认读取本地快照，不在页面加载时直连钉钉
- 手动刷新快照时，服务会重新拉取钉钉在职员工及各假期类型余额，并覆盖本地快照
- 快照表仅保存当前余额状态，不保存历史版本
- 年假释放计划页读取本地年假释放计划表，按日期字段展开释放计划
- 释放计划中的单个释放日期默认代表 1 天，若同一员工同一天出现多次则按天数聚合

#### 组件Y1: AnnualLeavePlanAdminService

**职责**:
- 提供员工级年假计划参数查询与更新
- 将页面编辑的 `status`、`adjustmentDays`、`notCountDays` 写回本地计划表
- 在需要时触发该员工本年度释放计划重算

**关键规则**:
- 仅允许编辑本地年假释放计划参数，不允许直接改员工主数据
- `adjustmentDays` 只影响累计应释放额度，不影响 `releaseSchedule`
- `notCountDays` 作为计划偏移量参与 `calculateLeaveDates()`，会影响释放日期
- 保存后若选择“保存并重算”，必须基于最新员工主数据重新生成计划

#### 组件1: 认证与授权 (Auth)

**职责**: 
- JWT Token 生成与验证
- LDAP/AD 认证（AD 用户） + 本地密码认证（本地用户，仅测试）
- **身份源管理（LOCAL/LDAP）** ⭐ v2.1.25 简化
- Token 黑名单管理
- 会话管理

**技术选型**: 
- NestJS Passport + JWT Strategy
- LDAP.js（LDAP 集成）
- Redis（Token 黑名单）

**关键接口**:
- `login()` - 用户登录，返回 JWT Token
- `validateToken()` - 验证 Token 有效性
- `refreshToken()` - 刷新 Token
- `logout()` - 登出，Token 加入黑名单
- `changePassword()` - 修改密码（仅本地用户）⭐ NEW

---

##### 1. 身份源（User Source）处理逻辑 ⭐ NEW

**两种身份源**:

| 身份源 | 代码 | 创建方式 | 认证方式 | 密码管理 | 同步更新 |
|--------|------|---------|---------|---------|---------|
| **本地用户** | `LOCAL` | 手动创建 | 本地密码验证（bcrypt） | ✅ 系统管理 | ❌ N/A |
| **AD用户（LDAP）** | `LDAP` | ⭐ **仅 Entra ID 同步** | LDAP/AD 认证 | ❌ AD 管理 | ✅ 自动更新 |

> **重要说明** (v2.1.25)：
> - 只有两种身份源：LOCAL（本地用户，用于测试）和 LDAP（AD用户，生产环境）
> - Entra ID **仅用于同步用户信息**，不是独立的身份源
> - **AD 用户只能通过 Entra ID 同步创建**，不支持手动创建
> - 同步的用户统一标记为 `source = LDAP`，使用 LDAP/AD 认证

**数据库字段**:
```typescript
// User 表
{
  source: UserSource, // 'LOCAL' | 'LDAP' (v2.1.25: 仅两种)
  passwordHash: string?, // 仅 LOCAL 用户有值
  externalId: string?, // LDAP DN 或 Entra Object ID (用于同步)
  ldapSyncedAt: DateTime? // 最后同步时间（Entra ID 同步的用户）
}
```

##### 2. 登录流程（区分身份源）

```typescript
@Injectable()
export class AuthService {
  async login(username: string, password: string): Promise<LoginResult> {
    // 1. 查找用户
    const user = await this.prisma.user.findFirst({
      where: {
        OR: [{ username }, { email: username }],
        deletedAt: null
      },
      include: { roles: { include: { role: true } } }
    });
    
    if (!user) {
      throw new UnauthorizedException('用户名或密码错误');
    }
    
    // 2. 检查用户状态
    if (user.status !== UserStatus.ACTIVE) {
      if (user.status === UserStatus.TERMINATED) {
        throw new UnauthorizedException('账号已注销');
      }
      throw new UnauthorizedException('账号已被停用，请联系管理员');
    }
    
    // 3. 根据身份源选择认证方式
    let authenticated = false;
    
    switch (user.source) {
      case UserSource.LDAP:
        // LDAP 认证（AD 用户，包括 Entra ID 同步的用户）
        try {
          authenticated = await this.ldapService.authenticate(username, password);
        } catch (error) {
          this.logger.warn(`LDAP authentication failed for ${username}`, { error });
          throw new UnauthorizedException('LDAP 认证失败');
        }
        break;
        
      case UserSource.LOCAL:
        // 本地密码认证（仅用于测试）
        if (!user.passwordHash) {
          throw new UnauthorizedException('密码未设置');
        }
        authenticated = await bcrypt.compare(password, user.passwordHash);
        break;
        
      default:
        throw new UnauthorizedException('不支持的身份源');
    }
    
    if (!authenticated) {
      throw new UnauthorizedException('用户名或密码错误');
    }
    
    // 4. 生成 Token
    const token = await this.generateToken(user);
    
    return {
      user: this.sanitizeUser(user),
      token
    };
  }
}
```

##### 3. 修改密码（仅本地用户）⭐ NEW

```typescript
@Injectable()
export class AuthService {
  async changePassword(
    userId: string,
    oldPassword: string,
    newPassword: string
  ): Promise<void> {
    // 1. 获取用户
    const user = await this.prisma.user.findUnique({
      where: { id: userId, deletedAt: null },
      select: {
        id: true,
        source: true,
        passwordHash: true,
        username: true
      }
    });
    
    if (!user) {
      throw new NotFoundException('用户不存在');
    }
    
    // 2. 检查身份源（关键）
    if (user.source !== UserSource.LOCAL) {
      throw new BadRequestException(
        this.getPasswordChangeErrorMessage(user.source)
      );
    }
    
    // 3. 验证当前密码
    if (!user.passwordHash) {
      throw new BadRequestException('密码未设置');
    }
    
    const isValid = await bcrypt.compare(oldPassword, user.passwordHash);
    if (!isValid) {
      throw new BadRequestException('当前密码错误');
    }
    
    // 4. 验证新密码强度
    this.validatePasswordStrength(newPassword);
    
    // 5. 检查新旧密码不同
    const isSameAsOld = await bcrypt.compare(newPassword, user.passwordHash);
    if (isSameAsOld) {
      throw new BadRequestException('新密码不能与旧密码相同');
    }
    
    // 6. 更新密码
    const newHash = await bcrypt.hash(newPassword, 10);
    
    await this.prisma.user.update({
      where: { id: userId },
      data: {
        passwordHash: newHash,
        updatedAt: new Date()
      }
    });
    
    // 7. 可选：使旧 Token 失效（强制重新登录）
    await this.tokenBlacklistService.addUser(userId);
    
    // 8. 记录审计日志
    this.logger.log(`Password changed for user ${user.username}`, {
      userId,
      source: user.source
    });
  }
  
  private getPasswordChangeErrorMessage(source: UserSource): string {
    switch (source) {
      case UserSource.LDAP:
        return 'AD 用户不能通过本系统修改密码，密码由 Active Directory 管理。请联系 IT 管理员或通过 AD 域控制器修改';
      default:
        return '当前用户类型不支持修改密码';
    }
  }
  
  private validatePasswordStrength(password: string): void {
    // 密码策略：至少8位，包含至少2种字符类型
    if (password.length < 8) {
      throw new BadRequestException('密码长度至少8位');
    }
    
    const hasUpperCase = /[A-Z]/.test(password);
    const hasLowerCase = /[a-z]/.test(password);
    const hasNumbers = /\d/.test(password);
    const hasSpecialChar = /[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/.test(password);
    
    const typeCount = [hasUpperCase, hasLowerCase, hasNumbers, hasSpecialChar]
      .filter(Boolean).length;
    
    if (typeCount < 2) {
      throw new BadRequestException(
        '密码必须至少包含以下字符类型中的2种：大写字母、小写字母、数字、特殊字符'
      );
    }
  }
}
```

##### 4. 用户创建时的身份源处理

```typescript
@Injectable()
export class UsersService {
  async create(dto: CreateUserDto, operatorId: string): Promise<User> {
    // ... 业务验证 ...
    
    // 根据身份源设置不同字段
    const userData: any = {
      username: dto.username,
      email: dto.email,
      displayName: dto.displayName,
      source: dto.source, // 'LOCAL' or 'LDAP'
      status: UserStatus.ACTIVE,
      createdBy: operatorId
    };
    
    // LOCAL 用户：生成密码哈希
    if (dto.source === UserSource.LOCAL) {
      if (!dto.password) {
        // 如果没有提供密码，生成临时密码
        dto.password = this.generateTemporaryPassword();
      }
      userData.passwordHash = await bcrypt.hash(dto.password, 10);
      userData.externalId = null;
    }
    
    // LDAP 用户：保存 LDAP DN
    if (dto.source === UserSource.LDAP) {
      if (!dto.ldapUsername) {
        throw new BadRequestException('LDAP 用户必须提供 LDAP 用户名（sAMAccountName）');
      }
      // 验证 LDAP 用户是否存在
      const ldapUser = await this.ldapService.findUser(dto.ldapUsername);
      if (!ldapUser) {
        throw new BadRequestException(`LDAP 用户 "${dto.ldapUsername}" 不存在`);
      }
      userData.passwordHash = null; // LDAP 用户无密码
      userData.externalId = ldapUser.dn; // 保存 LDAP DN
    }
    
    // 创建用户
    const user = await this.prisma.user.create({
      data: userData
    });
    
    // 发送欢迎邮件（根据身份源定制内容）
    await this.sendWelcomeEmail(user, dto);
    
    return user;
  }
  
  private async sendWelcomeEmail(user: User, dto: CreateUserDto): Promise<void> {
    if (user.source === UserSource.LOCAL) {
      // 本地用户：发送密码
      await this.emailService.send({
        to: user.email,
        subject: '欢迎加入 FFOA',
        template: 'user-welcome-local',
        context: {
          displayName: user.displayName,
          username: user.username,
          temporaryPassword: dto.password,
          loginUrl: `${process.env.APP_URL}/login`
        }
      });
    } else if (user.source === UserSource.LDAP) {
      // LDAP 用户：提示使用 LDAP 账号登录
      await this.emailService.send({
        to: user.email,
        subject: '欢迎加入 FFOA',
        template: 'user-welcome-ldap',
        context: {
          displayName: user.displayName,
          username: user.username,
          loginUrl: `${process.env.APP_URL}/login`,
          note: '请使用您的企业 LDAP/AD 账号和密码登录'
        }
      });
    }
  }
  
  private generateTemporaryPassword(): string {
    // 生成符合密码策略的临时密码
    const chars = 'ABCDEFGHJKMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789!@#$%';
    let password = '';
    for (let i = 0; i < 12; i++) {
      password += chars.charAt(Math.floor(Math.random() * chars.length));
    }
    return password;
  }
}
```

##### 5. 前端处理示例

```typescript
// 修改密码组件
function ChangePasswordForm() {
  const { user } = useAuth();
  
  // AD 用户不显示修改密码功能
  if (user.source !== 'LOCAL') {
    return (
      <Alert type="info">
        <p>AD 用户不能通过本系统修改密码，密码由 Active Directory 管理。请联系 IT 管理员或通过 AD 域控制器修改。</p>
      </Alert>
    );
  }
  
  // 本地用户：显示修改密码表单
  return (
    <Form onSubmit={handleChangePassword}>
      <PasswordInput name="oldPassword" label="当前密码" required />
      <PasswordInput name="newPassword" label="新密码" required />
      <PasswordInput name="confirmPassword" label="确认新密码" required />
      <Button type="submit">修改密码</Button>
    </Form>
  );
}
```

##### 6. 身份源处理总结

**创建用户**:
- LOCAL: 生成密码哈希，发送临时密码邮件（仅用于测试）
- LDAP: ⚠️ **不支持手动创建**，必须通过 Entra ID 同步

**登录认证**:
- LOCAL: bcrypt 密码验证（仅测试）
- LDAP: LDAP/AD 服务器认证（包括 Entra ID 同步的用户）

**修改密码**:
- LOCAL: ✅ 允许，系统管理
- LDAP: ❌ 禁止，返回 BadRequestException

**同步更新**:
- LOCAL: N/A
- LDAP: ✅ 通过 Entra ID 自动同步（定时任务，v2.1.24）

---

#### 组件2: 权限系统 (Permissions)

> 权限系统已拆分为独立模块文档，详见 **[docs/modules/iam/](../iam/)**
>
> - [架构设计](../iam/03-architecture.md) — 功能权限实现 + 数据权限设计 + 演进路线图
> - [数据模型](../iam/06-data-model.md) — Permission / Role / UserRole / DataPermission 表
> - [API 接口](../iam/07-api.md) — 角色管理、权限查询相关 API
> - [错误码](../iam/08-error-codes.md) — 认证和权限相关错误码

---

#### 组件3: 组织架构管理 (Organizations & Departments)

**职责**: 
- v2.0: 独立 Organization 表，是一等公民
- 部门树管理（自引用，parentId）
- 多部门归属（UserDepartment）
- 区域关联（primaryRegion + operatingRegions）

**技术选型**: 
- Prisma ORM（递归查询优化）
- PostgreSQL（物化视图，部门路径缓存）

**关键接口**:
- `getOrganization(id)` - 获取组织详情
- `getDepartmentTree(organizationId)` - 获取组织的部门树
- `getUserDepartments(userId)` - 获取用户的所有部门归属
- `assignUserToDepartment(userId, departmentId, positionId, managerId)` - 添加部门归属

---

#### 组件3.5: 岗位管理系统 (Positions) ⭐ NEW

**职责**: 
- 岗位（Position）的 CRUD 管理
- 职级层级（level）管理和排序
- 岗位使用情况统计
- 删除保护（有用户使用的岗位不能删除）

**技术选型**: 
- Prisma ORM
- Redis（岗位列表缓存）

**关键接口**:
- `createPosition(dto)` - 创建岗位
- `updatePosition(id, dto)` - 更新岗位
- `deletePosition(id)` - 删除岗位（带删除保护）
- `findAllPositions(query)` - 查询岗位列表（按 level 排序）
- `getPositionStats(id)` - 获取岗位使用统计

---

##### 1. Position 表结构

```typescript
// Prisma Schema
model Position {
  id          String   @id @default(uuid())
  code        String   @unique // 'ENG_L5'
  name        String   // '高级工程师'
  description String?  // 职责说明
  level       Int      // 职级层级（1-10，数字越大职级越高）
  category    String?  // 岗位类别（技术、管理、产品等）
  
  enabled     Boolean  @default(true)
  createdAt   DateTime @default(now())
  updatedAt   DateTime @updatedAt
  deletedAt   DateTime?
  
  // 关联
  userDepartments UserDepartment[]
  
  @@index([level])
  @@index([enabled])
  @@index([category])
}
```

##### 2. PositionsService 实现

```typescript
@Injectable()
export class PositionsService {
  constructor(
    private readonly prisma: PrismaService,
    private readonly logger: LoggerService,
    private readonly cacheManager: CacheManager
  ) {}
  
  async create(dto: CreatePositionDto, operatorId: string): Promise<Position> {
    // 1. 检查代码唯一性
    await this.checkCodeUniqueness(dto.code);
    
    // 2. 创建岗位
    const position = await this.prisma.position.create({
      data: {
        code: dto.code,
        name: dto.name,
        description: dto.description,
        level: dto.level,
        category: dto.category,
        enabled: true
      }
    });
    
    // 3. 清理缓存
    await this.invalidateCache();
    
    // 4. 记录日志
    this.logger.log(`Position created: ${position.code}`, {
      positionId: position.id,
      operatorId
    });
    
    return position;
  }
  
  async findAll(query: QueryPositionsDto): Promise<PaginatedResult<Position>> {
    const { page = 1, limit = 50, category, enabled, search } = query;
    
    // 尝试从缓存获取
    const cacheKey = `positions:list:${JSON.stringify(query)}`;
    const cached = await this.cacheManager.get<PaginatedResult<Position>>(cacheKey);
    if (cached) return cached;
    
    // 构建查询条件
    const where: any = { deletedAt: null };
    
    if (category) where.category = category;
    if (enabled !== undefined) where.enabled = enabled;
    if (search) {
      where.OR = [
        { code: { contains: search, mode: 'insensitive' } },
        { name: { contains: search, mode: 'insensitive' } }
      ];
    }
    
    // 并发查询
    const [items, total] = await Promise.all([
      this.prisma.position.findMany({
        where,
        skip: (page - 1) * limit,
        take: limit,
        orderBy: [
          { level: 'asc' },  // 按职级排序（升序）
          { name: 'asc' }
        ],
        include: {
          _count: {
            select: { userDepartments: true }
          }
        }
      }),
      this.prisma.position.count({ where })
    ]);
    
    const result = {
      items,
      total,
      page,
      limit,
      totalPages: Math.ceil(total / limit)
    };
    
    // 写入缓存
    await this.cacheManager.set(cacheKey, result, 600); // 10分钟
    
    return result;
  }
  
  async findOne(id: string): Promise<Position> {
    const position = await this.prisma.position.findUnique({
      where: { id, deletedAt: null },
      include: {
        _count: {
          select: { userDepartments: true }
        }
      }
    });
    
    if (!position) {
      throw new NotFoundException('岗位不存在');
    }
    
    return position;
  }
  
  async update(
    id: string,
    dto: UpdatePositionDto,
    operatorId: string
  ): Promise<Position> {
    // 1. 检查岗位是否存在
    await this.findOne(id);
    
    // 2. 如果修改代码，检查唯一性
    if (dto.code) {
      await this.checkCodeUniqueness(dto.code, id);
    }
    
    // 3. 更新岗位
    const position = await this.prisma.position.update({
      where: { id },
      data: {
        ...(dto.code && { code: dto.code }),
        ...(dto.name && { name: dto.name }),
        ...(dto.description !== undefined && { description: dto.description }),
        ...(dto.level !== undefined && { level: dto.level }),
        ...(dto.category !== undefined && { category: dto.category }),
        ...(dto.enabled !== undefined && { enabled: dto.enabled }),
        updatedAt: new Date()
      }
    });
    
    // 4. 清理缓存
    await this.invalidateCache();
    
    // 5. 记录日志
    this.logger.log(`Position updated: ${position.code}`, {
      positionId: position.id,
      changes: dto,
      operatorId
    });
    
    return position;
  }
  
  async delete(id: string, operatorId: string): Promise<void> {
    // 1. 检查岗位是否存在
    const position = await this.findOne(id);
    
    // 2. 删除保护：检查是否有用户使用该岗位
    const usageCount = await this.prisma.userDepartment.count({
      where: {
        positionId: id,
        leftAt: null
      }
    });
    
    if (usageCount > 0) {
      throw new BadRequestException(
        `无法删除岗位 "${position.name}"，该岗位被 ${usageCount} 名员工使用`
      );
    }
    
    // 3. 软删除
    await this.prisma.position.update({
      where: { id },
      data: {
        deletedAt: new Date()
      }
    });
    
    // 4. 清理缓存
    await this.invalidateCache();
    
    // 5. 记录日志
    this.logger.log(`Position deleted: ${position.code}`, {
      positionId: position.id,
      operatorId
    });
  }
  
  async getPositionStats(id: string): Promise<PositionStats> {
    const position = await this.findOne(id);
    
    // 统计使用情况
    const [activeCount, totalCount, departmentDistribution] = await Promise.all([
      // 当前在职人数
      this.prisma.userDepartment.count({
        where: {
          positionId: id,
          leftAt: null,
          user: { status: 'ACTIVE', deletedAt: null }
        }
      }),
      
      // 历史使用人数
      this.prisma.userDepartment.count({
        where: { positionId: id }
      }),
      
      // 部门分布
      this.prisma.userDepartment.groupBy({
        by: ['departmentId'],
        where: {
          positionId: id,
          leftAt: null
        },
        _count: true
      })
    ]);
    
    // 获取部门详情
    const departmentIds = departmentDistribution.map(d => d.departmentId);
    const departments = await this.prisma.department.findMany({
      where: { id: { in: departmentIds } },
      select: { id: true, code: true, name: true }
    });
    
    const distribution = departmentDistribution.map(d => {
      const dept = departments.find(dep => dep.id === d.departmentId);
      return {
        departmentId: d.departmentId,
        departmentName: dept?.name || 'Unknown',
        count: d._count
      };
    });
    
    return {
      positionId: position.id,
      positionCode: position.code,
      positionName: position.name,
      level: position.level,
      activeUsersCount: activeCount,
      totalUsersCount: totalCount,
      departmentDistribution: distribution
    };
  }
  
  private async checkCodeUniqueness(code: string, excludeId?: string): Promise<void> {
    const existing = await this.prisma.position.findFirst({
      where: {
        code,
        deletedAt: null,
        ...(excludeId && { id: { not: excludeId } })
      }
    });
    
    if (existing) {
      throw new BadRequestException(`岗位代码 "${code}" 已存在`);
    }
  }
  
  private async invalidateCache(): Promise<void> {
    // 清理所有岗位列表缓存
    const pattern = 'positions:list';
    const keys = await this.cacheManager.keys(pattern);
    await Promise.all(keys.map(key => this.cacheManager.del(key)));
  }
}
```

##### 3. PositionsController 实现

```typescript
@Controller('api/v1/positions')
@UseGuards(JwtAuthGuard, PermissionsGuard)
export class PositionsController {
  constructor(
    private readonly positionsService: PositionsService
  ) {}
  
  @Post()
  @RequirePermissions('position:create:organization')
  @Sensitive()
  @ApiOperation({ summary: '创建岗位' })
  async create(
    @Body() dto: CreatePositionDto,
    @CurrentUser() user: User
  ) {
    return this.positionsService.create(dto, user.id);
  }
  
  @Get()
  @RequirePermissions('position:read')
  @ApiOperation({ summary: '获取岗位列表（按职级排序）' })
  async findAll(@Query() query: QueryPositionsDto) {
    return this.positionsService.findAll(query);
  }
  
  @Get(':id')
  @RequirePermissions('position:read')
  @ApiOperation({ summary: '获取岗位详情' })
  async findOne(@Param('id') id: string) {
    return this.positionsService.findOne(id);
  }
  
  @Get(':id/stats')
  @RequirePermissions('position:read')
  @ApiOperation({ summary: '获取岗位使用统计' })
  async getStats(@Param('id') id: string) {
    return this.positionsService.getPositionStats(id);
  }
  
  @Patch(':id')
  @RequirePermissions('position:update:organization')
  @Sensitive()
  @ApiOperation({ summary: '更新岗位' })
  async update(
    @Param('id') id: string,
    @Body() dto: UpdatePositionDto,
    @CurrentUser() user: User
  ) {
    return this.positionsService.update(id, dto, user.id);
  }
  
  @Delete(':id')
  @RequirePermissions('position:delete:organization')
  @Sensitive()
  @ApiOperation({ summary: '删除岗位（带删除保护）' })
  async delete(
    @Param('id') id: string,
    @CurrentUser() user: User
  ) {
    await this.positionsService.delete(id, user.id);
    return { message: '删除成功' };
  }
}
```

##### 4. DTOs 定义

```typescript
export class CreatePositionDto {
  @ApiProperty({ description: '岗位代码', example: 'ENG_L5' })
  @IsString()
  @MinLength(2)
  @MaxLength(50)
  @Matches(/^[A-Z0-9_]+$/, { message: '岗位代码只能包含大写字母、数字和下划线' })
  code: string;
  
  @ApiProperty({ description: '岗位名称', example: '高级工程师' })
  @IsString()
  @MinLength(1)
  @MaxLength(100)
  name: string;
  
  @ApiPropertyOptional({ description: '职责说明' })
  @IsString()
  @IsOptional()
  description?: string;
  
  @ApiProperty({ description: '职级层级（1-10）', example: 5 })
  @IsInt()
  @Min(1)
  @Max(10)
  level: number;
  
  @ApiPropertyOptional({ description: '岗位类别', example: '技术' })
  @IsString()
  @IsOptional()
  category?: string;
}

export class UpdatePositionDto extends PartialType(CreatePositionDto) {
  @ApiPropertyOptional({ description: '是否启用' })
  @IsBoolean()
  @IsOptional()
  enabled?: boolean;
}

export class QueryPositionsDto {
  @ApiPropertyOptional({ description: '页码', example: 1 })
  @IsInt()
  @Min(1)
  @IsOptional()
  page?: number;
  
  @ApiPropertyOptional({ description: '每页数量', example: 50 })
  @IsInt()
  @Min(1)
  @Max(100)
  @IsOptional()
  limit?: number;
  
  @ApiPropertyOptional({ description: '岗位类别' })
  @IsString()
  @IsOptional()
  category?: string;
  
  @ApiPropertyOptional({ description: '是否启用' })
  @IsBoolean()
  @IsOptional()
  enabled?: boolean;
  
  @ApiPropertyOptional({ description: '搜索关键词' })
  @IsString()
  @IsOptional()
  search?: string;
}
```

##### 5. 前端使用示例

```typescript
// 岗位管理页面
function PositionManagementPage() {
  const { data: positions } = useQuery({
    queryKey: ['positions'],
    queryFn: () => fetch('/api/v1/positions').then(r => r.json())
  });
  
  return (
    <div>
      <Table>
        <thead>
          <tr>
            <th>代码</th>
            <th>名称</th>
            <th>职级</th>
            <th>类别</th>
            <th>使用人数</th>
            <th>操作</th>
          </tr>
        </thead>
        <tbody>
          {positions?.items.map(position => (
            <tr key={position.id}>
              <td>{position.code}</td>
              <td>{position.name}</td>
              <td>L{position.level}</td>
              <td>{position.category || '-'}</td>
              <td>{position._count.userDepartments}</td>
              <td>
                <Button onClick={() => handleEdit(position)}>编辑</Button>
                <Button 
                  variant="danger" 
                  onClick={() => handleDelete(position.id)}
                  disabled={position._count.userDepartments > 0}
                >
                  删除
                </Button>
              </td>
            </tr>
          ))}
        </tbody>
      </Table>
    </div>
  );
}
```

##### 6. 业务规则

1. **唯一性约束**: 岗位代码全局唯一
2. **职级排序**: 查询列表时按 level 升序排序
3. **删除保护**: 有用户使用的岗位不能删除
4. **权限要求**: 需要组织级权限（`position:create:organization`）
5. **缓存策略**: 岗位列表缓存 10 分钟

---

#### 组件4: 外部同步 (External Sync)

**职责**: 
- Microsoft Entra ID 用户同步（v2.1.24 支持定时自动同步）
- LDAP/AD 用户认证
- 同步锁管理（防止并发同步）
- 同步日志与报告

**技术选型**: 
- Microsoft Graph API SDK
- LDAP.js
- Bull Queue（异步同步任务）

**关键接口**:
- `syncFromEntra()` - 从 Entra ID 同步用户
- `authenticateWithLdap(username, password)` - LDAP 认证

---

#### 组件5: 流程角色解析系统 (Workflow Roles - v2.1) ⭐ NEW

**职责**: 
- 动态解析审批流程中的审批人
- 支持三种解析规则（组织关系、系统角色映射、固定用户）
- 流程提交时解析并固化审批人
- 与审批流程模块集成

**技术选型**: 
- Strategy Pattern（策略模式）
- Prisma ORM（规则配置存储）
- 解析结果固化（避免运行时性能问题）

**关键接口**:
- `resolveWorkflowRole(roleId, context)` - 解析流程角色，返回用户列表
- `createWorkflowRole(dto)` - 创建流程角色规则
- `testWorkflowRole(roleId, context)` - 测试解析规则

---

##### 1. WorkflowRole 表结构

```typescript
// Prisma Schema
model WorkflowRole {
  id          String   @id @default(uuid())
  code        String   @unique // 'DIRECT_MANAGER'
  name        String   // '直属上级'
  description String?
  
  // 规则类型
  ruleType    WorkflowRoleType // ORGANIZATIONAL_RELATION, SYSTEM_ROLE_MAPPING, FIXED_USERS
  
  // 规则配置（JSON）
  ruleConfig  Json
  
  // 兜底策略（v2.2 计划）
  fallbackStrategy String? // 'ESCALATE_TO_HEAD', 'NOTIFY_ADMIN', 'SKIP'
  
  enabled     Boolean  @default(true)
  createdAt   DateTime @default(now())
  updatedAt   DateTime @updatedAt
  
  @@index([ruleType])
  @@index([enabled])
}

// 规则类型枚举
enum WorkflowRoleType {
  ORGANIZATIONAL_RELATION  // 组织关系解析
  SYSTEM_ROLE_MAPPING      // 系统角色映射
  FIXED_USERS              // 固定用户列表
}
```

##### 2. 三种规则类型设计

**规则类型1: 组织关系解析（ORGANIZATIONAL_RELATION）**

```typescript
// ruleConfig 配置示例
{
  "relationType": "DIRECT_MANAGER",  // 或 "DEPARTMENT_HEAD"
  "departmentContext": "SUBMITTER_PRIMARY_DEPT", // 或指定 departmentId
  "levels": 1  // 向上几级（默认1）
}

// 实现示例
class OrganizationalRelationResolver implements WorkflowRoleResolver {
  async resolve(config: any, context: WorkflowContext): Promise<string[]> {
    const { relationType, departmentContext, levels = 1 } = config;
    const { submitterUserId } = context;
    
    // 1. 获取部门上下文
    let departmentId: string;
    if (departmentContext === 'SUBMITTER_PRIMARY_DEPT') {
      const userDept = await this.prisma.userDepartment.findFirst({
        where: { userId: submitterUserId, isPrimary: true, leftAt: null },
        select: { departmentId: true }
      });
      departmentId = userDept?.departmentId;
    } else {
      departmentId = departmentContext; // 指定部门ID
    }
    
    if (!departmentId) return [];
    
    // 2. 根据关系类型解析
    switch (relationType) {
      case 'DIRECT_MANAGER':
        return this.resolveDirectManager(submitterUserId, departmentId);
        
      case 'DEPARTMENT_HEAD':
        return this.resolveDepartmentHead(departmentId);
        
      case 'MANAGER_CHAIN':
        return this.resolveManagerChain(submitterUserId, departmentId, levels);
        
      default:
        throw new Error(`Unknown relation type: ${relationType}`);
    }
  }
  
  private async resolveDirectManager(
    userId: string,
    departmentId: string
  ): Promise<string[]> {
    const membership = await this.prisma.userDepartment.findFirst({
      where: { userId, departmentId, leftAt: null },
      select: { managerId: true }
    });
    
    return membership?.managerId ? [membership.managerId] : [];
  }
  
  private async resolveDepartmentHead(departmentId: string): Promise<string[]> {
    const department = await this.prisma.department.findUnique({
      where: { id: departmentId },
      select: { headId: true }
    });
    
    return department?.headId ? [department.headId] : [];
  }
  
  private async resolveManagerChain(
    userId: string,
    departmentId: string,
    levels: number
  ): Promise<string[]> {
    const chain: string[] = [];
    let currentUserId = userId;
    
    for (let i = 0; i < levels; i++) {
      const membership = await this.prisma.userDepartment.findFirst({
        where: { userId: currentUserId, departmentId, leftAt: null },
        select: { managerId: true }
      });
      
      if (!membership?.managerId) break;
      
      chain.push(membership.managerId);
      currentUserId = membership.managerId;
    }
    
    return chain;
  }
}
```

**规则类型2: 系统角色映射（SYSTEM_ROLE_MAPPING）**

```typescript
// ruleConfig 配置示例
{
  "systemRoleCode": "HR_MANAGER",  // 系统角色代码
  "organizationScope": "SUBMITTER_ORG",  // 或 "GLOBAL" 或指定 organizationId
  "selectionMode": "ALL"  // "ALL" 或 "ANY_ONE" 或 "RANDOM_ONE"
}

// 实现示例
class SystemRoleMappingResolver implements WorkflowRoleResolver {
  async resolve(config: any, context: WorkflowContext): Promise<string[]> {
    const { systemRoleCode, organizationScope, selectionMode = 'ALL' } = config;
    const { submitterUserId } = context;
    
    // 1. 获取角色ID
    const role = await this.prisma.role.findUnique({
      where: { code: systemRoleCode },
      select: { id: true }
    });
    
    if (!role) return [];
    
    // 2. 确定组织范围
    let orgId: string | null = null;
    if (organizationScope === 'SUBMITTER_ORG') {
      const userDept = await this.prisma.userDepartment.findFirst({
        where: { userId: submitterUserId, isPrimary: true, leftAt: null },
        select: { organizationId: true }
      });
      orgId = userDept?.organizationId;
    } else if (organizationScope !== 'GLOBAL') {
      orgId = organizationScope; // 指定组织ID
    }
    
    // 3. 查询拥有该角色的用户
    const userRoles = await this.prisma.userRole.findMany({
      where: {
        roleId: role.id,
        organizationId: orgId, // null = 全局角色
        user: {
          status: 'ACTIVE',
          deletedAt: null
        }
      },
      select: { userId: true }
    });
    
    const userIds = userRoles.map(ur => ur.userId);
    
    // 4. 根据选择模式返回
    switch (selectionMode) {
      case 'ALL':
        return userIds;
        
      case 'ANY_ONE':
      case 'RANDOM_ONE':
        return userIds.length > 0 ? [userIds[0]] : [];
        
      default:
        return userIds;
    }
  }
}
```

**规则类型3: 固定用户列表（FIXED_USERS）**

```typescript
// ruleConfig 配置示例
{
  "userIds": ["user-uuid-1", "user-uuid-2"],
  "approvalMode": "ANY_ONE"  // "ANY_ONE" 或 "ALL" 或 "SEQUENTIAL"
}

// 实现示例
class FixedUsersResolver implements WorkflowRoleResolver {
  async resolve(config: any, context: WorkflowContext): Promise<string[]> {
    const { userIds, approvalMode = 'ANY_ONE' } = config;
    
    // 验证用户是否存在且状态为 ACTIVE
    const validUsers = await this.prisma.user.findMany({
      where: {
        id: { in: userIds },
        status: 'ACTIVE',
        deletedAt: null
      },
      select: { id: true }
    });
    
    return validUsers.map(u => u.id);
  }
}
```

##### 3. WorkflowRolesService 实现

```typescript
@Injectable()
export class WorkflowRolesService {
  private resolvers: Map<WorkflowRoleType, WorkflowRoleResolver>;
  
  constructor(
    private readonly prisma: PrismaService,
    private readonly logger: LoggerService
  ) {
    // 注册解析器
    this.resolvers = new Map([
      [WorkflowRoleType.ORGANIZATIONAL_RELATION, new OrganizationalRelationResolver(prisma)],
      [WorkflowRoleType.SYSTEM_ROLE_MAPPING, new SystemRoleMappingResolver(prisma)],
      [WorkflowRoleType.FIXED_USERS, new FixedUsersResolver(prisma)]
    ]);
  }
  
  async resolveWorkflowRole(
    roleId: string,
    context: WorkflowContext
  ): Promise<string[]> {
    // 1. 获取流程角色配置
    const workflowRole = await this.prisma.workflowRole.findUnique({
      where: { id: roleId, enabled: true }
    });
    
    if (!workflowRole) {
      throw new NotFoundException(`Workflow role ${roleId} not found`);
    }
    
    // 2. 获取对应的解析器
    const resolver = this.resolvers.get(workflowRole.ruleType);
    if (!resolver) {
      throw new Error(`No resolver for rule type: ${workflowRole.ruleType}`);
    }
    
    // 3. 执行解析
    try {
      const userIds = await resolver.resolve(workflowRole.ruleConfig, context);
      
      this.logger.log(`Workflow role ${workflowRole.code} resolved to ${userIds.length} users`, {
        roleId,
        userIds,
        context
      });
      
      return userIds;
    } catch (error) {
      this.logger.error(`Failed to resolve workflow role ${workflowRole.code}`, {
        roleId,
        context,
        error: error.message
      });
      
      // 兜底策略（v2.2 计划）
      return this.applyFallbackStrategy(workflowRole, context);
    }
  }
  
  async resolveAllRoles(
    roleIds: string[],
    context: WorkflowContext
  ): Promise<Map<string, string[]>> {
    const results = new Map<string, string[]>();
    
    for (const roleId of roleIds) {
      const userIds = await this.resolveWorkflowRole(roleId, context);
      results.set(roleId, userIds);
    }
    
    return results;
  }
  
  async createWorkflowRole(dto: CreateWorkflowRoleDto): Promise<WorkflowRole> {
    // 验证规则配置
    this.validateRuleConfig(dto.ruleType, dto.ruleConfig);
    
    return this.prisma.workflowRole.create({
      data: {
        code: dto.code,
        name: dto.name,
        description: dto.description,
        ruleType: dto.ruleType,
        ruleConfig: dto.ruleConfig
      }
    });
  }
  
  async testWorkflowRole(
    roleId: string,
    testContext: WorkflowContext
  ): Promise<{ users: User[]; success: boolean; error?: string }> {
    try {
      const userIds = await this.resolveWorkflowRole(roleId, testContext);
      const users = await this.prisma.user.findMany({
        where: { id: { in: userIds } },
        select: {
          id: true,
          username: true,
          displayName: true,
          email: true
        }
      });
      
      return { users, success: true };
    } catch (error) {
      return {
        users: [],
        success: false,
        error: error.message
      };
    }
  }
  
  private validateRuleConfig(ruleType: WorkflowRoleType, config: any): void {
    // 验证配置完整性
    switch (ruleType) {
      case WorkflowRoleType.ORGANIZATIONAL_RELATION:
        if (!config.relationType) {
          throw new BadRequestException('relationType is required');
        }
        break;
        
      case WorkflowRoleType.SYSTEM_ROLE_MAPPING:
        if (!config.systemRoleCode) {
          throw new BadRequestException('systemRoleCode is required');
        }
        break;
        
      case WorkflowRoleType.FIXED_USERS:
        if (!config.userIds || config.userIds.length === 0) {
          throw new BadRequestException('userIds is required and cannot be empty');
        }
        break;
    }
  }
  
  private async applyFallbackStrategy(
    workflowRole: WorkflowRole,
    context: WorkflowContext
  ): Promise<string[]> {
    // v2.2 计划：兜底策略
    // 当前版本：返回空数组，由审批模块处理
    return [];
  }
}
```

##### 4. 与审批流程模块集成

```typescript
// 审批流程模块调用示例
@Injectable()
export class ApprovalProcessService {
  constructor(
    private readonly workflowRolesService: WorkflowRolesService,
    private readonly prisma: PrismaService
  ) {}
  
  async createApprovalProcess(
    dto: CreateApprovalDto,
    submitterUserId: string
  ) {
    // 1. 流程模板包含多个审批节点
    const template = await this.prisma.approvalTemplate.findUnique({
      where: { id: dto.templateId },
      include: {
        nodes: {
          orderBy: { sequence: 'asc' },
          include: { workflowRole: true }
        }
      }
    });
    
    // 2. 构建解析上下文
    const context: WorkflowContext = {
      submitterUserId,
      organizationId: dto.organizationId,
      departmentId: dto.departmentId,
      formData: dto.formData
    };
    
    // 3. 解析所有审批节点的审批人
    const approvalNodes = [];
    
    for (const templateNode of template.nodes) {
      if (templateNode.workflowRoleId) {
        // 动态解析审批人
        const approverIds = await this.workflowRolesService.resolveWorkflowRole(
          templateNode.workflowRoleId,
          context
        );
        
        if (approverIds.length === 0) {
          throw new BadRequestException(
            `无法解析审批节点 "${templateNode.name}" 的审批人，请联系管理员`
          );
        }
        
        approvalNodes.push({
          name: templateNode.name,
          sequence: templateNode.sequence,
          approverIds,  // 固化审批人列表
          approvalMode: templateNode.approvalMode, // ANY_ONE, ALL, SEQUENTIAL
          status: templateNode.sequence === 1 ? 'PENDING' : 'WAITING'
        });
      } else if (templateNode.approverUserIds) {
        // 固定审批人（向后兼容）
        approvalNodes.push({
          name: templateNode.name,
          sequence: templateNode.sequence,
          approverIds: templateNode.approverUserIds,
          approvalMode: templateNode.approvalMode,
          status: templateNode.sequence === 1 ? 'PENDING' : 'WAITING'
        });
      }
    }
    
    // 4. 创建审批流程实例（审批人已固化）
    const process = await this.prisma.approvalProcess.create({
      data: {
        templateId: dto.templateId,
        submitterId: submitterUserId,
        title: dto.title,
        status: 'PENDING',
        nodes: {
          createMany: {
            data: approvalNodes.map(node => ({
              name: node.name,
              sequence: node.sequence,
              approverIds: node.approverIds,
              approvalMode: node.approvalMode,
              status: node.status
            }))
          }
        }
      },
      include: {
        nodes: true
      }
    });
    
    // 5. 通知第一个节点的审批人
    await this.notifyApprovers(process.nodes[0]);
    
    return process;
  }
}
```

##### 5. API 示例

```typescript
@Controller('api/v1/workflow-roles')
@UseGuards(JwtAuthGuard, PermissionsGuard)
export class WorkflowRolesController {
  constructor(
    private readonly workflowRolesService: WorkflowRolesService
  ) {}
  
  @Post()
  @RequirePermissions('workflow:configure')
  async create(@Body() dto: CreateWorkflowRoleDto) {
    return this.workflowRolesService.createWorkflowRole(dto);
  }
  
  @Post(':id/test')
  @RequirePermissions('workflow:configure')
  async test(
    @Param('id') roleId: string,
    @Body() testContext: WorkflowContext
  ) {
    return this.workflowRolesService.testWorkflowRole(roleId, testContext);
  }
  
  @Get()
  @RequirePermissions('workflow:read')
  async findAll() {
    return this.prisma.workflowRole.findMany({
      where: { enabled: true },
      orderBy: { name: 'asc' }
    });
  }
}
```

##### 6. 前端配置界面示例

```typescript
// 创建流程角色表单
function CreateWorkflowRoleForm() {
  const [ruleType, setRuleType] = useState<WorkflowRoleType>('ORGANIZATIONAL_RELATION');
  
  return (
    <Form onSubmit={handleSubmit}>
      <Input name="code" label="角色代码" required />
      <Input name="name" label="角色名称" required />
      
      <Select name="ruleType" label="规则类型" onChange={(v) => setRuleType(v)}>
        <Option value="ORGANIZATIONAL_RELATION">组织关系解析</Option>
        <Option value="SYSTEM_ROLE_MAPPING">系统角色映射</Option>
        <Option value="FIXED_USERS">固定用户列表</Option>
      </Select>
      
      {ruleType === 'ORGANIZATIONAL_RELATION' && (
        <div>
          <Select name="ruleConfig.relationType" label="关系类型">
            <Option value="DIRECT_MANAGER">直属上级</Option>
            <Option value="DEPARTMENT_HEAD">部门负责人</Option>
            <Option value="MANAGER_CHAIN">上级链</Option>
          </Select>
          
          <Select name="ruleConfig.departmentContext" label="部门上下文">
            <Option value="SUBMITTER_PRIMARY_DEPT">提交人主部门</Option>
            {/* 或选择指定部门 */}
          </Select>
        </div>
      )}
      
      {ruleType === 'SYSTEM_ROLE_MAPPING' && (
        <div>
          <Select name="ruleConfig.systemRoleCode" label="系统角色">
            <Option value="HR_MANAGER">HR管理员</Option>
            <Option value="FINANCE_MANAGER">财务经理</Option>
            {/* ... */}
          </Select>
          
          <Select name="ruleConfig.organizationScope" label="组织范围">
            <Option value="SUBMITTER_ORG">提交人所属组织</Option>
            <Option value="GLOBAL">全局</Option>
          </Select>
        </div>
      )}
      
      {ruleType === 'FIXED_USERS' && (
        <UserMultiSelect name="ruleConfig.userIds" label="指定用户" />
      )}
      
      <Button type="submit">创建</Button>
      <Button type="button" onClick={handleTest}>测试规则</Button>
    </Form>
  );
}
```

##### 7. 性能考量

**设计决策**: 流程提交时解析并固化审批人

**优点**:
- ✅ 运行时无需重复解析，性能好
- ✅ 审批人确定后不受组织架构变动影响
- ✅ 审计日志清晰（审批人固化）

**缺点**:
- ⚠️ 组织架构变动后，已创建流程的审批人不会自动更新（这是设计意图）

**缓存策略**:
```typescript
// 流程角色解析结果不缓存（每次流程提交时实时解析）
// 但组织关系数据可以缓存
Redis Key: `user:manager:${userId}:${deptId}`
TTL: 10 minutes
Value: managerId

Redis Key: `dept:head:${deptId}`
TTL: 30 minutes
Value: headId
```

---

## 📊 数据流

### 核心流程：用户登录与权限验证

```mermaid
sequenceDiagram
    participant U as User
    participant F as Frontend
    participant G as API Gateway
    participant Auth as AuthService
    participant LDAP as LDAP Server
    participant DB as Database
    participant Redis as Redis Cache

    U->>F: 1. 输入用户名密码
    F->>G: 2. POST /api/v1/auth/login
    G->>Auth: 3. login(username, password)
    
    Auth->>LDAP: 4. LDAP 认证（优先）
    alt LDAP 认证成功
        LDAP-->>Auth: 5a. 认证成功
    else LDAP 认证失败
        Auth->>DB: 5b. 本地密码验证（回退）
        DB-->>Auth: 5c. 验证结果
    end
    
    Auth->>DB: 6. 检查用户状态 (status = ACTIVE)
    DB-->>Auth: 7. 用户信息
    
    Auth->>Auth: 8. 生成 JWT Token
    Auth-->>G: 9. 返回 Token + 用户信息
    G-->>F: 10. HTTP 200 + Token
    F-->>U: 11. 登录成功，跳转首页
    
    Note over F,U: 后续请求携带 Token
    
    U->>F: 12. 访问需要权限的页面
    F->>G: 13. GET /api/v1/users (Header: Authorization: Bearer {token})
    G->>G: 14. JwtAuthGuard 验证 Token
    G->>Redis: 15. 检查 Token 是否在黑名单
    Redis-->>G: 16. 不在黑名单
    
    G->>G: 17. PermissionsGuard 检查权限
    G->>Redis: 18. 查询权限缓存
    
    alt 缓存命中
        Redis-->>G: 19a. 返回缓存权限
    else 缓存未命中
        G->>DB: 19b. 查询用户权限 (含 organizationId)
        DB-->>G: 19c. 用户权限列表
        G->>Redis: 19d. 写入缓存 (TTL 5min)
    end
    
    G->>G: 20. 校验权限通过
    G->>DB: 21. 执行业务逻辑
    DB-->>G: 22. 返回数据
    G-->>F: 23. HTTP 200 + 数据
    F-->>U: 24. 显示页面
```

### 数据流说明

1. **步骤1-3**: 用户输入凭证，前端调用登录 API
2. **步骤4-5**: 认证服务优先尝试 LDAP 认证，失败则回退到本地密码验证
3. **步骤6-7**: 检查用户状态，只有 ACTIVE 用户可以登录
4. **步骤8-11**: 生成 JWT Token，返回给前端
5. **步骤12-16**: 后续请求携带 Token，先验证 Token 有效性和黑名单
6. **步骤17-20**: PermissionsGuard 检查权限，优先使用缓存
7. **步骤21-24**: 执行业务逻辑，返回数据

---

## 🔧 技术选型

### 后端技术

| 技术 | 版本 | 用途 | 选型原因 |
|------|------|------|---------|
| **NestJS** | 10.x | 后端框架 | 模块化、依赖注入、TypeScript 原生支持 |
| **Prisma** | 5.x | ORM | 类型安全、迁移管理、性能优化 |
| **PostgreSQL** | 16.x | 主数据库 | 可靠性高、支持 JSONB、事务隔离 |
| **Redis** | 7.x | 缓存 | 高性能、支持过期策略、原子操作 |
| **Passport.js** | - | 认证中间件 | 灵活、支持多种策略、与 NestJS 集成 |
| **jsonwebtoken** | - | JWT Token | 标准化、无状态、跨服务 |
| **ldapjs** | - | LDAP 客户端 | Node.js 原生 LDAP 支持 |
| **@microsoft/microsoft-graph-client** | - | Entra ID API | 官方 SDK、类型定义完整 |

### 前端技术

| 技术 | 版本 | 用途 | 选型原因 |
|------|------|------|---------|
| **Next.js** | 14.x | React 框架 | SSR、路由、API Routes、性能优化 |
| **TypeScript** | 5.x | 开发语言 | 类型安全、IDE 支持、重构友好 |
| **Tailwind CSS** | 3.x | UI 样式 | 原子化 CSS、响应式、定制性强 |
| **Headless UI** | - | 无样式组件 | 无障碍、与 Tailwind 完美集成 |
| **Zustand** | - | 全局状态 | 轻量、简单、无需 Provider |
| **TanStack Query** | 5.x | 数据获取 | 缓存、重试、实时更新 |
| **React Hook Form** | - | 表单管理 | 性能好、验证灵活、TS 支持 |
| **Zod** | - | 数据验证 | 类型推导、错误提示、与 Prisma 集成 |

### 数据存储

| 技术 | 版本 | 用途 | 选型原因 |
|------|------|------|---------|
| **PostgreSQL** | 16.x | 主数据库 | ACID、复杂查询、JSONB 支持 |
| **Redis** | 7.x | 缓存 + 黑名单 | 高性能、过期策略、Pub/Sub |

---

## 🔌 集成架构

### 依赖的模块/服务

| 模块/服务 | 集成方式 | 用途 | 影响 |
|----------|---------|------|------|
| **Audit System** | 服务间调用（内部模块） | 记录所有敏感操作的审计日志 | 如不可用，操作无法记录，但不阻塞业务 |
| **LDAP/AD Server** | LDAP 协议 | 用户身份认证 | 如不可用，回退到本地密码认证 |
| **Microsoft Entra ID** | REST API (Graph API) | 用户信息同步 | 如不可用，同步失败，不影响现有用户 |
| **Notification System** | 消息队列（Bull）| 发送用户状态变更通知 | 如不可用，通知丢失，不阻塞业务 |

### 被依赖的模块/服务

| 模块/服务 | 集成方式 | 用途 | 影响 |
|----------|---------|------|------|
| **审批流程模块** | REST API | 调用 WorkflowRoles 解析审批人 | 如不可用，无法创建/推进审批流程 |
| **业务模块（采购、报销等）** | REST API + Guards | 使用本模块的权限校验 | 如不可用，所有业务模块无法鉴权 |
| **前端应用** | REST API + JWT | 所有页面依赖本模块的用户认证 | 如不可用，整个应用无法使用 |

### 集成架构图

```mermaid
graph TD
    A[Organization Module] -->|REST API| B[Audit System]
    A -->|LDAP| C[LDAP/AD Server]
    A -->|Graph API| D[Microsoft Entra ID]
    A -->|MQ| E[Notification System]
    
    F[Approval Module] -->|REST API| A
    G[Purchase Module] -->|Guards| A
    H[Expense Module] -->|Guards| A
    I[Frontend App] -->|REST API + JWT| A
    
    style A fill:#4CAF50,color:#fff
    style F fill:#2196F3,color:#fff
    style G fill:#2196F3,color:#fff
    style H fill:#2196F3,color:#fff
    style I fill:#FF9800,color:#fff
```

---

## 🔐 安全设计

### 认证与授权

- **认证方式**: LDAP/AD（优先） → 本地密码（回退） → JWT Token
- **权限控制**: RBAC（角色） + PBAC（数据范围 scope）
- **v2.1 组织隔离**: 角色和权限按 organizationId 隔离
- **敏感操作**: 需要 `@Sensitive()` 装饰器，记录详细审计日志

### 数据安全

- **敏感数据加密**: 
  - 密码使用 bcrypt 加密（cost 10）
  - LDAP 配置使用环境变量加密存储
  
- **数据脱敏**: 
  - 审计日志中的密码字段自动脱敏
  - 导出用户列表时隐藏敏感字段

- **审计日志**: 
  - 所有 CRUD 操作自动记录
  - 权限变更、状态变更记录详细原因
  - 日志保留 7 年（符合合规要求）

### Token 安全

- **Token 失效策略**:
  ```typescript
  // 用户状态变更时，Token 立即失效
  async handleUserStatusChange(userId: string, newStatus: UserStatus) {
    if (newStatus !== UserStatus.ACTIVE) {
      await this.tokenBlacklistService.addUser(userId);
      await this.permissionCacheService.invalidate(userId);
    }
  }
  ```

- **Token 黑名单**: Redis 存储，TTL = Token 过期时间
- **刷新策略**: Access Token (24h) + Refresh Token (7d)

---

### 登录安全策略 (v2.1.25 简化设计)

#### 1. 失败锁定机制（暂不实现）

**设计决策** (v2.1.25):
- ⚠️ **登录失败锁定机制暂不实现**（简化设计）
- **原因**：
  - 生产环境主要使用 LDAP 认证，LDAP 服务器自带锁定机制
  - 本地用户仅用于测试，风险较低
- **替代措施**：
  - ✅ 本地用户强制使用强密码策略（最小8位 + 至少2种字符类型）
  - ✅ 记录所有登录失败日志，便于事后审计
  - ✅ 建议生产环境优先使用 LDAP/AD 认证
- **未来规划**：
  - 📋 如有需求，可在 v2.2 或后续版本添加

**当前实现**:
```typescript
// 简化的登录流程（无锁定机制）
async login(username: string, password: string, clientIp?: string): Promise<LoginResult> {
  // 1. 查找用户
  const user = await this.findUserByUsername(username);
  
  if (!user) {
    // 记录失败日志
    this.logger.warn(`Login failed: user not found`, { username, clientIp });
    throw new UnauthorizedException('用户名或密码错误');
  }
  
  // 2. 检查用户状态
  if (user.status !== UserStatus.ACTIVE) {
    throw new UnauthorizedException('账号已被停用');
  }
  
  // 3. 验证密码（根据身份源）
  const authenticated = await this.authenticateUser(user, password);
  
  if (!authenticated) {
    // 记录失败日志
    this.logger.warn(`Login failed: invalid password`, { 
      userId: user.id, 
      username, 
      clientIp 
    });
    throw new UnauthorizedException('用户名或密码错误');
  }
  
  // 4. 登录成功，生成 Token
  const token = await this.generateToken(user);
  
  // 5. 记录成功日志
  this.logger.log(`User logged in successfully`, {
    userId: user.id,
    username: user.username,
    source: user.source,
    clientIp
  });
  
  return { user: this.sanitizeUser(user), token };
}
```

#### 2. 安全审计（当前实现）

**记录内容**:
- 登录成功/失败事件
- 密码修改事件
- 状态变更事件
- 权限变更事件

**日志示例**:
```typescript
// 登录失败
{
  event: 'LOGIN_FAILED',
  username: 'zhangsan',
  clientIp: '192.168.1.100',
  userAgent: 'Mozilla/5.0...',
  reason: 'INVALID_PASSWORD',
  timestamp: '2026-01-05T10:30:00Z'
}

// 登录成功
{
  event: 'LOGIN_SUCCESS',
  userId: 'uuid',
  username: 'zhangsan',
  source: 'LDAP',
  clientIp: '192.168.1.100',
  timestamp: '2026-01-05T10:30:00Z'
}
```

---

## 🎨 前端架构

### 目录结构

```
frontend/src/app/organization/
├── page.tsx                         # 组织管理首页
├── layout.tsx                       # 布局（侧边栏导航）
│
├── users/                           # 用户管理
│   ├── page.tsx                     # 用户列表页
│   ├── [id]/
│   │   ├── page.tsx                 # 用户详情页
│   │   └── edit/
│   │       └── page.tsx             # 用户编辑页
│   ├── components/
│   │   ├── UserList.tsx             # 用户列表组件
│   │   ├── UserForm.tsx             # 用户表单组件
│   │   ├── UserDetail.tsx           # 用户详情组件
│   │   ├── DepartmentSelector.tsx   # 部门选择器
│   │   └── RoleAssign.tsx           # 角色分配组件
│   └── api/
│       └── users.ts                 # 用户 API 封装
│
├── departments/                     # 部门管理
│   ├── page.tsx                     # 部门树页面
│   ├── components/
│   │   ├── DepartmentTree.tsx       # 部门树组件
│   │   ├── DepartmentForm.tsx       # 部门表单
│   │   └── OrgChart.tsx             # 组织架构图
│   └── api/
│       └── departments.ts
│
├── roles/                           # 角色管理
│   ├── page.tsx                     # 角色列表
│   ├── [id]/
│   │   └── permissions/
│   │       └── page.tsx             # 权限配置页
│   ├── components/
│   │   ├── RoleList.tsx
│   │   ├── RoleForm.tsx
│   │   └── PermissionMatrix.tsx     # 权限矩阵
│   └── api/
│       └── roles.ts
│
├── sync/                            # 外部同步
│   ├── page.tsx                     # 同步管理页
│   ├── components/
│   │   ├── EntraIdSync.tsx          # Entra ID 同步
│   │   ├── LdapConfig.tsx           # LDAP 配置
│   │   └── SyncHistory.tsx          # 同步历史
│   └── api/
│       └── sync.ts
│
└── stores/                          # Zustand Stores
    ├── userStore.ts                 # 当前登录用户状态
    ├── permissionStore.ts           # 权限缓存
    └── orgStore.ts                  # 组织架构缓存
```

### 状态管理

- **全局状态 (Zustand)**: 
  - `userStore` - 当前登录用户信息、权限
  - `orgStore` - 组织架构缓存（部门树）
  - `permissionStore` - 权限判断辅助

- **本地状态**: 
  - 组件内部状态（React `useState`）
  - 表单状态（React Hook Form）

- **服务端状态 (TanStack Query)**: 
  - 用户列表、部门树、角色列表等
  - 自动缓存、重试、实时更新

---

## 🏛️ 后端架构

### 目录结构

```
backend/src/modules/organization/
├── organization.module.ts              # NestJS 模块定义
├── controllers/                        # 控制器层
│   ├── users.controller.ts             # 用户管理 HTTP 请求处理
│   ├── departments.controller.ts       # 部门管理
│   ├── roles.controller.ts             # 角色管理
│   ├── organizations.controller.ts     # 组织管理
│   ├── regions.controller.ts           # 区域管理
│   └── sync.controller.ts              # 外部同步
├── services/                           # 服务层
│   ├── users.service.ts                # 用户业务逻辑
│   ├── departments.service.ts          # 部门业务逻辑
│   ├── roles.service.ts                # 角色业务逻辑
│   ├── permissions.service.ts          # 权限业务逻辑
│   ├── organizations.service.ts        # 组织业务逻辑
│   ├── workflow-roles.service.ts       # 流程角色解析
│   ├── ldap.service.ts                 # LDAP 认证
│   └── entra.service.ts                # Entra ID 同步
├── dto/                                # 数据传输对象
│   ├── users/
│   │   ├── create-user.dto.ts          # 创建用户请求 DTO
│   │   ├── update-user.dto.ts          # 更新用户请求 DTO
│   │   └── query-users.dto.ts          # 查询用户请求 DTO
│   ├── departments/
│   │   ├── create-department.dto.ts
│   │   └── update-department.dto.ts
│   └── roles/
│       ├── assign-role.dto.ts          # 分配角色 DTO (v2.1)
│       └── assign-permissions.dto.ts
├── guards/                             # 守卫
│   ├── auth.guard.ts                   # JWT 认证验证
│   ├── permissions.guard.ts            # 权限检查 (v2.1)
│   └── roles.guard.ts                  # 角色检查
├── decorators/                         # 自定义装饰器
│   ├── current-user.decorator.ts       # 当前用户装饰器
│   ├── require-permissions.decorator.ts # 权限装饰器
│   └── sensitive.decorator.ts          # 敏感操作装饰器
├── interceptors/                       # 拦截器
│   └── transform.interceptor.ts        # 响应数据转换
├── constants/                          # 常量
│   ├── permissions.constants.ts        # 权限常量定义
│   ├── status.constants.ts             # 状态常量
│   └── error-codes.constants.ts        # 错误码定义
├── types/                              # 类型定义
│   ├── user.types.ts                   # 用户相关类型
│   ├── role.types.ts                   # 角色相关类型
│   └── permission.types.ts             # 权限相关类型
└── tests/                              # 测试文件
    ├── users.controller.spec.ts
    ├── users.service.spec.ts
    ├── roles.service.spec.ts
    └── permissions.guard.spec.ts
```

### 分层架构设计

#### Controller 层（控制器层）

**职责**:
- 处理 HTTP 请求和响应
- 参数验证和转换
- 调用 Service 层处理业务
- **不包含**业务逻辑

**示例 - UsersController**:
```typescript
@Controller('api/v1/users')
@UseGuards(JwtAuthGuard, PermissionsGuard)
export class UsersController {
  constructor(
    private readonly usersService: UsersService,
    private readonly logger: LoggerService
  ) {}
  
  @Get()
  @ApiOperation({ summary: '获取用户列表' })
  @RequirePermissions('user:read')
  async findAll(@Query() query: QueryUsersDto, @CurrentUser() user: User) {
    const organizationId = user.organizationId; // v2.1: 组织上下文
    return this.usersService.findAll(query, organizationId);
  }
  
  @Get(':id')
  @ApiOperation({ summary: '获取用户详情' })
  @RequirePermissions('user:read')
  async findOne(@Param('id') id: string) {
    return this.usersService.findOne(id);
  }
  
  @Post()
  @RequirePermissions('user:create')
  @Sensitive() // 敏感操作，记录详细审计日志
  @ApiOperation({ summary: '创建用户' })
  async create(
    @Body() dto: CreateUserDto,
    @CurrentUser() user: User
  ) {
    return this.usersService.create(dto, user.id);
  }
  
  @Patch(':id')
  @RequirePermissions('user:update')
  @Sensitive()
  @ApiOperation({ summary: '更新用户' })
  async update(
    @Param('id') id: string,
    @Body() dto: UpdateUserDto,
    @CurrentUser() user: User
  ) {
    return this.usersService.update(id, dto, user.id);
  }
  
  @Post(':id/roles')
  @RequirePermissions('role:assign')
  @Sensitive()
  @ApiOperation({ summary: '分配角色（v2.1支持组织隔离）' })
  async assignRoles(
    @Param('id') userId: string,
    @Body() dto: AssignRoleDto,
    @CurrentUser() user: User
  ) {
    // v2.1: 支持指定 organizationId
    return this.usersService.assignRoles(userId, dto, user.id);
  }
  
  @Patch(':id/status')
  @RequirePermissions('user:update')
  @Sensitive()
  @ApiOperation({ summary: '更新用户状态' })
  async updateStatus(
    @Param('id') id: string,
    @Body() dto: UpdateUserStatusDto,
    @CurrentUser() user: User
  ) {
    return this.usersService.updateStatus(id, dto.status, user.id);
  }
}
```

#### Service 层（服务层）

**职责**:
- 核心业务逻辑实现
- 事务管理
- 数据验证和处理
- 调用其他 Service
- **独立于** HTTP 层，可复用

**示例 - UsersService**:
```typescript
@Injectable()
export class UsersService {
  constructor(
    private readonly prisma: PrismaService,
    private readonly permissionsService: PermissionsService,
    private readonly logger: LoggerService,
    private readonly eventEmitter: EventEmitter2,
    private readonly cacheManager: CacheManager
  ) {}
  
  async create(dto: CreateUserDto, operatorId: string) {
    // 1. 业务验证
    await this.validateBusinessRules(dto);
    
    // 2. 检查唯一性
    await this.checkUniqueness(dto.username, dto.email);
    
    // 3. 数据创建（事务）
    const result = await this.prisma.$transaction(async (tx) => {
      // 创建用户
      const user = await tx.user.create({
        data: {
          username: dto.username,
          email: dto.email,
          displayName: dto.displayName,
          password: await this.hashPassword(dto.password),
          status: UserStatus.ACTIVE,
          createdBy: operatorId
        }
      });
      
      // 添加部门归属
      await tx.userDepartment.create({
        data: {
          userId: user.id,
          departmentId: dto.departmentId,
          organizationId: dto.organizationId, // v2.0 冗余字段
          positionId: dto.positionId,
          managerId: dto.managerId,
          isPrimary: true,
          joinedAt: new Date()
        }
      });
      
      return user;
    });
    
    // 4. 触发事件
    this.eventEmitter.emit('user.created', {
      userId: result.id,
      operatorId
    });
    
    // 5. 记录日志
    this.logger.log(`User created: ${result.id}`, {
      userId: result.id,
      operatorId
    });
    
    return result;
  }
  
  async findAll(query: QueryUsersDto, organizationId?: string) {
    const { page = 1, limit = 20, status, search, departmentId } = query;
    
    // 构建查询条件
    const where: any = {
      deletedAt: null
    };
    
    // v2.1: 组织级权限隔离
    if (organizationId) {
      where.userDepartments = {
        some: { organizationId }
      };
    }
    
    if (status) where.status = status;
    if (search) {
      where.OR = [
        { username: { contains: search } },
        { displayName: { contains: search } },
        { email: { contains: search } }
      ];
    }
    if (departmentId) {
      where.userDepartments = {
        some: { departmentId, leftAt: null }
      };
    }
    
    // 并发查询
    const [items, total] = await Promise.all([
      this.prisma.user.findMany({
        where,
        skip: (page - 1) * limit,
        take: limit,
        orderBy: { createdAt: 'desc' },
        include: {
          userDepartments: {
            where: { leftAt: null },
            include: {
              department: true,
              position: true
            }
          }
        }
      }),
      this.prisma.user.count({ where })
    ]);
    
    return {
      items,
      total,
      page,
      limit,
      totalPages: Math.ceil(total / limit)
    };
  }
  
  async assignRoles(
    userId: string,
    dto: AssignRoleDto,
    operatorId: string
  ) {
    // v2.1: 支持按组织分配角色
    const { roleAssignments } = dto;
    
    await this.prisma.$transaction(async (tx) => {
      // 清除旧的角色（同组织）
      for (const assignment of roleAssignments) {
        await tx.userRole.deleteMany({
          where: {
            userId,
            roleId: assignment.roleId,
            organizationId: assignment.organizationId
          }
        });
        
        // 添加新角色
        await tx.userRole.create({
          data: {
            userId,
            roleId: assignment.roleId,
            organizationId: assignment.organizationId // v2.1 核心
          }
        });
      }
    });
    
    // 清理权限缓存
    await this.permissionsService.invalidateUserPermissions(userId);
    
    // 记录审计日志
    this.logger.log(`Roles assigned to user ${userId}`, {
      userId,
      roleAssignments,
      operatorId
    });
    
    this.eventEmitter.emit('user.roles.assigned', {
      userId,
      roleAssignments,
      operatorId
    });
  }
  
  async updateStatus(userId: string, newStatus: UserStatus, operatorId: string) {
    // 1. 获取当前用户
    const user = await this.prisma.user.findUnique({
      where: { id: userId },
      include: { userRoles: { include: { role: true } } }
    });
    
    if (!user) {
      throw new NotFoundException('用户不存在');
    }
    
    // 2. 验证状态流转
    await this.validateStatusTransition(user.status, newStatus);
    
    // 3. 特殊检查
    if (newStatus !== UserStatus.ACTIVE) {
      // 检查是否是最后一个管理员
      await this.checkLastAdminProtection(userId);
      // 不能停用自己
      if (userId === operatorId) {
        throw new BadRequestException('不能停用自己的账号');
      }
    }
    
    // 4. 更新状态（事务）
    const result = await this.prisma.$transaction(async (tx) => {
      const updated = await tx.user.update({
        where: { id: userId },
        data: {
          status: newStatus,
          terminatedAt: newStatus === UserStatus.TERMINATED ? new Date() : null
        }
      });
      
      // 如果是离职，更新所有部门归属
      if (newStatus === UserStatus.TERMINATED) {
        await tx.userDepartment.updateMany({
          where: { userId, leftAt: null },
          data: { leftAt: new Date() }
        });
      }
      
      return updated;
    });
    
    // 5. Token 失效
    if (newStatus !== UserStatus.ACTIVE) {
      await this.invalidateUserTokens(userId);
      await this.permissionsService.invalidateUserPermissions(userId);
    }
    
    // 6. 记录日志
    this.logger.log(`User status changed: ${user.status} -> ${newStatus}`, {
      userId,
      oldStatus: user.status,
      newStatus,
      operatorId
    });
    
    return result;
  }
  
  private async validateBusinessRules(dto: CreateUserDto) {
    // 验证部门是否存在
    const department = await this.prisma.department.findUnique({
      where: { id: dto.departmentId }
    });
    if (!department) {
      throw new BadRequestException('部门不存在');
    }
    
    // 验证上级是否存在且同部门
    if (dto.managerId) {
      const manager = await this.prisma.userDepartment.findFirst({
        where: {
          userId: dto.managerId,
          departmentId: dto.departmentId,
          leftAt: null
        }
      });
      if (!manager) {
        throw new BadRequestException('上级必须是同部门成员');
      }
    }
  }
  
  private async checkUniqueness(username: string, email: string) {
    const existing = await this.prisma.user.findFirst({
      where: {
        OR: [{ username }, { email }],
        deletedAt: null
      }
    });
    
    if (existing) {
      if (existing.username === username) {
        throw new BadRequestException('用户名已被使用');
      }
      if (existing.email === email) {
        throw new BadRequestException('邮箱已被使用');
      }
    }
  }
  
  private async validateStatusTransition(from: UserStatus, to: UserStatus) {
    // 实现状态机验证逻辑
    // 参见 04-state-machine.md
  }
  
  private async checkLastAdminProtection(userId: string) {
    // 检查是否是最后一个管理员
    // 实现逻辑...
  }
}
```

#### DTO 层（数据传输对象）

**职责**:
- 定义 API 请求/响应格式
- 数据验证规则
- 自动生成 API 文档

**示例 - CreateUserDto**:
```typescript
export class CreateUserDto {
  @ApiProperty({ description: '用户名', example: 'zhangsan' })
  @IsString()
  @MinLength(3, { message: '用户名至少3个字符' })
  @MaxLength(50, { message: '用户名最多50个字符' })
  @Matches(/^[a-zA-Z0-9_]+$/, { message: '用户名只能包含字母、数字和下划线' })
  username: string;
  
  @ApiProperty({ description: '显示名称', example: '张三' })
  @IsString()
  @MinLength(1)
  @MaxLength(100)
  displayName: string;
  
  @ApiProperty({ description: '邮箱', example: 'zhang@ff.com' })
  @IsEmail({}, { message: '邮箱格式不正确' })
  email: string;
  
  @ApiPropertyOptional({ description: '电话' })
  @IsString()
  @IsOptional()
  @Matches(/^1[3-9]\d{9}$/, { message: '手机号格式不正确' })
  phone?: string;
  
  @ApiProperty({ description: '密码' })
  @IsString()
  @MinLength(8, { message: '密码至少8个字符' })
  @MaxLength(50)
  password: string;
  
  @ApiProperty({ description: '部门ID' })
  @IsUUID()
  departmentId: string;
  
  @ApiProperty({ description: '组织ID (v2.0)' })
  @IsUUID()
  organizationId: string;
  
  @ApiProperty({ description: '岗位ID' })
  @IsUUID()
  positionId: string;
  
  @ApiPropertyOptional({ description: '直属上级ID' })
  @IsUUID()
  @IsOptional()
  managerId?: string;
  
  @ApiPropertyOptional({ description: '默认区域' })
  @IsString()
  @IsOptional()
  defaultRegion?: string;
}
```

**示例 - AssignRoleDto (v2.1)**:
```typescript
export class RoleAssignmentDto {
  @ApiProperty({ description: '角色ID' })
  @IsUUID()
  roleId: string;
  
  @ApiProperty({ description: '组织ID（null表示全局角色）' })
  @IsUUID()
  @IsOptional()
  organizationId?: string | null;
}

export class AssignRoleDto {
  @ApiProperty({ 
    description: '角色分配列表（v2.1支持组织隔离）',
    type: [RoleAssignmentDto]
  })
  @IsArray()
  @ValidateNested({ each: true })
  @Type(() => RoleAssignmentDto)
  roleAssignments: RoleAssignmentDto[];
}
```

### 模块依赖注入

```typescript
@Module({
  imports: [
    // 依赖的其他模块
    AuthModule,           // 认证模块
    PermissionModule,     // 权限模块
    AuditModule,          // 审计模块
    EventEmitterModule,   // 事件模块
    CacheModule,          // 缓存模块
  ],
  controllers: [
    UsersController,
    DepartmentsController,
    RolesController,
    OrganizationsController,
    RegionsController,
    SyncController,
  ],
  providers: [
    UsersService,
    DepartmentsService,
    RolesService,
    PermissionsService,
    OrganizationsService,
    WorkflowRolesService,
    LdapService,
    EntraService,
    // Guards (如果是模块级)
    PermissionsGuard,
  ],
  exports: [
    UsersService,
    DepartmentsService,
    RolesService,
    PermissionsService,
    WorkflowRolesService, // 暴露给审批模块使用
  ],
})
export class OrganizationModule {}
```

### 中间件与拦截器

#### 全局拦截器
- **ResponseTransformInterceptor**: 统一响应格式
  ```typescript
  {
    "success": true,
    "data": {...},
    "timestamp": "2025-12-26T12:00:00Z"
  }
  ```
- **LoggingInterceptor**: 请求日志记录
- **ErrorInterceptor**: 错误处理和转换

#### 模块级守卫
- **JwtAuthGuard**: JWT 认证验证
- **PermissionsGuard**: 权限检查（v2.1 支持 organizationId）
- **RolesGuard**: 角色检查
- **RateLimitGuard**: 限流保护（防止 API 滥用）

#### 自定义装饰器
```typescript
// 权限装饰器
@RequirePermissions('user:create:organization')

// 当前用户装饰器
@CurrentUser() user: User

// 敏感操作装饰器（记录详细审计日志）
@Sensitive()

// API 文档装饰器
@ApiOperation({ summary: '创建用户' })
@ApiResponse({ status: 201, description: '创建成功' })
```

### 错误处理

```typescript
// 业务异常
throw new BusinessException(
  '库存不足',
  ErrorCode.INSUFFICIENT_STOCK
);

// HTTP 异常
throw new NotFoundException('用户不存在');
throw new BadRequestException('参数错误');
throw new UnauthorizedException('未授权');
throw new ForbiddenException('权限不足');

// 状态转换异常
throw new BusinessException(
  '不允许从 TERMINATED 流转到 ACTIVE',
  ErrorCode.INVALID_STATE_TRANSITION
);
```

### 事件驱动

```typescript
// 发布事件
this.eventEmitter.emit('user.created', {
  userId: user.id,
  operatorId
});

this.eventEmitter.emit('user.status.changed', {
  userId,
  oldStatus,
  newStatus,
  operatorId
});

// 监听事件
@OnEvent('user.created')
async handleUserCreated(payload: { userId: string; operatorId: string }) {
  // 发送欢迎邮件
  await this.notificationService.sendWelcomeEmail(payload.userId);
  
  // 初始化用户配置
  await this.userConfigService.initDefaults(payload.userId);
}

@OnEvent('user.status.changed')
async handleUserStatusChanged(payload: any) {
  // 如果用户被停用/离职，清理 Token
  if (payload.newStatus !== UserStatus.ACTIVE) {
    await this.authService.revokeUserTokens(payload.userId);
  }
}
```

---

## 🗄️ 数据库设计

### Schema 结构

```
platform_iam/                    # 身份认证与授权
├── users                        # 用户表
├── roles                        # 系统角色
├── permissions                  # 权限表
├── user_role_rel                # 用户-角色关联（含 organizationId）
├── role_permission_rel          # 角色-权限关联
└── workflow_roles               # 流程角色

corp_hr/                         # 组织架构
├── organizations                # 组织表（v2.0）
├── departments                  # 部门表
├── regions                      # 区域表
├── positions                    # 岗位表
├── user_departments             # 用户-部门关联（多部门）
├── organization_regions         # 组织-区域关联
└── department_regions           # 部门-区域关联（运营覆盖）
```

> **详细数据模型**: 参见 [数据模型文档](./06-data-model.md)

### 核心表设计

#### users (用户表)

**字段说明**:
- `id`: UUID 主键
- `status`: 用户状态（ACTIVE, INACTIVE, SUSPENDED, TERMINATED）
- `source`: 用户来源（LOCAL, LDAP, ENTRA）
- `externalId`: 外部系统 ID（LDAP DN / Entra Object ID）
- `defaultRegion`: 用户默认区域

#### user_role_rel (用户-角色关联 - v2.1)

**v2.1 核心变更**:
```sql
CREATE TABLE platform_iam.user_role_rel (
  id UUID PRIMARY KEY,
  user_id UUID NOT NULL,
  role_id UUID NOT NULL,
  organization_id UUID,  -- ✅ v2.1 新增：组织隔离
  region VARCHAR(10),    -- ⚠️ 已废弃，保留兼容
  created_at TIMESTAMPTZ NOT NULL,
  
  UNIQUE(user_id, role_id, organization_id),
  FOREIGN KEY (organization_id) REFERENCES corp_hr.organizations(id)
);
```

**业务规则**:
- `organization_id = NULL` → 全局角色（跨所有组织）
- `organization_id = {uuid}` → 组织角色（仅该组织有效）

#### user_departments (用户-部门关联)

**字段说明**:
- `user_id`, `department_id`: 多对多关联
- `organization_id`: 冗余字段，性能优化（v2.0）
- `position_id`: 用户在该部门的岗位
- `manager_id`: 用户在该部门的直属上级（必须同部门）
- `is_primary`: 是否为主部门（每用户只有一个）
- `left_at`: 离开时间（null = 仍在职）

### 索引设计

```sql
-- 用户查询性能优化
CREATE INDEX idx_users_status ON platform_iam.users(status) 
  WHERE deleted_at IS NULL;

CREATE INDEX idx_users_external_id ON platform_iam.users(external_id);

-- 权限查询性能优化（v2.1）
CREATE INDEX idx_user_role_user_org ON platform_iam.user_role_rel(user_id, organization_id);

-- 组织架构查询优化
CREATE INDEX idx_user_departments_user_id ON corp_hr.user_departments(user_id) 
  WHERE left_at IS NULL;

CREATE INDEX idx_user_departments_dept_org ON corp_hr.user_departments(department_id, organization_id);

CREATE INDEX idx_departments_parent_id ON corp_hr.departments(parent_id);
```

---

## ⚡ 性能设计

### 性能指标

| 指标 | 目标值 | 当前值 | 说明 |
|------|--------|--------|------|
| **API 响应时间** | < 200ms | ~150ms | P95，包含权限校验 |
| **权限查询** | < 50ms | ~30ms | 使用缓存后 |
| **部门树查询** | < 300ms | ~250ms | 1000+ 部门 |
| **并发用户数** | ≥ 1000 | 1200 | 峰值 QPS |
| **Token 验证** | < 10ms | ~5ms | Redis 查询 |

### 性能优化策略

#### 1. 缓存策略

| 缓存项 | Redis Key | TTL | 失效触发 |
|--------|----------|-----|----------|
| 用户权限 | `perm:{userId}:{orgId}` | 5min | 角色变更、权限变更 |
| 部门树 | `dept:tree:{orgId}` | 30min | 部门 CRUD |
| 用户信息 | `user:info:{userId}` | 10min | 用户更新 |
| Token 黑名单 | `token:bl:{userId}` | Token 过期时间 | 用户状态变更 |

**实现示例**:
```typescript
@Cacheable('user:permissions:{0}:{1}', 300) // TTL 5min
async getUserPermissions(userId: string, orgId?: string): Promise<string[]> {
  // 查询逻辑
}
```

#### 2. 数据库优化

- **索引优化**: 为常用查询字段建立索引（见上方索引设计）
- **查询优化**: 
  - 避免 N+1 查询（Prisma include）
  - 使用 `select` 限制返回字段
  - 批量查询代替循环查询

- **连接池**: 
  ```typescript
  datasources db {
    provider = "postgresql"
    url      = env("DATABASE_URL")
    pool     = 20  // 连接池大小
  }
  ```

#### 3. 前端优化

- **懒加载**: 大表单分步加载，减少首屏时间
- **虚拟滚动**: 长列表（1000+ 条）使用 `react-window`
- **代码分割**: 按页面动态导入组件
- **缓存策略**: TanStack Query 自动缓存 API 响应

---

## 🔄 可扩展性设计

### 横向扩展

- **无状态 API**: 所有状态存储在 Redis/PostgreSQL，支持多实例部署
- **负载均衡**: Nginx/ALB 分发请求到多个 NestJS 实例
- **数据库读写分离**: 主库（写） + 从库（读），读流量分流

### 纵向扩展

- **数据库性能升级**: 增加 PostgreSQL CPU/内存
- **Redis 集群**: 单机 → 主从 → 哨兵 → 集群
- **连接池优化**: 根据负载调整连接池大小

### 模块化设计

- **新增权限**: 在 `permissions` 表插入新记录，无需修改代码
- **新增角色**: 通过 API 动态创建，关联权限
- **新增流程角色**: 实现 `WorkflowRoleResolver` 接口
- **新增外部同步**: 实现 `ExternalSyncProvider` 接口

---

## 📊 监控与运维

### 监控指标

**系统指标**:
- CPU 使用率 < 70%
- 内存使用率 < 80%
- 磁盘使用率 < 85%

**业务指标**:
- 每日新增用户数
- 登录成功率 > 99%
- 权限查询 P95 延迟 < 50ms
- API 错误率 < 0.1%

**告警阈值**:
- API 响应时间 P95 > 500ms → 警告
- API 错误率 > 1% → 告警
- 数据库连接数 > 80% → 警告
- Redis 内存使用率 > 80% → 警告

### 日志设计

**关键操作日志**:
```typescript
logger.info('User login successful', {
  module: 'organization',
  operation: 'login',
  userId: user.id,
  username: user.username,
  ip: request.ip,
  userAgent: request.headers['user-agent'],
  duration: Date.now() - startTime
});
```

**日志级别**:
- `ERROR` - 系统错误、异常（需要立即处理）
- `WARN` - 业务警告、降级（如 LDAP 认证失败回退）
- `INFO` - 关键操作（登录、权限变更、状态变更）
- `DEBUG` - 详细调试信息（开发环境）

---

## ⚠️ 风险与挑战

### 技术风险

| 风险 | 影响 | 概率 | 缓解措施 |
|------|------|------|---------|
| LDAP 服务不可用 | 用户无法登录 | 中 | 本地密码认证回退 |
| Redis 缓存失效 | 性能下降 20% | 低 | 直接查询数据库，限流保护 |
| PostgreSQL 主库故障 | 服务不可用 | 低 | 主从切换（RTO < 5min） |
| 权限计算性能瓶颈 | 响应时间超标 | 低 | 缓存优化、数据库索引优化 |

### 依赖风险

| 依赖 | 风险 | 影响 | 备选方案 |
|------|------|------|---------|
| LDAP/AD Server | 网络故障、服务宕机 | 认证失败 | 本地密码认证 |
| Entra ID API | API 限流、服务不可用 | 同步失败 | 手动同步、延迟重试 |
| Audit System | 服务不可用 | 审计日志丢失 | 降级：本地日志记录 |

---

## 📝 待决策问题

### Open Questions

1. **Q**: 组织级权限隔离后，如何处理"跨组织查看"的需求（如集团 CEO 查看所有组织）？  
   **A**: 设计"全局角色"（organizationId = null），拥有跨组织权限。CEO 分配全局管理员角色。

2. **Q**: 用户同时属于多个组织时，前端如何切换当前工作组织？  
   **A**: 前端提供"组织切换器"，切换后重新获取该组织的权限和数据。后端 API 通过 `x-organization-id` Header 或 Query 参数接收。

3. **Q**: 部门主管（headId）与直属上级（managerId）的区别？  
   **A**: `headId` 是部门负责人（一个），`managerId` 是个人的直属上级（每个归属一个）。部门主管不一定是所有人的上级。

4. **Q**: 流程角色解析的性能如何保证（大量规则时）？  
   **A**: 流程角色解析在审批流程创建时执行一次并固化，不影响运行时性能。

---

## 🔗 相关文档

- [产品需求文档](./01-prd.md) - 业务背景和功能规格
- [用户旅程](./02-user-journey.md) - 用户操作场景
- [状态机](./04-state-machine.md) - 状态流转规则
- [UI 交互规范](./05-ui-interaction-spec.md) - 前端交互设计
- [数据模型](./06-data-model.md) - 数据库表设计
- [API 文档](./07-api.md) - 接口定义和示例
- [变更日志](./99-changelog.md) - 版本变更记录

---

## v2.4 SSO 登录架构（Entra ID OIDC）

> **关联工单**: #334  
> **版本**: v2.4  
> **状态**: 实现中

### 决策背景与变更

v2.1.25 版本曾在本节描述「认证降级策略」：Entra 同步用户由于 SSO 未实现，被迫走 LDAP → 本地密码 fallback。该方案随 v2.4 落地 OIDC 授权码流程后**正式终止**，本节改写为 SSO 登录架构。

**v2.4 核心变更**：

- **推翻** v2.1.1 「Entra 仅同步、不作为身份源」的历史决策
- **新增** Entra ID 作为 SSO 身份源，与本地密码登录通道**双通道并存**
- **保留** 本地密码登录、LDAP 认证路径 100% 不变（已有用户行为零影响）
- **保留** `entra.service.ts` 现有 ROPC 流程（OIDC 与 ROPC 并存，是否下线 ROPC 由二期 SCIM 工单评估）

### OIDC 实现库

**`openid-client`（OpenID Foundation 官方，业界事实标准）**——自带 discovery / JWKS 自动 rotation / PKCE / nonce / clock skew ±5min。**不**采用 `passport-azure-ad`（微软已弃用），**不**手写 `jose` 自行拼装 OIDC 流程。

### OIDC 授权码流程（含 PKCE 必须）

```mermaid
sequenceDiagram
    autonumber
    actor U as User (Browser)
    participant F as Frontend (Next.js)
    participant B as Backend (NestJS)
    participant E as Entra ID (Microsoft)

    U->>F: 1. 访问 /login 点「使用 Microsoft 登录」
    F->>B: 2. GET /api/v1/auth/sso/start?redirect=<safe-relative-path>
    B->>B: 3. 生成 state / nonce / code_verifier（crypto.randomBytes(32) → base64url）
    B->>B: 4. 写 4 个 HttpOnly cookie: sso_state / sso_nonce / sso_redirect / sso_code_verifier（TTL 15min, Secure, SameSite=Lax）
    B-->>U: 5. 302 → Entra authorize endpoint（含 code_challenge / state / nonce）
    U->>E: 6. 跳转 Microsoft，输入凭据 + MFA / 条件访问
    alt Entra 用户取消 / 拒绝
        E-->>U: 6a. 302 回 AZURE_REDIRECT_URI（带 error=access_denied / consent_required / ...）
        U->>B: 6b. GET /auth/sso/callback?error=access_denied&...
        B-->>U: 6c. 302 → /login?ssoError=SSO_USER_CANCELLED（或 SSO_CONSENT_REQUIRED / SSO_PROVIDER_REJECTED）
    else 正常分支
        E-->>U: 7. 302 回 AZURE_REDIRECT_URI（带 code + state）
        U->>B: 8. GET /auth/sso/callback?code=...&state=...
        B->>B: 9. 校验 state（对比 sso_state cookie）；过期 / 不匹配 → 401 SSO_TOKEN_INVALID
        B->>E: 10. POST token endpoint（code + code_verifier + client_secret）
        alt Entra 返回 invalid_grant / 5xx
            E-->>B: 10a. error=invalid_grant or 5xx
            B-->>U: 10b. 302 → /login?ssoError=SSO_TOKEN_INVALID 或 SSO_PROVIDER_UNAVAILABLE
        else 成功
            E-->>B: 11. 返回 id_token + access_token
            B->>B: 12. 校验 id_token：签名（JWKS 5min 缓存 + kid miss 重拉 + 30s cooldown） / iss（discovery metadata.issuer 模板 {tenantid} 替换后字符串相等） / aud=AZURE_CLIENT_ID / nonce vs cookie / exp ±5min / tid claim
            B->>B: 13. email lower-case → User 查找（事务内）
            B->>B: 14. JIT 建号 / 绑定回填 / LDAP 升级 / 冲突拒绝（见下方流程图，事务内）
            B->>B: 15. status 检查（!= ACTIVE → 403 IAM_USER_SUSPENDED）
            B->>B: 16. 写审计事件（事务内）
            B->>B: 17. 事务提交 → 签发本系统 JWT（Access 24h / Refresh 7d）
            B->>B: 18. 清除 4 个 sso_* cookie
            B-->>U: 19. 302 → ${sso_redirect}#accessToken=<jwt>&refreshToken=<jwt>（默认 /overview）
            U->>F: 20. 前端 /sso/landing 路由读 location.hash → 写 localStorage → history.replaceState('','', sso_redirect path) 清 hash → 跳业务页
        end
    end
```

**callback 响应协议（强约定）**：

- **成功**：302 至 `${sso_redirect}#accessToken=<jwt>&refreshToken=<jwt>`；前端 `/sso/landing` 路由读 `location.hash` → 写 localStorage（与现有 password 登录一致的存储位置） → `history.replaceState('', '', <sso_redirect 的 pathname+query>)` 清掉 hash → `useRouter().push(<sso_redirect>)`
- **失败**：302 至 `/login?ssoError=<CODE>`；登录页 `useEffect` 读 `?ssoError=` query → `showToast(t.auth.sso.error[<CODE>])` → `history.replaceState('', '', '/login')` 清 query 防重复触发
- **不**使用 JSON 响应体（302 + body 浏览器不暴露给 JS）
- **不**使用 HttpOnly Cookie 存 JWT（不兼容现有 localStorage 存 token 的 password 登录路径）

### JIT 建账号 / 绑定回填流程

```mermaid
flowchart TD
    A[ID token 校验通过] --> B{ID token 含 email claim?}
    B -- 否 --> Z1[400 SSO_EMAIL_MISSING]
    B -- 是 --> C[lower-case email]
    C --> D{User.email 存在?}
    D -- 否 --> E{email 域名 in SSO_ALLOWED_DOMAINS?}
    E -- 否 --> Z2[403 SSO_DOMAIN_NOT_ALLOWED]
    E -- 是 --> E2{运行时再查 SSO_JIT_DEFAULT_ORG_ID 默认 org<br/>WHERE deletedAt IS NULL}
    E2 -- 不存在 --> Z5[503 SSO_PROVIDER_UNAVAILABLE]
    E2 -- 存在 --> F[JIT 建 User<br/>username=lower email<br/>Employee 角色 1 行 UserRole<br/>绑 SSO_JIT_DEFAULT_ORG_ID<br/>不建 UserDepartment<br/>region 继承自默认 org<br/>passwordHash=null<br/>source=ENTRA<br/>externalId=oid<br/>externalSource=entra<br/>prisma.user.upsert 捕获 P2002 重查]
    F --> G[审计 SSO_JIT_CREATED]
    D -- 是 --> H{User.externalId 为 null?}
    H -- 是 --> I[CAS UPDATE WHERE externalId IS NULL<br/>受影响行 0 时重查 fallback]
    I --> J[审计 SSO_BINDING_FILLED<br/>path: binding_filled]
    H -- 否 --> K{externalSource?}
    K -- 'ldap' 且 externalId != oid --> L1[覆盖 externalId + externalSource='entra'<br/>事务内]
    L1 --> L2[审计 SSO_BINDING_UPGRADED_FROM_LDAP<br/>path: ldap_upgraded]
    K -- 'entra' 且 externalId == oid --> L[直接登录已有用户<br/>path: existing]
    K -- 'entra' 且 externalId != oid --> Z3[409 SSO_BINDING_CONFLICT<br/>审计 SSO_BINDING_CONFLICT]
    G --> M{User.status == ACTIVE?}
    J --> M
    L2 --> M
    L --> M
    M -- 否 --> Z4[403 IAM_USER_SUSPENDED]
    M -- 是 --> N[事务提交 → 签发 JWT<br/>审计 SSO_LOGIN_SUCCESS]
```

### 双通道并存说明

| 通道 | 入口端点 | 凭据介质 | 适用人群 | v2.4 变化 |
|---|---|---|---|---|
| 本地密码登录 | `POST /api/v1/auth/login` | 用户名 + bcrypt 密码 | LOCAL 用户 | 完全不变 |
| LDAP/AD 登录 | `POST /api/v1/auth/login` | AD 域账号 + 密码 | LDAP / Entra 同步用户 | 完全不变（遗留路径） |
| **Entra SSO 登录（新增）** | `GET /api/v1/auth/sso/start` → `/auth/sso/callback` | 浏览器跳转，Microsoft 域内认证 | 任何 email 命中 `SSO_ALLOWED_DOMAINS` 的用户 | v2.4 新增 |

**关键约束**：

- 三个通道签发的 JWT 走**同一套** JwtService，结构一致（含 organizationId / roleId / permissions），下游服务无需感知登录通道
- 任一通道签发的 token，状态检查 / token 时效 / refresh 流程完全一致
- 用户在 SSO 通道首次登录后，**仍可**通过本地密码登录（前提是 `passwordHash` 非空）；passwordHash 不会因 SSO 成功被清空
- `User.source` 字段不被 SSO 修改：保持 LOCAL/LDAP/ENTRA 历史值，避免影响现有同步逻辑
- `User.externalId` / `User.externalSource` 由 SSO 通道维护：JIT 时创建，已有用户首次 SSO 时回填

### 与现有 entra.service.ts ROPC 的差异

`entra.service.ts` 在 v2.4 之前已存在 ROPC（Resource Owner Password Credential）流程，**与本期 OIDC 新增通道完全独立、并存**。差异如下：

| 维度 | 现有 ROPC（保留） | v2.4 新增 OIDC |
|---|---|---|
| 协议 | OAuth2 ROPC（Microsoft 不推荐，但仍可用） | OIDC 授权码 + **PKCE 必须**（业界事实标准） |
| 凭据介质 | 后端代发用户名/密码到 Entra token endpoint | 浏览器跳转，用户在 Microsoft 域内输密码 |
| 密码是否经过本系统 | 是（短暂） | **否**（本系统永不接触 Entra 密码） |
| MFA / 条件访问 | 难以完整支持 | 完整支持（Entra 直接承担） |
| 主要用途 | 本地密码 fallback / 老接口兼容 | 浏览器单点登录（用户主路径） |
| 是否本期下线 | 否（v2.4 不动） | — |
| 二期评估 | SCIM 落地后评估是否下线 | — |

### 校验清单（实现时必须满足）

- [ ] **OIDC 库**：`openid-client`（不用 `passport-azure-ad` / 不手写 `jose`）
- [ ] **OIDC discovery**：通过 `https://login.microsoftonline.com/{AZURE_TENANT_ID}/v2.0/.well-known/openid-configuration` 拉取 issuer / jwks_uri / authorize / token endpoint，**不**硬编码端点
- [ ] **JWKS 缓存策略**：`openid-client` 默认 5min 缓存；ID token 校验时 `kid` 未命中缓存 → 立即清缓存重拉一次；同 `kid` 多次 miss 加 30s cooldown 防 thundering herd
- [ ] **PKCE 必须**（confidential client 也强制）：`code_verifier` 43-128 字符随机串，`code_challenge_method=S256`
- [ ] **state / nonce / code_verifier**：每次 start `crypto.randomBytes(32)` → base64url，写 4 个 HttpOnly cookie（`sso_state` / `sso_nonce` / `sso_redirect` / `sso_code_verifier`） + Secure + SameSite=Lax + TTL **15min**（容纳 Entra 条件访问 + MFA 慢操作）
- [ ] **ID token 校验**：
  - 签名（JWKS）
  - **issuer**：从 ID token 取 `tid` claim，用 discovery `metadata.issuer` 模板里 `{tenantid}` 替换后**严格字符串等于**比对（不能直接 hardcode `https://login.microsoftonline.com/{tenant}/v2.0`，否则 multi-tenant 应用无法兼容）
  - `aud = AZURE_CLIENT_ID`
  - `nonce` 等于 start 阶段写入的 `sso_nonce` cookie
  - `exp` / `nbf` / `iat` 时间校验容忍 **clock skew ±5min**（`openid-client` 默认）
- [ ] **AZURE_TENANT_ID 必须 GUID**：启动期 regex `/^[0-9a-fA-F]{8}-([0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}$/` 校验，不接受 `common` / `organizations` / `consumers`（避免 cross-tenant 攻击）
- [ ] **email 处理**：`email` claim 先 lower-case 再匹配；`email` 不存在时拒绝（`SSO_EMAIL_MISSING`），不使用 `preferred_username` 或 `upn` 作为 fallback
- [ ] **email 大小写归一化**：应用层 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 / 邀请）统一用 lower-case
- [ ] **JIT 域名白名单**：`SSO_ALLOWED_DOMAINS` 为 lower-case 逗号分隔列表，从 `email` 提取 `@` 后域名比对
- [ ] **JIT 字段补全**：`username = lower(email)`；`region` 继承自 `SSO_JIT_DEFAULT_ORG_ID` 对应 `Organization.region`（默认 `'CN'`）；建 1 行 `UserRole { userId, roleId=Employee 的 id, organizationId=SSO_JIT_DEFAULT_ORG_ID }`；**不建** `UserDepartment` 行（部门由 admin 后续手工分配）
- [ ] **事务边界（spec deviation）**：DB 回填 / JIT / LDAP 升级业务写入在 `prisma.$transaction()` 内；**失败路径 audit** (`SSO_BINDING_CONFLICT` / `LOGIN_FAILED`) 在 tx 内即时写；**成功路径 audit** (`SSO_LOGIN_SUCCESS` / `SSO_JIT_CREATED` / `SSO_BINDING_FILLED` / `SSO_BINDING_UPGRADED_FROM_LDAP`) tx 提交后写（已知 ≤ 进程崩溃间隙的丢失窗口；接受代价：避开 AuditService 独立连接看不到未提交 user 的 FK 违例，详见 `.learnings/ERRORS/ERR-20260519-010`）。JWT 在事务提交后签发；事务失败 → 503 `SSO_PROVIDER_UNAVAILABLE` + 前端引导重试。二期 AuditService tx-aware 后此项收口。
- [ ] **并发处理**：
  - 回填 UPDATE 用 CAS `WHERE id=$1 AND externalId IS NULL`，受影响行 = 0 时重新查
  - JIT 用 `prisma.user.upsert({where:{email}, create:{...}, update:{}})` 并捕获 P2002（unique violation） → 重新查走已存在分支
- [ ] **启动期 fail-fast**：实现 `SsoConfigValidator` Provider，`OnApplicationBootstrap` 钩子内：
  - `AZURE_TENANT_ID` GUID 校验
  - `SSO_ALLOWED_DOMAINS` 非空 → `SSO_JIT_DEFAULT_ORG_ID` 必填
  - `prisma.organization.findUnique({where:{id:env.SSO_JIT_DEFAULT_ORG_ID, deletedAt:null}})` 必须命中
  - 任一失败 → `process.exit(1)`
- [ ] **运行时 fail-safe**：JIT 触发时再查一次默认 org（`WHERE deletedAt IS NULL`），缺失 → 503 `SSO_PROVIDER_UNAVAILABLE`（不引入新错误码）
- [ ] **状态检查**：callback 命中 User 后检查 `status === ACTIVE`，否则 403 `IAM_USER_SUSPENDED`（复用现有错误码，不自创 `AUTH_USER_DISABLED`）
- [ ] **Entra error query 映射**：callback 流程第 0 步检测 `query.error` 非空：
  - `access_denied` → 403 `SSO_USER_CANCELLED`
  - `consent_required` / `interaction_required` → 403 `SSO_CONSENT_REQUIRED`
  - 其它 → 502 `SSO_PROVIDER_REJECTED`
- [ ] **错误屏蔽**：Entra 原始错误**不**直接透传给前端，统一翻译为 `SSO_*` 错误码
- [ ] **JWT secret rotation 对 SSO 透明**：本系统签发的 JWT 复用现有 `JwtService` secret；secret rotation 时所有通道（密码 / LDAP / SSO）一起 rotate，SSO 流程无特殊处理
- [ ] **审计**：5 个新事件必须落 AuditLog 表（`SSO_LOGIN_SUCCESS` / `SSO_JIT_CREATED` / `SSO_BINDING_FILLED` / `SSO_BINDING_UPGRADED_FROM_LDAP` / `SSO_BINDING_CONFLICT`）

### 环境变量

| 变量 | 是否新增 | 用途 |
|---|---|---|
| `AZURE_TENANT_ID` | 沿用 | Entra 租户 ID（**必须 GUID**，启动期 regex 校验；不接受 `common`/`organizations`/`consumers`） |
| `AZURE_CLIENT_ID` | 沿用 | 应用注册 client ID |
| `AZURE_CLIENT_SECRET` | 沿用 | 应用注册 secret |
| `AZURE_REDIRECT_URI` | **新增** | OIDC callback URL（每环境一套，必须与 Entra 应用注册一致） |
| `SSO_ALLOWED_DOMAINS` | **新增** | 逗号分隔域名白名单，控制 JIT 入口 |
| `SSO_JIT_DEFAULT_ORG_ID` | **新增** | JIT 建号时默认组织 ID；`SSO_ALLOWED_DOMAINS` 非空时必填；启动期 + 运行时双层校验 |

### Schema 影响（推翻早期"无变更"声明）

**本期含 1 个 prisma migration**：

1. `ALTER TYPE platform_audit.AuditAction ADD VALUE` 新增 5 个枚举值：
   - `SSO_LOGIN_SUCCESS`
   - `SSO_JIT_CREATED`
   - `SSO_BINDING_FILLED`
   - `SSO_BINDING_UPGRADED_FROM_LDAP`
   - `SSO_BINDING_CONFLICT`
2. 一次性 backfill SQL（幂等）：`UPDATE platform_iam.users SET email = LOWER(email) WHERE email != LOWER(email);`

已存在字段（足以承载 SSO 绑定语义，**不**改）：`User.passwordHash?` / `User.source UserSource @default(LOCAL)` / `User.externalId?` / `User.externalSource?` / `enum UserSource { LOCAL, ENTRA, LDAP }`。

**索引复用**：`lastSsoLoginAt` 反查走 `AuditLog` 的现有复合索引 `@@index([tenantId, userId, when])`（PostgreSQL B-tree 可双向扫描，满足 `WHERE userId=$1 AND action='SSO_LOGIN_SUCCESS' ORDER BY when DESC LIMIT 1`）；无需新增索引。详见 [06-data-model.md](./06-data-model.md)。

### 二期承诺

- **SCIM 协议落地**：实现 Entra → FFOA 实时用户生命周期同步（创建 / 更新 / 禁用 / 删除），关闭本期 ≤ 24h 同步窗口；Entra 禁用 → FFOA session 立即失效（< 5 分钟），独立工单跟踪
- **后台运维强制操作**：管理员可强制解绑 SSO 关联、强制 invalidate 指定用户的所有 session

---

## ✅ 评审记录

| 日期 | 参与人 | 评审结论 | 备注 |
|------|--------|---------|------|
| 2024-11-01 | FFOA Team | Approved | 初始架构评审通过 |
| 2025-12-20 | FFOA Team | Approved | v2.0 独立 Organization 表架构评审通过 |
| 2025-12-26 | FFOA Team | Approved | v2.1 组织级权限隔离架构评审通过 |
| 2025-12-26 | FFOA Team | Approved | v2.1.1 完整技术细节补充评审通过 |
| 2026-01-05 | FFOA Team | Approved | v2.1.25 登录安全简化、身份源优化、定时同步 |
| 2026-05-19 | FFOA Team | Approved | v2.4 Entra ID SSO 登录架构（OIDC 授权码流程） |

---

**最后更新**: 2026-05-19  
**架构师**: FFOA 开发团队  
**审批人**: FFOA Tech Lead  
**文档版本**: v2.4  
**文档状态**: ✅ 已完成（完整技术规格）
