This commit is contained in:
quyixiao 2026-06-10 01:48:44 +08:00
parent bdd3f16cd6
commit 09348e7e53
3 changed files with 446 additions and 62 deletions

View File

@ -1,27 +1,401 @@
package com.heyu.api.controller.car;
import com.heyu.api.baidu.handle.financial.BTrainTicketHandle;
import com.heyu.api.baidu.request.financial.BTrainTicketRequest;
import com.heyu.api.controller.BaseController;
import com.heyu.api.controller.ocr.BaiduOcrResult;
import com.heyu.api.controller.AbstractRecognizeController;
import com.heyu.api.controller.BaiduOcrError;
import com.heyu.api.data.annotation.CacheResult;
import com.heyu.api.data.annotation.EbAuthentication;
import com.heyu.api.data.annotation.NotIntercept;
import com.heyu.api.data.constants.ApiConstants;
import com.heyu.api.data.utils.ImageInputUtils;
import com.heyu.api.data.utils.ImageInputUtils.ResolvedImageInput;
import com.heyu.api.data.utils.MapUtils;
import com.heyu.api.data.utils.R;
import org.springframework.beans.factory.annotation.Autowired;
import com.heyu.api.data.utils.StringUtils;
import com.heyu.api.request.car.TrainTicketRecognizeRequest;
import com.heyu.api.resp.car.RecognizeTrainTicketResp;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
/**
* 火车票识别控制器
* <p>
* <b>接口背景</b>用于差旅报销行程审核票务核验等场景将火车票照片自动识别为结构化字段
* 替代人工录入底层对接百度智能云文字识别火车票识别能力由本服务完成参数校验请求转发与字段映射
* </p>
* <p>
* 支持版式纸质红色车票 / 蓝色磁卡车票 / 电子客票报销凭证自动识别始发终到车次席别座位票价
* 乘车人姓名与身份证号输出已脱敏等核心字段
* </p>
*
* <h3>百度官方文档</h3>
* <ul>
* <li>产品文档<a href="https://cloud.baidu.com/doc/OCR/s/Wk3h7y0lo">火车票识别</a></li>
* <li>接口地址{@code POST https://aip.baidubce.com/rest/2.0/ocr/v1/train_ticket}</li>
* </ul>
*
* <h3>本服务接口</h3>
* <ul>
* <li>路径{@code POST /train/ticket/recognize}</li>
* <li>Content-Type{@code application/json}{@code @RequestBody}</li>
* <li>鉴权{@link EbAuthentication}Tencent 鉴权头见项目网关配置</li>
* <li>结果缓存{@link CacheResult} 命中时不再调用平台节省计费</li>
* </ul>
*
* <h3>请求参数{@link TrainTicketRecognizeRequest}</h3>
* <ul>
* <li>imageUrlOrBase64HTTP(S) 链接或 Base64 字符串服务端自动识别支持 jpg/jpeg/png/bmp</li>
* </ul>
*
* <h3>响应结构{@link RecognizeTrainTicketResp}</h3>
* <ul>
* <li>核心date 日期 / time 时间 / departureStation 始发 / destination 终到 / number 车次</li>
* <li>票务level 席别 / seat 座位 / price 票价 / ticketNum 票号 / salesStation 售票站 / serialNumber 序列号</li>
* <li>乘车人name 姓名 / idNum 身份证号 6 + 4 中间脱敏</li>
* </ul>
*
* <h3>返回约定重要</h3>
* <ul>
* <li>一律 {@code R.ok()}入参校验失败平台异常识别结果为空等情况将说明写入 {@code msg}
* {@code data} 可能为空或字段不全对外不暴露百度字样日志内可排查</li>
* <li>入参校验失败时<strong>未调用百度不计费</strong></li>
* <li>身份证号统一脱敏返回中间 8 位以 * 替代原值不进入日志</li>
* </ul>
*
* @author heyu
* @since 1.0.0
* @see TrainTicketRecognizeRequest
* @see RecognizeTrainTicketResp
*/
@Slf4j
@RestController
@RequestMapping("/train/ticket")
@NotIntercept
public class RecognizeTrainTicketController extends BaseController {
public class RecognizeTrainTicketController extends AbstractRecognizeController {
@Autowired
private BTrainTicketHandle bTrainTicketHandle;
/** 百度火车票识别 API 路径 */
private static final String TRAIN_TICKET_URI = "/rest/2.0/ocr/v1/train_ticket";
@RequestMapping("/recognize")
/** 火车票识别无 side 概念,统一占位以复用父类上下文 */
private static final String SIDE_PLACEHOLDER = "train_ticket";
/**
* 火车票识别
*
* @param request 火车票识别请求
* @return {@link RecognizeTrainTicketResp}
*/
@EbAuthentication(tencent = ApiConstants.TENCENT_AUTH)
@CacheResult
public R recognize(BTrainTicketRequest request) {
return BaiduOcrResult.raw(bTrainTicketHandle.handle(request));
@PostMapping("/recognize")
public R recognize(@RequestBody TrainTicketRecognizeRequest request) {
long start = System.currentTimeMillis();
RecognizeContext ctx = null;
try {
// ---------- 步骤参数校验不调百度不扣费 ----------
String validateError = validateRequest(request);
if (validateError != null) {
ResolvedImageInput validateImage = request != null
? ImageInputUtils.resolve(request.getImageUrlOrBase64()) : null;
log.info("火车票识别:参数检查没通过,接口仍返回成功并附带提示(还没调识别、不扣费)。{} 返回给客户:{}",
buildInputLogContext(request, validateImage),
abbreviate(validateError, 120));
return okResult(SIDE_PLACEHOLDER, validateError, null);
}
// ---------- 步骤解析影像入参 & 构建上下文 ----------
ResolvedImageInput imageInput = ImageInputUtils.resolve(request.getImageUrlOrBase64());
ctx = new RecognizeContext(
SIDE_PLACEHOLDER,
imageInput,
buildInputLogContext(request, imageInput));
// ---------- 步骤组装百度 API 请求体 ----------
String content = buildRequestContent(imageInput);
if (isBlank(content)) {
log.error("火车票识别:组装请求失败,请求里没带有效图片。{}。{}",
ctx.imageInput.getType().getDesc(), ctx.inputLog);
return okResult(SIDE_PLACEHOLDER, formatHint(
"报文组装异常",
"识别请求体在序列化后未包含任何影像载荷,平台侧无法受理本次识别",
"请核对 imageUrlOrBase64 是否非空且为有效 Base64 或 HTTP(S) 链接",
"影像模式=" + ctx.imageInput.getType().name()
), null);
}
// ---------- 步骤调用百度平台识别 ----------
Map<String, Object> platformResult = callPlatform(content, ctx);
if (platformResult == null) {
return okResult(SIDE_PLACEHOLDER, formatHint(
"服务无回执",
"识别指令已下发,但在约定时间内未收到平台可解析的 JSON 回执",
"建议间隔 3~5 秒重试;若连续失败,请记录 traceId、调用时刻并联系技术支持排查链路",
"影像模式=" + ctx.imageInput.getType().name()
), null);
}
// ---------- 步骤解析平台结果 构建车票响应 PII 脱敏 ----------
RecognizeTrainTicketResp data = buildResp(platformResult);
String hint = resolvePlatformHint(platformResult, ctx, data);
// ---------- 步骤日志记录 & 返回 ----------
logRecognizeResult(ctx, platformResult, data, hint, start);
return okResult(SIDE_PLACEHOLDER, hint, data);
} catch (Exception e) {
long cost = System.currentTimeMillis() - start;
ResolvedImageInput fallbackImage = request != null
? ImageInputUtils.resolve(request.getImageUrlOrBase64()) : null;
String mode = ctx != null ? ctx.imageInput.getType().getDesc()
: (fallbackImage != null ? fallbackImage.getType().getDesc() : "未知");
log.error("火车票识别:程序运行出错,耗时 {} ms。{}。客户传的:{}。异常:{} - {}",
cost, mode, buildInputLogContext(request, fallbackImage),
e.getClass().getSimpleName(),
e.getMessage() != null ? e.getMessage() : "无具体说明", e);
return okResult(SIDE_PLACEHOLDER, formatHint(
"运行时故障",
"服务端在处理识别流程时抛出未预期异常,识别结果不可用",
"请勿重复高频重试;请保存 traceId、异常发生时间由技术支持结合堆栈进一步定位",
"异常类型=" + e.getClass().getSimpleName()
+ (e.getMessage() != null ? ",摘要=" + e.getMessage() : "")
), null);
}
}
/**
* 子类钩子火车票识别只有一种响应类型 side 区分
*/
@Override
protected Object defaultEmptyResp(String side) {
return new RecognizeTrainTicketResp();
}
// ===================== 流程拆分方法 =====================
private Map<String, Object> callPlatform(String content, RecognizeContext ctx) {
int len = content.length();
log.info("火车票识别:开始调用平台识别。{},请求大小约 {} 字节。{}",
ctx.imageInput.getType().getDesc(), len, ctx.inputLog);
Map<String, Object> result = requestBaidu(TRAIN_TICKET_URI, content);
if (result == null) {
log.error("火车票识别:平台没有返回结果(可能网络超时、鉴权失败或响应无法解析)。{},请求约 {} 字节。{}",
ctx.imageInput.getType().getDesc(), len, ctx.inputLog);
}
return result;
}
/**
* 解析平台返回结果判断是否需要向客户返回提示信息
* <p>判断顺序</p>
* <ol>
* <li>error_code 存在 平台业务拒绝</li>
* <li>words_result 缺失或非对象 平台未识别出车票</li>
* <li>响应对象全部 String 字段为空 字段映射失败</li>
* <li>以上均不命中 返回 null识别正常</li>
* </ol>
*/
private String resolvePlatformHint(Map<String, Object> platformResult, RecognizeContext ctx,
RecognizeTrainTicketResp data) {
Object errorCodeObj = platformResult.get("error_code");
if (errorCodeObj != null) {
String errorCode = String.valueOf(errorCodeObj);
Object errorMsgObj = platformResult.get("error_msg");
String errorMsg = errorMsgObj != null ? errorMsgObj.toString() : "平台未返回文字描述";
String category = BaiduOcrError.categoryOf(errorCode);
log.error("火车票识别:平台拒绝了本次识别。[{}] 错误码 {},原因:{}。{}。客户传的:{}。{}",
category, errorCode, errorMsg,
ctx.imageInput.getType().getDesc(),
ctx.inputLog, buildPlatformReceiptSummary(platformResult));
return formatHint(
category,
BaiduOcrError.reasonOf(errorCode),
BaiduOcrError.suggestionOf(errorCode, "火车票"),
"错误码=" + errorCode + ",错误描述=" + errorMsg
);
}
Object wordsResult = platformResult.get("words_result");
if (!(wordsResult instanceof Map) || ((Map<?, ?>) wordsResult).isEmpty()) {
log.info("火车票识别:平台返回了,但 words_result 缺失或为空对象(可能不是火车票、画面残缺或非证件照)。{}。客户传的:{}。{}",
ctx.imageInput.getType().getDesc(),
ctx.inputLog, buildPlatformReceiptSummary(platformResult));
return formatHint(
"结构化结果缺失",
"平台回执未包含可解析的火车票识别结果,无法进入字段映射环节",
"优先排查:① 画面是否为完整火车票(含车次/座位等核心信息);② 是否模糊 / 反光 / 过度倾斜;"
+ "③ 使用 URL 时确保平台抓取节点可访问且无 403/302 拦截",
"解析状态=结果对象为空"
);
}
if (isAllStringFieldsBlank(data)) {
log.info("火车票识别:平台有返回,但车次/始发/终到/姓名等字段一个都没识别出来。{}。客户传的:{}。平台回执:{}",
ctx.imageInput.getType().getDesc(),
ctx.inputLog, buildPlatformReceiptSummary(platformResult));
return formatHint(
"字段映射为空",
"平台回执已通过结构校验,但车次、始发/终到、座位等结构化字段均未命中",
"请确认上传图片为正面完整的火车票照片;推荐:白底拍摄、避开反光、保证四角与文字清晰",
"已映射字段数=0"
);
}
return null;
}
private void logRecognizeResult(RecognizeContext ctx, Map<String, Object> platformResult,
Object data, String hint, long start) {
long cost = System.currentTimeMillis() - start;
int mapped = countNonBlankStringFields(data);
if (StringUtils.isNotBlank(hint)) {
log.info("火车票识别:处理结束(接口仍返回成功,但带了提示信息)。耗时 {} ms识别出 {} 个字段。{}。客户传的:{}。平台回执:{}。给客户的提示:{}",
cost, mapped, ctx.imageInput.getType().getDesc(),
ctx.inputLog, buildPlatformReceiptSummary(platformResult), abbreviate(hint, 120));
} else {
log.info("火车票识别:识别成功。耗时 {} ms共识别出 {} 个字段。{}。客户传的:{}。平台回执:{}",
cost, mapped, ctx.imageInput.getType().getDesc(),
ctx.inputLog, buildPlatformReceiptSummary(platformResult));
}
}
// ===================== 校验 =====================
private String validateRequest(TrainTicketRecognizeRequest request) {
if (request == null) {
log.info("火车票识别:没收到任何请求参数(表单未绑定成功),直接拒绝,未调用识别、不扣费");
return formatHint(
"入参绑定失败",
"控制器未接收到可绑定的请求对象,所有业务字段均为空",
"请确认使用 POST 提交Content-Type 为 application/json字段名为 imageUrlOrBase64",
"绑定结果=TrainTicketRecognizeRequest 为 null"
);
}
ImageInputUtils.ValidationResult imageValidation =
ImageInputUtils.validate(request.getImageUrlOrBase64());
if (!imageValidation.isValid()) {
log.info("火车票识别imageUrlOrBase64 校验未通过({}),未调用识别、不扣费。原因:{}。{}",
imageValidation.getCategory(),
imageValidation.getReason(),
buildInputLogContext(request, null));
return formatHint(
imageValidation.getCategory(),
imageValidation.getReason(),
imageValidation.getSuggestion(),
imageValidation.getDetail()
);
}
return null;
}
// ===================== 请求 / 响应构造 =====================
private String buildRequestContent(ResolvedImageInput imageInput) {
StringBuilder sb = new StringBuilder();
if (imageInput == null || StringUtils.isBlank(imageInput.getFormValue())) {
return sb.toString();
}
if (ImageInputUtils.ImageInputType.URL == imageInput.getType()) {
sb.append("&url=").append(imageInput.getFormValue());
} else {
sb.append("&image=").append(imageInput.getFormValue());
}
return sb.toString();
}
/**
* 将百度返回的 words_result单对象映射为对外响应
* 身份证号在此步骤完成脱敏仅保留前 6 位与后 4 中间用 * 替代
*/
private RecognizeTrainTicketResp buildResp(Map<String, Object> platformResult) {
RecognizeTrainTicketResp resp = new RecognizeTrainTicketResp();
resp.setDate(getWord(platformResult, "date"));
resp.setTime(getWord(platformResult, "time"));
resp.setDepartureStation(firstNonBlank(
getWord(platformResult, "starting_station"),
getWord(platformResult, "sales_station")));
resp.setDestination(getWord(platformResult, "destination_station"));
resp.setNumber(getWord(platformResult, "train_num"));
resp.setLevel(getWord(platformResult, "seat_category"));
resp.setSeat(getWord(platformResult, "seat_num"));
resp.setName(getWord(platformResult, "name"));
resp.setIdNum(maskIdNumber(getWord(platformResult, "id_num")));
resp.setPrice(parsePrice(getWord(platformResult, "ticket_rates")));
resp.setTicketNum(getWord(platformResult, "ticket_num"));
resp.setSalesStation(getWord(platformResult, "sales_station"));
resp.setSerialNumber(getWord(platformResult, "serial_number"));
return resp;
}
/**
* words_result 对象中取指定字段百度返回的是 string无需 .words 包裹
*/
private String getWord(Map<String, Object> platformResult, String field) {
return MapUtils.getByExpr(platformResult, "words_result." + field);
}
/**
* 把百度返回的 "ticket_rates"字符串如 "104.5元" / "104.5"解析为 Float
* 解析失败返回 null保持响应字段不混入脏数据
*/
private Float parsePrice(String rawPrice) {
if (StringUtils.isBlank(rawPrice)) {
return null;
}
String cleaned = rawPrice.replaceAll("[^0-9.]", "").trim();
if (cleaned.isEmpty()) {
return null;
}
try {
return Float.parseFloat(cleaned);
} catch (NumberFormatException e) {
return null;
}
}
/**
* 对身份证号脱敏保留前 6 + 4 中间统一用 * 替代
* 长度不足 10 位时整体置为 ****避免反推
*/
private String maskIdNumber(String raw) {
if (StringUtils.isBlank(raw)) {
return null;
}
String trim = raw.trim();
if (trim.length() < 10) {
return "****";
}
int total = trim.length();
String head = trim.substring(0, 6);
String tail = trim.substring(total - 4);
StringBuilder middle = new StringBuilder();
for (int i = 0; i < total - 10; i++) {
middle.append('*');
}
return head + middle + tail;
}
// ===================== 私有工具 =====================
private String formatHint(String category, String reason, String suggestion, String detail) {
StringBuilder sb = new StringBuilder();
sb.append("【火车票识别·").append(category).append("").append(reason);
if (StringUtils.isNotBlank(detail)) {
sb.append("|定位信息:").append(detail);
}
sb.append("|处置指引:").append(suggestion);
return sb.toString();
}
/** 入参日志说明(不打印影像正文,只说类型与长度,禁止泄露 PII */
private String buildInputLogContext(TrainTicketRecognizeRequest request, ResolvedImageInput imageInput) {
if (request == null) {
return "未收到请求体";
}
String modeDesc = imageInput != null ? imageInput.getType().getDesc() : "影像未解析";
int rawLen = imageInput != null
? textLength(imageInput.getRawValue())
: textLength(request.getImageUrlOrBase64());
return String.format("%s影像原始长度 %d 字符", modeDesc, rawLen);
}
}

View File

@ -3,25 +3,23 @@ package com.heyu.api.request.car;
import lombok.Data;
/**
* 火车票识别请求参数百度OCR
* 火车票识别请求参数
*
* 支持识别各类火车票的全部字段包括车票号码乘车日期时间出发站到达站车次号席别座位号
* 对外提供简洁接口支持识别各类火车票的全部字段包括车票号码乘车日期时间出发站到达站车次号席别座位号
* 乘车人姓名票价等信息
*/
@Data
public class TrainTicketRecognizeRequest {
/**
* 图像数据base64编码后进行urlencode要求base64编码和urlencode后大小不超过4M
* 支持jpg/jpeg/png/bmp格式
* 和url二选一
* 影像入参URL Base64 二合一
* <ul>
* <li>HTTP/HTTPS 图片链接1024 字符 urlencode 时由服务端自动编码</li>
* <li>图片 Base64 字符串支持 jpg/jpeg/png/bmp可带 data:image/...;base64, 前缀
* 编码后 urlencode 前建议4M urlencode 时由服务端自动编码</li>
* </ul>
* 服务端根据内容自动识别为链接或 Base64
*/
private String imageBase64;
/**
* 图片完整URLURL长度不超过1024字节
* 和imageBase64二选一
*/
private String imageUrl;
private String imageUrlOrBase64;
}

View File

@ -1,80 +1,92 @@
package com.heyu.api.resp.car;
import com.heyu.api.data.dto.BaseResp;
import lombok.Data;
/**
* 火车票识别响应
* 火车票识别响应
*
* 百度OCR文档https://console.bce.baidu.com/support/#/api?product=AI&project=文字识别&parent=财务票据OCR&api=rest%2F2.0%2Focr%2Fv1%2Ftrain_ticket&method=post
* 字段对齐阿里云 RecognizeTrainTicket 规范覆盖纸质 / 电子 / 蓝色 / 红色等常见车票版式
*/
@Data
public class RecognizeTrainTicketResp extends BaseResp {
/**
* 乘车日期时间
*
* 示例值:
* 2017年08月05日2209开
* 乘车日期
* 示例值2017年08月05日
*/
public String date;
/***
* 始发站点
*
* 示例值:
* 苏州站
/**
* 开车时间""字尾缀
* 示例值22:09开
*/
public String time;
/**
* 始发站
* 示例值苏州站
*/
public String departureStation;
/***
* 目的站点
*
* 示例值:
* 南京南站
/**
* 目的站
* 示例值南京南站
*/
public String destination;
/**
* 车次号
* 示例值G7350
*/
public String number;
/**
* 座位席别
*
* 示例值:
* 二等座
* 示例值二等座
*/
public String level;
/**
* 座位车厢及座次号
* 示例值04车13A号
*/
public String seat;
/**
* 乘车人姓名
*
* 示例值:
* 帅帅
* 示例值帅帅
*/
public String name;
/**
* 车次号
*
* 示例值:
* G7350
* 乘车人身份证号已脱敏仅保留前 6 + 4
* 示例值320106********0024
*/
public String number;
public String idNum;
/***
/**
* 票价
*
* 示例值:
* 104.5
* 示例值104.5
*/
public Float price;
/***
* 座位车厢及座次号
*
* 示例值:
* 04车13A号
/**
* 票号
* 示例值Y123456
*/
public String seat;
public String ticketNum;
/**
* 售票站
* 示例值苏州站
*/
public String salesStation;
/**
* 序列号
* 示例值08051022090107L
*/
public String serialNumber;
}