diff --git a/api-web/api-interface/src/main/java/com/heyu/api/controller/car/RecognizeDriverLicense/CODE_SPECIFICATION.md b/api-web/api-interface/src/main/java/com/heyu/api/controller/car/RecognizeDriverLicense/CODE_SPECIFICATION.md index 5f2dbf2..8fc9669 100644 --- a/api-web/api-interface/src/main/java/com/heyu/api/controller/car/RecognizeDriverLicense/CODE_SPECIFICATION.md +++ b/api-web/api-interface/src/main/java/com/heyu/api/controller/car/RecognizeDriverLicense/CODE_SPECIFICATION.md @@ -6,7 +6,7 @@ > 新增同类接口(身份证、银行卡、护照、营业执照 …)请严格参照本规范, > 确保整个识别族接口在 **继承结构、返回协议、日志结构、错误处理、字段映射** 上保持一致。 > -> **文档版本**:v2.0(适配 `AbstractRecognizeController` + `BaiduOcrError` 父类抽取后的架构) +> **文档版本**:v2.2(新增日志严格两档、Controller 头注释 9 项、业务逻辑放置原则等用户体验维度规范) --- @@ -35,7 +35,7 @@ BaseController ← 通用:requestBaidu / 鉴权 / Red 1. 所有 OCR Controller **必须** 继承 `AbstractRecognizeController`,不再直接 `extends BaseController`。 2. 错误码 **不再** 在每个 Controller 内重复定义;统一使用顶级枚举 `BaiduOcrError`。 3. 影像入参 **必须** 走 `ImageInputUtils`,禁止 Controller 内自己判断 `imageUrl` / `imageBase64`。 -4. 请求对象只暴露 **两个字段**:`imageUrlOrBase64` + `side`,由服务端自动识别 URL/Base64。 +4. 请求对象**推荐**只暴露 `imageUrlOrBase64` + `side` 两个字段(识别类最优形态),由服务端自动识别 URL/Base64;业务需要可加,详见 §2.2。 --- @@ -72,10 +72,10 @@ controller/{domain}/{Object}/ ← 文档/测试报告/规范专 --- -## 二、请求对象规范(v2.0 强制) +## 二、请求对象规范(v2.1 建议为主) -### 2.1 字段约束 -**只允许两个字段**: +### 2.1 推荐基础结构 +对于"单图识别"类的最简接口,**推荐**只暴露两个字段,让调用方上手最快: ```java @Data public class XxxRequest { @@ -94,9 +94,28 @@ public class XxxRequest { } ``` -### 2.2 禁止 -- ❌ 不允许同时保留 `imageBase64` 与 `imageUrl` 两个字段(影像入参合并已沉淀) -- ❌ 不允许新增其他业务字段(如 `userId` / `merchantId`),如需上下文走鉴权/拦截层 +### 2.2 字段数量原则(建议性,按业务取舍) +- **≤ 2 字段**:识别类最优形态,强烈推荐 +- **3~4 字段**:业务确有需要可加(如多模态入参、识别选项开关),无需特批 +- **≥ 5 字段**:触发 Skill / CR 提示,请评估是否能: + - 合并语义重叠字段(如 `imageBase64` + `imageUrl` → `imageUrlOrBase64`) + - 把非业务字段(userId、merchantId、租户)下沉到鉴权 / Header / 拦截层 + - 把可选参数收敛成单个 `options` 嵌套对象 + +### 2.3 仍需遵守的硬性禁止项 +- ❌ **不允许同时保留** `imageBase64` 与 `imageUrl` 两个并列字段(影像入参合并已沉淀,统一走 `imageUrlOrBase64`) +- ❌ **不允许把鉴权信息**(userId / merchantId / token)放在 Request 对象里 —— 走 Header + AOP / `@EbAuthentication` + +### 2.4 选型决策表 +| 场景 | 推荐字段数 | 备注 | +|---|---|---| +| 单图单 side(驾驶证、行驶证) | 2 | `imageUrlOrBase64` + `side` | +| 单图无 side(银行卡、营业执照) | 1 | 仅 `imageUrlOrBase64` | +| 多图核验(人脸活体 + 身份证) | 2~3 | 多个影像字段或单个 `images: List` | +| 带识别选项(如检测方向、质量预警) | 3~4 | 可选项尽量给默认值 | +| 复杂业务(OCR + 表单解析 + 结构化) | 4~6 | 评估能否拆接口 | + +> **原则**:让用户用得**方便**优先于追求字段数极致少。 评估标准是"调用方一眼就能写出 demo"。 --- @@ -301,26 +320,49 @@ String suggestion = BaiduOcrError.suggestionOf(errorCode, sideLabel(ctx.side)); ## 八、日志规范 ### 8.1 日志格式 -- 一律使用 `@Slf4j`,禁止 `System.out.println` 与 `LoggerFactory.getLogger`。 -- 必须使用占位符 `{}`,禁止 `+` 拼接(除非全是常量)。 -- 每条业务日志必须包含:**业务名 + 处置动作 + side + imageMode + inputContext**。 +- 一律使用 `@Slf4j`,禁止 `System.out.println` 与 `LoggerFactory.getLogger` +- 必须使用占位符 `{}`,禁止 `+` 拼接(除非全是常量) +- 每条业务日志必须包含:**业务名 + 处置动作 + side + imageMode + 入参摘要 + (平台回执)** +- **每条日志要"说人话"**:完整句子描述发生了什么、为什么,禁止只罗列 `"key: {}, key: {}"` + +### 8.2 日志级别(严格两档) +**只允许 `log.info` 与 `log.error`**。禁用 `warn` / `debug` / `trace`。 -### 8.2 日志级别 | 场景 | 级别 | |---|---| | 参数校验未通过、调用前流程 | `INFO` | | 平台调用开始、识别成功 | `INFO` | +| 字段映射为空、平台返回空 words_result | `INFO`(属业务层"软失败") | | 平台返回业务错误(error_code 非空) | `ERROR` | | 平台无回执、网络/鉴权异常 | `ERROR` | -| 字段映射为空、平台返回空 words_result | `INFO`(属业务层"软失败") | | 运行时未捕获异常 | `ERROR` + 堆栈 | -### 8.3 敏感信息保护 -- **禁止** 将 Base64 全文打印到日志。仅打印长度,例如:`"影像原始长度 23456 字符"`。 -- **禁止** 打印用户姓名、身份证号、电话等 PII。如必须,用 `***` 中段脱敏。 -- URL 可以完整打印,便于复现。 +> **为什么禁 warn**:团队约定 `warn` 边界模糊,运维要么忽略要么报警,徒增混乱。要么是合法软失败(用 info),要么真的有问题(用 error)。 -### 8.4 三个标准日志构造方法 +### 8.3 文案差异化(开发者排查友好) +- 不同分支的日志文案必须可区分,禁止两个分支用同一句日志 +- 一眼能看出走到哪个分支、为什么走到那里 +- 失败类日志必须带"业务参数 + 失败原因 + 下一步排查建议"三件套 + +**好/坏对照**: +```java +// ❌ 看不懂、信息量低 +log.error("error {} {}", side, e); +log.warn("invalid input"); // 还用了禁用的 warn + +// ✅ 完整句子 + 业务参数 + 失败原因 +log.error("驾驶证识别:平台拒绝了本次识别。[{}] 错误码 {},原因:{}。识别{},{}。客户传的:{}。{}", + category, errorCode, errorMsg, + sideDesc(ctx.side), ctx.imageInput.getType().getDesc(), + ctx.inputLog, buildPlatformReceiptSummary(platformResult)); +``` + +### 8.4 敏感信息保护 +- **禁止** 将 Base64 全文打印到日志。仅打印长度,例如:`"影像原始长度 23456 字符"` +- **禁止** 打印用户姓名、身份证号、电话等 PII。如必须,用 `***` 中段脱敏 +- URL 可以完整打印,便于复现 + +### 8.5 三个标准日志构造方法 - `buildInputLogContext(request, imageInput)` — **子类实现**(涉及业务文案 sideDesc) - `buildPlatformReceiptSummary(platformResult)` — **父类提供** - `logRecognizeResult(ctx, platformResult, data, hint, start)` — **子类实现**(业务名 + 字段统计调父类) @@ -399,16 +441,25 @@ String suggestion = BaiduOcrError.suggestionOf(errorCode, sideLabel(ctx.side)); - [ ] **继承 `AbstractRecognizeController`**,不是 `BaseController` - [ ] **使用 `BaiduOcrError`**,未在 Controller 内嵌套 `PlatformError` - [ ] **未重复实现** 父类已提供的方法(okResult / getWords / isWordsResultEmpty 等) -- [ ] **请求对象只有两个字段**(imageUrlOrBase64 / side) +- [ ] **请求对象字段数评估**:≤2 推荐 / 3~4 可接受 / ≥5 已评估是否能合并、是否能下沉到鉴权层(见 §2.2) +- [ ] **请求对象不含鉴权字段**(userId / merchantId / token 走 Header) +- [ ] **影像入参不再并列** `imageBase64` 与 `imageUrl`,统一为 `imageUrlOrBase64` - [ ] **影像入参** 走 `ImageInputUtils`,未直接判断 `imageBase64`/`imageUrl` - [ ] **重写 `defaultEmptyResp(side)`** 钩子,保证 data 非空 - [ ] `recognize()` 方法 ≤ 80 行,6 个步骤注释完整 -- [ ] 所有错误返回走 `okResult()` + `formatHint()` -- [ ] `msg` / `data` 不含上游平台字样 +- [ ] **业务逻辑全部留在 Controller 内**(无 Service/Handler 中转,详见 §十五) +- [ ] **调用平台后只返回 `R.ok()`,不再 `R.error()`**(计费保护) +- [ ] 所有错误返回走 `okResult()` + `formatHint(category, reason, suggestion, detail)` 四要素 +- [ ] `msg` / `data` 不含 "百度"/"baidu"/"aliyun"/"tencent" 等上游字样 - [ ] 字段空判断使用反射工具,未手工列举 -- [ ] 日志含 `side` / `imageMode` / `inputContext` 三件套 +- [ ] **日志只有 `info` / `error` 两档**,无 `warn` / `debug` / `trace` +- [ ] **每条日志含**:业务名 + 处置动作 + side + imageMode + 入参摘要 + (平台回执) +- [ ] **日志"说人话"**:不允许只罗列 `"key: {}, key: {}"`,必须有完整描述 +- [ ] **不同分支日志文案有差异**,一眼能区分走到哪条分支 - [ ] Base64 / PII 未泄露到日志 - [ ] `@EbAuthentication` 已添加 +- [ ] **Controller 头注释包含 §十六 列出的 9 项必备小节**(背景/平台文档/接口路径/参数/返回/约定/@author/@since/@see) +- [ ] **`@author` 填真实负责人 + 联系方式**(姓名 + 邮箱 / 钉钉),便于线上找人 - [ ] 单元测试覆盖必测场景 --- @@ -446,7 +497,138 @@ String suggestion = BaiduOcrError.suggestionOf(errorCode, sideLabel(ctx.side)); --- -> **文档版本**:v2.0 +## 十五、业务逻辑位置原则(v2.2 新增) + +### 15.1 核心原则 +**所有业务判断、数据封装、异常捕获都留在 Controller 内**,不要拆到独立的 Service / Handler / Manager 类。 + +### 15.2 为什么这样设计 +OCR 适配类接口的业务很轻 —— 本质就是"参数校验 → 调上游 → 映射响应"。把这条链路拆到多个类反而: +- 排查问题要跨多个文件 +- 日志上下文断裂,traceId 在不同类间传递成本高 +- 复用价值低(每个接口的字段映射各不相同) +- 让人误以为有复杂业务(其实没有) + +**反例(不要这样写)**: +``` +Controller.recognize() + → SomeService.handle(request) + → SomeHandler.process(...) + → SomeMapper.toResp(...) +``` + +**正例**(标杆 `RecognizeDriverLicenseController`): +``` +Controller.recognize() { + step1: 参数校验 ← 私有方法 validateRequest() + step2: 构建上下文 ← new RecognizeContext(...) + step3: 组装请求体 ← 私有方法 buildRequestContent() + step4: 调上游 ← 私有方法 callPlatform() + step5: 映射响应 ← 私有方法 buildFaceResp() / buildBackResp() + step6: 日志 + 返回 ← 私有方法 logRecognizeResult() +} +``` +所有 `step*` 都是同一个 Controller 类内的 `private` 方法。 + +### 15.3 例外 +可以提取到独立类的,仅限**纯粹的、跨接口共享的工具**: +- ✅ `ImageInputUtils`(影像入参解析,所有 OCR 接口共用) +- ✅ `BaiduOcrError`(错误码字典,所有 OCR 接口共用) +- ✅ `AbstractRecognizeController` 提供的反射 / 模板方法 +- ❌ `DriverLicenseService` / `DriverLicenseHandler` — 业务专属,禁止 + +### 15.4 异常捕获要求 +- 主 try 块必须捕获 `Exception`(兜底) +- **catch 内必须分类描述**:通过 `formatHint(category, reason, suggestion, detail)` 给出完整错误信息 +- **禁止裸 catch**: + ```java + // ❌ 信息不足,开发者无法定位 + catch (Exception e) { log.error("error", e); } + + // ✅ 分类捕获 + 完整错误信息 + 计费保护(仍返回 ok) + catch (Exception e) { + log.error("驾驶证识别:程序运行出错,耗时 {} ms。识别{},{}。客户传的:{}。异常:{} - {}", + cost, sideDesc(side), mode, buildInputLogContext(request, fallbackImage), + e.getClass().getSimpleName(), + e.getMessage() != null ? e.getMessage() : "无具体说明", e); + return okResult(side, formatHint("运行时故障", "...", "...", "异常类型=..."), null); + } + ``` + +--- + +## 十六、Controller 头部注释要求(v2.2 新增) + +每个 Controller 的类 Javadoc **必须** 包含以下 9 项小节,顺序固定,便于检索: + +| # | 小节 | 必填 | 说明 | +|---|---|---|---| +| 1 | **接口背景 / 用途** | ✅ | 一段话讲清楚解决什么业务问题、服务什么场景 | +| 2 | **平台官方文档**(百度/阿里云/腾讯) | ✅ | 产品页 URL + 接口地址(**仅在类注释内部出现,对外接口/日志/返回体不暴露**) | +| 3 | **本服务接口** | ✅ | 路径 + HTTP 方法 + Content-Type + 鉴权方式 | +| 4 | **请求参数** | ✅ | 字段表(名称 / 必填 / 说明) | +| 5 | **响应结构** | ✅ | 字段表 + side 区分(如有) | +| 6 | **返回约定** | ✅ | 强调"调过平台一律 `R.ok()`"、错误信息走 `msg` 等业务约定 | +| 7 | `@author` | ✅ | **真实负责人 + 联系方式**(姓名 + 邮箱 / 钉钉),不允许 `system`/`heyu` 等通用占位 | +| 8 | `@since` | ✅ | 接口版本号 | +| 9 | `@see` | ✅ | 关联类(Request / 所有 Resp) | + +### 16.1 模板 +```java +/** + * 驾驶证识别控制器 + *

+ * 接口背景:替代客户手工录入驾驶证字段,应用于 XX 业务场景, + * 支持机动车驾驶证正页、副页及电子驾驶证正页的结构化识别。 + *

+ * + *

百度官方文档

+ * + * + *

本服务接口

+ * + * + *

请求参数

+ * + * + *

响应结构

+ * + * + *

返回约定

+ * + * + * @author 张三 (zhangsan@1024api.com / 钉钉 zs_dingding) + * @since 1.0.0 + * @see DriverLicenseRecognizeRequest + * @see RecognizeDriverLicenseFaceResp + * @see RecognizeDriverLicenseBackResp + */ +``` + +### 16.2 为什么这么严 +- **背景/用途**:新人接手能快速理解这个接口为什么存在 +- **平台文档**:上游字段变更时知道去哪查 +- **@author 真名 + 联系方式**:线上凌晨告警,运维需要立刻找到人,"@heyu" 这种占位毫无帮助 + +--- + +> **文档版本**:v2.2 > **维护人**:heyu > **最后更新**:2026-06-09 > **关联代码**: diff --git a/api-web/api-interface/src/main/java/com/heyu/api/controller/car/RecognizeLicensePlateController.java b/api-web/api-interface/src/main/java/com/heyu/api/controller/car/RecognizeLicensePlateController.java index 3f4e994..75a66c6 100644 --- a/api-web/api-interface/src/main/java/com/heyu/api/controller/car/RecognizeLicensePlateController.java +++ b/api-web/api-interface/src/main/java/com/heyu/api/controller/car/RecognizeLicensePlateController.java @@ -1,41 +1,504 @@ package com.heyu.api.controller.car; -import com.heyu.api.baidu.handle.traffic.BLicensePlateHandle; -import com.heyu.api.baidu.request.traffic.BLicensePlateRequest; -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.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.R; +import com.heyu.api.data.utils.StringUtils; import com.heyu.api.request.car.LicensePlateRecognizeRequest; -import org.springframework.beans.factory.annotation.Autowired; +import com.heyu.api.resp.car.RecognizeLicensePlateResp; +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.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * 车牌识别控制器 + *

+ * 接口背景:用于交通运输、停车场、物流车队等场景,将照片中的车牌号码自动识别为结构化文本, + * 替代人工录入。底层对接百度智能云文字识别「车牌识别」能力,由本服务完成参数校验、请求转发与字段映射。 + *

+ *

+ * 支持识别:中国大陆机动车蓝牌、黄牌(单双行)、绿牌、大型新能源(黄绿)、领使馆车牌、警牌、武警牌(单双行)、 + * 军牌(单双行)、港澳出入境车牌、农用车牌、民航车牌。 + *

+ * + *

百度官方文档

+ * + * + *

本服务接口

+ * + * + *

请求参数({@link LicensePlateRecognizeRequest})

+ * + * + *

响应结构({@link RecognizeLicensePlateResp})

+ * + * + *

返回约定(重要)

+ * + * + * @author heyu + * @since 1.0.0 + * @see LicensePlateRecognizeRequest + * @see RecognizeLicensePlateResp + */ +@Slf4j @RestController @RequestMapping("/car/license/plate") @NotIntercept -public class RecognizeLicensePlateController extends BaseController { +public class RecognizeLicensePlateController extends AbstractRecognizeController { - @Autowired - private BLicensePlateHandle bLicensePlateHandle; + /** 百度车牌识别 API 路径,完整地址见类注释 */ + private static final String LICENSE_PLATE_URI = "/rest/2.0/ocr/v1/license_plate"; - @PostMapping("/recognize") + /** 车牌识别无 side 概念,统一用此占位符填充上下文,便于日志格式与父类一致 */ + private static final String SIDE_PLACEHOLDER = "plate"; + + /** + * 车牌识别 + * + * @param request 车牌识别请求,字段见类注释「请求参数」 + * @return {@link RecognizeLicensePlateResp} + */ @EbAuthentication(tencent = ApiConstants.TENCENT_AUTH) + @PostMapping("/recognize") public R recognize(@RequestBody LicensePlateRecognizeRequest request) { - return BaiduOcrResult.raw(bLicensePlateHandle.handle(toBaiduRequest(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); + } + + // ---------- 步骤⑤:解析平台结果 → 构建车牌响应 ---------- + Map primary = pickPrimaryPlate(platformResult); + RecognizeLicensePlateResp data = buildPlateResp(primary); + String hint = resolvePlatformHint(platformResult, primary, 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); + } } - private BLicensePlateRequest toBaiduRequest(LicensePlateRecognizeRequest request) { - BLicensePlateRequest bRequest = new BLicensePlateRequest(); - if (request == null) { - return bRequest; + /** + * 子类钩子:车牌识别只有一种响应类型,无 side 区分。 + */ + @Override + protected Object defaultEmptyResp(String side) { + return new RecognizeLicensePlateResp(); + } + + // ===================== 流程拆分方法 ===================== + + /** + * 调用百度云车牌识别接口。 + * + * @param content 已拼装好的 POST 请求体 + * @param ctx 上下文(影像模式、入参摘要) + * @return 平台返回的 JSON 解析后的 Map;超时/鉴权/解析失败时返回 null + */ + private Map callPlatform(String content, RecognizeContext ctx) { + int len = content.length(); + log.info("车牌识别:开始调用平台识别。{},请求大小约 {} 字节。{}", + ctx.imageInput.getType().getDesc(), len, ctx.inputLog); + Map result = requestBaidu(LICENSE_PLATE_URI, content); + if (result == null) { + log.error("车牌识别:平台没有返回结果(可能网络超时、鉴权失败或响应无法解析)。{},请求约 {} 字节。{}", + ctx.imageInput.getType().getDesc(), len, ctx.inputLog); } - bRequest.setImageBase64(request.getImageBase64()); - bRequest.setImageUrl(request.getImageUrl()); - return bRequest; + return result; + } + + /** + * 解析平台返回结果,判断是否需要向客户返回提示信息。 + *

判断顺序:

+ *
    + *
  1. error_code 存在 → 平台业务拒绝
  2. + *
  3. words_result 为空 / 非数组 → 平台未识别出车牌
  4. + *
  5. 响应对象所有字段均为空 → 字段映射失败
  6. + *
  7. 以上均不命中 → 返回 null(识别正常)
  8. + *
+ */ + private String resolvePlatformHint(Map platformResult, + Map primary, + RecognizeContext ctx, + RecognizeLicensePlateResp 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 + ); + } + if (primary == null) { + log.info("车牌识别:平台返回了,但 words_result 为空或非数组(可能不是车牌、画面无车牌、或图片不清晰)。{}。客户传的:{}。{}", + ctx.imageInput.getType().getDesc(), + ctx.inputLog, buildPlatformReceiptSummary(platformResult)); + return formatHint( + "结构化结果缺失", + "平台回执未包含可解析的车牌识别结果,无法进入字段映射环节", + "优先排查:① 画面是否包含完整车牌;② 车牌是否被遮挡 / 反光 / 严重倾斜;" + + "③ 使用 URL 时确保平台抓取节点可访问且无 403/302 拦截", + "解析状态=结果集为空" + ); + } + if (isAllStringFieldsBlank(data)) { + log.info("车牌识别:平台有返回,但 plateNumber / color 等字段一个都没识别出来。{}。客户传的:{}。平台回执:{}", + ctx.imageInput.getType().getDesc(), + ctx.inputLog, buildPlatformReceiptSummary(platformResult)); + return formatHint( + "字段映射为空", + "平台回执已通过结构校验,但车牌号码、颜色等结构化字段均未命中", + "请确认上传图片为车辆照片且车牌占画面比例合理(建议占画面 1/8 以上);" + + "推荐重拍:垂直拍摄、避开反光、保证四角完整", + "已映射字段数=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(LicensePlateRecognizeRequest request) { + if (request == null) { + log.info("车牌识别:没收到任何请求参数(表单未绑定成功),直接拒绝,未调用识别、不扣费"); + return formatHint( + "入参绑定失败", + "控制器未接收到可绑定的请求对象,所有业务字段均为空", + "请确认使用 POST 提交;Content-Type 为 application/json;" + + "字段名与接口文档一致(imageUrlOrBase64)", + "绑定结果=LicensePlateRecognizeRequest 为 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; + } + + // ===================== 请求构造 ===================== + + /** + * 构造调用百度车牌识别的 POST 请求体(application/x-www-form-urlencoded 格式)。 + *

本接口默认走单车牌识别,不开启遮挡 / PS / 多张检测开关。

+ */ + 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()); + } + sb.append("&multi_detect=false"); + sb.append("&multi_scale=false"); + sb.append("&detect_complete=false"); + sb.append("&detect_risk=false"); + return sb.toString(); + } + + // ===================== 平台结果映射 ===================== + + /** + * 从百度返回的 {@code words_result} 数组中挑出"主车牌"。 + *

策略:

+ *
    + *
  1. 无 words_result 或为空 → 返回 null
  2. + *
  3. 只有一条 → 直接返回
  4. + *
  5. 多条 → 取号码非空且 probability 平均值最高的一条
  6. + *
+ */ + @SuppressWarnings("unchecked") + private Map pickPrimaryPlate(Map platformResult) { + Object wordsResult = platformResult.get("words_result"); + if (!(wordsResult instanceof List)) { + return null; + } + List> list = (List>) wordsResult; + if (list.isEmpty()) { + return null; + } + Map best = null; + double bestScore = -1; + for (Map item : list) { + if (item == null) { + continue; + } + Object number = item.get("number"); + if (number == null || StringUtils.isBlank(number.toString())) { + continue; + } + double score = averageProbability(item); + if (score > bestScore) { + bestScore = score; + best = item; + } + } + return best; + } + + /** + * 将"主车牌"映射为对外响应对象。 + */ + private RecognizeLicensePlateResp buildPlateResp(Map primary) { + RecognizeLicensePlateResp resp = new RecognizeLicensePlateResp(); + if (primary == null) { + return resp; + } + Object number = primary.get("number"); + if (number != null) { + resp.setPlateNumber(number.toString()); + } + double avgConfidence = averageProbability(primary); + if (avgConfidence >= 0) { + resp.setConfidence((float) avgConfidence); + resp.setPlateTypeConfidence((float) avgConfidence); + } + Object color = primary.get("color"); + if (color != null) { + String colorRaw = color.toString(); + resp.setPlateColor(translateColor(colorRaw)); + resp.setPlateType(inferPlateType(colorRaw, resp.getPlateNumber())); + } + return resp; + } + + /** + * 把百度返回的颜色英文标识翻译为中文(对外不暴露英文 enum)。 + */ + private String translateColor(String colorRaw) { + if (StringUtils.isBlank(colorRaw)) { + return null; + } + Map map = colorTranslationMap(); + String hit = map.get(colorRaw.trim().toLowerCase()); + return hit != null ? hit : colorRaw; + } + + /** + * 基于颜色 + 号码长度推断车牌类型,对齐阿里云 RecognizeLicensePlate 规范。 + */ + private String inferPlateType(String colorRaw, String plateNumber) { + if (StringUtils.isBlank(colorRaw)) { + return null; + } + String c = colorRaw.trim().toLowerCase(); + switch (c) { + case "blue": + return "小型汽车"; + case "yellow": + return "大型汽车"; + case "green": + case "gradual_green": + return "新能源车"; + case "yellow_green": + return "大型新能源车"; + case "white": + if (plateNumber != null && plateNumber.contains("警")) { + return "警车"; + } + if (plateNumber != null && (plateNumber.startsWith("WJ") || plateNumber.contains("WJ"))) { + return "武警车"; + } + return "军车"; + case "black": + if (plateNumber != null && (plateNumber.startsWith("使") || plateNumber.startsWith("领"))) { + return "使领馆车"; + } + return "港澳车"; + default: + return "其他"; + } + } + + private static Map colorTranslationMap() { + Map m = new HashMap<>(); + m.put("blue", "蓝"); + m.put("yellow", "黄"); + m.put("green", "绿"); + m.put("gradual_green", "渐变绿"); + m.put("yellow_green", "黄绿"); + m.put("white", "白"); + m.put("black", "黑"); + return Collections.unmodifiableMap(m); + } + + /** + * 计算百度 {@code probability} 数组的平均值;数组缺失或非数字时返回 -1。 + */ + @SuppressWarnings("unchecked") + private double averageProbability(Map item) { + Object probObj = item.get("probability"); + if (!(probObj instanceof List)) { + return -1; + } + List list = (List) probObj; + if (list.isEmpty()) { + return -1; + } + double sum = 0; + int count = 0; + for (Object o : list) { + if (o instanceof Number) { + sum += ((Number) o).doubleValue(); + count++; + } + } + return count == 0 ? -1 : sum / count; + } + + // ===================== 私有工具 ===================== + + /** + * 格式化为统一的提示文案。 + */ + 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(); + } + + /** 入参日志说明(不打印影像正文,只说类型与长度) */ + private String buildInputLogContext(LicensePlateRecognizeRequest 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/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; + +/** + * 火车票识别控制器 + *

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

+ *

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

+ * + *

百度官方文档

+ *
    + *
  • 产品文档:火车票识别
  • + *
  • 接口地址:{@code POST https://aip.baidubce.com/rest/2.0/ocr/v1/train_ticket}
  • + *
+ * + *

本服务接口

+ *
    + *
  • 路径:{@code POST /train/ticket/recognize}
  • + *
  • Content-Type:{@code application/json}({@code @RequestBody})
  • + *
  • 鉴权:{@link EbAuthentication}(Tencent 鉴权头,见项目网关配置)
  • + *
  • 结果缓存:{@link CacheResult} 命中时不再调用平台,节省计费
  • + *
+ * + *

请求参数({@link TrainTicketRecognizeRequest})

+ *
    + *
  • imageUrlOrBase64:HTTP(S) 链接或 Base64 字符串,服务端自动识别(支持 jpg/jpeg/png/bmp)
  • + *
+ * + *

响应结构({@link RecognizeTrainTicketResp})

+ *
    + *
  • 核心:date 日期 / time 时间 / departureStation 始发 / destination 终到 / number 车次
  • + *
  • 票务:level 席别 / seat 座位 / price 票价 / ticketNum 票号 / salesStation 售票站 / serialNumber 序列号
  • + *
  • 乘车人:name 姓名 / idNum 身份证号(前 6 位 + 后 4 位,中间脱敏)
  • + *
+ * + *

返回约定(重要)

+ *
    + *
  • 一律 {@code R.ok()};入参校验失败、平台异常、识别结果为空等情况,将说明写入 {@code msg}, + * {@code data} 可能为空或字段不全(对外不暴露「百度」字样,日志内可排查)
  • + *
  • 入参校验失败时未调用百度、不计费
  • + *
  • 身份证号统一脱敏返回(中间 8 位以 * 替代),原值不进入日志
  • + *
+ * + * @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/LicensePlateRecognizeRequest.java b/api-web/api-interface/src/main/java/com/heyu/api/request/car/LicensePlateRecognizeRequest.java index c4bd2fb..43ff5d4 100644 --- a/api-web/api-interface/src/main/java/com/heyu/api/request/car/LicensePlateRecognizeRequest.java +++ b/api-web/api-interface/src/main/java/com/heyu/api/request/car/LicensePlateRecognizeRequest.java @@ -3,25 +3,23 @@ package com.heyu.api.request.car; import lombok.Data; /** - * 车牌识别请求参数(百度OCR) + * 车牌识别请求参数 * - * 支持识别中国大陆机动车蓝牌、黄牌(单双行)、绿牌、大型新能源(黄绿)、领使馆车牌、警牌、武警牌(单双行)、 - * 军牌(单双行)、港澳出入境车牌、农用车牌、民航车牌 + * 对外提供简洁接口,支持识别中国大陆机动车蓝牌、黄牌(单双行)、绿牌、大型新能源(黄绿)、领使馆车牌、警牌、武警牌(单双行)、 + * 军牌(单双行)、港澳出入境车牌、农用车牌、民航车牌。 */ @Data public class LicensePlateRecognizeRequest { /** - * 图像数据,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/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/RecognizeLicensePlateResp.java b/api-web/api-interface/src/main/java/com/heyu/api/resp/car/RecognizeLicensePlateResp.java index 0dc9eb9..c3c5d98 100644 --- a/api-web/api-interface/src/main/java/com/heyu/api/resp/car/RecognizeLicensePlateResp.java +++ b/api-web/api-interface/src/main/java/com/heyu/api/resp/car/RecognizeLicensePlateResp.java @@ -1,53 +1,46 @@ package com.heyu.api.resp.car; - -import com.heyu.api.baidu.response.convert.BTextreviewQueryResp; import lombok.Data; -/*** - * https://next.api.aliyun.com/api/ocr/2019-12-30/RecognizeLicensePlate?useCommon=true&tab=DOC&lang=JAVA&sdkStyle=dara +/** + * 车牌识别响应 * - * 车牌识别 + * 字段对齐阿里云 RecognizeLicensePlate 规范: + * https://next.api.aliyun.com/api/ocr/2019-12-30/RecognizeLicensePlate * - * RecognizeLicensePlate + * 仅返回画面中"主车牌"(百度多车牌场景下默认取第一个,按置信度降序)。 */ @Data public class RecognizeLicensePlateResp { + /** + * 车牌号码。 + * 示例值:粤BP57E7 + */ + private String plateNumber; - /*** + /** + * 车牌号码的置信度,取值范围 0~1。 + * 示例值:0.997 + */ + private Float confidence; + + /** + * 车牌类型。包括:小型汽车、新能源车、大型汽车、挂车、教练车、警车、军车、使领馆车、港澳车。 + * 示例值:小型汽车 + */ + private String plateType; + + /** * 车牌类型的置信度,取值范围 0~1。 - * - * 示例值: - * 1 + * 示例值:0.95 */ private Float plateTypeConfidence; /** - * 车牌类型。包括:小型汽车、新能源车、大型汽车、挂车、教练车、警车、军车、使领馆车、港澳车。 - * - * 示例值: - * 小型汽车 + * 车牌颜色(蓝 / 绿 / 黄 / 黄绿 / 白 / 黑)。 + * 示例值:蓝 */ - private String plateType; - - - /*** - * 车牌号码的置信度,取值范围 0~1。 - * - * 示例值: - * 0.99745339155197144 - */ - private Float confidence; - - - /*** - * 车牌号码。 - * - * 示例值: - * 粤BP57E7 - */ - private String plateNumber; - + private String plateColor; } 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; }