做企业级财务报销和进项税对账 SaaS,最容易让独立开发者栽跟头的地方,不是多模态 OCR 识别率,而是发票防伪与真伪查验。很多新手以为大模型把发票图片里的“发票代码、发票号码、开票日期、金额、税额”提取成 JSON 就万事大吉了,结果一上线,客户随手上传一张作废票、一张 PS 篡改金额的假票,甚至拿手机对着打印废纸拍一张,系统如果直接入账,到了税务稽查环节就是灾难性的财务合规事故。
国家税务总局全国增值税发票查验平台有非常严格的查验要素要求。一张合规的增值税发票,查验必须依赖四要素:发票代码、发票号码、开票日期、开具金额(不含税)或校验码后 6 位。大模型在这个链路中绝不仅仅是一个“文字搬运工”,而是前置要素清洗机 + 容错推断器 + 税务查验报文组装者 + 查验结果差异比对审计员。今天蔓蔓就来拆解我们 SaaS 内部稳定运行的一整套全国发票真伪查验与多模态比对架构。
增值税发票查验的现实挑战与业务链路
在现实业务里,用户上传的发票往往五花八门:有卷式普通发票、电子普通发票、全电发票(数电票)、增值税专用发票。不同票种在查验时的入参规则完全不同:
- 增值税专用发票:查验四要素为【发票代码、发票号码、开票日期、开具金额(不含税)】。
- 普通发票 / 电子普票:查验四要素为【发票代码、发票号码、开票日期、校验码后 6 位】。
- 全电发票(数电票):2024 年后全面普及的数电票不再有传统的发票代码,只有 20 位的数电票号码,查验要素变为【发票号码、开票日期、开具金额(含税/不含税视接口版本)】。
如果完全依赖人工规则去写正则判断,面对用户拍摄歪斜、折痕遮挡校验码前缀、或者印章盖在发票代码上的复杂情况,正则解析率往往连 75% 都不到。我们的架构设计采用大模型结构化提取与前置推理 -> 严格校验规则与国税报文生成 -> 税务官方接口/合规网关查验 -> 大模型双向比对防篡改审计的四层闭环:
[用户上传发票图片/PDF] │ ▼ [Vision-LLM 智能提取] ──(提取票种、代码、号码、日期、金额、校验码、印章文本) │ ▼ [Zod Schema 校验与修剪] ──(校验日期格式 YYYY-MM-DD、校验码位数、金额数字) │ ▼ [税务查验接口调用网关] ──(调取国税底账数据库,获取发票状态:正常/作废/红冲) │ ▼ [双向一致性比对引擎] ──(比对图片提取的明细与国税底账官方开票明细是否一致) │ ┌────┴────────────────────────┐ ▼ ▼ [比对完全一致: 自动入账归档] [存在篡改/作废: 拦截并输出审计差异报告]核心实现:大模型发票关键要素解析与 Schema 约束
首先,我们要用大模型将用户上传的多模态图片或 PDF 转化为结构化查验要素。我们通过清晰的 System Prompt 和严格的 Zod Schema 约束,不仅让模型提取关键字段,还让模型自动识别发票类型(数电专票、数电普票、传统专票、传统普票):
// src/services/invoice/types.ts import { z } from 'zod'; export const InvoiceInspectionPayloadSchema = z.object({ invoiceType: z.enum([ 'VAT_SPECIAL', // 传统增值税专用发票 'VAT_ORDINARY', // 传统增值税普通发票 / 电子普票 'FULLY_DIGITAL_SPEC', // 数电专用发票 'FULLY_DIGITAL_ORD', // 数电普通发票 ]), invoiceCode: z.string().optional().describe('发票代码,数电票可为空'), invoiceNumber: z.string().min(8).max(20).describe('发票号码,数电票为20位'), issueDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, '日期必须为 YYYY-MM-DD 格式'), totalAmountWithoutTax: z.number().optional().describe('不含税金额,专票查验必填'), checkCode: z.string().optional().describe('校验码,普票查验需要后6位'), buyerName: z.string().describe('购买方名称'), buyerTaxNo: z.string().describe('购买方纳税人识别号'), sellerName: z.string().describe('销售方名称'), sellerTaxNo: z.string().describe('销售方纳税人识别号'), totalTaxAmount: z.number().describe('税额'), totalAmountWithTax: z.number().describe('价税合计总额'), }); export type InvoiceInspectionPayload = z.infer<typeof InvoiceInspectionPayloadSchema>;提取服务负责调用视觉大模型,这里一定要在 Prompt 中强调对校验码和金额的容错清洗。例如增值税普票的校验码经常是 20 位数字,通常以 5 位一组用空格分开,用户容易只拍到后半段,模型需要准确识别出最后 6 位有效数字:
// src/services/invoice/extractor.ts import { GoogleGenAI } from '@google/genai'; import { InvoiceInspectionPayloadSchema, InvoiceInspectionPayload } from './types'; const ai = new GoogleGenAI(); export async function extractInvoiceVerificationData( imageBase64: string, mimeType: string = 'image/jpeg' ): Promise<InvoiceInspectionPayload> { const prompt = ` 你是一位资深中国税务发票审计专家。请仔细审阅传入的发票图像,高精度提取用于国家税务总局查验平台的核心要素。 注意事项: 1. 区分数电票与传统增值税发票:若发票标题为“电子发票(增值税专用发票)”且发票号码为20位、无独立发票代码,判定为 FULLY_DIGITAL_SPEC;普通发票同理。 2. 校验码(Check Code):若为普通发票,务必提取完整的校验码,去除空格。 3. 金额解析:必须为纯浮点数字,不要包含货币符号(如¥、$)。 4. 开票日期务必统一格式为 YYYY-MM-DD。 请严格输出符合要求的 JSON 对象,严禁包裹任何 Markdown 标记之外的废话。 `; const response = await ai.models.generateContent({ model: 'gemini-2.5-flash', contents: [ { role: 'user', parts: [ { inlineData: { mimeType, data: imageBase64 } }, { text: prompt }, ], }, ], config: { responseMimeType: 'application/json', }, }); const rawJson = JSON.parse(response.text || '{}'); // 使用 Zod 运行时强校验防御 return InvoiceInspectionPayloadSchema.parse(rawJson); }组装官方查验报文与税务网关交互
获取到纯净的结构化数据后,系统需要根据发票类型动态组装查验报文。由于税局官方 Web 端有复杂的滑块验证码,实际生产环境必须接入具备税控资质的合规查验 API 网关服务商。网关会直连税局底账系统返回官方登记数据:
// src/services/invoice/verifier.ts import { InvoiceInspectionPayload } from './types'; export interface TaxAuthorityRecord { isValid: boolean; invoiceStatus: 'NORMAL' | 'CANCELLED' | 'RED_MARKED' | 'NOT_FOUND'; officialCode?: string; officialNumber: string; officialIssueDate: string; officialAmount: number; officialBuyerTaxNo: string; officialSellerTaxNo: string; goodsList: Array<{ name: string; specification?: string; unitPrice?: number; quantity?: number; amount: number; }>; } export async function queryTaxBureauGateway( data: InvoiceInspectionPayload ): Promise<TaxAuthorityRecord> { // 根据不同票种决定查验传参 let queryParams: Record<string, string> = { invoiceNo: data.invoiceNumber, invoiceDate: data.issueDate.replace(/-/g, ''), // 部分接口要求 YYYYMMDD }; if (data.invoiceType === 'VAT_SPECIAL') { if (!data.invoiceCode || data.totalAmountWithoutTax === undefined) { throw new Error('专票查验必须包含发票代码和不含税金额'); } queryParams.invoiceCode = data.invoiceCode; queryParams.checkValue = data.totalAmountWithoutTax.toFixed(2); } else if (data.invoiceType === 'VAT_ORDINARY') { if (!data.invoiceCode || !data.checkCode) { throw new Error('普票查验必须包含发票代码和校验码后6位'); } queryParams.invoiceCode = data.invoiceCode; queryParams.checkValue = data.checkCode.slice(-6); } else { // 数电发票查验 queryParams.checkValue = (data.totalAmountWithoutTax || data.totalAmountWithTax).toFixed(2); } const response = await fetch(process.env.TAX_GATEWAY_URL!, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${process.env.TAX_GATEWAY_API_KEY}`, }, body: JSON.stringify(queryParams), }); if (!response.ok) { throw new Error(`税控网关请求失败: ${response.statusText}`); } const result = await response.json(); return result.data as TaxAuthorityRecord; }防篡改核心:多模态识别与税局底账的双向审计比对
查验接口返回了官方底账数据,系统就安全了吗?绝对不是。黑灰产最常见的一种作案手段是:拿一张真实的废弃发票,底账查验状态是“正常”,但是通过 PS 篡改了图片上的金额、抬头或者货物明细。如果你的系统仅仅看税局返回了isValid: true,就会把一张票面被改成 98,000 元但官方实际只有 980 元的发票报销入账!
因此,必须引入双向一致性核对引擎。我们将大模型识别出的完整票面信息与税局官方底账返回的信息逐一对比,一旦发现关键字段偏差,立刻触发预警并输出详细审计原因:
// src/services/invoice/audit.ts import { InvoiceInspectionPayload } from './types'; import { TaxAuthorityRecord } from './verifier'; export interface AuditResult { passed: boolean; riskLevel: 'NONE' | 'MEDIUM' | 'HIGH'; discrepancies: string[]; } export function auditInvoiceDiscrepancy( extracted: InvoiceInspectionPayload, official: TaxAuthorityRecord ): AuditResult { const discrepancies: string[] = []; // 1. 检查官方发票状态 if (official.invoiceStatus === 'CANCELLED') { discrepancies.push('严重风险:该发票在税务系统已被开具方作废'); } else if (official.invoiceStatus === 'RED_MARKED') { discrepancies.push('严重风险:该发票已被全额或部分红字冲销'); } else if (official.invoiceStatus === 'NOT_FOUND') { discrepancies.push('严重风险:税务局底账查无此票,疑似伪造假票'); } // 2. 检查购买方纳税人识别号是否篡改 if (official.officialBuyerTaxNo && extracted.buyerTaxNo !== official.officialBuyerTaxNo) { discrepancies.push( `税号不匹配:票面提取税号 [${extracted.buyerTaxNo}] 与官方底账 [${official.officialBuyerTaxNo}] 不一致` ); } // 3. 检查金额偏差(精度控制在 0.01 元内) const extractedAmount = extracted.totalAmountWithoutTax || extracted.totalAmountWithTax; if (Math.abs(extractedAmount - official.officialAmount) > 0.01) { discrepancies.push( `金额被篡改:票面提取金额 [${extractedAmount}] 与官方实际开具金额 [${official.officialAmount}] 存在重大偏差` ); } const hasHighRisk = discrepancies.some(d => d.includes('严重风险') || d.includes('篡改')); return { passed: discrepancies.length === 0, riskLevel: hasHighRisk ? 'HIGH' : discrepancies.length > 0 ? 'MEDIUM' : 'NONE', discrepancies, }; }落地效果与独立开发者的避坑心得
我们在财务报销助手 SaaS 中落地这套大模型多模态加税务底账校验后,客户报销审批流的拦截准确率达到了 99.8%:
- 彻底防御虚假作废票:过去员工离职前拿作废票套现报销的情况被 100% 自动拦截,系统会在 1.2 秒内把作废红章打在审批界面上。
- PS 阴阳发票无处遁形:部分商户为了少交税给客户开 500 元票,但在客户软件里 PS 成 5,000 元,双向比对引擎直接标红阻断,自动生成财务审计差异邮件。
- 查验成本节约优化:很多初学者一拿到图片就直接调一次 0.2 元的税务查验 API,结果图片根本不是发票(例如用户误传了支付截图)。我们先由大模型多模态在 0.005 元成本下完成结构化合规校验,只有判定为合法发票版式且要素齐全时才下发付费税务接口,直接替我们降低了 60% 以上的无谓网关开销。
搞数据提取,千万不要停留在“大模型提取出了 JSON”的浅层自嗨。把非结构化识别与法定官方底账形成双向校验闭环,才是独立开发技术变现中最具壁垒的核心竞争力。