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;
+
+/**
+ * 车牌识别控制器
+ *
+ * 接口背景:用于交通运输、停车场、物流车队等场景,将照片中的车牌号码自动识别为结构化文本,
+ * 替代人工录入。底层对接百度智能云文字识别「车牌识别」能力,由本服务完成参数校验、请求转发与字段映射。
+ *
+ *
+ * 支持识别:中国大陆机动车蓝牌、黄牌(单双行)、绿牌、大型新能源(黄绿)、领使馆车牌、警牌、武警牌(单双行)、
+ * 军牌(单双行)、港澳出入境车牌、农用车牌、民航车牌。
+ *
+ *
+ * 百度官方文档
+ *
+ * - 产品文档:车牌识别
+ * - 接口地址:{@code POST https://aip.baidubce.com/rest/2.0/ocr/v1/license_plate}
+ *
+ *
+ * 本服务接口
+ *
+ * - 路径:{@code POST /car/license/plate/recognize}
+ * - Content-Type:{@code application/json}({@code @RequestBody})
+ * - 鉴权:{@link EbAuthentication}(Tencent 鉴权头,见项目网关配置)
+ *
+ *
+ * 请求参数({@link LicensePlateRecognizeRequest})
+ *
+ * - imageUrlOrBase64:HTTP(S) 链接或 Base64 字符串,服务端自动识别(支持 jpg/jpeg/png/bmp)
+ *
+ *
+ * 响应结构({@link RecognizeLicensePlateResp})
+ *
+ * - plateNumber 车牌号码 / confidence 号码置信度
+ * - plateType 车牌类型(小型汽车 / 新能源车 / 大型汽车 / 警车 / ...)
+ * - plateTypeConfidence 类型置信度(由号码置信度推导)
+ * - plateColor 车牌颜色(蓝 / 绿 / 黄 / 黄绿 / 白 / 黑)
+ *
+ *
+ * 返回约定(重要)
+ *
+ * - 一律 {@code R.ok()};入参校验失败、平台异常、识别结果为空等情况,将说明写入 {@code msg},
+ * {@code data} 可能为空或字段不全(对外不暴露「百度」字样,日志内可排查)
+ * - 入参校验失败时未调用百度、不计费
+ * - 多车牌场景默认取置信度最高的"主车牌"返回
+ *
+ *
+ * @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;
+ }
+
+ /**
+ * 解析平台返回结果,判断是否需要向客户返回提示信息。
+ * 判断顺序:
+ *
+ * - error_code 存在 → 平台业务拒绝
+ * - words_result 为空 / 非数组 → 平台未识别出车牌
+ * - 响应对象所有字段均为空 → 字段映射失败
+ * - 以上均不命中 → 返回 null(识别正常)
+ *
+ */
+ 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} 数组中挑出"主车牌"。
+ * 策略:
+ *
+ * - 无 words_result 或为空 → 返回 null
+ * - 只有一条 → 直接返回
+ * - 多条 → 取号码非空且 probability 平均值最高的一条
+ *
+ */
+ @SuppressWarnings("unchecked")
+ private Map pickPrimaryPlate(Map platformResult) {
+ Object wordsResult = platformResult.get("words_result");
+ if (!(wordsResult instanceof List)) {
+ return null;
+ }
+ List