feat:上传图片压缩校验

This commit is contained in:
dzc committed 2026-08-10 14:35:11 +08:00
1 parent 0f9d061f4e
commit db279acc61
4 files changed
+347 -30

No files matched your search

@@ -0,0 +1,244 @@
package cn.iocoder.yudao.module.member.client.service;
import lombok.extern.slf4j.Slf4j;
import javax.imageio.IIOImage;
import javax.imageio.ImageIO;
import javax.imageio.ImageWriteParam;
import javax.imageio.ImageWriter;
import javax.imageio.stream.ImageOutputStream;
import java.awt.Color;
import java.awt.Graphics2D;
import java.awt.RenderingHints;
import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.util.Base64;
import java.util.Iterator;
import java.util.Locale;
/**
* 阿里云实人认证图片处理器
*/
@Slf4j
final class FaceVerifyImageProcessor {
/** 允许下载的原始图片最大字节数:10 MB */
private static final int MAX_SOURCE_IMAGE_BYTES = 10 * 1024 * 1024;
/** 提交至阿里云的人脸图片最大字节数:1 MB,实际结果必须严格小于该值 */
private static final int MAX_FACE_IMAGE_BYTES = 1024 * 1024;
/** 阿里云要求的图片最小宽度 */
private static final int MIN_WIDTH = 480;
/** 阿里云要求的图片最小高度 */
private static final int MIN_HEIGHT = 640;
/** 阿里云推荐的图片短边宽度,用作缩放目标宽度 */
private static final int RECOMMENDED_WIDTH = 720;
/** 阿里云允许的图片最大宽度 */
private static final int MAX_WIDTH = 1080;
/** 阿里云允许的图片最大高度 */
private static final int MAX_HEIGHT = 1920;
/** JPEG 输出质量:0.9,用于兼顾人脸清晰度和文件大小 */
private static final float JPEG_QUALITY = 0.90F;
/** 图片超过 1 MB 时,每轮等比缩小的系数 */
private static final double SIZE_REDUCTION_FACTOR = 0.90D;
/** 下载图片的连接超时时间:5 秒 */
private static final int CONNECT_TIMEOUT_MILLIS = 5_000;
/** 下载图片的读取超时时间:10 秒 */
private static final int READ_TIMEOUT_MILLIS = 10_000;
private FaceVerifyImageProcessor() {
}
/**
* 下载图片并处理为阿里云接口要求的纯 Base64 字符串。
*/
static String downloadAndConvertToBase64(String imageUrl) {
byte[] sourceImage = downloadImage(imageUrl);
byte[] processedImage = normalizeImage(sourceImage);
log.info("|人脸识别|,图片处理完成,原始字节数={},处理后字节数={}", sourceImage.length, processedImage.length);
return Base64.getEncoder().encodeToString(processedImage);
}
/**
* 将图片等比缩放并统一编码为符合阿里云要求的 JPEG 图片。
*/
static byte[] normalizeImage(byte[] sourceImage) {
if (sourceImage == null || sourceImage.length == 0) {
throw new IllegalArgumentException("人脸认证图片内容为空");
}
// 先解码验证文件确实为运行环境支持的图片,避免将错误页或损坏文件传给阿里云。
BufferedImage decodedImage;
try (ByteArrayInputStream inputStream = new ByteArrayInputStream(sourceImage)) {
decodedImage = ImageIO.read(inputStream);
} catch (IOException e) {
throw new IllegalArgumentException("人脸认证图片解码失败", e);
}
if (decodedImage == null) {
throw new IllegalArgumentException("人脸认证图片格式不支持或文件已损坏");
}
int sourceWidth = decodedImage.getWidth();
int sourceHeight = decodedImage.getHeight();
// 阿里云要求人脸比对照片为竖图;横图不做裁剪,避免裁掉人脸区域。
if (sourceHeight <= sourceWidth) {
decodedImage.flush();
throw new IllegalArgumentException("人脸认证图片必须为竖图,且高度大于宽度");
}
// 保持原始宽高比缩放,并转换为不带透明通道的 RGB 图片。
int[] dimensions = calculateDimensions(sourceWidth, sourceHeight);
BufferedImage processedImage = resizeToRgb(decodedImage, dimensions[0], dimensions[1]);
decodedImage.flush();
byte[] encodedImage = encodeJpeg(processedImage);
// JPEG 质量固定为 0.9;若仍超过 1 MB,则继续缩小尺寸以保留人脸清晰度。
while (encodedImage.length >= MAX_FACE_IMAGE_BYTES) {
int nextWidth = (int) Math.floor(processedImage.getWidth() * SIZE_REDUCTION_FACTOR);
int nextHeight = (int) Math.floor(processedImage.getHeight() * SIZE_REDUCTION_FACTOR);
if (nextWidth < MIN_WIDTH || nextHeight < MIN_HEIGHT) {
processedImage.flush();
throw new IllegalArgumentException("人脸认证图片无法在有效尺寸内压缩至1MB以下");
}
BufferedImage smallerImage = resizeToRgb(processedImage, nextWidth, nextHeight);
processedImage.flush();
processedImage = smallerImage;
encodedImage = encodeJpeg(processedImage);
}
log.info("|人脸识别|,图片尺寸处理完成,原始尺寸={}x{},处理后尺寸={}x{}", sourceWidth, sourceHeight, processedImage.getWidth(), processedImage.getHeight());
processedImage.flush();
return encodedImage;
}
/**
* 根据阿里云尺寸限制计算目标宽高,保持图片原始宽高比。
* 大图优先将宽度缩放至 720 像素,小图仅放大至接口要求的最小尺寸。
*/
private static int[] calculateDimensions(int sourceWidth, int sourceHeight) {
double scale = 1D;
if (sourceWidth > RECOMMENDED_WIDTH || sourceHeight > MAX_HEIGHT) {
scale = Math.min((double) RECOMMENDED_WIDTH / sourceWidth, (double) MAX_HEIGHT / sourceHeight);
} else if (sourceWidth < MIN_WIDTH || sourceHeight < MIN_HEIGHT) {
scale = Math.max((double) MIN_WIDTH / sourceWidth, (double) MIN_HEIGHT / sourceHeight);
}
int targetWidth = Math.max(1, (int) Math.floor(sourceWidth * scale));
int targetHeight = Math.max(1, (int) Math.floor(sourceHeight * scale));
if (targetWidth < MIN_WIDTH || targetHeight < MIN_HEIGHT || targetWidth > MAX_WIDTH || targetHeight > MAX_HEIGHT) {
throw new IllegalArgumentException(String.format("人脸认证图片比例无法满足阿里云尺寸要求,原始尺寸:%dx%d", sourceWidth, sourceHeight));
}
return new int[]{targetWidth, targetHeight};
}
/**
* 使用双三次插值等比绘制图片,并使用白色背景消除透明通道。
*/
private static BufferedImage resizeToRgb(BufferedImage sourceImage, int targetWidth, int targetHeight) {
BufferedImage targetImage = new BufferedImage(targetWidth, targetHeight, BufferedImage.TYPE_INT_RGB);
Graphics2D graphics = targetImage.createGraphics();
try {
graphics.setColor(Color.WHITE);
graphics.fillRect(0, 0, targetWidth, targetHeight);
graphics.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BICUBIC);
graphics.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY);
graphics.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
graphics.drawImage(sourceImage, 0, 0, targetWidth, targetHeight, null);
} finally {
graphics.dispose();
}
return targetImage;
}
/**
* 使用固定质量将 RGB 图片编码为 JPEG 字节。
*/
private static byte[] encodeJpeg(BufferedImage image) {
Iterator<ImageWriter> writers = ImageIO.getImageWritersByFormatName("jpeg");
if (!writers.hasNext()) {
throw new IllegalStateException("当前运行环境不支持JPEG图片编码");
}
ImageWriter writer = writers.next();
try (ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
ImageOutputStream imageOutputStream = ImageIO.createImageOutputStream(outputStream)) {
ImageWriteParam writeParam = writer.getDefaultWriteParam();
writeParam.setCompressionMode(ImageWriteParam.MODE_EXPLICIT);
writeParam.setCompressionQuality(JPEG_QUALITY);
writer.setOutput(imageOutputStream);
writer.write(null, new IIOImage(image, null, null), writeParam);
imageOutputStream.flush();
return outputStream.toByteArray();
} catch (IOException e) {
throw new IllegalStateException("人脸认证图片编码失败", e);
} finally {
writer.dispose();
}
}
/**
* 下载远程图片,同时校验协议、HTTP 状态、响应类型和最大下载量。
*/
private static byte[] downloadImage(String imageUrl) {
if (imageUrl == null || imageUrl.trim().isEmpty()) {
throw new IllegalArgumentException("人脸认证图片地址为空");
}
HttpURLConnection connection = null;
try {
URL url = new URL(imageUrl);
if (!"http".equalsIgnoreCase(url.getProtocol()) && !"https".equalsIgnoreCase(url.getProtocol())) {
throw new IllegalArgumentException("人脸认证图片地址必须使用HTTP或HTTPS协议");
}
connection = (HttpURLConnection) url.openConnection();
connection.setConnectTimeout(CONNECT_TIMEOUT_MILLIS);
connection.setReadTimeout(READ_TIMEOUT_MILLIS);
connection.setInstanceFollowRedirects(true);
connection.setRequestProperty("Accept", "image/*");
int responseCode = connection.getResponseCode();
if (responseCode < HttpURLConnection.HTTP_OK || responseCode >= HttpURLConnection.HTTP_MULT_CHOICE) {
throw new IllegalArgumentException("人脸认证图片下载失败,HTTP状态码:" + responseCode);
}
String contentType = connection.getContentType();
if (contentType == null || !contentType.toLowerCase(Locale.ROOT).startsWith("image/")) {
throw new IllegalArgumentException("人脸认证图片地址返回的不是图片,Content-Type:" + contentType);
}
int contentLength = connection.getContentLength();
if (contentLength > MAX_SOURCE_IMAGE_BYTES) {
throw new IllegalArgumentException("人脸认证原始图片不能超过10MB");
}
// 即使服务端未返回 Content-Length,也在读取过程中限制总字节数,避免占用过多内存。
try (InputStream inputStream = connection.getInputStream();
ByteArrayOutputStream outputStream = new ByteArrayOutputStream(
contentLength > 0 ? Math.min(contentLength, MAX_SOURCE_IMAGE_BYTES) : 8192)) {
byte[] buffer = new byte[8192];
int totalBytes = 0;
int bytesRead;
while ((bytesRead = inputStream.read(buffer)) != -1) {
totalBytes += bytesRead;
if (totalBytes > MAX_SOURCE_IMAGE_BYTES) {
throw new IllegalArgumentException("人脸认证原始图片不能超过10MB");
}
outputStream.write(buffer, 0, bytesRead);
}
return outputStream.toByteArray();
}
} catch (IOException e) {
throw new IllegalArgumentException("人脸认证图片下载失败:" + e.getMessage(), e);
} finally {
if (connection != null) {
connection.disconnect();
}
}
}
}
@@ -16,12 +16,7 @@ import com.aliyun.teautil.models.RuntimeOptions;
import javax.annotation.Resource;
import javax.validation.Valid;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.URL;
import java.util.Arrays;
import java.util.Base64;
import java.util.List;
/**
@@ -39,31 +34,20 @@ public class ThirdFaceVerifyServiceImpl implements ThirdFaceVerifyService {
private static final List<String> endpoints = Arrays.asList("cloudauth.cn-shanghai.aliyuncs.com", "cloudauth.cn-beijing.aliyuncs.com");
public static String convertImageUrlToBase64(String imageUrl) {
try {
URL url = new URL(imageUrl);
ByteArrayOutputStream baos = new ByteArrayOutputStream();
try (InputStream is = url.openStream()) {
byte[] buffer = new byte[4096];
int bytesRead;
while ((bytesRead = is.read(buffer)) != -1) {
baos.write(buffer, 0, bytesRead);
}
}
byte[] imageBytes = baos.toByteArray();
return Base64.getEncoder().encodeToString(imageBytes);
} catch (Exception e) {
log.error("|人脸识别|,图片转换url-image to base64 错误:{}", e.getMessage());
return null;
}
}
/**
* 人脸识别
*/
@Override
public ThirdFaceVerifyResVO faceVerify(ThirdFaceVerifyReqVO dto) {
String base64Image = convertImageUrlToBase64(dto.getFaceVerifyUrl());
ThirdFaceVerifyResVO result = new ThirdFaceVerifyResVO();
String base64Image;
try {
base64Image = FaceVerifyImageProcessor.downloadAndConvertToBase64(dto.getFaceVerifyUrl());
} catch (IllegalArgumentException | IllegalStateException e) {
log.warn("|人脸识别|,图片处理失败:{}", e.getMessage());
result.setErrorMessage(e.getMessage());
return result;
}
InitFaceVerifyRequest request = new InitFaceVerifyRequest();
request.setSceneId(thirdAlConfig.getFaceSceneId());
@@ -86,11 +70,18 @@ public class ThirdFaceVerifyServiceImpl implements ThirdFaceVerifyService {
return null;
}
InitFaceVerifyResponseBody body = response.getBody();
InitFaceVerifyResponseBody.InitFaceVerifyResponseBodyResultObject resultObject = body.getResultObject();
ThirdFaceVerifyResVO result = new ThirdFaceVerifyResVO();
if (ObjectUtil.isEmpty(body)) {
result.setErrorMessage("第三方接口响应内容为空");
return result;
}
if (ObjectUtil.notEqual(body.getCode(), "200")) {
result.setErrorMessage(body.getMessage());
return result;
}
InitFaceVerifyResponseBody.InitFaceVerifyResponseBodyResultObject resultObject = body.getResultObject();
if (ObjectUtil.isEmpty(resultObject)) {
result.setErrorMessage("第三方接口认证结果为空");
return result;
}
result.setCertifyId(resultObject.getCertifyId());
result.setCertifyUrl(resultObject.getCertifyUrl());
@@ -270,7 +270,10 @@ public class ComAuthServiceImpl implements ComAuthService {
dto.setFaceVerifyUrl(auth.getAuthPic());
dto.setFaceVerifyUserId(auth.getAuthMemberId());
ThirdFaceVerifyResVO thirdRes = thirdFaceVerifyService.faceVerify(dto);
if (ObjectUtil.isEmpty(thirdRes) || thirdRes.isFail()) {
if (ObjectUtil.isEmpty(thirdRes)) {
throw exception(MemberErrorCode.THIRD_AUTH_START_ERROR, "第三方接口无响应");
}
if (thirdRes.isFail()) {
throw exception(MemberErrorCode.THIRD_AUTH_START_ERROR, thirdRes.getErrorMessage());
}
if (thirdRes.isPass()) {
@@ -522,4 +525,4 @@ public class ComAuthServiceImpl implements ComAuthService {
return SpringUtil.getBean(getClass());
}
}
}
@@ -0,0 +1,79 @@
package cn.iocoder.yudao.module.member.client.service;
import org.junit.jupiter.api.Test;
import javax.imageio.ImageIO;
import java.awt.Color;
import java.awt.Graphics2D;
import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
class FaceVerifyImageProcessorTest {
@Test
void normalizeImageResizesLargePortraitAndCompressesBelowLimit() throws Exception {
byte[] source = createPng(2316, 3088);
byte[] result = FaceVerifyImageProcessor.normalizeImage(source);
assertTrue(result.length < 1024 * 1024);
BufferedImage resultImage = ImageIO.read(new ByteArrayInputStream(result));
assertNotNull(resultImage);
assertEquals(720, resultImage.getWidth());
assertEquals(960, resultImage.getHeight());
}
@Test
void normalizeImageUpscalesImageToMinimumDimensions() throws Exception {
byte[] source = createPng(300, 400);
byte[] result = FaceVerifyImageProcessor.normalizeImage(source);
BufferedImage resultImage = ImageIO.read(new ByteArrayInputStream(result));
assertNotNull(resultImage);
assertEquals(480, resultImage.getWidth());
assertEquals(640, resultImage.getHeight());
}
@Test
void normalizeImageRejectsLandscapeImage() throws Exception {
byte[] source = createPng(800, 600);
IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
() -> FaceVerifyImageProcessor.normalizeImage(source));
assertTrue(exception.getMessage().contains("竖图"));
}
@Test
void normalizeImageRejectsUnsupportedContent() {
byte[] source = "not an image".getBytes();
assertThrows(IllegalArgumentException.class, () -> FaceVerifyImageProcessor.normalizeImage(source));
}
private static byte[] createPng(int width, int height) throws Exception {
BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
Graphics2D graphics = image.createGraphics();
try {
graphics.setPaint(new java.awt.GradientPaint(0, 0, Color.BLUE, width, height, Color.ORANGE));
graphics.fillRect(0, 0, width, height);
} finally {
graphics.dispose();
}
try (ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
ImageIO.write(image, "png", outputStream);
return outputStream.toByteArray();
} finally {
image.flush();
}
}
}