# LicenseManager Platform - 数据库迁移指南

| 项目 | 内容 |
| --- | --- |
| 文档版本 | v1.0 |
| 文档类型 | 数据库迁移指南 (Database Migration Guide) |
| 关联文档 | [DB Design](./LicenseManager-Platform-Database-Design-v1.0.md) · [Deployment Guide](./LicenseManager-Platform-Deployment-Guide-v1.0.md) · [Dev Setup Guide](./LicenseManager-Platform-Dev-Setup-Guide-v1.0.md) · [RDP v1.0.1](./LicenseManager-Platform-RDP-v1.0.1.md) |
| 目标读者 | 后端工程师、DBA、SRE、平台架构师 |
| 文档日期 | 2026-07-25 |

---

## 0. 修订历史

| 版本 | 日期 | 修订内容 |
| --- | --- | --- |
| v1.0 | 2026-07-25 | 初稿，基于 EF Core 8.0.10 实现与 P2 持久化迁移记录编写 |
| v1.1 | 2026-09 | 按代码实测同步：迁移 7 → 10 个（补 `WidenWebhookEndpointSecret`、`AddFeatureCatalogFields`、`_V2`）；`services.Replace` 覆盖实体 22 → 45 个（全量 EF 化）；TenantId 全局过滤器实体 24 → 27 个（反射动态附加） |
| v1.2 | 2026-10-08 | 商业化裁剪：移除订单/支付/发票/PSP/优惠券等资金流相关章节，资金流移交外部销售系统 |
| v1.3 | 2026-10-16 | `IUnitOfWork` 上移至 `src/LicenseManager.Platform/Abstractions/` 并新增 `ExecuteInTransactionAsync`；核对 4.3/4.5 与迁移锁（`MigrationAdvisoryLock` + `Database:MigrateOnStartup`）现状一致 |

---

## 1. 概述

### 1.1 技术栈

| 组件 | 版本 | 说明 |
| --- | --- | --- |
| EF Core | 8.0.10 | `Microsoft.EntityFrameworkCore` / `Microsoft.EntityFrameworkCore.Relational` |
| PostgreSQL 提供程序 | 8.0.10 | `Npgsql.EntityFrameworkCore.PostgreSQL`（默认推荐） |
| SQL Server 提供程序 | 8.0.10 | `Microsoft.EntityFrameworkCore.SqlServer` |
| MySQL 提供程序 | 8.0.2 | `Pomelo.EntityFrameworkCore.MySql`（生产推荐） |
| SQLite 提供程序 | 8.0.10 | `Microsoft.EntityFrameworkCore.Sqlite`（开发与测试） |
| Oracle 提供程序 | 8.23.60 | `Oracle.EntityFrameworkCore` |
| EF Core InMemory | 8.0.10 | 测试环境使用 |
| 目标框架 | .NET 8.0 | `LicenseManager.Infrastructure` 程序集 |

### 1.2 数据库提供程序

`LicenseManager.Infrastructure` 同时引用 5 种关系型数据库提供程序包，运行时通过 `Database:Provider` 配置节点切换，无需改动代码。提供程序枚举定义于 `DatabaseProvider.cs`：

| 枚举值 | 提供程序 | 用途 |
| --- | --- | --- |
| `PostgreSQL = 0` | Npgsql | 默认值，DatabaseOptions 未配置时回退 |
| `SqlServer = 1` | EF Core SqlServer | 私有化部署可选 |
| `MySql = 2` | Pomelo MySQL | 生产推荐（当前 `appsettings.json` 默认） |
| `Sqlite = 3` | EF Core Sqlite | 开发与本地测试 |
| `Oracle = 4` | Oracle EF Core | 企业客户可选 |

### 1.3 迁移策略

| 策略 | 适用场景 | 实现方式 |
| --- | --- | --- |
| **Code-First 迁移** | MySQL 生产环境 | `dotnet ef migrations add` 生成迁移；启动时 `MigrateAsync` 自动应用（仅 `ProviderName == "Pomelo.EntityFrameworkCore.MySql"`） |
| **EnsureCreated 建表** | PostgreSQL / SQL Server / SQLite / Oracle / InMemory 测试宿主 | `db.Database.EnsureCreatedAsync()` 直接按模型建表，跳过迁移历史（永不迁移） |
| **DI 覆盖持久化层** | 已上线服务无感切换 | `services.Replace(...)` 用 `EfRepository<T>` 覆盖 `InMemoryRepository<T>` |

迁移文件按提供程序拆分到独立程序集 `src/LicenseManager.Infrastructure.Migrations.{MySql,PostgreSql,Sqlite,SqlServer,Oracle}/Migrations/`。以 MySQL 方言为例，当前共 **8** 个迁移：`20260930060249_InitialCreate`（重建基线，50 张表，含 4 张安全台账表与 `license.RowVersion` 并发令牌列）、`20261006132444_AddSsoSamlFields`、`20261006153950_AddUserPhone`、`20261006164444_SystemConfigTable`（新建 `system_config` 表）、`20261007154400_AddCustomerContactFields`、`20261007170657_RemoveCommerce`（商业化裁剪：移除订单/支付/发票/PSP/优惠券等 11 张资金流相关表，资金流移交外部销售系统）、`20261014154021_PersistRemainingEntities`（32 个领域实体补持久化）、`20261015003447_AddTenantAndLookupIndexes`（14 个 `TenantId` 索引 + `license_record.LicenseKey` 唯一索引）；Oracle 方言另有 `NormalizeDecimalFormat`，共 9 个。当前模型快照共 **72** 张实体表（含 `__EFMigrationsHistory` 合计 73 张）。

> **SQLite 方言锚点迁移 `SqliteDateTimeOffsetAsUtc`（空操作）**：为根治 Sqlite 不支持 DateTimeOffset 比较/排序下推的问题，运行时仅在 `Database:Provider=Sqlite` 时为全部 DateTimeOffset 属性注册 `DateTimeOffset → DateTime（UtcDateTime）` 值转换器（`SqliteDateTimeOffsetConversionConvention`）。模型快照中这些属性 CLR 类型由 `DateTimeOffset` 变为 `DateTime`，但两种映射的 Sqlite 存储类型均为 `TEXT`，列定义不变，故该迁移不含任何 DDL，仅作为模型锚点写入迁移历史。**存量 SQLite 库注意**：迁移不改写存量数据，无转换器时代的 DateTimeOffset 按 `"yyyy-MM-dd HH:mm:ss.fffffffzzz"`（含偏移后缀）存储，新转换器读取该旧格式受机器时区影响——非 UTC 机器物化时抛 `ArgumentException`，UTC 机器恰可归一化读回（实测，见设计文档 9.4 节）。升级前须对存量库执行一次性数据清洗：可利用 SQLite 内建日期函数解析偏移并归一化为 UTC（如 `UPDATE audit_log SET OccurredAt = strftime('%Y-%m-%d %H:%M:%f', OccurredAt)`——SQLite 日期函数识别 `+HH:MM` 偏移并归一化为 UTC，注意 `%f` 仅毫秒精度；对亚毫秒精度有要求的列建议用 .NET 清洗脚本逐行重写），或按业务导出重建，否则旧行在非 UTC 机器上将无法读取。

---

## 2. DbContext 配置

### 2.1 LicenseManagerDbContext 结构

`LicenseManagerDbContext` 派生自 `DbContext`，同时实现 `Core.ILicenseStore` 接口，使许可证持久化能力直接挂载在主上下文上。构造函数注入 `DbContextOptions<LicenseManagerDbContext>`、`DatabaseOptions` 与可选的 `ITenantContext`（为 null 时不应用租户过滤），`DatabaseOptions` 用于在 `OnModelCreating` 阶段解析当前提供程序对应的 JSON 列类型。此外，`OnModelCreating` 通过反射扫描并为 **27** 个含 `TenantId` 的实体动态附加 `HasQueryFilter` 多租户全局查询过滤器，实现租户级数据可见性隔离。

```csharp
public class LicenseManagerDbContext : DbContext, ILicenseStore
{
    private readonly DatabaseOptions _dbOptions;
    private string JsonType => JsonColumnTypeResolver.GetJsonColumnType(_dbOptions.Provider);
    // ...
}
```

DbContext 注册方式参见 `DependencyInjection.cs`：

```csharp
services.AddDbContext<LicenseManagerDbContext>(opts =>
{
    if (!string.IsNullOrEmpty(dbOptions.ConnectionString))
        ConfigureDatabaseProvider(opts, dbOptions, configuration);
    configureDb?.Invoke(opts);  // 允许测试宿主覆盖为 InMemory
});
```

### 2.2 实体映射

DbContext 暴露 30+ 个 `DbSet`，按业务域划分为 8 组。所有表名采用蛇形（snake_case）命名，并通过 `ConfigureXxx` 私有方法集中配置。

#### 2.2.1 核心许可证表

| DbSet | 表名 | 主键 | 唯一索引 | JSON 列 |
| --- | --- | --- | --- | --- |
| `Licenses` | `license` | `Id` | `LicenseKey` | `Features`, `Metadata`, `Notes` |

#### 2.2.2 身份与访问管理表

| DbSet | 表名 | 主键 | 唯一索引 | JSON 列 |
| --- | --- | --- | --- | --- |
| `Users` | `user_account` | `Id` | `Email` | `RoleIds` |
| `Roles` | `role` | `Id` | `(TenantId, Name)` | `Permissions` |
| `ApiKeys` | `api_key` | `Id` | `KeyId` | `Scopes` |
| `PersonalAccessTokens` | `personal_access_token` | `Id` | - | `Scopes` |
| `Tenants` | `tenant` | `Id` | `Code` | `Settings` |

> 说明：`User` 实体映射到 `user_account` 表，避开某些数据库（如 PostgreSQL）保留字 `user`。

#### 2.2.3 产品目录表

| DbSet | 表名 | 主键 | 唯一索引 | JSON 列 |
| --- | --- | --- | --- | --- |
| `Products` | `product` | `Id` | `Code` | `Metadata` |
| `FeatureCatalog` | `feature_catalog` | `Id` | `Code` | - |
| `LicenseTemplates` | `license_template` | `Id` | `(ProductId, Code)` | `Features`, `Metadata` |

#### 2.2.4 许可证生命周期表

| DbSet | 表名 | 主键 | 非唯一索引 | JSON 列 |
| --- | --- | --- | --- | --- |
| `Activations` | `activation` | `Id` | `(LicenseId, HardwareId)` | - |
| `Devices` | `device` | `Id` | `HardwareId` | - |
| `ConsumptionLogs` | `consumption_log` | `Id` | `(LicenseId, FeatureCode)` | - |
| `LicenseEvents` | `license_event` | `Id` | `LicenseId` | - |
| `LicenseRecords` | `license_record` | `Id` | `LicenseKey` | - |

#### 2.2.5 订阅表

| DbSet | 表名 | 主键 | 索引 | JSON 列 |
| --- | --- | --- | --- | --- |
| `Subscriptions` | `subscription` | `Id` | `(CustomerId, ProductId)` | - |

> 说明：原 2.2.5 订单表（`Orders`）与原 2.2.6 账单与支付表（`Invoices` / `Quotes` / `PaymentIntents` / `Refunds` / `PaymentMethods` / `Currencies` / `ExchangeRates` / `PspConfigs`）已随商业化裁剪删除，资金流移交外部销售系统。

#### 2.2.6 浮动许可证表

| DbSet | 表名 | 主键 | 索引 |
| --- | --- | --- | --- |
| `FloatingServers` | `floating_server` | `Id` | - |
| `Leases` | `lease` | `Id` | `(FloatingServerId, Status)` |

#### 2.2.7 审计与 Webhook 表

| DbSet | 表名 | 主键 | 索引 | JSON 列 |
| --- | --- | --- | --- | --- |
| `AuditLogs` | `audit_log` | `Id` | `(ResourceType, ResourceId)`、`OccurredAt` | `BeforeJson`, `AfterJson` |
| `WebhookEndpoints` | `webhook_endpoint` | `Id` | - | `SubscribedEvents` |
| `WebhookDeliveries` | `webhook_delivery` | `Id` | `(Status, NextRetryAt)` | `PayloadJson` |

> 说明：原 `WebhookEventRecords`（`webhook_event_record`，PSP 回调去重台账）已随商业化裁剪删除。

#### 2.2.7b 安全台账表（R4-B 新增）

`LoginAttempts` → `login_attempt`（`Identifier + OccurredAt` 索引）、`IpWhitelistEntries` → `ip_whitelist_entry`（`TenantId` 索引）、`NonceRecords` → `nonce_record`（`Nonce` 唯一索引）、`IdempotencyRecords` → `idempotency_record`（`Key` 唯一索引）。这 4 张表已包含在各方言 `InitialCreate` 迁移中，无额外补迁移要求。

#### 2.2.8 系统管理与高级模块表

| DbSet | 表名 | 主键 | 索引 | JSON 列 |
| --- | --- | --- | --- | --- |
| `SystemKeys` | `system_key` | `Id` | `(KeyType, Version)` (Unique) | - |
| `Notifications` | `notification` | `Id` | `Status` | `PayloadJson` |
| `NamedUserAssignments` | `NamedUserAssignments` | `Id` | - | - |
| `EntitlementSets` | `EntitlementSets` | `Id` | - | - |
| `LicenseEntitlements` | `LicenseEntitlements` | `Id` | - | - |
| `LicenseUsers` | `LicenseUsers` | `Id` | - | - |
| `TokenPools` | `TokenPools` | `Id` | - | - |
| `TokenConsumptionLogs` | `TokenConsumptionLogs` | `Id` | - | - |
| `Eulas` | `Eulas` | `Id` | - | - |
| `EulaAcceptances` | `EulaAcceptances` | `Id` | - | - |
| `SsoProviders` | `SsoProviders` | `Id` | - | - |
| `SsoUserBindings` | `SsoUserBindings` | `Id` | - | - |
| `Integrations` | `Integrations` | `Id` | - | - |
| `IntegrationSyncLogs` | `IntegrationSyncLogs` | `Id` | - | - |

### 2.3 索引配置

索引策略遵循以下原则（实现于各 `ConfigureXxx` 方法）：

1. **业务唯一性约束** —— `LicenseKey`、`Email`、`KeyId`、`Code`、`Number` 等业务标识符全部声明 `IsUnique()` 索引，由数据库强制唯一性。
2. **复合索引覆盖高频查询** —— 如 `audit_log(ResourceType, ResourceId)` 支持按资源定位审计记录；`webhook_delivery(Status, NextRetryAt)` 支持重试调度器扫描待投递记录。
3. **时间序列索引** —— `audit_log.OccurredAt` 单列索引支持按时间范围审计查询。
4. **租户隔离复合唯一** —— `role(TenantId, Name)` 在租户范围内强制角色名唯一。

### 2.4 命名约定

| 维度 | 约定 | 示例 |
| --- | --- | --- |
| 表名 | 全小写 + 下划线分隔（snake_case），单数形式；高级模块表使用 EF 默认 PascalCase 复数命名（未显式 `ToTable`） | `user_account`、`audit_log`、`api_key` |
| 列名 | PascalCase（EF Core 默认） | `LicenseKey`、`CreatedAt` |
| 主键 | `Id`（Guid） | 所有实体统一 |
| 枚举列 | `HasConversion<string>()` + `HasMaxLength(32)` | `Status`、`LicenseType` |
| JSON 列 | 通过 `JsonColumnTypeResolver` 按提供程序解析 | PostgreSQL: `jsonb` / MySQL: `json` |
| 字符串 | 关键字段显式声明 `HasMaxLength` 与 `IsRequired` | `Email(255)`, `LicenseKey(128)` |

### 2.5 JSON 列与值转换器

不同数据库对 JSON 的原生支持差异由两层机制抹平：

**第一层：`JsonColumnTypeResolver`** —— 按提供程序返回列类型名：

| 提供程序 | 列类型 |
| --- | --- |
| PostgreSQL | `jsonb` |
| MySQL | `json` |
| SQL Server | `nvarchar(max)` |
| SQLite | `TEXT` |
| Oracle | `NCLOB` |

**第二层：`MarkDictionaryAndListAsScalar`** —— 在 `OnModelCreating` 开头扫描所有实体的 CLR 属性，将 `Dictionary<,>` 与可写 `List<T>` 强制声明为 JSON 标量列，避免 Pomelo MySQL 因无法推断关系而抛出 `Unable to determine the relationship...`。只读集合接口（`IReadOnlyList<T>` 等）被显式 `Ignore`。

**第三层：`JsonValueConverterConvention`** —— 仅当 Provider 为 `MySql` 时执行，为每个 JSON 列附加 `JsonValueConverter<T>`，通过 `JsonSerializer` 完成 CLR 对象与 JSON 字符串的往返序列化。PostgreSQL / SQL Server / SQLite 由提供程序原生处理，无需附加转换器。

---

## 3. 仓储模式

### 3.1 IRepository&lt;T&gt; 接口

定义于 `src/LicenseManager.Platform/Abstractions/IRepository.cs`，是平台服务层依赖的泛型聚合根仓储抽象。约定实体主键为 `Guid`：

```csharp
public interface IRepository<T> where T : class
{
    Task<T?> GetAsync(Guid id, CancellationToken ct = default);
    Task<T?> FirstOrDefaultAsync(Expression<Func<T, bool>> predicate, CancellationToken ct = default);
    Task<IReadOnlyList<T>> ListAsync(CancellationToken ct = default);
    Task SaveAsync(T entity, CancellationToken ct = default);
    Task DeleteAsync(Guid id, CancellationToken ct = default);
}
```

> 该接口是一个通用的“以 Guid 为主键的聚合根仓储”抽象，运行时可由内存实现（`InMemoryRepository<T>`）或 EF Core 持久化实现（`EfRepository<T>`）提供，具体由依赖注入配置决定。

### 3.2 EfRepository&lt;T&gt; 实现

`EfRepository<T>` 位于 `src/LicenseManager.Infrastructure/Persistence/EfRepository.cs`，实现 `IRepository<T>`，构造函数注入 `LicenseManagerDbContext`。核心方法策略：

| 方法 | 实现要点 |
| --- | --- |
| `GetAsync(Guid id)` | 调用 `DbSet.FindAsync(id)`，优先利用变更跟踪器一级缓存；若未命中再查询数据库 |
| `ListAsync()` | `AsNoTracking()` + `OrderBy(e => EF.Property<Guid>(e, "Id"))` + `Take(10000)` |
| `SaveAsync(T entity)` | 反射提取 `Id` → `FindAsync` 判定存在性 → 不存在则 `AddAsync`，存在则 `Entry(existing).CurrentValues.SetValues(entity)` → 立即 `SaveChangesAsync` |
| `DeleteAsync(Guid id)` | `FindAsync` 加载实体 → `Remove` → 立即 `SaveChangesAsync` |

**AsNoTracking + Take(10000) 保护**：`ListAsync` 是面向“列出全部”的语义，但 `audit_log`、`license_event`、`consumption_log` 等增长型表会无界膨胀。强制 `Take(10000)` 上限避免单次查询拖垮内存与变更跟踪器；调用方处理大数据量应使用专用分页查询接口。`AsNoTracking` 跳过变更跟踪器，避免 1 万条实体撑爆跟踪器内存。

**OrderBy(Id) 排序**：`OrderBy(e => EF.Property<Guid>(e, "Id"))` 保证分页结果稳定可复现，同时利用 Guid 主键的时间排序特性（UUID v7）近似按创建时间返回。

**主键提取**：`ExtractId` 通过反射读取 `Id` 属性（约定所有聚合根均以 `Id` 为 `Guid` 主键），缺失时抛出 `InvalidOperationException`。该约定避免了泛型仓储需为每个实体传入 `keySelector` 的样板代码。

### 3.3 内存仓储 vs EF 仓储（DI 覆盖策略）

平台层 `src/LicenseManager.Platform/DependencyInjection.cs` 默认将所有 `IRepository<T>` 注册为 `Singleton`，由 `InMemoryRepository<T>` 基于 `ConcurrentDictionary<Guid, T>` 提供线程安全的进程内存储。

基础设施层 `AddLicenseManagerInfrastructure` 通过 `services.Replace(...)` 显式覆盖 **34 个实体**的注册，将其替换为 `EfRepository<T>`（`Scoped` 生命周期）——全部已建模实体均已 EF 化，不再存在"仅内存仓储"的领域实体（`LoginAttempt` 除外，其为有意保留的内存记录，见 Troubleshooting）。覆盖实体包括：`User`、`Role`、`ApiKey`、`AuditLog`、`Subscription`、`Product`、`Customer`、`LicenseRecord`、`LicenseTemplate`、`Activation`、`ConsumptionLog`、`LicenseEvent`、`WebhookDelivery`、`WebhookEndpoint`、`PersonalAccessToken`、`Tenant`、`FeatureCatalogEntry`、`Device`、`FloatingServer`、`Lease`、`SystemKey`、`Notification`、`NamedUserAssignment`、`EntitlementSet`、`LicenseEntitlement`、`LicenseUser`、`TokenPool`、`TokenConsumptionLog`、`Eula`、`EulaAcceptance`、`SsoProvider`、`SsoUserBinding`、`Integration`、`IntegrationSyncLog`（原覆盖清单中的 `Order`、`Currency`、`ExchangeRate`、`PaymentIntent`、`Refund`、`Invoice`、`PspConfig`、`WebhookEventRecord`、`Quote`、`PaymentMethod`、`SubscriptionPolicy` 已随商业化裁剪删除）。

| 实体 | Platform 层注册 | Infrastructure 层覆盖 |
| --- | --- | --- |
| `User` | `Singleton<IRepository<User>, InMemoryRepository<User>>` | `Scoped<IRepository<User>, EfRepository<User>>` |
| `Role` | `Singleton<IRepository<Role>, InMemoryRepository<Role>>` | `Scoped<IRepository<Role>, EfRepository<Role>>` |
| `ApiKey` | `Singleton<IRepository<ApiKey>, InMemoryRepository<ApiKey>>` | `Scoped<IRepository<ApiKey>, EfRepository<ApiKey>>` |
| `AuditLog` | `Singleton<IRepository<AuditLog>, InMemoryRepository<AuditLog>>` | `Scoped<IRepository<AuditLog>, EfRepository<AuditLog>>` |
| `Subscription` | `Singleton<IRepository<Subscription>, InMemoryRepository<Subscription>>` | `Scoped<IRepository<Subscription>, EfRepository<Subscription>>` |
| `Product` | `Singleton<IRepository<Product>, InMemoryRepository<Product>>` | `Scoped<IRepository<Product>, EfRepository<Product>>` |
| `Customer` | `Singleton<IRepository<Customer>, InMemoryRepository<Customer>>` | `Scoped<IRepository<Customer>, EfRepository<Customer>>` |
| `LicenseRecord` | `Singleton<IRepository<LicenseRecord>, InMemoryRepository<LicenseRecord>>` | `Scoped<IRepository<LicenseRecord>, EfRepository<LicenseRecord>>` |
| `LicenseTemplate` | `Singleton<IRepository<LicenseTemplate>, InMemoryRepository<LicenseTemplate>>` | `Scoped<IRepository<LicenseTemplate>, EfRepository<LicenseTemplate>>` |
| `Activation` | `Singleton<IRepository<Activation>, InMemoryRepository<Activation>>` | `Scoped<IRepository<Activation>, EfRepository<Activation>>` |
| `ConsumptionLog` | `Singleton<IRepository<ConsumptionLog>, InMemoryRepository<ConsumptionLog>>` | `Scoped<IRepository<ConsumptionLog>, EfRepository<ConsumptionLog>>` |
| `LicenseEvent` | `Singleton<IRepository<LicenseEvent>, InMemoryRepository<LicenseEvent>>` | `Scoped<IRepository<LicenseEvent>, EfRepository<LicenseEvent>>` |
| `WebhookDelivery` | `Singleton<IRepository<WebhookDelivery>, InMemoryRepository<WebhookDelivery>>` | `Scoped<IRepository<WebhookDelivery>, EfRepository<WebhookDelivery>>` |
| `WebhookEndpoint` | `Singleton<IRepository<WebhookEndpoint>, InMemoryRepository<WebhookEndpoint>>` | `Scoped<IRepository<WebhookEndpoint>, EfRepository<WebhookEndpoint>>` |
| `PersonalAccessToken` | `Singleton<IRepository<PersonalAccessToken>, InMemoryRepository<PersonalAccessToken>>` | `Scoped<IRepository<PersonalAccessToken>, EfRepository<PersonalAccessToken>>` |
| `Tenant` | `Singleton<IRepository<Tenant>, InMemoryRepository<Tenant>>` | `Scoped<IRepository<Tenant>, EfRepository<Tenant>>` |
| `FeatureCatalogEntry` | `Singleton<IRepository<FeatureCatalogEntry>, InMemoryRepository<FeatureCatalogEntry>>` | `Scoped<IRepository<FeatureCatalogEntry>, EfRepository<FeatureCatalogEntry>>` |
| `Device` | `Singleton<IRepository<Device>, InMemoryRepository<Device>>` | `Scoped<IRepository<Device>, EfRepository<Device>>` |
| `FloatingServer` | `Singleton<IRepository<FloatingServer>, InMemoryRepository<FloatingServer>>` | `Scoped<IRepository<FloatingServer>, EfRepository<FloatingServer>>` |
| `Lease` | `Singleton<IRepository<Lease>, InMemoryRepository<Lease>>` | `Scoped<IRepository<Lease>, EfRepository<Lease>>` |
| `SystemKey` | `Singleton<IRepository<SystemKey>, InMemoryRepository<SystemKey>>` | `Scoped<IRepository<SystemKey>, EfRepository<SystemKey>>` |
| `Notification` | `Singleton<IRepository<Notification>, InMemoryRepository<Notification>>` | `Scoped<IRepository<Notification>, EfRepository<Notification>>` |
| `NamedUserAssignment` | `Singleton<IRepository<NamedUserAssignment>, InMemoryRepository<NamedUserAssignment>>` | `Scoped<IRepository<NamedUserAssignment>, EfRepository<NamedUserAssignment>>` |
| `EntitlementSet` | `Singleton<IRepository<EntitlementSet>, InMemoryRepository<EntitlementSet>>` | `Scoped<IRepository<EntitlementSet>, EfRepository<EntitlementSet>>` |
| `LicenseEntitlement` | `Singleton<IRepository<LicenseEntitlement>, InMemoryRepository<LicenseEntitlement>>` | `Scoped<IRepository<LicenseEntitlement>, EfRepository<LicenseEntitlement>>` |
| `LicenseUser` | `Singleton<IRepository<LicenseUser>, InMemoryRepository<LicenseUser>>` | `Scoped<IRepository<LicenseUser>, EfRepository<LicenseUser>>` |
| `TokenPool` | `Singleton<IRepository<TokenPool>, InMemoryRepository<TokenPool>>` | `Scoped<IRepository<TokenPool>, EfRepository<TokenPool>>` |
| `TokenConsumptionLog` | `Singleton<IRepository<TokenConsumptionLog>, InMemoryRepository<TokenConsumptionLog>>` | `Scoped<IRepository<TokenConsumptionLog>, EfRepository<TokenConsumptionLog>>` |
| `Eula` | `Singleton<IRepository<Eula>, InMemoryRepository<Eula>>` | `Scoped<IRepository<Eula>, EfRepository<Eula>>` |
| `EulaAcceptance` | `Singleton<IRepository<EulaAcceptance>, InMemoryRepository<EulaAcceptance>>` | `Scoped<IRepository<EulaAcceptance>, EfRepository<EulaAcceptance>>` |
| `SsoProvider` | `Singleton<IRepository<SsoProvider>, InMemoryRepository<SsoProvider>>` | `Scoped<IRepository<SsoProvider>, EfRepository<SsoProvider>>` |
| `SsoUserBinding` | `Singleton<IRepository<SsoUserBinding>, InMemoryRepository<SsoUserBinding>>` | `Scoped<IRepository<SsoUserBinding>, EfRepository<SsoUserBinding>>` |
| `Integration` | `Singleton<IRepository<Integration>, InMemoryRepository<Integration>>` | `Scoped<IRepository<Integration>, EfRepository<Integration>>` |
| `IntegrationSyncLog` | `Singleton<IRepository<IntegrationSyncLog>, InMemoryRepository<IntegrationSyncLog>>` | `Scoped<IRepository<IntegrationSyncLog>, EfRepository<IntegrationSyncLog>>` |

`services.Replace` 会移除容器中已存在的同类型注册并替换为新描述符，因此即便 Platform 层先注册了 `InMemoryRepository<T>`，Infrastructure 层后注册的 `EfRepository<T>` 也会生效。这是“接口实现覆盖、无服务层修改”的关键 —— 平台服务层（如 `UserService`、`ApiKeyService`、`AuditService`、`SubscriptionService`）的构造函数依然注入 `IRepository<T>`，对实现切换无感知。

### 3.4 IUnitOfWork 与 EfUnitOfWork

`IUnitOfWork` 接口位于 `src/LicenseManager.Platform/Abstractions/IUnitOfWork.cs`（已自 Infrastructure.Persistence.Abstractions 上移至 Platform.Abstractions，供 Core/Platform 服务层直接依赖），暴露两个方法：

```csharp
public interface IUnitOfWork
{
    Task<int> SaveChangesAsync(CancellationToken ct = default);
    Task ExecuteInTransactionAsync(Func<CancellationToken, Task> action, CancellationToken ct = default);
}
```

`ExecuteInTransactionAsync` 在单个数据库事务中执行一组操作：动作内的各次 SaveChanges 与最终统一提交共享同一事务，全部成功才提交，任一失败整体回滚；不支持事务的提供程序（如 EF Core InMemory）退化为顺序执行。签发与状态变更的 LicenseRecord/Core License 双写即通过该方法原子提交。

`EfUnitOfWork` 是其密封实现，包装 `LicenseManagerDbContext`，将 `SaveChangesAsync` 转发到 `DbContext.SaveChangesAsync`。DI 注册为 `Scoped`：

```csharp
services.AddScoped<IUnitOfWork, EfUnitOfWork>();
```

**与 `EfRepository.SaveAsync` 的协作**：`EfRepository<T>` 的 `SaveAsync` 在每次写入后立即 `SaveChangesAsync` 提交，保持与内存仓储一致的即时持久化语义。当业务场景需要跨多个仓储的事务一致性时（如“更新订阅状态 + 写入审计”原子操作），服务层注入 `IUnitOfWork`，先调用各仓储的写入方法（不立即提交），最后由 `IUnitOfWork.SaveChangesAsync` 一次性提交事务。`DbContext` 默认事务隔离级别即可保证一致性。

---

## 4. 迁移工作流

### 4.1 创建迁移

迁移按提供程序拆分到独立程序集，使用 `dotnet ef` CLI 在对应方言项目下生成迁移（新增/变更实体后需为各方言分别生成）：

```bash
# 在解决方案根目录执行（以 MySQL 方言为例；其余方言替换 --project 路径即可）
dotnet ef migrations add <MigrationName> \
  --project src/LicenseManager.Infrastructure.Migrations.MySql \
  --startup-project src/LicenseManager.Api
```

`MigrationsAssembly` 在 `ConfigureDatabaseProvider` 中按提供程序解析（`LicenseManager.Infrastructure.Migrations.{MySql,PostgreSql,Sqlite,SqlServer,Oracle}`），运行时仅加载当前提供程序对应的迁移程序集。生成的文件包含：

- `<timestamp>_<MigrationName>.cs` —— `Up` / `Down` 方法
- `<timestamp>_<MigrationName>.Designer.cs` —— 迁移时刻的模型快照
- `LicenseManagerDbContextModelSnapshot.cs` —— 当前模型快照（每次新增迁移会被重写）

> 迁移目录按提供程序拆分（`src/LicenseManager.Infrastructure.Migrations.{方言}/Migrations/`），以 MySQL 方言为例共有 **8** 个迁移（`InitialCreate` → `AddSsoSamlFields` → `AddUserPhone` → `SystemConfigTable` → `AddCustomerContactFields` → `RemoveCommerce` → `PersistRemainingEntities` → `AddTenantAndLookupIndexes`；Oracle 方言另有 `NormalizeDecimalFormat`）；当前模型快照共 **72** 张实体表（含 `__EFMigrationsHistory` 合计 73 张）。详见 §1.3。

### 4.2 应用迁移

```bash
# 应用所有未执行的迁移
dotnet ef database update \
  --project src/LicenseManager.Infrastructure.Migrations.MySql \
  --startup-project src/LicenseManager.Api

# 回滚到指定迁移
dotnet ef database update <PreviousMigrationName> \
  --project src/LicenseManager.Infrastructure.Migrations.MySql \
  --startup-project src/LicenseManager.Api

# 生成 SQL 脚本（用于 DBA 审核或离线气隙部署）
dotnet ef migrations script \
  --project src/LicenseManager.Infrastructure.Migrations.MySql \
  --startup-project src/LicenseManager.Api \
  --output ./migrate.sql
```

### 4.3 启动时自动迁移

`src/LicenseManager.Api/Program.cs` 在应用启动阶段、`SeedService.InitializeAsync` 之前，使用一个临时作用域执行数据库就绪检查。所有关系型提供程序（PostgreSQL / SQL Server / MySQL / SQLite / Oracle）统一走版本化迁移 `MigrateAsync`（各方言独立迁移程序集，由 DI 按 `Database:Provider` 选择）；仅历史遗留库（已有业务表但无 `__EFMigrationsHistory` 的 EnsureCreated 时代库）保持 `EnsureCreatedAsync` 并记 schema 漂移告警。`MigrateAsync` 幂等，重复启动不会重复应用迁移。

**多实例并发迁移互斥**（滚动发布场景）：

- `Database:MigrateOnStartup`（默认 `true`）：置 `false` 可关闭该实例的启动迁移。生产多实例部署建议仅单实例（或独立迁移 Job）保持开启，其余实例关闭。
- 开启时，`MigrationAdvisoryLock` 在迁移前尝试获取数据库级互斥锁：PostgreSQL 用 `pg_try_advisory_lock`、SQL Server 用 `sp_getapplock`（Session 作用域）、MySQL 用 `GET_LOCK`；等待超时由 `Database:MigrationLockTimeoutSeconds` 控制（默认 60 秒）。
- Oracle / SQLite 无跨连接 advisory lock 实现：SQLite 写操作由文件锁天然串行化；Oracle 建议按上述开关仅单实例执行迁移。
- 加锁超时或失败不阻断启动：记告警后在无锁保护下继续迁移（可用性优先）；并发迁移冲突会使实例启动失败，由编排层重启重试兜底。

### 4.4 测试环境

集成测试宿主通过 `AddLicenseManagerInfrastructure` 的 `configureDb` 回调注入 `UseInMemoryDatabase`：

```csharp
builder.Services.AddLicenseManagerInfrastructure(builder.Configuration, opts =>
{
    opts.UseInMemoryDatabase("test-db");
});
```

由于 `configureDb` 在 `ConfigureDatabaseProvider` 之后调用，`UseInMemoryDatabase` 会覆盖关系型提供程序配置，`IsRelational()` 返回 `false`，启动逻辑进入 `EnsureCreatedAsync` 分支。InMemory 提供程序不持久化数据，每次测试运行使用独立数据库名称（如 `test-db-{Guid}`）以隔离并发测试。

### 4.5 数据层可靠性配置参考

| 配置键 | 默认 | 说明 |
| --- | --- | --- |
| `Database:EnableRetryOnFailure` | `true` | 瞬态故障连接重试（EF 执行策略）。仅 SqlServer / PostgreSQL / MySql 生效；Sqlite（进程内文件库）与 Oracle（EF Core 8.23.x 未暴露入口）跳过 |
| `Database:MaxRetryCount` | `5` | 瞬态故障最大重试次数 |
| `Database:MaxRetryDelaySeconds` | `30` | 重试退避延迟上限（指数退避） |
| `Database:MigrateOnStartup` | `true` | 启动时自动应用 EF 迁移；多实例部署建议仅单实例开启（见 4.3） |
| `Database:MigrationLockTimeoutSeconds` | `60` | 启动迁移互斥锁（advisory lock）等待超时 |
| `License:VerifyCacheTtlSeconds` | `5` | verify/heartbeat 读路径 licenseKey→License 缓存 TTL；`0` 禁用回到强一致直查 |

> 启用 `EnableRetryOnFailure` 后，显式事务（`IUnitOfWork.ExecuteInTransactionAsync`）已由 `CreateExecutionStrategy().ExecuteAsync` 自动包裹，业务代码无需调整；事务内动作在重试时会整体重新执行，须保持幂等。
> 读缓存为进程内单例：本进程写后读立即可见（提交即失效）；跨实例变更在 TTL 窗口内有生效延迟（吊销/暂停最长延迟一个 TTL），对一致性敏感的场景可置 `0` 关闭。

---

## 5. P2 持久化迁移记录

### 5.1 迁移背景

LicenseManager Platform 早期为快速迭代，所有聚合根仓储均使用 `InMemoryRepository<T>`（基于 `ConcurrentDictionary<Guid, T>` 的进程内存储）。该方案存在以下问题：

1. **进程重启即丢数据** —— 用户、API 密钥、订阅、审计日志在重启后全部丢失，无法满足生产可用性。
2. **无事务一致性** —— 多仓储写入无法跨进程原子提交，存在部分写入风险。
3. **无并发隔离** —— 多实例水平扩展时，各实例仓储状态独立，数据不一致。
4. **无审计追溯** —— 审计日志随进程消失，无法满足合规要求。

P2 阶段首批将 5 个最关键的业务实体（`User`、`ApiKey`、`AuditLog`、`Order`、`Subscription`）迁移至 EF Core + 关系型数据库，随后逐步扩展至全部已建模实体（含产品目录、许可证生命周期，以及高级模块 `SsoProvider`、`Integration`、`Eula` 等；原覆盖清单中的 `PaymentIntent`、`Refund`、`WebhookDelivery`、`WebhookEndpoint`、`Invoice`、`PspConfig`、`WebhookEventRecord`、`Quote`、`PaymentMethod` 等支付域实体已随商业化裁剪删除，资金流移交外部销售系统）：

| 实体 | 业务影响 | 迁移理由 |
| --- | --- | --- |
| `User` | 用户登录、权限校验 | 凭据必须持久化 |
| `ApiKey` | API 鉴权 | 密钥丢失将导致全部集成中断 |
| `AuditLog` | 合规审计 | 法规要求长期留存 |
| `Subscription` | 订阅续期 | 后台续期扫描依赖历史状态 |
| `Role` | 角色与权限定义 | 权限配置需持久化 |
| `Product` | 产品目录、许可证签发 | 签发链路依赖产品定义 |
| `Customer` | 客户主数据 | 订阅与许可关联客户 |
| `LicenseRecord` | 许可证签发记录 | 交易级数据不可丢 |
| `LicenseTemplate` | 许可证模板 | 重启丢失模板导致签发链路断链 |
| `Activation` | 激活记录 | 激活状态需跨重启留存 |
| `ConsumptionLog` | 消耗计量 | 计量数据需长期留存 |
| `LicenseEvent` | 许可证事件 | 生命周期审计追溯 |

### 5.2 迁移策略

采用 **“接口实现覆盖、无服务层修改”** 策略，避免改动 80+ 个平台服务：

1. **保留接口契约** —— `IRepository<T>` 接口不变，所有平台服务（`UserService`、`ApiKeyService`、`AuditService`、`SubscriptionService` 等）继续通过构造函数注入该接口。
2. **新增 EF 实现** —— 在基础设施层新增 `EfRepository<T>`，实现同一接口，内部委托 `LicenseManagerDbContext`。
3. **DI 覆盖** —— 在 `AddLicenseManagerInfrastructure` 中使用 `services.Replace(...)` 替换 Platform 层注册的 `InMemoryRepository<T>`。
4. **跨仓储事务** —— 新增 `IUnitOfWork` + `EfUnitOfWork`，服务层在需要事务时显式注入。

### 5.3 DI 注册覆盖

`src/LicenseManager.Infrastructure/DependencyInjection.cs` 中的覆盖代码：

```csharp
// 关键实体从内存仓储迁移至 EF + IUnitOfWork 持久化层
// 覆盖 Platform 层注册的 InMemoryRepository<T>，改用 EfRepository<T>
// 生命周期从 Singleton 改为 Scoped（因 DbContext 为 Scoped）
services.Replace(ServiceDescriptor.Scoped<IRepository<User>, EfRepository<User>>());
services.Replace(ServiceDescriptor.Scoped<IRepository<Role>, EfRepository<Role>>());
services.Replace(ServiceDescriptor.Scoped<IRepository<ApiKey>, EfRepository<ApiKey>>());
services.Replace(ServiceDescriptor.Scoped<IRepository<AuditLog>, EfRepository<AuditLog>>());
services.Replace(ServiceDescriptor.Scoped<IRepository<Subscription>, EfRepository<Subscription>>());
services.Replace(ServiceDescriptor.Scoped<IRepository<Product>, EfRepository<Product>>());
services.Replace(ServiceDescriptor.Scoped<IRepository<Customer>, EfRepository<Customer>>());
services.Replace(ServiceDescriptor.Scoped<IRepository<LicenseRecord>, EfRepository<LicenseRecord>>());
// 以下实体已在 DbContext 中映射为表，但此前仍使用 Platform 层内存单例仓储，
// 导致服务重启后模板/激活记录丢失（产品持久化、模板丢失，签发链路断链）。
// 统一替换为 EF 持久化仓储，保证与 Product/LicenseRecord 一致的生命周期。
services.Replace(ServiceDescriptor.Scoped<IRepository<LicenseTemplate>, EfRepository<LicenseTemplate>>());
services.Replace(ServiceDescriptor.Scoped<IRepository<Activation>, EfRepository<Activation>>());
services.Replace(ServiceDescriptor.Scoped<IRepository<ConsumptionLog>, EfRepository<ConsumptionLog>>());
services.Replace(ServiceDescriptor.Scoped<IRepository<LicenseEvent>, EfRepository<LicenseEvent>>());
```

`services.Replace` 等价于“先 `Remove` 同类型已有注册，再 `Add` 新描述符”，确保容器中只保留 EF 实现。调用顺序至关重要：`AddLicenseManagerPlatform` 必须在 `AddLicenseManagerInfrastructure` 之前调用，否则 `Replace` 找不到被覆盖的目标。

### 5.4 生命周期变化

| 维度 | 内存仓储（迁移前） | EF 仓储（迁移后） |
| --- | --- | --- |
| 注册方式 | `services.AddSingleton<IRepository<T>, InMemoryRepository<T>>` | `services.Replace(ServiceDescriptor.Scoped<IRepository<T>, EfRepository<T>>())` |
| 生命周期 | `Singleton`（整个进程共享一个实例） | `Scoped`（每个请求一个实例） |
| 存储位置 | 进程内 `ConcurrentDictionary<Guid, T>` | 关系型数据库表 |
| 线程安全 | 由 `ConcurrentDictionary` 保证 | 由数据库事务与行锁保证 |
| 跨实例一致性 | 不一致（各实例独立） | 一致（共享数据库） |
| 持久化 | 进程退出即丢 | 持久化到磁盘 |
| 依赖 | 无 | 依赖 `LicenseManagerDbContext`（`Scoped`） |

生命周期从 `Singleton` 改为 `Scoped` 是必然 —— `EfRepository<T>` 依赖 `LicenseManagerDbContext`，而 `DbContext` 必须为 `Scoped`（EF Core 强制要求）。若 `EfRepository<T>` 保持 `Singleton`，会捕获一个长期存在的 `DbContext`，导致其变更跟踪器无限增长、连接泄漏。

---

## 6. 数据库提供程序

### 6.1 MySQL（生产推荐）

当前 `appsettings.json` 默认配置：

```json
{
  "Database": {
    "Provider": "MySql",
    "ConnectionString": ""
  },
  "ConnectionStrings": {
    "MySql": ""
  }
}
```

**MySQL 适配要点**：

- 使用 Pomelo `UseMySql(connectionString, serverVersion, ...)`。
- `ServerVersion` 通过 `Database:ServerVersion` 配置显式指定（如 `8.0.36`），避免每次启动 `AutoDetect` 连接数据库。
- 仅在未配置 `ServerVersion` 时回退到 `ServerVersion.AutoDetect(connectionString)`，但该方式会在启动时连接数据库，若 DB 不可用将导致启动失败。
- 所有 JSON 列通过 `JsonValueConverterConvention` 附加 `ValueConverter`（Pomelo 不原生支持 CLR 对象 → JSON 列）。
- 迁移脚本中字符串列默认附加 `MySql:CharSet = utf8mb4`，`Guid` 列使用 `char(36)` + `ascii_general_ci` 排序规则。

### 6.2 PostgreSQL（可选）

`DatabaseOptions` 默认值为 `PostgreSQL`，是除 MySQL 外的次选生产方案：

- 使用 `UseNpgsql(connectionString, npg => npg.MigrationsAssembly(...))`。
- JSON 列使用原生 `jsonb` 类型，支持 GIN 索引与 JSON 路径查询，性能优于 MySQL 的 `json`。
- `Guid` 列在 PostgreSQL 中原生为 `uuid` 类型，无排序规则开销。
- 迁移脚本可直接复用（同一份 `Migrations` 目录）。

### 6.3 InMemory（测试环境）

仅用于单元测试与集成测试宿主：

```csharp
opts.UseInMemoryDatabase("test-db");
```

- 不持久化数据，进程退出即清空。
- 不支持事务，`SaveChangesAsync` 为空操作。
- 启动时走 `EnsureCreatedAsync` 分支建表。
- 提供程序包 `Microsoft.EntityFrameworkCore.InMemory` 由测试项目引用。

### 6.4 其他提供程序

| 提供程序 | 用途 | 备注 |
| --- | --- | --- |
| `SqlServer` | 私有化部署（Windows 客户） | JSON 列映射为 `nvarchar(max)` |
| `Sqlite` | 本地开发与快速测试 | JSON 列映射为 `TEXT`，单文件数据库 |
| `Oracle` | 企业客户特殊需求 | JSON 列映射为 `NCLOB`，无原生 JSON 索引 |

### 6.5 连接字符串配置

`DatabaseOptions` 通过 `configuration.GetSection("Database").Bind(dbOptions)` 加载。兼容旧版配置的回退逻辑：

```csharp
if (string.IsNullOrEmpty(dbOptions.ConnectionString))
{
    dbOptions.ConnectionString = configuration.GetConnectionString("PostgreSQL")
        ?? configuration.GetConnectionString("MySql")
        ?? configuration.GetConnectionString("Default")
        ?? string.Empty;
}
```

**推荐配置（`appsettings.json`）**：

```json
{
  "Database": {
    "Provider": "MySql",
    "ConnectionString": "Server=mysql;Port=3306;Database=license_manager;User=lm;Password=***;CharSet=utf8mb4;",
    "ServerVersion": "8.0.36"
  },
  "ConnectionStrings": {
    "Redis": "redis:6379"
  }
}
```

**本地开发覆盖（`appsettings.Local.json`，已加入 `.gitignore`）**：

```json
{
  "Database": {
    "Provider": "Sqlite",
    "ConnectionString": "Data Source=local.db"
  }
}
```

`appsettings.Local.json` 在 `Program.cs` 中通过 `builder.Configuration.AddJsonFile("appsettings.Local.json", optional: true, reloadOnChange: true)` 加载，覆盖默认配置，便于本地使用 SQLite 或本地 MySQL 凭据。

---

## 7. 性能优化

### 7.1 AsNoTracking 使用

`EfRepository<T>.ListAsync` 与 `LicenseManagerDbContext.FindByKeyAsync`（`ILicenseStore` 实现）均使用 `AsNoTracking()`：

```csharp
// EfRepository<T>.ListAsync
return await _set.AsNoTracking()
    .OrderBy(e => EF.Property<Guid>(e, "Id"))
    .Take(10000)
    .ToListAsync(ct);

// LicenseManagerDbContext.FindByKeyAsync (ILicenseStore)
public async Task<License?> FindByKeyAsync(string key, CancellationToken ct = default)
    => await Licenses.AsNoTracking().FirstOrDefaultAsync(l => l.LicenseKey == key, ct);
```

`AsNoTracking` 跳过变更跟踪器，实体不被加入 `DbContext.ChangeTracker`，适用于：

- 只读查询（如列出全部实体、按 Key 查询许可证）
- 大结果集查询（避免跟踪器内存膨胀）
- 高频查询（减少跟踪器哈希表写入开销）

写入路径（`GetAsync`、`SaveAsync`、`DeleteAsync`）保持默认跟踪行为，以利用一级缓存与 `SetValues` 增量更新。

### 7.2 Take(10000) 全表保护

`ListAsync` 强制 `Take(10000)` 上限，作为增长型表（`audit_log`、`license_event`、`consumption_log`、`webhook_delivery` 等）的 OOM 防护。该上限是工程折中：

- **足够大** —— 覆盖 99% 的管理后台列表场景。
- **足够小** —— 即使全表扫描也不会拖垮 API 进程内存。
- **明确语义** —— 文档与代码注释明确告知调用方“大数据量请使用分页查询接口”。

未来若引入专用分页查询接口（如 `ListPagedAsync(int page, int size, Expression<Func<T, bool>>? filter)`），可逐步将管理后台迁移至分页接口，`ListAsync` 退化为内部用途。

### 7.3 索引策略

所有高频查询路径均已建立索引，关键索引清单参见 [2.2 实体映射](#22-实体映射) 各表的“索引”列。索引设计原则：

| 索引类型 | 用途 | 示例 |
| --- | --- | --- |
| 唯一索引 | 业务标识符唯一性 | `license.LicenseKey`、`user_account.Email`、`api_key.KeyId` |
| 复合索引 | 多列联合查询 | `audit_log(ResourceType, ResourceId)`、`subscription(CustomerId, ProductId)` |
| 时间索引 | 范围扫描 | `audit_log.OccurredAt`（审计时间窗口查询） |
| 调度索引 | 后台工作器扫描 | `webhook_delivery(Status, NextRetryAt)`（投递调度器） |

### 7.4 连接池配置

EF Core 默认使用 ADO.NET 连接池（`DbConnectionPool`），无需显式配置即可获得连接复用。生产环境调优建议：

| 参数 | 默认值 | 推荐值 | 说明 |
| --- | --- | --- | --- |
| `Maximum Pool Size` | 100 | 100~200 | MySQL / PostgreSQL 默认上限，按 API 实例数 × 并发请求数评估 |
| `Minimum Pool Size` | 0 | 5~10 | 预热连接，避免冷启动延迟 |
| `Connection Lifetime` | 0（无限） | 300 秒 | 定期重建连接，规避负载均衡器过期连接 |
| `Connection Timeout` | 15 秒 | 15~30 秒 | 启动时 DB 不可用应快速失败 |

连接字符串示例（MySQL）：

```
Server=mysql;Port=3306;Database=license_manager;User=lm;Password=***;
CharSet=utf8mb4;Maximum Pool Size=150;Minimum Pool Size=10;
Connection Lifetime=300;Connection Timeout=15;
```

`LicenseManagerDbContext` 注册为 `Scoped`，每个请求获取一个 `DbContext` 实例（内部持有一条连接），请求结束自动释放连接回池。`AddDbContext` 默认不开启线程安全警告，避免在 `Singleton` 服务中注入 `DbContext`（会引发 `InvalidOperationException`）。

---

## 8. 备份与恢复

数据库备份与恢复的完整流程（全量备份、增量备份、PITR、跨区域复制、灾备演练、恢复验证）详见：

- **[LicenseManager Platform - 部署与运维手册](./LicenseManager-Platform-Deployment-Guide-v1.0.md)** —— 第 6 章“备份与恢复”

本节仅列出与 EF Core 迁移直接相关的备份要点：

| 场景 | 操作 | 命令/工具 |
| --- | --- | --- |
| **迁移前快照** | 应用新迁移前先做全量备份 | `mysqldump` / `pg_dump` |
| **迁移回滚** | `dotnet ef database update <PrevMigration>` 后恢复备份 | EF CLI + DBA 工具 |
| **迁移脚本审核** | 生成 SQL 脚本交 DBA 审核后再应用 | `dotnet ef migrations script --output` |
| **气隙部署** | 离线环境无法 `dotnet ef database update`，需预先生成脚本在目标库执行 | `migrations script --idempotent` |

> **重要**：`MigrateAsync` 是幂等的，可重复执行；但 `Down` 回滚会删除列与表，**不可逆**。生产环境回滚必须先备份，并在 staging 验证 `Down` 脚本。

---

## 9. 附录：常用 EF 命令速查表

### 9.1 CLI 命令

```bash
# 安装 EF CLI（首次）
dotnet tool install --global dotnet-ef

# 升级 EF CLI
dotnet tool update --global dotnet-ef

# 查看 EF CLI 版本
dotnet ef --version
```

### 9.2 迁移命令

```bash
# 创建迁移（按提供程序拆分到独立程序集，以 MySQL 方言为例）
dotnet ef migrations add <Name> \
  --project src/LicenseManager.Infrastructure.Migrations.MySql \
  --startup-project src/LicenseManager.Api

# 删除最后一个迁移（尚未应用时）
dotnet ef migrations remove \
  --project src/LicenseManager.Infrastructure.Migrations.MySql \
  --startup-project src/LicenseManager.Api

# 列出所有迁移
dotnet ef migrations list \
  --project src/LicenseManager.Infrastructure.Migrations.MySql \
  --startup-project src/LicenseManager.Api

# 应用迁移到最新
dotnet ef database update \
  --project src/LicenseManager.Infrastructure.Migrations.MySql \
  --startup-project src/LicenseManager.Api

# 回滚到指定迁移
dotnet ef database update <TargetMigration> \
  --project src/LicenseManager.Infrastructure.Migrations.MySql \
  --startup-project src/LicenseManager.Api

# 生成 SQL 脚本（从初始到最新）
dotnet ef migrations script \
  --project src/LicenseManager.Infrastructure.Migrations.MySql \
  --startup-project src/LicenseManager.Api \
  --output ./migrate.sql

# 生成幂等 SQL 脚本（可重复执行，气隙部署推荐）
dotnet ef migrations script --idempotent \
  --project src/LicenseManager.Infrastructure.Migrations.MySql \
  --startup-project src/LicenseManager.Api \
  --output ./migrate-idempotent.sql

# 删除数据库（开发环境清库）
dotnet ef database drop \
  --project src/LicenseManager.Infrastructure.Migrations.MySql \
  --startup-project src/LicenseManager.Api
```

### 9.3 上下文命令

```bash
# 查看当前 DbContext 信息
dotnet ef dbcontext info \
  --project src/LicenseManager.Infrastructure \
  --startup-project src/LicenseManager.Api

# 生成 DbContext 与实体类（Database-First，从已有数据库反向工程）
dotnet ef dbcontext scaffold "<ConnectionString>" <Provider> \
  --project src/LicenseManager.Infrastructure \
  --startup-project src/LicenseManager.Api \
  --context LicenseManagerDbContext \
  --output-dir Persistence/Generated \
  --use-database-names
```

### 9.4 提供程序切换速查

| 配置 | `appsettings.json` 节点 | 提供程序包 |
| --- | --- | --- |
| MySQL | `"Provider": "MySql"` + `"ServerVersion": "8.0.36"` | `Pomelo.EntityFrameworkCore.MySql` 8.0.2 |
| PostgreSQL | `"Provider": "PostgreSQL"` | `Npgsql.EntityFrameworkCore.PostgreSQL` 8.0.10 |
| SQL Server | `"Provider": "SqlServer"` | `Microsoft.EntityFrameworkCore.SqlServer` 8.0.10 |
| SQLite | `"Provider": "Sqlite"` | `Microsoft.EntityFrameworkCore.Sqlite` 8.0.10 |
| Oracle | `"Provider": "Oracle"` | `Oracle.EntityFrameworkCore` 8.23.60 |

### 9.5 JSON 列类型映射速查

| 提供程序 | 列类型 | 是否需 `ValueConverter` |
| --- | --- | --- |
| PostgreSQL | `jsonb` | 否（原生支持） |
| MySQL | `json` | 是（`JsonValueConverterConvention`） |
| SQL Server | `nvarchar(max)` | 否（按字符串处理） |
| SQLite | `TEXT` | 否（按字符串处理） |
| Oracle | `NCLOB` | 否（按字符串处理） |

---

*本文档基于 `LicenseManager.Infrastructure` 实际代码编写，对应 EF Core 8.0.10 + Pomelo MySQL 8.0.2，迁移按提供程序拆分于 `src/LicenseManager.Infrastructure.Migrations.{MySql,PostgreSql,Sqlite,SqlServer,Oracle}/Migrations/`（MySQL 方言 8 个迁移，Oracle 9 个；模型快照 72 张实体表）。商业化裁剪后资金流相关表（订单/支付/发票/PSP/优惠券等 11 张）已移除，资金流移交外部销售系统。*
