简介BiliRaffle 是一款面向 B 站动态抽奖场景的 C# 桌面组件适合熟悉 .NET Framework 4.5 及以上、希望研究抽奖工具实现或进行二次开发的开发者与 UP 主参考。项目采用 WPF 与 MVVM 分层组织包含登录窗口、主窗口、抽奖逻辑、视图模型、命令、样式和转换器等模块可帮助读者理解动态数据获取、抽奖条件筛选、结果展示及界面交互的基本流程也能观察接口调用与本地业务逻辑之间的组织方式。压缩包共 32 个文件、约 60KB以 12 个 C# 源码和 4 个 XAML 界面文件为主另含配置文件、动态链接库、解决方案与工程文件、说明文档及许可协议便于在 Visual Studio 中打开编译、按模块阅读并对照配置与依赖排查运行问题。已有 697 人学习下载适合需要拆解 WPF 界面、MVVM 命令绑定、抽奖业务逻辑与轻量桌面工具工程结构的中级 C# 开发者参考。1. 一次转发抽奖两千条转发里的手工操作问题社区账号做一次动态转发抽奖开奖前运营同学把两千多条转发昵称一条条复制进表格中途发现同一个人用小号刷了十几条还有人抽完就删掉了转发最后名单对不上只能重抽。这类活儿交给手工做出错是迟早的事。BiliRaffle 就是针对这个场景一个用 C# 写的 B站动态抽奖组件走 bilibili-api 拉取指定动态的转发列表做去重、过滤、权重计算再用可复现的随机数开奖并导出结果。它面向的不是零基础围观者而是有账号运营需求、想自己控制抽奖规则并留下可核对记录的开发者属于典型的 bilibili-tool 小型工程。2. 架构拆解从动态抓取到开奖落地的四层结构这个项目体量不大但分层做得比较干净好处是每一层都能单独替换。整条链路是「拿数据 → 洗数据 → 算结果 → 出凭证」对应到代码里就是四个职责边界清晰的部分。先看整体结构再往下钻到目录和数据模型。2.1 分层设计Api、Service、Scheduler、ExportApi 层只干一件事发 HTTP 请求、解 JSON、把 B站返回的code翻成异常。这一层不关心抽奖规则也不关心数据要不要去重。Service 层是核心负责参与者聚合、过滤、权重和开奖算法全部是纯内存计算输入输出都是 POCO所以单元测试非常好写。Scheduler 层负责把一次抽奖拆成「定时触发 → 拉取 → 截止 → 开奖」的时序抽奖活动通常有截止时间不能让它在拉取中途就把人算进去。Export 层负责落地成 CSV / JSON / 图片给运营留一条能发出去的凭证。这么分的好处很直接接口路径变了只改 Api 层抽奖规则变了只改 Service 层想接定时任务就换个 Scheduler 实现。常见的坑是把 HTTP 请求和业务判断揉在一个类里等接口一改版整个文件都得动。bilibili-raffle 这类工具迭代快接口和风控策略变动频繁分层是省未来时间的做法。依赖注入的注册集中在一个ServiceCollectionExtension里宿主可以是控制台也可以套一层 ASP.NET Core 做 Web 界面public static class ServiceCollectionExtension { public static IServiceCollection AddRaffleCore(this IServiceCollection services, IConfiguration config) { // 单例持有 Cookie 与限流器避免每次请求重建 services.AddSingletonCookieStore(); services.AddSingletonRequestThrottler(); services.AddHttpClientDynamicApiClient(client { client.Timeout TimeSpan.FromSeconds(15); client.DefaultRequestHeaders.Referer new Uri(https://www.bilibili.com/); }); services.ConfigureRaffleOptions(config.GetSection(Raffle)); services.AddTransientIParticipantCollector, ParticipantCollector(); services.AddTransientIWinnerSelector, WeightedWinnerSelector(); services.AddTransientIResultExporter, CsvResultExporter(); // 需要后台定时跑就打开这一行 // services.AddHostedServiceRaffleHostedService(); return services; } }上面这段的要点在生命周期选择CookieStore和RequestThrottler必须是单例请求频率控制要靠全局状态才有效IWinnerSelector用瞬态因为它持有的是每次开奖的临时上下文。AddHttpClientT是为了让HttpClient的 DNS 轮换和连接池交给框架管手动new HttpClient()在高频请求下容易耗尽 socket这是个老生常谈但依然常见的翻车点。2.2 目录结构与工程组织仓库按职责分目录不是按文件类型分找代码时按「我要改哪一环」去定位比按「这是接口还是模型」找快得多。BiliRaffle/ ├── BiliRaffle.sln ├── src/ │ ├── BiliRaffle.Api/ # 接口封装层 │ │ ├── DynamicApiClient.cs # 动态详情、转发列表 │ │ ├── CookieStore.cs # 凭证读写与失效检测 │ │ ├── RequestThrottler.cs # 全局令牌桶限速 │ │ └── Models/ # ApiEnvelope、ForwardPage 等 │ ├── BiliRaffle.Core/ # 业务核心无 HTTP 依赖 │ │ ├── ParticipantCollector.cs # 聚合 去重 │ │ ├── Filters/ # IUserFilter 的若干实现 │ │ ├── Selectors/ # IWinnerSelector 实现 │ │ └── Models/ # Participant、RaffleRule │ ├── BiliRaffle.Console/ # 控制台宿主入口在这 │ └── BiliRaffle.Export/ # CSV / JSON 导出 └── tests/ └── BiliRaffle.Core.Tests/ # 纯内存测试不联网BiliRaffle.Core不引HttpClient也不引任何网络包这条约束很关键只要 Core 保持纯净就能把线上真实抓到的一份转发 JSON 存成测试夹具跑回归时完全不碰网络。反过来如果把 Core 和 Api 混在一起测试就得真连接口跑一次掉一次最后大家干脆不写测试了。2.3 数据模型Participant 与 RaffleRule模型设计上有一个决定影响很大参与者到底按「转发条目」还是按「用户」算。社区里见过不少抽奖工具按条目抽结果同一个人刷了二十条中奖概率直接翻二十倍。这个项目的做法是先聚合成用户粒度再用权重表达「多条转发」的差异抽奖永远在用户集合上进行。// 参与者以 mid 为唯一键转发条目折叠到 Entries 里 public sealed record Participant { public required long Mid { get; init; } public required string Nickname { get; init; } public int Level { get; init; } // 账号等级用于权重 public DateTimeOffset FirstForwardAt { get; init; } public ListForwardEntry Entries { get; init; } new(); public double Weight { get; set; } 1.0; // 由规则引擎计算 } // 单条转发记录保留原样以便复查 public sealed record ForwardEntry { public required long DynamicId { get; init; } // 这条转发本身的动态 id public DateTimeOffset PublishTime { get; init; } public string? Content { get; init; } // 转发附言 public bool HasImage { get; init; } } public sealed record RaffleRule { public int MinLevel { get; init; } 0; public DateTimeOffset Deadline { get; init; } // 截止时间晚于它的转发不算 public bool RequireOriginalContent { get; init; } false; // 要求带附言 public HashSetlong Blacklist { get; init; } new(); public int WinnerCount { get; init; } 1; public string? Seed { get; init; } // 为空则随机生成并记录 }Mid是 B站用户唯一 id用它做键最稳昵称会改、会重名不能做唯一标识。Entries保留原始转发记录是为了事后复查有人质疑「我明明转了为什么没中」把这条记录调出来就能看到他的转发时间是不是晚于截止时间、是不是被过滤规则命中。Seed字段是整个可复现设计的入口后面第 4 章会展开。Weight用double而不是整数是为了给未来的连续型权重留空间比如按粉丝牌等级做梯度加权。3. 环境搭建与跑通第一条抽奖任务把项目克隆下来到第一次出结果中间真正要动手改的只有两处运行时环境和凭证配置。剩下的走默认值就能跑。下面按实际动手顺序写。3.1 运行时与依赖准备项目是 .NET 工程SDK 版本以global.json和 csproj 里的TargetFramework为准常见的做法是 6.0 或 8.0 的 LTS 版本。先确认本机装了对应 SDK# 查看已安装的 SDK 列表确认目标版本在里面 dotnet --list-sdks # 还原依赖并编译第一次会拉 NuGet 包 dotnet restore BiliRaffle.sln dotnet build BiliRaffle.sln -c Release几条血泪经验一dotnet build报找不到net6.0目标框架九成是只装了 runtime 没装 SDK--list-sdks的输出是空的二公司网络下 NuGet 源被拦restore会卡很久然后超时这时改NuGet.config指向可用源三Release 编译出来的产物在src/BiliRaffle.Console/bin/Release/net6.0/配置文件要一起拷过去否则运行时报配置节点缺失。依赖本身不复杂主要是Microsoft.Extensions.Http、Microsoft.Extensions.Configuration.Json和一个 JSON 序列化库。项目用System.Text.Json就够没必要为了几个字段引重量级库。3.2 凭证配置与登录态维持B站的转发列表接口在未登录状态下能拿到一部分数据但字段会缺失频繁访问还更容易被风控拦。要稳定拉全量需要带上登录 Cookie。常见的三个字段是SESSDATA、bili_jct、DedeUserID从浏览器开发者工具里复制。凭证不要写死在代码里也不要提交进仓库。推荐的做法是放在环境变量或者被.gitignore排除的本地配置文件中public sealed class CookieStore { private readonly IConfiguration _config; public CookieStore(IConfiguration config) _config config; // 优先读环境变量其次读本地配置方便 CI 与本地开发共存 public string ToCookieHeader() { var sess Environment.GetEnvironmentVariable(BILI_SESSDATA) ?? _config[Bili:SessData]; var csrf Environment.GetEnvironmentVariable(BILI_BILI_JCT) ?? _config[Bili:BiliJct]; var uid Environment.GetEnvironmentVariable(BILI_DEDE_USER_ID) ?? _config[Bili:DedeUserId]; if (string.IsNullOrWhiteSpace(sess)) throw new InvalidOperationException(缺少 SESSDATA无法进行登录态请求); return $SESSDATA{sess}; bili_jct{csrf}; DedeUserID{uid}; } }ToCookieHeader()每次调用都重新拼字符串看着有点浪费但换来的是配置热更新时不重启进程也能生效调试阶段很方便。生产上更稳妥的是加一层内存缓存加过期时间二十分钟刷一次。bili_jct在只读场景下其实用不到它主要给写操作做 CSRF 校验留着是为了后续扩展。3.3 配置文件的字段含义appsettings.json里要改的就那么几项把含义搞清楚比反复试错快字段类型说明常见误用Raffle:DynamicIdlong目标动态的 id从动态链接里取误填成用户 uidRaffle:DeadlineISO8601截止时间必须带时区只写日期漏时区差 8 小时Raffle:MinLevelint账号等级下限0 表示不限填成粉丝牌等级Raffle:RequireOriginalContentbool是否要求转发带附言与「带图」混淆Raffle:WinnerCountint中奖人数大于参与者数时行为未定义Raffle:PageSizeint每页拉取条数调太大触发风控Raffle:Seedstring随机种子留空自动生成手填后忘了记录Export:OutputPathstring结果输出目录相对路径在服务里跑会跑偏Deadline和动态 id 是两个最容易填错的项。动态 id 是一串十九位左右的数字用户 uid 通常短得多Deadline建议直接写带偏移量的形式比如2024-06-01T23:59:5908:00如果写成本地时间又不带时区序列化出来会按 UTC 解释等于提前或延后八小时截止这类时间计算问题排查起来很费劲。3.4 第一次运行与结果验证配置就绪后直接跑控制台入口一次完整流程是「拉取 → 聚合 → 过滤 → 开奖 → 导出」cd src/BiliRaffle.Console dotnet run -c Release -- --dynamic-id 123456789012345678 --dry-run--dry-run表示只拉取和过滤、不开奖适合第一次验证接口通不通。跑通之后再把开关去掉。输出目录里会生成三类文件participants.csv是全量参与者filtered.log记录每条被过滤的原因winner.json是中奖名单和当次使用的种子。拿到中奖名单后我一般会抽查三个人去动态下面翻他们的转发时间确认都在Deadline之前然后再看filtered.log里过滤量是否合理——如果过滤掉了九成的人八成是MinLevel或RequireOriginalContent设得太严。4. 核心模块代码抓取、去重、过滤、开奖四段链路这一章是项目里真正有价值的部分四段链路串起来就是一次完整抽奖。每段都能单独摘出去复用到别的 bilibili-tool 场景比如做评论抽奖、专栏抽奖只需要换 Api 层。4.1 分页拉取转发列表与游标处理转发列表是分页接口参数是动态 id 加上一个偏移量。这个接口的路径和字段名会随平台改版变化实际开发时以浏览器开发者工具抓到的为准代码里抽成常量方便改public async TaskForwardPage GetForwardsAsync(long dynamicId, long offset, CancellationToken ct) { // 路径与参数名以实际抓包为准改版时只需要动这一处 var url $https://api.bilibili.com/x/polymer/web-dynamic/v1/detail/forward $?dynamic_id{dynamicId}offset{offset}type1; using var req new HttpRequestMessage(HttpMethod.Get, url); req.Headers.TryAddWithoutValidation(Cookie, _cookies.ToCookieHeader()); await _throttler.WaitAsync(ct); // 全局令牌桶避免并发过高 using var resp await _http.SendAsync(req, ct); var json await resp.Content.ReadAsStringAsync(ct); var envelope JsonSerializer.DeserializeApiEnvelopeForwardPage(json); if (envelope is null) throw new BiliApiException(-1, 响应为空); if (envelope.Code ! 0) throw new BiliApiException(envelope.Code, envelope.Message); return envelope.Data!; }外层循环用偏移量推进但要处理两个现实问题一是接口可能重复返回同一条转发二是总条数会在拉取过程中变化有人删了转发。做法是每页都按DynamicId去重塞进字典循环终止条件用「本页返回条数为 0」或者「偏移量超过总条数」两个条件中的任一个而不是只看总数public async TaskIReadOnlyListForwardEntry CollectAllAsync(long dynamicId, int pageSize, CancellationToken ct) { var bag new Dictionarylong, ForwardEntry(); long offset 0; while (true) { var page await _api.GetForwardsAsync(dynamicId, offset, ct); if (page.Items.Count 0) break; foreach (var item in page.Items) bag.TryAdd(item.DynamicId, item); // 重复项直接丢弃 offset page.Items.Count; if (page.HasMore is false) break; await Task.Delay(TimeSpan.FromMilliseconds(300), ct); // 主动降速 } return bag.Values.ToList(); }pageSize不建议调大常见的默认值在 20 到 50 之间调大只会更快撞上频率限制。Task.Delay那 300 毫秒看着保守但换成更激进的间隔后跑到几百页就开始零星返回风控错误得不偿失。字典的TryAdd是有意为之用索引器赋值会覆盖虽然内容一样但保留首次出现的版本更符合「以第一次看到为准」的直觉。4.2 参与者去重与过滤链拿到原始条目之后先聚合成用户再跑过滤链。过滤用接口加多个实现的组合模式每个实现只判断一件事配置里能自由开关public interface IUserFilter { string Name { get; } bool IsMatch(Participant user, RaffleRule rule, out string reason); } // 截止时间过滤晚于 Deadline 的转发条目不计入 public sealed class DeadlineFilter : IUserFilter { public string Name deadline; public bool IsMatch(Participant user, RaffleRule rule, out string reason) { reason string.Empty; if (user.FirstForwardAt rule.Deadline) return false; reason $首条转发时间 {user.FirstForwardAt:O} 晚于截止时间 {rule.Deadline:O}; return true; } } // 黑名单过滤mid 命中即剔除 public sealed class BlacklistFilter : IUserFilter { public string Name blacklist; public bool IsMatch(Participant user, RaffleRule rule, out string reason) { var hit rule.Blacklist.Contains(user.Mid); reason hit ? $mid {user.Mid} 在黑名单中 : string.Empty; return hit; } }IsMatch返回布尔的同时用out string reason带出原因是为了让filtered.log能写清楚每个人为什么被剔除。别小看这一点抽奖这种场景天然带着争议一份能说清原因的日志比结果本身还重要。过滤器的执行顺序按配置数组顺序走第一个命中的就短路返回所以把开销小、命中率高的黑名单放前面。聚合逻辑按Mid分组顺带把该用户所有转发里最早的时间记进FirstForwardAt这样「截止前转发过、截止后又转发」的用户能正确判定为有效var participants entries .GroupBy(e e.AuthorMid) .Select(g new Participant { Mid g.Key, Nickname g.First().AuthorName, Level g.Max(e e.AuthorLevel), FirstForwardAt g.Min(e e.PublishTime), Entries g.ToList() }) .Where(p filters.All(f !f.IsMatch(p, rule, out _))) .ToList();filters.All(...)这种写法会把out参数丢掉实际代码里建议换成显式 for 循环把原因写进日志别为了函数式写法牺牲可观测性。4.3 权重规则的计算方式权重表达的是「同一个用户多转发几条中奖概率高一点但不至于线性翻倍」。常见的做法是主权重 1.0附加条件按小幅度加成public sealed class WeightCalculator { public void Apply(IEnumerableParticipant users, RaffleRule rule) { foreach (var u in users) { double w 1.0; // 多条转发最多加 0.5防止刷屏者一家独大 w Math.Min(u.Entries.Count - 1, 5) * 0.1; // 带附言加 0.2说明是真看了内容 if (u.Entries.Any(e !string.IsNullOrWhiteSpace(e.Content))) w 0.2; // 高等级账号小幅加成 if (u.Level 5) w 0.1; u.Weight Math.Round(w, 2); } } }这里的数字都不是硬性标准是反复调出来的经验值给多条转发的加成必须有上限否则同一批小号刷几十条就能把权重拉到十以上等于默认允许刷票。Math.Min(..., 5) * 0.1这一行的含义是「最多算五条」第六条之后的转发不再增加权重。如果活动明确禁止重复转发直接把这段删掉把所有Weight固定成 1.0走等概率开奖逻辑更简单也更难被质疑。4.4 用固定种子做可复现的开奖开奖环节最关键的不是随机性是可复现。种子由动态 id 和截止时间派生任何人拿到这两项加上参与者名单都能复算出同一个结果public IReadOnlyListParticipant Select( IReadOnlyListParticipant users, RaffleRule rule, out string usedSeed) { // 先按 mid 排序保证列表顺序稳定否则同样的种子结果也会不一样 var pool users.OrderBy(u u.Mid).ToList(); usedSeed string.IsNullOrWhiteSpace(rule.Seed) ? ${rule.Deadline:yyyyMMddHHmmss}-{pool.Count} : rule.Seed; var rng new Random(StableHash(usedSeed)); // 跨平台稳定的哈希别用 string.GetHashCode var winners new ListParticipant(rule.WinnerCount); // 加权抽样按权重累加区间取随机点抽中后移除该用户 var remaining pool.Select(u (User: u, Weight: u.Weight)).ToList(); for (int i 0; i rule.WinnerCount remaining.Count 0; i) { var total remaining.Sum(x x.Weight); var point rng.NextDouble() * total; double acc 0; int hit remaining.Count - 1; for (int j 0; j remaining.Count; j) { acc remaining[j].Weight; if (point acc) { hit j; break; } } winners.Add(remaining[hit].User); remaining.RemoveAt(hit); // 移除保证一人最多中一次 } return winners; }三个细节值得说。OrderBy(u u.Mid)那行不能省Random对同一个种子产生同样的序列但抽样的结果依赖列表顺序顺序一变结果就变很多人就是在这一步踩坑明明种子一样结果对不上。StableHash要自己实现比如 FNV-1a 或 SHA256 取前四字节不能直接用string.GetHashCode()后者在不同运行时、不同进程之间可能不一致那就彻底失去复现能力了。最后remaining.RemoveAt(hit)是保证不重复中奖如果活动允许多人重复中奖改成不删除即可。5. 常见问题排查接口风控、Cookie 失效、结果对不上跑这类工具踩的坑高度集中把它整理成「现象 → 原因 → 解决」的形式遇到时能直接对上号。5.1 接口层风控报错与 Cookie 失效现象跑到一百多页时开始返回-352或-412之后连续失败隔一段时间又好了。原因典型的频率风控。不是代码逻辑问题是请求特征太像爬虫并发高、间隔固定、UA 缺失或者过于陈旧。解决把请求并发降到 1Task.Delay的间隔加到 500 毫秒以上并在区间内随机浮动补齐User-Agent和Referer请求头。Referer特别容易被漏掉很多人只加 UA 不加 Referer一样会被拦。同时把失败重试做成指数退避连续失败三次就停下来别硬刚。现象前一天跑得好好的第二天开始所有请求都返回未登录的数据字段大面积缺失。原因SESSDATA过期了。这类凭证有有效期长期不登录会被清掉。解决加一个启动时的凭证自检请求一个需要登录态的轻量接口返回未登录就提前报错退出别让流程跑到一半才暴露。凭证刷新频率看使用强度一周换一次是常见的做法。5.2 数据层条数对不上与时间错位现象动态页面上显示有两千多条转发接口只拉回来一千八百多条。原因被删的转发、被屏蔽的账号、折叠内容都不在接口返回里另外偏移量分页在拉取过程中如果列表发生变化会有轻微跳跃。解决接受这个差额但在导出文件里记录「页面显示总数」和「实际拉取数」两者差距超过 10% 就在日志里告警。真要补齐可以按时间窗口做二次扫描不过收益不大抽奖场景下这点遗漏不影响公平性。现象有人明明在截止前就转了却被判定为超时。原因接口返回的时间是秒级时间戳转换时用了服务器本地时区而服务器是 UTC导致整体偏移八小时。解决全程用DateTimeOffset转换时显式指定08:00偏移配置里的Deadline也强制带偏移量。项目里加一条单测把几个已知时间戳硬编码进去断言改代码时能立刻发现回归。5.3 结果层复算不一致与权重异常现象换台机器用同一个种子重跑中奖名单不一样。原因大概率是参与者列表顺序不稳定比如用了HashSet或者并行加载或者是种子派生的哈希函数不稳定。解决抽样前强制排序种子哈希用自己实现的稳定算法导出文件里把最终参与者列表的哈希值也写进去任何人都能核对输入是否一致。现象某个人权重算出来是 0 或者特别大。原因Entries.Count为 0 时Math.Min(count - 1, 5)得到 -1权重被减到 0.9 以下或者重复转发的上限逻辑被改动了。解决在WeightCalculator里对Entries.Count做下限保护小于 1 的直接跳过权重计算之后加一条断言落库前检查所有参与者权重都落在[1.0, 2.0]这个预期区间内超出就抛异常。这种「看起来不可能的输入」在生产里一定会出现。6. 二次开发把开奖规则换成你自己的业务这个项目最容易改的地方是开奖策略因为IWinnerSelector已经被抽成接口换实现不用动调用方。比如要做「多奖池」——一等奖一个、二等奖三个、参与奖二十个各自规则不同写一个MultiTierSelector按奖池循环调用加权抽样就行注意每个奖池要重新从剩余池里取避免一个人拿两个奖。再比如要接直播间弹幕开奖把IParticipantCollector换成弹幕采集实现即可Participant模型的Mid字段对应弹幕发送者 uid其余流程原样复用。还有一种改法值得考虑把抽奖规则从配置文件挪到数据库做成「活动」表加「规则」表一条动态一个活动这样可以同时管理多个正在进行的抽奖。改动量集中在RaffleRule的加载方式上Select、Apply这些纯函数完全不用动这也是前面强调 Core 层不依赖 HTTP 的好处。验证改造是否正确的顺序我习惯这么走先写一个不联网的测试造一百个Participant权重全设成 1.0跑一千次开奖统计每人中奖频次应该收敛到接近均等再把权重设成有明显差异检查频次比例是否跟权重比例吻合最后用真实数据跑一次--dry-run比对参与者数量和过滤日志。这三步跑完基本能排除掉九成的逻辑错误。改造点需要动的文件验证方式换开奖算法Selectors/*.cs等权重频次分布测试换数据源IParticipantCollector实现单条已知数据走通全流程加过滤条件Filters/*.cs检查filtered.log命中原因多活动管理规则加载部分两个活动并行跑结果互不污染新增过滤器的代码骨架很短照着已有的写就行// 示例只保留粉丝牌等级不低于 10 的用户 public sealed class FansMedalFilter : IUserFilter { private readonly int _minLevel; public FansMedalFilter(int minLevel) _minLevel minLevel; public string Name fans-medal; public bool IsMatch(Participant user, RaffleRule rule, out string reason) { // 粉丝牌信息在转发接口里通常拿不到需要用 mid 单独查注意缓存 var level _medalCache.GetLevel(user.Mid); reason level _minLevel ? $粉丝牌等级 {level} 低于 {_minLevel} : string.Empty; return level _minLevel; } }这个过滤器要注意成本每个用户一次额外请求两千人就是两千次必须加内存缓存加批量查询否则跑一轮够吃一壶。我一开始图省事直接逐个查跑到一半就被限流了。从那以后我每次改完开奖逻辑都会先造一百个假用户跑一千次频次统计确认分布没问题再接真实数据——这个习惯帮我省掉过至少两次需要公开道歉的重抽。希望帮到你。本文还有配套的精品资源点击获取