feat(doc): 添加文档分类和篡改检测功能支持base64图片上传

- 在BaseController中新增normalizeBase64Image方法处理多种base64图片格式
- 重构DocClassifyController支持imageBase64参数并添加URL编码处理
- 将ForgeryDetectionController从命令模式改为直接调用百度API
- 添加URLEncoder对图片参数进行编码防止传输错误
- 新增ForgeDetectionController的完整单元测试覆盖多种场景
- 添加DocClassifyController的base64图片上传测试用例
- 创建TestConstant统一管理测试用的图片资源
- 移除不再使用的内部DTO转换逻辑简化代码结构
This commit is contained in:
zhengBoss 2026-06-07 20:16:43 +08:00
parent 67e18b4fa3
commit 894c3124ac
8 changed files with 250 additions and 149 deletions

View File

@ -55,6 +55,34 @@ public class BaseController {
return value == null || value.trim().length() == 0;
}
/**
* 规范化 base64 图片输入兼容多种常见格式
* <ul>
* <li>data:image/png;base64,xxxxData URI 格式自动剥离前缀</li>
* <li>data:image/jpeg;base64,xxxx同理</li>
* <li> base64 字符串直接保留</li>
* <li>含换行/空白字符的 base64自动清除</li>
* </ul>
*
* @param imageBase64 原始输入
* @return 清理后的纯 base64 字符串
*/
protected String normalizeBase64Image(String imageBase64) {
if (imageBase64 == null) {
return null;
}
// 剥离 data:image/xxx;base64, 前缀
String value = imageBase64.trim();
if (value.regionMatches(true, 0, "data:", 0, 5)) {
int commaIdx = value.indexOf(',');
if (commaIdx >= 0) {
value = value.substring(commaIdx + 1);
}
}
// 清除 base64 中常见的换行和空白
return value.replaceAll("[\\s\\r\\n]", "");
}
protected Map<String, Object> requestBaidu(String uri, String content) {
String result = null;
try {

View File

@ -11,6 +11,7 @@ import com.heyu.api.data.utils.StringUtils;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.net.URLEncoder;
import java.util.Map;
/***
@ -45,7 +46,12 @@ public class DocClassifyController extends BaseController {
if (request == null || (isBlank(request.getImageBase64()) && isBlank(request.getImageUrl()))) {
return R.error("image 和 url 不能都为空");
}
String content = buildContent(request);
String content;
try {
content = buildContent(request);
} catch (Exception e) {
return R.error("参数编码失败");
}
Map<String, Object> result = requestBaidu(DOC_CLASSIFY_URI, content);
if (result == null) {
return R.error("识别失败");
@ -55,13 +61,13 @@ public class DocClassifyController extends BaseController {
return R.ok().setData(resp);
}
private String buildContent(BDocClassifyRequest request) {
private String buildContent(BDocClassifyRequest request) throws Exception {
StringBuilder sb = new StringBuilder();
if (StringUtils.isNotBlank(request.getImageBase64())) {
sb.append("&image=").append(request.getImageBase64());
sb.append("&image=").append(URLEncoder.encode(normalizeBase64Image(request.getImageBase64()), "UTF-8"));
}
if (StringUtils.isNotBlank(request.getImageUrl())) {
sb.append("&url=").append(request.getImageUrl());
sb.append("&url=").append(URLEncoder.encode(request.getImageUrl(), "UTF-8"));
}
return sb.toString();
}

View File

@ -1,23 +1,21 @@
package com.heyu.api.controller.doc;
import com.heyu.api.baidu.handle.doc.BForgeryDetectionHandle;
import com.heyu.api.baidu.request.doc.BForgeryDetectionRequest;
import com.alibaba.fastjson.JSONObject;
import com.heyu.api.baidu.response.doc.BForgeryDetectionResp;
import com.heyu.api.controller.BaseController;
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.ApiR;
import com.heyu.api.data.utils.R;
import com.heyu.api.data.utils.StringUtils;
import com.heyu.api.request.doc.ForgeryDetectionRequest;
import com.heyu.api.resp.doc.ForgeryDetectionResp;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.ArrayList;
import java.util.List;
import java.net.URLEncoder;
import java.util.Map;
/**
* 图片篡改检测
@ -27,82 +25,59 @@ import java.util.List;
* 支持对图像中的伪造区域以热力图形式进行可视化返回
*
* 百度文档https://cloud.baidu.com/doc/OCR/s/kmfdnccok
*
* @author zhengli
* @since 20260607_zl
*/
@Slf4j
@RestController
@RequestMapping("/doc")
@NotIntercept
public class ForgeryDetectionController {
public class ForgeryDetectionController extends BaseController {
@Autowired
private BForgeryDetectionHandle bForgeryDetectionHandle;
private static final String FORGERY_DETECTION_URI = "rest/2.0/ocr/v1/forgery_detection";
/**
* 图片篡改检测
* @param request: {"detectProportion":"true","detectThreshold":"0.9887","imageUrl":"https://www.opsky.com.cn/upload/20211224/KXfgvm2MFRAXKbPu5LK.png","restrictProbability":"0.8","returnHeatmap":"true"}
* @return
*
* 假设(Given) + (When) + (Then):
* - Given: 传入 imageBase64 imageUrl二选一
* - When: 调用百度篡改检测API使用默认参数
* - Then: 返回篡改检测结果包含 detectionResulttamperedLocation
*
* @param request {"imageBase64": "...", "imageUrl": "..."}
* @return 篡改检测结果
*/
@EbAuthentication(tencent = ApiConstants.TENCENT_AUTH)
@PostMapping("/forgeryDetection")
public R<ForgeryDetectionResp> forgeryDetection(ForgeryDetectionRequest request) {
long start = System.currentTimeMillis();
BForgeryDetectionRequest bRequest = toBaiduRequest(request);
ApiR<BForgeryDetectionResp> bR = bForgeryDetectionHandle.handle(bRequest);
if (!bR.isSuccess()) {
log.info("图片篡改检测-失败, error:{}, 耗时:{}ms", bR.getErrorMsg(), System.currentTimeMillis() - start);
return R.error(bR.getErrorMsg());
public R<BForgeryDetectionResp> forgeryDetection(ForgeryDetectionRequest request) {
if (request == null || (isBlank(request.getImageBase64()) && isBlank(request.getImageUrl()))) {
return R.error("image 和 url 不能都为空");
}
BForgeryDetectionResp bResp = bR.getData();
if (bResp.getResult() == null) {
log.info("图片篡改检测-结果为空, 耗时:{}ms", System.currentTimeMillis() - start);
return R.error("图片篡改检测结果为空");
String content;
try {
content = buildContent(request);
} catch (Exception e) {
log.error("forgeryDetection buildContent 异常", e);
return R.error("参数编码失败");
}
ForgeryDetectionResp resp = toResp(bResp);
log.info("图片篡改检测-完成, 耗时:{}ms", System.currentTimeMillis() - start);
Map<String, Object> result = requestBaidu(FORGERY_DETECTION_URI, content);
if (result == null) {
return R.error("识别失败");
}
BForgeryDetectionResp resp = JSONObject.parseObject(
JSONObject.toJSONString(result), BForgeryDetectionResp.class);
return R.ok().setData(resp);
}
private BForgeryDetectionRequest toBaiduRequest(ForgeryDetectionRequest request) {
BForgeryDetectionRequest bRequest = new BForgeryDetectionRequest();
if (request == null) {
return bRequest;
private String buildContent(ForgeryDetectionRequest request) throws Exception {
StringBuilder sb = new StringBuilder();
if (StringUtils.isNotBlank(request.getImageBase64())) {
sb.append("&image=").append(URLEncoder.encode(normalizeBase64Image(request.getImageBase64()), "UTF-8"));
}
bRequest.setImageBase64(request.getImageBase64());
bRequest.setImageUrl(request.getImageUrl());
bRequest.setDetectProportion(request.getDetectProportion());
bRequest.setDetectThreshold(request.getDetectThreshold());
bRequest.setReturnHeatmap(request.getReturnHeatmap());
bRequest.setRestrictProbability(request.getRestrictProbability());
return bRequest;
}
private ForgeryDetectionResp toResp(BForgeryDetectionResp bResp) {
ForgeryDetectionResp resp = new ForgeryDetectionResp();
BForgeryDetectionResp.ResultDTO result = bResp.getResult();
resp.setDetectionResult(result.getDetectionResult());
resp.setTamperedProportion(result.getTamperedProportion());
resp.setHeatmap(result.getHeatmap());
if (result.getTamperedLocation() != null) {
List<ForgeryDetectionResp.TamperedLocation> locations = new ArrayList<>();
for (BForgeryDetectionResp.TamperedLocationDTO dto : result.getTamperedLocation()) {
ForgeryDetectionResp.TamperedLocation location = new ForgeryDetectionResp.TamperedLocation();
location.setLeft(dto.getLeft());
location.setTop(dto.getTop());
location.setWidth(dto.getWidth());
location.setHeight(dto.getHeight());
location.setProbability(dto.getProbability());
locations.add(location);
}
resp.setTamperedLocation(locations);
if (StringUtils.isNotBlank(request.getImageUrl())) {
sb.append("&url=").append(URLEncoder.encode(request.getImageUrl(), "UTF-8"));
}
return resp;
return sb.toString();
}
}

View File

@ -4,6 +4,9 @@ import lombok.Data;
/**
* 图片篡改检测 - 前端请求参数
*
* @author zhengli
* @since 20260607_zl
*/
@Data
public class ForgeryDetectionRequest {
@ -19,28 +22,4 @@ public class ForgeryDetectionRequest {
* imageBase64 二选一
*/
private String imageUrl;
/**
* 是否返回图片篡改置信度
* - true返回
* - false不返回默认
*/
private String detectProportion;
/**
* 图片篡改检出阈值范围0.00011默认为0.9887
*/
private String detectThreshold;
/**
* 是否返回伪造区域热力图
* - true返回热力图base64编码
* - false不返回默认
*/
private String returnHeatmap;
/**
* 返回伪造区域坐标的阈值范围0.11默认为0.8
*/
private String restrictProbability;
}

View File

@ -0,0 +1,20 @@
package com;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
public class TestConstant {
public static final String TARGET_IMAGE_URL = "https://www.opsky.com.cn/upload/20211224/KXfgvm2MFRAXKbPu5LK.png";
public static final String TARGET_IMAGE_BASE64;
static {
try {
Path path = Paths.get("src/test/resources/image_base64.txt");
TARGET_IMAGE_BASE64 = "data:image/png;base64," + new String(Files.readAllBytes(path), StandardCharsets.UTF_8).trim();
} catch (Exception e) {
throw new RuntimeException("Failed to load image_base64.txt", e);
}
}
}

View File

@ -1,6 +1,7 @@
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;
@ -10,6 +11,7 @@ import org.springframework.beans.factory.annotation.Autowired;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* 文件检测分类 - 在线图片验证
@ -28,13 +30,12 @@ public class DocClassifyControllerTest extends ApiInterfaceApplicationTests {
@Autowired
private DocClassifyController docClassifyController;
private static final String TARGET_IMAGE_URL = "https://www.opsky.com.cn/upload/20211224/KXfgvm2MFRAXKbPu5LK.png";
@Test
void classifyByUrlTest() {
// 1. 构造请求
BDocClassifyRequest request = new BDocClassifyRequest();
request.setImageUrl(TARGET_IMAGE_URL);
request.setImageUrl(TestConstant.TARGET_IMAGE_URL);
// 2. 打印请求参数
System.out.println("\n========== 请求参数 ==========");
@ -75,4 +76,73 @@ public class DocClassifyControllerTest extends ApiInterfaceApplicationTests {
assertNotNull(result, "返回结果不能为空");
assertEquals("400", result.getCode(), "参数为空时应返回失败");
}
/**
* 假设(Given): 本地存在 base64 编码的测试图片
* (When): 调用 classify 接口传入 imageBase64 参数
* (Then): 返回成功的分类检测结果
*/
@Test
void classifyByBase64Test() {
// 1. 构造请求 - 使用 base64 图片
BDocClassifyRequest request = new BDocClassifyRequest();
request.setImageBase64(TestConstant.TARGET_IMAGE_BASE64);
// 2. 调用接口
R<BDocClassifyResp> result = docClassifyController.classify(request);
// 3. 断言
assertNotNull(result, "返回结果不能为空");
assertEquals("200", result.getCode(), "base64 传图应返回成功");
assertNotNull(result.getData(), "返回数据不能为空");
System.out.println("\n========== base64 分类结果 ==========");
System.out.println(JSON.toJSONString(result));
System.out.println("====================================");
}
/**
* 假设(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 返回识别失败
*/
@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("=====================================");
}
}

View File

@ -1,10 +1,10 @@
package com.heyu.api.controller.doc;
import com.ApiInterfaceApplicationTests;
import com.alibaba.fastjson.JSON;
import com.TestConstant;
import com.heyu.api.baidu.response.doc.BForgeryDetectionResp;
import com.heyu.api.data.utils.R;
import com.heyu.api.request.doc.ForgeryDetectionRequest;
import com.heyu.api.resp.doc.ForgeryDetectionResp;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
@ -12,75 +12,97 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
/**
* 图片篡改检测 - 在线图片验证
*
* 调用本地 /doc/forgeryDetection 接口验证目标图片是否被篡改
*
* 假设(Given): 目标图片可访问百度 API 可用
* (When): 调用 forgeryDetection 接口所有参数开启
* (Then): 返回检测结果
* 图片篡改检测 - 单元测试
*
* @author zhengli
* @since 20260528_zl
* @since 20260607_zl
*/
public class ForgeryDetectionControllerTest extends ApiInterfaceApplicationTests {
@Autowired
private ForgeryDetectionController forgeryDetectionController;
private static final String TARGET_IMAGE_URL = "https://www.opsky.com.cn/upload/20211224/KXfgvm2MFRAXKbPu5LK.png";
/**
* 验证传入 imageUrl 调用百度篡改检测使用默认参数
*
* 假设(Given): 百度 API 可用目标图片可访问
* (When): 只传 imageUrl不传可选参数
* (Then): 返回 code=200data nullresult 中有 detectionResult
*/
@Test
void forgeryDetectionAllParamsTest() {
// 1. 构造请求 - 所有参数开启
void forgeryDetectionWithImageUrlTest() {
// Arrange
ForgeryDetectionRequest request = new ForgeryDetectionRequest();
request.setImageUrl(TARGET_IMAGE_URL);
request.setDetectProportion("true");
request.setDetectThreshold("0.9887");
request.setReturnHeatmap("true");
request.setRestrictProbability("0.8");
request.setImageUrl(TestConstant.TARGET_IMAGE_URL);
// 2. 打印请求参数
System.out.println("\n========== 请求参数 ==========");
System.out.println(JSON.toJSONString(request));
System.out.println("==============================");
// Act
R<BForgeryDetectionResp> result = forgeryDetectionController.forgeryDetection(request);
// 3. 调用本地 /doc/forgeryDetection 接口
R<ForgeryDetectionResp> result = forgeryDetectionController.forgeryDetection(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(), "返回数据不能为空");
assertNotNull(result.getData().getResult(), "百度返回的 result 不能为空");
assertNotNull(result.getData().getResult().getDetectionResult(), "检测结果 detectionResult 不能为空");
}
// 6. 打印接口返回值
ForgeryDetectionResp data = result.getData();
String heatmapDisplay = data.getHeatmap() != null
? data.getHeatmap().substring(0, Math.min(50, data.getHeatmap().length())) + "...(共" + data.getHeatmap().length() + "字符)"
: "null";
/**
* 验证传入 imageBase64 调用百度篡改检测使用默认参数
*
* 假设(Given): 百度 API 可用imageBase64 为有效图片
* (When): 只传 imageBase64不传可选参数
* (Then): 返回 code=200data nullresult 中有 detectionResult
*/
@Test
void forgeryDetectionWithImageBase64Test() {
ForgeryDetectionRequest request = new ForgeryDetectionRequest();
request.setImageBase64(TestConstant.TARGET_IMAGE_BASE64);
System.out.println("\n========== /doc/forgeryDetection 接口返回 ==========");
System.out.println("{\"code\":\"" + result.getCode() + "\",");
System.out.println(" \"msg\":\"" + result.getMsg() + "\",");
System.out.println(" \"data\":{");
System.out.println(" \"detectionResult\":\"" + data.getDetectionResult() + "\",");
System.out.println(" \"tamperedProportion\":" + data.getTamperedProportion() + ",");
System.out.println(" \"heatmap\":\"" + heatmapDisplay + "\",");
System.out.println(" \"tamperedLocation\":" + JSON.toJSONString(data.getTamperedLocation()));
System.out.println(" }");
System.out.println("}");
System.out.println("====================================================");
// Act
R<BForgeryDetectionResp> result = forgeryDetectionController.forgeryDetection(request);
// 结论
if ("fake".equals(data.getDetectionResult())) {
System.out.println(">>> 结论: 图片【已被篡改】,篡改置信度: " + data.getTamperedProportion());
} else {
System.out.println(">>> 结论: 图片【未被篡改】,篡改置信度: " + data.getTamperedProportion());
}
// Assert
assertNotNull(result, "返回结果不能为空");
assertEquals("200", result.getCode(), "接口应返回成功");
assertNotNull(result.getData(), "返回数据不能为空");
assertNotNull(result.getData().getResult(), "百度返回的 result 不能为空");
assertNotNull(result.getData().getResult().getDetectionResult(), "检测结果 detectionResult 不能为空");
}
/**
* 验证image url 都为空时返回错误
*
* 假设(Given): 不传 imageBase64 也不传 imageUrl
* (When): 调用接口
* (Then): 返回 code=400msg 包含错误信息
*/
@Test
void forgeryDetectionEmptyParamsTest() {
// Arrange
ForgeryDetectionRequest request = new ForgeryDetectionRequest();
// Act
R<BForgeryDetectionResp> result = forgeryDetectionController.forgeryDetection(request);
// Assert
assertNotNull(result, "返回结果不能为空");
assertEquals("400", result.getCode(), "参数为空应返回 400");
}
/**
* 验证request null 时返回错误
*
* 假设(Given): request null
* (When): 调用接口
* (Then): 返回 code=400
*/
@Test
void forgeryDetectionNullRequestTest() {
// Act
R<BForgeryDetectionResp> result = forgeryDetectionController.forgeryDetection(null);
// Assert
assertNotNull(result, "返回结果不能为空");
assertEquals("400", result.getCode(), "request 为 null 应返回 400");
}
}

File diff suppressed because one or more lines are too long