diff --git a/api-web/api-interface/src/main/java/com/heyu/api/controller/doc/DocClassifyController.java b/api-web/api-interface/src/main/java/com/heyu/api/controller/doc/DocClassifyController.java
index d99c199..56a9af7 100644
--- a/api-web/api-interface/src/main/java/com/heyu/api/controller/doc/DocClassifyController.java
+++ b/api-web/api-interface/src/main/java/com/heyu/api/controller/doc/DocClassifyController.java
@@ -1,78 +1,405 @@
package com.heyu.api.controller.doc;
-import com.heyu.api.baidu.request.doc.BDocClassifyRequest;
-import com.heyu.api.baidu.response.doc.BDocClassifyResp;
-import com.heyu.api.controller.BaseController;
+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.R;
import com.heyu.api.data.utils.StringUtils;
+import com.heyu.api.request.doc.DocClassifyRequest;
+import com.heyu.api.resp.doc.DocClassifyItemResp;
+import com.heyu.api.resp.doc.DocClassifyResp;
+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.net.URLEncoder;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
import java.util.Map;
-/***
- * https://cloud.baidu.com/doc/OCR/s/qlor1ahik
+/**
+ * 文档分类检测控制器。
+ *
+ * 对图片中的文档、卡证、票据等含文字的主体进行检测、分类,
+ * 可同时支持一张图片中多张主体的情况,返回每个主体的类别及位置信息。
+ *
*
- * 文件检测分类
+ * 百度官方文档
+ *
+ * - 产品文档:文档分类
+ * - 接口地址:{@code POST https://aip.baidubce.com/rest/2.0/ocr/v1/doc_classify}
+ *
*
- * 接口描述
- * 对图片中的文档、卡证、票据等含文字的主体进行检测、分类,可同时支持一张图片中多张主体的情况,返回每个主体的类别及位置信息。
+ * 本服务接口
+ *
+ * - 路径:{@code POST /doc/classify}
+ * - Content-Type:{@code application/json}({@code @RequestBody})
+ * - 鉴权:{@link EbAuthentication}(Tencent 鉴权头,见项目网关配置)
+ *
+ *
+ * 返回约定
+ *
+ * - 一律 {@code R.ok()};入参校验失败、平台异常、识别结果为空等情况,将说明写入 {@code msg},
+ * {@code data} 可能为空或字段不全(对外不暴露上游平台字样,日志内可排查)
+ * - 入参校验失败时未调用平台、不计费
+ *
+ *
+ * @author zhengli
+ * @since 20260609_zl
+ * @see DocClassifyRequest
+ * @see DocClassifyResp
*/
+@Slf4j
@RestController
@RequestMapping("/doc")
@NotIntercept
-public class DocClassifyController extends BaseController {
+public class DocClassifyController extends AbstractRecognizeController {
+ /** 百度文档分类 API 路径 */
private static final String DOC_CLASSIFY_URI = "/rest/2.0/ocr/v1/doc_classify";
/**
- * 文件检测分类,对图片中的文档、卡证、票据等含文字的主体进行检测、分类
+ * 文档分类检测。
*
- * 请求参数:
- *
- * {
- * "imageBase64": "图像base64编码(与imageUrl二选一)",
- * "imageUrl": "图片完整URL(与imageBase64二选一)"
- * }
- *
+ * @param request 文档分类请求
+ * @return {@code R} 包含分类结果列表
*/
- @RequestMapping("/classify")
- @CacheResult
@EbAuthentication(tencent = ApiConstants.TENCENT_AUTH)
- public R classify(BDocClassifyRequest request) {
- if (request == null || (isBlank(request.getImageBase64()) && isBlank(request.getImageUrl()))) {
- return R.error("image 和 url 不能都为空");
- }
- String content;
+ @PostMapping("/classify")
+ @CacheResult
+ public R recognize(@RequestBody DocClassifyRequest request) {
+ long start = System.currentTimeMillis();
+ RecognizeContext ctx = null;
try {
- content = buildContent(request);
+ // ---------- 步骤①:参数校验(不调平台、不扣费) ----------
+ 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(null, validateError, null);
+ }
+
+ // ---------- 步骤②:解析影像入参 & 构建上下文 ----------
+ ResolvedImageInput imageInput = ImageInputUtils.resolve(request.getImageUrlOrBase64());
+ ctx = new RecognizeContext(null, imageInput, buildInputLogContext(request, imageInput));
+
+ // ---------- 步骤③:组装请求体 ----------
+ String content = buildRequestContent(imageInput);
+ if (isBlank(content)) {
+ log.error("文档分类:组装请求失败,请求里没带有效图片。{}。{}",
+ ctx.imageInput.getType().getDesc(), ctx.inputLog);
+ return okResult(null, formatHint(
+ "报文组装异常",
+ "识别请求体在序列化后未包含任何影像载荷,平台侧无法受理本次识别",
+ "请核对 imageUrlOrBase64 是否非空且为有效 Base64 或 HTTP(S) 链接",
+ "影像模式=" + ctx.imageInput.getType().name()
+ ), null);
+ }
+
+ // ---------- 步骤④:调用平台识别 ----------
+ Map platformResult = callPlatform(content, ctx);
+ if (platformResult == null) {
+ return okResult(null, formatHint(
+ "服务无回执",
+ "识别指令已下发,但在约定时间内未收到平台可解析的 JSON 回执",
+ "建议间隔 3~5 秒重试;若连续失败,请记录 traceId、调用时刻并联系技术支持",
+ "影像模式=" + ctx.imageInput.getType().name()
+ ), null);
+ }
+
+ // ---------- 步骤⑤:解析平台结果 → 构造响应对象 ----------
+ DocClassifyResp data = buildResp(platformResult);
+ String hint = resolvePlatformHint(platformResult, ctx, data);
+
+ // ---------- 步骤⑥:日志记录 & 返回 ----------
+ logRecognizeResult(ctx, platformResult, data, hint, start);
+ return okResult(null, hint, data);
+
} catch (Exception e) {
- return R.error("参数编码失败");
+ 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(null, formatHint(
+ "运行时故障",
+ "服务端在处理分类流程时抛出未预期异常,识别结果不可用",
+ "请勿重复高频重试;请保存 traceId、异常发生时间,由技术支持结合堆栈进一步定位",
+ "异常类型=" + e.getClass().getSimpleName()
+ + (e.getMessage() != null ? ",摘要=" + e.getMessage() : "")
+ ), null);
}
- Map result = requestBaidu(DOC_CLASSIFY_URI, content);
- if (result == null) {
- return R.error("识别失败");
- }
- BDocClassifyResp resp = com.alibaba.fastjson.JSONObject.parseObject(
- com.alibaba.fastjson.JSONObject.toJSONString(result), BDocClassifyResp.class);
- return R.ok().setData(resp);
}
- private String buildContent(BDocClassifyRequest request) throws Exception {
- StringBuilder sb = new StringBuilder();
- if (StringUtils.isNotBlank(request.getImageBase64())) {
- sb.append("&image=").append(URLEncoder.encode(normalizeBase64Image(request.getImageBase64()), "UTF-8"));
+ @Override
+ protected Object defaultEmptyResp(String side) {
+ return new DocClassifyResp();
+ }
+
+ // ===================== 流程拆分方法 =====================
+
+ private Map callPlatform(String content, RecognizeContext ctx) {
+ int len = content.length();
+ log.info("文档分类:开始调用平台识别。{},请求大小约 {} 字节。{}",
+ ctx.imageInput.getType().getDesc(), len, ctx.inputLog);
+ Map result = requestBaidu(DOC_CLASSIFY_URI, content);
+ if (result == null) {
+ log.error("文档分类:平台没有返回结果(可能网络超时、鉴权失败或响应无法解析)。"
+ + "{},请求约 {} 字节。{}",
+ ctx.imageInput.getType().getDesc(), len, ctx.inputLog);
}
- if (StringUtils.isNotBlank(request.getImageUrl())) {
- sb.append("&url=").append(URLEncoder.encode(request.getImageUrl(), "UTF-8"));
+ return result;
+ }
+
+ private String resolvePlatformHint(Map platformResult,
+ RecognizeContext ctx, DocClassifyResp 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 (isClassifyResultEmpty(platformResult)) {
+ log.info("文档分类:平台返回了,但没有分类结果数据(可能图片中未检测到含文字的主体)。{}。客户传的:{}。{}",
+ ctx.imageInput.getType().getDesc(),
+ ctx.inputLog, buildPlatformReceiptSummary(platformResult));
+ return formatHint(
+ "结构化结果缺失",
+ "平台回执未包含可解析的分类结果集合,无法进入字段映射环节",
+ "优先排查:上传内容是否为包含文字的文档、卡证或票据;使用 URL 时确保图片链接可公网访问且无防盗链",
+ "解析状态=结果集为空"
+ );
+ }
+ if (data == null || data.getItems() == null || data.getItems().isEmpty()) {
+ log.info("文档分类:平台有返回,但分类结果列表为空(可能图片不包含可识别主体)。{}。客户传的:{}。平台回执:{}",
+ ctx.imageInput.getType().getDesc(),
+ ctx.inputLog, buildPlatformReceiptSummary(platformResult));
+ return formatHint(
+ "分类结果为空",
+ "平台回执已通过结构校验,但未产出任何有效的分类条目",
+ "请确认上传图片包含可识别的文档、卡证或票据主体",
+ "分类条目数=0"
+ );
+ }
+ // 检查所有分类条目是否字段全空(type/probability/location 均未命中)
+ if (isAllItemsFieldsBlank(data)) {
+ log.info("文档分类:平台有返回条目,但每个条目的 type、probability 等字段全为空。{}。客户传的:{}。平台回执:{}",
+ ctx.imageInput.getType().getDesc(),
+ ctx.inputLog, buildPlatformReceiptSummary(platformResult));
+ return formatHint(
+ "字段映射为空",
+ "平台回执包含分类条目,但类别、置信度、位置等结构化字段均未命中",
+ "请确认上传图片清晰且包含可识别的文档、卡证或票据主体",
+ "分类条目数=" + data.getItems().size() + ",有效字段数=0"
+ );
+ }
+ return null;
+ }
+
+ private void logRecognizeResult(RecognizeContext ctx, Map platformResult,
+ DocClassifyResp data, String hint, long start) {
+ long cost = System.currentTimeMillis() - start;
+ int itemCount = data != null && data.getItems() != null ? data.getItems().size() : 0;
+ int totalFields = countTotalItemFields(data);
+ if (StringUtils.isNotBlank(hint)) {
+ log.info("文档分类:处理结束(接口仍返回成功,但带了提示信息)。耗时 {} ms,分类出 {} 个主体、共 {} 个字段。"
+ + "{}。客户传的:{}。平台回执:{}。给客户的提示:{}",
+ cost, itemCount, totalFields, ctx.imageInput.getType().getDesc(),
+ ctx.inputLog, buildPlatformReceiptSummary(platformResult), abbreviate(hint, 120));
+ } else {
+ log.info("文档分类:识别成功。耗时 {} ms,共分类出 {} 个主体、共 {} 个字段。{}。客户传的:{}。平台回执:{}",
+ cost, itemCount, totalFields, ctx.imageInput.getType().getDesc(),
+ ctx.inputLog, buildPlatformReceiptSummary(platformResult));
+ }
+ }
+
+ // ===================== 校验与上下文 =====================
+
+ private String validateRequest(DocClassifyRequest request) {
+ if (request == null) {
+ log.info("文档分类:没收到任何请求参数,直接拒绝,未调用识别、不扣费");
+ return formatHint(
+ "入参绑定失败",
+ "控制器未接收到可绑定的请求对象,所有业务字段均为空",
+ "请确认使用 POST 提交;Content-Type 为 application/json;"
+ + "字段名与接口文档一致(imageUrlOrBase64)",
+ "绑定结果=DocClassifyRequest 为 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) {
+ if (imageInput == null || StringUtils.isBlank(imageInput.getFormValue())) {
+ return "";
+ }
+ StringBuilder sb = new StringBuilder();
+ if (ImageInputUtils.ImageInputType.URL == imageInput.getType()) {
+ sb.append("&url=").append(imageInput.getFormValue());
+ } else {
+ sb.append("&image=").append(imageInput.getFormValue());
}
return sb.toString();
}
+ /**
+ * 从百度平台原始回执中构造分类响应。
+ * doc_classify 的 words_result 是 List 结构,不同于 OCR 识别的 Map 结构。
+ */
+ @SuppressWarnings("unchecked")
+ private DocClassifyResp buildResp(Map platformResult) {
+ DocClassifyResp resp = new DocClassifyResp();
+ Object wordsResult = platformResult.get("words_result");
+ Object wordsResultNum = platformResult.get("words_result_num");
+ resp.setCount(wordsResultNum != null ? String.valueOf(wordsResultNum) : "0");
+
+ if (!(wordsResult instanceof List)) {
+ resp.setItems(Collections.emptyList());
+ return resp;
+ }
+ List