Merge remote-tracking branch 'origin/master'
This commit is contained in:
commit
cf4565eddb
@ -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<String>` |
|
||||
| 带识别选项(如检测方向、质量预警) | 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
|
||||
/**
|
||||
* 驾驶证识别控制器
|
||||
* <p>
|
||||
* <b>接口背景</b>:替代客户手工录入驾驶证字段,应用于 XX 业务场景,
|
||||
* 支持机动车驾驶证正页、副页及电子驾驶证正页的结构化识别。
|
||||
* </p>
|
||||
*
|
||||
* <h3>百度官方文档</h3>
|
||||
* <ul>
|
||||
* <li>产品文档:<a href="https://cloud.baidu.com/doc/OCR/s/Vk3h7xzz7">驾驶证识别</a></li>
|
||||
* <li>接口地址:{@code POST https://aip.baidubce.com/rest/2.0/ocr/v1/driving_license}</li>
|
||||
* </ul>
|
||||
*
|
||||
* <h3>本服务接口</h3>
|
||||
* <ul>
|
||||
* <li>路径:{@code POST /driver/license/recognize}</li>
|
||||
* <li>Content-Type:{@code application/json}</li>
|
||||
* <li>鉴权:{@link EbAuthentication}</li>
|
||||
* </ul>
|
||||
*
|
||||
* <h3>请求参数</h3>
|
||||
* <ul>
|
||||
* <li>imageUrlOrBase64:URL 或 Base64</li>
|
||||
* <li>side:face / front / back</li>
|
||||
* </ul>
|
||||
*
|
||||
* <h3>响应结构</h3>
|
||||
* <ul>
|
||||
* <li>正页 → {@link RecognizeDriverLicenseFaceResp}(17 字段)</li>
|
||||
* <li>副页 → {@link RecognizeDriverLicenseBackResp}(4 字段)</li>
|
||||
* </ul>
|
||||
*
|
||||
* <h3>返回约定</h3>
|
||||
* <ul>
|
||||
* <li>调用过平台一律 {@code R.ok()}(计费保护);异常信息写入 msg</li>
|
||||
* <li>msg / data / code 不暴露 "百度" 字样,统一以"平台"称呼</li>
|
||||
* </ul>
|
||||
*
|
||||
* @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
|
||||
> **关联代码**:
|
||||
|
||||
@ -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;
|
||||
|
||||
/**
|
||||
* 车牌识别控制器
|
||||
* <p>
|
||||
* <b>接口背景</b>:用于交通运输、停车场、物流车队等场景,将照片中的车牌号码自动识别为结构化文本,
|
||||
* 替代人工录入。底层对接百度智能云文字识别「车牌识别」能力,由本服务完成参数校验、请求转发与字段映射。
|
||||
* </p>
|
||||
* <p>
|
||||
* 支持识别:中国大陆机动车蓝牌、黄牌(单双行)、绿牌、大型新能源(黄绿)、领使馆车牌、警牌、武警牌(单双行)、
|
||||
* 军牌(单双行)、港澳出入境车牌、农用车牌、民航车牌。
|
||||
* </p>
|
||||
*
|
||||
* <h3>百度官方文档</h3>
|
||||
* <ul>
|
||||
* <li>产品文档:<a href="https://cloud.baidu.com/doc/OCR/s/ck3h7y191">车牌识别</a></li>
|
||||
* <li>接口地址:{@code POST https://aip.baidubce.com/rest/2.0/ocr/v1/license_plate}</li>
|
||||
* </ul>
|
||||
*
|
||||
* <h3>本服务接口</h3>
|
||||
* <ul>
|
||||
* <li>路径:{@code POST /car/license/plate/recognize}</li>
|
||||
* <li>Content-Type:{@code application/json}({@code @RequestBody})</li>
|
||||
* <li>鉴权:{@link EbAuthentication}(Tencent 鉴权头,见项目网关配置)</li>
|
||||
* </ul>
|
||||
*
|
||||
* <h3>请求参数({@link LicensePlateRecognizeRequest})</h3>
|
||||
* <ul>
|
||||
* <li>imageUrlOrBase64:HTTP(S) 链接或 Base64 字符串,服务端自动识别(支持 jpg/jpeg/png/bmp)</li>
|
||||
* </ul>
|
||||
*
|
||||
* <h3>响应结构({@link RecognizeLicensePlateResp})</h3>
|
||||
* <ul>
|
||||
* <li>plateNumber 车牌号码 / confidence 号码置信度</li>
|
||||
* <li>plateType 车牌类型(小型汽车 / 新能源车 / 大型汽车 / 警车 / ...)</li>
|
||||
* <li>plateTypeConfidence 类型置信度(由号码置信度推导)</li>
|
||||
* <li>plateColor 车牌颜色(蓝 / 绿 / 黄 / 黄绿 / 白 / 黑)</li>
|
||||
* </ul>
|
||||
*
|
||||
* <h3>返回约定(重要)</h3>
|
||||
* <ul>
|
||||
* <li>一律 {@code R.ok()};入参校验失败、平台异常、识别结果为空等情况,将说明写入 {@code msg},
|
||||
* {@code data} 可能为空或字段不全(对外不暴露「百度」字样,日志内可排查)</li>
|
||||
* <li>入参校验失败时<strong>未调用百度、不计费</strong></li>
|
||||
* <li>多车牌场景默认取置信度最高的"主车牌"返回</li>
|
||||
* </ul>
|
||||
*
|
||||
* @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<String, Object> platformResult = callPlatform(content, ctx);
|
||||
if (platformResult == null) {
|
||||
return okResult(SIDE_PLACEHOLDER, formatHint(
|
||||
"服务无回执",
|
||||
"识别指令已下发,但在约定时间内未收到平台可解析的 JSON 回执",
|
||||
"建议间隔 3~5 秒重试;若连续失败,请记录 traceId、调用时刻并联系技术支持排查链路",
|
||||
"影像模式=" + ctx.imageInput.getType().name()
|
||||
), null);
|
||||
}
|
||||
|
||||
// ---------- 步骤⑤:解析平台结果 → 构建车牌响应 ----------
|
||||
Map<String, Object> 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<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(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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析平台返回结果,判断是否需要向客户返回提示信息。
|
||||
* <p>判断顺序:</p>
|
||||
* <ol>
|
||||
* <li>error_code 存在 → 平台业务拒绝</li>
|
||||
* <li>words_result 为空 / 非数组 → 平台未识别出车牌</li>
|
||||
* <li>响应对象所有字段均为空 → 字段映射失败</li>
|
||||
* <li>以上均不命中 → 返回 null(识别正常)</li>
|
||||
* </ol>
|
||||
*/
|
||||
private String resolvePlatformHint(Map<String, Object> platformResult,
|
||||
Map<String, Object> 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<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(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 格式)。
|
||||
* <p>本接口默认走单车牌识别,不开启遮挡 / PS / 多张检测开关。</p>
|
||||
*/
|
||||
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} 数组中挑出"主车牌"。
|
||||
* <p>策略:</p>
|
||||
* <ol>
|
||||
* <li>无 words_result 或为空 → 返回 null</li>
|
||||
* <li>只有一条 → 直接返回</li>
|
||||
* <li>多条 → 取号码非空且 probability 平均值最高的一条</li>
|
||||
* </ol>
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
private Map<String, Object> pickPrimaryPlate(Map<String, Object> platformResult) {
|
||||
Object wordsResult = platformResult.get("words_result");
|
||||
if (!(wordsResult instanceof List)) {
|
||||
return null;
|
||||
}
|
||||
List<Map<String, Object>> list = (List<Map<String, Object>>) wordsResult;
|
||||
if (list.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
Map<String, Object> best = null;
|
||||
double bestScore = -1;
|
||||
for (Map<String, Object> 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<String, Object> 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<String, String> 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<String, String> colorTranslationMap() {
|
||||
Map<String, String> 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<String, Object> item) {
|
||||
Object probObj = item.get("probability");
|
||||
if (!(probObj instanceof List)) {
|
||||
return -1;
|
||||
}
|
||||
List<Object> list = (List<Object>) 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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>imageUrlOrBase64:HTTP(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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 二合一):
|
||||
* <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;
|
||||
|
||||
/**
|
||||
* 图片完整URL,URL长度不超过1024字节
|
||||
* 和imageBase64二选一
|
||||
*/
|
||||
private String imageUrl;
|
||||
private String imageUrlOrBase64;
|
||||
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
/**
|
||||
* 图片完整URL,URL长度不超过1024字节
|
||||
* 和imageBase64二选一
|
||||
*/
|
||||
private String imageUrl;
|
||||
private String imageUrlOrBase64;
|
||||
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user