wanshenmean
2026-03-09 4c1f6449a2dd28bdfb76dae2bac127c2081f9f54
添加多出库口轮询功能设计文档

- 支持一个巷道配置多个出库口
- 使用 RoundRobinService 实现轮询负载均衡
- 使用 ConcurrentDictionary 保证线程安全
- 保持向后兼容性
已添加1个文件
323 ■■■■■ 文件已修改
Code/WMS/WIDESEA_WMSServer/docs/plans/2026-03-09-multi-outbound-address-roundrobin-design.md 323 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
Code/WMS/WIDESEA_WMSServer/docs/plans/2026-03-09-multi-outbound-address-roundrobin-design.md
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,323 @@
# å¤šå‡ºåº“口轮询功能设计文档
**日期**: 2026-03-09
**作者**: Claude Code
**状态**: å¾…实施
## æ¦‚è¿°
本设计旨在改进自动出库任务的目标地址配置功能,支持一个巷道配置多个出库口,并通过轮询算法实现负载均衡。
## éœ€æ±‚背景
当前系统中,`TargetAddresses` é…ç½®é‡‡ç”¨ä¸€å¯¹ä¸€æ˜ å°„(巷道前缀 â†’ å‡ºåº“口地址)。但实际业务中,一个巷道可能有多个出库口,需要支持:
1. **一对多关系**:一个巷道可以配置多个出库口
2. **负载均衡**:通过轮询算法选择出库口,避免单点压力
3. **向后兼容**:保持对单出口配置的支持
## æŠ€æœ¯æ–¹æ¡ˆ
### æž¶æž„选择
采用 **内存轮询计数器 + ç‹¬ç«‹æœåŠ¡ç±»** çš„æ¨¡å¼ï¼š
1. **RoundRobinService**:独立的轮询服务类,管理轮询计数器
2. **配置模型变更**:`Dictionary<string, string>` â†’ `Dictionary<string, List<string>>`
3. **线程安全**:使用 `ConcurrentDictionary` ä¿è¯å¤šçº¿ç¨‹å®‰å…¨
### æ ¸å¿ƒè®¾è®¡
#### 1. é…ç½®æ ¼å¼å˜æ›´
**appsettings.json:**
```json
{
  "AutoOutboundTask": {
    "TargetAddresses": {
      "GW": ["10081", "10082", "10083"],
      "CW": ["10080"]
    }
  }
}
```
**说明:**
- å€¼ç±»åž‹ä»Ž `string` æ”¹ä¸º `string[]`(JSON æ•°ç»„)
- æ”¯æŒä¸€ä¸ªå··é“配置多个出库口
- å•个出库口也可用数组格式(只有一个元素)
#### 2. é…ç½®æ¨¡åž‹ç±»
**AutoOutboundTaskOptions.cs:**
```csharp
public class AutoOutboundTaskOptions
{
    public bool Enable { get; set; } = true;
    public int CheckIntervalSeconds { get; set; } = 300;
    public Dictionary<string, List<string>> TargetAddresses { get; set; }
        = new()
        {
            { "GW", new List<string> { "10081" } },
            { "CW", new List<string> { "10080" } }
        };
}
```
#### 3. è½®è¯¢æœåŠ¡ç±»
**RoundRobinService.cs** (新建):
```csharp
using System.Collections.Concurrent;
namespace WIDESEA_Core.Core
{
    /// <summary>
    /// è½®è¯¢æœåŠ¡ - çº¿ç¨‹å®‰å…¨çš„地址轮询选择
    /// </summary>
    public class RoundRobinService
    {
        private readonly ConcurrentDictionary<string, int> _counters = new();
        /// <summary>
        /// èŽ·å–ä¸‹ä¸€ä¸ªåœ°å€ï¼ˆè½®è¯¢ï¼‰
        /// </summary>
        /// <param name="key">巷道前缀</param>
        /// <param name="addresses">地址列表</param>
        /// <returns>选中的地址</returns>
        public string GetNextAddress(string key, List<string> addresses)
        {
            if (addresses == null || addresses.Count == 0)
                return "10080";
            if (addresses.Count == 1)
                return addresses[0];
            // AddOrUpdate æ˜¯åŽŸå­æ“ä½œï¼Œçº¿ç¨‹å®‰å…¨
            int index = _counters.AddOrUpdate(
                key,
                0,                              // é¦–次使用,从 0 å¼€å§‹
                (k, old) => (old + 1) % addresses.Count  // è½®è¯¢ï¼šé€’增后取模
            );
            return addresses[index];
        }
    }
}
```
**关键特性:**
- ä½¿ç”¨ `ConcurrentDictionary<string, int>` å­˜å‚¨æ¯ä¸ªå··é“的当前索引
- `AddOrUpdate` æ–¹æ³•保证原子性,无需额外锁
- æ¨¡è¿ç®—实现循环轮询
- çº¿ç¨‹å®‰å…¨ï¼Œæ”¯æŒåŽå°æœåŠ¡çš„å¹¶å‘è°ƒç”¨
#### 4. TaskService æ–¹æ³•修改
**DetermineTargetAddress æ–¹æ³•:**
```csharp
private string DetermineTargetAddress(
    string roadway,
    Dictionary<string, List<string>> addressMap)
{
    if (string.IsNullOrWhiteSpace(roadway))
        return "10080";
    // æŸ¥æ‰¾åŒ¹é…çš„巷道前缀
    string matchedPrefix = null;
    foreach (var kvp in addressMap)
    {
        if (roadway.Contains(kvp.Key))
        {
            matchedPrefix = kvp.Key;
            break;
        }
    }
    if (matchedPrefix == null)
        return "10080";
    var addresses = addressMap[matchedPrefix];
    if (addresses == null || addresses.Count == 0)
        return "10080";
    // å•个地址,直接返回
    if (addresses.Count == 1)
        return addresses[0];
    // å¤šä¸ªåœ°å€ï¼Œä½¿ç”¨è½®è¯¢
    return _roundRobinService.GetNextAddress(matchedPrefix, addresses);
}
```
**依赖注入:**
```csharp
public class TaskService
{
    private readonly RoundRobinService _roundRobinService;
    public TaskService(
        // ... å…¶ä»–依赖
        RoundRobinService roundRobinService)
    {
        // ... å…¶ä»–赋值
        _roundRobinService = roundRobinService;
    }
}
```
#### 5. æœåŠ¡æ³¨å†Œ
**Program.cs:**
```csharp
// æ³¨å†Œä¸ºå•例,保证全局共享计数器
builder.Services.AddSingleton<RoundRobinService>();
```
## æ•°æ®æµç¨‹
```mermaid
graph TD
    A[创建出库任务] --> B[获取库存巷道信息]
    B --> C[查找匹配的巷道前缀]
    C --> D{地址列表数量}
    D -->|0个| E[返回默认地址 10080]
    D -->|1个| F[直接返回该地址]
    D -->|多个| G[调用 RoundRobinService]
    G --> H[查询/更新计数器]
    H --> I[返回 addresses counter % count]
    I --> J[创建任务]
    F --> J
    E --> J
```
## è½®è¯¢ç¤ºä¾‹
**配置:**
```json
{
  "TargetAddresses": {
    "GW": ["10081", "10082", "10083"]
  }
}
```
**调用序列:**
| æ¬¡æ•° | è®¡æ•°å™¨å€¼ | è¿”回地址 |
|------|---------|---------|
| 1    | 0       | 10081   |
| 2    | 1       | 10082   |
| 3    | 2       | 10083   |
| 4    | 0       | 10081   |
| 5    | 1       | 10082   |
| ...  | ...     | ...     |
## é”™è¯¯å¤„理
1. **配置错误**:
   - åœ°å€åˆ—表为空 â†’ è¿”回默认地址 "10080"
   - å··é“不匹配任何前缀 â†’ è¿”回默认地址 "10080"
2. **并发安全**:
   - ä½¿ç”¨ `ConcurrentDictionary` ä¿è¯çº¿ç¨‹å®‰å…¨
   - `AddOrUpdate` æ˜¯åŽŸå­æ“ä½œ
3. **应用重启**:
   - è½®è¯¢ä½ç½®é‡ç½®ä¸º 0
   - å¯æŽ¥å—的权衡(简化实现)
## å‘后兼容性
### æ—§é…ç½®æ ¼å¼
```json
{
  "TargetAddresses": {
    "GW": "10081",
    "CW": "10080"
  }
}
```
### æ–°é…ç½®æ ¼å¼
```json
{
  "TargetAddresses": {
    "GW": ["10081"],
    "CW": ["10080"]
  }
}
```
**兼容性处理:**
- JSON é…ç½®ç»‘定会自动处理两种格式
- å•个字符串会被解析为单元素数组
- çŽ°æœ‰é…ç½®æ— éœ€ä¿®æ”¹å³å¯å·¥ä½œ
## æ€§èƒ½å½±å“
1. **内存开销**:每个巷道前缀一个整数计数器(约 50 å­—节)
2. **CPU å¼€é”€**:模运算和字典查找,O(1) å¤æ‚度
3. **线程安全**:无锁设计,`ConcurrentDictionary` ä½¿ç”¨ä¼˜åŒ–的同步机制
## æµ‹è¯•计划
### å•元测试
1. `RoundRobinService.GetNextAddress` è½®è¯¢é€»è¾‘
2. å•个地址直接返回
3. å¤šä¸ªåœ°å€è½®è¯¢è¿”回
4. å¹¶å‘调用测试
### é›†æˆæµ‹è¯•
1. ä¿®æ”¹ appsettings.json ä¸ºå¤šå‡ºå£é…ç½®
2. åˆ›å»ºå¤šä¸ªå‡ºåº“任务
3. éªŒè¯ç›®æ ‡åœ°å€è½®è¯¢åˆ†é…
4. éªŒè¯æ—¥å¿—记录
### æ‰‹åŠ¨æµ‹è¯•
```sql
-- å‡†å¤‡æµ‹è¯•数据
UPDATE Dt_StockInfo
SET OutboundDate = DATEADD(MINUTE, -5, GETDATE())
WHERE PalletCode IN ('TEST001', 'TEST002', 'TEST003')
  AND StockStatus = 1;
```
启动应用,观察日志:
```
info: åˆ›å»ºä»»åŠ¡ TEST001,目标地址: 10081
info: åˆ›å»ºä»»åŠ¡ TEST002,目标地址: 10082
info: åˆ›å»ºä»»åŠ¡ TEST003,目标地址: 10083
```
## éƒ¨ç½²æ³¨æ„äº‹é¡¹
1. **配置更新**:
   - å°† `TargetAddresses` çš„值从字符串改为数组格式
   - ç¡®ä¿æ¯ä¸ªæ•°ç»„至少有一个元素
2. **依赖注入**:
   - ç¡®ä¿ `RoundRobinService` å·²æ³¨å†Œä¸ºå•例
   - `TaskService` æž„造函数需要注入该服务
3. **回滚方案**:
   - ä¿ç•™ `appsettings.json` å¤‡ä»½
   - å¦‚遇问题可恢复为单出口配置
## æœªæ¥æ”¹è¿›
1. **权重轮询**:支持为每个地址配置权重
2. **健康检查**:排除不可用的出库口
3. **持久化**:将轮询位置保存到 Redis/数据库
4. **监控统计**:记录每个地址的任务分配数量
## å®žæ–½æ¸…单
- [ ] ä¿®æ”¹ `AutoOutboundTaskOptions.TargetAddresses` ç±»åž‹
- [ ] åˆ›å»º `RoundRobinService` ç±»
- [ ] ä¿®æ”¹ `TaskService.DetermineTargetAddress` æ–¹æ³•
- [ ] åœ¨ `Program.cs` æ³¨å†Œ `RoundRobinService`
- [ ] æ›´æ–° `appsettings.json` é…ç½®ç¤ºä¾‹
- [ ] ç¼–译验证
- [ ] æ‰‹åŠ¨æµ‹è¯•
- [ ] æ›´æ–°è®¾è®¡æ–‡æ¡£