From e418a5ccdb5890896bd5bb8fdc357a6c13d6f783 Mon Sep 17 00:00:00 2001 From: Jerry Yan <792602257@qq.com> Date: Mon, 24 Nov 2025 21:07:52 +0800 Subject: [PATCH] =?UTF-8?q?feat(printer):=20=E5=BC=95=E5=85=A5=E7=85=A7?= =?UTF-8?q?=E7=89=87=E5=A4=84=E7=90=86=E7=AE=A1=E7=BA=BF=E6=9C=BA=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增Crop和PrinterOrderItem模型用于封装裁剪信息和打印订单项 - 实现基于Pipeline模式的照片处理流程,支持普通照片和拼图处理 - 添加多个处理阶段:下载、方向检测、条件旋转、水印、恢复方向、上传和清理 - 创建PipelineBuilder用于动态构建处理管线 - 实现抽象Stage基类和具体Stage实现类 - 添加Stage执行结果管理和异常处理机制 - 优化照片处理逻辑,使用管线替代原有复杂的嵌套处理代码 - 支持通过景区配置管理水印类型、存储适配器等参数 - 提供临时文件管理工具确保处理过程中文件及时清理 - 增强日志记录和错误处理能力,提升系统可维护性 --- .../pipeline/core/AbstractPipelineStage.java | 58 ++++ .../pipeline/core/PhotoProcessContext.java | 97 ++++++ .../basic/image/pipeline/core/Pipeline.java | 96 ++++++ .../image/pipeline/core/PipelineBuilder.java | 78 +++++ .../image/pipeline/core/PipelineStage.java | 40 +++ .../image/pipeline/core/StageResult.java | 75 +++++ .../pipeline/exception/PipelineException.java | 15 + .../exception/StageExecutionException.java | 23 ++ .../image/pipeline/stages/CleanupStage.java | 39 +++ .../stages/ConditionalRotateStage.java | 60 ++++ .../image/pipeline/stages/DownloadStage.java | 67 ++++ .../stages/ImageOrientationStage.java | 72 +++++ .../pipeline/stages/PuzzleBorderStage.java | 60 ++++ .../stages/RestoreOrientationStage.java | 66 ++++ .../image/pipeline/stages/UploadStage.java | 91 ++++++ .../image/pipeline/stages/WatermarkStage.java | 158 ++++++++++ .../image/pipeline/util/TempFileManager.java | 119 +++++++ src/main/java/com/ycwl/basic/model/Crop.java | 11 + .../ycwl/basic/model/PrinterOrderItem.java | 42 +++ .../printer/impl/PrinterServiceImpl.java | 291 ++++++++---------- 20 files changed, 1393 insertions(+), 165 deletions(-) create mode 100644 src/main/java/com/ycwl/basic/image/pipeline/core/AbstractPipelineStage.java create mode 100644 src/main/java/com/ycwl/basic/image/pipeline/core/PhotoProcessContext.java create mode 100644 src/main/java/com/ycwl/basic/image/pipeline/core/Pipeline.java create mode 100644 src/main/java/com/ycwl/basic/image/pipeline/core/PipelineBuilder.java create mode 100644 src/main/java/com/ycwl/basic/image/pipeline/core/PipelineStage.java create mode 100644 src/main/java/com/ycwl/basic/image/pipeline/core/StageResult.java create mode 100644 src/main/java/com/ycwl/basic/image/pipeline/exception/PipelineException.java create mode 100644 src/main/java/com/ycwl/basic/image/pipeline/exception/StageExecutionException.java create mode 100644 src/main/java/com/ycwl/basic/image/pipeline/stages/CleanupStage.java create mode 100644 src/main/java/com/ycwl/basic/image/pipeline/stages/ConditionalRotateStage.java create mode 100644 src/main/java/com/ycwl/basic/image/pipeline/stages/DownloadStage.java create mode 100644 src/main/java/com/ycwl/basic/image/pipeline/stages/ImageOrientationStage.java create mode 100644 src/main/java/com/ycwl/basic/image/pipeline/stages/PuzzleBorderStage.java create mode 100644 src/main/java/com/ycwl/basic/image/pipeline/stages/RestoreOrientationStage.java create mode 100644 src/main/java/com/ycwl/basic/image/pipeline/stages/UploadStage.java create mode 100644 src/main/java/com/ycwl/basic/image/pipeline/stages/WatermarkStage.java create mode 100644 src/main/java/com/ycwl/basic/image/pipeline/util/TempFileManager.java create mode 100644 src/main/java/com/ycwl/basic/model/Crop.java create mode 100644 src/main/java/com/ycwl/basic/model/PrinterOrderItem.java diff --git a/src/main/java/com/ycwl/basic/image/pipeline/core/AbstractPipelineStage.java b/src/main/java/com/ycwl/basic/image/pipeline/core/AbstractPipelineStage.java new file mode 100644 index 00000000..df4aa3cd --- /dev/null +++ b/src/main/java/com/ycwl/basic/image/pipeline/core/AbstractPipelineStage.java @@ -0,0 +1,58 @@ +package com.ycwl.basic.image.pipeline.core; + +import lombok.extern.slf4j.Slf4j; + +/** + * Pipeline Stage抽象基类 + * 提供默认实现和通用逻辑 + */ +@Slf4j +public abstract class AbstractPipelineStage implements PipelineStage { + + /** + * 默认总是执行 + * 子类可以覆盖此方法实现条件性执行 + */ + @Override + public boolean shouldExecute(C context) { + return true; + } + + /** + * 模板方法:执行Stage前的准备工作 + */ + protected void beforeExecute(C context) { + log.debug("[{}] 开始执行", getName()); + } + + /** + * 模板方法:执行Stage后的清理工作 + */ + protected void afterExecute(C context, StageResult result) { + if (result.isSuccess()) { + log.debug("[{}] 执行成功: {}", getName(), result.getMessage()); + } else if (result.isSkipped()) { + log.debug("[{}] 已跳过: {}", getName(), result.getMessage()); + } else if (result.isDegraded()) { + log.warn("[{}] 降级执行: {}", getName(), result.getMessage()); + } else { + log.error("[{}] 执行失败: {}", getName(), result.getMessage(), result.getException()); + } + } + + /** + * 子类实现具体的处理逻辑 + */ + protected abstract StageResult doExecute(C context); + + /** + * 最终执行方法(带钩子) + */ + @Override + public final StageResult execute(C context) { + beforeExecute(context); + StageResult result = doExecute(context); + afterExecute(context, result); + return result; + } +} diff --git a/src/main/java/com/ycwl/basic/image/pipeline/core/PhotoProcessContext.java b/src/main/java/com/ycwl/basic/image/pipeline/core/PhotoProcessContext.java new file mode 100644 index 00000000..60d679ae --- /dev/null +++ b/src/main/java/com/ycwl/basic/image/pipeline/core/PhotoProcessContext.java @@ -0,0 +1,97 @@ +package com.ycwl.basic.image.pipeline.core; + +import com.ycwl.basic.image.watermark.entity.WatermarkInfo; +import com.ycwl.basic.image.watermark.enums.ImageWatermarkOperatorEnum; +import com.ycwl.basic.model.Crop; +import com.ycwl.basic.model.PrinterOrderItem; +import com.ycwl.basic.image.pipeline.util.TempFileManager; +import com.ycwl.basic.storage.adapters.IStorageAdapter; +import lombok.Getter; +import lombok.Setter; + +import java.io.File; + +/** + * 图片处理管线上下文 + * 在各个Stage之间传递状态和数据 + */ +@Getter +@Setter +public class PhotoProcessContext { + + private final PrinterOrderItem orderItem; + private final TempFileManager tempFileManager; + private final Long scenicId; + + private File originalFile; + private File processedFile; + private boolean isLandscape = true; + private boolean needRotation = false; + private String resultUrl; + private IStorageAdapter storageAdapter; + private WatermarkInfo watermarkInfo; + private ImageWatermarkOperatorEnum watermarkType; + private String scenicText; + private String dateFormat; + private File qrcodeFile; + private Integer offsetLeft; + private Crop crop; + + public PhotoProcessContext(PrinterOrderItem orderItem, Long scenicId) { + this.orderItem = orderItem; + this.scenicId = scenicId; + this.tempFileManager = new TempFileManager(orderItem.getId().toString()); + } + + /** + * 是否为拼图类型 + */ + public boolean isPuzzle() { + return orderItem.getSourceId() != null && orderItem.getSourceId() == 0; + } + + /** + * 是否为普通照片 + */ + public boolean isNormalPhoto() { + return orderItem.getSourceId() != null && orderItem.getSourceId() > 0; + } + + /** + * 获取原图URL + */ + public String getOriginalUrl() { + return orderItem.getCropUrl(); + } + + /** + * 设置最终处理结果URL + */ + public void setResultUrl(String url) { + this.resultUrl = url; + this.orderItem.setCropUrl(url); + } + + /** + * 获取当前处理中的文件 + * 如果有processedFile则返回,否则返回originalFile + */ + public File getCurrentFile() { + return processedFile != null ? processedFile : originalFile; + } + + /** + * 更新处理后的文件 + */ + public void updateProcessedFile(File newFile) { + this.processedFile = newFile; + tempFileManager.registerTempFile(newFile); + } + + /** + * 清理所有临时文件 + */ + public void cleanup() { + tempFileManager.cleanup(); + } +} diff --git a/src/main/java/com/ycwl/basic/image/pipeline/core/Pipeline.java b/src/main/java/com/ycwl/basic/image/pipeline/core/Pipeline.java new file mode 100644 index 00000000..14466748 --- /dev/null +++ b/src/main/java/com/ycwl/basic/image/pipeline/core/Pipeline.java @@ -0,0 +1,96 @@ +package com.ycwl.basic.image.pipeline.core; + +import com.ycwl.basic.image.pipeline.exception.PipelineException; +import lombok.extern.slf4j.Slf4j; + +import java.util.ArrayList; +import java.util.List; + +/** + * 图片处理管线 + * 按顺序执行一系列Stage + */ +@Slf4j +public class Pipeline { + + private final List> stages; + private final String name; + + public Pipeline(String name, List> stages) { + this.name = name; + this.stages = new ArrayList<>(stages); + } + + /** + * 执行管线 + * + * @param context 管线上下文 + * @return 执行成功返回true + * @throws PipelineException 管线执行异常 + */ + public boolean execute(C context) { + log.info("[{}] 开始执行管线, Stage数量: {}", name, stages.size()); + long startTime = System.currentTimeMillis(); + + try { + for (int i = 0; i < stages.size(); i++) { + PipelineStage stage = stages.get(i); + String stageName = stage.getName(); + + log.debug("[{}] [{}/{}] 准备执行Stage: {}", name, i + 1, stages.size(), stageName); + + if (!stage.shouldExecute(context)) { + log.debug("[{}] Stage {} 条件不满足,跳过执行", name, stageName); + continue; + } + + long stageStartTime = System.currentTimeMillis(); + StageResult result = stage.execute(context); + long stageDuration = System.currentTimeMillis() - stageStartTime; + + logStageResult(stageName, result, stageDuration); + + if (result.isFailed()) { + log.error("[{}] Stage {} 执行失败,管线终止", name, stageName); + return false; + } + } + + long totalDuration = System.currentTimeMillis() - startTime; + log.info("[{}] 管线执行完成, 耗时: {}ms", name, totalDuration); + return true; + + } catch (Exception e) { + log.error("[{}] 管线执行异常", name, e); + throw new PipelineException("管线执行失败: " + e.getMessage(), e); + } + } + + private void logStageResult(String stageName, StageResult result, long duration) { + String statusIcon = switch (result.getStatus()) { + case SUCCESS -> "✓"; + case SKIPPED -> "○"; + case DEGRADED -> "△"; + case FAILED -> "✗"; + }; + + log.info("[{}] {} Stage {} - {} (耗时: {}ms)", + name, statusIcon, stageName, result.getStatus(), duration); + + if (result.getMessage() != null) { + log.debug("[{}] 详情: {}", name, result.getMessage()); + } + } + + public String getName() { + return name; + } + + public int getStageCount() { + return stages.size(); + } + + public List getStageNames() { + return stages.stream().map(PipelineStage::getName).toList(); + } +} diff --git a/src/main/java/com/ycwl/basic/image/pipeline/core/PipelineBuilder.java b/src/main/java/com/ycwl/basic/image/pipeline/core/PipelineBuilder.java new file mode 100644 index 00000000..ee9ee5c1 --- /dev/null +++ b/src/main/java/com/ycwl/basic/image/pipeline/core/PipelineBuilder.java @@ -0,0 +1,78 @@ +package com.ycwl.basic.image.pipeline.core; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; + +/** + * Pipeline构建器 + * 使用Builder模式动态组装管线 + */ +public class PipelineBuilder { + + private String name = "DefaultPipeline"; + private final List> stages = new ArrayList<>(); + + public PipelineBuilder() { + } + + public PipelineBuilder(String name) { + this.name = name; + } + + /** + * 设置管线名称 + */ + public PipelineBuilder name(String name) { + this.name = name; + return this; + } + + /** + * 添加Stage + */ + public PipelineBuilder addStage(PipelineStage stage) { + if (stage != null) { + this.stages.add(stage); + } + return this; + } + + /** + * 批量添加Stage + */ + public PipelineBuilder addStages(List> stages) { + if (stages != null) { + this.stages.addAll(stages); + } + return this; + } + + /** + * 条件性添加Stage + */ + public PipelineBuilder addStageIf(boolean condition, PipelineStage stage) { + if (condition && stage != null) { + this.stages.add(stage); + } + return this; + } + + /** + * 按优先级排序Stage + */ + public PipelineBuilder sortByPriority() { + this.stages.sort(Comparator.comparingInt(PipelineStage::getPriority)); + return this; + } + + /** + * 构建Pipeline + */ + public Pipeline build() { + if (stages.isEmpty()) { + throw new IllegalStateException("管线至少需要一个Stage"); + } + return new Pipeline<>(name, stages); + } +} diff --git a/src/main/java/com/ycwl/basic/image/pipeline/core/PipelineStage.java b/src/main/java/com/ycwl/basic/image/pipeline/core/PipelineStage.java new file mode 100644 index 00000000..548de51b --- /dev/null +++ b/src/main/java/com/ycwl/basic/image/pipeline/core/PipelineStage.java @@ -0,0 +1,40 @@ +package com.ycwl.basic.image.pipeline.core; + +/** + * 管线处理阶段接口 + * 每个Stage负责一个独立的图片处理步骤 + * + * @param Context类型 + */ +public interface PipelineStage { + + /** + * 获取Stage名称(用于日志和监控) + */ + String getName(); + + /** + * 判断是否需要执行此Stage + * 支持条件性执行(如:只有竖图才需要旋转) + * + * @param context 管线上下文 + * @return true-执行, false-跳过 + */ + boolean shouldExecute(C context); + + /** + * 执行Stage处理逻辑 + * + * @param context 管线上下文 + * @return 执行结果 + */ + StageResult execute(C context); + + /** + * 获取Stage的执行优先级(用于排序) + * 数值越小优先级越高,默认为100 + */ + default int getPriority() { + return 100; + } +} diff --git a/src/main/java/com/ycwl/basic/image/pipeline/core/StageResult.java b/src/main/java/com/ycwl/basic/image/pipeline/core/StageResult.java new file mode 100644 index 00000000..2092f03c --- /dev/null +++ b/src/main/java/com/ycwl/basic/image/pipeline/core/StageResult.java @@ -0,0 +1,75 @@ +package com.ycwl.basic.image.pipeline.core; + +import lombok.Getter; + +/** + * Stage执行结果 + */ +@Getter +public class StageResult { + + public enum Status { + SUCCESS, // 执行成功 + SKIPPED, // 跳过执行 + FAILED, // 执行失败 + DEGRADED // 降级执行 + } + + private final Status status; + private final String message; + private final Throwable exception; + + private StageResult(Status status, String message, Throwable exception) { + this.status = status; + this.message = message; + this.exception = exception; + } + + public static StageResult success() { + return new StageResult(Status.SUCCESS, null, null); + } + + public static StageResult success(String message) { + return new StageResult(Status.SUCCESS, message, null); + } + + public static StageResult skipped() { + return new StageResult(Status.SKIPPED, "条件不满足,跳过执行", null); + } + + public static StageResult skipped(String reason) { + return new StageResult(Status.SKIPPED, reason, null); + } + + public static StageResult failed(String message) { + return new StageResult(Status.FAILED, message, null); + } + + public static StageResult failed(String message, Throwable exception) { + return new StageResult(Status.FAILED, message, exception); + } + + public static StageResult degraded(String message) { + return new StageResult(Status.DEGRADED, message, null); + } + + public boolean isSuccess() { + return status == Status.SUCCESS; + } + + public boolean isSkipped() { + return status == Status.SKIPPED; + } + + public boolean isFailed() { + return status == Status.FAILED; + } + + public boolean isDegraded() { + return status == Status.DEGRADED; + } + + public boolean canContinue() { + return status == Status.SUCCESS || status == Status.SKIPPED || status == Status.DEGRADED; + } +} diff --git a/src/main/java/com/ycwl/basic/image/pipeline/exception/PipelineException.java b/src/main/java/com/ycwl/basic/image/pipeline/exception/PipelineException.java new file mode 100644 index 00000000..9a621331 --- /dev/null +++ b/src/main/java/com/ycwl/basic/image/pipeline/exception/PipelineException.java @@ -0,0 +1,15 @@ +package com.ycwl.basic.image.pipeline.exception; + +/** + * 管线处理异常基类 + */ +public class PipelineException extends RuntimeException { + + public PipelineException(String message) { + super(message); + } + + public PipelineException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/main/java/com/ycwl/basic/image/pipeline/exception/StageExecutionException.java b/src/main/java/com/ycwl/basic/image/pipeline/exception/StageExecutionException.java new file mode 100644 index 00000000..6a7e289c --- /dev/null +++ b/src/main/java/com/ycwl/basic/image/pipeline/exception/StageExecutionException.java @@ -0,0 +1,23 @@ +package com.ycwl.basic.image.pipeline.exception; + +/** + * Stage执行异常 + */ +public class StageExecutionException extends PipelineException { + + private final String stageName; + + public StageExecutionException(String stageName, String message) { + super(String.format("Stage '%s' 执行失败: %s", stageName, message)); + this.stageName = stageName; + } + + public StageExecutionException(String stageName, String message, Throwable cause) { + super(String.format("Stage '%s' 执行失败: %s", stageName, message), cause); + this.stageName = stageName; + } + + public String getStageName() { + return stageName; + } +} diff --git a/src/main/java/com/ycwl/basic/image/pipeline/stages/CleanupStage.java b/src/main/java/com/ycwl/basic/image/pipeline/stages/CleanupStage.java new file mode 100644 index 00000000..c898b8e2 --- /dev/null +++ b/src/main/java/com/ycwl/basic/image/pipeline/stages/CleanupStage.java @@ -0,0 +1,39 @@ +package com.ycwl.basic.image.pipeline.stages; + +import com.ycwl.basic.image.pipeline.core.AbstractPipelineStage; +import com.ycwl.basic.image.pipeline.core.PhotoProcessContext; +import com.ycwl.basic.image.pipeline.core.StageResult; +import lombok.extern.slf4j.Slf4j; + +/** + * 清理临时文件Stage + * 总是在管线最后执行,清理所有临时文件 + */ +@Slf4j +public class CleanupStage extends AbstractPipelineStage { + + @Override + public String getName() { + return "CleanupStage"; + } + + @Override + public int getPriority() { + return 999; + } + + @Override + protected StageResult doExecute(PhotoProcessContext context) { + try { + int fileCount = context.getTempFileManager().getTempFileCount(); + context.cleanup(); + + log.info("临时文件清理完成: 共{}个文件", fileCount); + return StageResult.success(String.format("已清理 %d 个临时文件", fileCount)); + + } catch (Exception e) { + log.warn("临时文件清理失败,但不影响主流程", e); + return StageResult.degraded("清理失败: " + e.getMessage()); + } + } +} diff --git a/src/main/java/com/ycwl/basic/image/pipeline/stages/ConditionalRotateStage.java b/src/main/java/com/ycwl/basic/image/pipeline/stages/ConditionalRotateStage.java new file mode 100644 index 00000000..afbf86e7 --- /dev/null +++ b/src/main/java/com/ycwl/basic/image/pipeline/stages/ConditionalRotateStage.java @@ -0,0 +1,60 @@ +package com.ycwl.basic.image.pipeline.stages; + +import com.ycwl.basic.image.pipeline.core.AbstractPipelineStage; +import com.ycwl.basic.image.pipeline.core.PhotoProcessContext; +import com.ycwl.basic.image.pipeline.core.StageResult; +import com.ycwl.basic.utils.ImageUtils; +import lombok.extern.slf4j.Slf4j; + +import java.io.File; + +/** + * 条件旋转Stage + * 如果是竖图,旋转90度变成横图(便于后续水印处理) + */ +@Slf4j +public class ConditionalRotateStage extends AbstractPipelineStage { + + private static final int OFFSET_LEFT_FOR_PORTRAIT = 40; + + @Override + public String getName() { + return "ConditionalRotateStage"; + } + + @Override + public boolean shouldExecute(PhotoProcessContext context) { + return context.isNormalPhoto() && !context.isLandscape(); + } + + @Override + protected StageResult doExecute(PhotoProcessContext context) { + try { + File currentFile = context.getCurrentFile(); + if (currentFile == null || !currentFile.exists()) { + return StageResult.failed("当前文件不存在"); + } + + File rotatedFile = context.getTempFileManager() + .createTempFile("rotated", ".jpg"); + + log.debug("竖图旋转90度: {} -> {}", currentFile.getName(), rotatedFile.getName()); + ImageUtils.rotateImage90(currentFile, rotatedFile); + + if (!rotatedFile.exists()) { + return StageResult.failed("旋转后的文件未生成"); + } + + context.updateProcessedFile(rotatedFile); + context.setNeedRotation(true); + context.setOffsetLeft(OFFSET_LEFT_FOR_PORTRAIT); + + log.info("竖图已旋转90度, offsetLeft={}", OFFSET_LEFT_FOR_PORTRAIT); + return StageResult.success("已旋转90度"); + + } catch (Exception e) { + log.error("图片旋转失败", e); + return StageResult.failed("旋转失败: " + e.getMessage(), e); + } + } +} diff --git a/src/main/java/com/ycwl/basic/image/pipeline/stages/DownloadStage.java b/src/main/java/com/ycwl/basic/image/pipeline/stages/DownloadStage.java new file mode 100644 index 00000000..d534cad0 --- /dev/null +++ b/src/main/java/com/ycwl/basic/image/pipeline/stages/DownloadStage.java @@ -0,0 +1,67 @@ +package com.ycwl.basic.image.pipeline.stages; + +import cn.hutool.http.HttpUtil; +import com.ycwl.basic.image.pipeline.core.AbstractPipelineStage; +import com.ycwl.basic.image.pipeline.core.PhotoProcessContext; +import com.ycwl.basic.image.pipeline.core.StageResult; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; + +import java.io.File; + +/** + * 下载图片Stage + * 从URL下载原图到本地临时文件 + */ +@Slf4j +public class DownloadStage extends AbstractPipelineStage { + + @Override + public String getName() { + return "DownloadStage"; + } + + @Override + protected StageResult doExecute(PhotoProcessContext context) { + String url = context.getOriginalUrl(); + if (StringUtils.isBlank(url)) { + return StageResult.failed("原图URL为空"); + } + + try { + String fileExtension = determineFileExtension(context); + String filePrefix = context.isPuzzle() ? "puzzle" : "print"; + + File downloadFile = context.getTempFileManager() + .createTempFile(filePrefix, fileExtension); + + log.debug("开始下载图片: {} -> {}", url, downloadFile.getName()); + HttpUtil.downloadFile(url, downloadFile); + + if (!downloadFile.exists() || downloadFile.length() == 0) { + return StageResult.failed("下载的文件不存在或为空"); + } + + context.setOriginalFile(downloadFile); + log.info("图片下载成功: {} ({}KB)", downloadFile.getName(), + downloadFile.length() / 1024); + + return StageResult.success(String.format("已下载 %dKB", downloadFile.length() / 1024)); + + } catch (Exception e) { + log.error("图片下载失败: {}", url, e); + return StageResult.failed("下载失败: " + e.getMessage(), e); + } + } + + private String determineFileExtension(PhotoProcessContext context) { + if (context.isPuzzle()) { + return ".png"; + } + String url = context.getOriginalUrl(); + if (url.toLowerCase().endsWith(".png")) { + return ".png"; + } + return ".jpg"; + } +} diff --git a/src/main/java/com/ycwl/basic/image/pipeline/stages/ImageOrientationStage.java b/src/main/java/com/ycwl/basic/image/pipeline/stages/ImageOrientationStage.java new file mode 100644 index 00000000..346d7545 --- /dev/null +++ b/src/main/java/com/ycwl/basic/image/pipeline/stages/ImageOrientationStage.java @@ -0,0 +1,72 @@ +package com.ycwl.basic.image.pipeline.stages; + +import com.ycwl.basic.model.Crop; +import com.ycwl.basic.image.pipeline.core.AbstractPipelineStage; +import com.ycwl.basic.image.pipeline.core.PhotoProcessContext; +import com.ycwl.basic.image.pipeline.core.StageResult; +import com.ycwl.basic.utils.ImageUtils; +import lombok.extern.slf4j.Slf4j; + +import java.io.File; + +/** + * 图片方向检测Stage + * 检测图片是横图还是竖图,并记录到Context + */ +@Slf4j +public class ImageOrientationStage extends AbstractPipelineStage { + + @Override + public String getName() { + return "ImageOrientationStage"; + } + + @Override + public boolean shouldExecute(PhotoProcessContext context) { + return context.isNormalPhoto(); + } + + @Override + protected StageResult doExecute(PhotoProcessContext context) { + try { + File currentFile = context.getCurrentFile(); + if (currentFile == null || !currentFile.exists()) { + return StageResult.failed("当前文件不存在"); + } + + boolean isLandscape = detectOrientation(currentFile, context.getCrop()); + context.setLandscape(isLandscape); + + String orientation = isLandscape ? "横图" : "竖图"; + log.info("图片方向检测: {}", orientation); + + return StageResult.success(orientation); + + } catch (Exception e) { + log.error("图片方向检测失败", e); + return StageResult.failed("方向检测失败: " + e.getMessage(), e); + } + } + + /** + * 检测图片方向 + * 优先使用Crop的rotation字段,其次使用图片宽高判断 + */ + private boolean detectOrientation(File imageFile, Crop crop) { + if (crop != null && crop.getRotation() != null) { + int rotation = crop.getRotation(); + boolean isLandscape = (rotation == 90 || rotation == 270); + log.debug("从Crop.rotation判断方向: rotation={}, isLandscape={}", rotation, isLandscape); + return isLandscape; + } + + try { + boolean isLandscape = ImageUtils.isLandscape(imageFile); + log.debug("从图片尺寸判断方向: isLandscape={}", isLandscape); + return isLandscape; + } catch (Exception e) { + log.warn("从图片尺寸判断方向失败,默认为横图: {}", e.getMessage()); + return true; + } + } +} diff --git a/src/main/java/com/ycwl/basic/image/pipeline/stages/PuzzleBorderStage.java b/src/main/java/com/ycwl/basic/image/pipeline/stages/PuzzleBorderStage.java new file mode 100644 index 00000000..a5147611 --- /dev/null +++ b/src/main/java/com/ycwl/basic/image/pipeline/stages/PuzzleBorderStage.java @@ -0,0 +1,60 @@ +package com.ycwl.basic.image.pipeline.stages; + +import com.ycwl.basic.image.pipeline.core.AbstractPipelineStage; +import com.ycwl.basic.image.pipeline.core.PhotoProcessContext; +import com.ycwl.basic.image.pipeline.core.StageResult; +import com.ycwl.basic.utils.ImageUtils; +import lombok.extern.slf4j.Slf4j; + +import java.io.File; + +/** + * 拼图边框处理Stage + * 为拼图添加白边框并向上偏移 + */ +@Slf4j +public class PuzzleBorderStage extends AbstractPipelineStage { + + private static final int BORDER_LR = 20; + private static final int BORDER_TB = 30; + private static final int SHIFT_UP = 15; + + @Override + public String getName() { + return "PuzzleBorderStage"; + } + + @Override + public boolean shouldExecute(PhotoProcessContext context) { + return context.isPuzzle(); + } + + @Override + protected StageResult doExecute(PhotoProcessContext context) { + try { + File currentFile = context.getCurrentFile(); + if (currentFile == null || !currentFile.exists()) { + return StageResult.failed("当前文件不存在"); + } + + File processedFile = context.getTempFileManager() + .createTempFile("puzzle_processed", ".png"); + + log.debug("拼图添加边框: lr={}, tb={}, shiftUp={}", BORDER_LR, BORDER_TB, SHIFT_UP); + ImageUtils.addBorderAndShiftUp(currentFile, processedFile, BORDER_LR, BORDER_TB, SHIFT_UP); + + if (!processedFile.exists()) { + return StageResult.failed("处理后的文件未生成"); + } + + context.updateProcessedFile(processedFile); + log.info("拼图边框处理完成: {}KB", processedFile.length() / 1024); + + return StageResult.success(String.format("边框处理完成 (%dKB)", processedFile.length() / 1024)); + + } catch (Exception e) { + log.error("拼图边框处理失败", e); + return StageResult.failed("边框处理失败: " + e.getMessage(), e); + } + } +} diff --git a/src/main/java/com/ycwl/basic/image/pipeline/stages/RestoreOrientationStage.java b/src/main/java/com/ycwl/basic/image/pipeline/stages/RestoreOrientationStage.java new file mode 100644 index 00000000..b61829d0 --- /dev/null +++ b/src/main/java/com/ycwl/basic/image/pipeline/stages/RestoreOrientationStage.java @@ -0,0 +1,66 @@ +package com.ycwl.basic.image.pipeline.stages; + +import com.ycwl.basic.image.pipeline.core.AbstractPipelineStage; +import com.ycwl.basic.image.pipeline.core.PhotoProcessContext; +import com.ycwl.basic.image.pipeline.core.StageResult; +import com.ycwl.basic.utils.ImageUtils; +import lombok.extern.slf4j.Slf4j; + +import java.io.File; + +/** + * 恢复图片方向Stage + * 如果之前旋转过竖图,现在旋转270度恢复为竖图 + */ +@Slf4j +public class RestoreOrientationStage extends AbstractPipelineStage { + + @Override + public String getName() { + return "RestoreOrientationStage"; + } + + @Override + public boolean shouldExecute(PhotoProcessContext context) { + return context.isNormalPhoto() && context.isNeedRotation(); + } + + @Override + protected StageResult doExecute(PhotoProcessContext context) { + try { + File currentFile = context.getCurrentFile(); + if (currentFile == null || !currentFile.exists()) { + return StageResult.failed("当前文件不存在"); + } + + String extension = getFileExtension(currentFile); + File finalFile = context.getTempFileManager() + .createTempFile("final", extension); + + log.debug("恢复竖图方向(旋转270度): {} -> {}", currentFile.getName(), finalFile.getName()); + ImageUtils.rotateImage270(currentFile, finalFile); + + if (!finalFile.exists()) { + return StageResult.failed("旋转后的文件未生成"); + } + + context.updateProcessedFile(finalFile); + log.info("竖图方向已恢复(旋转270度)"); + + return StageResult.success("已恢复竖图方向"); + + } catch (Exception e) { + log.error("图片旋转失败", e); + return StageResult.failed("旋转失败: " + e.getMessage(), e); + } + } + + private String getFileExtension(File file) { + String name = file.getName(); + int lastDot = name.lastIndexOf('.'); + if (lastDot > 0) { + return name.substring(lastDot); + } + return ".jpg"; + } +} diff --git a/src/main/java/com/ycwl/basic/image/pipeline/stages/UploadStage.java b/src/main/java/com/ycwl/basic/image/pipeline/stages/UploadStage.java new file mode 100644 index 00000000..0c6b3970 --- /dev/null +++ b/src/main/java/com/ycwl/basic/image/pipeline/stages/UploadStage.java @@ -0,0 +1,91 @@ +package com.ycwl.basic.image.pipeline.stages; + +import com.ycwl.basic.image.pipeline.core.AbstractPipelineStage; +import com.ycwl.basic.image.pipeline.core.PhotoProcessContext; +import com.ycwl.basic.image.pipeline.core.StageResult; +import com.ycwl.basic.storage.StorageFactory; +import com.ycwl.basic.storage.adapters.IStorageAdapter; +import com.ycwl.basic.storage.enums.StorageAcl; +import lombok.extern.slf4j.Slf4j; + +import java.io.File; + +/** + * 上传图片Stage + * 支持降级: 配置的存储 -> 默认assets-ext存储 + */ +@Slf4j +public class UploadStage extends AbstractPipelineStage { + + private static final String DEFAULT_STORAGE = "assets-ext"; + + @Override + public String getName() { + return "UploadStage"; + } + + @Override + protected StageResult doExecute(PhotoProcessContext context) { + File fileToUpload = context.getCurrentFile(); + if (fileToUpload == null || !fileToUpload.exists()) { + return StageResult.failed("没有可上传的文件"); + } + + IStorageAdapter adapter = context.getStorageAdapter(); + boolean usingDefaultStorage = false; + + if (adapter == null) { + log.debug("未配置存储适配器,使用默认存储: {}", DEFAULT_STORAGE); + try { + adapter = StorageFactory.use(DEFAULT_STORAGE); + usingDefaultStorage = true; + } catch (Exception e) { + return StageResult.failed("无法获取默认存储: " + e.getMessage(), e); + } + } + + try { + String uploadedUrl = uploadFile(adapter, fileToUpload); + context.setResultUrl(uploadedUrl); + + log.info("文件上传成功: {}", uploadedUrl); + + if (usingDefaultStorage) { + return StageResult.degraded("降级: 使用默认存储 " + DEFAULT_STORAGE); + } + + return StageResult.success("已上传"); + + } catch (Exception e) { + log.error("文件上传失败", e); + + if (!usingDefaultStorage) { + log.warn("尝试降级到默认存储"); + try { + IStorageAdapter defaultAdapter = StorageFactory.use(DEFAULT_STORAGE); + String uploadedUrl = uploadFile(defaultAdapter, fileToUpload); + context.setResultUrl(uploadedUrl); + + log.info("降级上传成功: {}", uploadedUrl); + return StageResult.degraded("降级: 使用默认存储 " + DEFAULT_STORAGE); + + } catch (Exception fallbackEx) { + log.error("降级上传也失败", fallbackEx); + return StageResult.failed("上传失败(包括降级): " + fallbackEx.getMessage(), fallbackEx); + } + } + + return StageResult.failed("上传失败: " + e.getMessage(), e); + } + } + + private String uploadFile(IStorageAdapter adapter, File file) throws Exception { + String filename = file.getName(); + String uploadPath = "print/" + filename; + + String url = adapter.uploadFile(uploadPath, file); + adapter.setAcl(StorageAcl.PUBLIC_READ, uploadPath); + + return url; + } +} diff --git a/src/main/java/com/ycwl/basic/image/pipeline/stages/WatermarkStage.java b/src/main/java/com/ycwl/basic/image/pipeline/stages/WatermarkStage.java new file mode 100644 index 00000000..7a1becd6 --- /dev/null +++ b/src/main/java/com/ycwl/basic/image/pipeline/stages/WatermarkStage.java @@ -0,0 +1,158 @@ +package com.ycwl.basic.image.pipeline.stages; + +import com.ycwl.basic.image.watermark.ImageWatermarkFactory; +import com.ycwl.basic.image.watermark.entity.WatermarkInfo; +import com.ycwl.basic.image.watermark.enums.ImageWatermarkOperatorEnum; +import com.ycwl.basic.image.watermark.operator.IOperator; +import com.ycwl.basic.image.pipeline.core.AbstractPipelineStage; +import com.ycwl.basic.image.pipeline.core.PhotoProcessContext; +import com.ycwl.basic.image.pipeline.core.StageResult; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; + +import java.io.File; +import java.util.Arrays; +import java.util.Date; +import java.util.List; + +/** + * 水印处理Stage + * 支持三级降级: 配置的水印类型 -> PRINTER_DEFAULT -> 无水印 + */ +@Slf4j +public class WatermarkStage extends AbstractPipelineStage { + + @Override + public String getName() { + return "WatermarkStage"; + } + + @Override + public boolean shouldExecute(PhotoProcessContext context) { + return context.isNormalPhoto(); + } + + @Override + protected StageResult doExecute(PhotoProcessContext context) { + ImageWatermarkOperatorEnum watermarkType = context.getWatermarkType(); + if (watermarkType == null) { + log.info("未配置水印类型,跳过水印处理"); + return StageResult.skipped("未配置水印"); + } + + List fallbackChain = buildFallbackChain(watermarkType); + log.debug("水印降级链: {}", fallbackChain); + + for (int i = 0; i < fallbackChain.size(); i++) { + ImageWatermarkOperatorEnum type = fallbackChain.get(i); + + if (type == null) { + log.warn("所有水印处理均失败,跳过水印"); + return StageResult.degraded("降级: 跳过水印处理"); + } + + try { + StageResult result = applyWatermark(context, type); + + if (i > 0) { + String degradeMsg = String.format("降级: %s -> %s", + watermarkType.getType(), type.getType()); + log.warn(degradeMsg); + return StageResult.degraded(degradeMsg); + } + + return result; + + } catch (Exception e) { + log.warn("水印类型 {} 处理失败: {}", type.getType(), e.getMessage()); + + if (i == fallbackChain.size() - 2) { + log.warn("所有水印类型均失败,准备跳过水印", e); + } + } + } + + return StageResult.degraded("降级: 跳过水印处理"); + } + + /** + * 构建降级链 + */ + private List buildFallbackChain(ImageWatermarkOperatorEnum primary) { + if (primary == ImageWatermarkOperatorEnum.PRINTER_DEFAULT) { + return Arrays.asList(primary, null); + } + return Arrays.asList( + primary, + ImageWatermarkOperatorEnum.PRINTER_DEFAULT, + null + ); + } + + /** + * 应用水印 + */ + private StageResult applyWatermark(PhotoProcessContext context, ImageWatermarkOperatorEnum type) + throws Exception { + + File currentFile = context.getCurrentFile(); + if (currentFile == null || !currentFile.exists()) { + throw new IllegalStateException("当前文件不存在"); + } + + String fileExt = type.getPreferFileType(); + File watermarkedFile = context.getTempFileManager() + .createTempFile("watermark_" + type.getType(), "." + fileExt); + + WatermarkInfo watermarkInfo = buildWatermarkInfo(context, currentFile, watermarkedFile, type); + + IOperator operator = ImageWatermarkFactory.get(type); + File result = operator.process(watermarkInfo); + + if (result == null || !result.exists()) { + throw new RuntimeException("水印处理后文件不存在"); + } + + context.updateProcessedFile(result); + log.info("水印应用成功: type={}, size={}KB", type.getType(), result.length() / 1024); + + return StageResult.success(String.format("水印: %s (%dKB)", + type.getType(), result.length() / 1024)); + } + + /** + * 构建水印参数 + */ + private WatermarkInfo buildWatermarkInfo(PhotoProcessContext context, File originalFile, + File watermarkedFile, ImageWatermarkOperatorEnum type) { + WatermarkInfo info = new WatermarkInfo(); + info.setOriginalFile(originalFile); + info.setWatermarkedFile(watermarkedFile); + + String scenicText = context.getScenicText(); + if (StringUtils.isNotBlank(scenicText)) { + info.setScenicLine(scenicText); + } + + Date now = new Date(); + String dateFormat = context.getDateFormat(); + if (StringUtils.isBlank(dateFormat)) { + dateFormat = "yyyy.MM.dd"; + } + info.setDatetime(now); + info.setDtFormat(dateFormat); + + File qrcodeFile = context.getQrcodeFile(); + if (qrcodeFile != null && qrcodeFile.exists()) { + info.setQrcodeFile(qrcodeFile); + } + + Integer offsetLeft = context.getOffsetLeft(); + if (offsetLeft != null) { + info.setOffsetLeft(offsetLeft); + } + + context.setWatermarkInfo(info); + return info; + } +} diff --git a/src/main/java/com/ycwl/basic/image/pipeline/util/TempFileManager.java b/src/main/java/com/ycwl/basic/image/pipeline/util/TempFileManager.java new file mode 100644 index 00000000..aa08b957 --- /dev/null +++ b/src/main/java/com/ycwl/basic/image/pipeline/util/TempFileManager.java @@ -0,0 +1,119 @@ +package com.ycwl.basic.image.pipeline.util; + +import lombok.extern.slf4j.Slf4j; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +/** + * 临时文件管理器 + * 统一管理所有临时文件的创建和清理 + */ +@Slf4j +public class TempFileManager { + + private final String processId; + private final Path tempDir; + private final List tempFiles; + + public TempFileManager() { + this.processId = UUID.randomUUID().toString(); + this.tempDir = initTempDirectory(); + this.tempFiles = new ArrayList<>(); + } + + public TempFileManager(String processId) { + this.processId = processId; + this.tempDir = initTempDirectory(); + this.tempFiles = new ArrayList<>(); + } + + private Path initTempDirectory() { + try { + Path systemTempDir = Files.createTempDirectory("photo_process_"); + log.debug("创建临时目录: {}", systemTempDir); + return systemTempDir; + } catch (IOException e) { + log.warn("无法创建系统临时目录,使用当前目录", e); + return Path.of("."); + } + } + + /** + * 创建临时文件 + * + * @param prefix 文件名前缀 + * @param suffix 文件名后缀(如 .jpg, .png) + * @return 临时文件 + */ + public File createTempFile(String prefix, String suffix) { + String filename = String.format("%s_%s%s", prefix, processId, suffix); + File tempFile = tempDir.resolve(filename).toFile(); + tempFiles.add(tempFile); + log.debug("创建临时文件: {}", tempFile.getAbsolutePath()); + return tempFile; + } + + /** + * 注册外部创建的临时文件 + */ + public void registerTempFile(File file) { + if (file != null && !tempFiles.contains(file)) { + tempFiles.add(file); + log.debug("注册临时文件: {}", file.getAbsolutePath()); + } + } + + /** + * 清理所有临时文件 + */ + public void cleanup() { + int deletedCount = 0; + int failedCount = 0; + + for (File file : tempFiles) { + if (file != null && file.exists()) { + boolean deleted = file.delete(); + if (deleted) { + deletedCount++; + log.debug("删除临时文件: {}", file.getAbsolutePath()); + } else { + failedCount++; + log.warn("无法删除临时文件: {}", file.getAbsolutePath()); + } + } + } + + tempFiles.clear(); + + if (deletedCount > 0) { + log.info("清理临时文件: 成功{}个, 失败{}个", deletedCount, failedCount); + } + + cleanupTempDirectory(); + } + + private void cleanupTempDirectory() { + if (tempDir != null && !tempDir.equals(Path.of("."))) { + try { + Files.deleteIfExists(tempDir); + log.debug("删除临时目录: {}", tempDir); + } catch (IOException e) { + log.warn("无法删除临时目录: {}", tempDir, e); + } + } + } + + public String getProcessId() { + return processId; + } + + public int getTempFileCount() { + return tempFiles.size(); + } +} diff --git a/src/main/java/com/ycwl/basic/model/Crop.java b/src/main/java/com/ycwl/basic/model/Crop.java new file mode 100644 index 00000000..6ff89ebf --- /dev/null +++ b/src/main/java/com/ycwl/basic/model/Crop.java @@ -0,0 +1,11 @@ +package com.ycwl.basic.model; + +import lombok.Data; + +/** + * 裁剪信息 + */ +@Data +public class Crop { + private Integer rotation; +} diff --git a/src/main/java/com/ycwl/basic/model/PrinterOrderItem.java b/src/main/java/com/ycwl/basic/model/PrinterOrderItem.java new file mode 100644 index 00000000..5b3abbd8 --- /dev/null +++ b/src/main/java/com/ycwl/basic/model/PrinterOrderItem.java @@ -0,0 +1,42 @@ +package com.ycwl.basic.model; + +import com.ycwl.basic.model.pc.printer.resp.MemberPrintResp; +import com.ycwl.basic.utils.JacksonUtil; +import lombok.Data; + +/** + * 打印订单项(用于管线处理) + */ +@Data +public class PrinterOrderItem { + + private Long id; + private Long sourceId; + private String cropUrl; + private Crop crop; + + /** + * 从MemberPrintResp转换 + */ + public static PrinterOrderItem fromMemberPrintResp(MemberPrintResp resp) { + PrinterOrderItem item = new PrinterOrderItem(); + item.setId(resp.getId() != null ? resp.getId().longValue() : null); + item.setSourceId(resp.getSourceId()); + item.setCropUrl(resp.getCropUrl()); + + if (resp.getCrop() != null) { + try { + Crop crop = new Crop(); + Integer rotation = JacksonUtil.getInt(resp.getCrop(), "rotation"); + if (rotation != null) { + crop.setRotation(rotation); + } + item.setCrop(crop); + } catch (Exception e) { + // 解析失败,crop为null + } + } + + return item; + } +} 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 b6621250..90b5c2e6 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 @@ -1,15 +1,21 @@ package com.ycwl.basic.service.printer.impl; -import cn.hutool.http.HttpUtil; import com.ycwl.basic.biz.OrderBiz; import com.ycwl.basic.constant.NumberConstant; -import com.ycwl.basic.constant.StorageConstant; import com.ycwl.basic.enums.OrderStateEnum; import com.ycwl.basic.exception.BaseException; -import com.ycwl.basic.image.watermark.ImageWatermarkFactory; -import com.ycwl.basic.image.watermark.entity.WatermarkInfo; +import com.ycwl.basic.image.pipeline.core.PhotoProcessContext; +import com.ycwl.basic.image.pipeline.core.Pipeline; +import com.ycwl.basic.image.pipeline.core.PipelineBuilder; +import com.ycwl.basic.image.pipeline.stages.CleanupStage; +import com.ycwl.basic.image.pipeline.stages.ConditionalRotateStage; +import com.ycwl.basic.image.pipeline.stages.DownloadStage; +import com.ycwl.basic.image.pipeline.stages.ImageOrientationStage; +import com.ycwl.basic.image.pipeline.stages.PuzzleBorderStage; +import com.ycwl.basic.image.pipeline.stages.RestoreOrientationStage; +import com.ycwl.basic.image.pipeline.stages.UploadStage; +import com.ycwl.basic.image.pipeline.stages.WatermarkStage; import com.ycwl.basic.image.watermark.enums.ImageWatermarkOperatorEnum; -import com.ycwl.basic.image.watermark.operator.IOperator; import com.ycwl.basic.integration.common.manager.DeviceConfigManager; import com.ycwl.basic.integration.common.manager.ScenicConfigManager; import com.ycwl.basic.mapper.FaceMapper; @@ -19,6 +25,7 @@ import com.ycwl.basic.mapper.OrderMapper; import com.ycwl.basic.mapper.PrintTaskMapper; import com.ycwl.basic.mapper.PrinterMapper; import com.ycwl.basic.mapper.SourceMapper; +import com.ycwl.basic.model.PrinterOrderItem; import com.ycwl.basic.model.mobile.face.FaceRecognizeResp; import com.ycwl.basic.model.mobile.order.PriceObj; import com.ycwl.basic.model.pc.face.entity.FaceEntity; @@ -55,7 +62,6 @@ import com.ycwl.basic.service.pc.FaceService; import com.ycwl.basic.service.printer.PrinterService; import com.ycwl.basic.storage.StorageFactory; import com.ycwl.basic.storage.adapters.IStorageAdapter; -import com.ycwl.basic.storage.enums.StorageAcl; import com.ycwl.basic.utils.ApiResponse; import com.ycwl.basic.utils.ImageUtils; import com.ycwl.basic.utils.JacksonUtil; @@ -731,6 +737,118 @@ public class PrinterServiceImpl implements PrinterService { private static final int TASK_STATUS_PENDING_REVIEW = 4; // 待审核 private final Lock syncTaskLock = new ReentrantLock(); + /** + * 创建普通照片处理管线 + */ + private Pipeline createNormalPhotoPipeline() { + return new PipelineBuilder("NormalPhotoPipeline") + .addStage(new DownloadStage()) + .addStage(new ImageOrientationStage()) + .addStage(new ConditionalRotateStage()) + .addStage(new WatermarkStage()) + .addStage(new RestoreOrientationStage()) + .addStage(new UploadStage()) + .addStage(new CleanupStage()) + .build(); + } + + /** + * 创建拼图处理管线 + */ + private Pipeline createPuzzlePipeline() { + return new PipelineBuilder("PuzzlePipeline") + .addStage(new DownloadStage()) + .addStage(new PuzzleBorderStage()) + .addStage(new UploadStage()) + .addStage(new CleanupStage()) + .build(); + } + + /** + * 使用管线处理照片 + * @param item 打印项 + * @param scenicId 景区ID + * @param qrCodeFile 二维码文件 + * @return 处理后的URL,失败返回原URL + */ + private String processPhotoWithPipeline(MemberPrintResp item, Long scenicId, File qrCodeFile) { + PrinterOrderItem orderItem = PrinterOrderItem.fromMemberPrintResp(item); + + PhotoProcessContext context = new PhotoProcessContext(orderItem, scenicId); + context.setQrcodeFile(qrCodeFile); + + try { + ScenicConfigManager scenicConfig = scenicRepository.getScenicConfigManager(scenicId); + + if (context.isNormalPhoto()) { + prepareNormalPhotoContext(context, scenicConfig); + } else if (context.isPuzzle()) { + prepareStorageAdapter(context, scenicConfig); + } + + Pipeline pipeline = context.isPuzzle() + ? createPuzzlePipeline() + : createNormalPhotoPipeline(); + + boolean success = pipeline.execute(context); + + if (success && context.getResultUrl() != null) { + log.info("照片处理成功: photoId={}, type={}, url={}", + item.getId(), + context.isPuzzle() ? "拼图" : "普通照片", + context.getResultUrl()); + return context.getResultUrl(); + } else { + log.warn("照片处理失败,使用原图: photoId={}", item.getId()); + return item.getCropUrl(); + } + + } catch (Exception e) { + log.error("照片处理异常,使用原图: photoId={}", item.getId(), e); + return item.getCropUrl(); + } finally { + context.cleanup(); + } + } + + /** + * 准备普通照片的Context配置 + */ + private void prepareNormalPhotoContext(PhotoProcessContext context, ScenicConfigManager scenicConfig) { + String printWatermarkType = scenicConfig.getString("print_watermark_type"); + if (StringUtils.isNotBlank(printWatermarkType)) { + ImageWatermarkOperatorEnum watermarkType = ImageWatermarkOperatorEnum.getByCode(printWatermarkType); + context.setWatermarkType(watermarkType); + } + + String scenicText = scenicConfig.getString("print_watermark_scenic_text", ""); + context.setScenicText(scenicText); + + String dateFormat = scenicConfig.getString("print_watermark_dt_format", "yyyy.MM.dd"); + context.setDateFormat(dateFormat); + + prepareStorageAdapter(context, scenicConfig); + } + + /** + * 准备存储适配器 + */ + private void prepareStorageAdapter(PhotoProcessContext context, ScenicConfigManager scenicConfig) { + try { + String storeType = scenicConfig.getString("store_type"); + if (storeType != null) { + IStorageAdapter adapter = StorageFactory.get(storeType); + String storeConfigJson = scenicConfig.getString("store_config_json"); + if (StringUtils.isNotBlank(storeConfigJson)) { + adapter.loadConfig(JacksonUtil.parseObject(storeConfigJson, Map.class)); + } + context.setStorageAdapter(adapter); + } + } catch (Exception e) { + log.warn("准备存储适配器失败,将使用默认存储: {}", e.getMessage()); + } + } + @Override public void setUserIsBuyItem(Long memberId, Long id, Long orderId) { if (redisTemplate.opsForValue().get(USER_PHOTO_LIST_TO_PRINTER + memberId + ":" + orderId) != null) { @@ -753,166 +871,9 @@ public class PrinterServiceImpl implements PrinterService { } userPhotoListByOrderId.forEach(item -> { PrinterEntity printer = printerMapper.getById(item.getPrinterId()); - // 水印处理逻辑(仅当sourceId不为空时执行) - String printUrl = item.getCropUrl(); - if (item.getSourceId() != null && item.getSourceId() > 0) { - try { - ScenicConfigManager scenicConfig = scenicRepository.getScenicConfigManager(item.getScenicId()); - String printWatermarkType = scenicConfig.getString("print_watermark_type"); - if (StringUtils.isNotBlank(printWatermarkType)) { - ImageWatermarkOperatorEnum watermarkType = ImageWatermarkOperatorEnum.getByCode(printWatermarkType); - if (watermarkType != null) { - // 准备存储适配器 - IStorageAdapter adapter; - String storeType = scenicConfig.getString("store_type"); - if (storeType != null) { - adapter = StorageFactory.get(storeType); - String storeConfigJson = scenicConfig.getString("store_config_json"); - if (StringUtils.isNotBlank(storeConfigJson)) { - adapter.loadConfig(JacksonUtil.parseObject(storeConfigJson, Map.class)); - } - } else { - adapter = StorageFactory.use("assets-ext"); - } - - // 准备水印处理器 - IOperator operator = ImageWatermarkFactory.get(watermarkType); - - // 生成唯一的处理标识符,避免多线程环境下的文件冲突 - String processId = item.getId() + "_" + UUID.randomUUID().toString(); - // 下载原图 - File originalFile = new File("print_" + processId + ".jpg"); - File watermarkedFile = new File("print_" + processId + "_" + watermarkType.getType() + "." + watermarkType.getPreferFileType()); - File rotatedOriginalFile = null; - File rotatedWatermarkedFile = null; - boolean needRotation = false; - - try { - HttpUtil.downloadFile(item.getCropUrl(), originalFile); - WatermarkInfo watermarkInfo = new WatermarkInfo(); - - // 判断图片方向并处理旋转 - boolean isLandscape = false; - try { - Integer rotate = JacksonUtil.getInt(item.getCrop(), "rotation"); - if (rotate != null) { - isLandscape = rotate % 180 == 0; - } - } catch (Exception ignored) { - } - - if (!isLandscape) { - // 竖图需要旋转为横图 - needRotation = true; - rotatedOriginalFile = new File("print_" + processId + "_rotated.jpg"); - ImageUtils.rotateImage90(originalFile, rotatedOriginalFile); - log.info("竖图已旋转为横图,照片ID: {}", item.getId()); - watermarkInfo.setOffsetLeft(40); - } - - // 处理水印 - watermarkInfo.setScenicLine(scenicConfig.getString("print_watermark_scenic_text", "")); - watermarkInfo.setOriginalFile(needRotation ? rotatedOriginalFile : originalFile); - watermarkInfo.setWatermarkedFile(watermarkedFile); - watermarkInfo.setQrcodeFile(qrCodeFile); - watermarkInfo.setDatetime(new Date()); - watermarkInfo.setDtFormat(scenicConfig.getString("print_watermark_dt_format", "yyyy.MM.dd")); - - operator.process(watermarkInfo); - - // 如果之前旋转了,需要将水印图片旋转回去 - if (needRotation) { - rotatedWatermarkedFile = new File("print_" + processId + "_final_" + watermarkType.getType() + "." + watermarkType.getPreferFileType()); - ImageUtils.rotateImage270(watermarkedFile, rotatedWatermarkedFile); - log.info("水印图片已旋转回竖图,照片ID: {}", item.getId()); - // 删除中间的横图水印文件 - if (watermarkedFile.exists()) { - watermarkedFile.delete(); - } - // 将最终的竖图水印文件赋值给watermarkedFile - watermarkedFile = rotatedWatermarkedFile; - } - - // 上传水印图片 - String watermarkedUrl = adapter.uploadFile(null, watermarkedFile, StorageConstant.PHOTO_WATERMARKED_PATH, watermarkedFile.getName()); - adapter.setAcl(StorageAcl.PUBLIC_READ, StorageConstant.PHOTO_WATERMARKED_PATH, watermarkedFile.getName()); - - printUrl = watermarkedUrl; - log.info("水印处理成功,打印照片ID: {}, 水印URL: {}", item.getId(), watermarkedUrl); - - } catch (Exception e) { - log.error("水印处理失败,使用原始照片进行打印。照片ID: {}", item.getId(), e); - } finally { - // 清理临时文件 - if (originalFile != null && originalFile.exists()) { - originalFile.delete(); - } - if (rotatedOriginalFile != null && rotatedOriginalFile.exists()) { - rotatedOriginalFile.delete(); - } - if (watermarkedFile != null && watermarkedFile.exists()) { - watermarkedFile.delete(); - } - if (rotatedWatermarkedFile != null && rotatedWatermarkedFile.exists()) { - rotatedWatermarkedFile.delete(); - } - } - } - } - } catch (Exception e) { - log.error("获取景区配置失败,使用原始照片进行打印。景区ID: {}, 照片ID: {}", item.getScenicId(), item.getId(), e); - } - } else if (item.getSourceId() != null && item.getSourceId() == 0) { - // 拼图:添加白边框并向上偏移以避免打印机偏移 - try { - // 生成唯一的处理标识符,避免多线程环境下的文件冲突 - String processId = item.getId() + "_" + UUID.randomUUID().toString(); - File originalFile = new File("puzzle_" + processId + ".png"); - File processedFile = new File("puzzle_" + processId + "_processed.png"); - - try { - // 下载原图 - HttpUtil.downloadFile(item.getCropUrl(), originalFile); - - // 添加白边框(左右20px,上下30px)并向上偏移15px - ImageUtils.addBorderAndShiftUp(originalFile, processedFile, 20, 30, 15); - - // 上传处理后的图片 - IStorageAdapter adapter; - ScenicConfigManager scenicConfig = scenicRepository.getScenicConfigManager(item.getScenicId()); - String storeType = scenicConfig.getString("store_type"); - if (storeType != null) { - adapter = StorageFactory.get(storeType); - String storeConfigJson = scenicConfig.getString("store_config_json"); - if (StringUtils.isNotBlank(storeConfigJson)) { - adapter.loadConfig(JacksonUtil.parseObject(storeConfigJson, Map.class)); - } - } else { - adapter = StorageFactory.use("assets-ext"); - } - - String processedUrl = adapter.uploadFile(null, processedFile, StorageConstant.PHOTO_WATERMARKED_PATH, processedFile.getName()); - adapter.setAcl(StorageAcl.PUBLIC_READ, StorageConstant.PHOTO_WATERMARKED_PATH, processedFile.getName()); - - printUrl = processedUrl; - log.info("拼图照片添加白边框并向上偏移成功,照片ID: {}, 新URL: {}", item.getId(), processedUrl); - - } catch (Exception e) { - log.error("拼图照片处理失败,使用原始照片进行打印。照片ID: {}", item.getId(), e); - } finally { - // 清理临时文件 - if (originalFile != null && originalFile.exists()) { - originalFile.delete(); - } - if (processedFile != null && processedFile.exists()) { - processedFile.delete(); - } - } - } catch (Exception e) { - log.error("拼图照片处理失败,使用原始照片进行打印。照片ID: {}", item.getId(), e); - } - } + // 使用管线处理照片 + String printUrl = processPhotoWithPipeline(item, item.getScenicId(), qrCodeFile); // 根据数量创建多个打印任务 Integer quantity = item.getQuantity();