# 面单下载接口性能优化方案

## 接口信息

- 路由：`GET /api/label/label-replace/waybill/{waybillNumber}/download`
- 方法：`DownloadLabelByWaybillNumber`
- 文件：`src/CONTROLLER/Controllers/LabelController.cs#L141-L760`

---

## 一、现状与瓶颈分析

### 1.1 当前串行调用链（热路径）

在正常命中 PDF 缓存（`GetValidCacheAsync` 返回非 null）之前，实际执行了以下**串行**数据库查询：

```
请求进入
  ↓ [DB查询 1] GetLabelReplaceRequestByWaybillNumberAsync
       → LabelReplaceEntity 表（按 NeutralWaybillNumber）
  ↓ [DB查询 2] CheckTriggerAsync → GetScanRecordsByNeutralWaybillNumberAsync
       → LabelScan 表（按 NeutralWaybillNumber，全量返回后 OrderBy + FirstOrDefault）
  ↓ [DB查询 3+4] GetValidCacheAsync
       → LabelPdfCache 表（按 NeutralWaybillNumber）
       → LabelReplace 表（再次查一次，兜底校验）
  ↓ 返回 PDF 字节流
```

**结论：正常命中缓存时，主流程执行了 4 次独立的数据库查询，全部串行执行。**

### 1.2 finally 块中的额外串行查询（L688-L758）

响应已经 `return File(...)` 之后，`finally` 块中还同步执行：

```
finally
  ↓ [DB写入] RecordScanAsync
       → 写入 LabelScan 表 + 写入 OrderLog 表（2次 DB 写入）
  ↓ [DB查询 5] _customerRepository.GetByIdAsync(customerId)
       → 查询 Customer 表
  ↓ [异步] 发送 Webhook（已经是 Task.Run，不阻塞）
```

**结论：`finally` 块中 `RecordScanAsync` 是同步等待的，这意味着响应实际上要等 DB 写入完成后才真正结束。这是导致"时快时慢"的核心原因之一。**

### 1.3 缓存命中路径中的重复查询

`GetValidCacheAsync` 内部会**第二次**查询 `LabelReplace` 表（用于兜底校验），而主流程在 L190 已经查过一次该表。这是一次明显的冗余 DB 查询。

### 1.4 `CheckTriggerAsync` 效率问题

`CheckTriggerAsync` 内调用 `GetScanRecordsByNeutralWaybillNumberAsync`，该方法返回**全量**扫描记录列表，然后在内存中做 `OrderBy(s => s.CreatedAt).FirstOrDefault()`。实际只需要第一条（最早）记录，全量查询是浪费。

### 1.5 `_customerRepository.GetByIdAsync` 在 finally 块中无缓存

`finally` 块 L713 调用 `_customerRepository.GetByIdAsync(customerId)`，该客户数据极少变化，但每次请求都实时查库，既无内存缓存也无 TTL 保护。而项目中已注册了 `IMemoryCache`（通过 `ICacheService`）。

### 1.6 `new HttpClient()` 直接实例化（L528）

在 URL 标签路径下，代码直接 `new HttpClient()`，绕过了已注入的 `_httpClientFactory`，存在连接池耗尽风险，且每次请求都创建新连接，无法复用 TCP 连接，影响延迟。

---

## 二、优化方案

### 优化点 1：并行执行独立 DB 查询（最高收益）

**目标**：将串行的多次 DB 查询改为并行执行。

**方案**：将 `GetLabelReplaceRequestByWaybillNumberAsync` 和 `GetValidCacheAsync` 并行执行（二者无依赖关系），同时将 `CheckTriggerAsync` 也并行触发（其结果只影响是否走 STOP 标签分支，不影响正常路径）。

**具体做法**：

```csharp
// 并行执行三个独立查询
var requestTask = _labelReplaceService.GetLabelReplaceRequestByWaybillNumberAsync(waybillNumber);
var cacheTask = _labelPdfCacheService.GetValidCacheAsync(waybillNumber);

await Task.WhenAll(requestTask, cacheTask);

request = requestTask.Result;
var cache = cacheTask.Result;

// 仅在 request 有效且有 Label 数据时才检查触发器
// (CheckTriggerAsync 可在拿到 request 后串行，因为它依赖 request.Label)
```

注意：`CheckTriggerAsync` 使用了 `dto.Label = label`，而此时 `label` 为 null（L355），所以触发条件 `!string.IsNullOrWhiteSpace(dto.Label)` 永远为 false，`CheckTriggerAsync` 的最终结果永远是 `false`。可以在 `request.Label` 确认不为空之后再调用 `CheckTriggerAsync`，这样可以在 request 为 null 或 ReplaceStatus=="N" 等早退出情况下完全跳过这次 DB 查询。

**预期收益**：并行执行将 3~4 次串行 DB 查询的时间压缩为 1~2 次的时间，理论上减少 50-70% 的等待时间。

---

### 优化点 2：消除 `GetValidCacheAsync` 内的重复查询

**问题**：`GetValidCacheAsync` 内部会在已有 `LabelReplaceEntity` 的情况下再次查询 `LabelReplace` 表做兜底校验。

**方案**：重构 `GetValidCacheAsync` 方法，增加一个重载，支持传入已查询好的 `LabelReplaceEntity order` 对象，跳过内部的二次查询：

```csharp
// 新增重载（或修改签名）
Task<LabelPdfCache?> GetValidCacheAsync(string waybillNumber, LabelReplaceEntity? order = null);
```

内部逻辑改为：若 `order` 参数不为 null，则跳过内部的 `_labelReplaceRepository.GetByWaybillNumberAsync` 调用。

**预期收益**：缓存命中路径减少 1 次 DB 查询。

---

### 优化点 3：`RecordScanAsync` 异步化（消除 finally 阻塞）

**问题**：`finally` 块中的 `RecordScanAsync` 是 `await` 的，这意味着 HTTP 响应实际上在 DB 写入完成前无法返回给客户端（ASP.NET Core 流式响应行为），直接增加了客户端感知延迟。

**方案**：将 `RecordScanAsync` 改为 `Task.Run` 后台执行，与 Webhook 回传逻辑保持一致：

```csharp
// 改为后台执行，不阻塞响应
_ = Task.Run(async () =>
{
    try
    {
        await _labelScanService.RecordScanAsync(...);
    }
    catch (Exception scanEx)
    {
        _logger.LogWarning(scanEx, "Failed to record scan for waybill number: {number}", waybillNumber);
    }
});
```

**注意**：需要在进入 `Task.Run` 之前将所有需要的变量值捕获到局部变量（`customerId`、`waybillNumber`、`scanResult` 等已为值类型/字符串，天然安全）。

**预期收益**：消除约 5-20ms 的 DB 写入等待时间，每次请求均受益。

---

### 优化点 4：`_customerRepository.GetByIdAsync` 增加内存缓存

**问题**：`finally` 块（L713）每次都直接查库获取客户信息，而客户数据变更极少。

**方案**：利用已注册的 `ICacheService` 为客户查询添加内存缓存，TTL 设置为 10 分钟：

```csharp
// 在 LabelController 注入 ICacheService
private readonly ICacheService _cacheService;

// 使用缓存包装 GetByIdAsync
private async Task<CustomerEntity?> GetCustomerWithCacheAsync(int customerId)
{
    var cacheKey = $"customer:{customerId}";
    var cached = await _cacheService.GetAsync<CustomerEntity>(cacheKey);
    if (cached != null) return cached;

    var customer = await _customerRepository.GetByIdAsync(customerId);
    if (customer != null)
        await _cacheService.SetAsync(cacheKey, customer, expirationMinutes: 10);
    return customer;
}
```

在 STOP 标签分支（L231、L377）和 finally Webhook 分支（L713）中，将 `_customerRepository.GetByIdAsync(customerId)` 替换为 `GetCustomerWithCacheAsync(customerId)`。

**预期收益**：对同一客户的重复请求，彻底消除 DB 查询，内存读取时间 < 0.1ms。

---

### 优化点 5：修复 `new HttpClient()` 改用 `_httpClientFactory`

**问题**：L528 使用 `new HttpClient()` 下载 URL 格式的标签，未使用已注入的 `_httpClientFactory`。

**方案**：

```csharp
// 将：
using var httpClient = new HttpClient();
httpClient.Timeout = TimeSpan.FromSeconds(_appSettings.ApiSettings.LabelDownloadTimeout);
labelBytes = await httpClient.GetByteArrayAsync(request.Label, token);

// 改为：
using var httpClient = _httpClientFactory.CreateClient();
httpClient.Timeout = TimeSpan.FromSeconds(_appSettings.ApiSettings.LabelDownloadTimeout);
labelBytes = await httpClient.GetByteArrayAsync(request.Label, token);
```

**预期收益**：复用 TCP 连接，避免每次创建新连接的握手开销（约 10-50ms），减少 `TIME_WAIT` socket 积压风险。

---

### 优化点 6：`GetPdfPageCount` 调用两次的问题

**问题**：L558 调用 `GetPdfPageCount(labelBytes)` 做校验（pageCount > 1 时返回错误），L596 在 else 分支中再次调用 `GetPdfPageCount(labelBytes)` 获取用于缓存的 pageCount。

**方案**：提取到同一个变量中使用：

```csharp
int pageCount = GetPdfPageCount(labelBytes);
if (pageCount > 1)
{
    // 返回错误
}
// else 分支直接使用已计算的 pageCount，不再重复调用
```

**预期收益**：避免重复解析 PDF 字节流（节省 CPU，取决于实现）。

---

### 优化点 7（可选）：内存缓存 `LabelReplaceEntity` 短期缓存

对于高频扫描的运单号，`GetLabelReplaceRequestByWaybillNumberAsync` 可能在极短时间内被重复调用。可在 ICacheService 中缓存 `LabelReplaceEntity` 30 秒，减少 DB 压力：

```csharp
var cacheKey = $"label_replace:{waybillNumber}";
request = await _cacheService.GetAsync<LabelReplaceEntity>(cacheKey);
if (request == null)
{
    request = await _labelReplaceService.GetLabelReplaceRequestByWaybillNumberAsync(waybillNumber);
    if (request != null)
        await _cacheService.SetAsync(cacheKey, request, expirationMinutes: 1); // 1分钟短期缓存
}
```

**注意**：由于 Label URL / Label 内容可能更新，缓存时间不宜过长（建议 1 分钟以内）。

---

## 三、优化优先级与预期效果汇总

| 优先级 | 优化点 | 预期收益 | 实现难度 |
|--------|--------|----------|----------|
| P0 | 优化点 3：RecordScanAsync 异步化 | 每次请求 -5~20ms | 低 |
| P0 | 优化点 1：并行执行 DB 查询 | 整体延迟减半 | 中 |
| P1 | 优化点 4：客户信息内存缓存 | 重复客户请求 -5~15ms | 低 |
| P1 | 优化点 2：消除 GetValidCacheAsync 重复查询 | 缓存命中时 -1次DB | 中 |
| P2 | 优化点 5：修复 HttpClient 实例化 | URL标签路径 -10~50ms | 低 |
| P2 | 优化点 6：GetPdfPageCount 重复调用 | CPU优化 | 低 |
| P3 | 优化点 7：短期内存缓存LabelReplace | 高频访问场景 | 低 |

---

## 四、详细代码改造说明（热路径重构后的伪代码）

```
请求进入
  ↓ 字符串清理（USPS 420 前缀）
  ↓ [并行启动] Task1: GetLabelReplaceRequestByWaybillNumberAsync
                Task2: GetValidCacheAsync（传入 null，先查缓存）
  ↓ await Task.WhenAll(Task1, Task2)
  
  ↓ 若 request == null → 早返回（NoOrderData）
  ↓ 若 ReplaceStatus == "N" → STOP标签分支（使用缓存后的 GetCustomerWithCacheAsync）
  ↓ 若 cache != null → 直接返回缓存 PDF（命中，最快路径）
  ↓ 若 request.Label 为空 → 早返回（NoLabelData）
  
  ↓ [串行] CheckTriggerAsync（此时才需要，传入真实 Label 值）
  ↓ 若 shouldTrigger → STOP标签分支
  
  ↓ 解码/下载 labelBytes（URL 路径使用 _httpClientFactory）
  ↓ GetPdfPageCount(labelBytes) 一次，复用结果
  ↓ 校验 pageCount、文件大小
  
  ↓ 后台异步：SaveCacheAsync + 条码识别
  ↓ return File(labelBytes, ...)
  
finally（不阻塞响应）：
  ↓ [Task.Run] RecordScanAsync
  ↓ [Task.Run（已有）] GetCustomerWithCacheAsync + Webhook 回传
```

---

## 五、实施顺序

1. **P0 - 优化点 3**：`RecordScanAsync` 改为 `Task.Run`（最低风险，最快收益）
2. **P0 - 优化点 1**：重构主流程为并行查询 + 修复缓存命中路径的提前判断顺序
3. **P1 - 优化点 4**：注入 `ICacheService`，为 `GetByIdAsync` 加内存缓存
4. **P1 - 优化点 2**：重构 `GetValidCacheAsync`，支持传入已有的 `LabelReplaceEntity`
5. **P2 - 优化点 5**：`new HttpClient()` 改为 `_httpClientFactory.CreateClient()`
6. **P2 - 优化点 6**：合并 `GetPdfPageCount` 两次调用
