This commit is contained in:
zpc 2026-01-31 02:52:42 +08:00
parent f2ee678a2f
commit cd5e451f5a
3 changed files with 581 additions and 0 deletions

View File

@ -0,0 +1,354 @@
# Design Document: 指定用户中奖功能
## Overview
本设计文档描述了"指定用户中奖"功能的技术实现方案。该功能允许后台管理员为盒子中的特定奖品指定中奖用户,使得该奖品只能被指定用户抽中(无限赏)或优先被指定用户抽中(有限赏带兜底)。
核心设计原则:
- 不修改奖品本身的概率配置
- 有限赏保证"每抽必中"机制(兜底)
- 无限赏严格保护指定奖品
- 最小化对现有抽奖流程的侵入
## Architecture
```mermaid
graph TB
subgraph "后台管理层"
AdminAPI[后台管理 API]
DesignatedPrizeService[DesignatedPrizeService]
end
subgraph "抽奖核心层"
LotteryEngine[LotteryEngine]
InventoryManager[InventoryManager]
PrizePoolFilter[奖品池过滤器]
end
subgraph "数据层"
DbContext[HoneyBoxDbContext]
DesignatedPrizeEntity[GoodsDesignatedPrize]
GoodsItemEntity[GoodsItem]
end
AdminAPI --> DesignatedPrizeService
DesignatedPrizeService --> DbContext
LotteryEngine --> PrizePoolFilter
LotteryEngine --> InventoryManager
PrizePoolFilter --> DbContext
DbContext --> DesignatedPrizeEntity
DbContext --> GoodsItemEntity
```
## Components and Interfaces
### 1. 数据实体GoodsDesignatedPrize
```csharp
namespace HoneyBox.Model.Entities;
/// <summary>
/// 指定中奖配置实体
/// </summary>
public class GoodsDesignatedPrize
{
public int Id { get; set; }
public int GoodsId { get; set; }
public int GoodsItemId { get; set; }
public int UserId { get; set; }
public bool IsActive { get; set; } = true;
public string? Remark { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
// 导航属性
public virtual Good? Goods { get; set; }
public virtual GoodsItem? GoodsItem { get; set; }
public virtual User? User { get; set; }
}
```
### 2. 抽奖引擎接口扩展ILotteryEngine
```csharp
public interface ILotteryEngine
{
// 现有方法保持不变
Task<LotteryDrawResult> DrawAsync(LotteryDrawRequest request);
Task<LotteryDrawResult> DrawInfiniteAsync(LotteryDrawRequest request);
// 内部使用,不暴露新接口
}
```
### 3. 指定中奖服务接口IDesignatedPrizeService
```csharp
namespace HoneyBox.Admin.Business.Services.Interfaces;
public interface IDesignatedPrizeService
{
Task<List<DesignatedPrizeDto>> GetByGoodsIdAsync(int goodsId);
Task<DesignatedPrizeDto> CreateAsync(CreateDesignatedPrizeRequest request);
Task<DesignatedPrizeDto> UpdateAsync(int id, UpdateDesignatedPrizeRequest request);
Task DeleteAsync(int id);
}
```
### 4. 后台管理 API 控制器
```csharp
[ApiController]
[Route("api/admin/goods/{goodsId}/designated-prizes")]
public class DesignatedPrizeController : BusinessControllerBase
{
[HttpGet]
public Task<ApiResponse<List<DesignatedPrizeDto>>> GetList(int goodsId);
[HttpPost]
public Task<ApiResponse<DesignatedPrizeDto>> Create(int goodsId, CreateDesignatedPrizeRequest request);
[HttpPut("{id}")]
public Task<ApiResponse<DesignatedPrizeDto>> Update(int goodsId, int id, UpdateDesignatedPrizeRequest request);
[HttpDelete("{id}")]
public Task<ApiResponse> Delete(int goodsId, int id);
}
```
## Data Models
### 数据库表设计
```sql
CREATE TABLE goods_designated_prizes (
id INT PRIMARY KEY IDENTITY(1,1),
goods_id INT NOT NULL,
goods_item_id INT NOT NULL,
user_id INT NOT NULL,
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)
);
```
### DTO 模型
```csharp
// 查询响应
public class DesignatedPrizeDto
{
public int Id { get; set; }
public int GoodsId { get; set; }
public int GoodsItemId { get; set; }
public string? GoodsItemTitle { get; set; }
public string? GoodsItemImgUrl { get; set; }
public int UserId { get; set; }
public string? UserNickname { get; set; }
public string? UserPhone { get; set; }
public bool IsActive { get; set; }
public string? Remark { get; set; }
public DateTime CreatedAt { get; set; }
}
// 创建请求
public class CreateDesignatedPrizeRequest
{
public int GoodsItemId { get; set; }
public int UserId { get; set; }
public string? Remark { get; set; }
}
// 更新请求
public class UpdateDesignatedPrizeRequest
{
public int? UserId { get; set; }
public bool? IsActive { get; set; }
public string? Remark { get; set; }
}
```
### 抽奖引擎内部数据结构
```csharp
// 指定中奖配置缓存结构
// Key: GoodsItemId, Value: UserId
private Dictionary<int, int> _designatedPrizes;
```
## Correctness Properties
*A property is a characteristic or behavior that should hold true across all valid executions of a system-essentially, a formal statement about what the system should do. Properties serve as the bridge between human-readable specifications and machine-verifiable correctness guarantees.*
### Property 1: Unique Constraint Enforcement
*For any* goods and goods_item combination, attempting to create a second designated prize configuration SHALL result in an error, ensuring only one user can be designated per prize.
**Validates: Requirements 1.2, 4.5**
### Property 2: Active Configuration Filtering
*For any* query to get designated prize configurations, the result SHALL only contain configurations where is_active is true, regardless of how many inactive configurations exist.
**Validates: Requirements 1.3**
### Property 3: Designated User Prize Pool Inclusion
*For any* designated user drawing from either finite or infinite lottery, their prize pool SHALL include all their designated prizes (where they are the designated user and is_active is true).
**Validates: Requirements 2.3, 3.4**
### Property 4: Non-Designated User Prize Pool Exclusion (Finite Lottery)
*For any* non-designated user drawing from a finite lottery with normal prizes available, their prize pool SHALL NOT contain any designated prizes.
**Validates: Requirements 2.4**
### Property 5: Non-Designated User Prize Pool Exclusion (Infinite Lottery - Strict)
*For any* non-designated user drawing from an infinite lottery, their prize pool SHALL NEVER contain designated prizes, even when no normal prizes exist.
**Validates: Requirements 3.3, 3.5**
### Property 6: Finite Lottery Fallback Mechanism
*For any* finite lottery where only designated prizes remain (normal prize pool is empty), a non-designated user SHALL be able to draw from all remaining prizes (fallback mechanism).
**Validates: Requirements 2.5**
### Property 7: Probability Calculation Invariance
*For any* prize pool (filtered or unfiltered), the probability calculation method SHALL remain unchanged - probabilities are calculated based on stock (finite) or real_pro (infinite) of the filtered pool, and the sum of probabilities SHALL equal 100%.
**Validates: Requirements 5.1, 5.3**
### Property 8: Prize Data Immutability
*For any* designated prize configuration operation (create, update, delete), the underlying prize's stock, real_pro, and other probability-related fields SHALL remain unchanged.
**Validates: Requirements 5.2**
### Property 9: Admin CRUD Validation
*For any* admin create operation, the system SHALL validate that goods_id, goods_item_id, and user_id all reference existing records; invalid references SHALL result in an error.
**Validates: Requirements 4.1**
## Error Handling
### 抽奖引擎错误处理
| 场景 | 处理方式 | 日志级别 |
|------|----------|----------|
| 指定中奖配置查询失败 | 降级为无配置模式,继续抽奖 | Warning |
| 有限赏兜底机制触发 | 记录日志,允许抽奖继续 | Warning |
| 无限赏奖品池为空(全是指定奖品) | 返回"暂无可抽奖品"错误 | Warning |
| 数据库连接异常 | 返回"系统繁忙"错误 | Error |
### 后台管理 API 错误处理
| 场景 | HTTP 状态码 | 错误消息 |
|------|-------------|----------|
| 盒子不存在 | 404 | "盒子不存在" |
| 奖品不存在 | 404 | "奖品不存在" |
| 用户不存在 | 404 | "用户不存在" |
| 重复配置 | 400 | "该奖品已配置指定用户" |
| 配置不存在 | 404 | "配置不存在" |
| 权限不足 | 403 | "无权限操作" |
### 错误处理代码示例
```csharp
// 抽奖引擎中的降级处理
private async Task<Dictionary<int, int>> GetDesignatedPrizesAsync(int goodsId)
{
try
{
return await _dbContext.GoodsDesignatedPrizes
.Where(dp => dp.GoodsId == goodsId && dp.IsActive)
.ToDictionaryAsync(dp => dp.GoodsItemId, dp => dp.UserId);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to query designated prizes for goods {GoodsId}, falling back to no configuration", goodsId);
return new Dictionary<int, int>();
}
}
```
## Testing Strategy
### 测试框架选择
- **单元测试框架**: xUnit
- **属性测试框架**: FsCheck (C# 属性测试库)
- **Mock 框架**: Moq
- **集成测试**: Microsoft.AspNetCore.Mvc.Testing
### 单元测试覆盖
1. **GoodsDesignatedPrize 实体测试**
- 字段默认值验证
- 导航属性关联
2. **DesignatedPrizeService 测试**
- CRUD 操作正确性
- 验证逻辑(存在性检查)
- 唯一约束处理
3. **LotteryEngine 奖品池过滤测试**
- 有限赏过滤逻辑(带兜底)
- 无限赏过滤逻辑(严格)
- 边界情况(空池、全指定等)
### 属性测试配置
每个属性测试运行 **100 次迭代**,使用 FsCheck 生成随机测试数据。
```csharp
// 属性测试示例配置
[Property(MaxTest = 100)]
public Property DesignatedUserSeesTheirPrizes()
{
// Feature: designated-prize-winner, Property 3: Designated User Prize Pool Inclusion
return Prop.ForAll(
Arb.From<int>(), // userId
Arb.From<List<GoodsItem>>(), // prizePool
Arb.From<Dictionary<int, int>>(), // designatedPrizes
(userId, prizePool, designatedPrizes) =>
{
// Test implementation
});
}
```
### 测试数据生成策略
```csharp
// 奖品池生成器
public static Arbitrary<List<GoodsItem>> GoodsItemListArbitrary()
{
return Gen.ListOf(Gen.Fresh(() => new GoodsItem
{
Id = Gen.Choose(1, 1000).Sample(0, 1).First(),
SurplusStock = Gen.Choose(0, 100).Sample(0, 1).First(),
RealPro = Gen.Choose(0, 100).Sample(0, 1).First()
})).ToArbitrary();
}
// 指定中奖配置生成器
public static Arbitrary<Dictionary<int, int>> DesignatedPrizesArbitrary()
{
return Gen.DictionaryOf(
Gen.Choose(1, 1000), // GoodsItemId
Gen.Choose(1, 10000) // UserId
).ToArbitrary();
}
```
### 集成测试场景
1. **有限赏完整流程测试**
- 指定用户抽中指定奖品
- 普通用户抽不到指定奖品
- 兜底机制触发场景
2. **无限赏完整流程测试**
- 指定用户抽中指定奖品
- 普通用户永远抽不到指定奖品
3. **后台管理 API 测试**
- 完整 CRUD 流程
- 并发创建重复配置
- 权限验证

View File

@ -0,0 +1,76 @@
# Requirements Document
## Introduction
指定用户中奖功能允许后台管理员为每个盒子配置"指定用户中奖"规则,使某个奖品只能由指定用户抽中。该功能不修改奖品本身的概率,指定用户仍需按概率抽奖,但非指定用户无法抽中该奖品。
## Glossary
- **Goods**: 盒子/商品,包含多个奖品的抽奖容器
- **GoodsItem**: 奖品,盒子中的单个可抽取物品
- **DesignatedPrize**: 指定中奖配置,将特定奖品指定给特定用户
- **LotteryEngine**: 抽奖引擎,负责执行抽奖逻辑的核心服务
- **FiniteLottery**: 有限赏抽奖,基于库存概率的抽奖模式(一番赏、福袋等),抽完即止
- **InfiniteLottery**: 无限赏抽奖,基于固定概率的抽奖模式(无限赏、领主赏等),无限池
- **PrizePool**: 奖品池,当前可抽取的奖品集合
- **FallbackMechanism**: 兜底机制,当普通奖品池为空时允许普通用户抽取指定奖品
## Requirements
### Requirement 1: 指定中奖配置数据管理
**User Story:** As a 后台管理员, I want to 为盒子中的奖品配置指定中奖用户, so that 特定奖品只能被指定用户抽中。
#### Acceptance Criteria
1. THE GoodsDesignatedPrize Entity SHALL store goods_id, goods_item_id, user_id, is_active, remark, created_at, and updated_at fields
2. WHEN a designated prize configuration is created, THE System SHALL enforce that one prize can only be designated to one user per goods (unique constraint on goods_id + goods_item_id)
3. WHEN a designated prize configuration is queried, THE System SHALL only return active configurations (is_active = true)
4. THE System SHALL support enabling and disabling designated prize configurations via the is_active field
### Requirement 2: 有限赏抽奖流程改造(带兜底机制)
**User Story:** As a 用户, I want to 在有限赏抽奖时遵循指定中奖规则, so that 指定奖品优先给指定用户,但保证每抽必中。
#### Acceptance Criteria
1. WHEN a user draws from a finite lottery, THE LotteryEngine SHALL query designated prize configurations for the goods
2. WHEN designated prize configurations exist, THE LotteryEngine SHALL separate the prize pool into normal prizes and designated prizes
3. WHEN the current user is a designated user for any prize, THE LotteryEngine SHALL include that user's designated prizes in their prize pool
4. WHEN the current user is not a designated user, THE LotteryEngine SHALL exclude designated prizes from their prize pool
5. IF the normal prize pool is empty (only designated prizes remain), THEN THE LotteryEngine SHALL allow the user to draw from all prizes (fallback mechanism)
6. WHEN the fallback mechanism is triggered, THE LotteryEngine SHALL log a warning for operational tracking
### Requirement 3: 无限赏抽奖流程改造(严格过滤)
**User Story:** As a 用户, I want to 在无限赏抽奖时遵循指定中奖规则, so that 指定奖品只能被指定用户抽中。
#### Acceptance Criteria
1. WHEN a user draws from an infinite lottery, THE LotteryEngine SHALL query designated prize configurations for the goods
2. WHEN designated prize configurations exist, THE LotteryEngine SHALL strictly filter the prize pool
3. WHEN a prize has a designated user configuration and the current user is not the designated user, THE LotteryEngine SHALL remove that prize from the pool
4. WHEN a prize has a designated user configuration and the current user is the designated user, THE LotteryEngine SHALL keep that prize in the pool
5. THE InfiniteLottery SHALL NOT have a fallback mechanism (designated prizes are strictly protected)
### Requirement 4: 后台管理 API
**User Story:** As a 后台管理员, I want to 通过 API 管理指定中奖配置, so that 我可以灵活配置和调整指定中奖规则。
#### Acceptance Criteria
1. WHEN an admin creates a designated prize configuration, THE System SHALL validate that the goods_id, goods_item_id, and user_id exist
2. WHEN an admin queries designated prize configurations for a goods, THE System SHALL return all configurations with user information
3. WHEN an admin updates a designated prize configuration, THE System SHALL update the is_active, user_id, or remark fields
4. WHEN an admin deletes a designated prize configuration, THE System SHALL remove the configuration from the database
5. IF a duplicate configuration is attempted (same goods_id and goods_item_id), THEN THE System SHALL return an error message
### Requirement 5: 概率保持不变
**User Story:** As a 产品经理, I want to 确保指定中奖不改变奖品概率, so that 抽奖公平性得到保证。
#### Acceptance Criteria
1. WHEN calculating prize probabilities, THE LotteryEngine SHALL use the original probability calculation method
2. THE DesignatedPrize configuration SHALL NOT modify the prize's stock, real_pro, or any probability-related fields
3. WHEN a designated user draws, THE LotteryEngine SHALL calculate probabilities based on the filtered prize pool

View File

@ -0,0 +1,151 @@
# Implementation Plan: 指定用户中奖功能
## Overview
本实现计划将"指定用户中奖"功能分解为可执行的编码任务。实现顺序为:数据层 → 抽奖核心层 → 后台管理层,确保每个步骤都能独立验证。
## Tasks
- [-] 1. 数据层实现
- [ ] 1.1 创建 GoodsDesignatedPrize 实体类
- 在 `HoneyBox.Model/Entities/` 目录创建 `GoodsDesignatedPrize.cs`
- 包含 Id, GoodsId, GoodsItemId, UserId, IsActive, Remark, CreatedAt, UpdatedAt 字段
- 添加导航属性 Goods, GoodsItem, User
- _Requirements: 1.1_
- [ ] 1.2 更新 HoneyBoxDbContext
- 添加 `DbSet<GoodsDesignatedPrize> GoodsDesignatedPrizes` 属性
- 在 `OnModelCreating` 中配置实体映射
- 配置唯一约束 (goods_id, goods_item_id)
- 配置索引 (goods_id, user_id, goods_item_id)
- _Requirements: 1.1, 1.2_
- [ ] 1.3 创建数据库迁移 SQL 脚本
- 在 `server/HoneyBox/scripts/` 目录创建 `create_goods_designated_prizes.sql`
- 包含建表语句、索引、唯一约束
- _Requirements: 1.1, 1.2_
- [ ] 2. 抽奖引擎核心改造
- [ ] 2.1 添加指定中奖配置查询方法
- 在 `LotteryEngine.cs` 中添加 `GetDesignatedPrizesAsync(int goodsId)` 私有方法
- 返回 `Dictionary<int, int>` (GoodsItemId → UserId)
- 只查询 is_active = true 的配置
- 添加异常降级处理(查询失败返回空字典)
- _Requirements: 2.1, 3.1, 1.3_
- [ ] 2.2 实现有限赏奖品池过滤方法(带兜底)
- 添加 `FilterPrizePoolWithFallback(prizePool, userId, designatedPrizes)` 私有方法
- 分离普通奖品和指定奖品
- 指定用户:返回普通奖品 + 自己的指定奖品
- 普通用户:优先返回普通奖品池
- 兜底:普通奖品池为空时返回全部奖品池
- 兜底触发时记录 Warning 日志
- _Requirements: 2.2, 2.3, 2.4, 2.5, 2.6_
- [ ] 2.3 编写有限赏过滤方法的属性测试
- **Property 3: Designated User Prize Pool Inclusion**
- **Property 4: Non-Designated User Prize Pool Exclusion (Finite)**
- **Property 6: Finite Lottery Fallback Mechanism**
- **Validates: Requirements 2.3, 2.4, 2.5**
- [ ] 2.4 实现无限赏奖品池过滤方法(严格)
- 添加 `FilterPrizePoolStrict(prizePool, userId, designatedPrizes)` 私有方法
- 移除所有非当前用户的指定奖品
- 保留当前用户的指定奖品
- 无兜底机制
- _Requirements: 3.2, 3.3, 3.4, 3.5_
- [ ] 2.5 编写无限赏过滤方法的属性测试
- **Property 5: Non-Designated User Prize Pool Exclusion (Infinite - Strict)**
- **Validates: Requirements 3.3, 3.5**
- [ ] 2.6 改造 DrawAsync 方法(有限赏)
- 在获取奖品池后调用 `GetDesignatedPrizesAsync`
- 如果有配置,调用 `FilterPrizePoolWithFallback` 过滤奖品池
- 保持后续概率计算和抽奖逻辑不变
- _Requirements: 2.1, 2.2, 5.1, 5.3_
- [ ] 2.7 改造 DrawInfiniteAsync 方法(无限赏)
- 在获取奖品池后调用 `GetDesignatedPrizesAsync`
- 如果有配置,调用 `FilterPrizePoolStrict` 过滤奖品池
- 保持后续概率计算和抽奖逻辑不变
- _Requirements: 3.1, 3.2, 5.1, 5.3_
- [ ] 2.8 编写概率计算不变性属性测试
- **Property 7: Probability Calculation Invariance**
- **Validates: Requirements 5.1, 5.3**
- [ ] 3. Checkpoint - 核心功能验证
- 确保所有测试通过,如有问题请询问用户
- [ ] 4. 后台管理服务层实现
- [ ] 4.1 创建 DTO 模型
- 在 `HoneyBox.Admin.Business/Models/DesignatedPrize/` 目录创建:
- `DesignatedPrizeDto.cs` - 查询响应
- `CreateDesignatedPrizeRequest.cs` - 创建请求
- `UpdateDesignatedPrizeRequest.cs` - 更新请求
- _Requirements: 4.2_
- [ ] 4.2 创建 IDesignatedPrizeService 接口
- 在 `HoneyBox.Admin.Business/Services/Interfaces/` 目录创建接口
- 定义 GetByGoodsIdAsync, CreateAsync, UpdateAsync, DeleteAsync 方法
- _Requirements: 4.1, 4.2, 4.3, 4.4_
- [ ] 4.3 实现 DesignatedPrizeService 服务
- 在 `HoneyBox.Admin.Business/Services/` 目录创建实现类
- 实现 GetByGoodsIdAsync查询配置列表关联用户和奖品信息
- 实现 CreateAsync验证存在性检查唯一约束创建配置
- 实现 UpdateAsync更新 is_active, user_id, remark 字段
- 实现 DeleteAsync删除配置
- _Requirements: 4.1, 4.2, 4.3, 4.4, 4.5_
- [ ] 4.4 编写服务层单元测试
- 测试 CRUD 操作正确性
- 测试验证逻辑(存在性检查)
- 测试唯一约束处理
- _Requirements: 4.1, 4.5_
- [ ] 4.5 编写唯一约束属性测试
- **Property 1: Unique Constraint Enforcement**
- **Validates: Requirements 1.2, 4.5**
- [ ] 5. 后台管理 API 层实现
- [ ] 5.1 创建 DesignatedPrizeController 控制器
- 在 `HoneyBox.Admin.Business/Controllers/` 目录创建控制器
- 路由:`api/admin/goods/{goodsId}/designated-prizes`
- 实现 GET列表、POST创建、PUT更新、DELETE删除端点
- 添加权限验证
- _Requirements: 4.1, 4.2, 4.3, 4.4_
- [ ] 5.2 注册服务到依赖注入容器
- 在 `ServiceCollectionExtensions.cs` 中注册 IDesignatedPrizeService
- _Requirements: 4.1_
- [ ] 5.3 编写 API 集成测试
- 测试完整 CRUD 流程
- 测试错误响应404, 400
- _Requirements: 4.1, 4.2, 4.3, 4.4, 4.5_
- [ ] 6. Checkpoint - 后台管理功能验证
- 确保所有测试通过,如有问题请询问用户
- [ ] 7. 集成测试和文档
- [ ] 7.1 编写端到端集成测试
- 有限赏完整流程:配置指定中奖 → 指定用户抽奖 → 验证结果
- 无限赏完整流程:配置指定中奖 → 普通用户抽奖 → 验证无法抽中
- 兜底机制测试:只剩指定奖品时普通用户抽奖
- _Requirements: 2.3, 2.4, 2.5, 3.3, 3.4, 3.5_
- [ ] 7.2 编写奖品数据不可变性属性测试
- **Property 8: Prize Data Immutability**
- **Validates: Requirements 5.2**
- [ ] 8. Final Checkpoint - 完整功能验证
- 确保所有测试通过,如有问题请询问用户
## Notes
- 每个任务都引用了具体的需求条款以确保可追溯性
- 检查点用于增量验证,确保每个阶段的正确性
- 属性测试验证通用正确性属性,单元测试验证具体示例和边界情况
- 所有测试任务均为必需,确保全面的测试覆盖