Merge branch 'master' of https://git.iwulin.tech/quyixiao/eb-service-api
t push # especially if it merges an updated upstream into a topic branch.
This commit is contained in:
commit
f1440a754d
@ -1,78 +1,415 @@
|
||||
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.DocClassifyLocationResp;
|
||||
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
|
||||
/**
|
||||
* 文档分类检测控制器。
|
||||
* <p>
|
||||
* 对图片中的文档、卡证、票据等含文字的主体进行检测、分类,
|
||||
* 可同时支持一张图片中多张主体的情况,返回每个主体的类别及位置信息。
|
||||
* </p>
|
||||
*
|
||||
* 文件检测分类
|
||||
* <h3>百度官方文档</h3>
|
||||
* <ul>
|
||||
* <li>产品文档:<a href="https://cloud.baidu.com/doc/OCR/s/qlor1ahik">文档分类</a></li>
|
||||
* <li>接口地址:{@code POST https://aip.baidubce.com/rest/2.0/ocr/v1/doc_classify}</li>
|
||||
* </ul>
|
||||
*
|
||||
* 接口描述
|
||||
* 对图片中的文档、卡证、票据等含文字的主体进行检测、分类,可同时支持一张图片中多张主体的情况,返回每个主体的类别及位置信息。
|
||||
* <h3>本服务接口</h3>
|
||||
* <ul>
|
||||
* <li>路径:{@code POST /doc/classify}</li>
|
||||
* <li>Content-Type:{@code application/json}({@code @RequestBody})</li>
|
||||
* <li>鉴权:{@link EbAuthentication}(Tencent 鉴权头,见项目网关配置)</li>
|
||||
* </ul>
|
||||
*
|
||||
* <h3>返回约定</h3>
|
||||
* <ul>
|
||||
* <li>一律 {@code R.ok()};入参校验失败、平台异常、识别结果为空等情况,将说明写入 {@code msg},
|
||||
* {@code data} 可能为空或字段不全(对外不暴露上游平台字样,日志内可排查)</li>
|
||||
* <li>入参校验失败时<strong>未调用平台、不计费</strong></li>
|
||||
* </ul>
|
||||
*
|
||||
* @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";
|
||||
|
||||
/**
|
||||
* 文件检测分类,对图片中的文档、卡证、票据等含文字的主体进行检测、分类
|
||||
* 文档分类检测。
|
||||
*
|
||||
* 请求参数:
|
||||
* <pre>
|
||||
* {
|
||||
* "imageBase64": "图像base64编码(与imageUrl二选一)",
|
||||
* "imageUrl": "图片完整URL(与imageBase64二选一)"
|
||||
* }
|
||||
* </pre>
|
||||
* @param request 文档分类请求
|
||||
* @return {@code R<DocClassifyResp>} 包含分类结果列表
|
||||
*/
|
||||
@RequestMapping("/classify")
|
||||
@CacheResult
|
||||
@EbAuthentication(tencent = ApiConstants.TENCENT_AUTH)
|
||||
public R<BDocClassifyResp> classify(BDocClassifyRequest request) {
|
||||
if (request == null || (isBlank(request.getImageBase64()) && isBlank(request.getImageUrl()))) {
|
||||
return R.error("image 和 url 不能都为空");
|
||||
}
|
||||
String content;
|
||||
@PostMapping("/classify")
|
||||
@CacheResult
|
||||
public R<DocClassifyResp> 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<String, Object> 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<String, Object> 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<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(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<String, Object> 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.getWordsResult() == null || data.getWordsResult().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.getWordsResult().size() + ",有效字段数=0"
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private void logRecognizeResult(RecognizeContext ctx, Map<String, Object> platformResult,
|
||||
DocClassifyResp data, String hint, long start) {
|
||||
long cost = System.currentTimeMillis() - start;
|
||||
int itemCount = data != null && data.getWordsResult() != null ? data.getWordsResult().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();
|
||||
}
|
||||
|
||||
/**
|
||||
* 从百度平台原始回执中构造分类响应。
|
||||
* <p>doc_classify 的 words_result 是 List 结构,不同于 OCR 识别的 Map 结构。</p>
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
private DocClassifyResp buildResp(Map<String, Object> platformResult) {
|
||||
DocClassifyResp resp = new DocClassifyResp();
|
||||
|
||||
// 一级字段:log_id
|
||||
Object logId = platformResult.get("log_id");
|
||||
resp.setLogId(logId != null ? String.valueOf(logId) : null);
|
||||
|
||||
// 一级字段:words_result
|
||||
Object wordsResult = platformResult.get("words_result");
|
||||
if (!(wordsResult instanceof List)) {
|
||||
resp.setWordsResult(Collections.emptyList());
|
||||
return resp;
|
||||
}
|
||||
List<Map<String, Object>> resultList = (List<Map<String, Object>>) wordsResult;
|
||||
List<DocClassifyItemResp> items = new ArrayList<>(resultList.size());
|
||||
for (Map<String, Object> item : resultList) {
|
||||
items.add(buildItemResp(item));
|
||||
}
|
||||
resp.setWordsResult(items);
|
||||
return resp;
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private DocClassifyItemResp buildItemResp(Map<String, Object> item) {
|
||||
DocClassifyItemResp resp = new DocClassifyItemResp();
|
||||
|
||||
// 二级字段:type
|
||||
Object type = item.get("type");
|
||||
resp.setType(type != null ? String.valueOf(type) : null);
|
||||
|
||||
// 二级字段:probability(注意:百度 API 原始字段名为 "probablity",拼写错误,此处与之保持一致)
|
||||
Object prob = item.get("probablity");
|
||||
resp.setProbability(prob != null ? String.valueOf(prob) : null);
|
||||
|
||||
// 二级字段:location(嵌套对象)
|
||||
Object location = item.get("location");
|
||||
if (location instanceof Map) {
|
||||
Map<String, Object> loc = (Map<String, Object>) location;
|
||||
DocClassifyLocationResp locationResp = new DocClassifyLocationResp();
|
||||
locationResp.setLeft(stringOrNull(loc.get("left")));
|
||||
locationResp.setTop(stringOrNull(loc.get("top")));
|
||||
locationResp.setWidth(stringOrNull(loc.get("width")));
|
||||
locationResp.setHeight(stringOrNull(loc.get("height")));
|
||||
resp.setLocation(locationResp);
|
||||
}
|
||||
return resp;
|
||||
}
|
||||
|
||||
private static String stringOrNull(Object value) {
|
||||
return value != null ? String.valueOf(value) : null;
|
||||
}
|
||||
|
||||
// ===================== 私有工具 =====================
|
||||
|
||||
/**
|
||||
* 判断分类结果是否为空。
|
||||
* <p>doc_classify 的 words_result 是 List,与 OCR 识别的 Map 结构不同,
|
||||
* 因此不能直接使用父类的 {@link #isWordsResultEmpty}。</p>
|
||||
*/
|
||||
private boolean isClassifyResultEmpty(Map<String, Object> 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.getWordsResult() == null) {
|
||||
return 0;
|
||||
}
|
||||
int total = 0;
|
||||
for (DocClassifyItemResp item : data.getWordsResult()) {
|
||||
total += countNonBlankStringFields(item);
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断所有分类条目的 String 字段是否全为空。
|
||||
* <p>doc_classify 的 words_result 是 List 结构,与 OCR 识别的 Map 结构不同,
|
||||
* 因此不能直接使用父类的 {@link #isAllStringFieldsBlank}(它只看顶层 String 字段),
|
||||
* 改为遍历每条 ItemResp 做反射判断。</p>
|
||||
*/
|
||||
private boolean isAllItemsFieldsBlank(DocClassifyResp data) {
|
||||
if (data == null || data.getWordsResult() == null || data.getWordsResult().isEmpty()) {
|
||||
return true;
|
||||
}
|
||||
for (DocClassifyItemResp item : data.getWordsResult()) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,28 @@
|
||||
package com.heyu.api.request.doc;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 文档分类检测请求参数。
|
||||
* <p>
|
||||
* 对外只暴露一个字段 {@code imageUrlOrBase64},
|
||||
* 服务端自动识别为 HTTP(S) 链接或 Base64 字符串。
|
||||
* </p>
|
||||
*
|
||||
* @author zhengli
|
||||
* @since 20260609_zl
|
||||
*/
|
||||
@Data
|
||||
public class DocClassifyRequest {
|
||||
|
||||
/**
|
||||
* 影像入参(URL 或 Base64 二合一):
|
||||
* <ul>
|
||||
* <li>HTTP/HTTPS 图片链接(≤1024 字符)</li>
|
||||
* <li>图片 Base64(可带 data:image/...;base64, 前缀)</li>
|
||||
* </ul>
|
||||
* 服务端通过 ImageInputUtils 自动识别。
|
||||
*/
|
||||
private String imageUrlOrBase64;
|
||||
|
||||
}
|
||||
@ -0,0 +1,42 @@
|
||||
package com.heyu.api.resp.doc;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 文档分类检测 — 单个分类结果项(words_result 数组元素)。
|
||||
* <p>
|
||||
* 二级字段:
|
||||
* <ul>
|
||||
* <li>type — 类别信息</li>
|
||||
* <li>probability — 分类置信度</li>
|
||||
* <li>location — 位置数组(左上角为坐标0点)</li>
|
||||
* </ul>
|
||||
* </p>
|
||||
*
|
||||
* @author zhengli
|
||||
* @since 20260609_zl
|
||||
*/
|
||||
@Data
|
||||
public class DocClassifyItemResp {
|
||||
|
||||
/**
|
||||
* 类别信息,当前可输出类别包括:卡证_身份证_正、卡证_银行卡、办公文档、表格 等 200+ 种。
|
||||
* 对应百度字段:type(string)
|
||||
* 示例:"卡证_银行卡"
|
||||
*/
|
||||
private String type;
|
||||
|
||||
/**
|
||||
* 分类置信度(0~1 之间的小数)。
|
||||
* 对应百度字段:probability(float)
|
||||
* 示例:"0.9999860525"
|
||||
*/
|
||||
private String probability;
|
||||
|
||||
/**
|
||||
* 位置信息(左上角为坐标0点)。
|
||||
* 对应百度字段:location(object)
|
||||
*/
|
||||
private DocClassifyLocationResp location;
|
||||
|
||||
}
|
||||
@ -0,0 +1,41 @@
|
||||
package com.heyu.api.resp.doc;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 文档分类检测 — 位置信息(location 子对象)。
|
||||
* <p>
|
||||
* 表示定位位置的长方形,左上角为坐标0点。
|
||||
* </p>
|
||||
*
|
||||
* @author zhengli
|
||||
* @since 20260609_zl
|
||||
*/
|
||||
@Data
|
||||
public class DocClassifyLocationResp {
|
||||
|
||||
/**
|
||||
* 表示定位位置的长方形左上顶点的水平坐标(像素)。
|
||||
* 对应百度字段:location.left(uint32)
|
||||
*/
|
||||
private String left;
|
||||
|
||||
/**
|
||||
* 表示定位位置的长方形左上顶点的垂直坐标(像素)。
|
||||
* 对应百度字段:location.top(uint32)
|
||||
*/
|
||||
private String top;
|
||||
|
||||
/**
|
||||
* 表示定位位置的长方形的宽度(像素)。
|
||||
* 对应百度字段:location.width(uint32)
|
||||
*/
|
||||
private String width;
|
||||
|
||||
/**
|
||||
* 表示定位位置的长方形的高度(像素)。
|
||||
* 对应百度字段:location.height(uint32)
|
||||
*/
|
||||
private String height;
|
||||
|
||||
}
|
||||
@ -1,19 +1,35 @@
|
||||
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;
|
||||
|
||||
/***
|
||||
* 分类置信度
|
||||
/**
|
||||
* 文档分类检测响应,对齐百度官方接口返回结构。
|
||||
* <p>
|
||||
* 一级字段:
|
||||
* <ul>
|
||||
* <li>logId — 唯一的日志id,用于问题定位</li>
|
||||
* <li>wordsResult — 检测和分类结果数组,如图片中无文字内容,则此数组为空</li>
|
||||
* </ul>
|
||||
* </p>
|
||||
*
|
||||
* @author zhengli
|
||||
* @since 20260609_zl
|
||||
*/
|
||||
@Data
|
||||
public class DocClassifyResp {
|
||||
|
||||
/**
|
||||
* 唯一的日志id,用于问题定位。
|
||||
* 对应百度字段:log_id(uint64)
|
||||
*/
|
||||
private Double probablity;
|
||||
/***
|
||||
* 类别信息,当前可输出类别列表见下文「附录:类别列表」
|
||||
*/
|
||||
private String type;
|
||||
private String logId;
|
||||
|
||||
/**
|
||||
* 检测和分类结果数组;如图片中无文字内容,则此数组为空。
|
||||
* 对应百度字段:words_result(array)
|
||||
*/
|
||||
private List<DocClassifyItemResp> wordsResult;
|
||||
|
||||
}
|
||||
|
||||
@ -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): 返回检测结果
|
||||
* 文档分类检测控制器测试。
|
||||
* <p>
|
||||
* 覆盖参数校验、上游异常、识别空结果、识别成功、兜底等必测场景。
|
||||
* </p>
|
||||
*
|
||||
* @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<BDocClassifyResp> 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<BDocClassifyResp> 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<BDocClassifyResp> 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<BDocClassifyResp> 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<BDocClassifyResp> 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);
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user