会员分佣,阿里人脸识别优化
This commit is contained in:
1 parent
66db2f11d9
commit
30176e7268
8 files changed
+658
-69
No files matched your search
+1
-1
@@ -31,7 +31,7 @@ public interface TradeBrokerageRecordApi {
|
||||
void addBrokerage(Long userId, BrokerageRecordBizTypeEnum bizType, @Valid List<BrokerageAddDTO> list);
|
||||
|
||||
/**
|
||||
* 取消佣金:将佣金记录,状态修改为已失效
|
||||
* 取消佣金:将佣金记录状态修改为已失效
|
||||
*
|
||||
* @param bizType 业务类型
|
||||
* @param bizId 业务编号
|
||||
|
||||
+428
@@ -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<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");
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
+86
-35
@@ -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<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());
|
||||
@@ -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++) {
|
||||
|
||||
+7
@@ -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<ThirdFindFaceVerifyResVo> findFaceVerify(@RequestParam("authId") Long authId) {
|
||||
return success(comAuthService.findFaceVerify(authId, getLoginUserId()));
|
||||
}
|
||||
|
||||
}
|
||||
+6
@@ -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);
|
||||
|
||||
/**
|
||||
* 获得用户认证
|
||||
*/
|
||||
|
||||
+48
-8
@@ -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<ComAuthDO>()
|
||||
.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());
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
-25
@@ -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);
|
||||
|
||||
+82
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
Reference in new issue
Block a user