331 lines
12 KiB
Markdown
331 lines
12 KiB
Markdown
# 指定用户中奖功能设计文档
|
||
|
||
## 一、需求概述
|
||
|
||
允许后台为每个盒子配置"指定用户中奖"规则:
|
||
- 某个奖品只能由指定用户抽中
|
||
- 不修改奖品本身的概率,指定用户仍需按概率抽奖
|
||
- 指定用户可多次抽中指定奖品
|
||
- 非指定用户无法抽中该奖品(但在有限赏中可能抽走库存)
|
||
|
||
## 二、数据库设计
|
||
|
||
### 新增表: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. **运营提示**:后台配置时应提示"有限赏的指定奖品可能被其他用户抽走"
|