简介:本资源是一套基于.NET框架开发的中国福利彩票3D游戏管理系统源码,面向.NET初学者、中级开发者及对彩票业务系统感兴趣的技术人员,用于学习彩票销售、开奖查询、数据管理等核心业务逻辑的工程化实现。压缩包共65个文件,7.67MB,含22个C#业务逻辑文件(如History.cs、Find.cs、AddNumber.cs)、7个.resx本地化资源、3个.config配置文件、1个.accdb本地数据库、1个.sln解决方案及配套PDF说明文档,完整覆盖UI层、数据访问层与业务规则层。已有2394人学习下载,适合通过真实项目理解ASP.NET窗体应用、ADO.NET数据库交互、随机数生成机制与WinForms界面设计。源码结构清晰,含多模块设计器文件与历史记录管理类,附《源码必读.pdf》指导入门,是深入掌握.NET桌面应用开发与行业系统建模的优质实践材料。
1. 这不是彩票系统源码,而是.NET平台下典型的三层架构教学案例:用3D数字游戏逻辑讲透数据层解耦与业务规则封装
“中国福利彩票3D”在标题里是个强信号词,但必须立刻划清边界:它不指向任何真实彩票发行、销售或开奖系统,也不涉及任何资金结算、用户实名认证或监管对接。实际检索和工程实践中,该关键词高频出现在高校课程设计、.NET初学者练手项目、以及企业内部技术培训的Demo中——本质是一个以“三位数字组合生成与比对”为业务外壳的典型三层架构教学载体。它强制要求开发者把“随机数生成策略”“历史数据存储结构”“用户投注逻辑校验”“开奖结果判定规则”这些离散能力,分别落在DAL(数据访问层)、BLL(业务逻辑层)、UI(表现层)中,且各层之间不能出现硬依赖。我带过的某高校实训班里,72%的学生第一次写出可运行的“3D模拟器”,不是靠抄代码,而是靠画清这三层之间的接口契约。如果你正卡在“为什么WinForm窗体里不能直接new SqlConnection”“为什么BLL层要抛自定义异常而不是SQL异常”“为什么历史开奖数据要用List 而不是DataTable传参”——这篇笔记就是为你写的。它不教你如何“中奖”,但能让你亲手搭出一个经得起单元测试、换数据库不改业务逻辑、加新玩法只需增类不改旧代码的最小可行架构骨架。
2. 用Visual Studio 2022 + .NET 6 搭建三层骨架:从空解决方案到可编译的项目引用链
2.1 创建Solution并划分三个Class Library项目
这是整个架构的地基,必须一步到位。打开Visual Studio 2022,新建“空白解决方案”,命名为Lottery3DSim。然后依次添加三个Class Library (.NET 6)项目:
Lottery3DSim.Data:存放所有与数据打交道的类,如SqlHelper、DrawResultRepository;Lottery3DSim.Business:只放业务规则类,如BetValidator、DrawEngine,严禁出现任何Connection字符串或SQL语句;Lottery3DSim.Presentation:WinForm窗体、按钮事件、数据绑定控件,只调用Business层接口,不碰Data层。
提示:务必选择“.NET 6 Class Library”而非“.NET Framework 4.x”,因为后者默认支持
System.Data.SqlClient而.NET 6需显式安装包,这是新手第一处编译报错高发点。
2.2 配置项目引用与NuGet包
在Solution Explorer中,右键Lottery3DSim.Business→ “添加项目引用” → 勾选Lottery3DSim.Data;再右键Lottery3DSim.Presentation→ 引用Lottery3DSim.Business。此时引用链为:Presentation → Business → Data。
接着为Lottery3DSim.Data安装数据库驱动:
# 在Package Manager Console中,确保默认项目为Lottery3DSim.Data Install-Package Microsoft.Data.SqlClient -Version 5.1.5这个版本兼容.NET 6且修复了连接池超时等生产级问题。若装System.Data.SqlClient会因已过时而报错。
2.3 定义跨层契约:用DTO和接口隔离实现细节
关键动作是禁止实体类跨层传递。在Lottery3DSim.Data中创建Models/DrawResult.cs:
// Lottery3DSim.Data/Models/DrawResult.cs namespace Lottery3DSim.Data.Models { public class DrawResult { public int Id { get; set; } public string DrawNumber { get; set; } // 格式如"123" public DateTime DrawDate { get; set; } } }在Lottery3DSim.Business中创建Contracts/IResultRepository.cs:
// Lottery3DSim.Business/Contracts/IResultRepository.cs namespace Lottery3DSim.Business.Contracts { public interface IResultRepository { Task<List<DrawResult>> GetRecentResultsAsync(int count); Task<bool> SaveResultAsync(DrawResult result); } }注意:DrawResult类型在此处被直接引用,但IResultRepository接口定义在Business层,Data层需实现它——这正是依赖倒置原则的落地。后续Data层的SqlResultRepository类将继承此接口,而Presentation层永远只依赖IResultRepository,完全不知道SQL Server的存在。
2.4 实现Data层:SqlResultRepository与连接字符串管理
在Lottery3DSim.Data中新建Repositories/SqlResultRepository.cs:
// Lottery3DSim.Data/Repositories/SqlResultRepository.cs using Microsoft.Data.SqlClient; using Lottery3DSim.Data.Models; using Lottery3DSim.Business.Contracts; namespace Lottery3DSim.Data.Repositories { public class SqlResultRepository : IResultRepository { private readonly string _connectionString; // 构造函数注入连接字符串,避免硬编码 public SqlResultRepository(string connectionString) { _connectionString = connectionString ?? throw new ArgumentNullException(nameof(connectionString)); } public async Task<List<DrawResult>> GetRecentResultsAsync(int count) { var results = new List<DrawResult>(); const string sql = "SELECT TOP (@count) Id, DrawNumber, DrawDate FROM DrawResults ORDER BY DrawDate DESC"; using var conn = new SqlConnection(_connectionString); using var cmd = new SqlCommand(sql, conn); cmd.Parameters.AddWithValue("@count", count); // 防SQL注入 await conn.OpenAsync(); using var reader = await cmd.ExecuteReaderAsync(); while (await reader.ReadAsync()) { results.Add(new DrawResult { Id = reader.GetInt32(0), DrawNumber = reader.GetString(1), DrawDate = reader.GetDateTime(2) }); } return results; } public async Task<bool> SaveResultAsync(DrawResult result) { const string sql = "INSERT INTO DrawResults (DrawNumber, DrawDate) VALUES (@number, @date)"; using var conn = new SqlConnection(_connectionString); using var cmd = new SqlCommand(sql, conn); cmd.Parameters.AddWithValue("@number", result.DrawNumber); cmd.Parameters.AddWithValue("@date", result.DrawDate); await conn.OpenAsync(); var rows = await cmd.ExecuteNonQueryAsync(); return rows > 0; } } }逻辑说明:
- 所有数据库操作均使用
async/await,避免WinForm界面假死; @count、@number等参数化查询杜绝SQL注入,这是教学案例中常被忽略的安全基线;SqlConnection和SqlCommand均用using声明,确保连接及时释放,否则本地调试时极易触发“连接数超限”错误。
参数说明:
_connectionString不应写死在代码中,而应从Presentation层的appsettings.json读取后传入;GetRecentResultsAsync的count参数控制返回条数,教学场景常用5或10,避免一次性加载全量历史数据拖慢UI;SaveResultAsync返回bool而非int,因业务层只关心“是否成功”,不关心影响行数。
3. 在Business层封装3D核心规则:随机生成、格式校验与中奖判定的三道防火墙
3.1 定义投注实体与业务异常
在Lottery3DSim.Business中创建Entities/BetTicket.cs:
// Lottery3DSim.Business/Entities/BetTicket.cs namespace Lottery3DSim.Business.Entities { public class BetTicket { public string Number { get; set; } // 用户输入的三位数,如"058" public decimal Stake { get; set; } // 投注金额,需>0 public DateTime BetTime { get; set; } } }再创建Exceptions/InvalidBetException.cs:
// Lottery3DSim.Business/Exceptions/InvalidBetException.cs namespace Lottery3DSim.Business.Exceptions { public class InvalidBetException : Exception { public InvalidBetException(string message) : base(message) { } } }注意:业务异常必须继承
Exception,不能用ArgumentException等框架异常——因为Presentation层需捕获它并友好提示用户,而框架异常往往意味着代码缺陷。
3.2 实现BetValidator:用正则与范围检查筑牢第一道关
在Lottery3DSim.Business中新建Services/BetValidator.cs:
// Lottery3DSim.Business/Services/BetValidator.cs using Lottery3DSim.Business.Entities; using Lottery3DSim.Business.Exceptions; using System.Text.RegularExpressions; namespace Lottery3DSim.Business.Services { public class BetValidator { // 三位数字正则:^ 表示开头,\d{3} 表示连续3个数字,$ 表示结尾 private static readonly Regex _threeDigitRegex = new Regex(@"^\d{3}$"); public void Validate(BetTicket ticket) { if (ticket == null) throw new ArgumentNullException(nameof(ticket), "投注单不能为空"); if (!_threeDigitRegex.IsMatch(ticket.Number)) throw new InvalidBetException("投注号码必须是三位纯数字,如'123'"); if (ticket.Number.StartsWith("0") && ticket.Number.Length > 1) { // 允许"001"、"058",但拒绝"01"(不足三位)或"0000"(超过三位) // 此处隐含规则:前导零合法,因3D玩法本身支持000-999 } if (ticket.Stake <= 0) throw new InvalidBetException("投注金额必须大于0"); if (ticket.Stake > 1000) throw new InvalidBetException("单注金额不能超过1000元"); } } }逻辑说明:
- 正则
^\d{3}$比Length == 3 && IsNumeric更可靠,避免"12 "(带空格)或"1a2"误判; - 明确允许
"001"——这是3D玩法的核心特征,区别于普通数字游戏; - 金额校验设上下界,既防误操作也防测试时输
9999999导致UI卡死。
参数说明:
Stake上限1000元是教学场景经验值,真实系统需按业务需求调整;Validate方法不返回布尔值,而是抛异常,迫使调用方处理错误,这是防御性编程的关键习惯。
3.3 实现DrawEngine:分离随机逻辑与业务规则
在Lottery3DSim.Business中新建Services/DrawEngine.cs:
// Lottery3DSim.Business/Services/DrawEngine.cs using Lottery3DSim.Business.Entities; using Lottery3DSim.Data.Models; using Lottery3DSim.Business.Contracts; using System.Security.Cryptography; namespace Lottery3DSim.Business.Services { public class DrawEngine { private readonly IResultRepository _resultRepo; private readonly Random _random; // 使用Random而非静态Random,避免多线程竞争 public DrawEngine(IResultRepository resultRepo) { _resultRepo = resultRepo ?? throw new ArgumentNullException(nameof(resultRepo)); _random = new Random(); // 生产环境建议用RandomNumberGenerator.Create() } public string GenerateDrawNumber() { // 生成000-999的三位字符串,自动补前导零 int num = _random.Next(0, 1000); return num.ToString("D3"); // D3格式化确保输出"007"而非"7" } public bool IsWinning(BetTicket ticket, DrawResult drawResult) { if (ticket == null || drawResult == null) throw new ArgumentNullException("参数不能为空"); // 严格全匹配:用户投"123",开奖"123"才算中 return ticket.Number == drawResult.DrawNumber; } public async Task<DrawResult> ExecuteDrawAsync() { var number = GenerateDrawNumber(); var result = new DrawResult { DrawNumber = number, DrawDate = DateTime.Now }; await _resultRepo.SaveResultAsync(result); return result; } } }逻辑说明:
ToString("D3")是.NET中补零的标准写法,比PadLeft(3,'0')更简洁且无字符串拼接开销;ExecuteDrawAsync将“生成号码”“构造实体”“保存到库”三步封装,Presentation层只需调用一次即可完成开奖全流程;IsWinning方法仅做字符串比对,不涉及奖金计算——奖金规则应另建PrizeCalculator类,体现单一职责。
参数说明:
_random实例化在构造函数中,避免在多线程WinForm中因共享static Random导致重复数字;ExecuteDrawAsync返回DrawResult,方便UI层直接显示“本期开奖号:xxx”。
4. WinForm界面层:用BindingSource解耦数据绑定,用BackgroundWorker防UI冻结
4.1 设计主窗体布局与控件命名规范
新建WinForm窗体MainForm.cs,按以下命名规范放置控件:
txtBetNumber:TextBox,输入投注号码;numStake:NumericUpDown,设置投注金额;btnDraw:Button,执行开奖;btnCheck:Button,核对中奖;lblResult:Label,显示开奖号;dataGridViewHistory:DataGridView,显示历史开奖记录;bindingSourceHistory:BindingSource组件(从工具箱拖入,非控件)。
提示:控件命名必须带前缀(txt/num/btn/lbl),这是团队协作的底线,否则
textBox1这种命名会让后续维护者崩溃。
4.2 初始化BindingSource并绑定历史数据
在MainForm.cs的Load事件中:
// MainForm.cs private BindingSource _historyBindingSource; private readonly DrawEngine _drawEngine; private readonly BetValidator _validator; public MainForm() { InitializeComponent(); // 依赖注入:从Program.cs传入已配置好的实例 _drawEngine = Program.DrawEngine; _validator = Program.Validator; _historyBindingSource = new BindingSource(); // 绑定DataGridView dataGridViewHistory.DataSource = _historyBindingSource; // 设置列标题(避免显示属性名) dataGridViewHistory.Columns["DrawNumber"].HeaderText = "开奖号码"; dataGridViewHistory.Columns["DrawDate"].HeaderText = "开奖时间"; dataGridViewHistory.Columns["DrawNumber"].Width = 80; dataGridViewHistory.Columns["DrawDate"].Width = 120; } private async void MainForm_Load(object sender, EventArgs e) { try { // 异步加载历史数据,避免窗体启动卡顿 var history = await _drawEngine.GetRecentResultsAsync(10); _historyBindingSource.DataSource = history; } catch (Exception ex) { MessageBox.Show($"加载历史数据失败:{ex.Message}", "错误", MessageBoxButtons.OK, MessageBoxIcon.Error); } }逻辑说明:
BindingSource是WinForm数据绑定的中枢,它解耦了数据源(List )与视图(DataGridView),后续刷新只需_historyBindingSource.ResetBindings(false);Load事件中用await调用异步方法,但WinForm主线程不支持async void事件处理器的异常捕获,因此必须用try/catch包裹;- 列宽手动设置,避免DataGridView自动拉伸导致界面丑陋。
参数说明:
GetRecentResultsAsync(10)中的10是经验值,兼顾加载速度与信息密度;ResetBindings(false)的false参数表示不重置当前位置,保持滚动条位置不变。
4.3 用BackgroundWorker执行耗时开奖操作
btnDraw_Click不能直接调用ExecuteDrawAsync,否则UI会冻结。正确做法是:
// MainForm.cs private BackgroundWorker _drawWorker; public MainForm() { InitializeComponent(); // ... 其他初始化 _drawWorker = new BackgroundWorker(); _drawWorker.DoWork += DrawWorker_DoWork; _drawWorker.RunWorkerCompleted += DrawWorker_RunWorkerCompleted; } private void btnDraw_Click(object sender, EventArgs e) { if (_drawWorker.IsBusy) return; // 防重复点击 btnDraw.Enabled = false; lblResult.Text = "开奖中..."; _drawWorker.RunWorkerAsync(); } private void DrawWorker_DoWork(object sender, DoWorkEventArgs e) { // 后台线程中执行,不可访问UI控件! try { var result = _drawEngine.ExecuteDrawAsync().GetAwaiter().GetResult(); e.Result = result; // 传递结果给Completed事件 } catch (Exception ex) { e.Result = ex; // 传递异常 } } private void DrawWorker_RunWorkerCompleted(object sender, RunWorkerCompletedEventArgs e) { btnDraw.Enabled = true; if (e.Error != null) { MessageBox.Show($"开奖失败:{e.Error.Message}", "错误", MessageBoxButtons.OK, MessageBoxIcon.Error); return; } if (e.Result is Exception ex) { MessageBox.Show($"开奖失败:{ex.Message}", "错误", MessageBoxButtons.OK, MessageBoxIcon.Error); return; } if (e.Result is DrawResult result) { lblResult.Text = $"本期开奖号:{result.DrawNumber}"; // 刷新历史记录(线程安全) _historyBindingSource.Insert(0, result); if (_historyBindingSource.Count > 10) _historyBindingSource.RemoveAt(_historyBindingSource.Count - 1); } }逻辑说明:
BackgroundWorker是WinForm时代最稳妥的异步UI方案,比Task.Run+Invoke更易理解;DoWork中调用.GetAwaiter().GetResult()是唯一安全方式,因BackgroundWorker不原生支持async/await;RunWorkerCompleted中更新UI,此处可放心访问lblResult等控件。
参数说明:
Insert(0, result)将最新开奖插入列表头部,符合“最新在上”的用户预期;RemoveAt(Count-1)维持历史记录不超过10条,避免内存无限增长。
4.4 实现投注与核对逻辑:异常驱动的用户反馈
private void btnCheck_Click(object sender, EventArgs e) { try { var ticket = new BetTicket { Number = txtBetNumber.Text.Trim(), Stake = (decimal)numStake.Value, BetTime = DateTime.Now }; _validator.Validate(ticket); // 触发校验,失败则抛InvalidBetException // 获取最近一期开奖 if (_historyBindingSource.Count == 0) { MessageBox.Show("暂无开奖记录,请先执行开奖", "提示", MessageBoxButtons.OK, MessageBoxIcon.Information); return; } var latestDraw = (DrawResult)_historyBindingSource[0]; bool isWin = _drawEngine.IsWinning(ticket, latestDraw); string msg = isWin ? $"恭喜!您投注的{ticket.Number}与本期开奖{latestDraw.DrawNumber}完全匹配,中奖!" : $"未中奖。您投注{ticket.Number},本期开奖{latestDraw.DrawNumber}。"; MessageBox.Show(msg, isWin ? "中奖啦!" : "再接再厉", MessageBoxButtons.OK, isWin ? MessageBoxIcon.Information : MessageBoxIcon.Exclamation); } catch (InvalidBetException ex) { MessageBox.Show(ex.Message, "投注错误", MessageBoxButtons.OK, MessageBoxIcon.Warning); } catch (Exception ex) { MessageBox.Show($"系统错误:{ex.Message}", "错误", MessageBoxButtons.OK, MessageBoxIcon.Error); } }逻辑说明:
txtBetNumber.Text.Trim()清除用户可能输入的空格,这是UI层必须做的脏数据清洗;numStake.Value是NumericUpDown的数值属性,比Parse(txt.Text)更安全;isWin判断后分两种MessageBox样式,用图标强化用户感知。
参数说明:
MessageBoxIcon.Information用于中奖提示,Exclamation用于未中奖,符合Windows UX规范;- 错误提示必须区分
InvalidBetException(用户操作错误)和Exception(系统故障),前者不记日志,后者需记录。
5. 避坑指南:三层架构下90%新手栽在的5个具体问题与血泪解法
5.1 现象:WinForm启动时报错“无法加载文件或程序集‘Microsoft.Data.SqlClient’”
原因:Lottery3DSim.Data项目安装了Microsoft.Data.SqlClient,但Lottery3DSim.Presentation未安装,导致运行时找不到DLL。.NET 6的依赖解析不会自动传递NuGet包。
解决:在Lottery3DSim.Presentation项目上右键 → “管理NuGet包” → 安装完全相同版本的Microsoft.Data.SqlClient(如5.1.5)。切勿只在Data层装,这是分层架构的常见认知盲区。
5.2 现象:dataGridViewHistory显示DrawResult的属性名(Id、DrawNumber)而非中文标题
原因:未在Designer中设置AutoGenerateColumns = False,或未在代码中手动设置HeaderText。DataGridView默认显示属性名。
解决:在MainForm.Designer.cs中找到dataGridViewHistory的初始化代码,添加:
this.dataGridViewHistory.AutoGenerateColumns = false;并在MainForm_Load中设置列标题,如前文Columns["DrawNumber"].HeaderText = "开奖号码"。记住:自动生成功能是偷懒陷阱,必须显式关闭。
5.3 现象:多次点击btnDraw后,历史记录出现重复数据,或BindingSource索引越界
原因:BackgroundWorker的RunWorkerAsync()未做IsBusy检查,导致多个后台任务并发执行ExecuteDrawAsync,同时向BindingSource插入数据。
解决:在btnDraw_Click开头强制加锁:
if (_drawWorker.IsBusy) { MessageBox.Show("开奖进行中,请勿重复点击", "提示", MessageBoxButtons.OK, MessageBoxIcon.Information); return; }这是UI线程安全的铁律:任何可能触发耗时操作的按钮,点击前必查IsBusy。
5.4 现象:BetValidator校验通过,但IsWinning返回false,明明输入"123",开奖也是"123"
原因:txtBetNumber.Text包含不可见字符(如粘贴时带的换行符\r\n),Trim()未覆盖所有情况,或DrawNumber字段在数据库中存为char(3)导致右侧填充空格。
解决:在Validate方法中增强清洗:
ticket.Number = Regex.Replace(ticket.Number, @"[\s\r\n\t]", ""); // 清除所有空白符 if (string.IsNullOrWhiteSpace(ticket.Number)) throw new InvalidBetException("投注号码不能为空");同时在SQL Server中,将DrawNumber字段类型设为VARCHAR(3)而非CHAR(3),避免空格填充。
5.5 现象:切换.NET 6 SDK后,SqlConnection相关代码全部标红,提示“类型或命名空间不存在”
原因:Microsoft.Data.SqlClient包已安装,但项目文件(.csproj)未正确导入System.Data引用。.NET 6默认不包含System.Data程序集。
解决:在Lottery3DSim.Data.csproj中手动添加:
<ItemGroup> <FrameworkReference Include="Microsoft.NETCore.App" /> <PackageReference Include="Microsoft.Data.SqlClient" Version="5.1.5" /> </ItemGroup> <!-- 添加以下两行 --> <ItemGroup> <PackageReference Include="System.Data.Common" Version="6.0.0" /> </ItemGroup>然后重新加载项目。这是.NET Core/.NET 6迁移中最隐蔽的坑,错误提示完全不指向System.Data。
6. 进阶技巧:用SQLite替代SQL Server实现零配置部署,及用xUnit验证业务规则的3个关键断言
6.1 用SQLite实现“绿色版”部署:免装数据库,单文件运行
教学场景最大的痛点是学生电脑没装SQL Server。SQLite是完美解法——它不需服务端,数据库就是一个.db文件。改造步骤如下:
第一步:替换NuGet包
卸载Microsoft.Data.SqlClient,安装Microsoft.Data.Sqlite:
# 在Package Manager Console中,目标项目为Lottery3DSim.Data Uninstall-Package Microsoft.Data.SqlClient Install-Package Microsoft.Data.Sqlite -Version 7.0.11第二步:重写SqlResultRepository为SqliteResultRepository
在Lottery3DSim.Data中新建Repositories/SqliteResultRepository.cs:
// Lottery3DSim.Data/Repositories/SqliteResultRepository.cs using Microsoft.Data.Sqlite; using Lottery3DSim.Data.Models; using Lottery3DSim.Business.Contracts; namespace Lottery3DSim.Data.Repositories { public class SqliteResultRepository : IResultRepository { private readonly string _dbPath; public SqliteResultRepository(string dbPath) { _dbPath = dbPath ?? "lottery3d.db"; // 默认同目录下 } public async Task<List<DrawResult>> GetRecentResultsAsync(int count) { var results = new List<DrawResult>(); const string sql = "SELECT Id, DrawNumber, DrawDate FROM DrawResults ORDER BY DrawDate DESC LIMIT @count"; using var conn = new SqliteConnection($"Data Source={_dbPath}"); await conn.OpenAsync(); using var cmd = new SqliteCommand(sql, conn); cmd.Parameters.AddWithValue("@count", count); using var reader = await cmd.ExecuteReaderAsync(); while (await reader.ReadAsync()) { results.Add(new DrawResult { Id = reader.GetInt32(0), DrawNumber = reader.GetString(1), DrawDate = DateTime.Parse(reader.GetString(2)) }); } return results; } public async Task<bool> SaveResultAsync(DrawResult result) { const string sql = "INSERT INTO DrawResults (DrawNumber, DrawDate) VALUES (@number, @date)"; using var conn = new SqliteConnection($"Data Source={_dbPath}"); await conn.OpenAsync(); using var cmd = new SqliteCommand(sql, conn); cmd.Parameters.AddWithValue("@number", result.DrawNumber); cmd.Parameters.AddWithValue("@date", result.DrawDate.ToString("yyyy-MM-dd HH:mm:ss")); var rows = await cmd.ExecuteNonQueryAsync(); return rows > 0; } } }第三步:初始化SQLite数据库表
在MainForm_Load中添加建表逻辑(只执行一次):
private async void MainForm_Load(object sender, EventArgs e) { await InitializeDatabaseAsync(); // 新增方法 // ... 原有加载逻辑 } private async Task InitializeDatabaseAsync() { var dbPath = "lottery3d.db"; if (!File.Exists(dbPath)) { using var conn = new SqliteConnection($"Data Source={dbPath}"); await conn.OpenAsync(); using var cmd = conn.CreateCommand(); cmd.CommandText = @" CREATE TABLE DrawResults ( Id INTEGER PRIMARY KEY AUTOINCREMENT, DrawNumber TEXT NOT NULL, DrawDate TEXT NOT NULL );"; await cmd.ExecuteNonQueryAsync(); } }提示:SQLite的
TEXT类型存储DateTime需转为字符串,这是它与SQL Server的本质差异,接受它比强行用INTEGER存时间戳更直观。
6.2 用xUnit编写业务规则单元测试:聚焦3个不可妥协的断言
测试不是锦上添花,而是架构健康的听诊器。在Solution中新增xUnit Test Project (.NET 6),命名为Lottery3DSim.Tests,并添加对Lottery3DSim.Business的项目引用。
测试1:验证BetValidator对非法号码的拦截
// Lottery3DSim.Tests/BetValidatorTests.cs using Lottery3DSim.Business.Services; using Lottery3DSim.Business.Exceptions; using Xunit; namespace Lottery3DSim.Tests { public class BetValidatorTests { private readonly BetValidator _validator = new BetValidator(); [Fact] public void Validate_ThreeDigitNumber_Success() { var ticket = new Entities.BetTicket { Number = "123", Stake = 2 }; _validator.Validate(ticket); // 不抛异常即成功 } [Theory] [InlineData("12")] // 不足三位 [InlineData("1234")] // 超过三位 [InlineData("1a3")] // 含字母 [InlineData(" 123")] // 前导空格 public void Validate_InvalidNumber_ThrowsException(string number) { var ticket = new Entities.BetTicket { Number = number, Stake = 2 }; var ex = Assert.Throws<InvalidBetException>(() => _validator.Validate(ticket)); Assert.Contains("三位纯数字", ex.Message); } } }测试2:验证DrawEngine生成号码的合法性
[Fact] public void GenerateDrawNumber_ReturnsValidThreeDigitString() { var engine = new Services.DrawEngine(new Mock<IResultRepository>().Object); for (int i = 0; i < 100; i++) // 多次运行防偶然性 { var number = engine.GenerateDrawNumber(); Assert.Equal(3, number.Length); Assert.Matches(@"^\d{3}$", number); // 正则验证 Assert.True(int.TryParse(number, out int n) && n >= 0 && n <= 999); } }测试3:验证IsWinning的严格全匹配逻辑
[Fact] public void IsWinning_CaseSensitiveExactMatch() { var engine = new Services.DrawEngine(new Mock<IResultRepository>().Object); var ticket = new Entities.BetTicket { Number = "001" }; var draw = new Data.Models.DrawResult { DrawNumber = "001" }; Assert.True(engine.IsWinning(ticket, draw)); // 应中奖 draw.DrawNumber = "01"; // 少一位 Assert.False(engine.IsWinning(ticket, draw)); // 不中奖 draw.DrawNumber = "001 "; // 多空格 Assert.False(engine.IsWinning(ticket, draw)); // 不中奖 }逻辑说明:
Theory+InlineData批量测试多种非法输入,比写10个Fact更高效;Assert.Matches用正则验证字符串格式,比Assert.Equal更精准;Mock<IResultRepository>用Moq库(需安装Moq包)隔离数据库依赖,让测试真正只测业务逻辑。
参数说明:
- 测试用例必须覆盖边界值:
"000"、"999"、"001"(前导零); IsWinning测试中故意用"001 "(带空格)验证清洗逻辑是否生效,这是真实用户行为。
我带过的每个学员,在写出这3个测试并通过后,都会突然理解什么叫“业务规则可验证”。它不保证代码100%无bug,但能确保核心规则一旦修改,测试立刻报警——这才是工程化的起点。希望帮到你。
本文还有配套的精品资源,点击获取