一张发票拍歪、带阴影、字段布局不统一,传统 OCR 往往要为每种模板写规则。视觉语言模型能同时看文字与版面,却仍可能把“含税金额”和“价税合计”看混。可行做法不是让模型直接报销,而是让它给出候选字段,Java 再检查金额格式、上限和必填项。读完你能搭出一个不会把模型文本直接当财务事实的最小入口。
Gemini 官方图像理解文档说明:小图片可作为 inline data 发送,模型能进行图像描述、分类与视觉问答。这里用 REST 而非 Java SDK,原因是 Java 17 自带 HttpClient,示例无需引入不确定版本的第三方包;正式项目可按官方迁移文档改用 Google GenAI SDK。

先准备环境
需要 Java 17+ 与一张已脱敏的 receipt.jpg。从环境变量读取密钥,绝不把密钥写进类文件。模型名 gemini-3.8-flash 与请求路径来自官方生成内容与图像理解文档;不同账号可用模型会变化,应在发布前核对。
export GEMINI_API_KEY='你的密钥'
javac ReceiptCheck.java && java ReceiptCheck receipt.jpg
完整代码
为保持最小可复制性,提示要求单行 JSON;extract 是教学级的窄解析器,生产环境请使用 Jackson 等成熟 JSON 库并校验完整响应结构。HTTP 连接设为 10 秒、请求设为 25 秒。
import java.net.URI;
import java.net.http.*;
import java.nio.file.*;
import java.time.Duration;
import java.util.Base64;
import java.util.regex.*;
public class ReceiptCheck {
static String value(String json, String name) {
Matcher m = Pattern.compile("\\\"" + name + "\\\"\\s*:\\s*\\\"?([^,}\\\"]+)").matcher(json);
if (!m.find()) throw new IllegalArgumentException("缺少字段: " + name);
return m.group(1).trim();
}
static boolean valid(String invoiceNo, String amount) {
if (!invoiceNo.matches("[A-Za-z0-9-]{6,32}")) return false;
try {
double money = Double.parseDouble(amount);
return money >= 0 && money <= 50000;
} catch (NumberFormatException e) { return false; }
}
public static void main(String[] args) throws Exception {
if (args.length != 1) throw new IllegalArgumentException("用法: java ReceiptCheck receipt.jpg");
String key = System.getenv("GEMINI_API_KEY");
if (key == null || key.isBlank()) throw new IllegalStateException("缺少 GEMINI_API_KEY");
String image = Base64.getEncoder().encodeToString(Files.readAllBytes(Path.of(args[0])));
String prompt = "读取票据。只返回 JSON: {\"invoice_no\":\"\",\"total_amount\":\"0.00\"};"
+ "看不清则用 UNKNOWN,不能猜测。";
String safePrompt = prompt.replace("\\", "\\\\").replace("\"", "\\\"");
String body = "{\"contents\":[{\"parts\":[{\"text\":\"" + safePrompt + "\"},"
+ "{\"inline_data\":{\"mime_type\":\"image/jpeg\",\"data\":\"" + image + "\"}}]}]}";
HttpClient client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)).build();
HttpRequest request = HttpRequest.newBuilder(URI.create("https://generativelanguage.googleapis.com/v1beta/models/gemini-3.8-flash:generateContent?key=" + key))
.timeout(Duration.ofSeconds(25)).header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(body)).build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200) throw new IllegalStateException("API 状态: " + response.statusCode());
String text = value(response.body(), "text");
String no = value(text.replace("\\\\\"", "\\\""), "invoice_no");
String amount = value(text.replace("\\\\\"", "\\\""), "total_amount");
System.out.println(valid(no, amount) ? "PASS " + no + " " + amount : "REVIEW 原图与字段不一致");
}
}
原理与边界
内联图片会被 Base64 编码后成为请求的一部分;模型输出只是候选,valid 把发票号限定为 6–32 位、金额限定为 0–50000。上限不是通用财务规则,而是教学阈值,应改成企业政策。成功的预期输出类似 PASS A1234567 128.50;格式异常或无法读取时应进入 REVIEW。本次已用 javac ReceiptCheck.java 完成语法编译,未提供密钥或票据,没有调用线上 API。
三个常见错误
- 把原始票据上传到调试日志:票据可能含税号和地址;保存哈希、字段结果和最短必要审计信息即可。
- 用
double结算:本例仅做范围门禁;真实金额应使用BigDecimal并按币种规则舍入。 - 只看 HTTP 200:200 不等于识别成功,仍要检查候选、字段与业务一致性。
适用场景与下一步
适合低风险的预填单、票据归档和人工审核加速;不适合无人工复核的付款、税务申报与凭证替代。工程化可增加:图片大小限制、病毒扫描、同一发票号去重、原图访问权限、双模型抽检与人工纠正回流。五分钟练习:把 mime_type 改为按文件扩展名选择,并为 PNG 写一个拒绝未知类型的测试。
你的票据流程里,哪一类错误必须由程序而不是模型拦住?
关注「蜗牛聊AI」,一起看懂技术变化背后的真正机会。
本文首发于 java4u.cn,转载请注明出处。

