You've already forked FrameTour-BE
feat(printer): 引入照片处理管线机制
- 新增Crop和PrinterOrderItem模型用于封装裁剪信息和打印订单项 - 实现基于Pipeline模式的照片处理流程,支持普通照片和拼图处理 - 添加多个处理阶段:下载、方向检测、条件旋转、水印、恢复方向、上传和清理 - 创建PipelineBuilder用于动态构建处理管线 - 实现抽象Stage基类和具体Stage实现类 - 添加Stage执行结果管理和异常处理机制 - 优化照片处理逻辑,使用管线替代原有复杂的嵌套处理代码 - 支持通过景区配置管理水印类型、存储适配器等参数 - 提供临时文件管理工具确保处理过程中文件及时清理 - 增强日志记录和错误处理能力,提升系统可维护性
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
package com.ycwl.basic.image.pipeline.core;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
/**
|
||||
* Pipeline Stage抽象基类
|
||||
* 提供默认实现和通用逻辑
|
||||
*/
|
||||
@Slf4j
|
||||
public abstract class AbstractPipelineStage<C extends PhotoProcessContext> implements PipelineStage<C> {
|
||||
|
||||
/**
|
||||
* 默认总是执行
|
||||
* 子类可以覆盖此方法实现条件性执行
|
||||
*/
|
||||
@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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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<C extends PhotoProcessContext> {
|
||||
|
||||
private final List<PipelineStage<C>> stages;
|
||||
private final String name;
|
||||
|
||||
public Pipeline(String name, List<PipelineStage<C>> 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<C> 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<String> getStageNames() {
|
||||
return stages.stream().map(PipelineStage::getName).toList();
|
||||
}
|
||||
}
|
||||
@@ -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<C extends PhotoProcessContext> {
|
||||
|
||||
private String name = "DefaultPipeline";
|
||||
private final List<PipelineStage<C>> stages = new ArrayList<>();
|
||||
|
||||
public PipelineBuilder() {
|
||||
}
|
||||
|
||||
public PipelineBuilder(String name) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置管线名称
|
||||
*/
|
||||
public PipelineBuilder<C> name(String name) {
|
||||
this.name = name;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加Stage
|
||||
*/
|
||||
public PipelineBuilder<C> addStage(PipelineStage<C> stage) {
|
||||
if (stage != null) {
|
||||
this.stages.add(stage);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量添加Stage
|
||||
*/
|
||||
public PipelineBuilder<C> addStages(List<PipelineStage<C>> stages) {
|
||||
if (stages != null) {
|
||||
this.stages.addAll(stages);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 条件性添加Stage
|
||||
*/
|
||||
public PipelineBuilder<C> addStageIf(boolean condition, PipelineStage<C> stage) {
|
||||
if (condition && stage != null) {
|
||||
this.stages.add(stage);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按优先级排序Stage
|
||||
*/
|
||||
public PipelineBuilder<C> sortByPriority() {
|
||||
this.stages.sort(Comparator.comparingInt(PipelineStage::getPriority));
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建Pipeline
|
||||
*/
|
||||
public Pipeline<C> build() {
|
||||
if (stages.isEmpty()) {
|
||||
throw new IllegalStateException("管线至少需要一个Stage");
|
||||
}
|
||||
return new Pipeline<>(name, stages);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.ycwl.basic.image.pipeline.core;
|
||||
|
||||
/**
|
||||
* 管线处理阶段接口
|
||||
* 每个Stage负责一个独立的图片处理步骤
|
||||
*
|
||||
* @param <C> Context类型
|
||||
*/
|
||||
public interface PipelineStage<C extends PhotoProcessContext> {
|
||||
|
||||
/**
|
||||
* 获取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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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<PhotoProcessContext> {
|
||||
|
||||
@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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<PhotoProcessContext> {
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<PhotoProcessContext> {
|
||||
|
||||
@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";
|
||||
}
|
||||
}
|
||||
@@ -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<PhotoProcessContext> {
|
||||
|
||||
@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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<PhotoProcessContext> {
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<PhotoProcessContext> {
|
||||
|
||||
@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";
|
||||
}
|
||||
}
|
||||
@@ -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<PhotoProcessContext> {
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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<PhotoProcessContext> {
|
||||
|
||||
@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<ImageWatermarkOperatorEnum> 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<ImageWatermarkOperatorEnum> 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;
|
||||
}
|
||||
}
|
||||
@@ -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<File> 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();
|
||||
}
|
||||
}
|
||||
11
src/main/java/com/ycwl/basic/model/Crop.java
Normal file
11
src/main/java/com/ycwl/basic/model/Crop.java
Normal file
@@ -0,0 +1,11 @@
|
||||
package com.ycwl.basic.model;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 裁剪信息
|
||||
*/
|
||||
@Data
|
||||
public class Crop {
|
||||
private Integer rotation;
|
||||
}
|
||||
42
src/main/java/com/ycwl/basic/model/PrinterOrderItem.java
Normal file
42
src/main/java/com/ycwl/basic/model/PrinterOrderItem.java
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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<PhotoProcessContext> createNormalPhotoPipeline() {
|
||||
return new PipelineBuilder<PhotoProcessContext>("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<PhotoProcessContext> createPuzzlePipeline() {
|
||||
return new PipelineBuilder<PhotoProcessContext>("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<PhotoProcessContext> 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();
|
||||
|
||||
Reference in New Issue
Block a user