视觉模型能读票据,不代表每张图片都该发给它。上传错文件、超大图片和"申请金额比识别金额高很多"才是报销自动化最常见的事故入口。答案很朴素:Java 先校验文件,再让模型抽取字段,最后用 BigDecimal 做金额比对。你会拿到一个可运行的 Java 17+ 最小程序;它只演示请求构造与本地门禁,不把模型的文字当成付款指令。
Gemini 的图像理解文档说明可将图像作为多模态输入。这里采用 REST(Representational State Transfer,基于HTTP的接口风格)而非假定某个 Java SDK 的版本;请求超时、密钥和响应状态都显式处理。示例未在本次任务中实际调用线上 API;本机的 javac 缺少 Java 运行时,未能编译,已完成静态审阅。
#mermaid-svg-drFa688U3mTDVOCs{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-drFa688U3mTDVOCs .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-drFa688U3mTDVOCs .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-drFa688U3mTDVOCs .error-icon{fill:#552222;}#mermaid-svg-drFa688U3mTDVOCs .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-drFa688U3mTDVOCs .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-drFa688U3mTDVOCs .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-drFa688U3mTDVOCs .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-drFa688U3mTDVOCs .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-drFa688U3mTDVOCs .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-drFa688U3mTDVOCs .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-drFa688U3mTDVOCs .marker{fill:#333333;stroke:#333333;}#mermaid-svg-drFa688U3mTDVOCs .marker.cross{stroke:#333333;}#mermaid-svg-drFa688U3mTDVOCs svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-drFa688U3mTDVOCs p{margin:0;}#mermaid-svg-drFa688U3mTDVOCs .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-drFa688U3mTDVOCs .cluster-label text{fill:#333;}#mermaid-svg-drFa688U3mTDVOCs .cluster-label span{color:#333;}#mermaid-svg-drFa688U3mTDVOCs .cluster-label span p{background-color:transparent;}#mermaid-svg-drFa688U3mTDVOCs .label text,#mermaid-svg-drFa688U3mTDVOCs span{fill:#333;color:#333;}#mermaid-svg-drFa688U3mTDVOCs .node rect,#mermaid-svg-drFa688U3mTDVOCs .node circle,#mermaid-svg-drFa688U3mTDVOCs .node ellipse,#mermaid-svg-drFa688U3mTDVOCs .node polygon,#mermaid-svg-drFa688U3mTDVOCs .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-drFa688U3mTDVOCs .rough-node .label text,#mermaid-svg-drFa688U3mTDVOCs .node .label text,#mermaid-svg-drFa688U3mTDVOCs .image-shape .label,#mermaid-svg-drFa688U3mTDVOCs .icon-shape .label{text-anchor:middle;}#mermaid-svg-drFa688U3mTDVOCs .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-drFa688U3mTDVOCs .rough-node .label,#mermaid-svg-drFa688U3mTDVOCs .node .label,#mermaid-svg-drFa688U3mTDVOCs .image-shape .label,#mermaid-svg-drFa688U3mTDVOCs .icon-shape .label{text-align:center;}#mermaid-svg-drFa688U3mTDVOCs .node.clickable{cursor:pointer;}#mermaid-svg-drFa688U3mTDVOCs .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-drFa688U3mTDVOCs .arrowheadPath{fill:#333333;}#mermaid-svg-drFa688U3mTDVOCs .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-drFa688U3mTDVOCs .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-drFa688U3mTDVOCs .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-drFa688U3mTDVOCs .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-drFa688U3mTDVOCs .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-drFa688U3mTDVOCs .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-drFa688U3mTDVOCs .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-drFa688U3mTDVOCs .cluster text{fill:#333;}#mermaid-svg-drFa688U3mTDVOCs .cluster span{color:#333;}#mermaid-svg-drFa688U3mTDVOCs div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-drFa688U3mTDVOCs .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-drFa688U3mTDVOCs rect.text{fill:none;stroke-width:0;}#mermaid-svg-drFa688U3mTDVOCs .icon-shape,#mermaid-svg-drFa688U3mTDVOCs .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-drFa688U3mTDVOCs .icon-shape p,#mermaid-svg-drFa688U3mTDVOCs .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-drFa688U3mTDVOCs .icon-shape .label rect,#mermaid-svg-drFa688U3mTDVOCs .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-drFa688U3mTDVOCs .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-drFa688U3mTDVOCs .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-drFa688U3mTDVOCs :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 失败
通过
通过
不通过
上传票据
类型、大小校验
拒绝并提示
视觉模型抽取
人工/规则解析金额
金额容差校验
待审批
人工复核
环境与运行
需要 JDK 17+,不需要 Maven 依赖。设置 export GEMINI_API_KEY='你的密钥',把示例保存为 ReceiptGate.java,编译运行:javac ReceiptGate.java && java ReceiptGate receipt.jpg 128.50。当前端点与模型名请以你的账号和官方图像理解文档为准。真实项目不应把原始票据长期写入日志。
java
import java.io.*;
import java.math.BigDecimal;
import java.net.URI;
import java.net.http.*;
import java.nio.file.*;
import java.time.Duration;
import java.util.Base64;
public class ReceiptGate {
static void check(Path file) throws IOException {
String n = file.getFileName().toString().toLowerCase();
if (!(n.endsWith(".jpg") || n.endsWith(".jpeg") || n.endsWith(".png")))
throw new IllegalArgumentException("只接受JPG/PNG");
if (Files.size(file) > 5 * 1024 * 1024) throw new IllegalArgumentException("图片超过5MB");
}
static boolean within(BigDecimal declared, BigDecimal extracted) {
return declared.subtract(extracted).abs().compareTo(new BigDecimal("0.01")) <= 0;
}
public static void main(String[] args) throws Exception {
if (args.length != 2) throw new IllegalArgumentException("用法: 图片路径 申报金额");
String key = System.getenv("GEMINI_API_KEY");
if (key == null || key.isBlank()) throw new IllegalStateException("缺少GEMINI_API_KEY");
Path file = Paths.get(args[0]); check(file);
String b64 = Base64.getEncoder().encodeToString(Files.readAllBytes(file));
String body = "{\"contents\":[{\"parts\":[{\"text\":\"仅返回票据总金额\"},"
+ "{\"inlineData\":{\"mimeType\":\"image/jpeg\",\"data\":\"" + b64 + "\"}}]}]}";
HttpRequest req = HttpRequest.newBuilder(URI.create(
"https://generativelanguage.googleapis.com/v1beta/models/gemini-3.8-flash:generateContent"))
.timeout(Duration.ofSeconds(25)).header("x-goog-api-key", key)
.header("Content-Type", "application/json").POST(HttpRequest.BodyPublishers.ofString(body)).build();
HttpResponse<String> res = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build()
.send(req, HttpResponse.BodyHandlers.ofString());
if (res.statusCode() / 100 != 2) throw new IOException("API状态=" + res.statusCode());
System.out.println("模型原始响应请交由受控JSON解析器提取金额,HTTP=" + res.statusCode());
}
}
check 在上传前拒绝类型和大小,避免把任意文件或巨图带入模型成本;within 使用 BigDecimal,不能用 double 比较货币。请求对连接设置5秒、整体设置25秒超时;非2xx响应直接失败。为了保持示例只用 JDK,代码没有脆弱地用字符串"猜"模型金额:生产中应要求模型按 JSON Schema 返回 currency、amount 和 confidence,再用 Jackson 等成熟 JSON 库解析。
预期是成功时打印 HTTP 状态,缺密钥、文件类型不符、超过5MB或网络/API失败时抛出清晰异常。常见错误:图片实际为PNG却硬写JPEG MIME;把模型输出中的逗号金额直接转数值;将0.01容差用于不含税/含税不同口径;失败后无限重试造成重复上传。适合做"进入审批前的辅助核验",不适合自动付款或替代税务规则。
工程化时需加入病毒扫描、对象存储短期签名URL、JSON响应校验、票据哈希去重、人工复核与保留期限。5分钟实践:把 within 的容差改为按币种表配置,并为 128.50 与 128.52 写两条单元测试。
如果票据金额与员工填写金额不一致,你的流程会自动拒绝还是进入复核?
关注「蜗牛聊AI」,一起看懂技术变化背后的真正机会。
本文首发于 java4u.cn,转载请注明出处。