diff --git a/yudao-module-mall/yudao-module-trade-api/src/main/java/cn/iocoder/yudao/module/trade/api/brokerage/TradeBrokerageRecordApi.java b/yudao-module-mall/yudao-module-trade-api/src/main/java/cn/iocoder/yudao/module/trade/api/brokerage/TradeBrokerageRecordApi.java index 1d34530..8b13c6b 100644 --- a/yudao-module-mall/yudao-module-trade-api/src/main/java/cn/iocoder/yudao/module/trade/api/brokerage/TradeBrokerageRecordApi.java +++ b/yudao-module-mall/yudao-module-trade-api/src/main/java/cn/iocoder/yudao/module/trade/api/brokerage/TradeBrokerageRecordApi.java @@ -31,7 +31,7 @@ public interface TradeBrokerageRecordApi { void addBrokerage(Long userId, BrokerageRecordBizTypeEnum bizType, @Valid List list); /** - * 取消佣金:将佣金记录,状态修改为已失效 + * 取消佣金:将佣金记录状态修改为已失效 * * @param bizType 业务类型 * @param bizId 业务编号 diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/client/service/FaceVerifyImageProcessor.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/client/service/FaceVerifyImageProcessor.java new file mode 100644 index 0000000..883096a --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/client/service/FaceVerifyImageProcessor.java @@ -0,0 +1,428 @@ +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.geom.AffineTransform; +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; + /** 横图转竖图时使用的目标宽高比(宽:高),约等于 3:4 */ + private static final double PORTRAIT_ASPECT = 3D / 4D; + + /** 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("人脸认证图片格式不支持或文件已损坏"); + } + + // iPhone 竖拍常是「横像素 + EXIF Orientation」,必须先转正再判断竖/横图 + int orientation = readJpegExifOrientation(sourceImage); + if (orientation > 1) { + BufferedImage oriented = applyExifOrientation(decodedImage, orientation); + decodedImage.flush(); + decodedImage = oriented; + log.info("|人脸识别|,已按 EXIF Orientation={} 转正比对图", orientation); + } + + int sourceWidth = decodedImage.getWidth(); + int sourceHeight = decodedImage.getHeight(); + // 阿里云要求人脸比对照片为竖图(高>宽);横图居中裁成竖图,避免直接失败 + if (sourceHeight <= sourceWidth) { + BufferedImage portrait = cropToPortrait(decodedImage); + decodedImage.flush(); + decodedImage = portrait; + sourceWidth = decodedImage.getWidth(); + sourceHeight = decodedImage.getHeight(); + log.info("|人脸识别|,横图已居中裁成竖图,尺寸={}x{}", sourceWidth, sourceHeight); + } + 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; + } + + /** + * 将横图居中裁成竖图(约 3:4),尽量保留中间人脸区域。 + */ + private static BufferedImage cropToPortrait(BufferedImage source) { + int width = source.getWidth(); + int height = source.getHeight(); + int cropHeight = height; + int cropWidth = (int) Math.floor(cropHeight * PORTRAIT_ASPECT); + if (cropWidth >= cropHeight) { + cropWidth = Math.max(1, cropHeight - 1); + } + if (cropWidth > width) { + cropWidth = width; + cropHeight = (int) Math.floor(cropWidth / PORTRAIT_ASPECT); + if (cropHeight > height) { + cropHeight = height; + } + if (cropHeight <= cropWidth) { + cropHeight = Math.min(height, cropWidth + 1); + } + } + int x = Math.max(0, (width - cropWidth) / 2); + int y = Math.max(0, (height - cropHeight) / 2); + cropWidth = Math.min(cropWidth, width - x); + cropHeight = Math.min(cropHeight, height - y); + return source.getSubimage(x, y, cropWidth, cropHeight); + } + + /** + * 解析 JPEG EXIF Orientation(Tag 0x0112),非 JPEG / 失败返回 1。 + */ + private static int readJpegExifOrientation(byte[] jpeg) { + try { + if (jpeg == null || jpeg.length < 4 || (jpeg[0] & 0xFF) != 0xFF || (jpeg[1] & 0xFF) != 0xD8) { + return 1; + } + int offset = 2; + while (offset + 4 < jpeg.length) { + if ((jpeg[offset] & 0xFF) != 0xFF) { + break; + } + int marker = jpeg[offset + 1] & 0xFF; + offset += 2; + if (marker == 0xD9 || marker == 0xDA) { + break; + } + if (offset + 2 > jpeg.length) { + break; + } + int segLen = ((jpeg[offset] & 0xFF) << 8) | (jpeg[offset + 1] & 0xFF); + if (segLen < 2 || offset + segLen > jpeg.length) { + break; + } + if (marker == 0xE1 && segLen >= 8) { + int exifStart = offset + 2; + if (exifStart + 6 <= jpeg.length + && jpeg[exifStart] == 'E' && jpeg[exifStart + 1] == 'x' + && jpeg[exifStart + 2] == 'i' && jpeg[exifStart + 3] == 'f' + && jpeg[exifStart + 4] == 0 && jpeg[exifStart + 5] == 0) { + int tiff = exifStart + 6; + boolean little = jpeg[tiff] == 'I' && jpeg[tiff + 1] == 'I'; + if (!(little || (jpeg[tiff] == 'M' && jpeg[tiff + 1] == 'M'))) { + return 1; + } + int ifd0 = tiff + readInt(jpeg, tiff + 4, 4, little); + if (ifd0 + 2 > jpeg.length) { + return 1; + } + int entries = readInt(jpeg, ifd0, 2, little); + for (int i = 0; i < entries; i++) { + int entry = ifd0 + 2 + i * 12; + if (entry + 12 > jpeg.length) { + break; + } + int tag = readInt(jpeg, entry, 2, little); + if (tag == 0x0112) { + return readInt(jpeg, entry + 8, 2, little); + } + } + } + } + offset += segLen; + } + } catch (Exception ignore) { + // ignore + } + return 1; + } + + private static int readInt(byte[] data, int off, int len, boolean littleEndian) { + int v = 0; + if (littleEndian) { + for (int i = 0; i < len; i++) { + v |= (data[off + i] & 0xFF) << (8 * i); + } + } else { + for (int i = 0; i < len; i++) { + v = (v << 8) | (data[off + i] & 0xFF); + } + } + return v; + } + + private static BufferedImage applyExifOrientation(BufferedImage src, int orientation) { + int w = src.getWidth(); + int h = src.getHeight(); + AffineTransform tx = new AffineTransform(); + int newW = w; + int newH = h; + switch (orientation) { + case 2: + tx.scale(-1, 1); + tx.translate(-w, 0); + break; + case 3: + tx.translate(w, h); + tx.rotate(Math.PI); + break; + case 4: + tx.scale(1, -1); + tx.translate(0, -h); + break; + case 5: + tx.rotate(Math.PI / 2); + tx.scale(-1, 1); + newW = h; + newH = w; + break; + case 6: // 90 CW —— iPhone 竖拍最常见 + tx.translate(h, 0); + tx.rotate(Math.PI / 2); + newW = h; + newH = w; + break; + case 7: + tx.scale(-1, 1); + tx.translate(-h, 0); + tx.translate(0, w); + tx.rotate(3 * Math.PI / 2); + newW = h; + newH = w; + break; + case 8: + tx.translate(0, w); + tx.rotate(3 * Math.PI / 2); + newW = h; + newH = w; + break; + default: + return src; + } + BufferedImage dst = new BufferedImage(newW, newH, BufferedImage.TYPE_INT_RGB); + Graphics2D g = dst.createGraphics(); + try { + g.setColor(Color.WHITE); + g.fillRect(0, 0, newW, newH); + g.drawImage(src, tx, null); + } finally { + g.dispose(); + } + return dst; + } + + /** + * 根据阿里云尺寸限制计算目标宽高,保持图片原始宽高比。 + * 大图优先将宽度缩放至 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 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"); + } + + 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(); + } + } + } + +} diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/client/service/ThirdFaceVerifyServiceImpl.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/client/service/ThirdFaceVerifyServiceImpl.java index 088224e..a2739eb 100644 --- a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/client/service/ThirdFaceVerifyServiceImpl.java +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/client/service/ThirdFaceVerifyServiceImpl.java @@ -2,6 +2,9 @@ package cn.iocoder.yudao.module.member.client.service; import cn.hutool.core.bean.BeanUtil; import cn.hutool.core.util.ObjectUtil; +import cn.hutool.core.util.StrUtil; +import cn.hutool.json.JSONObject; +import cn.hutool.json.JSONUtil; import cn.iocoder.yudao.module.member.client.vo.thirdfaceverify.ThirdFindFaceVerifyReqVo; import cn.iocoder.yudao.module.member.client.vo.thirdfaceverify.ThirdFindFaceVerifyResVo; import cn.iocoder.yudao.module.member.client.vo.thirdfaceverify.ThirdFaceVerifyReqVO; @@ -16,12 +19,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 +37,20 @@ public class ThirdFaceVerifyServiceImpl implements ThirdFaceVerifyService { private static final List 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()); @@ -72,8 +59,23 @@ public class ThirdFaceVerifyServiceImpl implements ThirdFaceVerifyService { // 参数 request.setOuterOrderNo(dto.getCode()); request.setUserId(String.valueOf(dto.getFaceVerifyUserId())); - request.setMetaInfo(dto.getMetaInfoJson()); - // request.setFaceContrastPictureUrl(dto.getFaceVerifyUrl()); + // MetaInfo 必须是客户端 getMetaInfos 原始字符串;错误包装会导致 SDK 不弹刷脸页 + String metaInfo = dto.getMetaInfoJson(); + if (StrUtil.isNotBlank(metaInfo) && metaInfo.trim().startsWith("{") && metaInfo.contains("\"metaInfoJson\"")) { + try { + JSONObject obj = JSONUtil.parseObj(metaInfo); + String nested = obj.getStr("metaInfoJson"); + if (StrUtil.isNotBlank(nested)) { + log.warn("|人脸识别|,检测到 MetaInfo 被包了一层 authExtraJson,已自动解开"); + metaInfo = nested; + } + } catch (Exception ignore) { + // keep original + } + } + request.setMetaInfo(metaInfo); + log.info("|人脸识别|,InitFaceVerify outerOrderNo={} userId={} metaLen={}", + dto.getCode(), dto.getFaceVerifyUserId(), metaInfo == null ? 0 : metaInfo.length()); request.setFaceContrastPicture(base64Image); // 配置 @@ -86,11 +88,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()); @@ -146,16 +155,58 @@ public class ThirdFaceVerifyServiceImpl implements ThirdFaceVerifyService { log.error("|人脸识别|,第三方接口无响应"); return null; } - ThirdFindFaceVerifyResVo result = new ThirdFindFaceVerifyResVo(); DescribeFaceVerifyResponseBody body = response.getBody(); - if (ObjectUtil.notEqual(body.getCode(), "200")) { - result.setErrorMessage(body.getMessage()); - } + ThirdFindFaceVerifyResVo result = new ThirdFindFaceVerifyResVo(); DescribeFaceVerifyResponseBody.DescribeFaceVerifyResponseBodyResultObject resultObject = body.getResultObject(); - result = BeanUtil.toBean(resultObject, ThirdFindFaceVerifyResVo.class); + if (ObjectUtil.isNotEmpty(resultObject)) { + result = BeanUtil.toBean(resultObject, ThirdFindFaceVerifyResVo.class); + if (result == null) { + result = new ThirdFindFaceVerifyResVo(); + } + } + if (ObjectUtil.notEqual(body.getCode(), "200")) { + // 接口级失败信息(不可被 ResultObject 拷贝覆盖) + result.setErrorMessage(body.getMessage()); + log.warn("|人脸识别|,DescribeFaceVerify 失败 code={} msg={} certifyId={}", + body.getCode(), body.getMessage(), code.getCertifyId()); + } else if (ObjectUtil.isEmpty(result.getErrorMessage())) { + result.setErrorMessage(describeSubCode(result.getSubCode(), result.getPassed())); + } + log.info("|人脸识别|,DescribeFaceVerify certifyId={} passed={} subCode={} err={}", + code.getCertifyId(), result.getPassed(), result.getSubCode(), result.getErrorMessage()); return result; } + /** 阿里云 ResultObject.SubCode → 可读原因(对齐官方错误码说明) */ + private static String describeSubCode(String subCode, String passed) { + if (ObjectUtil.equals("T", passed) || ObjectUtil.equals("200", subCode)) { + return "认证通过"; + } + if (ObjectUtil.isEmpty(subCode)) { + return ObjectUtil.equals("F", passed) ? "认证未通过" : null; + } + switch (subCode) { + case "201": + return "姓名和身份证不一致"; + case "202": + return "查询不到身份信息"; + case "203": + return "查询不到照片或照片不可用"; + case "204": + return "人脸比对不一致(与上传头像不是同一人或活体质量偏低)"; + case "205": + return "活体检测存在风险"; + case "206": + return "业务策略限制(设备环境异常/高频认证等)"; + case "207": + return "人脸与身份证人脸比对不一致"; + case "209": + return "权威比对源异常"; + default: + return "认证未通过(subCode=" + subCode + ")"; + } + } + private DescribeFaceVerifyResponse describeFaceVerifyAutoRoute(DescribeFaceVerifyRequest request) { DescribeFaceVerifyResponse lastResponse = null; for (int i = 0; i < endpoints.size(); i++) { diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/app/comauth/AppComAuthController.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/app/comauth/AppComAuthController.java index 457b332..8a62b05 100644 --- a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/app/comauth/AppComAuthController.java +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/app/comauth/AppComAuthController.java @@ -8,6 +8,7 @@ import cn.iocoder.yudao.module.member.controller.app.comauth.vo.CreateManAuthReq import cn.iocoder.yudao.module.member.controller.app.comauth.vo.CreateWechatAuthReqVo; import cn.iocoder.yudao.module.member.controller.app.comauth.vo.CreateWechatErrorAuthReqVo; import cn.iocoder.yudao.module.member.controller.app.comauth.vo.CreateWomanAuthReqVo; +import cn.iocoder.yudao.module.member.client.vo.thirdfaceverify.ThirdFindFaceVerifyResVo; import cn.iocoder.yudao.module.member.dal.dataobject.comauth.ComAuthDO; import cn.iocoder.yudao.module.member.service.comauth.ComAuthService; import io.swagger.v3.oas.annotations.Operation; @@ -81,4 +82,10 @@ public class AppComAuthController { return success(true); } + @GetMapping("/find-face-verify") + @Operation(summary = "查询阿里云人脸认证结果(SDK 失败排查)") + public CommonResult findFaceVerify(@RequestParam("authId") Long authId) { + return success(comAuthService.findFaceVerify(authId, getLoginUserId())); + } + } diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/comauth/ComAuthService.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/comauth/ComAuthService.java index 2b67f74..9ddf11b 100644 --- a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/comauth/ComAuthService.java +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/comauth/ComAuthService.java @@ -10,6 +10,7 @@ import cn.iocoder.yudao.module.member.controller.app.comauth.vo.CreateManAuthReq import cn.iocoder.yudao.module.member.controller.app.comauth.vo.CreateWechatAuthReqVo; import cn.iocoder.yudao.module.member.controller.app.comauth.vo.CreateWechatErrorAuthReqVo; import cn.iocoder.yudao.module.member.controller.app.comauth.vo.CreateWomanAuthReqVo; +import cn.iocoder.yudao.module.member.client.vo.thirdfaceverify.ThirdFindFaceVerifyResVo; import cn.iocoder.yudao.module.member.dal.dataobject.comauth.ComAuthDO; /** @@ -62,6 +63,11 @@ public interface ComAuthService { */ void cancelUserAuth(Long authId, Long loginUserId); + /** + * 查询阿里云人脸认证结果(DescribeFaceVerify),用于 SDK 2006 等失败排查 + */ + ThirdFindFaceVerifyResVo findFaceVerify(Long authId, Long loginUserId); + /** * 获得用户认证 */ diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/comauth/ComAuthServiceImpl.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/comauth/ComAuthServiceImpl.java index b045e76..2014083 100644 --- a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/comauth/ComAuthServiceImpl.java +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/comauth/ComAuthServiceImpl.java @@ -187,7 +187,10 @@ public class ComAuthServiceImpl implements ComAuthService { @Transactional(rollbackFor = Exception.class) public ComAuthDO createManAuth(CreateManAuthReqVo dto) { ComAuthDO startAuth = isStartAuth(dto.getMemberId()); - if (ObjectUtil.isNotEmpty(startAuth)) return startAuth; + // 进行中的旧单直接作废:复用旧 certifyId 常导致 SDK 不弹刷脸页(客户端未提交) + if (ObjectUtil.isNotEmpty(startAuth)) { + authMapper.deleteById(startAuth.getId()); + } // 新增 ComAuthDO saveObj = new ComAuthDO(); saveObj.setAuthMemberId(dto.getMemberId()); @@ -204,8 +207,8 @@ public class ComAuthServiceImpl implements ComAuthService { updateObj.setId(saveObj.getId()); updateObj.setAuthExtraJson(authExtraJson); authMapper.updateById(updateObj); - // 发起认证 - getSelf().startAuth(saveObj, authExtraJson); + // 发起认证:必须传客户端原始 MetaInfo,不能传整段 authExtraJson + getSelf().startAuth(saveObj, dto.getMetaInfoJson()); return authMapper.selectById(saveObj.getId()); } @@ -216,7 +219,10 @@ public class ComAuthServiceImpl implements ComAuthService { @Transactional(rollbackFor = Exception.class) public ComAuthDO createWomanAuth(CreateWomanAuthReqVo dto) { ComAuthDO startAuth = isStartAuth(dto.getMemberId()); - if (ObjectUtil.isNotEmpty(startAuth)) return startAuth; + // 进行中的旧单直接作废:复用旧 certifyId 常导致 SDK 不弹刷脸页(客户端未提交) + if (ObjectUtil.isNotEmpty(startAuth)) { + authMapper.deleteById(startAuth.getId()); + } // 新增auth ComAuthDO saveObj = new ComAuthDO(); saveObj.setAuthMemberId(dto.getMemberId()); @@ -238,8 +244,8 @@ public class ComAuthServiceImpl implements ComAuthService { updateObj.setId(saveObj.getId()); updateObj.setAuthExtraJson(authExtraJson); authMapper.updateById(updateObj); - // 发起认证 - getSelf().startAuth(saveObj, authExtraJson); + // 发起认证:必须传客户端原始 MetaInfo,不能传整段 authExtraJson + getSelf().startAuth(saveObj, dto.getMetaInfoJson()); return authMapper.selectById(saveObj.getId()); } @@ -270,7 +276,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()) { @@ -390,6 +399,37 @@ public class ComAuthServiceImpl implements ComAuthService { authMapper.deleteById(detail.getId()); } + /** + * 查询阿里云人脸认证结果(DescribeFaceVerify) + */ + @Override + public ThirdFindFaceVerifyResVo findFaceVerify(Long authId, Long loginUserId) { + ComAuthDO detail = authMapper.selectOne(new LambdaQueryWrapperX() + .eq(ComAuthDO::getId, authId) + .eq(ComAuthDO::getAuthMemberId, loginUserId) + ); + if (ObjectUtil.isEmpty(detail)) { + throw exception(MemberErrorCode.COM_AUTH_NOT_EXISTS); + } + if (ObjectUtil.isEmpty(detail.getBizId())) { + throw exception(MemberErrorCode.EMPTY, "认证单 certifyId 为空,无法查询阿里云结果"); + } + ThirdFindFaceVerifyReqVo dto = new ThirdFindFaceVerifyReqVo(); + dto.setCertifyId(detail.getBizId()); + ThirdFindFaceVerifyResVo res = thirdFaceVerifyService.findFaceVerify(dto); + if (ObjectUtil.isEmpty(res)) { + throw exception(MemberErrorCode.EMPTY, "查询阿里云认证结果失败,请稍后重试"); + } + // 落库失败原因,便于后台排查;不在此改 authStatus(仍由回调/轮询/客户端 cancel 处理) + if (ObjectUtil.isNotEmpty(res.getErrorMessage())) { + ComAuthDO updateObj = new ComAuthDO(); + updateObj.setId(detail.getId()); + updateObj.setFailReason(res.getErrorMessage()); + authMapper.updateById(updateObj); + } + return res; + } + /** * 获得用户认证 */ @@ -522,4 +562,4 @@ public class ComAuthServiceImpl implements ComAuthService { return SpringUtil.getBean(getClass()); } -} \ No newline at end of file +} diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/opentype/OpenTypeServiceImpl.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/opentype/OpenTypeServiceImpl.java index 6a82acd..462d25f 100644 --- a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/opentype/OpenTypeServiceImpl.java +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/opentype/OpenTypeServiceImpl.java @@ -33,9 +33,6 @@ import cn.iocoder.yudao.module.pay.api.refund.dto.PayRefundCreateReqDTO; import cn.iocoder.yudao.module.pay.api.refund.dto.PayRefundRespDTO; import cn.iocoder.yudao.module.pay.enums.order.PayOrderStatusEnum; import cn.iocoder.yudao.module.pay.enums.refund.PayRefundStatusEnum; -import cn.iocoder.yudao.module.trade.api.brokerage.TradeBrokerageRecordApi; -import cn.iocoder.yudao.module.trade.api.brokerage.dto.BrokerageAddDTO; -import cn.iocoder.yudao.module.trade.enums.brokerage.BrokerageRecordBizTypeEnum; import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; import com.baomidou.mybatisplus.core.metadata.IPage; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; @@ -93,8 +90,6 @@ public class OpenTypeServiceImpl implements OpenTypeService { private PayOrderApi payOrderApi; @Resource private PayRefundApi payRefundApi; - @Resource - private TradeBrokerageRecordApi tradeBrokerageRecordApi; /** * 获取最后一条会员开通记录 @@ -208,24 +203,6 @@ public class OpenTypeServiceImpl implements OpenTypeService { return openTypeMapper.selectList(pageReqVO); } - /** - * 开通会员后增加分销佣金 - */ - private void addOpenTypeBrokerage(Long memberId, Long openTypeUserId, Integer price, String title) { - if (price == null || price <= 0) { - return; - } - BrokerageAddDTO brokerageAddDTO = new BrokerageAddDTO(); - brokerageAddDTO.setBizId(String.valueOf(openTypeUserId)); - brokerageAddDTO.setBasePrice(price); - brokerageAddDTO.setFirstFixedPrice(0); - brokerageAddDTO.setSecondFixedPrice(0); - brokerageAddDTO.setSourceUserId(memberId); - brokerageAddDTO.setTitle(title); - tradeBrokerageRecordApi.addBrokerage(memberId, BrokerageRecordBizTypeEnum.OPEN_TYPE, - java.util.Collections.singletonList(brokerageAddDTO)); - } - /** * 购买会员 */ @@ -445,8 +422,6 @@ public class OpenTypeServiceImpl implements OpenTypeService { } // 添加会员tag memberRecentTagService.createOrUpdateVipTag(openTypeUserDO.getMemberId()); - // 开通会员分佣:仅在支付成功后发放,避免未支付订单重复计佣 - addOpenTypeBrokerage(openTypeUserDO.getMemberId(), openTypeUserDO.getId(), openTypeUserDO.getPayPrice(), openType.getTitle()); String message = String.format("恭喜您购买成功,此次购买的产品:%s", openType.getTitle()); this.chatApi.messageUserSendText(openTypeUserDO.getMemberId(), message); diff --git a/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/client/service/FaceVerifyImageProcessorTest.java b/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/client/service/FaceVerifyImageProcessorTest.java new file mode 100644 index 0000000..9ff587f --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/client/service/FaceVerifyImageProcessorTest.java @@ -0,0 +1,82 @@ +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 normalizeImageConvertsLandscapeToPortraitByCenterCrop() throws Exception { + byte[] source = createPng(800, 600); + + byte[] result = FaceVerifyImageProcessor.normalizeImage(source); + + BufferedImage resultImage = ImageIO.read(new ByteArrayInputStream(result)); + assertNotNull(resultImage); + assertTrue(resultImage.getHeight() > resultImage.getWidth()); + assertTrue(resultImage.getWidth() >= 480); + assertTrue(resultImage.getHeight() >= 640); + } + + @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(); + } + } + +}