# LicenseManager Platform - 第三方对接集成指南

| 项目 | 内容 |
| --- | --- |
| 文档版本 | v1.1 |
| 文档类型 | 集成指南 (Platform Integration Guide) |
| 关联文档 | [RDP v1.0.1](./LicenseManager-Platform-RDP-v1.0.1.md) · [TAD v1.0](./LicenseManager-Platform-TAD-v1.0.md) · [API Spec](./LicenseManager-Platform-API-Spec-v1.0.md) · [Deployment Guide](./LicenseManager-Platform-Deployment-Guide-v1.0.md) |
| 目标读者 | 后端工程师、集成工程师、SRE、安全工程师 |
| 文档日期 | 2026-07-25 |

---

## 0. 修订历史

| 版本 | 日期 | 修订内容 |
| --- | --- | --- |
| v1.0 | 2026-07-25 | 初稿：覆盖 PSP / OIDC / SMTP / Webhook 投递 / 弹性 HTTP / 凭据加密 / 配置参考 / 集成测试 / 附录 |
| v1.1 | 2026-10-08 | 商业化裁剪：移除 PSP/支付集成章节，资金流移交外部销售系统 |

---

## 1. 概述

本指南覆盖 LicenseManager Platform 与外部系统（OIDC 身份提供商、SMTP 邮件服务、第三方 Webhook 订阅方）的对接实现，面向需要扩展、替换或运维外部集成的工程师。

> 支付/PSP 集成已随商业化裁剪移除，资金流由外部销售系统承接；本系统仅保留订阅续期（后台每小时自动扫描 + 手动触发 `POST /v1/admin/subscriptions/renewal/execute`）与出站事件通知（`subscription.renewed` / `subscription.payment_failed`）。

### 1.1 集成范围

| 类别 | 模块 | 入口实现 |
| --- | --- | --- |
| 单点登录（OIDC） | `LicenseManager.Platform.Services` | `OidcClient` |
| 邮件通知（SMTP） | `LicenseManager.Platform.Services` | `SmtpEmailSender` |
| Webhook 投递 | `LicenseManager.Platform.Services` | `WebhookDeliveryWorker` |
| 凭据加密 | `LicenseManager.Platform.Services` | `SecretProtector` |
| 弹性 HTTP | `LicenseManager.Infrastructure` | `DependencyInjection.RegisterHttpClients` |

### 1.2 设计原则

1. **适配器模式（Adapter Pattern）**：所有外部依赖通过 `IOidcClient`、`IEmailSender` 等抽象接口暴露给平台层，具体实现在基础设施层组装，便于替换或并存多个实现。
2. **`IHttpClientFactory` + Polly**：所有出站 HTTP 调用使用命名 `HttpClient`，由 `IHttpClientFactory` 统一管理 `HttpClientHandler` 生命周期，并通过 Polly 实现瞬态故障重试与超时，避免 `HttpClient` socket 耗尽与单点雪崩。
3. **环境变量优先的密钥注入**：所有外部凭据（Webhook Secret、数据保护密钥）优先从环境变量读取，其次从 `IConfiguration` 读取；空值统一归一化为 `null` 以触发测试环境 fallback。
4. **Fail-Closed 原则**：OIDC ID Token 校验、出站 Webhook 签名等安全路径在凭据缺失或校验失败时一律拒绝（fail-closed），不降级放行。

---

## 2. 支付服务提供商（PSP）集成（已废弃）

> 支付/PSP 集成已随商业化裁剪移除，资金流由外部销售系统承接。本章原内容（`IPaymentProvider` 接口、11 个 PSP 适配器、Stripe/PayPal 生产配置与 Webhook 验签、`psp_config` 配置、`PaymentService` 回调幂等等）已删除；本系统仅保留订阅续期（后台每小时自动扫描 + 手动触发 `POST /v1/admin/subscriptions/renewal/execute`）与出站事件通知（`subscription.renewed` / `subscription.payment_failed`）。

---

## 3. OIDC 单点登录集成

### 3.1 接口定义

`LicenseManager.Platform.Abstractions.IOidcClient` 定义两个核心方法：

- `ExchangeCodeAsync`：使用授权码（authorization code）向 IdP 交换 token。
- `ValidateIdTokenAsync`：校验 ID Token 并提取 claims。

默认实现：`LicenseManager.Platform.Services.OidcClient`。

### 3.2 授权码交换流程

`ExchangeCodeAsync(issuerUrl, clientId, clientSecret, redirectUri, code, ct)` 实现两步流程：

#### 步骤 1：Discovery 文档发现

- `GET {issuerUrl}/.well-known/openid-configuration`
- 解析 JSON，提取 `token_endpoint` 字段。
- 失败（非 2xx 或缺字段）返回 `null` 并记录警告日志。

#### 步骤 2：Token 端点交换

- `POST {token_endpoint}`，Content-Type: `application/x-www-form-urlencoded`
- 表单参数：
  - `grant_type=authorization_code`
  - `code=<授权码>`
  - `redirect_uri=<回调地址>`
  - `client_id=<客户端 ID>`
  - `client_secret=<客户端密钥>`
- 解析响应 JSON，构造 `OidcTokenResponse`：
  - `access_token`（必需）
  - `id_token`（必需）
  - `token_type`（默认 `Bearer`）
  - `expires_in`（默认 0）
  - `refresh_token`（可选，仅字符串类型时提取）
- 任一必需字段缺失返回 `null`。

### 3.3 ID Token 校验

`ValidateIdTokenAsync(idToken, expectedIssuer, expectedAudience, expectedNonce, ct)` 已通过 discovery/JWKS 完成完整的 JWT 签名校验 + claims 校验，签名校验失败返回 `null`（fail-closed）：

1. 通过 `ConfigurationManager<OpenIdConnectConfiguration>` 获取 `{expectedIssuer}/.well-known/openid-configuration` discovery 文档与 JWKS 公钥（按 issuer 缓存，1 小时自动刷新）。
2. 使用 `JsonWebTokenHandler` + `TokenValidationParameters` 完成校验：
   - **签名校验**：`ValidateIssuerSigningKey = true`，使用 JWKS 签名密钥（`IssuerSigningKeys`）验签。
   - **iss 校验**：`ValidateIssuer = true`，`ValidIssuer = expectedIssuer`。
   - **aud 校验**：`ValidateAudience = true`，`ValidAudience = expectedAudience`。
   - **exp 校验**：`ValidateLifetime = true`，时钟偏移容差 1 分钟（`ClockSkew`）。
3. **nonce 校验**：若 `expectedNonce` 非空，验签通过后再检查 ID Token 的 `nonce` claim 必须严格相等，不匹配返回 `null`。
4. 校验通过后从 token 中收集全部 claims 到 `Dictionary<string, string>`（多值 claim 用逗号合并）。
5. 任一校验失败（含 discovery/JWKS 获取失败、签名无效、iss/aud/exp/nonce 不匹配）记录警告日志并返回 `null`（fail-closed）。

### 3.4 历史说明

> 早期实现仅做 claims 校验、不校验 JWT 签名（历史安全限制，代码中曾标注 `TODO`）。当前实现已通过 discovery/JWKS 完成签名校验 + iss/aud/exp/nonce 校验，签名失败返回 `null`（fail-closed），上述限制不再存在。

### 3.5 配置来源

OIDC 参数不来自 `IConfiguration` 的 `Oidc:*` 配置键，而是来源于 SSO Providers 管理数据（`SsoProvider` 实体：`IssuerUrl` / `ClientId` / `ClientSecret` 等），由 `SsoService` 在授权码交换与回调校验时按 Provider 读取。

### 3.6 HttpClient 配置

- **HttpClient 名称**：`oidc`
- **超时**：15 秒
- **Polly 重试**：未配置（仅设置超时），调用方应自行处理 transient 错误
- **Fallback**：`IHttpClientFactory` 不可用时（如单元测试）回退到裸 `HttpClient`（超时 15 秒），并发出警告日志

---

## 4. SMTP 邮件集成

### 4.1 接口定义

`LicenseManager.Platform.Abstractions.IEmailSender.SendAsync(to, subject, htmlBody, ct)` 返回 `bool` 表示发送是否成功。

默认实现：`LicenseManager.Platform.Services.SmtpEmailSender`。

### 4.2 SmtpEmailSender 实现

构造时从 `IConfiguration` 读取 SMTP 连接参数，发送时基于 MailKit 构造 `MimeMessage`，通过 `MailKit.Net.Smtp.SmtpClient` 的 `SendAsync` 异步发送。

#### 4.2.1 默认值

| 参数 | 默认值 | 来源 |
| --- | --- | --- |
| `From` 邮箱 | `noreply@licensemanager.cn` | 常量 `DefaultFromEmail` |
| `From` 名称 | `LicenseManager` | 常量 `DefaultFromName` |
| `Port` | `25` | 解析失败时回退 |
| `EnableSsl` | `false` | 解析失败时回退 |
| `DevHost` | `localhost` | 常量，开发环境占位 |

### 4.3 配置项

| 来源 | 键 | 说明 |
| --- | --- | --- |
| `IConfiguration` | `Smtp:Host` | SMTP 服务器主机名；为空或 `localhost` 时跳过发送 |
| `IConfiguration` | `Smtp:Port` | SMTP 端口，默认 25 |
| `IConfiguration` | `Smtp:Username` | 认证用户名；非空时启用基本认证 |
| `IConfiguration` | `Smtp:Password` | 认证密码 |
| `IConfiguration` | `Smtp:EnableSsl` | 是否启用 SSL/TLS |
| `IConfiguration` | `Smtp:FromEmail` | 发件人邮箱，未配置使用默认值 |
| `IConfiguration` | `Smtp:FromName` | 发件人名称，未配置使用默认值 |

### 4.4 开发环境行为

当 `Smtp:Host` 未配置或显式等于 `localhost`（大小写不敏感）时，`SendAsync` 跳过实际发送：

- 记录警告日志：`SMTP not configured, skipping email send (to=..., subject=...)`
- 返回 `true`（成功）
- 目的：避免开发环境真的尝试连接 SMTP 服务器，同时保持调用方（如 `NotificationService`）的向后兼容。

收件人 `to` 为空时返回 `false` 并记录警告。

### 4.5 超时配置

`CancellationTokenSource.CancelAfter(30 秒)`（`SendTimeout` 常量）。MailKit 无简单 Timeout 属性，超时通过 CancellationToken 传递；并与调用方 `ct` 通过 `CreateLinkedTokenSource` 合并，同时作用于连接、认证、发送阶段。超时触发时记录错误日志并返回 `false`。

### 4.6 认证策略

- `Smtp:Username` 非空时：调用 `AuthenticateAsync(username, password)` 认证。
- `Smtp:Username` 为空时：跳过 `AuthenticateAsync`（匿名发送）。

### 4.7 实现说明

- 已使用 `MailKit`（Microsoft 官方推荐的 `System.Net.Mail.SmtpClient` 替代方案），提供真正的 async / cancellation token 支持与更稳健的 SSL/TLS 协商。
- SSL/TLS 模式按端口自动选择：端口 465 且启用 SSL 时使用隐式 SSL（`SecureSocketOptions.SslOnConnect`）；端口 587/25 启用 SSL 时使用 STARTTLS（`StartTlsWhenAvailable`）；未启用 SSL 时不加密（`None`）。
- 当前未通过命名 HttpClient 集成 SendGrid 等云邮件服务，`smtp` 命名 HttpClient 为预留占位。

---

## 5. Webhook 投递集成

### 5.1 WebhookDeliveryWorker 后台服务

`LicenseManager.Platform.Services.WebhookDeliveryWorker` 继承 `BackgroundService`，作为托管后台服务持续运行。投递记录由 `WebhookService.PublishAsync` 经 `IWebhookEndpointService.GetSubscribersAsync(eventType)` 生成——**管理端注册并启用的 Webhook 端点即出站订阅来源**（订阅事件匹配的端点会收到投递）。

本服务为 Singleton HostedService，而仓储在 Infrastructure 层为 Scoped（依赖 Scoped 的 DbContext），因此通过 `IServiceScopeFactory` 在**每轮投递中创建独立作用域**解析仓储，避免 captive dependency。

#### 5.1.1 执行循环

- **轮询间隔**：5 秒（`PollInterval`）
- **每轮上限**：50 条待投递记录（`Take(50)`），避免单轮失控
- **筛选条件**：
  - `Status == Pending`
  - `NextRetryAt == null` 或 `NextRetryAt <= DateTimeOffset.UtcNow`
- **排序**：按 `CreatedAt` 升序处理
- **异常处理**：单轮异常被捕获并记录，循环继续；`OperationCanceledException`（stoppingToken）时退出。
- **归档清理**：每轮投递后删除终态（Delivered / Failed）且创建于 **30 天前**的投递记录，防止表无限膨胀；清理失败仅记日志，不影响主投递流程。

#### 5.1.2 HttpClient 配置

- **HttpClient 名称**：`webhook`
- **超时**：15 秒
- **禁止自动重定向**：`AllowAutoRedirect = false`（防目标端点以 30x 重定向至内网地址造成 SSRF）
- **Fallback**：`IHttpClientFactory` 不可用时回退到裸 `HttpClient`（超时 15 秒）

### 5.2 重试策略（指数退避）

`HandleFailure(delivery, reason)` 在投递失败（非 2xx 或异常）时调用：

- `AttemptCount++`。
- 若 `AttemptCount >= MaxAttempts`：标记 `Status = Failed`，`NextRetryAt = null`，`LastResponse = reason`。
- 否则：计算下次重试时间 `NextRetryAt = UtcNow + delaySeconds`，其中
  - `delaySeconds = Min(5 * 2^(AttemptCount-1), 3600)`
  - 即第 1 次失败后 5s，第 2 次 10s，第 3 次 20s，第 4 次 40s，……，上限 1 小时。
- 失败原因记录到 `LastResponse`（仅在原值为空时覆盖，保留首次失败原因）。

| 重试次数 | 下次延迟 |
| --- | --- |
| 1 | 5s |
| 2 | 10s |
| 3 | 20s |
| 4 | 40s |
| 5 | 80s |
| ... | ... |
| 上限 | 3600s（1 小时） |

### 5.3 SSRF 防护

`WebhookEndpointService.ValidateWebhookUrl(url, isDevelopment)` 在端点创建/更新时校验目标 URL，`WebhookDeliveryWorker` 在每次投递前再次调用同一方法进行运行时校验。

#### 5.3.1 校验规则

1. **非空校验**：URL 不能为空。
2. **绝对 URL**：必须能被 `Uri.TryCreate(url, UriKind.Absolute, ...)` 解析。
3. **协议校验**：
   - 生产环境：必须 HTTPS。
   - 开发环境：HTTPS，或 `HTTP + localhost`。
4. **localhost 校验**：生产环境拒绝 `localhost` / `::1`。
5. **私有/环回 IP 校验**：直接 IP 形式的 URL 拒绝以下范围：
   - `127.0.0.0/8`（loopback）
   - `10.0.0.0/8`（class A private）
   - `169.254.0.0/16`（link-local）
   - `172.16.0.0/12`（class B private）
   - `192.168.0.0/16`（class C private）
   - `0.0.0.0/8`（current network）
   - IPv6 loopback / link-local / site-local

#### 5.3.2 失败处理

- 端点未找到：`Status = Failed`，`LastResponse = "endpoint_not_found"`。
- SSRF 校验拒绝：`Status = Failed`，`LastResponse = "url_rejected:{ex.Message}"`，不再重试。

#### 5.3.3 连接时 IP 校验（防 DNS rebinding）

`webhook` 命名 HttpClient 的 `SocketsHttpHandler` 配置了 `ConnectCallback`，在 **DNS 解析后、TCP 连接前**校验解析出的实际 IP（注册期 `ValidateWebhookUrl` 只校验 URL 字面量，攻击者可在校验后把域名重新解析到内网地址，即 DNS rebinding）：

- 全部解析结果命中私有/环回段时抛 `InvalidOperationException`（记为投递失败，不中断 worker 循环）；存在任一公网 IP 时优先连接首个公网地址。
- `IsPrivateOrLoopback` 判定覆盖：IPv4 `127.0.0.0/8`、`10.0.0.0/8`、`169.254.0.0/16`、`172.16.0.0/12`、`192.168.0.0/16`、`0.0.0.0/8`；IPv6 loopback、link-local、site-local、唯一本地 `fc00::/7`（首字节 `0xfc`/`0xfd`）；以及 **IPv4-mapped IPv6（`::ffff:10.x.x.x` 等，先还原为 IPv4 再判定，防止绕过私有段检查）**。

### 5.4 HMAC-SHA256 签名头

当端点配置了 `Secret` 时，每次投递附加签名头：

```
X-LicenseManager-Signature: sha256={hex_lowercase_hmac}
X-LicenseManager-Timestamp: {unix_seconds}
X-LicenseManager-Event: {delivery.EventType}
```

- 密钥来源：`WebhookEndpoint.Secret`，可能已通过 `SecretProtector.Encrypt` 加密存储。
- 解密逻辑：`SecretProtector.IsInitialized ? SecretProtector.Decrypt(secret) : secret`（未初始化时视为明文，向后兼容）。
- 签名算法：`HMAC-SHA256(key=utf8(secret), data=utf8("{timestamp}\n{payloadJson}"))`，输出十六进制小写串。
- 签名原文：**`"{timestamp}\n{payload}"`**（Unix 秒时间戳 + 换行 + 原始 payload），时间戳同时通过 `X-LicenseManager-Timestamp` 头下发（防重放）。
- Payload 内容：`delivery.PayloadJson`（原始 JSON 字符串）。

接收方应使用相同算法重新计算并常量时间比较，确保请求未被篡改。官方 SDK（csharp / nodejs / python / go / rust）的 `WebhookVerifier` 会对 `X-LicenseManager-Timestamp` 做 **±300 秒时效校验**；请求未携带该头时回退为纯 payload 签名校验（向后兼容旧版接收方）。

---

## 6. 弹性 HTTP 客户端策略

### 6.1 注册位置

`oidc` / `smtp` 由 `LicenseManager.Infrastructure.DependencyInjection.RegisterHttpClients` 在 `AddLicenseManagerInfrastructure` 中注册；`webhook` / `integration` 客户端由 Platform 层注册（`src/LicenseManager.Platform/DependencyInjection.cs`，Infrastructure 侧不再重复注册；Platform 侧另有一份 `oidc` 注册）。`webhook` 的 SSRF 防护 handler（`AllowAutoRedirect = false` + `ConnectCallback` 连接时 IP 校验，见 §5.3.3）依赖 Platform 层的 `WebhookEndpointService.IsPrivateOrLoopback`。

> 原 `psp` / `currency` 命名 HttpClient 已随支付/汇率模块商业化裁剪一并移除。

### 6.2 命名 HttpClient 清单

| 名称 | 超时 | Polly 重试次数 | 用途 |
| --- | --- | --- | --- |
| `oidc` | 15 秒 | 无 | OIDC discovery + token endpoint |
| `webhook` | 15 秒 | 无 | Webhook 投递（`AllowAutoRedirect = false` + `ConnectCallback` 连接时 IP 校验，见 §5.3.3；Platform 层注册） |
| `integration` | 30 秒 | 无 | 第三方集成同步与连通性测试（`IntegrationService`；Platform 层注册） |
| `smtp` | 无 | 无 | 占位命名 HttpClient，预留给将来 SendGrid 等基于 HttpClient 的邮件集成 |

### 6.3 Polly 重试策略

```csharp
static IAsyncPolicy<HttpResponseMessage> TransientRetryPolicy(int retries) =>
    HttpPolicyExtensions.HandleTransientHttpError()
        .OrResult(r => r.StatusCode == HttpStatusCode.TooManyRequests)
        .WaitAndRetryAsync(retries, attempt => TimeSpan.FromSeconds(Math.Pow(2, attempt)));
```

- **触发条件**：
  - `HttpRequestException`（网络层错误）
  - HTTP 5xx 服务端错误
  - HTTP 408 Request Timeout
  - HTTP 429 Too Many Requests
- **退避策略**：指数退避，`2^attempt` 秒（即 2s / 4s / 8s ...）。
- **应用范围**：原仅 `psp` 客户端启用，且仅限 GET 请求（涉及资金的 POST 端点不得自动重试，防止超时重发导致重复创建意图/重复退款）——该客户端已随支付模块商业化裁剪移除；当前 `oidc` / `webhook` / `smtp` / `integration` 均未启用 Polly，上述策略保留为通用基础设施。

### 6.4 超时配置

| 客户端 | 超时 | 说明 |
| --- | --- | --- |
| `oidc` | 15s | Discovery + Token 两次连续调用，需较短超时 |
| `webhook` | 15s | 第三方端点响应不可控，需较短超时 |
| `smtp` | 未设置 | 当前未使用 |

---

## 7. 凭据加密

### 7.1 SecretProtector（AES-GCM）

`LicenseManager.Platform.Services.SecretProtector` 是静态加密服务，使用 AES-GCM 对称加密算法保护敏感字符串（如 Webhook 端点密钥、第三方 API 凭据）。

#### 7.1.1 算法参数

| 参数 | 值 |
| --- | --- |
| 算法 | AES-GCM |
| 密钥长度 | 32 字节（256 位） |
| Nonce 长度 | 12 字节 |
| Tag 长度 | 16 字节 |
| Nonce 生成 | `RandomNumberGenerator.GetBytes`（每次加密随机） |
| 密文格式 | `v1:{nonceBase64}:{ciphertextBase64}:{tagBase64}` |

### 7.2 环境变量 LICENSEMANAGER_DATA_PROTECTION_KEY

| 属性 | 值 |
| --- | --- |
| 变量名 | `LICENSEMANAGER_DATA_PROTECTION_KEY` |
| 编码 | base64 字符串 |
| 解码后长度 | 必须为 32 字节 |
| 优先级 | 环境变量 > `IConfiguration[同名键]` |
| 生产环境 | **必须设置**，否则 `Initialize` 抛出 `InvalidOperationException` |
| 开发环境 | 未设置时使用进程级缓存的随机密钥，每次重启变化 |

### 7.3 初始化流程

`SecretProtector.Initialize(configuration, env, logger)` 应在应用启动时调用一次（DI 注册阶段）：

1. 双重检查锁（`_initLock`）确保只初始化一次。
2. 优先从环境变量读取密钥，其次从 `IConfiguration[KeyEnvironmentVariable]` 读取。
3. base64 解码并校验长度必须为 32 字节，否则抛出异常。
4. 生产环境（`env.IsProduction()`）密钥缺失时抛出 `InvalidOperationException`。
5. 开发环境密钥缺失时使用 `RandomNumberGenerator.GetBytes(32)` 生成随机密钥，并发出警告日志。
6. 设置 `_isInitialized = true`。

### 7.4 向后兼容（旧 XOR 数据）

`Decrypt` 方法支持三种输入：

1. **`v1:` 前缀**：走 AES-GCM 解密（`DecryptV1`）。
2. **无 `v1:` 前缀但合法 base64**：走旧版 XOR + base64 伪解码（`bytes[i] ^= 0x5A`），用于迁移期兼容历史数据。
3. **非 base64 输入**：原样返回（视为明文）。

> `IsEncrypted(value)` 仅识别 `v1:` 前缀，旧 XOR 数据不会被识别为已加密。这影响 `WebhookEndpointService.EncryptSecret` 的判断：当 `SecretProtector.IsInitialized` 为 `false` 时（如单元测试），密钥以明文存储。

### 7.5 使用场景

| 调用方 | 用途 |
| --- | --- |
| `WebhookEndpointService.CreateAsync` | 加密 Webhook 端点密钥后入库 |
| `WebhookDeliveryWorker.TryDeliverAsync` | 投递前解密端点密钥用于 HMAC 签名 |
| `IntegrationService` | 加密存储第三方集成的敏感凭据 |

---

## 8. 配置参考

### 8.1 环境变量清单

| 环境变量 | 必填 | 说明 |
| --- | --- | --- |
| `LICENSEMANAGER_DATA_PROTECTION_KEY` | 生产必填 | base64 编码的 32 字节 AES-GCM 密钥 |
| `ASPNETCORE_ENVIRONMENT` | 推荐 | `Development` / `Staging` / `Production` |

> 原 `STRIPE_*` / `PAYPAL_*` / 各 PSP `*_WEBHOOK_SECRET` / `MOCK_WEBHOOK_SECRET` 等支付凭据环境变量已随支付模块商业化裁剪移除。

### 8.2 appsettings 配置项清单

```json
{
  "Database": {
    "Provider": "MySql",
    "ConnectionString": "...",
    "ServerVersion": "8.0.32" // 可选覆盖项，仅特定提供程序需要时配置
  },
  "Smtp": {
    "Host": "smtp.example.com",
    "Port": 587,
    "Username": "postmaster@license-manager.local",
    "Password": "...",
    "EnableSsl": true,
    "FromEmail": "noreply@licensemanager.cn",
    "FromName": "LicenseManager"
  },
  "RateLimit": {
    "PerMinute": 600,
    "TrustXForwardedFor": false // 反向代理部署时显式开启才信任 X-Forwarded-For；默认 false（直接用 RemoteIpAddress）
  },
  "ConnectionStrings": {
    "Redis": "redis://...",
    "PostgreSQL": "...",
    "MySql": "...",
    "Default": "..."
  }
}
```

> 原 `Stripe` / `PayPal` / 各 PSP 与 `Mock` 的 Webhook Secret 配置键已随支付模块商业化裁剪移除；空字符串统一归一化为 `null` 的归一化规则不变。

---

## 9. 集成测试指南

### 9.1 测试环境行为（Webhook 验签）（已废弃）

> PSP 适配器及其入站 Webhook 验签已随商业化裁剪移除，本节原「WebhookSecret 为 null 时 fail-closed」测试矩阵不再适用；出站 Webhook 投递（`webhook` HttpClient，见 §5）不受影响。

### 9.2 InMemory Database 兼容性

`AddLicenseManagerInfrastructure` 的设计兼容 InMemory 数据库用于测试：

```csharp
services.AddDbContext<LicenseManagerDbContext>(opts =>
{
    if (!string.IsNullOrEmpty(dbOptions.ConnectionString))
    {
        ConfigureDatabaseProvider(opts, dbOptions, configuration);
    }
    configureDb?.Invoke(opts);  // 测试宿主通过此回调注入 UseInMemoryDatabase
});
```

- `Database:ConnectionString` 为空时跳过具体提供程序配置，仅执行 `configureDb` 回调。
- 测试宿主可通过 `configureDb: opts => opts.UseInMemoryDatabase("test")` 注入 InMemory 提供程序。
- EF Repository 替换 InMemoryRepository 的注册（`services.Replace`）在测试环境同样生效，因此关键实体（User / ApiKey / AuditLog / Subscription）在测试中以 EF + InMemory 方式持久化。

### 9.3 IHttpClientFactory 缺失场景

多个组件支持 `IHttpClientFactory` 为 `null` 的 fallback：

| 组件 | Fallback 行为 |
| --- | --- |
| `OidcClient` | 裸 `HttpClient`，超时 15 秒，记录警告日志 |
| `WebhookDeliveryWorker` | 裸 `HttpClient`，超时 15 秒 |

> 在测试环境若需触发真实 HTTP 调用路径，应在 DI 容器中注册 `IHttpClientFactory`（`services.AddHttpClient()`）。

### 9.4 SecretProtector 测试场景

- **未初始化**：`IsInitialized = false`，`WebhookEndpointService.EncryptSecret` 保留明文，`WebhookDeliveryWorker` 直接使用明文密钥签名。
- **开发环境随机密钥**：进程重启后旧密文无法解密，仅适用于单次运行的测试。
- **生产环境**：未设置环境变量时 `Initialize` 抛异常，启动失败。

---

## 10. 附录：各 PSP API 端点参考（已废弃）

> 支付/PSP 集成已随商业化裁剪移除，原各 PSP API 端点参考（Stripe / PayPal / Alipay / WeChat Pay / Adyen / Braintree / UnionPay / Razorpay / MercadoPago / LemonSqueezy）已删除；资金流由外部销售系统承接。

---

> **文档维护说明**：本指南内容基于 `LicenseManager.Infrastructure` 与 `LicenseManager.Platform` 实际代码生成。当适配器实现、HttpClient 注册、环境变量清单发生变更时，应同步更新本文件并提升修订版本号。
