diff --git a/src/main/java/com/ycwl/basic/service/pc/impl/FaceServiceImpl.java b/src/main/java/com/ycwl/basic/service/pc/impl/FaceServiceImpl.java index 03639921..8271902a 100644 --- a/src/main/java/com/ycwl/basic/service/pc/impl/FaceServiceImpl.java +++ b/src/main/java/com/ycwl/basic/service/pc/impl/FaceServiceImpl.java @@ -58,6 +58,7 @@ import com.ycwl.basic.repository.VideoTaskRepository; import com.ycwl.basic.service.mobile.GoodsService; import com.ycwl.basic.service.pc.FaceService; import com.ycwl.basic.service.pc.ScenicService; +import com.ycwl.basic.service.printer.PrinterService; import com.ycwl.basic.service.task.TaskFaceService; import com.ycwl.basic.service.task.TaskService; import com.ycwl.basic.storage.StorageFactory; @@ -152,6 +153,8 @@ public class FaceServiceImpl implements FaceService { private MemberRelationRepository memberRelationRepository; @Autowired private TemplateRepository templateRepository; + @Autowired + private PrinterService printerService; @Override public ApiResponse> pageQuery(FaceReqQuery faceReqQuery) { @@ -215,16 +218,16 @@ public class FaceServiceImpl implements FaceService { SearchFaceRespVo userDbSearchResult = faceService.searchFace(faceBodyAdapter, USER_FACE_DB_NAME+scenicId, faceUrl, "判断是否为用户上传过的人脸"); float strictScore = 0.6F; if (userDbSearchResult == null) { - // 都是null了,那得是新的 + // 都是null了,那得是新的 faceBodyAdapter.addFace(USER_FACE_DB_NAME+scenicId, newFaceId.toString(), faceUrl, newFaceId.toString()); } else if (userDbSearchResult.getSampleListIds() == null || userDbSearchResult.getSampleListIds().isEmpty()) { - // 没有匹配到过,也得是新的 + // 没有匹配到过,也得是新的 faceBodyAdapter.addFace(USER_FACE_DB_NAME+scenicId, newFaceId.toString(), faceUrl, newFaceId.toString()); } else if (userDbSearchResult.getFirstMatchRate() < strictScore) { - // 有匹配结果,但是不匹配旧的 + // 有匹配结果,但是不匹配旧的 faceBodyAdapter.addFace(USER_FACE_DB_NAME+scenicId, newFaceId.toString(), faceUrl, newFaceId.toString()); } else { - // 有匹配结果,且能匹配旧的数据 + // 有匹配结果,且能匹配旧的数据 Optional faceAny = userDbSearchResult.getSampleListIds().stream().filter(_faceId -> { FaceEntity face = faceRepository.getFace(_faceId); if (face == null) { @@ -265,6 +268,11 @@ public class FaceServiceImpl implements FaceService { resp.setUrl(faceUrl); resp.setFaceId(newFaceId); matchFaceId(newFaceId, oldFaceId == null); + + // 异步执行自动添加打印 + Long finalFaceId = newFaceId; + new Thread(() -> autoAddPhotosToPreferPrint(finalFaceId), "auto-add-print-" + newFaceId).start(); + return resp; } @@ -1719,4 +1727,114 @@ public class FaceServiceImpl implements FaceService { log.error("记录低阈值检测人脸失败:faceId={}", faceId, e); } } + + /** + * 自动将人脸关联的照片添加到优先打印列表 + * 根据景区和设备配置自动添加type=2的照片到用户打印列表 + * + * @param faceId 人脸ID + */ + private void autoAddPhotosToPreferPrint(Long faceId) { + try { + // 1. 获取人脸信息 + FaceEntity face = faceRepository.getFace(faceId); + if (face == null) { + log.warn("人脸不存在,无法自动添加打印: faceId={}", faceId); + return; + } + + Long scenicId = face.getScenicId(); + Long memberId = face.getMemberId(); + + // 2. 获取景区配置 + ScenicConfigManager scenicConfig = scenicRepository.getScenicConfigManager(scenicId); + if (scenicConfig == null) { + log.warn("景区配置不存在,跳过自动添加打印: scenicId={}", scenicId); + return; + } + + // 3. 检查景区是否启用打印功能 + Boolean printEnable = scenicConfig.getBoolean("print_enable"); + if (printEnable == null || !printEnable) { + log.debug("景区未启用打印功能,跳过自动添加: scenicId={}", scenicId); + return; + } + + // 4. 查询该faceId关联的所有type=2的照片 + List imageSources = sourceMapper.listImageSourcesByFaceId(faceId); + if (imageSources == null || imageSources.isEmpty()) { + log.debug("该人脸没有关联的照片,跳过自动添加: faceId={}", faceId); + return; + } + + // 5. 按照deviceId分组处理 + Map> sourcesByDevice = imageSources.stream() + .filter(source -> source.getDeviceId() != null) + .collect(Collectors.groupingBy(SourceEntity::getDeviceId)); + + int totalAdded = 0; + for (Map.Entry> entry : sourcesByDevice.entrySet()) { + Long deviceId = entry.getKey(); + List deviceSources = entry.getValue(); + + // 6. 获取设备配置 + DeviceConfigManager deviceConfig = deviceRepository.getDeviceConfigManager(deviceId); + if (deviceConfig == null) { + log.debug("设备配置不存在,跳过该设备: deviceId={}", deviceId); + continue; + } + + // 7. 检查是否启用优先打印 + Boolean preferPrintEnable = deviceConfig.getBoolean("prefer_print_enable"); + if (preferPrintEnable == null || !preferPrintEnable) { + log.debug("设备未启用优先打印,跳过: deviceId={}", deviceId); + continue; + } + + // 8. 获取优先打印数量配置 + Integer preferPrintCount = deviceConfig.getInteger("prefer_print_count"); + if (preferPrintCount == null) { + log.debug("设备未配置优先打印数量,跳过: deviceId={}", deviceId); + continue; + } + + // 9. 根据配置添加照片到打印列表 + List sourcesToAdd; + if (preferPrintCount > 0) { + // 如果大于0,按照数量限制添加 + sourcesToAdd = deviceSources.stream() + .limit(preferPrintCount) + .collect(Collectors.toList()); + log.info("设备{}配置优先打印{}张,实际添加{}张", + deviceId, preferPrintCount, sourcesToAdd.size()); + } else { + // 如果小于等于0,添加该设备的所有照片 + sourcesToAdd = deviceSources; + log.info("设备{}配置优先打印所有照片,实际添加{}张", + deviceId, sourcesToAdd.size()); + } + + // 10. 批量添加到打印列表 + for (SourceEntity source : sourcesToAdd) { + try { + printerService.addUserPhoto(memberId, scenicId, source.getUrl()); + totalAdded++; + } catch (Exception e) { + log.warn("添加照片到打印列表失败: sourceId={}, url={}, error={}", + source.getId(), source.getUrl(), e.getMessage()); + } + } + } + + if (totalAdded > 0) { + log.info("自动添加打印完成: faceId={}, 成功添加{}张照片", faceId, totalAdded); + } else { + log.debug("自动添加打印完成: faceId={}, 无符合条件的照片", faceId); + } + + } catch (Exception e) { + // 出现异常则放弃,不影响主流程 + log.error("自动添加打印失败,已忽略: faceId={}", faceId, e); + } + } } diff --git a/src/main/java/com/ycwl/basic/service/printer/impl/PrinterServiceImpl.java b/src/main/java/com/ycwl/basic/service/printer/impl/PrinterServiceImpl.java index 42d2fa4a..e462885f 100644 --- a/src/main/java/com/ycwl/basic/service/printer/impl/PrinterServiceImpl.java +++ b/src/main/java/com/ycwl/basic/service/printer/impl/PrinterServiceImpl.java @@ -241,7 +241,58 @@ public class PrinterServiceImpl implements PrinterService { entity.setMemberId(memberId); entity.setScenicId(scenicId); entity.setOrigUrl(url); - entity.setCropUrl(url); + + // 获取打印尺寸 + String cropUrl = url; // 默认使用原图 + try { + // 从打印机表获取尺寸 + Integer printWidth = null; + Integer printHeight = null; + + List printers = printerMapper.listByScenicId(scenicId); + if (printers != null && !printers.isEmpty()) { + PrinterResp firstPrinter = printers.get(0); + printWidth = firstPrinter.getPreferW(); + printHeight = firstPrinter.getPreferH(); + log.debug("从打印机获取尺寸: scenicId={}, printerId={}, width={}, height={}", + scenicId, firstPrinter.getId(), printWidth, printHeight); + } + + // 如果打印机没有配置或配置无效,使用默认值 + if (printWidth == null || printWidth <= 0) { + printWidth = 1020; + log.debug("打印机宽度未配置或无效,使用默认值: width={}", printWidth); + } + if (printHeight == null || printHeight <= 0) { + printHeight = 1520; + log.debug("打印机高度未配置或无效,使用默认值: height={}", printHeight); + } + + // 使用smartCropAndFill裁剪图片 + File croppedFile = ImageUtils.smartCropAndFill(url, printWidth, printHeight); + + try { + // 上传裁剪后的图片(使用File版本的uploadFile方法) + String[] split = url.split("\\."); + String ext = split.length > 0 ? split[split.length - 1] : "jpg"; + + cropUrl = StorageFactory.use().uploadFile(null, croppedFile, "printer", UUID.randomUUID() + "." + ext); + + log.info("照片裁剪成功: memberId={}, scenicId={}, 原图={}, 裁剪后={}, 尺寸={}x{}", + memberId, scenicId, url, cropUrl, printWidth, printHeight); + } finally { + // 清理临时文件 + if (croppedFile != null && croppedFile.exists()) { + croppedFile.delete(); + } + } + } catch (Exception e) { + log.error("照片裁剪失败,使用原图: memberId={}, scenicId={}, url={}", memberId, scenicId, url, e); + // 出现异常则使用原图 + cropUrl = url; + } + + entity.setCropUrl(cropUrl); entity.setStatus(0); printerMapper.addUserPhoto(entity); return entity.getId(); diff --git a/src/main/java/com/ycwl/basic/utils/ImageUtils.java b/src/main/java/com/ycwl/basic/utils/ImageUtils.java index d4f4ad5c..622a8080 100644 --- a/src/main/java/com/ycwl/basic/utils/ImageUtils.java +++ b/src/main/java/com/ycwl/basic/utils/ImageUtils.java @@ -193,6 +193,235 @@ public class ImageUtils { } } + /** + * 智能裁切图片以填充目标尺寸,支持自动旋转以减少裁切损失 + * + * @param imageSource 图片源,可以是URL字符串或File对象 + * @param targetWidth 目标宽度 + * @param targetHeight 目标高度 + * @return 处理后的临时文件 + * @throws IOException 读取或处理图片失败 + * @throws IllegalArgumentException 参数无效 + */ + public static File smartCropAndFill(Object imageSource, int targetWidth, int targetHeight) throws IOException { + if (targetWidth <= 0 || targetHeight <= 0) { + throw new IllegalArgumentException("目标宽高必须大于0"); + } + + BufferedImage originalImage = loadImage(imageSource); + if (originalImage == null) { + throw new IOException("无法加载图片: " + imageSource); + } + + File resultFile = null; + BufferedImage processedImage = null; + try { + // 计算最优方案(是否需要旋转以及如何裁切) + CropStrategy strategy = calculateOptimalCropStrategy( + originalImage.getWidth(), + originalImage.getHeight(), + targetWidth, + targetHeight + ); + + log.info("图片处理策略: 原始尺寸={}x{}, 目标尺寸={}x{}, 旋转={}, 裁切损失={}像素", + originalImage.getWidth(), originalImage.getHeight(), + targetWidth, targetHeight, + strategy.rotationDegrees, strategy.pixelsLost); + + // 如果需要旋转,先旋转图片 + BufferedImage workingImage = originalImage; + if (strategy.rotationDegrees != 0) { + workingImage = rotateImage(originalImage, strategy.rotationDegrees); + } + + // 执行居中裁切并缩放 + processedImage = cropAndResize(workingImage, targetWidth, targetHeight); + + // 保存到临时文件 + resultFile = File.createTempFile("smartcrop_", ".jpg"); + ImageIO.write(processedImage, "jpg", resultFile); + + log.info("图片处理完成,输出文件: {}", resultFile.getAbsolutePath()); + return resultFile; + + } finally { + if (originalImage != null) { + originalImage.flush(); + } + if (processedImage != null) { + processedImage.flush(); + } + } + } + + /** + * 从URL或File加载图片 + */ + private static BufferedImage loadImage(Object imageSource) throws IOException { + if (imageSource instanceof String) { + String urlStr = (String) imageSource; + if (urlStr.startsWith("http://") || urlStr.startsWith("https://")) { + // 从URL加载 + java.net.URL url = new java.net.URL(urlStr); + return ImageIO.read(url); + } else { + // 作为文件路径处理 + return ImageIO.read(new File(urlStr)); + } + } else if (imageSource instanceof File) { + return ImageIO.read((File) imageSource); + } else { + throw new IllegalArgumentException("图片源必须是String(URL或路径)或File对象,实际类型: " + + (imageSource != null ? imageSource.getClass().getName() : "null")); + } + } + + /** + * 计算最优裁切策略 + */ + private static CropStrategy calculateOptimalCropStrategy( + int srcWidth, int srcHeight, int targetWidth, int targetHeight) { + + CropStrategy best = new CropStrategy(); + best.rotationDegrees = 0; + best.pixelsLost = Integer.MAX_VALUE; + + // 测试三种情况: 不旋转、旋转90度、旋转270度 + int[][] scenarios = { + {0, srcWidth, srcHeight}, // 不旋转 + {90, srcHeight, srcWidth}, // 旋转90度 + {270, srcHeight, srcWidth} // 旋转270度 + }; + + for (int[] scenario : scenarios) { + int rotation = scenario[0]; + int currentWidth = scenario[1]; + int currentHeight = scenario[2]; + + // 计算裁切损失 + double srcRatio = (double) currentWidth / currentHeight; + double targetRatio = (double) targetWidth / targetHeight; + + int pixelsLost; + if (srcRatio > targetRatio) { + // 源图更宽,需要裁掉左右两边 + int neededWidth = (int) Math.ceil(currentHeight * targetRatio); + pixelsLost = (currentWidth - neededWidth) * currentHeight; + } else { + // 源图更高,需要裁掉上下两边 + int neededHeight = (int) Math.ceil(currentWidth / targetRatio); + pixelsLost = (currentHeight - neededHeight) * currentWidth; + } + + if (pixelsLost < best.pixelsLost) { + best.rotationDegrees = rotation; + best.pixelsLost = pixelsLost; + } + } + + return best; + } + + /** + * 旋转图片 + */ + private static BufferedImage rotateImage(BufferedImage source, int degrees) { + if (degrees == 0) { + return source; + } + + int width = source.getWidth(); + int height = source.getHeight(); + + // 90度和270度会交换宽高 + BufferedImage rotated; + Graphics2D g2d = null; + + try { + if (degrees == 90 || degrees == 270) { + rotated = new BufferedImage(height, width, source.getType()); + g2d = rotated.createGraphics(); + + AffineTransform transform = new AffineTransform(); + if (degrees == 90) { + transform.translate(height / 2.0, width / 2.0); + transform.rotate(Math.PI / 2); + transform.translate(-width / 2.0, -height / 2.0); + } else { // 270度 + transform.translate(height / 2.0, width / 2.0); + transform.rotate(-Math.PI / 2); + transform.translate(-width / 2.0, -height / 2.0); + } + + g2d.setTransform(transform); + g2d.drawImage(source, 0, 0, null); + } else { + throw new IllegalArgumentException("仅支持90度和270度旋转"); + } + + return rotated; + } finally { + if (g2d != null) { + g2d.dispose(); + } + } + } + + /** + * 居中裁切并缩放到目标尺寸 + */ + private static BufferedImage cropAndResize(BufferedImage source, int targetWidth, int targetHeight) { + int srcWidth = source.getWidth(); + int srcHeight = source.getHeight(); + + // 计算裁切区域(居中) + double srcRatio = (double) srcWidth / srcHeight; + double targetRatio = (double) targetWidth / targetHeight; + + int cropX, cropY, cropWidth, cropHeight; + + if (srcRatio > targetRatio) { + // 源图更宽,裁掉左右 + cropHeight = srcHeight; + cropWidth = (int) Math.round(srcHeight * targetRatio); + cropX = (srcWidth - cropWidth) / 2; + cropY = 0; + } else { + // 源图更高,裁掉上下 + cropWidth = srcWidth; + cropHeight = (int) Math.round(srcWidth / targetRatio); + cropX = 0; + cropY = (srcHeight - cropHeight) / 2; + } + + // 裁切 + BufferedImage cropped = source.getSubimage(cropX, cropY, cropWidth, cropHeight); + + // 如果裁切后的尺寸与目标尺寸不完全一致,进行缩放 + if (cropWidth != targetWidth || cropHeight != targetHeight) { + BufferedImage resized = new BufferedImage(targetWidth, targetHeight, BufferedImage.TYPE_INT_RGB); + Graphics2D g2d = resized.createGraphics(); + try { + g2d.drawImage(cropped, 0, 0, targetWidth, targetHeight, null); + return resized; + } finally { + g2d.dispose(); + cropped.flush(); + } + } + + return cropped; + } + + /** + * 裁切策略内部类 + */ + private static class CropStrategy { + int rotationDegrees; // 0, 90, 或 270 + int pixelsLost; // 损失的像素数 + } + public static class Base64DecodedMultipartFile implements MultipartFile { private final byte[] imgContent;