# LicenseManager Platform - 故障排查指南

| 项目 | 内容 |
| --- | --- |
| 文档版本 | v1.0 |
| 文档类型 | 故障排查指南 (Troubleshooting Guide) |
| 关联文档 | [RDP v1.0.1](./LicenseManager-Platform-RDP-v1.0.1.md) · [TAD v1.0](./LicenseManager-Platform-TAD-v1.0.md) · [DB Design](./LicenseManager-Platform-Database-Design-v1.0.md) · [API Spec](./LicenseManager-Platform-API-Spec-v1.0.md) · [Dev Setup Guide](./LicenseManager-Platform-Dev-Setup-Guide-v1.0.md) · [Deployment Guide](./LicenseManager-Platform-Deployment-Guide-v1.0.md) · [Testing Strategy](./LicenseManager-Platform-Testing-Strategy-v1.0.md) |
| 目标读者 | 后端工程师、前端工程师、QA、SRE、运维工程师、技术支持 |
| 文档日期 | 2026-07-25 |

---

## 0. 修订历史

| 版本 | 日期 | 修订内容 |
| --- | --- | --- |
| v1.0 | 2026-07-25 | 初稿：基于 `src/` 与 `web/admin/` 现有代码与已知问题整理启动、认证、API、数据库、前端、测试、第三方集成、性能、日志等九大类故障的排查路径 |
| v1.1 | 2026-09 | 按代码实测同步：迁移 7 → 10 个；`services.Replace` 22 → 45 个实体（全量 EF 化，§5.4 重写）；审计表名 `AuditLogs` → `audit_log`（列名 PascalCase）；`LoginAttempt` 更正为内存记录（非数据库表）；异常映射行号以 `ExceptionHandlingMiddleware.cs:50-57` 为准 |
| v1.2 | 2026-09 | 修正 `/v1/readyz` 依赖状态值：实际取值集合为 `ok` / `unreachable` / `error` / `degraded` / `skipped`（整体 `status` 为 `ready` / `not_ready`），文档此前的 `not_ready` / `not_configured` 单项状态不存在（以 `StatusController.Ready` 为准） |
| v1.3 | 2026-10-08 | 商业化裁剪：移除/标注资金流相关内容（§8.1/8.2/8.5/8.6/8.8/8.9/8.10、Stripe/PayPal 配置键与环境变量、psp HttpClient 等），移交外部销售系统；迁移列表同步至现状（按提供程序拆分，含 RemoveCommerce） |
| v1.4 | 2026-10-14 | 默认种子管理员凭据同步为 `admin@licensemanager.cn` / `Admin@123456`（与演示环境一致） |

---

## 1. 概述

### 1.1 文档目的

本指南面向 LicenseManager Platform 的开发、测试与运维人员，提供系统在开发、测试、生产环境运行过程中常见问题的快速定位与处置方法。每一条故障条目均包含：

- **现象**：用户或监控可观察到的症状；
- **根因**：导致该现象的常见原因；
- **排查步骤**：从日志、配置、依赖逐层缩小范围的有序操作；
- **处置方案**：可执行的修复或规避动作。

本指南不替代 [Deployment Guide](./LicenseManager-Platform-Deployment-Guide-v1.0.md) 中的部署流程，也不重复 [API Spec](./LicenseManager-Platform-API-Spec-v1.0.md) 中的接口契约，仅聚焦"出问题后怎么办"。

### 1.2 适用范围

| 范围 | 说明 |
| --- | --- |
| 适用 | 本地开发、测试环境、生产环境的运行时故障排查 |
| 不适用 | 功能需求变更、架构升级、安全合规审计（参考各自专属文档） |

### 1.3 问题分类

| 类别 | 章节 | 典型症状 |
| --- | --- | --- |
| 启动问题 | §2 | 进程无法启动、迁移失败、Seed 报错 |
| 认证问题 | §3 | 401/403、HMAC 签名失败、Token 过期 |
| API 问题 | §4 | 4xx/5xx 状态码、限流、代理 502 |
| 数据库问题 | §5 | 连接超时、字符集错误、表锁、数据不一致 |
| 前端问题 | §6 | 白屏、i18n 键未翻译、代理 502 |
| 测试问题 | §7 | 单元测试失败、E2E flaky、E2E 401 误报 |
| 第三方集成 | §8 | SMTP、OIDC（支付/PSP 集成已随商业化裁剪移除，资金流移交外部销售系统） |
| 性能问题 | §9 | 响应慢、内存泄漏、全表加载 |
| 日志与诊断 | §10 | 日志位置、结构化查询、审计日志 |

### 1.4 排查通用流程

无论问题属于哪一类，建议遵循以下通用步骤：

1. **复现并定格**：在受影响环境复现一次，记录请求方法、路径、时间戳、`requestId`（响应体中返回）；
2. **查日志**：根据时间戳与 `requestId` 在应用日志中定位异常堆栈（参考 §10）；
3. **查依赖**：通过 `GET /v1/readyz` 与 `GET /v1/status/dependencies` 检查数据库、Redis 是否就绪；
4. **查配置**：核对 `appsettings.json`、`appsettings.Local.json`、环境变量是否与运行环境匹配；
5. **隔离变量**：在另一台机器或干净环境复现，排除本地缓存/旧进程干扰；
6. **查阅本指南**：按类别查找对应条目，按"排查步骤"逐项执行。

---

## 2. 启动问题

### 2.1 后端无法启动（端口 8080 被占用）

**现象**

执行 `dotnet run --project src/LicenseManager.Api --urls http://localhost:8080` 后立即退出，日志包含：

```
System.IO.IOException: Failed to bind to address http://localhost:8080: address already in use.
```

或前端 `vite.config.ts` 中配置的代理目标 `http://localhost:8080`（参考 `web/admin/vite.config.ts` 第 19 行）返回 502。

**根因**

- 另一个 `LicenseManager.Api` 进程未退出（常见于改代码后未停止旧 `dotnet run`）；
- 其他应用占用了 8080 端口（如 Jenkins、其他 Web 服务）；
- 之前异常退出的进程未释放 socket（TIME_WAIT 状态）。

**排查步骤**

1. 执行 `netstat -ano | findstr :8080`（PowerShell）查看占用进程 PID；
2. 执行 `Get-Process -Id <PID>` 确认进程身份；
3. 若为 `dotnet.exe` 或 `LicenseManager.Api.exe`，确认是否为遗留的本地调试进程；
4. 检查 IDE（Visual Studio / Rider）是否已启动同一项目实例。

**处置方案**

- 停止遗留进程：`Stop-Process -Id <PID> -Force`；
- 如需保留 8080 上的其他服务，改用其他端口启动 API：`dotnet run --urls http://localhost:8081`，并同步修改 `web/admin/vite.config.ts` 中 `proxy['/v1'].target` 与 `web/admin/e2e/fixtures.ts` 中 `API_BASE`；
- 不要直接修改 `Program.cs` 中的根路径端点，根路径 `/` 返回的 `endpoints` 信息仍指向 `/v1/status` 等相对路径，与端口无关。

### 2.2 数据库连接失败

**现象**

启动日志包含以下任一错误：

```
MySqlConnector.MySqlConnectionException: Unable to connect to any of the specified MySQL hosts.
MySqlConnector.MySqlException: Access denied for user 'xxx'@'xxx'
System.Net.Sockets.SocketException: Connection refused
```

`GET /v1/readyz` 返回 `database: unreachable`（连接失败）或 `database: error`（探测抛异常），响应整体 `status: not_ready`。

**根因**

- `appsettings.json` 中 `ConnectionStrings:MySql` 或 `Database:ConnectionString` 为空（默认值为空字符串，参考 `appsettings.json` 第 4、7 行）；
- MySQL 服务未启动；
- 连接串中的主机、端口、用户名、密码错误；
- 防火墙或网络安全组阻挡 3306 端口；
- `Database:Provider` 与实际数据库不匹配（默认配置为 `MySql`，参考 `appsettings.json` 第 3 行；`DatabaseOptions` 默认值为 `PostgreSQL`，参考 `src/LicenseManager.Infrastructure/Persistence/DatabaseOptions.cs` 第 13 行——若 `appsettings.json` 未显式配置 `Database:Provider`，会回退到 PostgreSQL 提供程序）；
- MySQL 8.0 服务端默认 `caching_sha2_password` 认证插件，老版本 MySqlConnector 可能不兼容。

**排查步骤**

1. 检查 `appsettings.json` 与 `appsettings.Local.json`（被 `.gitignore` 忽略，用于本地凭据，参考 `Program.cs` 第 21、24 行 `AddJsonFile` + `AddEnvironmentVariables`）中 `Database:Provider` 与连接串是否已配置；
2. 在本机执行 `Test-NetConnection <db-host> -Port <db-port>` 验证网络可达；
3. 使用 `mysql -h <host> -u <user> -p` 在 API 主机上手动验证凭据；
4. 查看 `GET /v1/readyz` 响应中 `database.error` 字段的详细错误信息；
5. 若使用 `Database:ServerVersion` 未配置，`ConfigureDatabaseProvider` 会调用 `ServerVersion.AutoDetect`（参考 `src/LicenseManager.Infrastructure/DependencyInjection.cs` 第 145-151 行），该调用会真实连接数据库，若 DB 不可达将导致启动失败——可在 `appsettings.json` 中显式设置 `Database:ServerVersion` 为 `"8.0.36"` 等值以避免启动期探测。

**处置方案**

- 启动 MySQL：`net start MySQL80`（Windows）或 `systemctl start mysql`（Linux）；
- 在 `appsettings.Local.json` 中覆盖 `ConnectionStrings:MySql`：
  ```json
  { "ConnectionStrings": { "MySql": "Server=127.0.0.1;Port=3306;Database=licensemanager;User=lm;Password=***;CharSet=utf8mb4;" } }
  ```
- 若使用 PostgreSQL，需将 `Database:Provider` 改为 `PostgreSQL`，并配置 `ConnectionStrings:PostgreSQL`（参考 `DependencyInjection.cs` 第 46-48 行的回退顺序）；
- MySQL 8.0 认证插件问题：执行 `ALTER USER 'lm'@'%' IDENTIFIED WITH mysql_native_password BY '***';` 或升级 MySqlConnector 至最新版本。

### 2.3 EF 迁移失败

**现象**

启动日志包含：

```
Microsoft.EntityFrameworkCore.Database.ConcurrencyException: Database already exists and has a different migration history.
MySqlConnector.MySqlException: Table 'xxx' already exists
```

**根因**

- `Program.cs` 第 142-144 行在关系型数据库上调用 `db.Database.MigrateAsync()`，若 `__EFMigrationsHistory` 表中记录的迁移 ID 与程序集内迁移不一致则报错；
- 当前迁移已按数据库提供程序拆分到独立程序集（参考 `src/LicenseManager.Infrastructure.Migrations.*`）。以 MySQL 提供程序为例，当前包含 6 个迁移：`20260930060249_InitialCreate`、`20261006132444_AddSsoSamlFields`、`20261006153950_AddUserPhone`、`20261006164444_SystemConfigTable`、`20261007154400_AddCustomerContactFields`、`20261007170657_RemoveCommerce`（商业化裁剪迁移，移除订单/支付/发票等资金流相关表），若数据库由旧版本创建可能存在迁移历史不一致（旧版商业化迁移已被 `RemoveCommerce` 取代）；
- 手动在数据库中创建了表（如通过 SQL 脚本）但未写入迁移历史；
- 跨数据库提供程序切换（如从 PostgreSQL 切到 MySQL）后旧表残留。

**排查步骤**

1. 连接数据库，查询 `SELECT * FROM __EFMigrationsHistory;` 查看已应用迁移；
2. 对比 `src/LicenseManager.Infrastructure/Migrations/` 目录下的迁移文件名；
3. 若数据库为空但报"表已存在"，检查是否有多 schema 混用；
4. 检查 `Database:Provider` 是否与数据库实际类型一致——EF 迁移程序集通过 `migrationsAssembly` 绑定到 `LicenseManagerDbContext` 所在程序集（参考 `DependencyInjection.cs` 第 126 行定义、第 133/140/154/161/168 行各提供程序分支使用），不同提供程序的迁移不通用。

**处置方案**

- 开发环境：删除数据库重建，`DROP DATABASE licensemanager; CREATE DATABASE licensemanager;`，重启 API 自动应用迁移；
- 测试环境：执行 `dotnet ef database update --project src/LicenseManager.Infrastructure --startup-project src/LicenseManager.Api` 显式补齐迁移；
- 生产环境：禁止 `DROP`，应按缺失的迁移逐条执行 `INSERT INTO __EFMigrationsHistory(MigrationId, ProductVersion) VALUES ('<MigrationId>', '8.0.x');`（如 `20260930060249_InitialCreate`、`20261007170657_RemoveCommerce` 等）手动对齐迁移历史（仅在确认表结构与迁移一致时使用）；
- 切换数据库提供程序后必须新建空库，禁止跨提供程序复用旧库。

### 2.4 SeedService 报错

**现象**

启动日志包含以下任一：

```
InvalidOperationException: 默认管理员密码未配置。请在生产环境设置 'Seed:AdminPassword' 环境变量（或配置键），以避免使用公开的占位凭据。
InvalidOperationException: 生产环境必须设置环境变量 LICENSEMANAGER_DATA_PROTECTION_KEY（base64 编码的 32 字节 AES-GCM 密钥）。
```

或日志级别为 `warning` 的 `初始化默认管理员账号失败`。

**根因**

- **生产环境未设置 `Seed:AdminPassword`**：`SeedService.ResolveAdminPassword` 在生产环境会抛出异常（参考 `src/LicenseManager.Api/Services/SeedService.cs` 第 42-47 行），开发环境回退到占位密码 `Admin@123456`；
- **生产环境未设置 `LICENSEMANAGER_DATA_PROTECTION_KEY` 环境变量**：`SecretProtector.Initialize` 在生产环境抛出异常（参考 `src/LicenseManager.Platform/Services/SecretProtector.cs` 第 85-89 行）；
- **`SecretProtector` 未初始化即被调用**：尽管 `EnsureKey` 方法会自动生成进程级随机密钥（参考 `SecretProtector.cs` 第 168-177 行），但服务层可能通过 `IsInitialized` 判断加密是否激活，导致写入数据时被跳过；
- **`IUserService` 注册异常**：`SeedService.InitializeAsync` 通过 `GetRequiredService<IUserService>()` 解析服务（参考 `SeedService.cs` 第 61 行），DI 链路任一环节失败将抛 `InvalidOperationException`。

**排查步骤**

1. 确认运行环境：`ASPNETCORE_ENVIRONMENT` 是否为 `Production`；
2. 检查环境变量：
   - `Seed:AdminPassword`（可作为环境变量或 `appsettings.Production.json` 配置键）；
   - `LICENSEMANAGER_DATA_PROTECTION_KEY`（base64 编码的 32 字节，可用 `[Convert]::ToBase64String([byte[]]@(1..32))` 生成测试值）；
3. 查看完整异常堆栈：`SeedService.InitializeAsync` 的 `catch (Exception ex)` 仅 `LogWarning`（参考 `SeedService.cs` 第 84-87 行），不阻止启动，但管理员账号可能未创建——需检查日志中的 inner exception；
4. 若 `SecretProtector` 在开发环境使用进程级随机密钥（参考 `SecretProtector.cs` 第 92-97 行的 warning 日志），进程重启后旧密文将无法解密——表现为已加密的敏感配置（如 SMTP 密码、OIDC ClientSecret 等）读出来为乱码。

**处置方案**

- 生产环境通过环境变量注入：
  ```powershell
  $env:Seed__AdminPassword = "<强密码>"
  $env:LICENSEMANAGER_DATA_PROTECTION_KEY = "<base64-32-bytes>"
  ```
- 开发环境如需跨重启解密旧密文，也建议显式设置 `LICENSEMANAGER_DATA_PROTECTION_KEY`；
- 若管理员账号因 Seed 异常未创建，可手动通过 API 之外的方式插入：开发环境直接重启进程（确保配置已修复后 Seed 会重新执行）；生产环境需联系 DBA 在 `Users` 表中插入记录并使用 `PasswordHasher` 计算密码哈希；
- 注意 `SeedService` 是幂等的（参考 `SeedService.cs` 第 70-74 行），若 `admin@licensemanager.cn` 已存在则跳过，不会更新密码——若需重置密码需先删除该用户记录或通过密码重置接口。

### 2.5 Redis 连接失败

**现象**

`GET /v1/readyz` 返回 `redis: error`（探测抛异常）或 `redis: degraded`（读写校验不一致），响应整体 `status: not_ready`，日志包含：

```
StackExchange.Redis.RedisConnectionException: It was not possible to connect to the redis server(s).
StackExchange.Redis.RedisTimeoutException: Timeout performing GET key
```

**根因**

- `appsettings.json` 中 `ConnectionStrings:Redis` 为空（默认值为空字符串，参考 `appsettings.json` 第 8 行）——此情况下 `DependencyInjection.cs` 第 102-107 行不会注册 Redis 缓存，`readyz` 探针将报告 `redis: skipped`（属于配置缺失而非连接失败；注意整体 `status` 仍为 `ready`，`skipped` 视为可接受的降级）；
- Redis 服务未启动或端口（默认 6379）不可达；
- 连接串中的密码错误（Redis 6+ 启用 ACL）；
- Redis 内存满，写入失败。

**排查步骤**

1. 检查 `appsettings.json` / `appsettings.Local.json` 中 `ConnectionStrings:Redis` 是否已配置；
2. 执行 `Test-NetConnection <redis-host> -Port 6379` 验证网络可达；
3. 使用 `redis-cli -h <host> -a <password> ping` 验证认证；
4. 查看就绪探针详细错误：`GET /v1/readyz` 响应中 `dependencies` 数组中 `name=redis` 的 `error` 字段。

**处置方案**

- 启动 Redis：`redis-server.exe`（Windows）或 `systemctl start redis`（Linux）；
- 若暂不使用 Redis，可留空 `ConnectionStrings:Redis`，系统会降级运行（仅失去分布式缓存能力，限流仍由 `IMemoryCache` 提供，参考 `Program.cs` 第 50 行）；
- 若 Redis 不可用但已配置，`readyz` 依赖项返回 `redis: error`（整体 `status: not_ready`、HTTP 503），生产环境应通过 K8s readinessProbe 阻断流量直至恢复；
- Redis 内存满：执行 `redis-cli INFO memory` 查看 `used_memory`，扩容或配置 `maxmemory-policy` 为 `allkeys-lru`。

---

## 3. 认证问题

### 3.1 登录返回 401

**现象**

`POST /v1/auth/login` 返回：

```json
{ "success": false, "error": { "code": "auth.invalid_credentials", "message": "Invalid email or password." } }
```

或：

```json
{ "success": false, "error": { "code": "auth.account_locked", "message": "Account temporarily locked..." } }
```

状态码 401 或 423。

**根因**

- 邮箱或密码错误（`AuthController.Login` 第 83-93 行调用 `IUserService.VerifyPasswordAsync` 校验）；
- 用户不存在（`GetByEmailAsync` 返回 null，参考 `AuthController.cs` 第 95-100 行）；
- 账号被禁用（`UserStatus` 非 `Active`，需检查 `IUserService.VerifyPasswordAsync` 实现）；
- 账号被锁定：连续失败尝试触发 `ILoginSecurityService.IsLockedAsync` 返回 true（参考 `AuthController.cs` 第 68-81 行），锁定时长 15 分钟（`LockoutMinutes = 15`，第 20 行）；
- 开发环境使用占位密码 `Admin@123456`，但生产环境未设置 `Seed:AdminPassword` 导致管理员账号密码哈希与预期不符；
- 数据库中 `user_account` 表为空（Seed 失败导致）。

**排查步骤**

1. 确认请求体格式：`{ "email": "admin@licensemanager.cn", "password": "Admin@123456" }`，邮箱字段不能为空（参考 `AuthController.cs` 第 59-62 行）；
2. 检查响应状态码：401 表示凭据错误，423 表示账号锁定；
3. 在数据库中确认用户存在：`SELECT Id, Email, Status, PasswordHash FROM user_account WHERE Email = 'admin@licensemanager.cn';`（表名 snake_case 单数，列名 PascalCase，以实际 schema 为准）；
4. 检查登录安全服务状态：`LoginAttempt` 为**内存记录**（`InMemoryRepository`，非数据库表，重启即丢、多实例不互通），无法 SQL 查询——通过日志或重启后重试判断；
5. 若是新部署的生产环境，确认 `Seed:AdminPassword` 是否已设置——若未设置，`SeedService` 在生产环境会抛异常（参考 `SeedService.cs` 第 42-47 行），管理员账号可能未创建。

**处置方案**

- 凭据错误：使用正确密码重试，开发环境默认密码为 `Admin@123456`（参考 `SeedService.cs` 第 49 行）；
- 账号锁定：等待 15 分钟自动解锁；失败记录保存在内存中，重启 API 进程即清空；
- 用户不存在：修复 `Seed:AdminPassword` 配置后重启 API，`SeedService` 会自动创建默认管理员（参考 `SeedService.cs` 第 75-82 行）；
- 用户被禁用：通过 DBA 将 `user_account.Status` 改为 `Active`（值 `0`，参考 `UserStatus` 枚举）；
- 生产环境首次部署：必须通过环境变量设置 `Seed__AdminPassword`，不要使用占位密码。

### 3.2 API 调用返回 401

**现象**

调用 `/v1/admin/*` 或其他受保护端点返回：

```json
{ "success": false, "error": { "code": "unauthorized", "message": "Missing Authorization header." } }
```

或：

```json
{ "success": false, "error": { "code": "auth.invalid_token", "message": "Authentication failed." } }
```

**根因**

- 请求未携带 `Authorization` 头（参考 `AuthenticationMiddleware.cs` 第 75-80 行）；
- Token 过期：JWT 默认有效期 8 小时（参考 `AuthController.cs` 第 110 行 `TimeSpan.FromHours(8)`），过期后 `AuthenticateJwt` 返回 null（参考 `AuthenticationMiddleware.cs` 第 134-140 行）；
- Token 无效：签名不匹配（`Jwt:Secret` 在签发与校验时不一致，参考 `AuthenticationMiddleware.cs` 第 120-127 行）；
- Token 格式错误：JWT 必须为 `header.payload.signature` 三段式（参考 `AuthenticationMiddleware.cs` 第 113-114 行）；
- API Key 无效：`IApiKeyService.ValidateAsync` 返回 null（密钥被撤销、过期或 secret 错误）；
- `Auth:Enabled` 配置为 false 时所有请求标记为 `system` 用户（参考 `AuthenticationMiddleware.cs` 第 60-66 行）——若误关闭认证会导致鉴权链路异常。

**排查步骤**

1. 确认请求头包含 `Authorization: Bearer <jwt>`；
2. 在 [jwt.io](https://jwt.io) 解码 JWT 查看 `exp` 字段，确认是否过期；
3. 检查 `Jwt:Secret` 配置是否一致——开发环境使用进程级随机密钥（参考 `JwtSecretResolver.cs` 第 46-55 行），**进程重启后旧 token 全部失效**，这是开发环境最常见的 401 原因；
4. 查看 API 日志中是否有 `JWT signature verification failed` 或 `JWT expired for subject`（参考 `AuthenticationMiddleware.cs` 第 125、138 行）；
5. 若使用 API Key，确认 `Authorization: ApiKey <keyId>:<secret>` 格式正确，且 keyId 与 secret 以冒号分隔（参考 `AuthenticationMiddleware.cs` 第 151-164 行）。

**处置方案**

- Token 过期：重新调用 `POST /v1/auth/login` 获取新 token；
- 进程重启后旧 token 失效：开发环境通过 `appsettings.Local.json` 设置稳定的 `Jwt:Secret`（≥32 字节）以避免每次重启都需重新登录；
- 生产环境：设置强 `Jwt:Secret` 环境变量，参考 `JwtSecretResolver.cs` 第 38-43 行的生产环境校验；
- API Key 失效：通过 `/v1/admin/api-keys` 重新生成；
- 前端管理后台 401 时 `client.ts` 会自动清空 `sessionStorage` 中的 `admin_token` 并跳转 `/login`（参考 `web/admin/src/api/client.ts` 第 72-85 行），用户需重新登录。

### 3.3 HMAC 签名验证失败

**现象**

使用 `Authorization: Hmac <keyId>:<signature>` 调用 API 返回 401，日志包含：

```
HMAC auth missing X-LicenseManager-Api-Key header for key <keyId>
HMAC timestamp skew exceeded
HMAC signature mismatch for key <keyId>
```

**根因**

HMAC 认证方案要求三个请求头协同工作（参考 `AuthenticationMiddleware.cs` 第 166-233 行）：

| 请求头 | 用途 |
| --- | --- |
| `Authorization: Hmac <keyId>:<signature>` | 提供 keyId 与签名 |
| `X-LicenseManager-Api-Key: <secret>` | 提供 secret（不参与签名输入，仅用于服务端密钥校验） |
| `X-LicenseManager-Timestamp: <unix-seconds>` | 时间戳，参与签名输入 |

常见错误：

- 缺少 `X-LicenseManager-Api-Key` 头（参考第 185-190 行）；
- 缺少 `X-LicenseManager-Timestamp` 头或值非数字（参考第 193-197 行）；
- 时间戳与服务器时间偏差超过 ±300 秒（参考第 200-205 行）；
- 签名计算公式错误：正确公式为 `HMAC-SHA256(secret, timestamp + "\n" + body)`，结果以 hex 小写编码（参考第 219-223 行）；
- 请求体被代理或中间件修改后未重新签名（如反向代理规范化换行符）；
- API Key 已被撤销或过期：`IApiKeyService.ValidateAsync` 返回 null（参考第 211-212 行）。

**排查步骤**

1. 确认三个请求头都已设置；
2. 使用与服务端同步的 NTP 时间源，确认客户端时间戳与服务器偏差在 ±300 秒内；
3. 客户端重新计算签名：
   ```javascript
   const crypto = require('crypto');
   const body = JSON.stringify(payload);
   const signingInput = `${timestamp}\n${body}`;
   const signature = crypto.createHmac('sha256', secret).update(signingInput).digest('hex');
   ```
4. 在 API 日志中查看具体的失败原因（`missing header` / `timestamp skew` / `signature mismatch`）；
5. 若签名持续不匹配，抓包确认请求体未被中间层（CDN、代理）篡改。

**处置方案**

- 修正请求头与签名计算公式；
- 同步客户端与服务器时间（`w32tm /resync`）；
- 若代理必须修改请求体，需在代理层重新计算签名；
- `/v1/customer/licenses/activate` 与 `/v1/customer/licenses/verify` 客户端端点默认经 `Auth:AllowPublicActivation`（默认 `true`）放行以兼容现有 SDK；生产环境应显式置为 `false`，此后客户端 SDK 必须携带 ApiKey/Hmac 或 Bearer 凭据（该开关为已知技术债务，代码含 TODO 标注，待 SDK 改造后移除）。

### 3.4 角色权限不足（403）

**现象**

调用 `/v1/admin/*` 端点返回：

```json
{ "success": false, "error": { "code": "forbidden", "message": "Forbidden." } }
```

**根因**

- `AuthorizationMiddleware` 检查到 `/v1/admin/` 前缀路径但 `HttpContext.Items["user"]` 为 null（参考 `AuthorizationMiddleware.cs` 第 63-71 行）——通常发生在 `AuthenticationMiddleware` 因配置异常未写入用户但未返回 401 的边缘情况，此时 message 为 `Forbidden.`；
- **角色不足**：operator/support 角色访问 admin-only 子路径（`IsAdminOnlyPath`，如 `/users`、`/roles`、`/api-keys` 等 18 个前缀），或 support 角色访问 operator 及以上子路径（`IsOperatorUpPath`，如 `/audit`、`/reports`、`/webhooks` 等 25 个前缀），此时 message 为 `Insufficient role.`（参考 `AuthorizationMiddleware.cs` 第 73-89 行）；
- 前端 `client.ts` 对 403 的处理是弹出"无权限访问"提示（参考 `client.ts` 第 87-90 行）。

**排查步骤**

1. 检查请求是否携带有效 `Authorization` 头——403 通常意味着认证已通过但权限不足，但本系统的实现中 403 也可能是因为 `HttpContext.Items["user"]` 未被写入（认证中间件异常）；
2. 查看请求路径是否以 `/v1/admin/` 开头；
3. 检查 API 日志中是否有 `Authorization denied for {Method} {Path}: no authenticated user`（参考 `AuthorizationMiddleware.cs` 第 68 行）；
4. 检查 API 日志中是否有 `Authorization denied for {Method} {Path}: role {Role} not permitted`（参考 `AuthorizationMiddleware.cs` 第 77、86 行）——命中即为角色不足。

**处置方案**

- 确保认证中间件正确执行（参考 §3.2 排查 401）；
- 角色不足导致的 403：换用具有足够角色的账号——`admin` 可访问全部 `/v1/admin/*`；`operator` 不能访问 admin-only 路径；`support` 还会被 operator 级路径拒绝。或由管理员调整账号角色；
- 前端路由守卫已按路由 `meta.roles` 数组拦截无权限页面并提示 `common.forbidden`，无需自行判断。

---

## 4. API 问题

### 4.1 404 Not Found

**现象**

请求返回：

```json
{ "success": false, "error": { "code": "not_found", "message": "No resource matches the supplied id or filter." } }
```

状态码 404（注意：`ExceptionHandlingMiddleware` 仅处理抛出的异常（其中 `NotFoundException` → `not_found`，参考 `ExceptionHandlingMiddleware.cs` 第 56 行）；ASP.NET Core 路由系统的 404（空体）由 `ApiResponseWrapperMiddleware.NormalizeErrorAsync` 归一化为标准错误信封（`MapStatusToCode(404) = "not_found"`，参考 `ApiResponseWrapperMiddleware.cs` 第 128-130、166 行），不会返回 `internal_error`）。

**根因**

- 路由不存在：请求路径拼写错误或前缀不符（API 统一前缀 `/v1/`）；
- 控制器未注册：`Program.cs` 第 198 行 `MapControllers` 仅注册被 `[ApiController]` 与 `[Route]` 标注的控制器，若漏标 attribute 则不生效；
- HTTP 方法不匹配：如对 `HttpGet` 端点发起 POST 请求，框架返回 405 而非 404；
- 环境差异：开发环境启用 Swagger（参考 `Program.cs` 第 143-146 行 `IsDevelopment`），生产环境不暴露 `/swagger`，相关请求会 404。

**排查步骤**

1. 核对请求路径与 HTTP 方法，参考 [API Spec](./LicenseManager-Platform-API-Spec-v1.0.md)；
2. 开发环境访问 `http://localhost:8080/swagger` 查看已注册端点清单；
3. 检查控制器是否标注 `[ApiController]` 与 `[Route("v1/xxx")]`；
4. 检查 `Program.cs` 是否调用 `MapControllers()`（参考第 134 行）。

**处置方案**

- 修正请求路径与方法；
- 若为自定义控制器，确保继承 `ControllerBase` 并标注 `[ApiController]` 与 `[Route]`；
- 生产环境如需暴露 Swagger（不推荐），可移除 `Program.cs` 第 81 行的 `IsDevelopment()` 判断。

### 4.2 405 Method Not Allowed

**现象**

请求返回 405，响应头包含 `Allow: GET, POST`。

**根因**

- HTTP 方法与路由定义不匹配（如对 `[HttpGet]` 端点发起 PUT）；
- 路由冲突：同一路径注册了多个不同方法的端点，但客户端使用了未定义的方法；
- `ApiController` 模式下，body 参数绑定失败可能误判为方法不允许。

**排查步骤**

1. 查看响应头 `Allow` 字段，确认支持的 HTTP 方法；
2. 在 Swagger 中查看该路径支持的方法；
3. 检查控制器上是否同时标注了 `[HttpGet]`/`[HttpPost]` 等。

**处置方案**

- 使用正确的 HTTP 方法重试；
- 若 API 设计变更，同步更新前端调用与 [API Spec](./LicenseManager-Platform-API-Spec-v1.0.md)。

### 4.3 400 Bad Request

**现象**

请求返回：

```json
{ "success": false, "error": { "code": "invalid_argument", "message": "Invalid request." } }
```

或：

```json
{ "success": false, "error": { "code": "invalid_operation", "message": "Operation invalid." } }
```

**根因**

- 请求体 JSON 格式错误（参考 `ExceptionHandlingMiddleware.cs` 第 50-51 行，`ArgumentException` 与 `InvalidOperationException` 均映射到 400）；
- 模型绑定失败：`[FromBody]` 反序列化失败、必填字段缺失、类型转换失败；
- 参数校验失败（`DataAnnotation` 或自定义校验）；
- `ApiResponseWrapperMiddleware` 包装前的控制器抛出 `ArgumentException`。

**排查步骤**

1. 检查请求体是否为合法 JSON，`Content-Type` 是否为 `application/json`；
2. 比对请求体结构与控制器入参 DTO（参考 `src/LicenseManager.Api/Dtos/Requests/`）；
3. 查看 API 日志中的异常堆栈（`ExceptionHandlingMiddleware` 会 `LogError` 完整异常，参考第 34 行）；
4. 注意：`ExceptionHandlingMiddleware` 对系统异常的消息做了脱敏处理（参考第 42-44 行注释），客户端看到的 `Invalid request.` 不含细节，需查日志获取原始 `ex.Message`。

**处置方案**

- 修正请求体格式与字段；
- 前端 `client.ts` 第 91-99 行会捕获 400 错误并通过 `ElMessage.error` 弹窗，需在表单中提供字段级错误提示。

### 4.4 429 Too Many Requests

**现象**

请求返回：

```json
{
  "success": false,
  "error": {
    "code": "rate_limit_exceeded",
    "message": "Too many requests; retry after backoff.",
    "details": { "limit": 600, "window_seconds": 60, "retry_after_seconds": 60 }
  }
}
```

响应头包含 `Retry-After: 60`。

**根因**

- `RateLimitingMiddleware` 按限流主体每分钟限流：已认证请求按 JWT `sub`（每账号独立配额，键 `rl:sub:{sub}:{minute}`），匿名请求按客户端 IP（键 `rl:ip:{ip}:{minute}`）；仓库 `appsettings.json` 配置为每分钟 600 次（代码兜底默认 100，参考 `RateLimitingMiddleware.cs` 第 42 行 `_limitPerMinute`，配置键 `RateLimit:PerMinute`）；多实例部署可配置 `RateLimit:Distributed=true` 走 Redis 全局计数（Redis 未配置时回退内存限流并告警一次）；
- 客户端 IP 通过 `X-Forwarded-For` 头解析（参考第 190-199 行），若反向代理未透传该头，所有匿名请求会被归为同一 IP（代理 IP）导致限流误触发；
- E2E 测试场景：`web/admin/e2e/fixtures.ts` 第 50-55 行使用 worker 作用域 fixture 共享 token，**正是为了避免 200+ 测试并行触发限流**——若禁用该缓存会立即触发 429；
- 跳过路径为 `PublicPathRegistry` 公开路径全集（`/swagger`、`/v1/healthz`、`/v1/readyz`、`/v1/status`、`/v1/error-codes`、`/v1/metrics`、`/v1/auth/login`、`/v1/config/features`、`/v1/auth/sms`、`/v1/portal/auth/sms`、`/v1/auth/sso`、`/v1/portal/auth/sso`、`/v1/keys`、`/v1/portal/auth/login`、`/v1/portal/auth/forgot-password`、`/scim`、`/v1/partner`、`/v1/sandbox`，参考 `RateLimitingMiddleware.cs` 第 181 行 `ShouldSkip` 转调 `PublicPathRegistry`）。

**排查步骤**

1. 查看响应 `details.limit` 与 `details.window_seconds` 确认限流阈值；
2. 检查同一限流主体（已认证账号或匿名 IP）是否在 60 秒内发起超过 600 次请求；
3. 若所有客户端均被限流，检查反向代理是否透传 `X-Forwarded-For` 头；
4. 生产环境通过 `ForwardedHeaders` 中间件还原真实客户端 IP（参考 `Program.cs` 第 95-98 行，仅生产环境启用）。

**处置方案**

- 客户端实现指数退避重试，参考 `Retry-After` 头；
- 调高 `RateLimit:PerMinute` 配置（如改为 1000）；
- 反向代理（Nginx）配置 `proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;`；
- E2E 测试**不要禁用** `fixtures.ts` 中的 worker 作用域 token 缓存。

### 4.5 500 Internal Server Error

**现象**

请求返回：

```json
{ "success": false, "error": { "code": "internal_error", "message": "An unexpected error occurred." } }
```

`requestId` 字段可用于关联日志。

**根因**

- 控制器抛出未被 `ExceptionHandlingMiddleware` 特殊处理的异常（参考第 53 行的默认分支）；
- 数据库异常：连接断开、约束冲突、超时；
- 第三方服务调用异常（SMTP、OIDC IdP）；
- `NullReferenceException` 等编程错误；
- `ApiResponseWrapperMiddleware` 包装响应时与已写入的流冲突。

**排查步骤**

1. 使用响应中的 `requestId` 在日志中检索（`ExceptionHandlingMiddleware` 第 34 行 `LogError` 包含 `Method`、`Path`，但不直接输出 `TraceIdentifier`——需通过时间戳或路径关联）；
2. 在 `Logs` 目录或 stdout 查找 `Unhandled exception on {Method} {Path}` 日志条目；
3. 检查 `inner exception` 是否为数据库相关（`MySqlException`、`DbUpdateException`）；
4. 若为生产环境，`ExceptionHandlingMiddleware` 不会向客户端泄露内部异常消息（参考第 42-44 行脱敏注释），需依赖日志诊断。

**处置方案**

- 修复根本代码缺陷；
- 临时缓解：重启 API 进程恢复（仅适用于内存状态异常）；
- 第三方服务异常：检查 `RegisterHttpClients` 中配置的 Polly 重试策略（参考 `DependencyInjection.cs` 第 219-243 行），如 `oidc` 客户端仅对 GET 请求重试 2 次、指数退避（原 psp 客户端已随商业化裁剪移除）。

### 4.6 502 Bad Gateway

**现象**

前端通过 Vite 代理调用 API 返回 502。

**根因**

- 后端 API 未启动（参考 `vite.config.ts` 第 19 行 `target: 'http://localhost:8080'`）；
- 后端端口与代理目标不一致；
- 后端进程崩溃但代理仍尝试转发；
- 代理与后端网络不通（容器化部署中常见）。

**排查步骤**

1. 直接访问 `http://localhost:8080/` 确认 API 是否返回根路径概览（参考 `Program.cs` 第 102-116 行）；
2. 检查 `vite.config.ts` 中 `proxy['/v1'].target` 与实际 API 端口是否一致；
3. 查看 Vite dev server 日志，确认代理错误详情；
4. 容器化部署中检查容器网络与端口映射。

**处置方案**

- 启动后端：`dotnet run --project src/LicenseManager.Api --urls http://localhost:8080`；
- 同步代理目标端口；
- 容器化部署使用 Docker network 或 `host.docker.internal` 而非 `localhost`。

---

## 5. 数据库问题

### 5.1 连接超时

**现象**

```
MySqlConnector.MySqlException: Connect Timeout expired.
```

**根因**

- MySQL 服务未启动；
- 防火墙阻挡 3306 端口；
- 网络延迟过高（跨可用区访问）；
- 连接池耗尽（`MaximumPoolSize` 默认 100）；
- 长事务持有连接未释放。

**排查步骤**

1. `Test-NetConnection <db-host> -Port 3306` 验证网络可达；
2. 在 MySQL 主机执行 `SHOW PROCESSLIST;` 查看活动连接数；
3. 执行 `SHOW VARIABLES LIKE 'max_connections';` 确认服务端连接上限；
4. 检查 API 日志中是否有 `Timeout expired` 集中出现的时间段。

**处置方案**

- 启动 MySQL 服务；
- 开放防火墙 3306 端口；
- 调整连接串 `MaximumPoolSize`、`ConnectionTimeout` 参数；
- 排查长事务：`SELECT * FROM information_schema.innodb_trx;`；
- API 端排查未 `Dispose` 的 DbContext（`LicenseManagerDbContext` 生命周期为 Scoped，DI 容器自动释放）。

### 5.2 字符集问题（utf8mb4 要求）

**现象**

- 中文存储后变为 `???` 或乱码；
- Emoji 字符写入失败；
- 错误 `Incorrect string value: '\xF0\x9F\x98\x80'`。

**根因**

- MySQL 数据库或表的字符集非 `utf8mb4`（`utf8` 仅支持 BMP 字符，不支持 4 字节 UTF-8 如 Emoji）；
- 连接串未指定 `CharSet=utf8mb4`；
- `LicenseManagerDbContext` 未显式配置字符集（代码中无 `HasCharSet` 调用，依赖连接串与服务端默认值）。

**排查步骤**

1. 在 MySQL 执行：
   ```sql
   SELECT DEFAULT_CHARACTER_SET_NAME, DEFAULT_COLLATION_NAME FROM information_schema.SCHEMATA WHERE SCHEMA_NAME = 'licensemanager';
   ```
2. 检查连接串是否包含 `CharSet=utf8mb4;`（参考 §2.2 示例连接串）；
3. 查看具体表的字符集：`SHOW TABLE STATUS FROM licensemanager;`。

**处置方案**

- 修改数据库字符集：
  ```sql
  ALTER DATABASE licensemanager CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
  ```
- 修改表字符集（需逐表执行或生成脚本）：
  ```sql
  ALTER TABLE Users CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
  ```
- 连接串强制指定 `CharSet=utf8mb4;`；
- 新建数据库时直接指定 `CREATE DATABASE licensemanager CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;`。

### 5.3 表锁死

**现象**

- API 响应缓慢，部分请求超时；
- MySQL `SHOW PROCESSLIST` 中大量 `Waiting for table metadata lock` 或 `Lock wait timeout exceeded`。

**根因**

- 长事务未提交（如 `EfUnitOfWork` 未调用 `SaveChangesAsync` 或 `Dispose`）；
- DDL 操作（如 `ALTER TABLE`）持有元数据锁；
- `EfRepository.SaveAsync` 立即提交（参考 `EfRepository.cs` 第 77 行），通常不会长事务，但跨仓储事务需显式 `IUnitOfWork` 提交（参考第 16 行注释）；
- 连接泄漏：DbContext 未被 DI 容器正确释放。

**排查步骤**

1. `SELECT * FROM information_schema.innodb_trx;` 查看未提交事务；
2. `SHOW PROCESSLIST;` 查看线程状态；
3. `SELECT * FROM performance_schema.events_waits_current;` 查看当前锁等待；
4. 检查 API 日志中是否有 `SaveChangesAsync` 异常但未回滚。

**处置方案**

- `KILL <pid>;` 终止长事务线程；
- 修复代码：确保 `IUnitOfWork.SaveChanges` 在 `try-finally` 中调用，异常时回滚；
- DDL 操作在低峰期执行；
- 调整 `innodb_lock_wait_timeout`（默认 50 秒）。

### 5.4 数据不一致（内存状态 vs 持久化数据）

**现象**

- 通过 API 创建的实体在另一次查询中找不到；
- 单元测试通过但集成测试失败（或反之）；
- 重启 API 后部分状态丢失。

**根因**

- **全部 45 个已建模实体的仓储均已通过 `services.Replace` 从 `InMemoryRepository<T>` 替换为 `EfRepository<T>`**（`DependencyInjection.cs` 第 71-125 行）——领域数据全部持久化，不再存在"实体仅内存存储"的情况；
- 仍为进程内内存状态的组件：`LoginAttempt`（登录失败记录，有意保留内存实现，重启即丢、多实例不互通）、`InMemoryMetricsRegistry`、限流计数器（未启用 `RateLimit:Distributed` 时）、幂等缓存（`IMemoryCache`）、浮动服务器租约（`LeaseService` 进程内维护）；
- 多实例部署时上述内存状态不共享，导致实例间行为不一致。

**排查步骤**

1. 确认丢失的是否为领域实体数据——若是，检查数据库连接与迁移状态（45 个实体均已 EF 化，理论上不会丢）；
2. 若丢失的是登录锁定状态、限流计数、指标或幂等缓存，属内存状态，重启后重置为预期行为；
3. 多实例部署中检查负载均衡后的请求路由，并为限流配置 `RateLimit:Distributed=true` + Redis。

**处置方案**

- 领域数据持久化已全量覆盖（45 个实体）；新增实体时在 `DependencyInjection.cs` 中追加 `services.Replace(ServiceDescriptor.Scoped<IRepository<YourEntity>, EfRepository<YourEntity>>());`；
- 多实例部署前为限流启用 Redis 分布式计数；幂等缓存外置列路线图；
- 重启 API 前需知会用户内存状态（登录锁定、`InMemoryMetricsRegistry`、限流计数器、进程内租约）将丢失。

---

## 6. 前端问题

### 6.1 页面白屏

**现象**

浏览器访问 `http://localhost:5173` 后页面空白，控制台有 JS 错误。

**根因**

- JS 资源加载失败（路径错误、Vite dev server 未启动）；
- Vue 路由错误（参考 `web/admin/src/router/index.ts`）；
- `main.ts` 中组件注册异常（参考 `web/admin/src/main.ts` 第 42-53 行）；
- `i18n` 初始化失败（`zh-CN.json` 或 `en-US.json` 解析错误）；
- Element Plus 样式未加载（参考 `main.ts` 第 4-5 行）。

**排查步骤**

1. 打开浏览器 DevTools → Console 查看 JS 错误堆栈；
2. DevTools → Network 查看是否有 404 资源；
3. 直接访问 `http://localhost:5173/main.ts` 确认 Vite dev server 正常响应；
4. 检查 `web/admin/src/main.ts` 中的 `app.use(...)` 调用顺序。

**处置方案**

- 启动 Vite dev server：`cd web/admin && npm run dev`；
- 修复路由配置或组件 import 路径；
- 清除浏览器缓存与 sessionStorage（`sessionStorage.clear()`）；
- 检查 `i18n/index.ts` 第 14-19 行的 `getDefaultLocale` 是否在 `navigator.language` 异常时抛错。

### 6.2 菜单显示语言键而非翻译文本

**现象**

菜单项显示 `menu.dashboard`、`common.save` 等键名字符串而非"仪表盘"、"保存"等翻译文本。

**根因**

- `vue-i18n` 配置中 `messages` 未包含对应键（参考 `i18n/index.ts` 第 30-33 行加载 `zh-CN.json` 与 `en-US.json`）；
- 组件中使用 `$t('key')` 但 key 在 JSON 文件中不存在；
- `legacy: false` 模式下组合式 API 使用方式错误（参考 `i18n/index.ts` 第 26 行 `legacy: false`）；
- `fallbackLocale: 'en-US'`（参考第 29 行）——若 en-US 中也无该键则显示键名。

**排查步骤**

1. 在 DevTools 中执行 `i18n.global.messages` 查看已加载的消息字典；
2. 检查 `zh-CN.json` 与 `en-US.json` 中是否包含报错键名；
3. 确认组件使用 `{{ $t('key') }}` 或 `const { t } = useI18n()` 而非硬编码字符串；
4. 检查 `setLocale` 调用是否正确（参考 `i18n/index.ts` 第 36-40 行）。

**处置方案**

- 在 `zh-CN.json` / `en-US.json` 中补齐缺失的键值对；
- 修复组件中错误的 key 引用；
- 若为新功能，提交前在两个语言文件中同步添加翻译；
- 切换语言后部分页面可能因硬编码文本未刷新——参考 §6.4。

### 6.3 API 代理 502

**现象**

前端调用 API 返回 502，浏览器 Network 面板显示 `/v1/xxx` 请求失败。

**根因**

- 后端 API 未启动（参考 `vite.config.ts` 第 18-21 行代理配置 `target: 'http://localhost:8080'`）；
- 后端端口变更但 `vite.config.ts` 未同步；
- Vite dev server 启动时后端未就绪，代理缓存了不可达状态。

**排查步骤**

1. 直接 `curl http://localhost:8080/` 验证后端可达；
2. 检查 `vite.config.ts` 中 `server.proxy['/v1'].target` 与后端实际端口是否一致；
3. 重启 Vite dev server 使代理配置生效；
4. 查看 Vite 终端日志中的代理错误详情。

**处置方案**

- 启动后端 API；
- 同步 `vite.config.ts` 与后端端口；
- 重启 Vite dev server：`Ctrl+C` 后重新 `npm run dev`。

### 6.4 语言切换无效

**现象**

点击语言切换器后部分文本仍为原语言。

**根因**

- 组件中存在硬编码文本未使用 `$t()` 包裹；
- `setLocale` 调用后未触发组件重新渲染（`legacy: false` 模式下应自动响应）；
- `localStorage` 中 `admin_locale` 缓存了旧值（参考 `i18n/index.ts` 第 22-23 行 `persistLocale`）；
- 部分页面在 `onMounted` 中读取了语言快照，未响应式更新。

**排查步骤**

1. 切换语言后在 DevTools 执行 `i18n.global.locale.value` 确认 locale 已变更；
2. 检查 `document.documentElement.lang` 是否更新（参考 `i18n/index.ts` 第 39、43 行）；
3. 在仍显示旧语言的组件中搜索硬编码字符串；
4. 清除 `localStorage` 后重新加载页面。

**处置方案**

- 将硬编码文本替换为 `$t('key')`；
- 在 `zh-CN.json` / `en-US.json` 中补齐对应翻译；
- 确保所有页面使用 `useI18n()` 的 `t` 函数而非全局 `i18n.global.t`（组合式 API 推荐方式）。

### 6.5 Element Plus 组件样式异常

**现象**

- `el-table` 边框消失、`el-button` 颜色错乱；
- 暗色模式下样式错乱；
- 自定义样式覆盖不生效。

**根因**

- `main.ts` 第 4-5 行的 Element Plus CSS 未正确加载；
- 暗色模式 CSS（`element-plus/theme-chalk/dark/css-vars.css`）未启用；
- 全局样式 `styles/main.css`（参考 `main.ts` 第 26 行）与 Element Plus 样式冲突；
- E2E 测试中 `el-select` 下拉渲染为 `<teleport>` 到 `body`，定位器需使用 `.el-select-dropdown__item`（参考 `web/admin/e2e/crud-operations.spec.ts` 第 70-73 行）。

**排查步骤**

1. DevTools → Network 查看 Element Plus CSS 是否 200 加载；
2. DevTools → Elements 检查 DOM 是否有 `el-` 前缀类名；
3. 检查 `document.documentElement.classList` 是否包含 `dark`；
4. 自定义样式确认使用了 `:deep()` 穿透 scoped 限制。

**处置方案**

- 确认 `main.ts` 中 `import 'element-plus/dist/index.css'` 与 `import 'element-plus/theme-chalk/dark/css-vars.css'` 未被误删；
- 暗色模式切换通过 `document.documentElement.classList.add('dark')` 触发；
- E2E 测试中操作 `el-select` 下拉使用 `.el-select-dropdown__item:has-text("xxx")` 定位器。

---

## 7. 测试问题

### 7.1 单元测试失败

**现象**

`dotnet test` 报告失败，错误信息包含：

```
System.InvalidOperationException: Unable to resolve service for type 'LicenseManager.Platform.Abstractions.IUserService'
Moq.MockException: All invocations on the mock must have a corresponding setup.
```

**根因**

- DI 容器配置不完整：`AddLicenseManagerCore` / `AddLicenseManagerPlatform` / `AddLicenseManagerInfrastructure` 未按 `Program.cs` 第 42-44 行的顺序调用；
- Mock 设置不完整：`Moq.Mock<T>` 的 `Verify` 调用未在 `Setup` 中声明；
- `SecretProtector` 未在测试中初始化：测试环境虽会自动生成进程级随机密钥（参考 `SecretProtector.cs` 第 168-177 行 `EnsureKey`），但 `IsInitialized` 为 false，可能影响服务层判断；
- `JwtSecretResolver` 在测试环境使用进程级缓存密钥（参考 `JwtSecretResolver.cs` 第 46-55 行），同一测试运行内 token 签发与校验一致，但跨进程不一致；
- EF Core InMemory 提供程序行为与关系型数据库差异（如不支持外键约束、事务回滚）。

**排查步骤**

1. 查看测试输出中的 `System.InvalidOperationException` 完整堆栈，定位缺失的服务；
2. 检查测试工程的 `GlobalUsings.cs` 与 DI 注册顺序；
3. 对 Mock 调用使用 `MockBehavior.Loose`（默认）以减少 `Verify` 失败；
4. 参考已有的 `tests/LicenseManager.Api.Tests/ApiFactory.cs` 等测试基础设施。

**处置方案**

- 补全 DI 注册：参考 `Program.cs` 第 42-44 行；
- 使用 `WebApplicationFactory<Program>`（参考 `tests/LicenseManager.Api.Tests/ApiFactory.cs`）替代手动 DI；
- 测试中显式初始化 `SecretProtector`（若服务依赖 `IsInitialized`）；
- 跨数据库的差异通过抽象层屏蔽，不要在单元测试中断言数据库特定行为。

### 7.2 E2E 测试 flaky

**现象**

Playwright E2E 测试偶尔失败，重跑可能通过。常见失败点：

- `el-select` 下拉选项点击无响应；
- `el-dialog` 关闭动画期间定位器找不到元素；
- 表格加载未完成即断言。

**根因**

- Element Plus 的 `el-select` 下拉通过 `<teleport>` 渲染到 `body`，且开启动画，Playwright 的 `click` 可能在动画完成前触发（参考 `web/admin/e2e/crud-operations.spec.ts` 第 70-73 行的 `.el-select-dropdown__item` 定位器）；
- `el-dialog` 关闭有 300ms 动画，期间元素仍在 DOM 中（参考 `web/admin/e2e/customers.spec.ts` 第 25-26 行使用 `not.toBeVisible({ timeout: 3000 })`）；
- `el-table` 数据加载异步，断言前未等待 `networkidle`（参考 `web/admin/e2e/list-pages.spec.ts` 第 164 行 `waitForLoadState('networkidle')`）；
- 测试并行度过高触发后端限流（参考 §4.4）；
- `playwright.config.ts` 第 26 行 `actionTimeout: 10_000` 与第 27 行 `navigationTimeout: 30_000` 设置过紧；
- CI 环境资源受限，`workers: 1`（参考 `playwright.config.ts` 第 19 行）但本地默认为 CPU 核数。

**排查步骤**

1. 使用 `--reporter=html` 查看失败截图与 trace（参考 `playwright.config.ts` 第 23 行 `trace: 'on-first-retry'`、第 24 行 `screenshot: 'only-on-failure'`）；
2. 在失败用例前添加 `await page.waitForTimeout(500)` 等待动画（不推荐作为长期方案）；
3. 使用 `await expect(locator).toBeVisible()` 替代 `await locator.click({ force: true })`；
4. 检查后端 API 是否在测试期间返回 5xx 或 429。

**处置方案**

- 对 Element Plus 下拉使用 `await page.locator('.el-select-dropdown__item:has-text("xxx")').click()`，必要时先 `waitFor` 可见；
- 对 `el-dialog` 关闭使用 `await expect(dialog).not.toBeVisible({ timeout: 3000 })`；
- 对表格数据使用 `await expect(page.locator('.el-table__row')).toHaveCount(N)`；
- 调整 `playwright.config.ts` 第 18 行 `retries: process.env.CI ? 2 : 0` 在本地也启用 1 次重试以观察 flaky；
- 不要禁用 `fixtures.ts` 第 50-55 行的 worker 作用域 token 缓存，否则会触发 429。

### 7.3 E2E 测试 401 误报

**现象**

E2E 测试中调用 API 返回 401，但凭据正确。

**根因**

- `fixtures.ts` 第 50-55 行的 `authState` fixture 为 worker 作用域，**只在每个 worker 启动时登录一次**，token 缓存在 worker 内共享给该 worker 下所有 test；
- 后端 API 进程重启后 `Jwt:Secret` 在开发环境使用进程级随机密钥（参考 `JwtSecretResolver.cs` 第 46-55 行），旧 token 失效；
- 测试运行期间后端进程崩溃重启，缓存的 token 不再有效；
- 并行 worker 各自登录，若后端限流触发 429，登录请求失败导致 `authState` 抛错（参考 `fixtures.ts` 第 40-41 行 `throw new Error`）；
- `sessionStorage` 在 `page.addInitScript` 中预填的 token（参考 `fixtures.ts` 第 58-64 行）若与最新登录的 token 不一致（如多个 worker 复用同一 browser context）。

**排查步骤**

1. 查看失败用例的错误信息：`登录接口返回 401` 或 `登录接口返回 429`；
2. 确认后端 API 进程在测试期间未重启（检查 `dotnet run` 终端输出）；
3. 在 `fixtures.ts` 第 33-48 行的 `loginViaApi` 中添加日志打印 `token` 前 20 字符，确认不同 worker 拿到不同 token；
4. 检查 `playwright.config.ts` 第 19 行 `workers` 是否过高（默认 `undefined` 等于 CPU 核数）。

**处置方案**

- 测试期间不要重启后端 API；
- 若后端必须重启，重启后清空 Playwright worker 缓存（关闭并重开测试运行）；
- 在 `appsettings.Local.json` 中设置稳定的 `Jwt:Secret`（≥32 字节），使 token 跨重启仍有效；
- 降低 `workers` 数量或调高 `RateLimit:PerMinute` 以避免登录触发限流；
- **不要**为每个 test 单独登录（参考 `fixtures.ts` 注释，会触发 429）。

---

## 8. 第三方集成问题

### 8.1 Stripe Webhook 签名验证失败

（已随商业化裁剪移除：支付/PSP/退款相关故障不再适用，资金流由外部销售系统承接。）

### 8.2 PayPal Webhook 验证失败

（已随商业化裁剪移除：支付/PSP/退款相关故障不再适用，资金流由外部销售系统承接。）

### 8.3 SMTP 邮件发送失败

**现象**

- 日志包含 `Failed to send email to {To} with subject {Subject}`（参考 `SmtpEmailSender.cs` 第 103 行）；
- 用户未收到邮件（注册、密码重置、通知等场景）。

**根因**

- `appsettings.json` 第 42-49 行 `Smtp:Host` 为空或 `localhost`（参考 `SmtpEmailSender.cs` 第 60-64 行，此时 `SendAsync` 直接返回 `true` 但不发送，仅 `LogWarning`）；
- SMTP 主机不可达（网络问题、防火墙）；
- 认证失败（用户名/密码错误）；
- SSL/TLS 配置错误（`EnableSsl` 与端口不匹配，如 587 端口需 STARTTLS）；
- 收件人地址为空（参考第 52-55 行，`SendAsync` 返回 `false`）；
- SMTP 超时（默认 30 秒；已迁移至 MailKit，通过 CancellationToken 传递超时，参考 `SmtpEmailSender.cs` 第 20-21、82-84 行）。

**排查步骤**

1. 检查 `appsettings.json` / `appsettings.Local.json` 中 `Smtp` 节点配置；
2. 在 API 主机执行 `Test-NetConnection <smtp-host> -Port <port>` 验证网络可达；
3. 使用 `openssl s_client -connect <smtp-host>:<port> -starttls smtp` 测试 TLS 握手；
4. 查看 API 日志中的完整异常堆栈（`SmtpException` 包含状态码）；
5. 若日志为 `SMTP not configured, skipping email send`，说明 `Host` 未配置或为 `localhost`（参考第 60-64 行），并非真的发送失败。

**处置方案**

- 配置 `Smtp:Host`、`Smtp:Port`、`Smtp:Username`、`Smtp:Password`、`Smtp:EnableSsl`；
- 常见 SMTP 配置：
  - Gmail: `smtp.gmail.com:587`, `EnableSsl=true`（需应用专用密码）；
  - Office 365: `smtp.office365.com:587`, `EnableSsl=true`；
  - SendGrid: `smtp.sendgrid.net:587`, `EnableSsl=true`；
- 测试环境使用 [MailHog](https://github.com/mailhog/MailHog) 或 [Mailpit](https://github.com/axllent/mailpit) 作为本地 SMTP 服务器；
- 已迁移至 MailKit（`SmtpEmailSender` 基于 `MailKit.Net.Smtp.SmtpClient`），无遗留 TODO。

### 8.4 OIDC 登录失败

**现象**

OIDC 单点登录流程失败，日志包含：

```
OIDC discovery request to {Url} failed with status {Status}
ID Token iss claim mismatch
ID Token aud claim does not include expected audience
ID Token has expired
IHttpClientFactory unavailable; falling back to a bare HttpClient
```

**根因**

- `OidcClient` 依赖 `IHttpClientFactory` 创建名为 `oidc` 的 HttpClient（参考 `OidcClient.cs` 第 17、33 行），未注册时回退到裸 `HttpClient` 并 `LogWarning`（参考第 36-39 行）——生产环境不应出现此警告；
- IdP 的 discovery 文档 URL 不可达（`{issuerUrl}/.well-known/openid-configuration`，参考第 63 行）；
- `issuerUrl`、`clientId`、`clientSecret`、`redirectUri` 配置错误；
- ID Token 的 `iss`、`aud`、`exp`、`nonce` claims 校验失败（参考 `ValidateIdTokenAsync` 第 145-231 行）；
- JWT 签名校验失败：已通过 IdP 的 JWKS 公钥完成签名校验（`ValidateIssuerSigningKey = true`，签名密钥来自 discovery 文档的 JWKS，参考 `OidcClient.cs` 第 160-189 行）——签名不匹配、密钥不可用或 JWKS 拉取失败均返回 null（fail-closed）；
- `expectedNonce` 与 IdP 返回的 `nonce` claim 不匹配（防重放失败）。

**排查步骤**

1. 确认 `IHttpClientFactory` 已注册（`DependencyInjection.cs` 第 113 行调用 `RegisterHttpClients`，`AddHttpClient("oidc", ...)` 在第 236 行）；
2. 浏览器直接访问 `{issuerUrl}/.well-known/openid-configuration` 确认 discovery 文档可访问；
3. 在 API 日志中查看具体的 OIDC 失败原因（`iss mismatch` / `aud mismatch` / `expired` / `nonce mismatch`）；
4. 在 [jwt.io](https://jwt.io) 解码 ID Token 查看 `iss`、`aud`、`exp`、`nonce` claims；
5. 确认 `redirectUri` 与 IdP 控制台配置的回调 URL 一致。

**处置方案**

- 修正 `OIDC` 配置（issuerUrl、clientId、clientSecret、redirectUri）；
- 同步服务器时间（NTP），避免 `exp` 校验误判；
- 确保每次登录流程生成唯一的 `nonce` 并在回调时正确传递；
- **JWT 签名校验已实现**（JWKS 验签，参考 `OidcClient.cs` 第 142-143 行注释）——若验签失败，确认 IdP 的 JWKS 端点（`{issuerUrl}/.well-known/openid-configuration` 指向的 `jwks_uri`）可访问；
- 反向代理透传 `Authorization` 头与原始 URL（用于 `redirectUri` 拼接）。

### 8.5 支付退款失败（404 / 状态校验 / 剩余可退）

（已随商业化裁剪移除：支付/PSP/退款相关故障不再适用，资金流由外部销售系统承接。）

### 8.6 入站支付 Webhook 返回 503 / 200 的重试语义

（已随商业化裁剪移除：支付/PSP/退款相关故障不再适用，资金流由外部销售系统承接。）

### 8.7 出站 Webhook 投递失败（签名时间戳 / 端点未生效）

**现象**

订阅方报告验签失败或收不到投递；管理端注册的端点看似正常但从未收到事件。

**根因**

- **签名原文变化**：出站签名附加 `X-LicenseManager-Timestamp` 头（Unix 秒），签名原文为 `"{timestamp}\n{payload}"`（不再是纯 payload）——接收方需按新原文验签；官方 SDK（csharp/nodejs/python）已内置该头解析与 ±300 秒时效校验，无该头时向后兼容纯 payload 签名（参考 `WebhookDeliveryWorker.cs` 第 190-201 行）；
- **端点未生效**：投递订阅来自 `WebhookService.PublishAsync` 经 `IWebhookEndpointService.GetSubscribersAsync` 查询管理端注册且启用的端点——端点未启用（`IsActive=false`）或订阅事件类型不匹配时不会投递；
- **URL 被 SSRF 校验拒绝**：目标 URL 非 HTTPS、指向私有/环回 IP 等（`LastResponse` 为 `url_rejected:...`，不再重试）；
- **重定向被拒**：webhook 客户端 `AllowAutoRedirect=false`，目标端点返回 30x 视为失败（防重定向 SSRF）；
- **记录被清理**：终态（Delivered/Failed）且超过 30 天的投递记录会被自动删除（每轮投递后清理，参考 `WebhookDeliveryWorker.cs` 第 124-138 行）。

**排查步骤**

1. 检查端点的 `IsActive` 与订阅事件类型是否覆盖目标事件；
2. 查看 `webhook_delivery` 记录的 `LastStatusCode` / `LastResponse`（`endpoint_not_found`、`url_rejected:...`、`HTTP 30x`）；
3. 接收方确认按 `"{timestamp}\n{payload}"` 原文 + `X-LicenseManager-Timestamp` 头验签，且本地时钟偏差 < ±300 秒。

**处置方案**

- 接收方升级到官方 SDK 新版 `WebhookVerifier`（自动处理时间戳头，无头时兼容旧签名）；
- 自定义接收方按 `HMAC-SHA256(secret, "{timestamp}\n{payload}")` 重算并校验时间戳时效；
- 端点 URL 改为公网 HTTPS 地址并启用端点后重试投递。

### 8.8 证书族 PSP 回调被拒（fail-dead 设计）

（已随商业化裁剪移除：支付/PSP/退款相关故障不再适用，资金流由外部销售系统承接。）

### 8.9 webhook secret 来自 PspConfig 还是 env/config（判断方法）

（已随商业化裁剪移除：支付/PSP/退款相关故障不再适用，资金流由外部销售系统承接。）

### 8.10 PSP 事件重投递被去重跳过（预期幂等行为）

（已随商业化裁剪移除：支付/PSP/退款相关故障不再适用，资金流由外部销售系统承接。）

## 9. 性能问题

### 9.1 API 响应慢

**现象**

API 平均响应时间从毫秒级升至秒级，P99 延迟超过 1 秒。

**根因**

- **N+1 查询**：`EfRepository.ListAsync` 加载列表后在循环中逐条查询关联实体（参考 `EfRepository.cs` 第 50-58 行的 `ListAsync` 实现）；
- **全表扫描**：未为查询字段建立索引，MySQL `EXPLAIN` 显示 `type=ALL`；
- **数据库连接池耗尽**：默认 `MaximumPoolSize=100`，高并发下排队等待连接；
- 限流误判：反向代理部署未开启 `RateLimit:TrustXForwardedFor`，所有请求被归为同一代理 IP，导致部分请求 429 后重试放大流量；
- **序列化开销**：`ApiResponseWrapperMiddleware` 包装大响应体时同步序列化阻塞；
- **同步阻塞**：控制器调用 `.Result` 或 `.Wait()` 而非 `await`。

**排查步骤**

1. 启用慢查询日志：`SET GLOBAL slow_query_log = 'ON'; SET GLOBAL long_query_time = 1;`；
2. 使用 `EXPLAIN` 分析慢查询执行计划；
3. 检查 `SHOW PROCESSLIST` 是否有大量 `Sleep` 状态连接；
4. 通过 `GET /v1/metrics` 查看请求计数与延迟分布；
5. 使用 APM（如 Application Insights、Datadog）追踪请求链路。

**处置方案**

- N+1 查询：改用 `Include` 或 `Join` 预加载关联数据；
- 全表扫描：为 `WHERE`、`ORDER BY`、`JOIN` 字段建立索引；
- 连接池：调高 `MaximumPoolSize` 或增加 API 实例；
- 限流：参考 §4.4 配置 `X-Forwarded-For`；
- 序列化：使用 `System.Text.Json` 的异步写入（参考 `ApiResponseWrapperMiddleware` 实现）；
- 同步阻塞：全局搜索 `.Result`、`.Wait()`、`Task.Run` 并替换为 `await`。

### 9.2 内存泄漏

**现象**

API 进程内存持续增长，重启后恢复正常。

**根因**

- **`ConcurrentDictionary` 速率限制计数器**：`RateLimitingMiddleware` 使用 `IMemoryCache` 配合 90 秒过期（参考 `RateLimitingMiddleware.cs` 第 109-116 行），过期后由 GC 回收——理论上不泄漏，但若 `IMemoryCache` 实现异常或 `Counter` 对象被外部引用则可能累积；
- `InMemoryMetricsRegistry`（参考 `Program.cs` 第 47 行注册为 Singleton）：指标时间序列无上限可能累积；
- `InMemoryRepository<T>`（Singleton）：若实体持续增长（如 `AuditLog`）会撑爆内存；
- `IDisposable` 资源未释放：`HttpClient` 未通过 `IHttpClientFactory` 创建；
- 事件订阅未取消：长生命周期对象订阅短生命周期对象的事件导致引用链。

**排查步骤**

1. 使用 `dotnet-counters monitor --process-id <pid> --counters System.Runtime` 监控 GC 堆大小；
2. 使用 `dotnet-dump collect --process-id <pid>` 生成堆转储；
3. 使用 `dotnet-gcdump` 分析对象类型与数量；
4. 检查 `InMemoryRepository<T>` 中实体数量是否异常增长；
5. 监控 `IMemoryCache` 的 `CacheCount` 与 `EstimatedSize`。

**处置方案**

- 速率限制：当前实现已使用 `IMemoryCache` 自动过期，无需额外处置；若使用 `ConcurrentDictionary` 自实现限流需手动清理；
- `InMemoryMetricsRegistry`：增加时间窗口清理逻辑，仅保留最近 N 小时数据；
- `AuditLog` 等增长型实体已在 `EfRepository<T>` 中通过 `Take(10000)` 限制（参考 §9.3）；
- `HttpClient` 始终通过 `IHttpClientFactory` 创建（参考 `DependencyInjection.cs` 第 205-228 行）；
- 事件订阅：在 `Dispose` 中取消订阅。

### 9.3 EfRepository.ListAsync 全表加载

**现象**

- 调用 `IInMemoryRepository<T>.ListAsync()` 返回的数据不全（最多 10000 条）；
- 或返回 10000 条数据导致内存与网络开销大。

**根因**

`EfRepository<T>.ListAsync` 实现（参考 `src/LicenseManager.Infrastructure/Persistence/EfRepository.cs` 第 50-58 行）：

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

- 添加 `Take(10000)` 作为安全上限，防止 `AuditLog` 等增长型表导致 OOM；
- 使用 `AsNoTracking` 避免变更跟踪器撑爆；
- **不返回全量数据**——超过 10000 条的记录会被截断，调用方需感知此限制。

**排查步骤**

1. 确认目标表的实际行数：`SELECT COUNT(*) FROM <table>;`；
2. 检查调用方是否假设 `ListAsync` 返回全量数据；
3. 对于需要全量导出的场景，应使用分页或流式查询。

**处置方案**

- **不要**移除 `Take(10000)` 限制——这是防止 OOM 的安全网；
- 大数据量场景使用分页查询接口（`page`、`page_size` 参数）；
- 全量导出场景使用 `IAsyncEnumerable<T>` 或 `SqlBulkCopy` 流式读取；
- 若确需调高上限，应同时评估内存与网络影响，并改用分页；
- `AuditLog`、`LoginAttempt` 等增长型表应配置定期归档任务。

---

## 10. 日志与诊断

### 10.1 日志位置与级别

**日志位置**

| 环境 | 输出位置 | 配置 |
| --- | --- | --- |
| 开发 | 控制台（stdout） | `Program.cs` 第 14 行 `Console.OutputEncoding = Encoding.UTF8`，确保中文不乱码 |
| 测试 | 测试运行器输出 | xUnit `ITestOutputHelper` 或控制台 |
| 生产 | 控制台 / 文件 / 日志服务 | 通过 `ILogger` 提供程序扩展（如 Serilog、NLog） |

> 项目默认未配置文件日志提供程序，生产环境需通过 `Microsoft.Extensions.Logging.Console` 或第三方 sink（Serilog）输出。

**日志级别**

`appsettings.json` 第 10-14 行配置：

```json
"Logging": {
  "LogLevel": {
    "Default": "Information",
    "Microsoft.AspNetCore": "Warning"
  }
}
```

| 级别 | 用途 | 示例 |
| --- | --- | --- |
| `Trace` | 极详细诊断 | 通常不开启 |
| `Debug` | 开发调试 | `SeedService.cs` 第 72 行 `LogDebug("默认管理员账号已存在")` |
| `Information` | 业务事件 | `SeedService.cs` 第 82 行 `LogInformation("已创建默认管理员账号")` |
| `Warning` | 可预期异常 | `SeedService.cs` 第 86 行 `LogWarning("初始化默认管理员账号失败")` |
| `Error` | 未处理异常 | `ExceptionHandlingMiddleware.cs` 第 34 行 `LogError(ex, "Unhandled exception")` |
| `Critical` | 严重故障 | `JwtSecretResolver.cs` 第 48 行 `LogCritical("JWT secret is not configured")` |

**调试技巧**

- 临时调高日志级别：在 `appsettings.Local.json` 中覆盖：
  ```json
  { "Logging": { "LogLevel": { "Default": "Debug", "Microsoft.EntityFrameworkCore": "Information" } } }
  ```
- EF Core SQL 日志：设置 `Microsoft.EntityFrameworkCore.Database.Command` 为 `Information` 可查看实际 SQL；
- 启动期异常：`Program.cs` 第 152 行 `SeedService.InitializeAsync` 的异常被 `catch` 后 `LogWarning`（参考 `SeedService.cs` 第 84-87 行），**不会阻止启动**——需查 warning 日志确认是否成功。

### 10.2 结构化日志查询

LicenseManager 使用 ASP.NET Core 内置的 `ILogger<T>` 结构化日志，所有日志均采用模板化消息（非字符串插值），便于日志聚合系统（ELK、Loki、Datadog）查询。

**结构化字段约定**

| 字段 | 出现位置 | 示例值 |
| --- | --- | --- |
| `Method` | `ExceptionHandlingMiddleware` 第 34 行 | `POST` |
| `Path` | `ExceptionHandlingMiddleware` 第 34 行 | `/v1/admin/users` |
| `Email` | `AuthController` 第 70、87、118 行 | `admin@licensemanager.cn` |
| `Subject` | `AuthenticationMiddleware` 第 138 行 | 用户 ID |
| `KeyId` | `AuthenticationMiddleware` 第 188、228 行 | API Key ID |
| `To` | `SmtpEmailSender` 第 62、94、103 行 | 收件人邮箱 |
| `Subject` (邮件) | `SmtpEmailSender` 第 62、94、103 行 | 邮件主题 |
| `Issuer` | `OidcClient` 第 134 行 | OIDC Issuer URL |

**查询示例（Kusto / Loki）**

```kusto
// 查找所有登录失败
AppLogs
| where Level == "Warning" or Level == "Error"
| where Message has "登录失败" or Message has "Login"
| project Timestamp, Level, Message, Email

// 查找所有未处理异常
AppLogs
| where Message startswith "Unhandled exception on"
| project Timestamp, Method, Path, Exception

// 查找 HMAC 签名失败
AppLogs
| where Message has "HMAC" and Message has "mismatch"
| project Timestamp, KeyId
```

### 10.3 审计日志查询

**审计日志存储**

- `AuditLog` 实体通过 `EfRepository<AuditLog>` 持久化到数据库（参考 `DependencyInjection.cs` 第 74 行）；
- 表名 `audit_log`（snake_case 单数，`DbSet` 属性名为 `AuditLogs`），主键 `Id`（Guid）；
- 关键字段：`Action`、`ResourceType`、`ResourceId`、`ActorType`、`ActorId`、`OccurredAt`、`BeforeJson`、`AfterJson`（JSON 快照，列名 PascalCase，以实际 schema 为准）。

**通过 API 查询**

```
GET /v1/admin/audit-log?resourceType=User&from=2026-07-01T00:00:00Z&to=2026-07-31T23:59:59Z&limit=50&offset=0
```

支持的查询参数（参考 `AdminController.cs` 第 416-424 行）：`resourceId`（资源标识）、`resourceType`（资源类型）、`from`/`to`（时间范围）、`limit`（返回条数上限，默认 50）、`offset`（分页偏移，默认 0）。

**直接数据库查询**

```sql
SELECT Id, Action, ResourceType, ResourceId, ActorType, ActorId, OccurredAt, BeforeJson, AfterJson
FROM audit_log
WHERE ResourceType = 'User'
  AND OccurredAt >= '2026-07-01'
  AND OccurredAt < '2026-08-01'
ORDER BY OccurredAt DESC
LIMIT 50;
```

**审计日志字段说明**

| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `Id` | Guid | 审计条目主键 |
| `Action` | string | 操作类型（如 `create`、`update`、`delete`、`login`） |
| `ResourceType` | string | 资源类型（如 `User`、`License`、`Order`） |
| `ResourceId` | string | 资源 ID |
| `ActorType` | string | 操作者类型（`user`、`api_key`、`system`） |
| `ActorId` | string | 操作者 ID（参考 `AuditService.cs` 第 51 行，可从 `HttpContext` 回填） |
| `OccurredAt` | DateTimeOffset | 发生时间（UTC） |
| `Before` | string (JSON) | 变更前快照 |
| `After` | string (JSON) | 变更后快照 |

**审计日志限制**

- `EfRepository<AuditLog>.ListAsync` 受 `Take(10000)` 限制（参考 §9.3），全量查询需使用分页接口；
- `AuditService.RecordAsync` 不写入 `requestId`，需通过 `OccurredAt` 与 `ActorId` 关联应用日志；
- 审计日志不记录失败操作（如登录失败由 `LoginAttempt` 表单独记录）。

---

## 11. 附录

### 11.1 错误码速查表

#### 认证类错误码

| 状态码 | error.code | 触发位置 | 含义 |
| --- | --- | --- | --- |
| 401 | `unauthorized` | `AuthenticationMiddleware.cs` 第 84 行 | 缺少 `Authorization` 头 |
| 401 | `auth.invalid_token` | `AuthenticationMiddleware.cs` 第 91 行 | 认证失败（token 无效/过期/Api Key 无效/HMAC 失败） |
| 401 | `auth.invalid_credentials` | `AuthController.cs` 第 65、94、101 行 | 邮箱或密码错误 |
| 403 | `forbidden` | `AuthorizationMiddleware.cs` 第 69 行 | 已认证但无权限访问 `/v1/admin/*` |
| 423 | `auth.account_locked` | `AuthController.cs` 第 75-80 行 | 账号因多次失败被锁定 15 分钟 |
| 429 | `rate_limit_exceeded` | `RateLimitingMiddleware.cs` 第 79 行 | 超过每分钟请求限制（仓库配置 600，代码兜底默认 100） |

#### 业务类错误码

| 状态码 | error.code | 触发位置 | 含义 |
| --- | --- | --- | --- |
| 400 | `invalid_argument` | `ExceptionHandlingMiddleware.cs` 第 57 行 | `ArgumentException` 派生异常 |
| 400 | `invalid_operation` | `ExceptionHandlingMiddleware.cs` 第 58 行 | `InvalidOperationException` |
| 404 | `not_found` | `ExceptionHandlingMiddleware.cs` 第 56 行 | `NotFoundException` |
| 409 | `license.InvalidOperation` | `ExceptionHandlingMiddleware.cs` 第 50 行 | `LicenseInvalidOperationException` |
| 422 | `license.<ExceptionName>` | `ExceptionHandlingMiddleware.cs` 第 51 行 | `LicenseException` 派生业务异常 |
| 422 | `business_rule_violation` | `ExceptionHandlingMiddleware.cs` 第 54 行 | `UnprocessableEntityException` |
| 422 | `LICENSE_REVOKED` / `LICENSE_DISABLED` / `LICENSE_SUSPENDED` / `LICENSE_EXPIRED` / `HARDWARE_MISMATCH` 等 | `AirgapActivationService.cs` 第 74-96 行 | 离线激活/客户生命周期端点返回的大写业务码（HTTP 422，`Problem(title,status,code)` 信封，非 `license.` 前缀体系） |
| 500 | `internal_error` | `ExceptionHandlingMiddleware.cs` 第 60 行 | 未处理异常（默认分支） |

> 注意：`error.code` 中 `<ExceptionName>` 由 `ExceptionHandlingMiddleware.ToErrorCode` 生成（参考第 82-88 行），去掉 `Exception` 后缀，保留 PascalCase（如 `license.LicenseExpired`）。另有校验失败 `validation_error`（400，`ApiResponseWrapperMiddleware` 由 ProblemDetails 转换）、维护模式 `maintenance_mode`（503）、存根端点 `feature_unavailable`（501），以 `GET /v1/error-codes`（`ErrorCodesController`）返回的字典为准。

### 11.2 日志位置速查表

| 日志类型 | 位置 | 查询方式 |
| --- | --- | --- |
| 应用日志（控制台） | API 进程 stdout | `dotnet run` 终端直接查看 |
| 应用日志（文件） | 需配置文件日志提供程序 | 默认不输出文件，生产环境推荐 Serilog + 文件 sink |
| EF Core SQL 日志 | 应用日志（`Information` 级别） | 设置 `Microsoft.EntityFrameworkCore.Database.Command` 为 `Information` |
| 审计日志 | 数据库 `audit_log` 表 | `GET /v1/admin/audit-log` 或 SQL 查询（参考 §10.3） |
| 登录尝试日志 | **内存记录**（`InMemoryRepository<LoginAttempt>`，非数据库表，重启即丢） | 应用日志；不可 SQL 查询 |
| SMTP 邮件日志 | 应用日志 | 搜索 `Email sent to` 或 `Failed to send email` |
| 健康检查日志 | 应用日志 | `GET /v1/healthz` 与 `GET /v1/readyz` 响应 |
| Playwright 测试日志 | `web/admin/playwright-report/` | `npx playwright show-report` |
| 测试结果 TRX | `test-results/*.trx` | Visual Studio / `dotnet test --logger "console;verbosity=detailed"` |

### 11.3 配置键速查表

| 配置键 | 默认值 | 来源 | 说明 |
| --- | --- | --- | --- |
| `Database:Provider` | `PostgreSQL`（`DatabaseOptions`） | `appsettings.json` 设置为 `MySql` | 数据库提供程序 |
| `Database:ConnectionString` | 空 | `appsettings.json` / `appsettings.Local.json` | 数据库连接串 |
| `Database:ServerVersion` | 空 | `appsettings.json` | MySQL 版本号（避免启动期 AutoDetect） |
| `ConnectionStrings:MySql` | 空 | `appsettings.json` | MySQL 连接串（兼容旧版配置） |
| `ConnectionStrings:Redis` | 空 | `appsettings.json` | Redis 连接串（空则不启用） |
| `Jwt:Secret` | 空 | 环境变量 / `appsettings.Local.json` | JWT 签名密钥（生产必填） |
| `Auth:Enabled` | `true` | `appsettings.json` | 是否启用认证 |
| `RateLimit:PerMinute` | `600`（代码兜底 `100`） | `appsettings.json` | 每分钟请求限制（已认证按 JWT sub，匿名按 IP） |
| `Cors:AllowedOrigins` | `[]` | `appsettings.json` | 生产环境 CORS 白名单 |
| `Seed:AdminEmail` | `admin@licensemanager.cn` | `SeedService.cs` 第 14、24 行 | 默认管理员邮箱 |
| `Seed:AdminPassword` | 空（开发回退 `Admin@123456`） | 环境变量 / `appsettings.Local.json` | 默认管理员密码（生产必填） |
| `Smtp:Host` | 空 | `appsettings.json` | SMTP 主机（空或 `localhost` 跳过发送） |
| `Smtp:Port` | `25` | `appsettings.json` | SMTP 端口 |
| `Smtp:EnableSsl` | `false` | `appsettings.json` | 是否启用 SSL/TLS |
| `Maintenance:Enabled` | `false` | `appsettings.json` | 是否启用维护模式 |

### 11.4 关键端口速查表

| 端口 | 用途 | 配置位置 |
| --- | --- | --- |
| 8080 | 后端 API（HTTP） | `dotnet run --urls http://localhost:8080` |
| 5173 | 前端 Vite dev server | `web/admin/vite.config.ts` 第 16 行 |
| 3306 | MySQL | 数据库服务 |
| 6379 | Redis | 缓存服务 |

### 11.5 关键环境变量速查表

| 环境变量 | 用途 | 必填场景 |
| --- | --- | --- |
| `ASPNETCORE_ENVIRONMENT` | 运行环境（`Development` / `Production`） | 始终建议设置 |
| `LICENSEMANAGER_DATA_PROTECTION_KEY` | AES-GCM 数据保护密钥（base64 编码 32 字节） | 生产必填 |
| `Seed__AdminPassword` | 默认管理员密码 | 生产必填（注意双下划线对应 `:` 分层） |
| `Jwt__Secret` | JWT 签名密钥 | 生产必填 |

### 11.6 紧急处置 Checklist

生产环境出现故障时，按以下顺序快速排查：

- [ ] `GET /v1/healthz` 返回 200？（确认 API 进程存活）
- [ ] `GET /v1/readyz` 返回 `ready`？（确认数据库、Redis 可用）
- [ ] `GET /v1/status/dependencies` 各依赖状态？（定位具体不可用组件）
- [ ] 检查 API 进程内存与 CPU 是否异常？
- [ ] 检查数据库连接数 `SHOW STATUS LIKE 'Threads_connected';`？
- [ ] 检查日志中是否有 `Unhandled exception` 集中爆发？
- [ ] 检查 `RateLimit:PerMinute` 是否被触发（429 错误激增）？
- [ ] 检查第三方服务（SMTP、OIDC IdP）状态页？
- [ ] 检查 `LICENSEMANAGER_DATA_PROTECTION_KEY` 与 `Jwt:Secret` 是否被意外变更？
- [ ] 必要时回滚至上一稳定版本，并保留现场堆转储与日志供事后分析。

---

> 本指南将随版本迭代持续更新，新增故障条目请在修订历史中记录。如发现未覆盖的问题，参考 [API Spec](./LicenseManager-Platform-API-Spec-v1.0.md) 与 [Deployment Guide](./LicenseManager-Platform-Deployment-Guide-v1.0.md) 进一步定位。

---

## 12. 市场特性与登录相关排查（2026-10）

| 现象 | 原因 | 处理 |
| --- | --- | --- |
| SCIM `/scim/v2/*` 全部返回 401 | `Scim:BearerToken` 未配置或令牌错误（fail-closed，两种情况文案一致） | 在 appsettings（或环境变量 `Scim__BearerToken`）配置 ≥32 字符随机值并下发给 IdP |
| 启动即失败并提示短信 Provider 未实现 | `Sms:Provider` 配置为 `dev` 以外的值（内置仅 `dev` 开发发送器） | 改回 `dev`，或实现 `ISmsSender` 后注册对应服务商 |
| 登录页看不到「短信验证码」Tab | 特性开关 `feature.sms_login` 被关闭 | 管理端「系统配置」页面开启，或删除 `system_config` 中该键恢复默认开启 |
| Cookie 横幅 / SCIM 说明不显示 | 对应为欧美特性，默认关闭 | 管理端「系统配置」页面开启 `feature.cookie_consent` / `feature.scim` |
| 短信验证码发送返回 429 `sms.cooldown` | 同号 60 秒冷却（内存频控，重启清零） | 等待 60 秒后重试；生产接入真实短信通道后频率由服务商配额共同约束 |
| 本地 SQLite 启动报 `no such column: u.Phone`（或缺 `system_config` 表） | 本地库由 `EnsureCreated` 创建且无迁移历史，模型加列后未重建 | 删除本地 `licensemanager.db` 重启（开发库无数据时）；生产库执行 `dotnet ef database update` |
