HaniBlindBox/docs/1.1.0/指定用户中奖功能设计文档.md
2026-01-30 16:59:53 +08:00

331 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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