# LicenseManager Platform - API 设计规范 (OpenAPI 3.1)

| 项目 | 内容 |
| --- | --- |
| 文档版本 | v1.0 |
| 文档类型 | API 设计规范 (API Specification) |
| 规范版本 | OpenAPI 3.1.0 + JSON Schema 2020-12 |
| 关联文档 | [RDP v1.0.1](./LicenseManager-Platform-RDP-v1.0.1.md) · [TAD v1.0](./LicenseManager-Platform-TAD-v1.0.md) |
| 文档日期 | 2026-07-23 |

---

## 0. 修订历史

| 版本 | 日期 | 修订内容 |
| --- | --- | --- |
| v1.0 | 2026-07-23 | 初稿 |
| v1.1 | 2026-09 | 按代码实测同步：认证三方案（Bearer/ApiKey/Hmac）与 HMAC 签名输入、响应信封 `{success,data,requestId,timestamp}`、错误码字典对齐 `GET /v1/error-codes`（42 个码，含 `feature_unavailable`）、幂等头 `Idempotency-Key`（POST/PUT/PATCH，24h，冲突 409 `idempotency_conflict`）、限流 429 响应（`Retry-After` + body 字段，无 `X-RateLimit-*` 头）、动作端点改子路径风格、删除不存在的 `GET /v1/openapi.json` 与 `POST /v1/webhooks/replay/{delivery_id}`、补 `PATCH /v1/admin/licenses/{id}` 与客户设备端点、分页/PII 脱敏标注现状与路线图、SDK 包名表对齐五语言实际包名、端点总数更新为约 530 |
| v1.2 | 2026-09 | 按第四轮审查同步：§9.1 补 `airgap-activate` / `airgap-decode` 端点（airgap-activate 注明当前不查库即签发，许可证存在性/状态/硬件绑定校验修复中，见审查报告 R4-A-2）；consume 端点标注"消费写入链路规划中"（当前恒 422 `FEATURE_NOT_FOUND`，见 R4-A-8） |
| v1.3 | 2026-09 | 按代码实测同步：**修正 `airgap-activate` 过时描述**——服务端校验链已落地（归属 → 状态 → 硬件绑定 → 席位占用 → 签发），删除"不查库即签发/修复中"标注（v1.2 条目中相关表述已过时，以本版为准）；新增 `GET /v1/keys` 公钥分发（匿名公开，含退役密钥供验签历史许可证）、`GET /v1/customer/licenses/revocations` 吊销列表、`POST /v1/customer/licenses/{licenseId}/airgap-sync` 离线用量回传、Portal 离线入口 `POST /v1/portal/offline/airgap-activate`（与客户入口共用校验链，另补 `upload-req` 存根）；§9.1 请求/响应字段名对齐 DTO（camelCase）与源码匿名对象（snake_case）；`refresh-file` 更正为 `licenseKey` + `hardwareId` 请求与 JSON `RefreshResult` 响应（原 `license_id` + octet-stream 有误）；§2.1 / §12.1 区分平台主 API 与独立浮动授权服务器（`X-Api-Key` / 401 `INVALID_API_KEY`）两套认证模型；补 ProblemDetails 422 归一化说明与业务码（§4.2.2 / §8.2）；端点总数更新为实测 531（`[Http*]` 531 / 动作方法 530，零 `[AllowAnonymous]`，以代码为准） |
| v1.4 | 2026-10 | 新增 §9.9 部署授权 API（自举授权）：`GET /v1/admin/deployment-license` 状态查询与 `POST /v1/admin/deployment-license/import` 授权文件导入（RSA-2048 验签，公钥内置、私钥仅厂商侧持有）；注明三处配额校验接入点的错误码与无效导入 400 `deployment_license_invalid` |
| v1.5 | 2026-10-16 | 按 P1/P2 安全与性能改造同步：新增 §9.6b 会话管理（`POST /v1/auth/login` / `POST /v1/auth/logout` JWT 吊销）；§9.2 补 `PUT /v1/admin/api-keys/{id}/scopes`（ApiKey 最小权限）；`/v1/status`、`/v1/metrics` 默认需认证（`Observability:PublicStatus=false`，§3.2/§9.4/§9.8）；licenseKey 传输方式核对与实现一致（请求体 / `X-License-Key` 头，查询参数已过时） |

---

## 1. 设计原则

| 原则 | 说明 |
| --- | --- |
| **RESTful** | 资源导向,语义清晰 |
| **统一前缀** | `/v1/{context}/{resource}` |
| **统一响应** | 所有响应为 JSON,统一包装 |
| **统一错误** | 统一错误码字典,带文档链接 |
| **版本控制** | URI 路径版本(`/v1/`),承诺 24 个月兼容 |
| **幂等性** | 写操作支持 `Idempotency-Key` |
| **分页标准化** | Cursor-based,稳定 |
| **过滤/排序** | 查询参数约定,无副作用 |
| **安全第一** | 默认 TLS,强制 mTLS(可选) |
| **SDK 友好** | OpenAPI 自动生成,错误码可拉取 |

---

## 2. 认证与授权

### 2.1 认证机制

所有 API 认证均通过 `Authorization` 请求头完成，服务端按方案前缀（`Bearer` / `ApiKey` / `Hmac`）分派（`AuthenticationMiddleware`）：

| 场景 | 方式 | Header |
| --- | --- | --- |
| 内部用户(管理控制台) | JWT（8 小时有效，无 refresh token，过期重新登录） | `Authorization: Bearer <jwt>` |
| 客户用户(自助门户) | JWT（同上） | `Authorization: Bearer <jwt>` |
| 个人访问令牌 | Bearer | `Authorization: Bearer <pat>` |
| SDK / 应用（API Key） | API Key | `Authorization: ApiKey <keyId>:<secret>` |
| SDK / 应用（HMAC 签名） | HMAC-SHA256 | `Authorization: Hmac <keyId>:<signature>` + `X-LicenseManager-Api-Key: <secret>` + `X-LicenseManager-Timestamp: <unix秒>` |
| Webhook 出站(我们发出) | HMAC 签名 | `X-LicenseManager-Signature: sha256=...` + `X-LicenseManager-Timestamp: ...`（仅当端点配置了 Secret） |
| 端到端 OIDC(SDK 端) | Bearer ID Token | `Authorization: Bearer eyJhbGc...` |

> 注意：平台主 API（`LicenseManager.Api`）不存在 `X-API-Key` / `X-Signature` / `X-Nonce` 请求头；API Key 与 HMAC 凭据一律经 `Authorization` 头传递。
>
> **两个服务的认证模型不同（勿混淆）**：上表适用于平台主 API。独立浮动授权服务器（`LicenseManager.Server`，浮动租约 API `/v1/leases/*`）**只认 `X-Api-Key` 请求头**（与配置项 `FloatingLicensing:ApiKey` 常量时间比较，不接受 `Authorization` 三方案）：缺失或不匹配返回 `401`，响应体为 `{"error":"INVALID_API_KEY","message":"Missing or invalid X-Api-Key header."}`（**非**标准错误信封）；配置项缺失时 fail-closed 返回 `503` `SERVER_NOT_CONFIGURED`。该服务豁免认证的路径：`/health`、`/healthz`、`/swagger`、`/openapi`。
>
> **浮动租约端点要点**：`POST /v1/leases/checkout` 请求体为 `{"productId", "hardwareId", "leaseDurationSeconds"}`，其中 `productId` **必填**（座席池按产品隔离计数，配额见 `FloatingLicensing:ProductSeatLimits`）；`heartbeat`/`release` 仅凭 `leaseToken`；响应为 snake_case（`lease_token` / `product_id` / `hardware_id` / `lease_expires_at`）；`GET /v1/leases` 状态响应含 `products` 分产品占用数组。

### 2.2 HMAC 签名(SDK / API Key)

```
1. 签名输入:
   signingInput = TIMESTAMP + "\n" + BODY(原始请求体字符串)
2. 计算签名:
   signature = HEX-LOWERCASE(HMAC-SHA256(secret, signingInput))
3. 请求头:
   Authorization: Hmac <keyId>:<signature>
   X-LicenseManager-Api-Key: <secret>     # secret 用于密钥校验与签名密钥,不参与签名输入
   X-LicenseManager-Timestamp: 1700000000  # Unix 秒
```

- 时间戳容差:± 300 秒（超时拒绝）
- 无 nonce 机制；重放防护仅依赖时间戳窗口
- 实现现状：签名不匹配、时间戳缺失/超差、`X-LicenseManager-Api-Key` 缺失或密钥校验失败，均统一返回 401 `auth.invalid_token`（`auth.hmac_signature_invalid` / `auth.hmac_timestamp_invalid` 保留在错误码字典中，当前中间件不单独下发）

### 2.3 授权

- **RBAC**:角色 + 权限点(`resource:action`)
- **资源范围**:`org_id` / `customer_id` / `brand_id` / `tenant_id`
- **API Key scope**:`['license:activate', 'license:verify']` 等显式声明
- **PAT scope**:同 API Key,但绑定用户

### 2.4 限流

- 固定窗口限流（每分钟窗口）：已认证请求按认证身份分桶（JWT `sub` / ApiKey / HMAC 互不串桶，每身份独立配额）、匿名请求按客户端 IP。默认 **100 次/分钟**（代码默认；仓库 `appsettings.json` 配置为 600，可通过 `RateLimit:PerMinute` 调整）。
- **不返回** `X-RateLimit-Limit` / `X-RateLimit-Remaining` / `X-RateLimit-Reset` 头。
- 触发限流时返回 `429 Too Many Requests`，带 `Retry-After: 60` 响应头，错误体 `error.details` 自带限流参数：

```json
{
  "success": false,
  "error": {
    "code": "rate_limit_exceeded",
    "message": "Rate limit exceeded. Try again later.",
    "details": { "limit": 100, "window_seconds": 60, "retry_after_seconds": 60 }
  },
  "requestId": "...",
  "timestamp": "..."
}
```

- 多实例部署需配置 Redis（`RateLimit:Distributed=true`）以实现全局限流；未配置时回退为实例级内存限流并记录警告日志。

---

## 3. URL 设计

### 3.1 基础规则

- 全小写
- 复数名词:`/licenses` / `/customers` / `/devices`
- 资源关系:`/customers/{id}/licenses`(子资源)
- 动作避免在 URL 中,用 HTTP Method 表达
- 特殊动作用子路径段表达:`/licenses/{id}/revoke`、`/licenses/{id}/suspend`(本项目实际风格,不使用冒号动作语法)

### 3.2 端点前缀

| 域 | 前缀 | 说明 |
| --- | --- | --- |
| 管理 API | `/v1/admin/` | 内部用户(Super Admin / PM / Sales / Support) |
| 客户 API | `/v1/customer/` | 客户用户(Customer Admin / User) |
| 门户 API | `/v1/portal/` | 客户自助门户(需门户认证) |
| 伙伴 / 沙箱 | `/v1/partner/` · `/v1/sandbox/` | **规划中**：后端为空数据存根(GET 返回空、写操作返回 501 `feature_unavailable`),功能未上线 |
| Webhook 出 | `/v1/admin/webhooks/` · `/v1/admin/webhook-deliveries` | 投递管理(收拢至管理命名空间,需认证) |
| 状态 | `/v1/status` · `/v1/metrics` · `/v1/healthz` · `/v1/readyz` | 健康检查；`/v1/status`、`/v1/metrics` 默认需认证（`Observability:PublicStatus`，见 §9.8），`/v1/healthz`、`/v1/readyz` 始终匿名 |
| 文档 | `/swagger/v1/swagger.json` | OpenAPI 规范,**仅 Development 环境挂载**；不存在 `GET /v1/openapi.json` |

### 3.3 HTTP 方法语义

| 方法 | 语义 | 幂等 | 安全 |
| --- | --- | --- | --- |
| `GET` | 读取 | ✅ | ✅ |
| `POST` | 创建 / 触发动作 | ❌ | ❌ |
| `PUT` | 全量替换 | ✅ | ❌ |
| `PATCH` | 部分更新 | ❌ | ❌ |
| `DELETE` | 删除 | ✅ | ❌ |

### 3.4 状态码

| 状态 | 场景 |
| --- | --- |
| 200 OK | 成功(GET/PUT/PATCH/DELETE) |
| 201 Created | 创建成功 |
| 202 Accepted | 异步已接受,返回 job_id |
| 204 No Content | 成功无内容 |
| 400 Bad Request | 请求参数错误 |
| 401 Unauthorized | 未认证 |
| 403 Forbidden | 无权限 |
| 404 Not Found | 资源不存在 |
| 409 Conflict | 状态冲突(如已激活) |
| 422 Unprocessable Entity | 业务规则违反 |
| 429 Too Many Requests | 限流 |
| 500 Internal Server Error | 服务端错误 |
| 503 Service Unavailable | 服务暂不可用 |

---

## 4. 请求与响应

### 4.1 请求头(必填)

```
Content-Type: application/json
Accept: application/json
Idempotency-Key: <uuid>             # 写操作幂等键(可选,见 §7)
Authorization: ...                  # 见 §2
```

> `requestId` 由服务端为每个请求生成并写入响应信封,客户端无需(也不应)自行注入追踪头。

### 4.2 响应包装

所有响应经 `ApiResponseWrapperMiddleware` 统一包装为以下信封(前端与五个 SDK 均按此消费):

#### 4.2.1 成功响应(单资源与列表)

```json
{
  "success": true,
  "data": { "id": "...", "licenseKey": "LMC-XXXX-XXXX-XXXX", "...": "..." },
  "requestId": "7f8d9e0a-1234-5678-9abc-def012345678",
  "timestamp": "2026-07-23T12:00:00.000Z"
}
```

- `data` 为平铺的业务对象(camelCase),**不是** JSON:API 的 `{id,type,attributes}` 结构;
- 列表端点的 `data` 直接为数组(分页现状见 §5)。

#### 4.2.2 错误响应

```json
{
  "success": false,
  "error": {
    "code": "license.NotFound",
    "message": "License was not found.",
    "details": { "...": "..." }
  },
  "requestId": "7f8d9e0a-1234-5678-9abc-def012345678",
  "timestamp": "2026-07-23T12:00:00.000Z"
}
```

- `error.code` 取自错误码字典(见 §8,`GET /v1/error-codes` 可拉取全量);
- `error.details` 可选,承载字段级错误或上下文(如限流参数);
- 不再使用 `{errors:[...]}` 数组形式。

> **ProblemDetails 归一化**：控制器返回的 422 业务失败（`ProblemDetails`，`title` 为机器可读业务码）经 `ApiResponseWrapperMiddleware` 自动转换为上述错误信封——原 `title`（如 `LICENSE_REVOKED`）落入 **`error.message`**，`error.code` 取状态码兜底值 **`unprocessable_entity`**，原 `detail` 不下发（详见 §8.2 业务码注）。缺 `Authorization` 请求头时 `error.code` 为 `"unauthorized"`（非字典中的 `auth.unauthorized`）；`/v1/healthz`、`/v1/readyz`、`/v1/openapi`、`/v1/status`、`/v1/error-codes`、`/v1/metrics`、`/swagger` 等 7 条豁免路径不套信封（返回裸 JSON，与 `ShouldSkip` 一致）。

### 4.3 时间格式

- **存储**:UTC(`TIMESTAMPTZ`)
- **API 响应**:ISO 8601 with Z: `"2026-07-23T12:00:00.000Z"`
- **查询参数**:ISO 8601,UTC 优先

### 4.4 货币

- **存储**:整数最小单位(分) + `currency`
- **API 响应**:`{ amount: 1999, currency: "USD" }` (1.999 单位)
- 浮点数不参与金额计算

### 4.5 PII 脱敏(路线图)

> **规划功能,当前代码未实现**——响应中的 email 等 PII 字段目前以明文返回,以下约定为路线图:
>
> - 默认脱敏:`email` → `a***@example.com`
> - 显式参数 `?sensitive=true` 返回明文(需权限)
> - 日志中永远脱敏

---

## 5. 分页

### 5.1 现状(以代码为准)

- 多数列表端点**暂无分页参数**,返回满足筛选条件的全量结果(服务端有 `Take(10000)` 上限保护),`data` 直接为数组;
- 部分端点支持少量筛选查询参数(如 `GET /v1/admin/licenses?productId=&customerId=`),以各端点实际签名为准;
- 审计日志 `GET /v1/admin/audit-log` 使用偏移分页 `offset` / `limit`(见 §5.2)。

### 5.2 偏移分页(审计日志)

```
GET /v1/admin/audit-log?offset=0&limit=50
```

- 仅用于审计等明确分页场景。

### 5.3 Cursor 分页(路线图)

> **规划功能,当前代码未实现。**目标形态:
>
> ```
> GET /v1/admin/licenses?limit=20&cursor=eyJpZCI6Ii4uLiJ9
> ```
>
> ```json
> {
>   "success": true,
>   "data": [ ... ],
>   "meta": { "page": { "limit": 20, "next_cursor": "...", "has_more": true } }
> }
> ```
>
> - 默认 `limit=20`,最大 100
> - 排序:`sort=-created_at`(`-`前缀 DESC,默认 ASC)
> - 复杂筛选:`filter[status]=active&filter[product_id]=xxx`

---

## 6. 过滤与排序(路线图)

> 本节约定的 `filter[...]` / `sort` / `q` / `fields[...]` 参数**当前代码未实现**,列为路线图;现状仅个别端点支持专用筛选查询参数(见 §5.1)。

### 6.1 过滤

- 单值:`filter[status]=active`
- 多值:`filter[status]=active,suspended` (OR)
- 范围:`filter[created_at][gte]=2026-01-01&filter[created_at][lt]=2026-12-31`
- 全文:`q=search+term`(简单实现,可升级 OpenSearch)

### 6.2 排序

- `sort=-created_at`(多列逗号分隔)
- 支持列:每端点文档化

### 6.3 字段选择

- `fields[license]=id,status,expires_at`
- 减少响应体积,提升性能

---

## 7. 幂等性

### 7.1 写操作幂等键

```
POST /v1/admin/licenses
Idempotency-Key: 8e03978e-40d5-43e8-bc93-6891aab6c1da
```

- 请求头名为 **`Idempotency-Key`**(非 `X-Idempotency-Key`);
- 仅 `POST` / `PUT` / `PATCH` 触发幂等处理(`GET`/`DELETE` 不参与);
- 仅缓存 **2xx** 响应,默认 **24 小时**(`Idempotency:CacheHours` 可配),缓存于进程内 `IMemoryCache`;
- 命中缓存时原样回放响应,并附加响应头 `Idempotent-Replay: true`;
- 服务端校验请求体哈希:**同一 Key 携带不同请求体**时返回 `409` + 错误码 `idempotency_conflict`,不会错误回放旧响应。

### 7.2 受影响端点

所有 `POST` / `PUT` / `PATCH` 端点。`DELETE` 不属于幂等键处理范围。

---

## 8. 错误码字典

### 8.1 错误码命名

实际代码采用**混合风格**(以 `GET /v1/error-codes` 输出为准):

- 通用/传输层:`snake_case`(如 `validation_error`、`not_found`);
- 认证域:`auth.*` 点号小写(如 `auth.api_key_invalid`);
- 许可证生命周期:`license.<PascalCase>`(如 `license.NotFound`);
- 计费域:`billing.*` 点号小写(如 `billing.payment_failed`)。

### 8.2 完整错误码(36 个,与 `GET /v1/error-codes` 输出一致)

**通用 / 传输**

| 错误码 | HTTP | 描述 |
| --- | --- | --- |
| `validation_error` | 400 | 请求体或参数未通过校验 |
| `invalid_argument` | 400 | 参数无效或缺失 |
| `invalid_operation` | 400 | 当前状态不允许该操作 |
| `not_found` | 404 | 资源不存在 |
| `forbidden` | 403 | 已认证但无权限 |
| `conflict` | 409 | 与资源当前状态冲突 |
| `idempotency_conflict` | 409 | 同一 `Idempotency-Key` 携带了不同的请求体 |
| `rate_limit_exceeded` | 429 | 超出限流配额,按 `Retry-After` 退避重试 |
| `internal_error` | 500 | 未预期的服务端错误 |
| `feature_unavailable` | 501 | 功能规划中未上线（partner / sandbox 存根写操作） |
| `maintenance_mode` | 503 | 服务处于维护模式 |

**认证(auth.\*)**

| 错误码 | HTTP | 描述 |
| --- | --- | --- |
| `auth.unauthorized` | 401 | 认证缺失或无效 |
| `auth.invalid_token` | 401 | 令牌格式无法解析 |
| `auth.expired_token` | 401 | 令牌已过期 |
| `auth.insufficient_permissions` | 403 | 已认证但缺少所需 scope/角色 |
| `auth.api_key_invalid` | 401 | API Key id 或 secret 无效 |
| `auth.api_key_revoked` | 401 | API Key 已被吊销 |
| `auth.api_key_expired` | 401 | API Key 已过期 |
| `auth.hmac_signature_invalid` | 401 | HMAC 签名与请求体不匹配 |
| `auth.hmac_timestamp_invalid` | 401 | HMAC 时间戳缺失或超出 ±300s 容差 |

**许可证生命周期(license.\*)**

| 错误码 | HTTP | 描述 |
| --- | --- | --- |
| `license.LicenseExpired` | 422 | 许可证已过期 |
| `license.LicenseRevoked` | 422 | 许可证已吊销 |
| `license.LicenseSuspended` | 422 | 许可证已挂起 |
| `license.LicenseSignature` | 422 | 签名许可证验签失败 |
| `license.HardwareMismatch` | 422 | 硬件 ID 与已激活设备不匹配 |
| `license.ActivationLimitReached` | 422 | 已达最大激活数 |
| `license.FeatureNotLicensed` | 422 | 特性未授权 |
| `license.FeatureConsumptionExceeded` | 422 | 特性消耗超限 |
| `license.InvalidOperation` | 409 | 许可证状态流转非法 |
| `license.NotFound` | 404 | 许可证不存在 |
| `license.AlreadyActivated` | 409 | 该设备已激活过此许可证 |
| `license.MaxDevicesReached` | 422 | 已达最大设备数 |
| `license.InvalidLicenseKey` | 422 | 许可证密钥格式非法 |
| `license.ConsumptionNegative` | 422 | 消耗增量将导致总量为负 |
| `license.GracePeriodExceeded` | 422 | 离线宽限期已过 |
| `license.AntiRollbackDetected` | 422 | 反回滚校验失败 |

> 注:`feature_unavailable`(501) 已计入上述字典(通用/传输分组),仅由 partner / sandbox 存根端点的写操作返回(规划中功能)。

### 8.3 错误码字典 API

```
GET /v1/error-codes
```

返回完整字典(供 SDK 拉取)

---

## 9. 主要端点

### 9.1 授权(License)API

> **认证说明**：客户生命周期端点 `activate` / `verify` / `heartbeat` / `deactivate` / `refresh-file` / `checkout` 在默认配置 `Auth:AllowPublicActivation=true` 下**匿名公开**(无需认证);将 `Auth:AllowPublicActivation` 设为 `false` 可收紧为强制认证(生产环境建议)。下文 `security` 标注描述收紧后的要求,以代码为准。

```yaml
openapi: 3.1.0
info:
  title: LicenseManager API
  version: 1.0.0
  description: License management API for LicenseManager Platform

# License 端点
/v1/customer/licenses/activate:
  post:
    summary: Activate a license using activation key
    operationId: activateLicense
    tags: [Licenses]
    security:
      - ApiKey: []
    requestBody:
      required: true
      content:
        application/json:
          schema:
            type: object
            required: [activationKey]
            properties:
              activationKey: { type: string }
              hardwareId: { type: string }
              hostname: { type: string }
              osVersion: { type: string }
              sdkVersion: { type: string }
              userAccount: { type: string }
    responses:
      '200':
        description: License activated
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ActivateLicenseResponse'
      '409':
        $ref: '#/components/responses/Conflict'
      '422':
        $ref: '#/components/responses/UnprocessableEntity'

/v1/customer/licenses/verify:
  post:
    summary: Verify an existing license
    operationId: verifyLicense
    tags: [Licenses]
    security:
      - ApiKey: []
    requestBody:
      required: true
      content:
        application/json:
          schema:
            type: object
            required: [licenseKey]
            properties:
              licenseKey: { type: string }
              hardwareId: { type: string }
              sdkVersion: { type: string }
              hostname: { type: string }
              osVersion: { type: string }
              validateFeatures: { type: boolean }
              userAccount: { type: string }
    responses:
      '200':
        description: License valid
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/VerifyLicenseResponse'

/v1/customer/licenses/heartbeat:
  post:
    summary: License heartbeat (lightweight validity check, updates LastSeenAt)
    operationId: heartbeat
    tags: [Licenses]
    security:
      - ApiKey: []
    requestBody:
      required: true
      content:
        application/json:
          schema:
            type: object
            required: [licenseKey]
            properties:
              licenseKey: { type: string }
              hardwareId: { type: string }
              hostname: { type: string }
              osVersion: { type: string }
              sdkVersion: { type: string }
    responses:
      '200':
        description: Heartbeat accepted (verification result)
      '422':
        description: License invalid (failure code in problem details)

/v1/customer/licenses/{license_id}:
  get:
    summary: Get license details
    operationId: getLicense
    tags: [Licenses]
    security:
      - ApiKey: []
      - Bearer: []
    parameters:
      - $ref: '#/components/parameters/LicenseId'
    responses:
      '200':
        description: License details
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/License'

/v1/customer/licenses/{license_id}/deactivate:
  post:
    summary: Deactivate a device
    operationId: deactivateLicense
    tags: [Licenses]
    security:
      - ApiKey: []
      - Bearer: []
    parameters:
      - $ref: '#/components/parameters/LicenseId'
    requestBody:
      required: true
      content:
        application/json:
          schema:
            type: object
            required: [licenseKey]
            properties:
              licenseKey: { type: string }
              hardwareId: { type: string }
    responses:
      '200':
        description: Deactivated

/v1/customer/licenses/{license_id}/features/{feature_code}:
  get:
    summary: Get a feature value
    operationId: getFeature
    tags: [Features]
    security:
      - ApiKey: []
    parameters:
      - $ref: '#/components/parameters/LicenseId'
      - in: path
        name: feature_code
        required: true
        schema: { type: string }
      - in: header
        name: X-License-Key
        required: true
        schema: { type: string }
        description: 许可证密钥（首选传输方式；GET 无请求体，避免密钥进入 URL 日志）
      - in: query
        name: licenseKey
        required: false
        deprecated: true
        schema: { type: string }
        description: 已过时，仅为兼容旧版 SDK 保留；命中时响应携带 Deprecation 头
      - in: query
        name: hardwareId
        required: false
        schema: { type: string }
    responses:
      '200':
        description: Feature state（enabled 基于许可证真实 Features 判定；许可证内无此功能时为 false）
        content:
          application/json:
            schema:
              type: object
              properties:
                featureCode: { type: string }
                enabled: { type: boolean }
      '400':
        description: 未提供许可证密钥（validation_error）
      '422':
        description: ProblemDetails（title 为业务码，如 LICENSE_NOT_FOUND）

/v1/customer/licenses/{license_id}/features/{feature_code}/consume:
  post:
    summary: Report feature consumption
    operationId: consumeFeature
    tags: [Features]
    description: >
      消费写入链路已落地：按 licenseId + licenseKey 定位平台记录，基于 Core License 真实
      Features 校验额度后以乐观并发原子更新权威计数并追加消费日志；许可证密钥优先取请求体
      `licenseKey` 字段，其次 `X-License-Key` 头，查询参数 `licenseKey` 已过时（响应携带
      Deprecation 头）。
    security:
      - ApiKey: []
    parameters:
      - $ref: '#/components/parameters/LicenseId'
      - in: path
        name: feature_code
        required: true
        schema: { type: string }
      - in: header
        name: X-License-Key
        required: false
        schema: { type: string }
        description: 许可证密钥（请求体 licenseKey 缺省时的次选传输方式）
      - in: query
        name: licenseKey
        required: false
        deprecated: true
        schema: { type: string }
        description: 已过时，仅为兼容旧版 SDK 保留；命中时响应携带 Deprecation 头
      - in: query
        name: hardwareId
        required: false
        schema: { type: string }
    requestBody:
      required: true
      content:
        application/json:
          schema:
            type: object
            properties:
              licenseKey: { type: string, description: 许可证密钥（首选传输方式） }
              delta: { type: number, description: 本次消费增量，缺省按 1 处理（ConsumeFeatureRequest.Delta 可空） }
    responses:
      '200':
        description: Consumption recorded
        content:
          application/json:
            schema:
              type: object
              properties:
                featureCode: { type: string }
                currentConsumption: { type: number, nullable: true }
                success: { type: boolean }

/v1/customer/licenses/checkout:
  post:
    summary: One-step checkout (activate + verify, returns signed license)
    operationId: checkoutLicense
    tags: [Licenses]
    requestBody:
      required: true
      content:
        application/json:
          schema:
            type: object
            required: [activationKey]
            properties:
              activationKey: { type: string }
              hardwareId: { type: string }
              hostname: { type: string }
              osVersion: { type: string }
              sdkVersion: { type: string }
              userAccount: { type: string }
    responses:
      '200':
        description: Checkout result with signed license

/v1/customer/licenses/{license_id}/devices:
  get:
    summary: List devices bound to a license
    operationId: listLicenseDevices
    tags: [Licenses]
    security:
      - ApiKey: []
      - Bearer: []
    parameters:
      - $ref: '#/components/parameters/LicenseId'
    responses:
      '200':
        description: Device list

/v1/customer/licenses/{license_id}/devices/{device_id}/disable:
  post:
    summary: Disable a device on a license
    operationId: disableLicenseDevice
    tags: [Licenses]
    security:
      - ApiKey: []
      - Bearer: []
    parameters:
      - $ref: '#/components/parameters/LicenseId'
      - in: path
        name: device_id
        required: true
        schema: { type: string, format: uuid }
    responses:
      '200':
        description: Device disabled

/v1/customer/licenses/{license_id}/devices/{device_id}:
  delete:
    summary: Remove a device from a license
    operationId: deleteLicenseDevice
    tags: [Licenses]
    security:
      - ApiKey: []
      - Bearer: []
    parameters:
      - $ref: '#/components/parameters/LicenseId'
      - in: path
        name: device_id
        required: true
        schema: { type: string, format: uuid }
    responses:
      '200':
        description: Device removed

/v1/customer/licenses/refresh-file:
  post:
    summary: Refresh signed license payload (online)
    operationId: refreshFile
    tags: [Licenses]
    security:
      - Bearer: []
    requestBody:
      required: true
      content:
        application/json:
          schema:
            type: object
            required: [licenseKey]
            properties:
              licenseKey: { type: string, description: 许可证密钥（RefreshFileRequest.LicenseKey 必填） }
              hardwareId: { type: string, description: 硬件标识，可选，缺省由服务端自动采集 }
    responses:
      '200':
        description: RefreshResult JSON（success/signedLicense/failureCode/failureMessage），成功时含最新签名许可证载荷；失败时 422 ProblemDetails（title 为业务码）
        content:
          application/json:
            schema:
              type: object
              properties:
                success: { type: boolean }
                signedLicense: { type: object, nullable: true }
                failureCode: { type: string, nullable: true }
                failureMessage: { type: string, nullable: true }

/v1/customer/licenses/airgap-activate:
  post:
    summary: Air-gap activation (issue signed license from activation code)
    operationId: airgapActivate
    tags: [Licenses]
    description: >
      离线激活：基于激活码签发 RSA 签名许可证负载，适用于完全无网环境。
      服务端经共享服务 AirgapActivationService 统一校验后签发，校验链为
      归属 → 状态 → 硬件绑定 → 席位占用 → 签发（CustomerController 与
      PortalOfflineController 共用同一服务）：激活码须为当前客户名下有效
      许可证密钥，否则返回 404/422（LICENSE_REVOKED/DISABLED/SUSPENDED/EXPIRED 等）。
    security:
      - Bearer: []
    requestBody:
      required: true
      content:
        application/json:
          schema:
            type: object
            required: [activationCode]
            properties:
              activationCode: { type: string, maxLength: 512 }
              productCode: { type: string, maxLength: 256 }
              hardwareId: { type: string, maxLength: 256 }
    responses:
      '200':
        description: Signed license payload
      '400':
        $ref: '#/components/responses/BadRequest'

/v1/customer/licenses/airgap-decode:
  post:
    summary: Decode and validate a signed license payload (offline)
    operationId: airgapDecode
    tags: [Licenses]
    security:
      - Bearer: []
    requestBody:
      required: true
      content:
        application/json:
          schema:
            type: object
            required: [signedLicense]
            properties:
              signedLicense: { type: object }
              hardwareId: { type: string, maxLength: 256 }
    responses:
      '200':
        description: License payload (valid)
      '422':
        description: Signature/validation failed

/v1/customer/licenses/revocations:
  get:
    summary: List revoked licenses of current customer (for offline revocation cache)
    operationId: listMyRevocations
    tags: [Licenses]
    security:
      - Bearer: []
    responses:
      '200':
        description: Revocation list (snake_case anonymous object)
        content:
          application/json:
            schema:
              type: object
              properties:
                revocations:
                  type: array
                  items:
                    type: object
                    properties:
                      license_id: { type: string, format: uuid }
                      license_key: { type: string }
                      reason: { type: string, nullable: true }

/v1/customer/licenses/{licenseId}/airgap-sync:
  post:
    summary: Report offline feature consumption deltas after reconnecting
    operationId: airgapSync
    tags: [Licenses]
    security:
      - Bearer: []
    parameters:
      - in: path
        name: licenseId
        required: true
        schema: { type: string, format: uuid }
    requestBody:
      required: true
      content:
        application/json:
          schema:
            type: object
            properties:
              hardwareId: { type: string, description: 执行消费的硬件标识，可空，缺省为 airgap-sync }
              consumptions:
                type: array
                items:
                  type: object
                  required: [featureCode, delta]
                  properties:
                    featureCode: { type: string }
                    delta: { type: number }
    responses:
      '200':
        description: Per-item sync results (snake_case: license_id/synced_at/results[].feature_code/success/total|error_code/message)
      '404':
        description: License not found or not owned by current customer

/v1/keys:
  get:
    summary: List license-signing public keys (incl. retired, for offline verification)
    operationId: listKeys
    tags: [System]
    security: []
    responses:
      '200':
        description: Anonymous public key list (snake_case: keys[].key_version/algorithm/public_key/public_key_pem/status/activated_at/retired_at)

/v1/portal/offline/airgap-activate:
  post:
    summary: Portal offline activation (same AirgapActivationService chain as customer entry)
    operationId: portalAirgapActivate
    tags: [Portal]
    security:
      - Bearer: []
    requestBody:
      required: true
      content:
        application/json:
          schema:
            type: object
            required: [activationCode]
            properties:
              activationCode: { type: string, maxLength: 512 }
              productCode: { type: string, maxLength: 256 }
              hardwareId: { type: string, maxLength: 256 }
    responses:
      '200':
        description: Signed license payload; 404/422 on ownership/status/hardware/seat check failure

/v1/portal/offline/upload-req:
  post:
    summary: Upload offline request file (stub, returns simulated success)
    operationId: portalUploadReq
    tags: [Portal]
    security:
      - Bearer: []
    requestBody:
      required: true
      content:
        application/json:
          schema:
            type: object
            properties:
              fileName: { type: string }
              content: { type: string }
    responses:
      '200':
        description: Stub result (snake_case: uploaded/requestId/status/message)
```

### 9.2 管理 API

```yaml
/v1/admin/products:
  get:
    summary: List products
    operationId: listProducts
    tags: [Admin: Products]
    security:
      - Bearer: []
    responses:
      '200':
        description: List of products（现状无分页参数,返回全量,见 §5.1）
  post:
    summary: Create a product
    operationId: createProduct
    tags: [Admin: Products]
    security:
      - Bearer: []
    requestBody:
      required: true
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ProductCreate'

/v1/admin/products/{product_id}:
  get: { ... }
  patch: { ... }
  delete: { ... }

/v1/admin/customers:
  get: { ... }
  post: { ... }

/v1/admin/customers/{customer_id}:
  get: { ... }
  patch: { ... }

/v1/admin/licenses:
  get: { ... }   # 支持 productId / customerId 查询参数筛选
  post: { ... }   # 签发 license

/v1/admin/licenses/{license_id}:
  get: { ... }
  patch:
    summary: Update mutable license fields
    operationId: updateLicense
    tags: [Admin: Licenses]
    requestBody:
      required: true
      content:
        application/json:
          schema:
            type: object
            properties:
              expiresAt: { type: string, format: date-time, nullable: true, description: 为 null 表示不修改 }
              maxDevices: { type: integer, nullable: true, description: 为 null 表示不修改 }
    responses:
      '200':
        description: License updated
      '404':
        description: License not found
  # 注意:不存在 DELETE /v1/admin/licenses/{id}——许可证只能吊销(revoke),不能删除。

/v1/admin/licenses/{license_id}/suspend:
  post:
    summary: Suspend a license
    operationId: suspendLicense
    tags: [Admin: Licenses]
    requestBody:
      required: true
      content:
        application/json:
          schema:
            type: object
            properties:
              reason: { type: string }
    responses:
      '200':
        description: License suspended

/v1/admin/licenses/{license_id}/resume:
  post: { ... }   # 子路径动作风格

/v1/admin/licenses/{license_id}/revoke:
  post: { ... }

/v1/admin/licenses/{license_id}/renew:
  post: { ... }   # body: { validityDays }

/v1/admin/licenses/{license_id}/extend:
  post: { ... }   # body: { extensionDays }

/v1/admin/licenses/{license_id}/devices:
  get: { ... }

/v1/admin/api-keys/{id}/scopes:
  put:
    summary: Update API key scopes (最小权限模型)
    operationId: updateApiKeyScopes
    tags: [Admin: ApiKeys]
    security:
      - Bearer: []
    requestBody:
      required: true
      content:
        application/json:
          schema:
            type: object
            properties:
              scopes:
                type: array
                items: { type: string }
                description: 授权范围（如 license:activate / license:verify）；空数组回退为系统级全权信任，"*" 为显式全权
    responses:
      '200':
        description: updated=true
      '404':
        description: API key not found
  # 相关:`POST /v1/admin/api-keys/{id}/revoke` 吊销 API 密钥
```

### 9.3 订阅续期 API

> 订单、支付、发票、退款、PSP 配置等资金流 API 已随商业化裁剪整体移除，由外部销售系统承接；本系统仅保留订阅续期能力。

```yaml
/v1/admin/subscriptions/renewal/due:
  get:
    summary: 查询到期待续订订阅列表
    tags: [Admin: Operations]

/v1/admin/subscriptions/renewal/execute:
  post:
    summary: 手动触发订阅续期（后台每小时亦自动扫描执行）
    tags: [Admin: Operations]
    responses:
      '200':
        description: >-
          续期成功后自动延长关联许可证有效期（ExpiresAt）；
          结果经出站 Webhook 事件 subscription.renewed / subscription.payment_failed 通知外部系统。

```
### 9.4 公开 / 状态 API

> **认证说明（P1 起）**：`/v1/status`、`/v1/metrics` 默认**需认证**（可能泄露版本/环境/依赖拓扑等内部信息），仅当配置 `Observability:PublicStatus=true` 时匿名公开；`/v1/healthz`、`/v1/readyz` 始终匿名（编排探针依赖）。上述路径无论是否公开，响应均不套 `{success,data}` 信封（见 §4.2.2）。

```yaml
/v1/healthz:
  get:
    summary: Liveness probe
    operationId: healthz
    tags: [System]
    security: []
    responses:
      '200':
        description: Service alive

/v1/readyz:
  get:
    summary: Readiness probe
    operationId: readyz
    tags: [System]
    security: []
    responses:
      '200':
        description: Service ready
      '503':
        description: Service not ready

/v1/status:
  get:
    summary: Service status
    operationId: publicStatus
    tags: [System]
    # 默认需认证（Bearer / ApiKey / Hmac 任一）；Observability:PublicStatus=true 时才匿名公开
    security:
      - Bearer: []
    responses:
      '200':
        description: Service status
      '401':
        description: 未认证（默认配置下）

/v1/metrics:
  get:
    summary: Prometheus-style metrics
    operationId: metrics
    tags: [System]
    # 认证策略同 /v1/status（Observability:PublicStatus，默认 false 需认证）
    security:
      - Bearer: []
    responses:
      '200':
        description: Metrics payload（Prometheus 文本，text/plain; version=0.0.4）
      '401':
        description: 未认证（默认配置下）

# 注意:不存在 GET /v1/openapi.json。
# OpenAPI 文档由 Swashbuckle 生成,仅 Development 环境挂载于 /swagger/v1/swagger.json(含 /swagger UI)。
```

### 9.5 Webhook 接收（已移除）

> PSP 支付回调（`POST /v1/webhooks/payment/{provider}`）已随商业化裁剪移除；公开 `v1/webhooks/*` 命名空间当前无入站端点。出站事件（`subscription.renewed` 等）由系统主动推送至客户配置的端点。

> **变更说明**：出站 Webhook 投递管理端点已全部收拢到管理命名空间（`v1/admin`，需认证 + 角色授权）。**不存在** `POST /v1/webhooks/replay/{delivery_id}`。当前管理端点包括：
>
> - `GET /v1/admin/webhook-deliveries`、`GET /v1/admin/webhooks/deliveries`（列表，支持 `eventType` 过滤与 `limit` 收敛）
> - `GET /v1/admin/webhooks/deliveries/{id}`
> - `GET /v1/admin/webhooks/endpoints/{endpointId}/deliveries`
> - `POST /v1/admin/webhooks/deliveries/{id}/replay`、`POST /v1/admin/webhooks/replay/{deliveryId}`（重放，同步执行，成功返回 **200**）

### 9.6 短信验证码登录 API（admin + portal）

匿名端点（防枚举：无论手机号是否绑定账户，`send` 均返回 `{sent:true}`，含格式非法号码）。验证码 5 分钟有效、同号 60 秒发送冷却、同号每小时 5 条 / 同 IP 每小时 30 条频控、连续 5 次错误作废；失败尝试接入登录锁定。

| 端点 | 说明 |
| --- | --- |
| `POST /v1/auth/sms/send` | 管理端发送验证码，body `{"phone":"+8613800138000"}` → `{sent:true}`；错误码：`sms.cooldown`（429，60s 冷却）、`sms.phone_rate_limited`（429）、`sms.ip_rate_limited`（429）、`auth.account_locked`（423） |
| `POST /v1/auth/sms/login` | 管理端验证码登录，body `{"phone","code"}` → `{token,user}`（复用密码登录签发链路，Admin 无角色拒绝）；错误码：`auth.invalid_sms_code`（401） |
| `POST /v1/portal/auth/sms/send` | Portal 发送验证码（同上） |
| `POST /v1/portal/auth/sms/login` | Portal 验证码登录（默认 customer 角色） |

手机号存储于 `user_account.Phone`（唯一索引，迁移 `AddUserPhone`），由管理端用户接口维护；`PhoneNormalizer` 兼容 `+` / `00` 前缀与分隔符格式互通。

### 9.6b 会话管理 API（登录 / 登出）

| 端点 | 认证 | 说明 |
| --- | --- | --- |
| `POST /v1/auth/login` | 匿名 | 管理端密码登录，body `{"email","password"}` → `{token,user}`（JWT 8 小时有效）；错误码：`auth.invalid_credentials`（401）、`auth.account_locked`（423，连续失败锁定） |
| `POST /v1/auth/logout` | Bearer JWT | 登出：将当前 JWT 的 `jti` 加入吊销名单（TTL 取令牌剩余有效期，到期自动清除），令牌在剩余有效期内不可再使用；历史无 `jti` 的令牌无法精确吊销，仍返回 200（客户端清除 token 即可，安全性由 `exp` 短有效期兜底）。响应 `{success:true, message:"已退出登录。"}`；未认证 401 |
| `POST /v1/portal/auth/login` | 匿名 | Portal 客户登录（默认 customer 角色） |

### 9.7 SCIM 2.0 目录同步 API（RFC 7644 子集）

`/scim/v2` 命名空间，响应为 SCIM 原生 `application/scim+json`（不套 `{success,data}` 信封）。鉴权为独立 `Scim:BearerToken` 常量时间校验（SHA-256 + FixedTimeEquals），未配置时全部 401 fail-closed。`userName`（登录邮箱）不可变（400 `mutability`）；`active=false` 映射软禁用（Suspended，可恢复）；DELETE 为软删除。

| 端点 | 说明 |
| --- | --- |
| `GET /scim/v2/ServiceProviderConfig` | 服务商能力描述 |
| `GET /scim/v2/Users` | 列表 / 查询：`filter=userName eq "value"`（仅支持该过滤）、`startIndex`/`count` 分页、`count=0` 仅返回 `totalResults`；不支持的 filter 返回 400 `invalidFilter` |
| `POST /scim/v2/Users` | 创建（缺 `userName` 400 `invalidValue`；重复 409 `uniqueness`；无密码时生成随机密码） |
| `GET /scim/v2/Users/{id}` | 读取；未知 id 404 |
| `PUT /scim/v2/Users/{id}` | 整体替换 |
| `PATCH /scim/v2/Users/{id}` | replace/remove 操作，含无 `path` 的整对象形态（Entra ID）；`active` / `name.displayName` / `displayName` / `userName`（400 mutability）/ 其他路径 400 `invalidPath` |
| `DELETE /scim/v2/Users/{id}` | 软删除；删除后 GET 返回 404 |

### 9.8 特性配置与系统配置 API

| 端点 | 认证 | 说明 |
| --- | --- | --- |
| `GET /v1/config/features` | 匿名 | 前端特性开关集合：`{ "feature.sms_login": true, "feature.cookie_consent": false, "feature.scim": false }`（存储于 `system_config` 表，未覆盖项返回代码默认值） |
| `GET /v1/admin/system-configs` | 管理员 JWT | 全部特性配置详情：`[{key, enabled, defaultValue, updatedAt, updatedBy}]` |
| `PUT /v1/admin/system-configs/{key}` | 管理员 JWT | 设置开关：body `{"value": true\|false}` → 单条详情；目录外键 404 |

**状态与健康（本版随 Portal 公开状态页 `/status` 一起提供）**：

> 自 P1 起下表三个端点默认**需认证**（`Observability:PublicStatus` 默认 `false`；置 `true` 恢复匿名公开，见 §9.4）。

| 端点 | 认证 | 说明 |
| --- | --- | --- |
| `GET /v1/status` | 默认需认证 | 服务状态（裸 JSON：status / service / version / environment / started_at / uptime_seconds） |
| `GET /v1/status/dependencies` | 默认需认证 | 依赖健康与延迟（database / redis；异常详情仅写服务端日志） |
| `GET /v1/status/maintenance` | 默认需认证 | 维护模式状态（`Maintenance:Enabled`） |
| `GET /v1/metrics` | 默认需认证 | Prometheus 文本格式指标（`text/plain; version=0.0.4`，不套信封；同上受 `Observability:PublicStatus` 控制） |

### 9.9 部署授权 API（自举授权）

部署授权控制**本部署实例**可用的档位（Free / Pro / Enterprise）与配额（有效许可证数 / 浮动席位数 / 每月 SDK 调用数），由厂商 RSA-2048 私钥签发的授权文件驱动；验签公钥内置于程序集（客户侧无私钥，授权文件仅能由厂商签发），且每次验签前对内置公钥做 SHA-256 指纹完整性自检（防程序集被 patch 替换公钥后自签发）。未导入或授权失效时 fail-safe 降级为 Free 档。厂商自有域名部署（白名单 `*.licensemanager.cn`）豁免全部校验。配额校验接入点：许可证签发（422 `deployment_license_limit`）、浮动席位 checkout（409 `DEPLOYMENT_SEAT_LIMIT`）、SDK 生命周期端点月调用（429 `sdk_quota_exceeded`）。导入连续失败 3 次及以上日志升级 Error（破解尝试信号）。

| 端点 | 认证 | 说明 |
| --- | --- | --- |
| `GET /v1/admin/deployment-license` | 管理员 JWT | 当前授权状态：`{status: missing\|valid\|expired\|invalid, tier, whitelisted, licensee, expiresAt, quotas{maxLicenses, maxFloatingSeats, maxSdkCallsPerMonth}, allowedFeatures[], sdkCallsThisMonth, licenseCount}` |
| `POST /v1/admin/deployment-license/import` | 管理员 JWT | 导入授权文件：body `{"file_content": "<授权文件 JSON 原文>"}`；验签成功持久化到 `system_config` 并即时生效（返回导入后状态）；验签失败 / 格式错误 / 签发时间在未来 / 档位无效 / 时钟回拨超容差 → 400 `deployment_license_invalid`（message 面向用户可直接展示） |

---

## 10. Webhook 规范(出站)

### 10.1 协议

- **协议**:HTTPS POST
- **格式**:JSON
- **认证**:HMAC-SHA256 签名

### 10.2 请求

```http
POST /your-endpoint HTTP/1.1
Host: your-domain.com
Content-Type: application/json
X-LicenseManager-Event: license.activated
X-LicenseManager-Signature: sha256=4f7d...
X-LicenseManager-Timestamp: 1700000000

{
  "id": "7f8d9e0a-1234-5678-9abc-def012345678",
  "type": "license.activated",
  "time": "2026-07-23T12:00:00.000Z",
  "version": "1.0",
  "source": "license-manager",
  "tenant_id": "...",
  "data": {
    "license_id": "...",
    "license_key": "LMC-XXXX-XXXX-XXXX",
    "customer_id": "...",
    "product_id": "...",
    "hardware_id": "...",
    "activated_at": "2026-07-23T12:00:00.000Z"
  }
}
```

> 签名与时间戳头**仅在端点配置了 Secret 时下发**；`X-LicenseManager-Event` 始终携带事件类型。投递由 `WebhookDeliveryWorker` 后台执行（5 秒轮询、单轮最多 50 条、HTTP 超时 15 秒），发送前经 SSRF 校验；端点已删除/停用时不再无签名发送，直接标记 Failed（`endpoint_not_found`）。

### 10.3 签名验证

```python
# 接收方验证(伪代码)
expected = "sha256=" + hmac_sha256(secret, f"{timestamp}\n{body}").hex()
received = request.headers["X-LicenseManager-Signature"]
assert hmac.compare_digest(expected, received)
```

- 签名原文为 `"{timestamp}\n{payload}"`（Unix 秒时间戳 + 换行 + 原始 JSON body），结果以 `sha256=` 前缀 + hex 小写编码。
- 时间戳通过 `X-LicenseManager-Timestamp` 头下发（Unix 秒）；接收方应校验时间戳容差（建议 ±300s）防重放。
- 向后兼容：未携带时间戳头的旧投递按纯 payload 签名验证。

### 10.4 期望响应

- `2xx`:成功,标记 delivered
- 非 `2xx` / 网络失败:进入指数退避重试;达到最大尝试次数后标记 failed(可手动重放)
- 终态投递记录(Delivered/Failed)保留 30 天后由后台清理归档

### 10.5 重试策略

| 项 | 值 |
| --- | --- |
| 默认最大尝试次数 | 5（`WebhookDelivery.MaxAttempts`） |
| 退避算法 | 指数退避:5s → 10s → 20s → 40s → ……(上限 1 小时) |
| 轮询间隔 | 5 秒(每轮最多投递 50 条) |
| 最终失败 | 标记 Failed,挂起(可经管理端点手动重放) |

### 10.6 事件类型(完整目录,46 条)

事件目录由服务端 `WebhookEventCatalogService` 集中维护(37 条,含 `license.issued`、`license.renewed`、`license.extended`、`subscription.created`、`subscription.renewed` 等,均为许可/订阅语义事件),可通过管理端点查询;另参见 RDP §4.55。

---

## 11. OpenAPI 3.1 规范(关键 Schema)

> 注意:以下为设计期参考 Schema。**实际 DTO 为平铺 camelCase 结构并包在 `{success,data,requestId,timestamp}` 信封中(见 §4.2),非 JSON:API `{id,type,attributes}` 形态**;字段以代码与各 SDK 模型为准。

### 11.1 License

```yaml
License:
  type: object
  required: [id, type, attributes]
  properties:
    id: { type: string, format: uuid }
    type: { type: string, enum: [license] }
    attributes:
      type: object
      required:
        - license_key
        - status
        - license_type
      properties:
        license_key: { type: string }
        activation_key: { type: string, nullable: true }
        customer_id: { type: string, format: uuid }
        product_id: { type: string, format: uuid }
        template_id: { type: string, format: uuid }
        license_type:
          type: string
          enum: [node_locked, floating, consumption, named_user, bundle, trial, perpetual]
        status:
          type: string
          enum: [not_activated, active, inactive, disabled, suspended, expired, revoked]
        is_trial: { type: boolean }
        is_perpetual: { type: boolean }
        issued_at: { type: string, format: date-time }
        first_activated_at: { type: string, format: date-time, nullable: true }
        expires_at: { type: string, format: date-time, nullable: true }
        grace_until: { type: string, format: date-time, nullable: true }
        max_devices: { type: integer, nullable: true }
        max_activations: { type: integer, nullable: true }
        current_devices: { type: integer }
        current_activations: { type: integer }
        feature_values:
          type: object
          additionalProperties: true
        custom_fields:
          type: object
          additionalProperties: true
        metadata:
          type: object
          additionalProperties: true
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
```

### 11.2 ActivateLicenseResponse

```yaml
ActivateLicenseResponse:
  type: object
  required: [data]
  properties:
    data:
      type: object
      required: [type, attributes]
      properties:
        type: { type: string, enum: [license_activation] }
        attributes:
          type: object
          required: [license, signed_payload, signature]
          properties:
            license:
              $ref: '#/components/schemas/License'
            signed_payload: { type: string, description: 'Base64 编码的签名 payload' }
            signature: { type: string, description: 'RSA 签名' }
            key_version: { type: integer }
            issued_at: { type: string, format: date-time }
```

### 11.3 Error

实际错误对象(信封内 `error` 字段,见 §4.2.2):

```yaml
Error:
  type: object
  required: [code, message]
  properties:
    code: { type: string, description: '错误码,见错误码字典(§8)' }
    message: { type: string }
    details: { type: object, additionalProperties: true, nullable: true }
```

### 11.4 通用响应

```yaml
SuccessEnvelope:
  type: object
  required: [success, data, requestId, timestamp]
  properties:
    success: { type: boolean, enum: [true] }
    data: { description: '业务对象或数组(列表端点现状直接为数组,见 §5.1)' }
    requestId: { type: string }
    timestamp: { type: string, format: date-time }

ErrorEnvelope:
  type: object
  required: [success, error, requestId, timestamp]
  properties:
    success: { type: boolean, enum: [false] }
    error:
      $ref: '#/components/schemas/Error'
    requestId: { type: string }
    timestamp: { type: string, format: date-time }
```

---

## 12. 安全 Schema

### 12.1 认证方案

```yaml
components:
  securitySchemes:
    ApiKey:
      type: apiKey
      in: header
      name: Authorization
      description: |
        API Key 认证(凭据格式 keyId:secret):
        Authorization: ApiKey <keyId>:<secret>
    
    Hmac:
      type: apiKey
      in: header
      name: Authorization
      description: |
        HMAC-SHA256 签名认证(见 §2.2):
        Authorization: Hmac <keyId>:<signature>
        X-LicenseManager-Api-Key: <secret>
        X-LicenseManager-Timestamp: <unix秒>
    
    Bearer:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: |
        用户 JWT(8 小时有效,无 refresh):
        Authorization: Bearer eyJhbGc...
        或 PAT:
        Authorization: Bearer <pat>
    
    IdpToken:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: |
        OIDC IdP token(SDK 端):
        Authorization: Bearer <id_token>
```

### 12.2 响应(安全)

```yaml
components:
  responses:
    Unauthorized:
      description: Unauthorized
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
    Forbidden:
      description: Forbidden
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
    NotFound:
      description: Not Found
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
    Conflict:
      description: Conflict
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
    UnprocessableEntity:
      description: Unprocessable Entity
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
    TooManyRequests:
      description: Too Many Requests（无 X-RateLimit-* 头;限流参数在错误体 error.details 中,见 §2.4）
      headers:
        Retry-After:
          schema: { type: integer, example: 60 }
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'

  schemas:
    ErrorResponse:
      type: object
      required: [success, error]
      properties:
        success: { type: boolean, enum: [false] }
        error:
          $ref: '#/components/schemas/Error'
        requestId: { type: string }
        timestamp: { type: string, format: date-time }
```

---

## 13. SDK 生成

### 13.1 生成策略

- 使用 OpenAPI Generator / Kiota / NSwag
- 单一 OpenAPI 文档,多语言 SDK
- 自动发布到 NuGet / npm / PyPI / Maven / Cargo

### 13.2 目标语言(已随仓库发布,`sdk/` 目录)

| 语言 | 包名 / 模块 | 状态 |
| --- | --- | --- |
| .NET | `LicenseManager.Client.Sdk`(NuGet PackageId) | ✅ 已提供 |
| TypeScript/JS | `@license-manager/sdk` | ✅ 已提供 |
| Python | `licensemanager-sdk` | ✅ 已提供 |
| Go | `github.com/license-manager/sdk-go`(单 package `licensemanager`) | ✅ 已提供 |
| Rust | `license_manager`(crate) | ✅ 已提供 |
| Java / PHP / Ruby / Swift / Kotlin | — | 规划中,未提供 |

### 13.3 SDK 必备特性

- 信封自动解包(`{success,data,...}` → `data`)
- 类型化错误(认证/授权/未找到/限流/校验异常分类)
- 错误码翻译
- 超时可配
- 许可证激活 / 校验 / 心跳 / 反激活 / checkout(一键激活)
- 续期(`validityDays`)与延期(`extensionDays`)

### 13.4 SDK 集成示例(.NET)

```csharp
using LicenseManager.Client.Sdk;

var client = new LicenseManagerClient(new LicenseManagerOptions
{
    BaseUrl = "https://api.license-manager.com",
    ApiKey = "keyId:secret"   // 经 Authorization: ApiKey 头传递
});

var activation = await client.Activations.ActivateAsync(new ActivationRequest
{
    ActivationKey = "XXXX-XXXX-XXXX",
    HardwareId = "hw-001"
});

Console.WriteLine($"Success: {activation.Success}");
Console.WriteLine($"Signed payload: {activation.SignedPayload}");
```

---

## 14. 版本控制策略

### 14.1 URI 路径版本

- `/v1/` 前缀
- 承诺 `/v1/` 至少 24 个月向后兼容
- 新增字段/端点不破坏兼容
- 删除/重命名前 6 个月公告

### 14.2 破坏性变更(必须升级 v2)

- 删除公开端点
- 改变请求/响应字段语义
- 改变认证机制
- 改变错误码语义
- 改变 webhook 协议

### 14.3 弃用流程

1. 标记 `Deprecated` 头
2. 文档中标注
3. CHANGELOG 公告
4. 邮件通知 API Key 持有者
5. 6 个月后下线
6. 12 个月后删除

---

## 15. 附录

### 15.1 关联文档

- [RDP v1.0.1](./LicenseManager-Platform-RDP-v1.0.1.md) - 产品需求
- [TAD v1.0](./LicenseManager-Platform-TAD-v1.0.md) - 技术架构
- [Database Design](./LicenseManager-Platform-Database-Design-v1.0.md) - 数据库设计
- [Deployment Guide](./LicenseManager-Platform-Deployment-Guide-v1.0.md) - 部署运维

### 15.2 端点总数

实测 `[Http*]` 动作 **531** 个（动作方法 530，零 `[AllowAnonymous]`，以代码为准）:

| 域 | 端点数 |
| --- | --- |
| 管理 API(`/v1/admin`,13 个控制器) | ~ 420 |
| 伙伴 / 沙箱存根(`/v1/partner`、`/v1/sandbox`,GET 返回空数据、写操作返回 501 `feature_unavailable`) | 42 |
| 客户 + 门户 API(`/v1/customer`、`/v1/portal`) | ~ 50 |
| 公开 / 系统(状态、健康检查、错误码字典) | ~ 10 |
| **合计** | **511** |

> 存根端点为规划中功能的占位实现,随迭代逐步替换为真实逻辑。

### 15.3 变更说明(v1.1)

- 认证统一为 `Authorization` 头三方案(`Bearer` / `ApiKey` / `Hmac`),移除 `X-API-Key` / `X-Signature` / `X-Nonce` 头描述;
- 响应信封统一为 `{success,data,requestId,timestamp}` / `{success:false,error:{code,message,details?}}`;
- 错误码字典对齐 `GET /v1/error-codes` 实测输出(42 个);
- 动作端点统一为子路径风格(`/licenses/{id}/suspend` 等);
- 删除不存在的 `GET /v1/openapi.json` 与 `POST /v1/webhooks/replay/{delivery_id}`;
- 新增 `PATCH /v1/admin/licenses/{id}`(`expiresAt` / `maxDevices`)与客户设备端点(`GET/POST disable/DELETE /v1/customer/licenses/{id}/devices[/{deviceId}[/disable]]`);许可证不可删除,只能吊销;
- 限流 429 响应改为 `Retry-After: 60` + body `details`(`limit` / `window_seconds` / `retry_after_seconds`),无 `X-RateLimit-*` 头;
- 幂等头为 `Idempotency-Key`(POST/PUT/PATCH,24h,仅缓存 2xx,回放头 `Idempotent-Replay: true`,请求体冲突返回 409 `idempotency_conflict`);
- cursor 分页、PII 脱敏、`filter/sort/q/fields` 标注为路线图(当前未实现)。

### 15.4 端点规范示例

参见 [LicenseManager OpenAPI](https://docs.license-manager.com/openapi.json)(发布时)
