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 +/** + * 文档分类检测控制器。 + *

+ * 对图片中的文档、卡证、票据等含文字的主体进行检测、分类, + * 可同时支持一张图片中多张主体的情况,返回每个主体的类别及位置信息。 + *

* - * 文件检测分类 + *

百度官方文档

+ * * - * 接口描述 - * 对图片中的文档、卡证、票据等含文字的主体进行检测、分类,可同时支持一张图片中多张主体的情况,返回每个主体的类别及位置信息。 + *

本服务接口

+ * + * + *

返回约定

+ * + * + * @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> resultList = (List>) wordsResult; + List items = new ArrayList<>(resultList.size()); + for (Map item : resultList) { + items.add(buildItemResp(item)); + } + resp.setItems(items); + return resp; + } + + @SuppressWarnings("unchecked") + private DocClassifyItemResp buildItemResp(Map item) { + DocClassifyItemResp resp = new DocClassifyItemResp(); + Object type = item.get("type"); + resp.setType(type != null ? String.valueOf(type) : null); + // 注意:百度 API 原始字段名为 "probablity"(拼写错误),此处与之保持一致 + Object prob = item.get("probablity"); + resp.setProbability(prob != null ? String.valueOf(prob) : null); + + Object location = item.get("location"); + if (location instanceof Map) { + Map loc = (Map) location; + resp.setTop(stringOrNull(loc.get("top"))); + resp.setLeft(stringOrNull(loc.get("left"))); + resp.setWidth(stringOrNull(loc.get("width"))); + resp.setHeight(stringOrNull(loc.get("height"))); + } + return resp; + } + + private static String stringOrNull(Object value) { + return value != null ? String.valueOf(value) : null; + } + + // ===================== 私有工具 ===================== + + /** + * 判断分类结果是否为空。 + *

doc_classify 的 words_result 是 List,与 OCR 识别的 Map 结构不同, + * 因此不能直接使用父类的 {@link #isWordsResultEmpty}。

+ */ + private boolean isClassifyResultEmpty(Map platformResult) { + Object wordsResult = platformResult.get("words_result"); + if (wordsResult == null) { + return true; + } + if (wordsResult instanceof List) { + return ((List) wordsResult).isEmpty(); + } + return true; + } + + /** + * 统计所有分类条目的非空字段总数(使用父类反射工具)。 + */ + private int countTotalItemFields(DocClassifyResp data) { + if (data == null || data.getItems() == null) { + return 0; + } + int total = 0; + for (DocClassifyItemResp item : data.getItems()) { + total += countNonBlankStringFields(item); + } + return total; + } + + /** + * 判断所有分类条目的 String 字段是否全为空。 + *

doc_classify 的 words_result 是 List 结构,与 OCR 识别的 Map 结构不同, + * 因此不能直接使用父类的 {@link #isAllStringFieldsBlank}(它只看顶层 String 字段), + * 改为遍历每条 ItemResp 做反射判断。

+ */ + private boolean isAllItemsFieldsBlank(DocClassifyResp data) { + if (data == null || data.getItems() == null || data.getItems().isEmpty()) { + return true; + } + for (DocClassifyItemResp item : data.getItems()) { + if (!isAllStringFieldsBlank(item)) { + return false; + } + } + return true; + } + + 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(DocClassifyRequest 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/doc/DocClassifyRequest.java b/api-web/api-interface/src/main/java/com/heyu/api/request/doc/DocClassifyRequest.java new file mode 100644 index 0000000..e97138c --- /dev/null +++ b/api-web/api-interface/src/main/java/com/heyu/api/request/doc/DocClassifyRequest.java @@ -0,0 +1,28 @@ +package com.heyu.api.request.doc; + +import lombok.Data; + +/** + * 文档分类检测请求参数。 + *

+ * 对外只暴露一个字段 {@code imageUrlOrBase64}, + * 服务端自动识别为 HTTP(S) 链接或 Base64 字符串。 + *

+ * + * @author zhengli + * @since 20260609_zl + */ +@Data +public class DocClassifyRequest { + + /** + * 影像入参(URL 或 Base64 二合一): + *
    + *
  • HTTP/HTTPS 图片链接(≤1024 字符)
  • + *
  • 图片 Base64(可带 data:image/...;base64, 前缀)
  • + *
+ * 服务端通过 ImageInputUtils 自动识别。 + */ + private String imageUrlOrBase64; + +} diff --git a/api-web/api-interface/src/main/java/com/heyu/api/resp/doc/DocClassifyItemResp.java b/api-web/api-interface/src/main/java/com/heyu/api/resp/doc/DocClassifyItemResp.java new file mode 100644 index 0000000..04e7f7b --- /dev/null +++ b/api-web/api-interface/src/main/java/com/heyu/api/resp/doc/DocClassifyItemResp.java @@ -0,0 +1,60 @@ +package com.heyu.api.resp.doc; + +import lombok.Data; + +/** + * 文档分类检测 — 单个分类结果项。 + *

+ * 所有字段均为 String 类型,保证反射统计工具覆盖。 + * 字段与百度 doc_classify 接口返回的 words_result 数组元素一一对应。 + *

+ * + * @author zhengli + * @since 20260609_zl + */ +@Data +public class DocClassifyItemResp { + + /** + * 类别信息,当前可输出类别包括:卡证_身份证_正、卡证_银行卡、办公文档、表格 等 200+ 种。 + * 对应百度字段:type(string) + * 示例:"卡证_银行卡" + */ + private String type; + + /** + * 分类置信度(0~1 之间的小数,字符串形式)。 + * 对应百度字段:probablity(float) + * 示例:"0.9999860525" + */ + private String probability; + + /** + * 位置信息 — 表示定位位置的长方形左上顶点的垂直坐标(像素)。 + * 对应百度字段:location.top(uint32) + * 示例:"1684" + */ + private String top; + + /** + * 位置信息 — 表示定位位置的长方形左上顶点的水平坐标(像素)。 + * 对应百度字段:location.left(uint32) + * 示例:"300" + */ + private String left; + + /** + * 位置信息 — 表示定位位置的长方形的宽度(像素)。 + * 对应百度字段:location.width(uint32) + * 示例:"2710" + */ + private String width; + + /** + * 位置信息 — 表示定位位置的长方形的高度(像素)。 + * 对应百度字段:location.height(uint32) + * 示例:"1712" + */ + private String height; + +} diff --git a/api-web/api-interface/src/main/java/com/heyu/api/resp/doc/DocClassifyResp.java b/api-web/api-interface/src/main/java/com/heyu/api/resp/doc/DocClassifyResp.java index 2ad9a83..863251c 100644 --- a/api-web/api-interface/src/main/java/com/heyu/api/resp/doc/DocClassifyResp.java +++ b/api-web/api-interface/src/main/java/com/heyu/api/resp/doc/DocClassifyResp.java @@ -1,19 +1,33 @@ package com.heyu.api.resp.doc; - -import com.heyu.api.data.dto.BaseResp; import lombok.Data; -@Data -public class DocClassifyResp extends BaseResp { +import java.util.List; - /*** - * 分类置信度 +/** + * 文档分类检测响应。 + *

+ * 对图片中的文档、卡证、票据等含文字的主体进行检测和分类, + * 返回每个主体的类别、置信度和位置信息。 + * 如图片中无文字内容,items 为空列表。 + *

+ * + * @author zhengli + * @since 20260609_zl + */ +@Data +public class DocClassifyResp { + + /** + * 分类结果列表;图片中无文字内容时为空列表。 + * 示例:[{type="卡证_银行卡", probability="0.9999860525", top="1684", left="300", width="2710", height="1712"}] */ - private Double probablity; - /*** - * 类别信息,当前可输出类别列表见下文「附录:类别列表」 - */ - private String type; + private List items; + + /** + * 检测到的主体数量。 + * 示例:"3" + */ + private String count; } diff --git a/api-web/api-interface/src/test/java/com/heyu/api/controller/doc/DocClassifyControllerTest.java b/api-web/api-interface/src/test/java/com/heyu/api/controller/doc/DocClassifyControllerTest.java index d0fc1cc..cb26fb0 100644 --- a/api-web/api-interface/src/test/java/com/heyu/api/controller/doc/DocClassifyControllerTest.java +++ b/api-web/api-interface/src/test/java/com/heyu/api/controller/doc/DocClassifyControllerTest.java @@ -2,10 +2,9 @@ package com.heyu.api.controller.doc; import com.ApiInterfaceApplicationTests; import com.TestConstant; -import com.alibaba.fastjson.JSON; -import com.heyu.api.baidu.request.doc.BDocClassifyRequest; -import com.heyu.api.baidu.response.doc.BDocClassifyResp; import com.heyu.api.data.utils.R; +import com.heyu.api.request.doc.DocClassifyRequest; +import com.heyu.api.resp.doc.DocClassifyResp; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -14,135 +13,169 @@ import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; /** - * 文件检测分类 - 在线图片验证 - * - * 调用本地 /doc/classify 接口,对图片中的文档、卡证、票据进行检测分类。 - * - * 假设(Given): 目标图片可访问,百度 API 可用 - * 当(When): 调用 classify 接口,传入图片 URL - * 则(Then): 返回检测结果 + * 文档分类检测控制器测试。 + *

+ * 覆盖参数校验、上游异常、识别空结果、识别成功、兜底等必测场景。 + *

* * @author zhengli - * @since 20260531_zl + * @since 20260609_zl */ public class DocClassifyControllerTest extends ApiInterfaceApplicationTests { @Autowired private DocClassifyController docClassifyController; + // ===================== 参数校验场景 ===================== + /** + * 假设(Given): 不传任何请求参数 + * 当(When): 调用 classify 接口 + * 则(Then): 返回 code=200,data 不为 null,msg 含【文档分类·入参绑定失败】 + */ + @Test + void classifyRequestNullTest() { + // Act + R result = docClassifyController.recognize(null); + + // Assert + assertNotNull(result, "返回结果不能为空"); + assertEquals("200", result.getCode(), "接口应返回成功"); + assertNotNull(result.getData(), "data 不能为 null(defaultEmptyResp 生效)"); + assertTrue(result.getData() instanceof DocClassifyResp, "data 类型应为 DocClassifyResp"); + String msg = result.getMsg(); + assertNotNull(msg, "msg 不应为 null"); + assertTrue(msg.contains("【文档分类·入参绑定失败】"), "msg 应含入参绑定失败前缀"); + assertNoUpstreamPlatformName(msg, "msg"); + } + + /** + * 假设(Given): imageUrlOrBase64 为空字符串 + * 当(When): 调用 classify 接口 + * 则(Then): 返回 code=200,msg 含【文档分类·影像源缺失】 + */ + @Test + void classifyImageEmptyTest() { + // Arrange + DocClassifyRequest request = new DocClassifyRequest(); + request.setImageUrlOrBase64(""); + + // Act + R result = docClassifyController.recognize(request); + + // Assert + assertNotNull(result, "返回结果不能为空"); + assertEquals("200", result.getCode(), "接口应返回成功"); + assertNotNull(result.getData(), "data 不能为 null"); + String msg = result.getMsg(); + assertNotNull(msg, "msg 不应为 null"); + assertTrue(msg.contains("【文档分类·影像源缺失】"), "msg 应含影像源缺失前缀"); + assertNoUpstreamPlatformName(msg, "msg"); + } + + /** + * 假设(Given): imageUrlOrBase64 为非法 Base64 + * 当(When): 调用 classify 接口 + * 则(Then): 返回 code=200,msg 含分类错误信息 + */ + @Test + void classifyInvalidBase64Test() { + // Arrange + DocClassifyRequest request = new DocClassifyRequest(); + request.setImageUrlOrBase64("not_a_valid_base64!!!"); + + // Act + R result = docClassifyController.recognize(request); + + // Assert + assertNotNull(result, "返回结果不能为空"); + assertEquals("200", result.getCode(), "接口应返回成功"); + assertNotNull(result.getData(), "data 不能为 null"); + String msg = result.getMsg(); + assertNotNull(msg, "msg 不应为 null"); + assertTrue(msg.startsWith("【文档分类·"), "msg 应含文档分类前缀"); + assertNoUpstreamPlatformName(msg, "msg"); + } + + /** + * 假设(Given): imageUrlOrBase64 为非法 URL(ftp 协议) + * 当(When): 调用 classify 接口 + * 则(Then): 返回 code=200,msg 含链接协议无效 + */ + @Test + void classifyInvalidUrlProtocolTest() { + // Arrange + DocClassifyRequest request = new DocClassifyRequest(); + request.setImageUrlOrBase64("ftp://example.com/image.png"); + + // Act + R result = docClassifyController.recognize(request); + + // Assert + assertNotNull(result, "返回结果不能为空"); + assertEquals("200", result.getCode(), "接口应返回成功"); + assertNotNull(result.getData(), "data 不能为 null"); + String msg = result.getMsg(); + assertTrue(msg.contains("【文档分类·"), "msg 应含文档分类前缀"); + assertNoUpstreamPlatformName(msg, "msg"); + } + + // ===================== 识别成功场景 ===================== + + /** + * 假设(Given): 目标图片可访问,百度 API 可用 + * 当(When): 调用 classify 接口,传入图片 URL + * 则(Then): 返回 code=200,data 不为 null 且含分类结果 + */ @Test void classifyByUrlTest() { - // 1. 构造请求 - BDocClassifyRequest request = new BDocClassifyRequest(); - request.setImageUrl(TestConstant.TARGET_IMAGE_URL); + // Arrange + DocClassifyRequest request = new DocClassifyRequest(); + request.setImageUrlOrBase64(TestConstant.TARGET_IMAGE_URL); - // 2. 打印请求参数 - System.out.println("\n========== 请求参数 =========="); - System.out.println(JSON.toJSONString(request)); - System.out.println("=============================="); + // Act + R result = docClassifyController.recognize(request); - // 3. 调用本地 /doc/classify 接口 - R result = docClassifyController.classify(request); - - // 4. 打印响应结果 - System.out.println("\n========== 响应结果 =========="); - System.out.println(JSON.toJSONString(result)); - System.out.println("=============================="); - - // 5. 断言 + // Assert assertNotNull(result, "返回结果不能为空"); assertEquals("200", result.getCode(), "接口应返回成功"); assertNotNull(result.getData(), "返回数据不能为空"); - - // 6. 打印接口返回值 - System.out.println("\n========== /doc/classify 接口返回 =========="); - System.out.println("{\"code\":\"" + result.getCode() + "\","); - System.out.println(" \"msg\":\"" + result.getMsg() + "\","); - System.out.println(" \"data\":" + JSON.toJSONString(result.getData())); - System.out.println("}"); - System.out.println("================================================"); - } - - @Test - void classifyParamEmptyTest() { - // 1. 构造请求 - image 和 url 都为空 - BDocClassifyRequest request = new BDocClassifyRequest(); - - // 2. 调用接口 - R result = docClassifyController.classify(request); - - // 3. 断言 - assertNotNull(result, "返回结果不能为空"); - assertEquals("400", result.getCode(), "参数为空时应返回失败"); + assertTrue(result.getData() instanceof DocClassifyResp, "data 类型应为 DocClassifyResp"); + assertNoUpstreamPlatformName(result.getMsg(), "msg"); } /** * 假设(Given): 本地存在 base64 编码的测试图片 - * 当(When): 调用 classify 接口,传入 imageBase64 参数 - * 则(Then): 返回成功的分类检测结果 + * 当(When): 调用 classify 接口,传入 imageUrlOrBase64 参数(base64) + * 则(Then): 返回 code=200,data 不为 null */ @Test void classifyByBase64Test() { - // 1. 构造请求 - 使用 base64 图片 - BDocClassifyRequest request = new BDocClassifyRequest(); - request.setImageBase64(TestConstant.TARGET_IMAGE_BASE64); + // Arrange + DocClassifyRequest request = new DocClassifyRequest(); + request.setImageUrlOrBase64(TestConstant.TARGET_IMAGE_BASE64); - // 2. 调用接口 - R result = docClassifyController.classify(request); + // Act + R result = docClassifyController.recognize(request); - // 3. 断言 + // Assert assertNotNull(result, "返回结果不能为空"); assertEquals("200", result.getCode(), "base64 传图应返回成功"); assertNotNull(result.getData(), "返回数据不能为空"); - - System.out.println("\n========== base64 分类结果 =========="); - System.out.println(JSON.toJSONString(result)); - System.out.println("===================================="); + assertNoUpstreamPlatformName(result.getMsg(), "msg"); } - /** - * 假设(Given): 同时传入 imageBase64 和 imageUrl - * 当(When): 调用 classify 接口 - * 则(Then): 接口正常返回成功(image 优先级更高) - */ - @Test - void classifyByBase64AndUrlBothTest() { - // 1. 构造请求 - 同时传入 base64 和 url - BDocClassifyRequest request = new BDocClassifyRequest(); - request.setImageBase64(TestConstant.TARGET_IMAGE_BASE64); - request.setImageUrl(TestConstant.TARGET_IMAGE_URL); - - // 2. 调用接口 - R result = docClassifyController.classify(request); - - // 3. 断言 - assertNotNull(result, "返回结果不能为空"); - assertEquals("200", result.getCode(), "同时传 base64 和 url 应返回成功"); - assertNotNull(result.getData(), "返回数据不能为空"); - } + // ===================== 断言工具方法 ===================== /** - * 假设(Given): imageBase64 传入无效数据(非 base64 字符串) - * 当(When): 调用 classify 接口 - * 则(Then): 百度 API 返回识别失败 + * 断言文本中不包含上游平台字样(百度、baidu 等)。 */ - @Test - void classifyByInvalidBase64Test() { - // 1. 构造请求 - 传入无效 base64 - BDocClassifyRequest request = new BDocClassifyRequest(); - request.setImageBase64("invalid_base64_data_not_an_image"); - - // 2. 调用接口 - R result = docClassifyController.classify(request); - - // 3. 断言 - 百度 API 会返回错误,接口应包装为非200或返回错误信息 - assertNotNull(result, "返回结果不能为空"); - // 无效图片百度会返回错误码,不一定是200 - assertTrue(result.getCode() != null, "返回 code 不能为空"); - - System.out.println("\n========== 无效 base64 结果 =========="); - System.out.println(JSON.toJSONString(result)); - System.out.println("====================================="); + private void assertNoUpstreamPlatformName(String text, String fieldName) { + if (text == null) { + return; + } + String lower = text.toLowerCase(); + assertTrue(!lower.contains("百度") && !lower.contains("baidu") && !lower.contains("aliyun") && !lower.contains("tencent"), + fieldName + " 不应包含上游平台字样,实际值:" + text); } }