# 指定用户中奖功能设计文档

## 一、需求概述

允许后台为每个盒子配置"指定用户中奖"规则：
- 某个奖品只能由指定用户抽中
- 不修改奖品本身的概率，指定用户仍需按概率抽奖
- 指定用户可多次抽中指定奖品
- 非指定用户无法抽中该奖品（但在有限赏中可能抽走库存）

## 二、数据库设计

### 新增表：goods_designated_prizes（指定中奖配置表）

```sql
CREATE TABLE goods_designated_prizes (
    id INT PRIMARY KEY IDENTITY(1,1),
    goods_id INT NOT NULL,                    -- 盒子ID
    goods_item_id INT NOT NULL,               -- 奖品ID
    user_id BIGINT NOT NULL,                  -- 指定用户ID
    is_active BIT NOT NULL DEFAULT 1,         -- 是否启用
    remark NVARCHAR(200),                     -- 备注说明
    created_at DATETIME2 NOT NULL DEFAULT GETDATE(),
    updated_at DATETIME2,
    
    -- 索引
    INDEX IX_designated_goods_id (goods_id),
    INDEX IX_designated_user_id (user_id),
    INDEX IX_designated_goods_item (goods_item_id),
    
    -- 唯一约束：同一盒子的同一奖品只能指定给一个用户
    CONSTRAINT UQ_goods_item_user UNIQUE (goods_id, goods_item_id)
);
```

### 字段说明

| 字段 | 类型 | 说明 |
|------|------|------|
| goods_id | INT | 盒子ID，关联 goods 表 |
| goods_item_id | INT | 奖品ID，关联 goods_items 表 |
| user_id | BIGINT | 指定用户ID，关联 users 表 |
| is_active | BIT | 是否启用，0=禁用 1=启用 |
| remark | NVARCHAR(200) | 备注，如"VIP用户专属" |

## 三、抽奖逻辑改造

### 3.1 盒子类型分类

根据代码分析，系统有两类抽奖模式：

| 类型 | 抽奖方法 | 特点 | 包含的赏品类型 |
|------|----------|------|----------------|
| 有限赏 | `DrawAsync` | 基于库存概率，抽完即止 | 一番赏(1)、福袋(5)、幸运赏(6)、盲盒(10)、幸运赏新(11) |
| 无限赏 | `DrawInfiniteAsync` | 基于固定概率(real_pro)，无限池 | 无限赏(2)、领主赏(8)、连击赏(9)、翻倍赏(16)、福利屋(15) |

### 3.2 有限赏抽奖流程改造（一番赏等）

**特殊性**：一番赏是"每抽必中，抽完即止"的机制，不能因为指定中奖配置导致用户抽不到东西。

```
原流程：
1. 获取可用奖品池（库存>0）
2. 按库存计算概率
3. 权重随机选择
4. 扣减库存
5. 创建记录

新流程：
1. 获取可用奖品池（库存>0）
2. 查询指定中奖配置
3. 【新增】分离奖品池：
   - 普通奖品池：没有指定用户配置的奖品
   - 指定奖品池：有指定用户配置的奖品
4. 【新增】选择抽奖池：
   - 如果当前用户是某个指定奖品的指定用户 → 使用【全部奖品池】
   - 如果当前用户是普通用户：
     - 普通奖品池不为空 → 使用【普通奖品池】
     - 普通奖品池为空（只剩指定奖品）→ 使用【全部奖品池】（兜底保证必中）
5. 按库存计算概率
6. 权重随机选择
7. 扣减库存
8. 创建记录
```

**兜底机制说明**：
- 正常情况：普通用户从普通奖品池抽，抽不到指定奖品
- 极端情况：当盒子只剩下指定奖品时，普通用户也能抽走，保证一番赏"必中"机制
- 这意味着：指定中奖只是"优先权"，不是"绝对保证"

### 3.3 无限赏抽奖流程改造

```
原流程：
1. 获取奖品池（real_pro>0）
2. 按 real_pro 计算概率
3. 权重随机选择
4. 创建记录（不扣库存）

新流程：
1. 获取奖品池（real_pro>0）
2. 查询当前用户的指定中奖配置
3. 【新增】过滤奖品池：
   - 如果奖品有指定用户配置 且 当前用户不是指定用户 → 从池中移除
   - 如果奖品有指定用户配置 且 当前用户是指定用户 → 保留在池中
4. 按 real_pro 计算概率
5. 权重随机选择
6. 创建记录
```

### 3.4 核心代码改造点

#### LotteryEngine.cs

```csharp
// 新增方法：获取指定中奖配置
private async Task<Dictionary<int, long>> GetDesignatedPrizesAsync(int goodsId)
{
    return await _dbContext.GoodsDesignatedPrizes
        .Where(dp => dp.GoodsId == goodsId && dp.IsActive)
        .ToDictionaryAsync(dp => dp.GoodsItemId, dp => dp.UserId);
}

// 新增方法：过滤奖品池（无限赏用，严格过滤）
private List<GoodsItem> FilterPrizePoolStrict(
    List<GoodsItem> prizePool, 
    int userId, 
    Dictionary<int, long> designatedPrizes)
{
    return prizePool.Where(prize =>
    {
        // 如果该奖品没有指定用户配置，所有人都可以抽
        if (!designatedPrizes.TryGetValue(prize.Id, out var designatedUserId))
            return true;
        
        // 如果有指定用户配置，只有指定用户可以抽
        return designatedUserId == userId;
    }).ToList();
}

// 新增方法：过滤奖品池（有限赏用，带兜底机制）
private List<GoodsItem> FilterPrizePoolWithFallback(
    List<GoodsItem> prizePool, 
    int userId, 
    Dictionary<int, long> designatedPrizes)
{
    // 分离普通奖品和指定奖品
    var normalPrizes = prizePool.Where(prize => 
        !designatedPrizes.ContainsKey(prize.Id)).ToList();
    
    var userDesignatedPrizes = prizePool.Where(prize =>
        designatedPrizes.TryGetValue(prize.Id, out var uid) && uid == userId).ToList();
    
    // 如果当前用户有指定奖品，返回全部奖品池（包含其指定奖品）
    if (userDesignatedPrizes.Any())
    {
        return prizePool.Where(prize =>
        {
            if (!designatedPrizes.TryGetValue(prize.Id, out var designatedUserId))
                return true; // 普通奖品
            return designatedUserId == userId; // 只包含自己的指定奖品
        }).ToList();
    }
    
    // 普通用户：优先从普通奖品池抽
    if (normalPrizes.Any())
    {
        return normalPrizes;
    }
    
    // 兜底：普通奖品池为空，只剩指定奖品，允许抽走（保证一番赏必中）
    _logger.LogWarning("Fallback: Only designated prizes left, allowing normal user to draw. GoodsId={GoodsId}, UserId={UserId}", 
        prizePool.FirstOrDefault()?.GoodsId, userId);
    return prizePool;
}
```

#### DrawAsync 改造（有限赏）

```csharp
public async Task<LotteryDrawResult> DrawAsync(LotteryDrawRequest request)
{
    // 1. 获取可用奖品池
    var prizePool = await _inventoryManager.GetAvailablePrizePoolAsync(request.GoodsId, request.Num);
    
    // 2. 【新增】获取指定中奖配置
    var designatedPrizes = await GetDesignatedPrizesAsync(request.GoodsId);
    
    // 3. 【新增】过滤奖品池（带兜底机制）
    if (designatedPrizes.Any())
    {
        prizePool = FilterPrizePoolWithFallback(prizePool, request.UserId, designatedPrizes);
    }
    
    if (!prizePool.Any())
    {
        result.ErrorMessage = "暂无可抽奖品";
        return result;
    }
    
    // 后续流程不变...
}
```

#### DrawInfiniteAsync 改造（无限赏）

```csharp
public async Task<LotteryDrawResult> DrawInfiniteAsync(LotteryDrawRequest request)
{
    // 1. 获取奖品池
    var prizePool = await GetInfinitePrizePoolAsync(request.GoodsId, request.Num);
    
    // 2. 【新增】获取指定中奖配置
    var designatedPrizes = await GetDesignatedPrizesAsync(request.GoodsId);
    
    // 3. 【新增】严格过滤奖品池（无限赏不需要兜底）
    if (designatedPrizes.Any())
    {
        prizePool = FilterPrizePoolStrict(prizePool, request.UserId, designatedPrizes);
    }
    
    if (!prizePool.Any())
    {
        result.ErrorMessage = "暂无可抽奖品";
        return result;
    }
    
    // 后续流程不变...
}
```

## 四、业务场景分析

### 4.1 无限赏场景

| 场景 | 处理方式 |
|------|----------|
| 指定用户抽奖 | 奖品池包含指定奖品，按概率正常抽 |
| 指定用户抽中指定奖品 | 正常中奖，可多次中奖 |
| 指定用户没抽中 | 抽到其他奖品 |
| 非指定用户抽奖 | 奖品池不包含指定奖品，永远抽不到 |
| 指定奖品一直存在 | ✅ 无限赏不扣库存，奖品永远在 |

### 4.2 有限赏场景（一番赏等）

| 场景 | 处理方式 |
|------|----------|
| 指定用户抽奖 | 奖品池包含指定奖品，按概率正常抽 |
| 指定用户抽中指定奖品 | 正常中奖，扣减库存 |
| 普通用户抽奖（有普通奖品） | 只从普通奖品池抽，抽不到指定奖品 |
| 普通用户抽奖（只剩指定奖品） | 兜底机制生效，可以抽走指定奖品 |
| 指定用户一直不抽 | ⚠️ 当只剩指定奖品时，会被其他用户抽走 |

**重要说明**：
- 指定中奖是"优先权"而非"绝对保证"
- 如果指定用户迟迟不来抽，当盒子只剩指定奖品时，其他用户会抽走
- 这是为了保证一番赏"每抽必中"的核心机制

### 4.3 边界情况

| 情况 | 处理 |
|------|------|
| 指定用户不存在 | 配置生效，但该用户永远不会来抽 |
| 指定奖品不存在 | 配置无效，不影响抽奖 |
| 同一奖品指定多个用户 | 数据库唯一约束阻止，一个奖品只能指定一个用户 |
| 同一用户被指定多个奖品 | 允许，用户可以抽中多个指定奖品 |
| 禁用配置 | is_active=0，等同于没有配置 |

## 五、后台管理功能

### 5.1 功能入口

在盒子管理 → 奖品列表页面，每个奖品行增加"指定中奖"操作按钮。

### 5.2 配置弹窗

```
┌─────────────────────────────────────┐
│  指定中奖配置                        │
├─────────────────────────────────────┤
│  奖品：【SP赏】限定手办               │
│                                     │
│  指定用户：[用户搜索框]              │
│            用户ID: 10001            │
│            昵称: 张三                │
│                                     │
│  备注：[VIP专属奖品]                 │
│                                     │
│  状态：○ 启用  ○ 禁用               │
│                                     │
│        [取消]  [保存]               │
└─────────────────────────────────────┘
```

### 5.3 API 接口

```
POST   /api/admin/goods/{goodsId}/designated-prizes     # 添加/更新配置
GET    /api/admin/goods/{goodsId}/designated-prizes     # 获取配置列表
DELETE /api/admin/goods/{goodsId}/designated-prizes/{id} # 删除配置
```

## 六、实施步骤

### 第一阶段：数据库

1. 创建 `goods_designated_prizes` 表
2. 创建 `GoodsDesignatedPrize` 实体类
3. 更新 `HoneyBoxDbContext`

### 第二阶段：抽奖逻辑

1. 在 `LotteryEngine` 中添加指定中奖过滤逻辑
2. 改造 `DrawAsync` 方法（有限赏）
3. 改造 `DrawInfiniteAsync` 方法（无限赏）
4. 添加单元测试

### 第三阶段：后台管理

1. 添加后台 API 接口
2. 前端页面开发（如需要）

## 七、注意事项

1. **性能考虑**：指定中奖配置查询会增加一次数据库访问，建议加缓存
2. **概率不变**：指定用户的中奖概率不变，只是普通用户的奖品池被缩小
3. **有限赏兜底**：当只剩指定奖品时，普通用户也能抽走，保证必中机制
4. **无限赏严格**：无限赏没有兜底，指定奖品只有指定用户能抽到
5. **日志记录**：建议记录指定中奖的命中日志和兜底触发日志，便于运营追踪
6. **运营提示**：后台配置时应提示"有限赏的指定奖品可能被其他用户抽走"
