恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
思否_第7天_废标风控引擎工程实践
首页
资讯中心
/
思否_第7天_废标风控引擎工程实践
思否_第7天_废标风控引擎工程实践
发布时间:2026/8/14 14:35:33
从零搭建一套招投标废标风控引擎规则设计、签章检测与包号校验的工程实践做招投标系统的朋友应该都有体会——招标文件里的废标规则散落在正文、附件、补遗公告等各个角落格式五花八门人工逐条核对既慢又容易漏。我们在 OpenCheck 里做了一套废标风控引擎核心思路是把废标规则结构化存储然后用规则引擎统一调度检查。这篇文章拆解几个关键技术点数据库怎么设计、规则引擎怎么跑、签章检测怎么做、包号一致性怎么校验。系统架构整体分四层招标文件(PDF/Word) │ ▼ ┌──────────────────┐ │ 文档解析层 │ pdf-parse / mammoth / tesseract │ (PDF → Chunks) │ └────────┬─────────┘ │ ▼ ┌──────────────────┐ │ 规则引擎层 │ DisqualificationEngine │ (规则 → 检查) │ └────────┬─────────┘ │ ▼ ┌──────────────────┐ │ 结果聚合层 │ 汇总检查结果计算风险分数 └────────┬─────────┘ │ ▼ ┌──────────────────┐ │ 前端展示层 │ 形式合规总览面板 └──────────────────┘文档解析层用 pdf-parse 处理 PDFmammoth 处理 Wordtesseract 做 OCR。解析出来的 chunk 按页码和坐标组织好交给下游的规则引擎。废标风控库数据库设计废标规则的核心挑战在于规则类型多样——有的是正则匹配密封处要有公章有的是关键词检测禁止手写有的是图像识别骑缝章检测还有的是组合逻辑A 和 B 同时满足才通过。我们用 PostgreSQL 的 JSONB 类型来存规则配置这样不同类型的规则可以共用同一张表不需要为每种检查类型建单独的表。-- 废标规则主表CREATETABLEdisqualification_rules(id UUIDPRIMARYKEYDEFAULTgen_random_uuid(),rule_codeVARCHAR(32)UNIQUENOTNULL,-- 规则编码: F001, F002...categoryVARCHAR(32)NOTNULL,-- 分类: seal/signature/format/qualificationseverityVARCHAR(16)DEFAULTcritical,-- 严重程度: critical/warning/infotitleTEXTNOTNULL,-- 规则标题descriptionTEXT,-- 规则描述check_typeVARCHAR(32)NOTNULL,-- 检查类型: regex/keyword/image/rules_enginecheck_config JSONBNOTNULL,-- 检查配置(核心)industryVARCHAR(64),-- 适用行业: all/construction/it/medicalis_activeBOOLEANDEFAULTtrue,created_at TIMESTAMPTZDEFAULTNOW(),updated_at TIMESTAMPTZDEFAULTNOW());check_config是整个设计的核心。不同的check_type对应不同的 JSONB 结构interfaceRuleConfig{type:regex|keyword|image|rules_engine|combo;pattern?:string;// 正则表达式regex 类型用keywords?:string[];// 关键词列表keyword 类型用required?:boolean;// 是否必须存在page_scope?:string;// 检查范围: first_page/last_page/allerror_msg?:string;// 错误提示confidence?:number;// 置信度阈值image 类型用// rules_engine 类型专用支持嵌套子规则logic?:AND|OR|NOT;sub_rules?:RuleConfig[];}举个例子密封处必须加盖公章这条规则的配置{type:regex,pattern:密封处.*(?:公章|印章),required:true,error_msg:密封处未加盖公章,page_scope:last_page}用 JSONB 的好处是可以用 PostgreSQL 的 JSONB 索引做查询优化比如按check_type过滤规则时走 GIN 索引而不是全表扫描。规则引擎统一调度所有检查规则引擎的主类DisqualificationEngine做的事情不复杂——加载活跃规则遍历执行汇总结果。但有几个工程上的细节值得说。classDisqualificationEngine{privaterules:DisqRule[];privateimageDetector:SignatureDetector;asynccheck(assessment:Assessment):PromiseDisqResult[]{constresults:DisqResult[][];constpdfChunksawaitthis.parsePDF(assessment.fileUrl);for(construleofthis.rules){// 行业过滤非本行业的规则跳过if(rule.industry!allrule.industry!assessment.industry){continue;}letresult:DisqResult;switch(rule.check_type){caseregex:resultthis.checkRegex(rule,pdfChunks);break;casekeyword:resultthis.checkKeyword(rule,pdfChunks);break;caseimage:resultawaitthis.imageDetector.check(rule,pdfChunks);break;caserules_engine:resultawaitthis.checkRulesEngine(rule,pdfChunks);break;casecombo:resultawaitthis.checkCombo(rule,pdfChunks);break;}results.push(result);}returnresults;}}正则检查的实现比较直接但有一个容易忽略的点page_scope过滤。有些规则只在最后一页生效比如密封章有些只在第一页比如封面信息需要在匹配前先把 chunk 按页码范围过滤掉否则会误报。privatecheckRegex(rule:DisqRule,chunks:PDFChunk[]):DisqResult{constconfigrule.check_configasRegexConfig;constregexnewRegExp(config.pattern,g);consttargetChunksthis.filterByPageScope(chunks,config.page_scope);for(constchunkoftargetChunks){constmatchchunk.text.match(regex);if(match){return{rule_id:rule.id,status:config.required?pass:warning,evidence:{page:chunk.page_number,text:match[0],confidence:1.0}};}}return{rule_id:rule.id,status:config.required?fail:pass,evidence:null};}包号一致性校验这个模块解决一个具体的业务问题同一个包号信息项目名称、投标截止时间、保证金金额等在招标文件和投标文件中必须完全一致。但人工比对时全角半角、空格、标点符号的差异经常导致误判。核心逻辑是文本归一化后再对比classPackageConsistencyChecker{asynccheck(assessmentId:string):PromiseConsistencyResult[]{constresults:ConsistencyResult[][];consttenderPackagesawaitthis.extractTenderPackages(assessmentId);constbidPackagesawaitthis.extractBidPackages(assessmentId);constfieldsToCheck[project_name,bid_deadline,bid_bond,qualification_req,technical_score_ratio];for(constpkgoftenderPackages){constbidPkgbidPackages.find(bb.package_nopkg.package_no);if(!bidPkg){results.push({package_no:pkg.package_no,consistent:false,diff_detail:投标文件中未找到包号${pkg.package_no}的响应});continue;}for(constfieldoffieldsToCheck){constnormalizedTenderthis.normalize(pkg[field]);constnormalizedBidthis.normalize(bidPkg[field]);if(normalizedTender!normalizedBid){results.push({package_no:pkg.package_no,field_name:field,consistent:false,diff_detail:招标文件: ${pkg[field]} vs 投标文件: ${bidPkg[field]}});}}}returnresults;}// 文本归一化去空格、全角转半角、统一小写privatenormalize(text:string):string{returntext.replace(/[\s\u3000]/g,).replace(/[。]/g,mString.fromCharCode(m.charCodeAt(0)-0xFEE0)).toLowerCase();}}normalize函数做了三件事去除所有空格包括全角空格\u3000、全角标点转半角、统一小写。这样包 1和包1、“和”,就不会被误判为不一致。签章检测OpenCV ONNX 模型签章检测是整个系统中最重的部分。我们用的是 OpenCV 做图像预处理自训练的 ONNX 模型做签章区域检测和分类。图像预处理流水线privatepreprocess(imageData:Buffer):Tensor{constimgcv.imdecode(imageData);// 1. 转灰度constgraycv.cvtColor(img,cv.COLOR_BGR2GRAY);// 2. 高斯降噪constdenoisedcv.GaussianBlur(gray,[5,5],0);// 3. 自适应二值化处理光照不均constbinarycv.adaptiveThreshold(denoised,255,cv.ADAPTIVE_THRESH_GAUSSIAN_C,cv.THRESH_BINARY,11,2);returnthis.toTensor(binary);}这里用自适应二值化而不是全局阈值是因为扫描件的招标文件经常光照不均匀——有的区域偏白有的偏灰。全局阈值会把浅色签章直接丢掉。检测流程asynccheck(rule:DisqRule,chunks:PDFChunk[]):PromiseDisqResult{constconfigrule.check_configasImageConfig;for(constchunkofchunks){if(!chunk.image_data)continue;constprocessedthis.preprocess(chunk.image_data);constdetectionsawaitthis.detect(processed);// 按置信度阈值过滤constvalidDetectionsdetections.filter(dd.confidenceconfig.confidence);if(validDetections.length0){constclassifiedawaitthis.classify(validDetections);return{rule_id:rule.id,status:pass,evidence:{page:chunk.page_number,seal_type:classified.type,// 公章/合同章/法人章confidence:classified.confidence,bbox:classified.bounding_box}};}}return{rule_id:rule.id,status:fail,evidence:{error:未检测到签章}};}模型输出包括签章类型分类公章、合同章、法人章和置信度分数。置信度阈值通过check_config中的confidence字段配置不同类型的项目可以调不同的阈值。密封检查密封检查结合了文本匹配和图像检测两条路径asynccheckSeal(assessment:Assessment):PromiseSealCheckResult{constresults:SealCheckResult{has_seal:false,seal_position:null,seal_type:null,issues:[]};constlastPageawaitthis.getLastPage(assessment.fileUrl);// 路径1文本匹配——检查是否有密封处封口等标识文字constsealTextPattern/密封[处口线]|封口|密封章/g;consthasSealTextsealTextPattern.test(lastPage.text);// 路径2图像检测——检查密封处是否有公章/骑缝章constsealDetectionawaitthis.imageDetector.detectSeal(lastPage.image);if(hasSealTextsealDetection.has_seal){results.has_sealtrue;results.seal_positionsealDetection.position;results.seal_typesealDetection.type;}else{if(!hasSealText)results.issues.push(未找到密封处标识文字);if(!sealDetection.has_seal)results.issues.push(密封处未检测到公章);}// 识别密封方式constsealMethodPattern/密封条|密封章|密封袋|火漆/g;constsealMethodlastPage.text.match(sealMethodPattern);results.seal_methodsealMethod?sealMethod[0]:未识别;returnresults;}两条路径都通过才算密封合格。只有文字标识没有章或者有章但找不到密封处标识都会报 issue。踩坑记录坑1JSONB 规则配置的性能问题。规则数量上到几百条之后全量加载规则表会变慢。解法是启动时一次性加载到内存后续只监听增量更新。规则变更不频繁缓存命中率很高。坑2正则匹配的性能。有些规则的正则写得不好比如带大量回溯的.*在几百页的 PDF 文本上跑会很慢。解法是对规则正则做预检查禁止使用会导致 catastrophic backtracking 的模式。坑3扫描件 OCR 质量差异大。不同扫描仪出来的 PDF 质量差别很大有的清晰有的模糊到 OCR 识别率不到 60%。解法是对 OCR 置信度低于阈值的页面标记为需人工复核而不是直接给出结论。坑4图像检测的误报。有些招标文件上的红色水印、Logo 会被模型误识别为签章。解法是在分类阶段增加了对签章形状圆形、椭圆和文字特征的二次校验。小结废标风控引擎的核心设计思路是用 JSONB 统一存储不同类型的规则配置用规则引擎统一调度检查逻辑用图像检测处理签章和密封这类视觉检查项。技术上没有特别花哨的东西更多是工程上的细节处理——文本归一化、页码范围过滤、OCR 质量兜底、正则性能防护。这些细节单独看都不难但漏掉任何一个都会在实际使用中出问题。#废标风控 #规则引擎 #PDF解析 #签章检测 #TypeScript #OpenCV #ONNX #招投标 #工程实践