# 数据对外开放接口层 - API 规格

> **module**: db-external-access
> **doc_type**: API
> **status**: Active
> **owner**: FFOA Team
> **upstream_docs**: 01-prd.md, 06-data-model.md
> **last_verified**: 2026-04-24

---

## 接口分组

| 分组 | 路径前缀 | 鉴权方式 | 说明 |
|------|---------|---------|------|
| API Key 管理 | `/admin/data-open/keys` | JWT（平台内部管理员） | 创建/管理 API Key |
| 组织架构数据 | `/data/organizations` | `X-Api-Key` | 需要 `org-readonly` 角色 |
| 用户数据 | `/data/users` | `X-Api-Key` | 需要 `user-readonly` 角色 |
| 考勤数据 | `/data/attendance` | `X-Api-Key` | 需要 `attendance-readonly` 角色 |

**⚠️ 所有 `/data/**` 接口均为只读（GET），无写操作。**

---

## 通用约定

### 请求头（数据接口）

```
X-Api-Key: doa_xK9mN2pQrTvW8yZaAbCdEfGhIj3lMn4o
```

### 通用分页参数

| 参数 | 类型 | 默认 | 说明 |
|------|------|------|------|
| `page` | number | 1 | 页码，从 1 开始 |
| `pageSize` | number | 20 | 每页条数，最大 100 |

### 通用响应结构

**列表响应**：
```json
{
  "data": [...],
  "pagination": {
    "page": 1,
    "pageSize": 20,
    "total": 150,
    "totalPages": 8
  }
}
```

**单条响应**：
```json
{
  "data": { ... }
}
```

**错误响应**：
```json
{
  "error": {
    "code": "UNAUTHORIZED",
    "message": "Invalid or expired API key"
  }
}
```

### 错误码

| HTTP 状态 | code | 触发场景 |
|-----------|------|---------|
| 401 | `UNAUTHORIZED` | Key 不存在、已停用、已过期 |
| 403 | `FORBIDDEN` | Key 有效但无访问该接口的数据角色 |
| 429 | `RATE_LIMITED` | 超过 Key 的限流阈值 |
| 400 | `INVALID_PARAMS` | 参数校验失败 |
| 500 | `INTERNAL_ERROR` | 服务内部错误 |

---

## API Key 管理接口

> 鉴权：平台 JWT，需要 `system:admin` 或 `data-open:manage` 权限
> ⏳ 所有接口待实现

### 创建 API Key

```
POST /admin/data-open/keys
```

**请求体**：
```json
{
  "name": "报表系统-生产",
  "consumer": "bi-platform",
  "dataRoles": ["org-readonly", "attendance-readonly"],
  "rateLimit": 200,
  "expiresAt": null
}
```

**响应**（201）：
```json
{
  "data": {
    "id": "uuid",
    "name": "报表系统-生产",
    "consumer": "bi-platform",
    "keyPrefix": "doa_xK9m",
    "plainKey": "doa_xK9mN2pQrTvW8yZaAbCdEfGhIj3lMn4o",
    "dataRoles": ["org-readonly", "attendance-readonly"],
    "rateLimit": 200,
    "isActive": true,
    "expiresAt": null,
    "createdAt": "2026-04-24T10:00:00Z"
  }
}
```

> ⚠️ `plainKey` 仅此一次返回，之后不可再查。

---

### 查询 API Key 列表

```
GET /admin/data-open/keys
```

**响应**（200）：
```json
{
  "data": [
    {
      "id": "uuid",
      "name": "报表系统-生产",
      "consumer": "bi-platform",
      "keyPrefix": "doa_xK9m",
      "dataRoles": ["org-readonly", "attendance-readonly"],
      "rateLimit": 200,
      "isActive": true,
      "lastUsedAt": "2026-04-24T09:30:00Z",
      "expiresAt": null,
      "createdAt": "2026-04-24T10:00:00Z"
    }
  ]
}
```

> `plainKey` 不出现在列表响应中。

---

### 启用 / 停用 Key

```
PATCH /admin/data-open/keys/:id
```

**请求体**：
```json
{ "isActive": false }
```

**响应**（200）：返回更新后的 Key 对象（不含 `plainKey`）。

---

### 删除 Key

```
DELETE /admin/data-open/keys/:id
```

**响应**（204）：无响应体。

> 删除后对应的 `DataAccessLog` 记录中 `apiKeyId` 保留（历史审计不清除）。

---

### 查询访问日志

```
GET /admin/data-open/keys/:id/logs
```

**查询参数**：

| 参数 | 类型 | 说明 |
|------|------|------|
| `startDate` | ISO 8601 | 开始时间 |
| `endDate` | ISO 8601 | 结束时间 |
| `page` | number | 页码 |
| `pageSize` | number | 每页条数，最大 100 |

**响应**（200）：
```json
{
  "data": [
    {
      "id": "uuid",
      "method": "GET",
      "path": "/data/organizations",
      "statusCode": 200,
      "duration": 45,
      "resultCount": 20,
      "ip": "10.0.1.5",
      "createdAt": "2026-04-24T09:30:00Z"
    }
  ],
  "pagination": { "page": 1, "pageSize": 20, "total": 384, "totalPages": 20 }
}
```

---

## 组织架构数据接口

> 鉴权：`X-Api-Key`，需要 `org-readonly` 数据角色
> ⏳ 所有接口待实现

### 查询组织列表

```
GET /data/organizations
```

**查询参数**：

| 参数 | 类型 | 说明 |
|------|------|------|
| `page` | number | 页码 |
| `pageSize` | number | 每页条数 |
| `parentId` | string | 筛选指定父组织下的子组织；不传则返回所有 |

**响应**（200）：
```json
{
  "data": [
    {
      "id": "uuid",
      "name": "技术部",
      "code": "TECH",
      "parentId": "uuid-or-null",
      "level": 2,
      "isActive": true,
      "createdAt": "2025-01-01T00:00:00Z"
    }
  ],
  "pagination": { ... }
}
```

---

### 查询组织成员列表

```
GET /data/organizations/:orgId/members
```

**查询参数**：

| 参数 | 类型 | 说明 |
|------|------|------|
| `page` | number | 页码 |
| `pageSize` | number | 每页条数 |

**响应**（200）：
```json
{
  "data": [
    {
      "userId": "uuid",
      "name": "张三",
      "employeeNo": "EMP001",
      "positionName": "软件工程师",
      "isActive": true,
      "joinedAt": "2024-03-01T00:00:00Z"
    }
  ],
  "pagination": { ... }
}
```

> 不返回手机号、邮箱等敏感字段。如接入方需要，需申请 `user-readonly` 角色并通过用户接口获取（脱敏后）。

---

## 用户数据接口

> 鉴权：`X-Api-Key`，需要 `user-readonly` 数据角色
> ⏳ 所有接口待实现

### 查询用户列表（脱敏）

```
GET /data/users
```

**查询参数**：

| 参数 | 类型 | 说明 |
|------|------|------|
| `orgId` | string | 筛选指定组织的用户 |
| `isActive` | boolean | 是否在职 |
| `page` | number | 页码 |
| `pageSize` | number | 每页条数 |

**响应**（200）：
```json
{
  "data": [
    {
      "id": "uuid",
      "name": "张三",
      "employeeNo": "EMP001",
      "phone": "138****5678",
      "email": "zh***@example.com",
      "orgId": "uuid",
      "orgName": "技术部",
      "positionName": "软件工程师",
      "isActive": true
    }
  ],
  "pagination": { ... }
}
```

---

### 查询单个用户（脱敏）

```
GET /data/users/:userId
```

**响应**（200）：返回同上单条对象，不含分页。

---

## 考勤数据接口

> 鉴权：`X-Api-Key`，需要 `attendance-readonly` 数据角色
> ⏳ 所有接口待实现
> ⚠️ 具体暴露字段待与接入方对齐后确认

### 查询会议考勤汇总

```
GET /data/attendance/meetings
```

**查询参数**：

| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `startDate` | YYYY-MM-DD | ✅ | 开始日期 |
| `endDate` | YYYY-MM-DD | ✅ | 结束日期，与 startDate 差值不超过 31 天 |
| `orgId` | string | | 筛选组织 |
| `page` | number | | 页码 |
| `pageSize` | number | | 每页条数 |

**响应**（200）：
```json
{
  "data": [
    {
      "meetingId": "uuid",
      "meetingName": "周例会",
      "meetingDate": "2026-04-21",
      "totalAttendees": 12,
      "actualAttendees": 10,
      "attendanceRate": 0.83
    }
  ],
  "pagination": { ... }
}
```

---

### 查询现场考勤汇总

```
GET /data/attendance/site
```

**查询参数**：

| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `startDate` | YYYY-MM-DD | ✅ | 开始日期 |
| `endDate` | YYYY-MM-DD | ✅ | 结束日期，差值不超过 31 天 |
| `orgId` | string | | 筛选组织 |
| `page` | number | | 页码 |
| `pageSize` | number | | 每页条数 |

**响应**（200）：
```json
{
  "data": [
    {
      "userId": "uuid",
      "name": "张三",
      "date": "2026-04-21",
      "checkInTime": "09:02",
      "checkOutTime": "18:15",
      "status": "NORMAL"
    }
  ],
  "pagination": { ... }
}
```

---

## 实现注意事项（给后端开发者）

1. **Guard 顺序**：先执行 `ApiKeyGuard`（验证 Key 存在 + 有效 + 未过期），再执行 `DataRoleGuard`（验证 Key 有访问当前接口的数据角色）
2. **限流实现**：推荐使用 `@nestjs/throttler`，按 `apiKeyId` 为 key 做滑动窗口计数
3. **AccessLog 写入**：使用 NestJS Interceptor 在响应发出后异步写入，不阻塞主链路；若写入失败只打 warn 日志，不影响响应
4. **lastUsedAt 更新**：同样异步写入，不阻塞鉴权流程
5. **脱敏处理**：在 Service 层主动 `select` 字段并 map 输出，不依赖数据库层控制；永远不要把整个 entity 直接返回给接入方
6. **查询范围**：数据接口的所有查询需要加 `organizationId` 隔离（接入方只能看其被授权的组织数据，⏳ V2 扩展按组织授权时实现）
