17 KiB
Design Document: 抽奖系统迁移
Overview
本设计文档描述了将PHP抽奖系统迁移到.NET 8的技术方案。抽奖系统是盲盒平台的核心功能,需要确保算法准确性、数据一致性和API兼容性。
迁移范围包括:
- 抽奖结果查询接口(一番赏、无限赏)
- 中奖记录查询接口
- 道具卡抽奖功能
- 抽奖算法引擎
- 库存管理机制
Architecture
┌─────────────────────────────────────────────────────────────┐
│ API Layer │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │
│ │LotteryCtrl │ │InfiniteCtrl │ │ GoodsController │ │
│ └──────┬──────┘ └──────┬──────┘ └──────────┬──────────┘ │
└─────────┼────────────────┼────────────────────┼─────────────┘
│ │ │
┌─────────┼────────────────┼────────────────────┼─────────────┐
│ ▼ ▼ ▼ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Service Layer │ │
│ │ ┌──────────────┐ ┌──────────────┐ ┌───────────┐ │ │
│ │ │LotteryService│ │ PrizeService │ │OrderService│ │ │
│ │ └──────┬───────┘ └──────┬───────┘ └─────┬─────┘ │ │
│ │ │ │ │ │ │
│ │ ┌──────▼─────────────────▼────────────────▼──────┐ │ │
│ │ │ LotteryEngine │ │ │
│ │ │ ┌────────────────┐ ┌─────────────────────┐ │ │ │
│ │ │ │WeightedRandom │ │InventoryManager │ │ │ │
│ │ │ └────────────────┘ └─────────────────────┘ │ │ │
│ │ └────────────────────────────────────────────────┘ │ │
│ └─────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
│
┌─────────▼───────────────────────────────────────────────────┐
│ Data Layer │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │
│ │ OrderItem │ │ GoodsItem │ │ ItemCard │ │
│ └─────────────┘ └─────────────┘ └─────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
Components and Interfaces
1. ILotteryService 接口
/// <summary>
/// 抽奖服务接口
/// </summary>
public interface ILotteryService
{
/// <summary>
/// 获取一番赏抽奖结果
/// </summary>
Task<PrizeOrderLogResponseDto> GetPrizeOrderLogAsync(int userId, string orderNum);
/// <summary>
/// 获取无限赏抽奖结果
/// </summary>
Task<PrizeOrderLogResponseDto> GetInfinitePrizeOrderLogAsync(int userId, string orderNum);
/// <summary>
/// 获取无限赏中奖记录
/// </summary>
Task<InfiniteShangLogResponseDto> GetInfiniteShangLogAsync(int goodsId, int page, int pageSize);
/// <summary>
/// 获取每日抽奖记录
/// </summary>
Task<DailyPrizeRecordsResponseDto> GetDailyPrizeRecordsAsync(int goodsId);
/// <summary>
/// 道具卡抽奖
/// </summary>
Task<ItemCardDrawResponseDto> DrawWithItemCardAsync(int userId, int goodsId, int itemCardId);
}
2. ILotteryEngine 接口
/// <summary>
/// 抽奖引擎接口
/// </summary>
public interface ILotteryEngine
{
/// <summary>
/// 执行单次抽奖
/// </summary>
Task<LotteryDrawResult> DrawAsync(LotteryDrawRequest request);
/// <summary>
/// 执行多次抽奖
/// </summary>
Task<List<LotteryDrawResult>> DrawMultipleAsync(LotteryDrawRequest request, int count);
/// <summary>
/// 计算奖品概率
/// </summary>
Task<List<PrizeProbability>> CalculateProbabilitiesAsync(int goodsId, int num);
/// <summary>
/// 验证抽奖结果
/// </summary>
Task<bool> ValidateDrawResultAsync(LotteryDrawResult result);
}
3. IInventoryManager 接口
/// <summary>
/// 库存管理接口
/// </summary>
public interface IInventoryManager
{
/// <summary>
/// 原子扣减库存
/// </summary>
Task<bool> DeductStockAsync(int goodsItemId, int quantity = 1);
/// <summary>
/// 检查库存可用性
/// </summary>
Task<bool> CheckStockAvailabilityAsync(int goodsItemId);
/// <summary>
/// 获取可抽奖品池
/// </summary>
Task<List<GoodsItem>> GetAvailablePrizePoolAsync(int goodsId, int num);
}
Data Models
Request/Response DTOs
// 抽奖结果响应
public class PrizeOrderLogResponseDto
{
[JsonPropertyName("status")]
public int Status { get; set; }
[JsonPropertyName("msg")]
public string Msg { get; set; } = string.Empty;
[JsonPropertyName("data")]
public List<PrizeOrderLogItemDto> Data { get; set; } = new();
}
public class PrizeOrderLogItemDto
{
[JsonPropertyName("id")]
public int Id { get; set; }
[JsonPropertyName("order_id")]
public int OrderId { get; set; }
[JsonPropertyName("goodslist_title")]
public string GoodslistTitle { get; set; } = string.Empty;
[JsonPropertyName("goodslist_imgurl")]
public string GoodslistImgurl { get; set; } = string.Empty;
[JsonPropertyName("goodslist_price")]
public string GoodslistPrice { get; set; } = "0.00";
[JsonPropertyName("goodslist_money")]
public string GoodslistMoney { get; set; } = "0.00";
[JsonPropertyName("goodslist_type")]
public int GoodslistType { get; set; }
[JsonPropertyName("status")]
public int Status { get; set; }
[JsonPropertyName("addtime")]
public int Addtime { get; set; }
[JsonPropertyName("prize_code")]
public string PrizeCode { get; set; } = string.Empty;
[JsonPropertyName("luck_no")]
public int LuckNo { get; set; }
}
// 无限赏中奖记录响应
public class InfiniteShangLogResponseDto
{
[JsonPropertyName("status")]
public int Status { get; set; }
[JsonPropertyName("msg")]
public string Msg { get; set; } = string.Empty;
[JsonPropertyName("data")]
public InfiniteShangLogDataDto Data { get; set; } = new();
}
public class InfiniteShangLogDataDto
{
[JsonPropertyName("data")]
public List<InfiniteShangLogItemDto> Data { get; set; } = new();
[JsonPropertyName("last_page")]
public int LastPage { get; set; }
}
// 每日抽奖记录响应
public class DailyPrizeRecordsResponseDto
{
[JsonPropertyName("status")]
public int Status { get; set; }
[JsonPropertyName("msg")]
public string Msg { get; set; } = string.Empty;
[JsonPropertyName("data")]
public List<DailyPrizeRecordItemDto> Data { get; set; } = new();
}
// 道具卡抽奖响应
public class ItemCardDrawResponseDto
{
[JsonPropertyName("status")]
public int Status { get; set; }
[JsonPropertyName("msg")]
public string Msg { get; set; } = string.Empty;
[JsonPropertyName("data")]
public ItemCardDrawDataDto? Data { get; set; }
}
// 抽奖引擎内部模型
public class LotteryDrawRequest
{
public int UserId { get; set; }
public int GoodsId { get; set; }
public int Num { get; set; }
public int OrderId { get; set; }
public string OrderNum { get; set; } = string.Empty;
public int OrderType { get; set; }
}
public class LotteryDrawResult
{
public int GoodsItemId { get; set; }
public string Title { get; set; } = string.Empty;
public string ImgUrl { get; set; } = string.Empty;
public int ShangId { get; set; }
public string ShangTitle { get; set; } = string.Empty;
public string ShangColor { get; set; } = string.Empty;
public decimal Price { get; set; }
public decimal ScMoney { get; set; }
public string PrizeCode { get; set; } = string.Empty;
public int LuckNo { get; set; }
public DateTime DrawTime { get; set; }
}
public class PrizeProbability
{
public int GoodsItemId { get; set; }
public string Title { get; set; } = string.Empty;
public int SurplusStock { get; set; }
public decimal Probability { get; set; }
public double Weight { get; set; }
}
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: 抽奖结果查询正确性
For any valid order with lottery prizes, when querying lottery results, the returned prize list SHALL contain exactly the prizes associated with that order, with all required fields (title, image, price, recovery value, prize_code, luck_no) populated correctly.
Validates: Requirements 1.1, 1.3, 2.1, 2.2, 2.3
Property 2: 中奖记录分页正确性
For any product with winning records, when querying with page number P and page size S, the returned records SHALL be a subset of all records, with count <= S, and records SHALL be ordered by time descending.
Validates: Requirements 3.1, 3.6, 4.1, 5.3
Property 3: 中奖记录过滤正确性
For any shang_id filter value, all returned winning records SHALL have shang_id matching the filter value (when filter > 0), or include all shang_ids (when filter = 0).
Validates: Requirements 3.2, 4.2
Property 4: 概率计算正确性
For any prize pool with total surplus stock T > 0, the probability of each prize with surplus stock S SHALL equal S/T * 100, and the sum of all probabilities SHALL equal 100%.
Validates: Requirements 7.1
Property 5: 权重随机分布正确性
For any prize pool, over a large number of draws (N >= 1000), the actual distribution of selected prizes SHALL approximate the theoretical probability distribution within acceptable variance (chi-square test p-value > 0.05).
Validates: Requirements 7.2
Property 6: 库存扣减原子性
For any successful lottery draw, the surplus_stock of the selected prize SHALL decrease by exactly 1, and no prize with surplus_stock = 0 SHALL be selected.
Validates: Requirements 7.3, 7.4, 8.1, 8.4
Property 7: 并发安全性
For any concurrent lottery draws on the same prize pool, the total number of prizes distributed SHALL NOT exceed the initial total surplus stock, preventing overselling.
Validates: Requirements 8.3
Property 8: 抽奖记录持久化完整性
For any completed lottery draw, an order_item record SHALL be created with correct user_id, goods_id, goodslist_id, shang_id, prize_code (unique), source=1, and appropriate order_type.
Validates: Requirements 9.1, 9.2, 9.3, 9.4
Property 9: API响应格式一致性
For any API response, the structure SHALL follow { "status": int, "msg": string, "data": object }, with all field names in snake_case format.
Validates: Requirements 10.1, 10.2, 10.3, 10.4
Property 10: 道具卡使用正确性
For any item card draw request, if the card is valid and owned by the user, the draw SHALL execute and the card SHALL be marked as consumed; if invalid or already used, an error SHALL be returned without executing the draw.
Validates: Requirements 6.1, 6.2, 6.3
Error Handling
错误类型和处理策略
| 错误类型 | 错误码 | 处理策略 |
|---|---|---|
| 订单不存在 | 0 | 返回空列表或错误消息 |
| 商品不存在 | 0 | 返回错误消息 |
| 商品已下架 | 0 | 返回错误消息 |
| 库存不足 | 0 | 返回错误消息,不执行抽奖 |
| 道具卡无效 | 0 | 返回错误消息 |
| 道具卡已使用 | 0 | 返回错误消息 |
| 并发冲突 | 0 | 重试或返回错误 |
| 系统异常 | 0 | 记录日志,返回通用错误 |
异常处理代码示例
public async Task<LotteryDrawResult> DrawAsync(LotteryDrawRequest request)
{
try
{
// 1. 获取可用奖品池
var prizePool = await _inventoryManager.GetAvailablePrizePoolAsync(
request.GoodsId, request.Num);
if (!prizePool.Any())
{
throw new BusinessException("暂无可抽奖品");
}
// 2. 计算概率并选择奖品
var probabilities = CalculateProbabilities(prizePool);
var selectedPrize = SelectPrizeByWeight(probabilities);
// 3. 原子扣减库存
var deducted = await _inventoryManager.DeductStockAsync(selectedPrize.Id);
if (!deducted)
{
// 库存扣减失败,重试
return await DrawAsync(request);
}
// 4. 创建抽奖记录
var result = await CreateDrawRecordAsync(request, selectedPrize);
return result;
}
catch (DbUpdateConcurrencyException)
{
// 并发冲突,重试
_logger.LogWarning("Concurrency conflict during draw, retrying...");
return await DrawAsync(request);
}
catch (BusinessException)
{
throw;
}
catch (Exception ex)
{
_logger.LogError(ex, "Lottery draw failed");
throw new BusinessException("抽奖失败,请稍后重试");
}
}
Testing Strategy
单元测试
- 测试概率计算逻辑的正确性
- 测试权重随机选择算法
- 测试库存扣减逻辑
- 测试响应格式转换
属性测试
使用 FsCheck 或类似库进行属性测试:
// Property 4: 概率计算正确性
[Property]
public Property ProbabilityCalculation_SumsTo100()
{
return Prop.ForAll(
Arb.From<List<int>>().Filter(stocks => stocks.Any(s => s > 0)),
stocks =>
{
var prizePool = stocks.Select((s, i) => new GoodsItem
{
Id = i,
SurplusStock = Math.Max(0, s)
}).ToList();
var probabilities = _engine.CalculateProbabilities(prizePool);
var sum = probabilities.Sum(p => p.Probability);
return Math.Abs(sum - 100) < 0.01m;
});
}
// Property 6: 库存扣减原子性
[Property]
public Property StockDeduction_DecrementsByOne()
{
return Prop.ForAll(
Arb.From<int>().Filter(stock => stock > 0),
initialStock =>
{
var item = new GoodsItem { Id = 1, SurplusStock = initialStock };
_inventoryManager.DeductStockAsync(item.Id).Wait();
var updatedItem = _dbContext.GoodsItems.Find(item.Id);
return updatedItem.SurplusStock == initialStock - 1;
});
}
集成测试
- 测试完整的抽奖流程
- 测试API端点响应格式
- 测试与PHP API的兼容性
- 测试并发场景下的数据一致性
测试配置
- 属性测试最少运行100次迭代
- 使用内存数据库进行单元测试
- 使用真实数据库进行集成测试
- 并发测试使用多线程模拟