简介:晓风彩票代购程序源码 v5.0.2 面向彩票合买代购平台开发者与 .NET 技术学习者,提供一套覆盖会员安全检测、投注记录查询、代理分销和后台管理的网站代码,可用于搭建或研究彩票网络销售系统。压缩包共 6 个文件,约 13.44MB,包含源码压缩包、数据库文件、说明文档和著作权证书图片等;其中 htm 与 txt 便于了解项目说明,db 提供基础数据结构,jpg 为著作权证书图片。已有 2778 人学习下载,具有一定参考热度。代码涉及六大维度会员安全评分、自动开奖与兑奖、四级代理分销、自动充值及多彩种管理,并采用 Ajax 轮询开奖号码以降低开奖延时;v5.0.2 还增加彩种类别、无限极代理设置以及 Windows 2000/8 环境支持。后台按模块划分,便于二次开发与功能裁剪,适合需要研究彩票代购业务逻辑、代理体系或 .NET 网站架构的读者查阅。
1. 一套 .NET 彩票代购程序源码能拿来干什么
拿到彩票代购程序源码 v5.0.2,很多人的第一反应是双击 .sln 直接编译,然后卡在数据库连接串那一行。这套 .NET 源码包的业务面其实很清楚:彩种与玩法配置、期号开售与封盘、出票下单、注单结算派奖、账户资金流水、代理分成、日终报表。它适合两类开发者——手上有线下门店出票记账场景、想找一套现成订单骨架来改的人;以及想拿一套真实的订单型 .NET 系统练手、把结算链路看透的人。边界先说在前面:这套系统按线下门店出票、记账、内部票务对账设计,不对接任何资金盘口。下面按能不能跑、跑起来长什么样、改哪里三件事往下拆。
2. 技术栈与工程结构:ASP.NET + SQL Server 这套老搭档怎么分层
这套源码的骨架属于典型的 .NET Framework 时代工程:ASP.NET MVC 5 做站点,ADO.NET 加 Dapper 做数据访问,SQL Server 存业务数据,IIS 托管,定时任务单独挂在 Quartz.NET 上。这种组合放到今天不算新,但胜在部署门槛低、门店那台 Windows 服务器就能跑,出了问题也容易定位。v5.0.2 这个版本号说明它已经迭代过多轮,代码里会留下不少历史痕迹,读之前先把分层摸清,比一头扎进页面里强。
2.1 跑起来之前先盘依赖
先别急着编译,把运行时要什么列清楚,能省掉一半的「为什么跑不起来」。这套系统对环境的依赖不算重,但每一项缺了都会在启动阶段报错,而且报错信息往往指向不明的地方。
| 依赖项 | 版本建议 | 缺了会怎样 |
|---|---|---|
| .NET Framework | 4.6.1 及以上 | 编译直接失败,提示目标框架未安装 |
| SQL Server | 2012 及以上 | 登录页能打开,一点登录就 500 |
| IIS | 8.0 及以上 | 静态页正常,.aspx/.cshtml 全部 404 |
| Quartz.NET | 2.x | 站点正常,但期号不会自动开售、注单不结算 |
| Redis(可选) | 3.x | 配了没起会一直重连,日志刷屏但不影响主流程 |
我一般会先在本机装好 .NET Framework 和 SQL Server Express,把库跑起来,再考虑 IIS。用 Visual Studio 自带的 IIS Express 调试能省掉站点配置这一环,等业务跑通了再往正式 IIS 上挪。Redis 在这个版本里是可选项,配置文件里留了开关,默认关闭;如果你的门店规模不大,别开它,多一个组件就多一个半夜出问题的可能。
还有一点容易被忽略:这套工程用的是 packages.config 管理 NuGet 包,不是 PackageReference。用新版 Visual Studio 打开会提示迁移,别点。迁移动不动就把老版本的 Dapper、Newtonsoft.Json 升上去,接口签名一变,几十个仓储类全红。血泪经验是:老项目能不升级就不升级,先把业务跑通再说。
2.2 目录结构与分层职责
工程结构不复杂,但分层边界划得还算清楚,改代码之前值得先看一遍目录。
LotteryAgent/ ├── src/ │ ├── Lottery.Web/ # 前台站点:选号、出票、注单查询 │ ├── Lottery.Admin/ # 运营后台:彩种、期号、代理、报表 │ ├── Lottery.Core/ # 领域模型与业务服务(结算、限额、赔率) │ ├── Lottery.Data/ # 仓储层,Dapper 手写 SQL 为主 │ ├── Lottery.Jobs/ # Quartz 宿主:开奖抓取、结算、日终对账 │ └── Lottery.Common/ # 日志、加密、时间、金额工具类 ├── db/ │ ├── 01_schema.sql # 建表脚本 │ └── 02_initdata.sql # 彩种、玩法、赔率基础数据 ├── config/ │ ├── appSettings.config # 连接串、外部开奖源地址 │ └── log4net.config # 日志分级与落盘路径 └── publish/ # 发布输出目录Lottery.Core是整个工程的重量所在。彩种规则、赔率计算、限额判定、结算判定全部集中在这一层,页面和定时任务都只是它的调用方。这种划分的好处是,你改结算逻辑时不用满工程找,坏处是 Core 里塞了不少和数据库实体长得一模一样的 DTO,读的时候要留神别把两者混了。
Lottery.Data里几乎不用 Entity Framework,全是 Dapper 手写 SQL。这在一套订单系统里其实是好事:扣余额、条件更新、批量结算这类操作,手写 SQL 比 ORM 生成的可控得多。代价是仓储类数量多,一个表一个类,改字段的时候要同步改好几处地方。
Lottery.Jobs是独立的控制台宿主,和 Web 站点分进程跑。这个设计值得保留,后面讲定时任务踩坑时会说原因。
2.3 数据库里绕不开的七张表
业务再花哨,落到库表上就那么几张核心表。先认清它们的关系,后面读代码会顺很多。
Lottery:彩种表,记录彩种编码、名称、开奖周期、是否启用。LotteryPlay:玩法表,挂在彩种下,存赔率、单注上限、返点比例。LotteryIssue:期号表,一行一期,关键字段是开售时间、截止时间、开奖号、状态。BetOrder:注单主表,存客户端单号、期号、金额、赔率快照、结算状态、中奖金额。BetOrderDetail:注单明细,一期多注时一行一注,号码存在这里。Account与AccountFlow:账户表存可用、冻结、在途三个金额字段,流水表一行一笔变动,带业务单号。Agent:代理表,存层级关系、返点比例,分成结算时按它往上追溯。
赔率快照这个字段要特别注意,它不是数据库里直接等于LotteryPlay的当前赔率,而是下单那一刻写进注单里的固定值。后来运营调了赔率,历史注单的结算金额不能跟着变,否则对账永远对不上。我见过有人图省事在结算时实时查玩法表拿赔率,结果一次调价之后,前一天所有未结算注单的派奖金额全变了,翻车翻得很难看。
3. 核心链路拆解:一期从开售到派奖的完整请求路径
这条链路是整个系统的主干,也是二次开发动得最多的地方。一次完整的周期是:运营配置彩种玩法,系统按期生成期号并开售,用户或门店出票下单、扣减可用余额,到了截止时间封盘,开奖号落地后触发批量结算,中奖的注单派奖回账户,未中的把冻结金额转成实际支出。任何一环断了,第二天对账就会炸。
3.1 彩种、玩法与期号配置
期号是自动生成的,配置在IssueGenerateService里。核心逻辑是根据彩种的开奖周期,往前推算未来的若干期,把期号、开售时间、截止时间一次性写好。这样即使定时任务挂了几小时,重启之后也能补齐。
// IssueGenerateService.Generate:按期补齐未来 N 期 public int Generate(string lotteryCode, int days = 3) { var lottery = _lotteryRepo.Get(lotteryCode); if (lottery == null || !lottery.Enabled) return 0; // 以最后一次开奖时间为基准往后推,避免重复生成 var last = _issueRepo.GetLast(lotteryCode); var start = last?.DrawTime ?? DateTime.Today; var created = 0; for (var i = 1; i <= days; i++) { // 期号规则由彩种配置决定,常见是按日期+序号 var issueNo = lottery.IssueRule.Build(start.AddDays(i)); if (_issueRepo.Exists(lotteryCode, issueNo)) continue; var drawTime = lotteries.DrawTimeOf(start.AddDays(i)); // 开奖时刻 var closeTime = drawTime.AddMinutes(-lottery.CloseAheadMin); // 提前封盘 _issueRepo.Insert(new LotteryIssue { LotteryCode = lotteryCode, IssueNo = issueNo, OpenTime = start.AddDays(i).Date, CloseTime = closeTime, DrawTime = drawTime, Status = IssueStatus.Open }); created++; } return created; }days控制往前补几期,门店场景给 3 天足够,多了浪费表空间。CloseAheadMin是封盘提前量,一般设 5 到 10 分钟,给开奖源同步留缓冲。这个值配小了容易出事:用户在截止前最后一秒下单,事务提交时已经过了开奖时间,注单就变成了废单,退款流程又得单独写。
期号状态是个状态机:Open开售、Closed封盘、Drawn已开奖、Settled已结算。状态只能单向流转,不能回退。所有和期号有关的操作,第一件事都是校验状态,这一步漏了,后面全白搭。
3.2 出票下单:幂等、限额和余额冻结
下单是整个系统里并发最高、也最容易出问题的一步。要同时处理三件事:同一个客户端单号不能重复入账、金额不能超过限额、余额不能被并发扣成负数。
// BetService.PlaceBet:下单主流程 public BetResult PlaceBet(BetRequest req) { // 1. 幂等:同一个客户端单号只处理一次,重复请求直接返回原单 var exist = _betRepo.GetByClientNo(req.ClientNo); if (exist != null) return BetResult.Duplicate(exist.OrderNo); // 2. 期号状态与截止时间双重校验 var issue = _issueRepo.Get(req.LotteryCode, req.IssueNo); if (issue == null || issue.Status != IssueStatus.Open) return BetResult.Fail("期号未开售或已封盘"); if (DateTime.Now >= issue.CloseTime) return BetResult.Fail("已过截止时间"); // 3. 金额统一用 decimal,别碰 double var amount = req.Multiple * req.UnitPrice; if (amount <= 0 || amount > _limit.GetSingleLimit(req.AgentId)) return BetResult.Fail("金额超出限额"); using (var tran = _db.BeginTransaction()) { // 4. 条件更新扣余额,影响行数为 0 就是余额不够 var rows = _accountRepo.Freeze(req.AgentId, amount, tran); if (rows == 0) { tran.Rollback(); return BetResult.Fail("余额不足"); } var order = _betRepo.Insert(req, amount, tran); // 落注单 _flowRepo.Insert(req.AgentId, FlowType.Bet, -amount, order.OrderNo, tran); // 记流水 tran.Commit(); return BetResult.Ok(order.OrderNo); } }几个参数值得单独说。ClientNo是幂等的锚点,必须由调用方生成并且全局唯一,常见做法是「门店号 + 时间戳 + 随机尾数」。Multiple和UnitPrice相乘得到总金额,乘完立刻用decimal计算,中途出现一次double就会引入浮点误差,日终对账时能差出几毛钱,找起来极其难受。
第 4 步的Freeze是并发控制的要点,它不是先查余额再更新,而是一条带条件的 UPDATE,WHERE Available >= @amount。影响行数为 0 就说明这个账户的余额已经不够了,直接回滚。这种写法把判断和修改压在一条语句里,数据库的行锁会保证同一账户的并发请求排队执行,不需要在应用层加锁。
3.3 开奖结果落地与批量结算
结算由 Quartz 任务驱动,每期开奖之后跑一次。整个任务最关键的两个词:分批、幂等。
// SettleJob:开奖后的结算任务,单实例执行 [DisallowConcurrentExecution] // Quartz 层面防止本机并发 public class SettleJob : IJob { public void Execute(IJobExecutionContext ctx) { // 1. 一次只取一期,状态必须是已开奖未结算 var issue = _issueRepo.GetPendingSettle(); if (issue == null) return; var draw = _drawRepo.Get(issue.LotteryCode, issue.IssueNo); if (draw == null || string.IsNullOrEmpty(draw.WinNumbers)) { _log.Warn($"期号 {issue.IssueNo} 开奖号未落地,跳过"); return; } // 2. 分批取未结算注单,500 条一批,避免大事务拖垮库 var orders = _betRepo.GetUnsettled(issue.Id, 500); while (orders.Count > 0) { foreach (var o in orders) { var prize = _checker.Check(o, draw) // 玩法判定,返回中奖金额 ? o.Amount * o.OddsSnapshot : 0m; // 3. 条件更新只结算未结算的单,天然幂等 if (_betRepo.MarkSettled(o.Id, prize) == 0) continue; _accountRepo.Settle(o.AgentId, o.Amount, prize, o.OrderNo); } orders = _betRepo.GetUnsettled(issue.Id, 500); } _issueRepo.MarkSettled(issue.Id); } }DisallowConcurrentExecution只保证同一个调度器实例里不并发,多台机器一起跑还是会撞车。真正的幂等靠的是MarkSettled那条带WHERE Status = 0的 UPDATE,谁先更新成功谁算数,另一台影响行数为 0 直接跳过。这个双保险是我后来才加的,早期只有 Quartz 标注,部署成两台之后同一注单被派了两次奖,对账时才发现。
OddsSnapshot就是 2.3 节里强调的赔率快照。_checker.Check是玩法判定的入口,按彩种和玩法分派到具体的判定器,单注、复式、胆拖各走各的分支。这块代码量大但逻辑直白,真要改的话建议先补测试再动,不然后面很难验证。
4. 资金与对账模块:账户流水、代理分成和日终核对
订单系统里最容易出事的不是下单,是钱。下单错了顶多一单异常,账错了就是一片。这套源码在资金这块的设计思路是:账户表只存当前值,流水表记全部变动,任何时刻余额都能从流水推出来。听起来简单,实现里有几个点必须踩准。
4.1 账户模型:可用、冻结、在途三个字段
Account表只有三个金额字段:Available可用、Frozen冻结、InTransit在途。下单时可用减、冻结加;结算时冻结减,中了就加到可用,没中就直接扣掉。在途主要给提现和退款流程用。
-- 冻结:下单时扣可用、加冻结,条件更新防止扣成负数 UPDATE dbo.Account SET Available = Available - @amount, Frozen = Frozen + @amount, RowVersion = RowVersion + 1 WHERE AgentId = @agentId AND Available >= @amount; -- 结算:释放冻结,中奖金额进可用 UPDATE dbo.Account SET Frozen = Frozen - @betAmount, Available = Available + @prize, RowVersion = RowVersion + 1 WHERE AgentId = @agentId AND Frozen >= @betAmount;两条语句都带>=条件,这是整个资金模块的底线。少了它,一次并发就能把余额扣成负数,然后所有下游报表全错。RowVersion字段是为了给乐观锁留后路,目前主流程没用到,但二次开发如果要加跨表事务,它会很有用。
账户操作必须和流水在同一个事务里。流水表AccountFlow的字段设计是:AgentId、FlowType、Amount(带正负)、BalanceAfter、BizNo、CreateTime。BizNo存业务单号,出问题时拿它一查就能串起整条链路。BalanceAfter是冗余字段,写的时候多一步,查账的时候少一堆 JOIN,我认为值得。
4.2 代理分成与佣金结算的触发点
分成逻辑挂在结算之后,按Agent表里的层级往上追。每一层有自己的返点比例,返的是流水金额的百分比,不是中奖金额的百分比,这两者在业务上完全是两回事,代码里也分开算。
// CommissionService.Settle:注单结算后按层级返点 public void Settle(BetOrder order) { var agent = _agentRepo.Get(order.AgentId); var level = 0; // 最多往上追三级,防止脏数据造成死循环 while (agent != null && level < 3) { var rate = agent.CommissionRate; // 例如 0.008 表示千分之八 if (rate > 0) { var commission = Math.Round(order.Amount * rate, 2, MidpointRounding.AwayFromZero); if (commission > 0) { _accountRepo.Credit(agent.AgentId, commission, order.OrderNo); _flowRepo.Insert(agent.AgentId, FlowType.Commission, commission, order.OrderNo, tran: null); } } agent = agent.ParentId > 0 ? _agentRepo.Get(agent.ParentId) : null; level++; } }CommissionRate存在代理表里,不是硬编码。Math.Round的第三个参数必须显式写AwayFromZero,默认的银行家舍入会把 0.5 分抹平,几千单累积下来差额不小,日终对账会冒出来。三级上限是保护性设计,代理层级正常就两三级,脏数据里如果出现环,没有这个上限会一直转下去。
这个方法的tran参数传了null,意味着分成是独立事务。这样设计是有意的:分成失败不该影响主结算,主结算完成之后单独补跑分成也能对上。代价是分成可能失败而无人知晓,所以我在外层加了失败重试队列,后面第 6 章会讲到改造方式。
4.3 日终对账与差异定位
每天凌晨跑一次对账,核心就一句 SQL:账户当前余额应该等于流水汇总。
-- 日终对账:账户余额与流水汇总比对,差额超过 1 分就报警 SELECT a.AgentId, a.Available + a.Frozen + a.InTransit AS BalanceNow, ISNULL(SUM(f.Amount), 0) AS FlowSum FROM dbo.Account a LEFT JOIN dbo.AccountFlow f ON f.AgentId = a.AgentId AND f.CreateTime < @dayEnd GROUP BY a.AgentId, a.Available, a.Frozen, a.InTransit HAVING ABS((a.Available + a.Frozen + a.InTransit) - ISNULL(SUM(f.Amount), 0)) > 0.01;@dayEnd传当天 23:59:59,避免第二天凌晨的流水混进来。差额阈值设 0.01,是因为金额字段是decimal(18,2),理论上不会有比 1 分更小的差异,一旦出现就一定是逻辑问题,不是精度问题。
这条 SQL 查出来的是「哪几个账户对不上」,定位具体哪一笔还得往下钻。我的做法是拿BizNo去流水表反查,按时间排序看最后一笔的BalanceAfter是不是等于账户当前值,不等就说明中间有笔操作没写流水。最常见的原因是有人加了新的资金操作但忘了调用流水写入,这种问题只有对账能发现,日志里一点痕迹都没有。
5. 部署与排查:IIS、SQL Server 和定时任务的五个翻车现场
源码能编译不代表能跑,能跑不代表跑得对。这一章按部署顺序走一遍,再把几个高频问题摊开说。每个问题我都按「现象、原因、解决」写,照着对号入座基本能定位。
5.1 从空服务器到能登录的部署步骤
准备一台 Windows Server,装好 .NET Framework 4.6.1、SQL Server 和 IIS 的角色(ASP.NET、ISAPI 扩展、静态内容三项必须勾上)。剩下的步骤可以脚本化,我一般写成这样:
# 1. 建库并导入结构与基础数据,连接串按实际改 sqlcmd -S ".\SQLEXPRESS" -U sa -P "YourPassword" -i db\01_schema.sql sqlcmd -S ".\SQLEXPRESS" -U sa -P "YourPassword" -d LotteryAgent -i db\02_initdata.sql # 2. 还原 NuGet 包并编译,老工程走 packages.config nuget restore LotteryAgent.sln msbuild LotteryAgent.sln /p:Configuration=Release /p:Platform="Any CPU" # 3. 发布到站点目录,先停应用池再覆盖文件 Import-Module WebAdministration Stop-WebAppPool -Name "LotteryPool" robocopy src\Lottery.Web\bin C:\sites\lottery\bin /MIR robocopy src\Lottery.Admin\bin C:\sites\admin\bin /MIR Start-WebAppPool -Name "LotteryPool"覆盖文件前先停应用池,这一步别省。不停池直接覆盖 DLL,IIS 会锁文件,robocopy报拒绝访问,看起来像权限问题,其实只是文件被占用。/MIR会镜像删除目标目录里多余的旧文件,第一次发布可以加,日常更新建议换成/E,免得把配置目录里手工改过的东西删了。
数据库连接串、外部开奖源地址这类配置放在config/appSettings.config,不要写死在代码里。定时任务宿主Lottery.Jobs.exe用 Windows 计划任务或服务的方式拉起,账号建议用独立的应用账号,别用 Administrator,出问题的时候审计日志能看出是谁动的手。
5.2 常见问题排查:五条现象、原因和解决
现象一:登录成功之后立刻跳回登录页。原因是 Forms 认证的 Cookie 域和站点实际访问域名不一致,或者多个站点共用一个应用池、machineKey不同步导致 Cookie 解不开。解决办法是在两个站点的 Web.config 里显式配置相同的machineKey,并把forms节点的domain留空,让它跟着当前域名走。多站点共用一个池的时候这个问题必现,我吃过一次亏,排查了两小时才发现是密钥问题。
现象二:同一期的注单派了两次奖,账户余额虚高。原因是定时任务被部署在了两台机器上,Quartz 的[DisallowConcurrentExecution]只管单机。解决是三层:结算日前用WHERE Status = 0的条件更新做幂等,任务宿主只留一台实例,再加一把数据库级的应用锁做兜底。三层里任意一层都能挡住,但只靠一层都不踏实。
现象三:注单金额和对账差几分钱。原因是代码里某处把decimal转成了double做比较,或者在Math.Round时用了默认的银行家舍入。解决是全链路统一decimal(18,2),舍入显式指定MidpointRounding.AwayFromZero,下单金额在写库前做一次Math.Round(x, 2)收敛。这种问题最难查,因为单笔误差极小,只有累积到日终才会显形。
现象四:开奖号一直不落地,期号卡在已开奖状态。原因是外部开奖源页面结构变了,或者抓取时编码用错,中文页面用 UTF-8 解析出现乱码,正则匹配不到。解决是把抓取逻辑做成适配器,每个源一个实现类,失败时降级到后台手工录入开奖号。手工录入入口一定要做,不要指望外部源永远稳定,这是这类系统唯一可靠的后悔药。
现象五:晚上出票高峰,下单接口大面积超时。原因是BetOrder表缺索引,每次幂等查询都在全表扫,或者下单事务里塞了太多非必要操作,锁持有的时间过长。解决是给ClientNo、(LotteryCode, IssueNo, Status)建复合索引,事务里只保留扣余额、写注单、写流水三步,日志、消息推送这类操作全部挪到事务外。
6. 二次开发怎么下手:把结算从定时任务里抽出来单独测
改这套系统,我建议从结算模块开始,原因是它是整个链路里业务最重、也最难验证的一段。定时任务里的代码天然不好测:你得等一期开奖、造一批注单、再盯着数据库看结果。改造方向就一个,把结算逻辑从IJob里抽出来,让它不依赖调度器、不依赖外部开奖源。
// 结算逻辑独立成服务,入参是开奖结果,出参是结算报告 public interface ISettlementService { SettleReport Settle(string lotteryCode, string issueNo, DrawResult draw); } // 外部开奖源抽象成接口,线上抓取和本地回放各一个实现 public interface IDrawSource { DrawResult Fetch(string lotteryCode, string issueNo); } // 本地 JSON 回放源,单元测试和压测都靠它 public class ReplayDrawSource : IDrawSource { private readonly string _dir; public ReplayDrawSource(string dir) => _dir = dir; public DrawResult Fetch(string lotteryCode, string issueNo) { var file = Path.Combine(_dir, $"{lotteryCode}_{issueNo}.json"); return File.Exists(file) ? JsonConvert.DeserializeObject<DrawResult>(File.ReadAllText(file)) : null; } }抽出ISettlementService之后,单元测试就变成纯内存的事:预置一批注单,喂一个开奖结果,断言中奖金额、账户变动、流水条数。ReplayDrawSource让开奖数据可以离线回放,压测的时候直接灌几千条历史数据进去跑,比在线上等真实开奖快得多。
上线前还有一步别省:影子对账。新旧两套结算逻辑同时跑,新逻辑只写日志不写库,跑满一天之后比对两边的派奖总额和注单状态,差异为零再切。我第二次改结算就是这么做的,第一次没做,切完之后发现某类复式玩法的判定顺序和老逻辑不一致,差点造成一批注单少派。
从那以后我每次动结算相关的代码,都强制自己先把回放数据和影子对账跑一遍,哪怕只是改了一个舍入参数。这套流程多花半小时,换的是不用半夜爬起来查账。希望帮到你。
本文还有配套的精品资源,点击获取