简介:BiliRaffle 是一款面向 B 站动态抽奖场景的 C# 桌面工具组件,适合希望了解或二次开发抽奖流程的开发者与进阶用户。它围绕动态抽奖的登录、界面交互与抽奖逻辑组织代码,采用 .NET Framework 4.5 及以上环境,可配合相关 Bilibili API 依赖使用,帮助读者快速理解抽奖工具的结构与实现方式。压缩包共 32 个文件,约 60KB,以 12 个 cs 源码和 4 个 xaml 界面文件为主,并包含 config、xml 配置、dll 依赖、png/ico 图标及 sln/csproj 工程文件,便于直接查看工程组织与界面资源。已有 697 人学习,适合用作 C# WPF/MVVM 小工具实践参考;通过登录窗口、主界面、抽奖逻辑、视图模型、命令与转换器等模块,可借鉴界面分层、配置读取、资源管理与基础抽奖流程设计,也可在此基础上进行功能扩展与调试。
1. 从一次手动翻转到 BiliRaffle:B站动态抽奖组件到底省了哪几步
动态转发刚过千,评论区一片「蹲」,到了开奖时间你还在往下翻列表,翻到第三十页才发现有人被数了两遍。这是自己办过一次 B站动态抽奖之后最容易踩坑的地方。BiliRaffle 这个 bilibili-raffle 组件把这条链路做成了可复用的 C# 工程:登录态由 bilibili-api 那一层统一维护,互动用户按分页抓全,资格规则逐条过筛,最后用带种子的随机算法抽出中奖名单并落盘成可公示的快照。它不适合想找「一键涨粉」的人,适合要定期办活动、需要结果可复核的 UP 主运营、社区管理员,以及想拿它当 bilibili-tool 二次开发底座的 C# 开发者。
2. 工程骨架与技术选型:C# 侧把抽奖拆成四个工程
抽奖这件事看着简单,难的是出错之后能查。一个数字对不上,你得知道是数据没抓全、规则筛错了人,还是随机算法本身有问题。BiliRaffle 在工程结构上做的第一件事,就是把「说话」「判规则」「摇号」「出结果」拆成互不越界的四段,任何一段出问题都能单独拿输入输出对拍。这一章的目录结构、依赖选型和配置项,都是围绕这个目标展开的。
2.1 目录结构:四个工程各管一段
BiliRaffle/ ├── src/ │ ├── BiliRaffle.Api/ # 请求封装、WBI 签名、限速、Cookie 维护 │ ├── BiliRaffle.Core/ # 领域模型、规则链、抽奖算法、快照 │ ├── BiliRaffle.Cli/ # 命令行入口,一次开奖一个命令 │ └── BiliRaffle.Export/ # CSV / JSON / 公示图输出 ├── config/ │ └── appsettings.json ├── tests/ │ └── BiliRaffle.Core.Tests/ └── BiliRaffle.slnBiliRaffle.Api只认 URL 和参数,返回原始 JSON 或反序列化后的 DTO,不认识「中奖」这个概念。BiliRaffle.Core是纯逻辑层,输入是参与者列表和规则配置,输出是中奖名单,不依赖网络,因此可以直接写单元测试。BiliRaffle.Cli负责把配置读进来、把四层串起来、把过程日志打到控制台。BiliRaffle.Export单独拎出来是因为公示格式经常改——今天导 CSV,明天要带中奖截图,后天要生成一张公示图,把它塞在 Core 里会让核心逻辑跟着一起动。
这种拆法还有一个隐性好处:如果你后面想做成 Web 服务,只需要新增一个BiliRaffle.Web工程引用 Core,把 Cli 里的编排逻辑搬过去,Api 和 Core 一行都不用改。常见做法是把编排抽成一个DrawOrchestrator类放在 Core 里,Cli 和 Web 都调它。
2.2 技术选型:为什么是 .NET 8 + IHttpClientFactory + Polly
| 关注点 | 选型 | 选择理由 |
|---|---|---|
| 运行时 | .NET 8 | 长期支持版本,System.Text.Json源生成器能省掉反射开销 |
| HTTP | IHttpClientFactory+ 命名客户端 | 统一管理 Cookie 容器、超时、默认请求头,避免 Socket 耗尽 |
| 重试限流 | Polly | 429/412 需要退避,写死在while里迟早失控 |
| 配置 | Microsoft.Extensions.Configuration | 支持 appsettings.json + 环境变量 + 命令行三层覆盖 |
| 依赖注入 | Microsoft.Extensions.DependencyInjection | 规则链要靠它做插件注册,见第 6 章 |
| 导出 | CsvHelper | 字段含逗号、换行时不用自己写转义 |
| 测试 | xUnit | 种子可复现的断言写起来最直接 |
关于 bilibili-api 这一层,有两种常见做法:一是直接引用社区维护的 C# 封装包,二是自己用HttpClient封一层薄客户端。BiliRaffle 走的是后一条路,原因很实际——抽奖场景用到的接口就那么几个(动态详情、转发/评论列表、用户信息、关注关系),自己封一层反而能精确控制签名参数顺序和重试节奏,出问题时也不必等上游发版。如果你更看重省事,引用现成的 bilibili-api 包也完全可行,把BiliRaffle.Api换成一个适配器实现即可。
IHttpClientFactory的注册我一般这么写:
// Program.cs builder.Services.AddHttpClient("bili", client => { client.BaseAddress = new Uri("https://api.bilibili.com"); client.DefaultRequestHeaders.UserAgent.ParseAdd( "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/122.0 Safari/537.36"); client.DefaultRequestHeaders.Referrer = new Uri("https://www.bilibili.com/"); client.Timeout = TimeSpan.FromSeconds(15); }) .AddPolicyHandler(Policy<HttpResponseMessage> .Handle<HttpRequestException>() .OrResult(r => (int)r.StatusCode is 412 or 429) .WaitAndRetryAsync(3, i => TimeSpan.FromSeconds(Math.Pow(2, i)))); // 2s / 4s / 8s这里的Referrer和User-Agent不是可选项,缺了任何一个都可能直接被拦。重试策略只针对 412 和 429 这两类明确表示「你太快了」的状态码做指数退避,其余 4xx 一律不重试——参数错了重试一百次还是错,只会把风控额度耗光。超时给 15 秒是因为转发列表接口在动态特别热的时候确实会慢,给 5 秒会误杀正常请求。
2.3 配置先行:一份 appsettings.json 决定开奖行为
所有会影响结果的参数都必须能落到文件里,这是「可复核」的前提。配置结构大致长这样:
{ "Bili": { "Cookie": "SESSDATA=xxx; bili_jct=xxx; buvid3=xxx;", "RequestDelayMs": 800, "MaxPages": 200 }, "Draw": { "DynamicId": 112233445566, "WinnerCount": 10, "Seed": "spring-event-2024", "ExcludeUpOwner": true }, "Rules": { "RequireRepost": true, "RequireFollow": true, "MinLevel": 2, "ExcludeUids": [ 10001, 10002 ], "CommentKeyword": null } }几个参数值得单独说。Seed是抽奖的种子,一旦定下来就不要改,公示时连同种子一起公开,任何人都能拿同样的参与者列表复算出同样的名单,这是消除「内定」质疑最有效的手段。RequestDelayMs默认 800 毫秒,别为了快改成 100——转发上万条的动态,你把间隔压到 100 毫秒,请求大概率会在第 5 页左右被拦下来,前面抓的全白费。MaxPages是个保险丝,防止游标逻辑写错之后无限循环,200 页按每页 30 条算是 6000 条,一般活动够用。
Cookie里的buvid3经常被忽略。只填SESSDATA大多数只读接口能通,但一旦触发风控校验,缺buvid3的请求会更容易被判成异常。我一般建议在浏览器里登录后,用开发者工具把整个 Cookie 头复制过来,别只挑一个字段。
注意:配置文件里带着登录态,提交代码前务必把
config/appsettings.json加进.gitignore,仓库里只留一份appsettings.sample.json。这事踩过一次就够记一辈子。
2.4 首次运行的四步
- 装 .NET 8 SDK,
dotnet --version确认版本号以 8 开头。 - 复制
appsettings.sample.json为appsettings.json,填入 Cookie 和动态 ID。 - 先跑
dotnet run --project src/BiliRaffle.Cli -- dry-run,这一步只抓数据不出结果,用来确认能拿到参与者。 - 确认参与者数量合理后,去掉
--dry-run正式开奖,结果会写到output/{dynamicId}-{timestamp}/下。
dry-run这个开关看着多余,实际是最省时间的调试入口。很多人第一次跑直接开奖,结果参与者只有 3 个人,还以为接口挂了,其实只是规则里RequireFollow开着、关注关系校验又因为频率限制大面积失败了。先跑 dry-run 看一眼过滤报告,每个规则刷掉了多少人一目了然。
3. 采集链路拆解:从动态 ID 到一份干净的参与者名单
数据采集是整条链路里最容易出问题的一段。抽奖本身的逻辑就那么几行,但参与者名单少一条、多一条、或者混进几个已注销账号,都会让最终结果站不住脚。这一章按请求进入的顺序拆:先解决「请求能不能过」,再解决「数据能不能拿全」,最后解决「拿全了怎么收敛成统一模型」。
3.1 登录态与 WBI 签名:请求能过的那道门
B站 web 端接口现在基本都要求 WBI 签名,少了w_rid和wts两个参数,返回的不是数据而是-352或-403。签名流程分三步:先从导航接口拿到img_key和sub_key,按固定位置表从两个 key 拼出的字符串里挑字符组成 32 位mixinKey,再把请求参数按 key 排序拼接、加上时间戳、跟mixinKey一起做 MD5。
// WbiSigner.cs private static readonly int[] MixinKeyEncTab = { 46, 47, 18, 2, 53, 8, 23, 32, 15, 50, 10, 31, 58, 3, 45, 35, 27, 43, 5, 49, 33, 9, 42, 19, 29, 28, 14, 39, 12, 38, 41, 13, 37, 48, 7, 16, 24, 55, 40, 61, 26, 17, 0, 1, 60, 51, 30, 4, 22, 25, 54, 21, 56, 59, 6, 63, 57, 62, 11, 36, 20, 34, 44, 52 }; public static string Sign(IDictionary<string, string> parameters, string imgKey, string subKey) { // 1. 两个 key 拼接后按下标表取 32 个字符,得到 mixinKey var raw = imgKey + subKey; var mixinKey = new string(MixinKeyEncTab.Select(i => raw[i]).Take(32).ToArray()); // 2. 每次请求都必须重新生成 wts,缓存签名是无效的 parameters["wts"] = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(); // 3. 参数按 key 的字典序排列,值先过滤掉 !'()* 再做 URL 编码 var query = string.Join("&", parameters .OrderBy(kv => kv.Key, StringComparer.Ordinal) .Select(kv => $"{kv.Key}={Uri.EscapeDataString(Sanitize(kv.Value))}")); // 4. md5(query + mixinKey) 就是 w_rid var wRid = Convert.ToHexString( MD5.HashData(Encoding.UTF8.GetBytes(query + mixinKey))).ToLowerInvariant(); return $"{query}&w_rid={wRid}"; } private static string Sanitize(string value) => new(value.Where(c => c is not ('!' or '\'' or '(' or ')' or '*')).ToArray());MixinKeyEncTab这张表是固定常量,64 个下标一个都不能错,错一位签出来的w_rid就永远对不上,而且报错信息只会告诉你「请求异常」,不会告诉你签名错了,属于典型的黑匣子问题。img_key和sub_key有效期通常是按天算的,做法是在内存里缓存一份并记录获取时间,超过一小时就主动刷新一次,别每个请求都去拿一次导航接口——那反而会被判定成异常流量。
Sanitize那一步容易被漏掉。参数的原始值里如果带!、'、(、)、*这几个字符,直接 URL 编码后算出来的 MD5 跟服务端算的不一致,结果就是签名校验失败。这几个字符在搜索关键词、昵称这类自由文本里出现的概率不低,写规则的时候只要有一个关键词带了括号,整条链路就全挂。
3.2 分页抓取:游标、去重与限速
转发列表和评论列表都是分页接口,核心是三件事:页码怎么递进、跨页怎么去重、请求之间怎么限速。
public async IAsyncEnumerable<Participant> EnumerateRepostsAsync( long dynamicId, [EnumeratorCancellation] CancellationToken ct = default) { const int pageSize = 30; var seen = new HashSet<long>(); // 以 uid 去重,跨页兜底 for (var pn = 1; pn <= _options.MaxPages; pn++) { var json = await _api.GetAsync("/x/v2/reply", new Dictionary<string, string> { ["type"] = "11", // 11 表示动态类型 ["oid"] = dynamicId.ToString(), ["sort"] = "0", // 0 = 按时间;按热度排序会漏数据 ["pn"] = pn.ToString(), ["ps"] = pageSize.ToString() }, ct); var page = JsonSerializer.Deserialize<ReplyPage>(json, _jsonOptions); if (page?.Replies is null || page.Replies.Count == 0) yield break; // 空页即终止,不要靠总数判断 foreach (var r in page.Replies) { if (r.Mid == 0) continue; // 已注销账号 uid 恒为 0 if (!seen.Add(r.Mid)) continue; // 同一人多次转发只算一次 yield return Participant.FromReply(r); } await Task.Delay(_options.RequestDelayMs, ct); } }sort参数选 0 还是 2 直接决定数据完整性。按热度排序时,列表会随着互动量变化动态重排,你翻到第 10 页的时候,原本在第 3 页的人可能已经被挤到第 12 页去了,最后统计出来的名单必然是重复加遗漏。按时间排序是稳定的,代价是热评区那些高赞内容不会优先出现——抽奖场景下这不重要,完整性才重要。
空页终止这个判断也要注意。接口返回的total字段在不同动态类型下并不总是准确,有的动态显示几千条转发,实际能翻到的只有几百条,如果你按total算页数,后面几十页全是空转,白白消耗请求额度。以「这一页返回 0 条」作为终止条件最稳。
seen这个 HashSet 是跨页兜底。理论上按时间排序不该出现重复,但在动态被大量转发时,翻页过程中有新数据插入会导致偏移,重复就出现了。去重放在抓取层而不是后面的规则层,是因为后面的每条规则都可能要打接口(比如查关注关系),对一个重复的 uid 打两次接口纯属浪费。
3.3 参与者模型:把 raw json 收敛成 Participant
抓回来的 JSON 字段又多又杂,直接往业务层传会后患无穷。做法是定义一个精简的领域模型,把用得到的字段提出来,顺手做一次规范化。
public sealed record Participant { public required long Uid { get; init; } public required string Nickname { get; init; } public string? AvatarUrl { get; init; } public DateTimeOffset RepostedAt { get; init; } public string? RepostContent { get; init; } public long RepostId { get; init; } // 用于溯源,公示时能定位到具体那条转发 public static Participant FromReply(ReplyItem r) => new() { Uid = r.Mid, Nickname = r.Member?.Uname ?? $"user_{r.Mid}", AvatarUrl = r.Member?.Avatar, RepostedAt = DateTimeOffset.FromUnixTimeSeconds(r.Ctime), RepostContent = r.Content?.Message, RepostId = r.Rpid }; }RepostId这个字段在抽奖逻辑里用不到,但对复核极其关键。名单公示出去之后,如果有人质疑「我明明转发了为什么没中」,你需要的是一条能点开的转发记录,而不是一个孤零零的 uid。昵称做空值兜底也是同理,注销或改名之后Member可能是 null,这时候用user_{uid}占位,至少导出文件不会崩。
RepostedAt用DateTimeOffset而不是DateTime,是因为公示时经常要标注「截至某时间」。DateTime不带时区信息,服务器换个时区跑出来的结果就差 8 小时,这种差异在争议场景下会被无限放大。
4. 抽奖内核:资格规则链与带种子的可复现随机
参与者名单拿到手,接下来是两件互相独立的事:先把不符合资格的人剔掉,再从剩下的人里摇号。这两步分开做的好处是,任何一步出问题都能单独复现——过滤有问题就只看过滤报告,随机有问题就固定种子重跑一遍。
4.1 规则链:每条资格一个 IRule
抽奖规则天然是一组可增删的判定条件:得转发过、得关注、等级不能太低、不能是内部人员、评论里得带指定关键词。如果用一个巨大的if把这些全塞进去,每加一条规则就要改一次核心代码,改错了还不好回滚。
public interface IRule { string Name { get; } // 出现在过滤报告里,必须是稳定标识 Task<RuleResult> CheckAsync(Participant p, DrawContext ctx, CancellationToken ct); } public sealed record RuleResult(bool Pass, string Reason) { public static readonly RuleResult Ok = new(true, "ok"); public static RuleResult Fail(string reason) => new(false, reason); }Name是稳定标识,不要用中文描述、也不要用GetType().Name。原因是过滤报告要跟历史记录做对比,如果某次重构把类名改了,历史数据就对不上了。用min-level、require-follow这种短横线命名,一眼能看出是哪个规则,又不会因为改名而失效。
编排逻辑用一个RulePipeline:
public async Task<FilterReport> ApplyAsync( IReadOnlyList<Participant> raw, DrawContext ctx, CancellationToken ct) { var survivors = new List<Participant>(raw.Count); var rejects = new Dictionary<string, List<long>>(); // 规则名 -> 被刷掉的 uid foreach (var p in raw) { var passed = true; foreach (var rule in _rules) { var result = await rule.CheckAsync(p, ctx, ct); if (result.Pass) continue; if (!rejects.TryGetValue(rule.Name, out var list)) rejects[rule.Name] = list = new List<long>(); list.Add(p.Uid); passed = false; break; // 命中一条就出局,后面的规则不必再打接口 } if (passed) survivors.Add(p); } return new FilterReport(survivors, rejects); }break这个短路很关键。假设参与者有 5000 人,require-follow和min-level两条规则各要打一次接口,如果全部跑完,请求量就是一万次;短路之后大部分人会在第一条规则就被刷掉,实际请求量能降到两三千。这不只是性能问题,而是能不能在限流阈值内跑完的问题。
rejects这个字典是过滤报告的核心。开奖之后如果有人来问「我怎么没中」,你可以直接告诉他:「你在min-level这条规则被刷掉了,当时等级是 1。」这句话的说服力,比「系统随机抽的」强一百倍。
规则本身的实现要处理失败语义。比如查关注关系的接口超时了,这条规则该判通过还是判失败?我的做法是引入三态:通过、不通过、无法判定。无法判定的先标出来,最后统一人工复核,而不是默认判失败——把真实参与者误刷掉,比多几个人进池子的后果严重得多。
4.2 随机算法:种子、洗牌与加权
摇号这块最容易想当然。很多人直接OrderBy(_ => Guid.NewGuid()).Take(n),这样写有两个问题:一是分布不一定均匀,二是完全不可复现,公示之后没人能验证。
public static IReadOnlyList<Participant> Draw( IReadOnlyList<Participant> pool, int count, string seedText) { if (count <= 0 || pool.Count == 0) return Array.Empty<Participant>(); count = Math.Min(count, pool.Count); // 用 SHA256 派生种子。string.GetHashCode 在不同进程间结果不同,不能用 var seedBytes = SHA256.HashData(Encoding.UTF8.GetBytes(seedText)); var seed = BitConverter.ToInt32(seedBytes, 0); var rng = new Random(seed); // Fisher-Yates 部分洗牌:只洗前 count 个位置,复杂度 O(count) 而非 O(n) var index = Enumerable.Range(0, pool.Count).ToArray(); for (var i = 0; i < count; i++) { var j = rng.Next(i, index.Length); (index[i], index[j]) = (index[j], index[i]); } return index.Take(count).Select(i => pool[i]).ToList(); }seedText建议用$"dyn={dynamicId};seed={userSeed}"这种可读形式,公示的时候直接把这串文本贴出去,别人拿去重跑就能得到同样的结果。用 SHA256 派生而不是直接seedText.GetHashCode(),是因为 .NET 的字符串哈希默认带随机化种子,同一个字符串在两个进程里算出来的值不同,跨机器重跑结果就对不上了。
Fisher-Yates 只洗前count个位置,这个优化在池子有十几万人的时候能省不少时间。注意洗牌范围是[i, index.Length)而不是[0, index.Length),前者是标准的部分洗牌写法,后者会让结果出现偏差。
如果活动规则要求「评论质量加权」或者「老粉权重更高」,就在洗牌之后加一层加权抽样:给每个参与者算一个权重,累加求和,然后在总权重区间里取随机点。这时候种子要同时控制两个随机源,最好把洗牌和加权抽样拆成两个独立Random实例,各自用派生种子初始化,避免两者的调用顺序影响结果。
4.3 结果快照:让开奖结果经得起复核
抽完奖只导出一个名单是不够的,得导出一份能自证的结果包。
public sealed record DrawSnapshot( long DynamicId, string Seed, string RuleHash, // 规则配置序列化后的 SHA256 DateTimeOffset FetchedAt, int ParticipantCount, int WinnerCount, IReadOnlyList<long> WinnerUids, IReadOnlyDictionary<string, int> RejectStats); public static string ComputeRuleHash(DrawOptions options) { var json = JsonSerializer.Serialize(options, JsonOpts); return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(json))) .ToLowerInvariant()[..16]; // 取前 16 位够用了 }RuleHash是对规则配置做的指纹。开奖之后如果有人质疑「你是不是中途改规则了」,把公示里的RuleHash跟当时的配置算一遍对比就知道。FetchedAt记录抓取开始的时间,用来界定「截至什么时候的转发算数」。RejectStats是每条规则刷掉的人数,这东西放出来其实很加分——它说明你不是随便抽的,而是真的在筛。
提示:输出目录建议按
{dynamicId}-{yyyyMMddHHmmss}命名,把原始参与者列表(脱敏后)、过滤报告、中奖名单、快照 JSON 四份文件放在一起。少了原始列表,后面想复算都复算不了。
5. 避坑与排查:限流、脏数据和被质疑的中奖名单
这一章是按「现象 → 原因 → 解决」记录的实际踩坑。抽奖工具的技术难点不在算法,在于你得跟一个随时会变、随时会限流的外部接口长期打交道,还得让结果在几百双眼睛下面站得住。
5.1 请求层:签名、限流和被吞掉的错误码
现象一:本地跑得好好的,换台机器或者第二天再跑,所有请求都返回-352。
原因基本只有两个:WBI 的img_key缓存过期了,或者请求头缺buvid3导致风控等级被抬高。img_key是按天刷新的,把签名结果缓存在内存里跑一整晚的常驻任务,跨过零点之后就开始全线失败。
解决:img_key缓存加一小时过期,超过就重新拉一次导航接口;Cookie里补齐buvid3,并且把这个字段写进配置检查——启动时解析一遍 Cookie,缺关键字段直接报错退出,不要等到跑到一半才发现。
现象二:翻到第五六页的时候突然开始 412,前面抓的全废。
原因是请求间隔压得太短。默认 800 毫秒在大部分动态上是安全的,但如果碰上热门活动,几千人同时转发,接口压力大,服务端会更早触发限流。
解决:把RequestDelayMs提到 1200~1500 毫秒,同时给每个请求加随机抖动(±300 毫秒),避免所有请求间隔完全一致——机器味太重的访问节奏本身就是一个特征。另外要在抓取过程中落盘中间结果,每抓完一页就写一次断点文件,被打断了可以从上次的页码续上,不用从头再来。
现象三:接口返回 200,code字段却是非 0,反序列化之后拿到一堆 null。
这类错误最隐蔽,因为 HTTP 层完全正常,Polly 的重试策略也不会触发。bilibili 的接口把业务错误放在响应体的code里,-404、-352、-412都可能藏在 200 响应中。
解决:在BiliRaffle.Api层统一解包,先校验code == 0再返回业务数据,非 0 时抛一个带code和message的自定义异常。这个封装要在最开始就做对,否则后面每个调用点都要写一遍if (code != 0),漏一处就是一个静默失败。
5.2 数据层:注销账号、重复转发和时间戳错位
现象四:导出的名单里有昵称是user_0的记录,点进去查无此人。
原因是uid == 0。已注销、被永久封禁、或者某些匿名转发的账号,接口返回的mid就是 0。不及时剔掉,这些人会进入抽奖池,万一中奖了公示出去就是一个没法解释的账号。
解决:在EnumerateRepostsAsync里直接跳过Mid == 0,同时在日志里记一条计数。日志里记下来是为了排查——如果一次活动里 uid 为 0 的占比异常高,可能是数据源有问题,而不是真的这么多人注销了。
现象五:同一个人连转五条,中奖概率变成别人的五倍。
这个不用解释,转发抽奖的经典漏洞。有的人就是靠刷转发提高中签率。
解决:在抓取层用HashSet<long>按 uid 去重,保留最早一条转发的时间和RepostId。有些活动规则允许多次转发提高权重,那就把去重放到规则层做成一个可选规则dedup-by-uid,默认开启,需要加权的时候显式关掉,这样规则是写在配置里的、可公示的,而不是藏在代码里。
现象六:公示的「截至时间」和实际抓到的最后一条转发时间差了半小时。
原因是FetchedAt记录的是程序启动时间,而不是抓完最后一页的时间。中间如果因为限流重试耗了二十分钟,这个偏差就会很明显。
解决:FetchedAt拆成StartedAt和FinishedAt两个字段,公示里用FinishedAt,并且额外记一个「最后一条抓到转发的Ctime」,这两个数应该很接近,差太多说明数据没抓全,得重抓。
5.3 结果层:被质疑的中奖名单
现象七:名单发出去,评论区第一条就是「凭什么是他」。
这个跟技术没关系,跟流程有关系。绝大多数质疑的来源是「过程不可见」。
解决:公示的时候把四样东西一起发出去——参与者总数、每条规则的淘汰人数、随机种子、规则配置的哈希。有心人拿这些去复算,算出来一样,质疑自然就没了。反过来,如果你只发一个名单,那被质疑是必然的,跟你抽得公不公平没关系。
现象八:开奖之后发现中奖者里有个人是内部人员的小号。
原因是ExcludeUids只排除了主账号。
解决:排除名单要在开奖前明确写进配置,并且跟规则哈希一起公示。更进一步的做法是加一条「账号注册时间早于活动开始时间」的规则,把活动期间临时注册的小号直接挡在池子外。这条规则在接口能拿到注册时间的前提下,过滤效果比手工维护排除名单好得多。
注意:所有失败的规则判定都要留痕,哪怕最后没有影响结果。事后复盘的时候,你唯一能依靠的就是那份过滤报告,没有它,任何一次「为什么是他」的疑问都只能靠嘴解释。
6. 二次开发:把抽奖组件改成你自己的服务
这个组件最值钱的地方不是抽奖算法,而是那条「采集 → 过滤 → 摇号 → 快照」的管线。把它当底座往上加东西,比从零写一个要省太多事。下面是我实际改过的三个方向,按改动量从小到大排。
| 扩展点 | 需要动的接口 | 改动量 | 典型场景 |
|---|---|---|---|
| 新增资格规则 | 实现IRule+ 注册 DI | 半天 | 等级门槛、关键词命中、注册时间限制 |
| 换数据源 | 替换IParticipantSource | 一天 | 从转发改成评论、直播间弹幕 |
| 改成常驻服务 | 新增BiliRaffle.Web+ 定时任务 | 两三天 | 定期开奖、结果查询页 |
新增一条规则是最常见的需求,实现起来就一个类:
public sealed class MinLevelRule : IRule { public string Name => "min-level"; public async Task<RuleResult> CheckAsync( Participant p, DrawContext ctx, CancellationToken ct) { // 用户信息接口调用量大,走 ctx.Users 里的缓存,别裸打 var info = await ctx.Users.GetInfoAsync(p.Uid, ct); if (info is null) return RuleResult.Fail("user-info-unavailable"); return info.Level >= ctx.Options.MinLevel ? RuleResult.Ok : RuleResult.Fail($"level={info.Level}"); } } // 注册 services.AddSingleton<IRule, MinLevelRule>(); services.AddSingleton<IRule, RequireFollowRule>(); services.AddSingleton<RulePipeline>();ctx.Users那层缓存必须要有。一条活动 5000 人,如果每个规则都直接打用户信息接口,光这一项就是 5000 次请求,跑不完就被限流了。做法是按 uid 做内存缓存,有效期设成一次开奖的生命周期,同一个 uid 只在第一次查的时候真正打接口。
改数据源也不难,把IParticipantSource抽出来就行:
public interface IParticipantSource { IAsyncEnumerable<Participant> EnumerateAsync(long targetId, CancellationToken ct); }转发列表、评论区、直播间弹幕各实现一个,DrawOrchestrator完全不感知差异。这一步在最初设计时预留了接口,后面加数据源基本是零成本。
改完之后必须验证,验证的核心就一条:同样的种子必须得到同样的结果。写成一个回归测试:
[Fact] public void Draw_WithSameSeed_ProducesSameWinners() { var pool = Enumerable.Range(1, 500) .Select(i => new Participant { Uid = i, Nickname = $"u{i}" }) .ToList(); var a = Lottery.Draw(pool, 10, "dyn=12345;seed=test"); var b = Lottery.Draw(pool, 10, "dyn=12345;seed=test"); Assert.Equal(a.Select(x => x.Uid), b.Select(x => x.Uid)); }再配一个「不同种子结果不同」的断言,两个测试一起跑。如果哪天有人把Random(seed)换成了Random.Shared,或者 .NET 大版本升级改了Random的内部算法,这个测试会第一个报警。这里有一处容易忽略的坑:System.Random在 .NET 6 之后换了实现,同一个种子在 .NET Framework 和 .NET 8 上算出来的序列是不一样的。所以如果你的公示页面承诺「任何人都能复算」,要么锁定运行时版本写进说明,要么自己实现一个简单的线性同余生成器,把它固定下来。
我最早做这类工具的时候,图省事把种子写死在代码里,公示的时候只发了名单。后来那次活动结束后,有人拿着截图一条条数转发,说我少算了二十几个人。我翻遍日志也说不清到底是哪一步出的问题,最后只能重抽一次。从那以后我每次开奖前都强制走一遍 dry-run,把过滤报告和参与者总数先贴出来,确认没人有异议了再摇号。多花十分钟,省掉的是没法补的信任。希望帮到你。
本文还有配套的精品资源,点击获取