feat(image): 实现源图片超分辨率增强流水线

- 引入Pipeline模式重构图片处理流程
- 新增SourcePhotoUpdateStage用于上传并更新源图片URL
- 扩展PhotoProcessContext支持超分场景配置
- 增加SOURCE_PHOTO_SUPER_RESOLUTION枚举值
- 修改各Stage判断逻辑适配新的图片类型系统
- 调整SourceService接口支持File类型参数
- 优化超分处理日志记录和异常处理机制
This commit is contained in:
2025-11-25 19:17:55 +08:00
parent bcebe5defe
commit 7b18d7c2af
17 changed files with 491 additions and 60 deletions

View File

@@ -0,0 +1,45 @@
package com.ycwl.basic.image.pipeline.annotation;
import com.ycwl.basic.image.pipeline.enums.StageOptionalMode;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* Stage配置注解
* 用于声明Stage的元数据和可选性控制信息
*/
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface StageConfig {
/**
* Stage的唯一标识
* 用于外部配置引用该Stage
* 例如: "watermark", "download", "upload"
*/
String stageId();
/**
* 可选性模式
* 默认为UNSUPPORT(不支持外部控制)
*/
StageOptionalMode optionalMode() default StageOptionalMode.UNSUPPORT;
/**
* Stage描述信息
* 用于文档和日志说明
*/
String description() default "";
/**
* 默认是否启用
* 仅当optionalMode=SUPPORT时有效
* 当外部配置未明确指定时,使用此默认值
*/
boolean defaultEnabled() default true;
}

View File

@@ -3,6 +3,7 @@ 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.image.pipeline.enums.ImageSource;
import com.ycwl.basic.image.pipeline.enums.ImageType;
import com.ycwl.basic.image.pipeline.enums.PipelineScene;
import com.ycwl.basic.integration.common.manager.ScenicConfigManager;
import com.ycwl.basic.model.Crop;
@@ -15,6 +16,8 @@ import lombok.Setter;
import java.io.File;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
import java.util.function.Consumer;
/**
* 图片处理管线上下文
@@ -24,10 +27,43 @@ import java.util.Map;
@Setter
public class PhotoProcessContext {
private final PrinterOrderItem orderItem;
private final TempFileManager tempFileManager;
// ==================== 核心字段(构造时必填)====================
/**
* 处理过程唯一标识
* 用于 TempFileManager 创建隔离的临时文件目录
*/
private final String processId;
/**
* 原图 URL
*/
private final String originalUrl;
/**
* 景区 ID
*/
private final Long scenicId;
/**
* 临时文件管理器
*/
private final TempFileManager tempFileManager;
// ==================== 图片元信息 ====================
/**
* 图片类型
*/
private ImageType imageType = ImageType.NORMAL_PHOTO;
/**
* 裁剪/旋转信息
*/
private Crop crop;
// ==================== 管线配置 ====================
/**
* 景区配置管理器,用于获取景区相关配置
*/
@@ -50,6 +86,8 @@ public class PhotoProcessContext {
*/
private Map<String, Boolean> stageEnabledMap = new HashMap<>();
// ==================== 处理过程状态 ====================
private File originalFile;
private File processedFile;
private boolean isLandscape = true;
@@ -62,14 +100,78 @@ public class PhotoProcessContext {
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());
// ==================== 回调 ====================
/**
* 结果 URL 回调
* 用于在 setResultUrl 时通知外部更新相关数据
*/
private Consumer<String> resultUrlCallback;
// ==================== 构造函数(私有)====================
private PhotoProcessContext(Builder builder) {
this.processId = builder.processId;
this.originalUrl = builder.originalUrl;
this.scenicId = builder.scenicId;
this.tempFileManager = new TempFileManager(processId);
this.imageType = builder.imageType;
this.crop = builder.crop;
this.scene = builder.scene;
this.source = builder.source;
this.resultUrlCallback = builder.resultUrlCallback;
}
// ==================== 静态工厂方法 ====================
/**
* 从 PrinterOrderItem 创建 Context(打印场景兼容方法)
*
* @param orderItem 打印订单项
* @param scenicId 景区ID
* @return PhotoProcessContext
*/
public static PhotoProcessContext fromPrinterOrderItem(PrinterOrderItem orderItem, Long scenicId) {
return PhotoProcessContext.builder()
.processId(orderItem.getId().toString())
.originalUrl(orderItem.getCropUrl())
.scenicId(scenicId)
.imageType(ImageType.fromSourceId(orderItem.getSourceId()))
.crop(orderItem.getCrop())
.resultUrlCallback(url -> orderItem.setCropUrl(url))
.build();
}
/**
* 为超分辨率场景创建 Context
*
* @param itemId 项目ID(用于临时文件隔离)
* @param url 原图URL
* @param scenicId 景区ID
* @return PhotoProcessContext
*/
public static PhotoProcessContext forSuperResolution(Long itemId, String url, Long scenicId) {
return PhotoProcessContext.builder()
.processId(itemId.toString())
.originalUrl(url)
.scenicId(scenicId)
.imageType(ImageType.NORMAL_PHOTO)
.source(ImageSource.IPC)
.scene(PipelineScene.SOURCE_PHOTO_SUPER_RESOLUTION)
.build();
}
/**
* 获取 Builder
*/
public static Builder builder() {
return new Builder();
}
// ==================== 业务方法 ====================
/**
* 从景区配置和请求参数中加载Stage开关配置
*
@@ -94,33 +196,14 @@ public class PhotoProcessContext {
return stageEnabledMap.getOrDefault(stageId, defaultEnabled);
}
/**
* 是否为拼图类型
*/
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);
if (resultUrlCallback != null) {
resultUrlCallback.accept(url);
}
}
/**
@@ -145,4 +228,72 @@ public class PhotoProcessContext {
public void cleanup() {
tempFileManager.cleanup();
}
// ==================== Builder ====================
public static class Builder {
private String processId;
private String originalUrl;
private Long scenicId;
private ImageType imageType = ImageType.NORMAL_PHOTO;
private Crop crop;
private PipelineScene scene;
private ImageSource source;
private Consumer<String> resultUrlCallback;
public Builder processId(String processId) {
this.processId = processId;
return this;
}
public Builder originalUrl(String originalUrl) {
this.originalUrl = originalUrl;
return this;
}
public Builder scenicId(Long scenicId) {
this.scenicId = scenicId;
return this;
}
public Builder imageType(ImageType imageType) {
this.imageType = imageType;
return this;
}
public Builder crop(Crop crop) {
this.crop = crop;
return this;
}
public Builder scene(PipelineScene scene) {
this.scene = scene;
return this;
}
public Builder source(ImageSource source) {
this.source = source;
return this;
}
public Builder resultUrlCallback(Consumer<String> callback) {
this.resultUrlCallback = callback;
return this;
}
public PhotoProcessContext build() {
// 参数校验
if (originalUrl == null || originalUrl.isBlank()) {
throw new IllegalArgumentException("originalUrl is required");
}
if (scenicId == null) {
throw new IllegalArgumentException("scenicId is required");
}
// processId 可以自动生成
if (processId == null || processId.isBlank()) {
processId = UUID.randomUUID().toString();
}
return new PhotoProcessContext(this);
}
}
}

View File

@@ -0,0 +1,59 @@
package com.ycwl.basic.image.pipeline.enums;
/**
* 图片类型枚举
* 用于区分管线处理的图片类型,替代通过 sourceId 判断的逻辑
*/
public enum ImageType {
/**
* 普通照片
* 对应原 sourceId > 0 的情况(IPC设备拍摄)
*/
NORMAL_PHOTO("normal", "普通照片"),
/**
* 拼图
* 对应原 sourceId == 0 的情况
*/
PUZZLE("puzzle", "拼图"),
/**
* 手机上传
* 对应原 sourceId == null 的情况
*/
MOBILE_UPLOAD("mobile", "手机上传");
private final String code;
private final String description;
ImageType(String code, String description) {
this.code = code;
this.description = description;
}
public String getCode() {
return code;
}
public String getDescription() {
return description;
}
/**
* 从 sourceId 推断图片类型
* 用于兼容现有数据结构
*
* @param sourceId 源ID(null=手机上传, 0=拼图, >0=普通照片)
* @return 对应的图片类型
*/
public static ImageType fromSourceId(Long sourceId) {
if (sourceId == null) {
return MOBILE_UPLOAD;
} else if (sourceId == 0) {
return PUZZLE;
} else {
return NORMAL_PHOTO;
}
}
}

View File

@@ -16,7 +16,13 @@ public enum PipelineScene {
* 图片增强场景
* 包括图片美化、滤镜处理等
*/
IMAGE_ENHANCE("image_enhance", "图片增强");
IMAGE_ENHANCE("image_enhance", "图片增强"),
/**
* 源图片超分辨率增强场景
* IPC设备拍摄的源图片进行质量提升
*/
SOURCE_PHOTO_SUPER_RESOLUTION("source_photo_sr", "源图片超分辨率增强");
private final String code;
private final String description;

View File

@@ -0,0 +1,35 @@
package com.ycwl.basic.image.pipeline.enums;
import lombok.Getter;
/**
* Stage可选性模式枚举
* 定义Stage是否支持外部配置控制
*/
@Getter
public enum StageOptionalMode {
/**
* 不支持外部控制
* Stage的执行完全由代码中的业务逻辑决定
*/
UNSUPPORT("不支持外部控制"),
/**
* 支持外部控制
* Stage可以通过景区配置或请求参数进行开启/关闭
*/
SUPPORT("支持外部控制"),
/**
* 强制开启
* Stage必须执行,不允许外部配置关闭
*/
FORCE_ON("强制开启");
private final String description;
StageOptionalMode(String description) {
this.description = description;
}
}

View File

@@ -4,6 +4,7 @@ import com.ycwl.basic.image.pipeline.annotation.StageConfig;
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.image.pipeline.enums.ImageType;
import com.ycwl.basic.image.pipeline.enums.StageOptionalMode;
import com.ycwl.basic.utils.ImageUtils;
import lombok.extern.slf4j.Slf4j;
@@ -31,7 +32,7 @@ public class ConditionalRotateStage extends AbstractPipelineStage<PhotoProcessCo
@Override
protected boolean shouldExecuteByBusinessLogic(PhotoProcessContext context) {
return context.isNormalPhoto() && !context.isLandscape();
return context.getImageType() == ImageType.NORMAL_PHOTO && !context.isLandscape();
}
@Override

View File

@@ -5,6 +5,7 @@ import com.ycwl.basic.image.pipeline.annotation.StageConfig;
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.image.pipeline.enums.ImageType;
import com.ycwl.basic.image.pipeline.enums.StageOptionalMode;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
@@ -38,7 +39,7 @@ public class DownloadStage extends AbstractPipelineStage<PhotoProcessContext> {
try {
String fileExtension = determineFileExtension(context);
String filePrefix = context.isPuzzle() ? "puzzle" : "print";
String filePrefix = context.getImageType() == ImageType.PUZZLE ? "puzzle" : "print";
File downloadFile = context.getTempFileManager()
.createTempFile(filePrefix, fileExtension);
@@ -63,7 +64,7 @@ public class DownloadStage extends AbstractPipelineStage<PhotoProcessContext> {
}
private String determineFileExtension(PhotoProcessContext context) {
if (context.isPuzzle()) {
if (context.getImageType() == ImageType.PUZZLE) {
return ".png";
}
String url = context.getOriginalUrl();

View File

@@ -5,6 +5,7 @@ import com.ycwl.basic.image.pipeline.annotation.StageConfig;
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.image.pipeline.enums.ImageType;
import com.ycwl.basic.image.pipeline.enums.StageOptionalMode;
import com.ycwl.basic.utils.ImageUtils;
import lombok.extern.slf4j.Slf4j;
@@ -30,7 +31,7 @@ public class ImageOrientationStage extends AbstractPipelineStage<PhotoProcessCon
@Override
protected boolean shouldExecuteByBusinessLogic(PhotoProcessContext context) {
return context.isNormalPhoto();
return context.getImageType() == ImageType.NORMAL_PHOTO;
}
@Override

View File

@@ -4,6 +4,7 @@ import com.ycwl.basic.image.pipeline.annotation.StageConfig;
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.image.pipeline.enums.ImageType;
import com.ycwl.basic.image.pipeline.enums.StageOptionalMode;
import com.ycwl.basic.utils.ImageUtils;
import lombok.extern.slf4j.Slf4j;
@@ -45,7 +46,7 @@ public class ImageQualityCheckStage extends AbstractPipelineStage<PhotoProcessCo
@Override
protected boolean shouldExecuteByBusinessLogic(PhotoProcessContext context) {
// 仅对普通照片执行质量检测
return context.isNormalPhoto();
return context.getImageType() == ImageType.NORMAL_PHOTO;
}
@Override

View File

@@ -4,6 +4,7 @@ import com.ycwl.basic.image.pipeline.annotation.StageConfig;
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.image.pipeline.enums.ImageType;
import com.ycwl.basic.image.pipeline.enums.StageOptionalMode;
import com.ycwl.basic.utils.ImageUtils;
import lombok.extern.slf4j.Slf4j;
@@ -34,7 +35,7 @@ public class PuzzleBorderStage extends AbstractPipelineStage<PhotoProcessContext
@Override
protected boolean shouldExecuteByBusinessLogic(PhotoProcessContext context) {
return context.isPuzzle();
return context.getImageType() == ImageType.PUZZLE;
}
@Override

View File

@@ -4,6 +4,7 @@ import com.ycwl.basic.image.pipeline.annotation.StageConfig;
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.image.pipeline.enums.ImageType;
import com.ycwl.basic.image.pipeline.enums.StageOptionalMode;
import com.ycwl.basic.utils.ImageUtils;
import lombok.extern.slf4j.Slf4j;
@@ -29,7 +30,7 @@ public class RestoreOrientationStage extends AbstractPipelineStage<PhotoProcessC
@Override
protected boolean shouldExecuteByBusinessLogic(PhotoProcessContext context) {
return context.isNormalPhoto() && context.isNeedRotation();
return context.getImageType() == ImageType.NORMAL_PHOTO && context.isNeedRotation();
}
@Override

View File

@@ -0,0 +1,82 @@
package com.ycwl.basic.image.pipeline.stages;
import com.ycwl.basic.image.pipeline.annotation.StageConfig;
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.image.pipeline.enums.StageOptionalMode;
import com.ycwl.basic.service.pc.SourceService;
import lombok.extern.slf4j.Slf4j;
import java.io.File;
/**
* 源图片上传和更新Stage
* 专门用于将增强后的图片上传并更新数据库中的URL
*
* 与UploadStage的区别:
* - UploadStage: 通用上传,仅上传到存储并设置Context.resultUrl
* - SourcePhotoUpdateStage: 专门用于源图片,上传+更新数据库source表的URL字段
*/
@Slf4j
@StageConfig(
stageId = "source_photo_update",
optionalMode = StageOptionalMode.FORCE_ON,
description = "源图片上传和数据库更新",
defaultEnabled = true
)
public class SourcePhotoUpdateStage extends AbstractPipelineStage<PhotoProcessContext> {
private final SourceService sourceService;
private final Long sourceId;
/**
* 构造函数
*
* @param sourceService 源图片服务
* @param sourceId 源图片ID
*/
public SourcePhotoUpdateStage(SourceService sourceService, Long sourceId) {
this.sourceService = sourceService;
this.sourceId = sourceId;
}
@Override
public String getName() {
return "SourcePhotoUpdateStage";
}
@Override
protected StageResult doExecute(PhotoProcessContext context) {
File fileToUpload = context.getCurrentFile();
if (fileToUpload == null || !fileToUpload.exists()) {
return StageResult.failed("没有可上传的文件");
}
if (sourceService == null) {
return StageResult.failed("SourceService未注入");
}
if (sourceId == null) {
return StageResult.failed("SourceId为空");
}
try {
// 调用SourceService上传并更新URL
String uploadedUrl = sourceService.uploadAndUpdateUrl(sourceId, fileToUpload);
// 设置结果URL到Context
context.setResultUrl(uploadedUrl);
log.info("源图片上传并更新成功: sourceId={}, url={}", sourceId, uploadedUrl);
return StageResult.success("已上传并更新: " + uploadedUrl);
} catch (Exception e) {
log.error("源图片上传并更新失败: sourceId={}", sourceId, e);
return StageResult.failed("上传并更新失败: " + e.getMessage(), e);
}
}
}

View File

@@ -8,6 +8,7 @@ import com.ycwl.basic.image.pipeline.annotation.StageConfig;
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.image.pipeline.enums.ImageType;
import com.ycwl.basic.image.pipeline.enums.StageOptionalMode;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
@@ -37,7 +38,7 @@ public class WatermarkStage extends AbstractPipelineStage<PhotoProcessContext> {
@Override
protected boolean shouldExecuteByBusinessLogic(PhotoProcessContext context) {
return context.isNormalPhoto();
return context.getImageType() == ImageType.NORMAL_PHOTO;
}
@Override