简介:这是一份面向招标采购信息化开发者、Python 学习者与评标系统实施人员的源码合集,围绕评标专家随机抽取这一典型业务场景,汇集大树、宏达、九鼎、闻道、云智等七款抽取软件的实现代码与配套应用。资源共 59 个文件,压缩包约 78.05MB,以 dll 依赖库、qm 语言翻译文件、exe 可执行程序为主,另含 cpp/h 与 pro、ui 等 Qt 工程文件及 txt 说明,既能直接运行对照流程,也能在 Qt 环境里阅读源码结构。已有 806 人学习下载。读者可从中了解专家库建模、随机抽取算法、机构与专业分类、界面交互与多语言适配等实现思路,对比不同厂商在数据结构与抽取规则上的差异,为自己的抽取模块开发、源码改造或课程案例设计积累参考素材,属于需要一定编程基础的中高阶参考资料。
1. 需求切入:能抽出来容易,能自证清白难
某招标代理机构在开标前一天被质疑:凭什么抽中的是这三个人?操作员翻出 Excel 的随机函数,对方一句"你怎么证明你没改过参数"就把他问住了。评标专家随机抽取软件系统真正难的地方从来不是"随机",而是随机之后可复核、可追溯、可举证。这套含七款系统的源码包,覆盖的正是从专家库维护、回避规则过滤、随机抽取、短信语音通知到抽取记录归档的完整链路,七套实现分别在抽取内核、界面形态和通知方式上走了不同路线。它适合两类人:做毕设或课设、需要交付一套能跑通"抽取—回避—通知—归档"闭环的学生;以及要给单位内部搭一套小工具、又不想从零设计表结构的开发者。下面按我实际做这类系统的顺序,把表结构、抽取算法、源码取舍和验收方法拆开讲。
2. 业务建模:专家库、回避关系与抽取批次的表结构
抽取系统出问题,八成不是算法写错,而是建模阶段没想清楚"谁有资格被抽""抽完了怎么留证"。我一般先把数据分成三块:静态的专家档案、动态的回避与轮空约束、一次性的抽取批次与明细。三块职责分开,后面换算法、换通知渠道都不用动表。
2.1 专家主表与状态机的设计
专家表别急着堆字段,先定状态机。通常的划分是:待审核、正常、暂停、退出四种状态,只有"正常"能进入候选池。暂停和退出的区别在于是否保留历史抽取资格记录——暂停是临时性的(比如被投诉核查期间),退出是永久性的。很多初级实现只用一个is_valid布尔字段,结果核查期间抽中了人,人工去数据库改标记,改完忘了改回来,后面统计口径全乱。
核心字段我一般这么定:
| 字段 | 类型 | 说明 |
|---|---|---|
| expert_no | varchar(32) | 业务编号,对外展示用,不复用 |
| name | varchar(64) | 姓名 |
| id_card_enc | varchar(256) | 身份证号密文,AES 存储,另存哈希用于查重 |
| major_code | varchar(16) | 专业类别编码,对应评标专业目录 |
| title_level | smallint | 职称等级,用于资格门槛过滤 |
| org_id | bigint | 所属单位,关联单位表,回避判断的主键 |
| status | tinyint | 0待审核 1正常 2暂停 3退出 |
| join_count | int | 累计参评次数 |
| last_draw_time | datetime | 上次被抽中时间,用于轮空计算 |
| region_code | varchar(12) | 可参评区域 |
身份证号这一列值得单独说。它既是查重依据,又是敏感信息,直接用明文存,后面做数据脱敏导出、日志打印时到处漏。我一般存两列:密文列用于回显,哈希列(加固定盐的 SHA-256)用于唯一索引和查重。这样建唯一索引不会因为加密随机盐导致同一人算出不同值。
CREATE TABLE expert ( id BIGINT PRIMARY KEY AUTO_INCREMENT, expert_no VARCHAR(32) NOT NULL, name VARCHAR(64) NOT NULL, id_card_enc VARCHAR(256) NOT NULL, -- AES 密文,仅授权接口可解 id_card_hash CHAR(64) NOT NULL, -- 加盐 SHA-256,用于查重与唯一约束 major_code VARCHAR(16) NOT NULL, title_level SMALLINT NOT NULL DEFAULT 0, org_id BIGINT NOT NULL, region_code VARCHAR(12) DEFAULT NULL, status TINYINT NOT NULL DEFAULT 0, -- 0待审核 1正常 2暂停 3退出 join_count INT NOT NULL DEFAULT 0, last_draw_time DATETIME DEFAULT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_no (expert_no), UNIQUE KEY uk_idcard (id_card_hash), KEY idx_pool (status, major_code, region_code) -- 候选池裁剪的主索引 );idx_pool这个联合索引是给后面候选集裁剪用的,顺序按"区分度从高到低、等值条件在前"排。候选池裁剪是每次抽取都要跑的热路径,索引没建对,几千人的库也会出现秒级延迟,现场演示时鼠标转圈很难看。
2.2 回避关系的三种类型与落表方式
回避规则是整个系统里最容易被低估的部分。用户嘴上说"随机抽",实际要的是"在符合条件的人里随机抽"。把回避逻辑写成一堆 if-else 塞在抽取代码里,是最典型的翻车姿势——新增一条规则要改代码、要重新发版,还得把历史批次重跑一遍验证。
我的做法是单独一张回避表,用类型加值的方式表达,抽取时统一翻译成一条 NOT EXISTS 子查询:
- 单位回避:专家所属单位与投标人、代理机构存在隶属或控股关系;
- 项目关联回避:专家参与过本项目的前期咨询、设计、代理、评审;
- 亲属与利益回避:与投标人法定代表人或项目负责人存在特定关系。
外加一类特殊的"轮空回避":最近 N 次抽取中已被抽中、或距上次参评不足 M 天的专家排除。这条不是法律意义上的回避,但业务上必须支持,否则同几个人反复被抽,投诉照样来。
-- 候选集裁剪:一次性把状态、专业、轮空、项目回避全部过滤掉 SELECT e.id, e.expert_no, e.name, e.join_count, e.last_draw_time FROM expert e WHERE e.status = 1 AND e.major_code = #{majorCode} AND (#{regionCode} IS NULL OR e.region_code = #{regionCode}) -- 轮空:距上次参评不足 N 天,或近 K 次已抽中 AND (e.last_draw_time IS NULL OR e.last_draw_time < DATE_SUB(NOW(), INTERVAL #{coolDownDays} DAY)) -- 项目级回避:单位、关联方、亲属统一走同一张表 AND NOT EXISTS ( SELECT 1 FROM expert_avoid a WHERE a.expert_id = e.id AND a.project_id = #{projectId} AND (a.valid_to IS NULL OR a.valid_to > NOW()) ) ORDER BY e.join_count ASC, e.last_draw_time ASC;注意ORDER BY那一段,它不是排序需求,而是为加权抽取做预处理——参评次数少的排前面。这里有个坑:last_draw_time为 NULL 的专家在 MySQL 升序里排最前,看起来正好符合"从没抽过的优先",但如果业务要求"新人必须有一定概率被抽中但不能全抽新人",就得在应用层做加权,不能依赖这个排序。
2.3 抽取批次、明细与审计哈希链
一次抽取动作必须落三张表:批次主表记"这次抽取的条件和随机种子",明细表记"抽中了谁、以什么顺序",审计表记"谁在什么时候做了什么"。前两张是业务需要,第三张是举证需要。
批次表里最关键的一列是random_seed。它记录本次抽取使用的随机种子,配合批次表里保存的条件快照(专业、人数、回避规则版本号、候选集数量),事后可以完整复现"当时为什么抽出这几个人"。没有这一列,任何质疑都只能靠嘴解释。
审计表我一般做成哈希链:每条日志存前一条的哈希,形成prev_hash + payload → this_hash的链条。单条日志可以被删,但删除后链条断裂,用一条校验 SQL 就能发现。这不是密码学级别的防篡改,但足以应付内部核查和答辩现场"你怎么保证日志没被改"这类问题。
import hashlib, json def append_audit(conn, action: str, payload: dict, operator: str) -> str: """追加一条审计日志,返回本条哈希。payload 会做键排序保证可复现。""" cur = conn.cursor() cur.execute("SELECT this_hash FROM audit_log ORDER BY id DESC LIMIT 1") row = cur.fetchone() prev_hash = row[0] if row else "0" * 64 body = json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":")) raw = f"{prev_hash}|{action}|{operator}|{body}" this_hash = hashlib.sha256(raw.encode("utf-8")).hexdigest() cur.execute( "INSERT INTO audit_log(action, payload, operator, prev_hash, this_hash) " "VALUES (%s, %s, %s, %s, %s)", (action, body, operator, prev_hash, this_hash), ) conn.commit() return this_hashsort_keys=True和separators两个参数不能省。前者保证同样的字典在不同进程里序列化结果一致,后者去掉多余空格,否则同一份数据在 Windows 和 Linux 上算出的哈希不同,校验必失败。这个坑我在跨平台部署时踩过一次,排查了大半天才定位到序列化差异。
3. 抽取内核:从等概率洗牌到可复核的种子链
抽取内核是整个系统的技术核心,也是七套源码差异最大的地方。有的用random.sample一行搞定,有的做了加权轮次,有的把规则写进了配置。不管哪种,评判标准只有两条:每一次抽取的概率分布是否说得清,结果是否能被独立复现。
3.1 随机源怎么选:rand() 为什么会被质疑
先分清两个层次。业务上要求的"随机"是统计意义上的均匀分布,技术上要求的"不可预测"是密码学意义上的。用random.random()生成一个 0 到 1 的数,在单机单线程下统计分布没问题,但它基于 Mersenne Twister,观察足够多的输出就能反推内部状态——这在演示场景里不是什么大问题,但被问到时答不上来就很被动。
我的选法是:业务随机用random.Random(seed),种子本身用secrets生成。种子来自操作系统熵池,不可预测;拿到种子后灌进独立的Random实例,抽签过程确定、可复现。这样既满足"现场抽的结果没法提前猜",又满足"事后能按种子复现"。直接用全局的random模块是另一种常见错误:多线程环境下全局状态共享,两个批次并发抽取会互相干扰,抽出来的结果和种子对不上。
还有一类更土的做法是拿时间戳当种子。秒级时间戳只有有限取值,知道大概时间就能穷举,属于自己给自己挖坑。
3.2 带约束的加权抽取实现
纯洗牌解决不了三个业务约束:专业人数配额(一个专业最多抽 2 人)、单位配额(同一单位最多 1 人)、以及"参评次数少的适当倾斜"。这些约束之间会冲突,比如候选池里 5 个人有 3 个来自同一单位,硬抽 4 个人必然违反单位配额。所以抽取逻辑不能是"打乱取前 N 个",得带回溯。
我的写法是分组加权洗牌加冲突重抽:
import random from collections import defaultdict def weighted_draw(candidates, need, quota_by_org=1, max_retry=200, rng=None): """ candidates: [{'id':1,'org_id':9,'join_count':3}, ...] need: 需要抽取的人数 quota_by_org: 同一单位最多中签人数 rng: random.Random 实例,外部传入以保证同种子可复现 返回 (中签列表, 使用的中签轮次),失败抛异常而不是静默少抽 """ rng = rng or random.Random() pool = list(candidates) # 权重:参评次数越少权重越高,用 1/(1+count) 抑制高频专家 weights = [1.0 / (1 + c["join_count"]) for c in pool] for attempt in range(max_retry): picked, org_cnt = [], defaultdict(int) idx = list(range(len(pool))) w = list(weights) # 按权重不放回抽样:每次按剩余权重轮盘选一个 for _ in range(need): total = sum(w) r = rng.random() * total acc = 0.0 for i, wi in zip(idx, w): acc += wi if acc >= r: picked.append(pool[i]) org_cnt[pool[i]["org_id"]] += 1 pos = idx.index(i) idx.pop(pos); w.pop(pos) break # 冲突检测:单位配额 if all(v <= quota_by_org for v in org_cnt.values()): return picked, attempt + 1 raise RuntimeError("候选池容量不足,无法在配额约束下完成抽取")几个参数值得说明。weights用1/(1+count)而不是1/count,是为了避免从没参评过的专家权重无限大——count=0时前者是 1.0,后者直接除零。max_retry设 200 不是拍脑袋:候选池 200 人、抽 5 人、单位配额 1 的场景下,实测一般 3 到 10 轮就收敛;超过 200 轮说明候选池本身不可行,这时候应该抛异常让人工介入,而不是硬返回一个违反配额的名单。静默降级是这类系统最危险的行为,抽出来的名单不符合规则,事后没人知道。
rng必须外部传入。内核自己random.Random()一个新的,种子就丢了,复现功能直接报废。
3.3 种子链:把"可复核"做成一键操作
抽取完成后,批次表里存三样东西:种子、候选集快照的哈希、规则版本号。复核时用同样的种子、同样的候选集、同样的规则版本重跑一次内核,比对结果是否一致。
候选集快照不存全量数据(几千条明细太重),只存"候选专家 ID 排序后拼接的 SHA-256"。校验逻辑是:重新按当时的条件查询候选池,算哈希,和存档比对;一致则说明候选池没被改过,再用种子复现抽取顺序。
def snapshot_hash(expert_ids): """候选集指纹:ID 升序拼接后取 SHA-256,用于事后比对候选池是否被改动。""" joined = ",".join(str(i) for i in sorted(expert_ids)) return hashlib.sha256(joined.encode()).hexdigest()这套机制的价值在答辩和现场质疑时特别明显:对方说"你是不是内定了",你打开批次详情,把种子输进复现工具,结果一模一样,候选集指纹也对得上,话题就结束了。没有这套东西,只能反复强调"系统是随机的",说服力约等于零。
提示:种子和复现工具要一起交付。只存种子不给复现入口,等于存了个黑匣子,核查时还是得找你写脚本。
4. 七套源码拆解:抽取内核的三种风格与模块取舍
七套系统源码放在一起,最大的价值不是"多",而是能看到同一个业务在不同抽象层次上的实现差异。我把它们归成三类,每一类的适用场景和改造代价都不一样。
4.1 三类抽取内核的实现风格
第一类是单文件脚本型。整段抽取逻辑加一个命令行菜单,几百行,无数据库,专家和回避关系都放 JSON 或者 Excel。优点是拿来就能跑,改起来也快;缺点是并发和数据量都撑不住,上千条专家记录后加载变慢,而且没有审计链路。这类适合课设演示、内部小范围试用,或者作为理解业务的最小可运行样本。
第二类是桌面端单体。界面加本地库,抽取、通知、打印一条龙,数据存在本地文件型数据库里。这类实现通常会把业务规则硬编码在窗体事件里,比如"同单位只能抽一个"直接写在按钮点击的回调中。可维护性一般,但交付形态完整,适合需要离线运行、不依赖服务器的场景。
第三类是 Web 前后端分离。抽取内核被抽成独立服务,前面挂接口,后面挂库,通知模块异步化。这类结构最接近生产形态,也是我在正式项目里会选的路线。改造时要注意它的规则往往做成了配置表,读懂配置项的语义比读代码更花时间。
三类实现里,抽取内核的代码量占比其实都不高,真正占大头的是专家库维护、导入导出、通知重试这些外围功能。选源码时别只看抽取那几十行写得漂不漂亮,要看外围模块的完整度。
4.2 通知确认与补抽的闭环
抽取只是开始,专家确认参评才算结束。通知模块通常走短信加语音两条路,短信负责触达,语音负责确认。这里有个业务细节:专家回复"1"确认、"2"请假,超时未回复视为放弃,系统自动从替补队列里补抽。
补抽的实现要复用同一批次的候选池和种子派生规则,不能重新生成候选集。常见错误是补抽时重新查库,结果候选池里已经混进了刚被抽中的人,或者回避关系在两次查询之间发生了变化,补抽结果和主抽结果口径不一致。
我的做法是主抽时一次性按need + backup抽足人数,前need个是正式名单,后backup个按顺序进替补队列。补抽时从替补队列里按序取,取完还不够才触发新一轮抽取并记一条新的审计日志。这样既保证了替补顺序可追溯,也避免了重复查库带来的口径漂移。
通知状态要落表,字段至少包括:发送时间、渠道、送达状态、回复内容、回复时间、最终确认结果。缺了回复时间这一列,后面统计"专家平均响应时长"就没数据,而这恰恰是很多单位考核时要看的指标。
4.3 七套源码的取舍对照
| 维度 | 单文件脚本型 | 桌面端单体 | Web 前后端分离 |
|---|---|---|---|
| 部署成本 | 极低,装个运行时即可 | 低,需装客户端 | 中,需服务与库 |
| 数据容量 | 千条以内 | 万条以内 | 十万条量级 |
| 审计与复现 | 基本没有,需自己补 | 部分有日志,无哈希链 | 多数带日志表 |
| 规则可配置性 | 硬编码 | 硬编码在界面层 | 配置表驱动 |
| 改造成本 | 低,但改完还是单机 | 中,界面逻辑和业务耦合 | 高,需读懂配置语义 |
| 适用场景 | 课设演示、最小样本 | 离线单机交付 | 正式项目、多人协作 |
选型的判断标准我一般看两条:一是数据规模会不会破万,二是需不需要向第三方举证。两条只要中一条,就直接上第三类;两条都不中,第一类改起来最快。
5. 避坑与常见问题排查:抽取现场最容易翻车的五件事
5.1 抽出来的人数比要求少
现象是设置了抽 5 人,结果只出了 3 人,系统还不报错。原因通常是候选池在过滤后容量不足,而代码里用了for循环加break的写法,池子空了就静默退出。改成抽取前先count一次候选池,不足时明确抛异常并提示"当前条件下仅剩 N 名可选专家",让人工决定是放宽条件还是延后抽取。静默少抽在这类系统里属于严重缺陷,名单不齐的评标现场是要重新组织的。
5.2 同一批次重复执行抽出不同结果
现象是同一个批次号点两次"重新抽取",两次结果不一样。原因多半是代码在每次调用时都重新生成了种子,而不是从批次记录里读取。正确的做法是批次创建时生成一次种子并落库,后续所有抽取、补抽、复现都从这个种子派生,派生规则写进文档。如果业务上确实允许"重抽",那就应该新建一个批次号,而不是覆盖原批次——原批次的记录一旦被覆盖,举证链条就断了。
5.3 中文姓名排序或去重出问题
现象是导出名单里同一个人出现两次,或者按姓名排序时生僻字排到了最后。前者多半是查重依赖了姓名加单位这种弱组合,同名同姓直接误判;后者是排序规则用了二进制比较,UTF-8 编码下汉字按字节排,结果完全不符合拼音顺序。查重一律走身份证哈希,排序按CONVERT(name USING gbk)或者干脆按专家编号排,别在数据库层赌字符集行为。
5.4 通知发出去了但状态一直是"未确认"
现象是专家明明回复了短信,系统里还显示待确认。原因一般是短信回执走的是异步回调,而回调接口没有做幂等,重复回调把"已确认"覆盖回"待发送";或者回调地址配的是内网地址,运营商侧根本回调不进来。排查顺序是:先看回调日志有没有进来,再看进来的报文时间戳是否乱序,最后检查状态更新语句有没有加"仅当当前状态为待确认时才更新"的条件。
5.5 审计日志哈希链校验失败
现象是运行一段时间后校验脚本报链条断裂,但业务上没人删过数据。原因通常有三个:一是并发写入时两条日志读到同一个prev_hash,链条分叉;二是payload里存了浮点数或时间对象,不同环境序列化结果不同;三是数据库连接没开事务,插入成功但提交失败。解决方法是写入时对日志表加行锁或者用单写队列串行化,payload只允许字符串和整数,时间统一转成 ISO 格式字符串再序列化。
注意:哈希链校验要定期跑,最好是每次抽取后自动跑一次。等出问题再查,链条断在哪一条已经很难定位了。
6. 验收与答辩:怎么证明这次抽签没有猫腻
验收这类系统,别去数功能点,就做三件事,做完基本能说明问题。
第一件是复现演示。提前准备一个历史批次,当着验收人的面,从批次详情里读出种子和候选集指纹,粘进复现工具,跑出来的名单必须和存档一致,指纹也必须一致。中间故意改一个参数(比如把专业编码换掉),结果必须变,指纹也必须变——这一步是证明"复现工具不是硬编码返回固定结果"。整个过程不超过两分钟,但说服力比十页文档都强。
第二件是约束验证。构造一个刻意刁难的候选池:10 个人里有 4 个同单位、3 个在回避名单里、2 个刚参评过。跑一次抽 3 人,检查结果是否满足单位配额、是否排除了回避人员、是否优先抽了参评次数少的。再把这个池子缩到只剩 2 个合格人选,验证系统是否明确报错而不是硬凑。
第三件是异常演练。断网跑一次通知,看状态是否正确停留在"待发送"并能重试;并发点两次抽取按钮,看是否只生成一个批次;手动删掉审计表中间一条记录,看校验脚本能否准确定位到断裂位置。这三项过了,系统的健壮性基本可信。
答辩环节的问题往往集中在两处。一是"你的随机是真的随机吗",标准答法是分层解释:种子来自操作系统熵池,不可预测;抽取过程用独立随机数实例,同种子可复现;候选集有指纹,事后可验证未被篡改。二是"如果我要加一条新的回避规则怎么办",这时候别去讲代码,直接打开回避表的结构,说清楚新增一个avoid_type加一条NOT EXISTS条件即可,不动抽取内核。能答到这个程度,说明设计是通的。
我自己的习惯是,每次交付这类系统前,强制走一遍"删一条日志、改一个候选、断一次网络"的三步自检,走完才敢让人来看演示。这套动作花不到半小时,但能把最容易在现场翻车的地方提前暴露出来。希望帮到你。
本文还有配套的精品资源,点击获取