From 09348e7e5317f90531ef81ec6577748175619a8e Mon Sep 17 00:00:00 2001 From: quyixiao <2621048238@qq.com> Date: Wed, 10 Jun 2026 01:48:44 +0800 Subject: [PATCH] jruqwhnt --- .../car/RecognizeTrainTicketController.java | 396 +++++++++++++++++- .../car/TrainTicketRecognizeRequest.java | 22 +- .../resp/car/RecognizeTrainTicketResp.java | 90 ++-- 3 files changed, 446 insertions(+), 62 deletions(-) diff --git a/api-web/api-interface/src/main/java/com/heyu/api/controller/car/RecognizeTrainTicketController.java b/api-web/api-interface/src/main/java/com/heyu/api/controller/car/RecognizeTrainTicketController.java index bf4d9be..5817028 100644 --- a/api-web/api-interface/src/main/java/com/heyu/api/controller/car/RecognizeTrainTicketController.java +++ b/api-web/api-interface/src/main/java/com/heyu/api/controller/car/RecognizeTrainTicketController.java @@ -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; + +/** + * 火车票识别控制器 + *

+ * 接口背景:用于差旅报销、行程审核、票务核验等场景,将火车票照片自动识别为结构化字段, + * 替代人工录入。底层对接百度智能云文字识别「火车票识别」能力,由本服务完成参数校验、请求转发与字段映射。 + *

+ *

+ * 支持版式:纸质红色车票 / 蓝色磁卡车票 / 电子客票报销凭证;自动识别始发、终到、车次、席别、座位、票价、 + * 乘车人姓名与身份证号(输出已脱敏)等核心字段。 + *

+ * + *

百度官方文档

+ * + * + *

本服务接口

+ * + * + *

请求参数({@link TrainTicketRecognizeRequest})

+ * + * + *

响应结构({@link RecognizeTrainTicketResp})

+ * + * + *

返回约定(重要)

+ * + * + * @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 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 callPlatform(String content, RecognizeContext ctx) { + int len = content.length(); + log.info("火车票识别:开始调用平台识别。{},请求大小约 {} 字节。{}", + ctx.imageInput.getType().getDesc(), len, ctx.inputLog); + Map result = requestBaidu(TRAIN_TICKET_URI, content); + if (result == null) { + log.error("火车票识别:平台没有返回结果(可能网络超时、鉴权失败或响应无法解析)。{},请求约 {} 字节。{}", + ctx.imageInput.getType().getDesc(), len, ctx.inputLog); + } + return result; + } + + /** + * 解析平台返回结果,判断是否需要向客户返回提示信息。 + *

判断顺序:

+ *
    + *
  1. error_code 存在 → 平台业务拒绝
  2. + *
  3. words_result 缺失或非对象 → 平台未识别出车票
  4. + *
  5. 响应对象全部 String 字段为空 → 字段映射失败
  6. + *
  7. 以上均不命中 → 返回 null(识别正常)
  8. + *
+ */ + private String resolvePlatformHint(Map 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 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 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 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); } } diff --git a/api-web/api-interface/src/main/java/com/heyu/api/request/car/TrainTicketRecognizeRequest.java b/api-web/api-interface/src/main/java/com/heyu/api/request/car/TrainTicketRecognizeRequest.java index 7dc25bf..4438c59 100644 --- a/api-web/api-interface/src/main/java/com/heyu/api/request/car/TrainTicketRecognizeRequest.java +++ b/api-web/api-interface/src/main/java/com/heyu/api/request/car/TrainTicketRecognizeRequest.java @@ -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 二合一): + *
    + *
  • HTTP/HTTPS 图片链接(≤1024 字符,未 urlencode 时由服务端自动编码)
  • + *
  • 图片 Base64 字符串(支持 jpg/jpeg/png/bmp;可带 data:image/...;base64, 前缀; + * 编码后 urlencode 前建议≤4M,未 urlencode 时由服务端自动编码)
  • + *
+ * 服务端根据内容自动识别为链接或 Base64。 */ - private String imageBase64; - - /** - * 图片完整URL,URL长度不超过1024字节 - * 和imageBase64二选一 - */ - private String imageUrl; + private String imageUrlOrBase64; } diff --git a/api-web/api-interface/src/main/java/com/heyu/api/resp/car/RecognizeTrainTicketResp.java b/api-web/api-interface/src/main/java/com/heyu/api/resp/car/RecognizeTrainTicketResp.java index 0ec3b6d..d9acfc8 100644 --- a/api-web/api-interface/src/main/java/com/heyu/api/resp/car/RecognizeTrainTicketResp.java +++ b/api-web/api-interface/src/main/java/com/heyu/api/resp/car/RecognizeTrainTicketResp.java @@ -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日22:09开 + * 乘车日期。 + * 示例值: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; }