You've already forked FrameTour-BE
fix(core): 修复 StageResult 中 nextStages 的不可变性问题
- 将 nextStages 初始化改为使用 Collections.unmodifiableList 包装 - 防止外部代码修改 nextStages 列表内容 - 保证 StageResult 的不可变性和线程安全性 - 添加完整的单元测试覆盖各种构造场景
This commit is contained in:
595
src/main/java/com/ycwl/basic/image/pipeline/CLAUDE.md
Normal file
595
src/main/java/com/ycwl/basic/image/pipeline/CLAUDE.md
Normal file
@@ -0,0 +1,595 @@
|
||||
# Image Pipeline 图片处理管线
|
||||
|
||||
## 概述
|
||||
|
||||
Image Pipeline 是一个通用的、可扩展的图片处理管线框架,用于组织和执行一系列图片处理操作(Stage)。
|
||||
|
||||
### 核心特性
|
||||
|
||||
- **责任链模式**: 将图片处理流程拆分为独立的 Stage,按顺序执行
|
||||
- **Builder 模式**: 灵活组装管线,支持条件性添加 Stage
|
||||
- **动态 Stage 添加**: 支持在运行时根据条件动态添加后续 Stage
|
||||
- **降级策略**: 支持多级降级执行,确保管线在异常情况下的稳定性
|
||||
- **配置驱动**: 支持通过外部配置控制 Stage 的启用/禁用
|
||||
- **类型安全**: 使用泛型和枚举确保类型安全
|
||||
- **解耦设计**: Context 独立于业务模型,支持多种使用场景
|
||||
|
||||
## 包结构
|
||||
|
||||
```
|
||||
com.ycwl.basic.image.pipeline
|
||||
├── annotation/ # 注解定义
|
||||
│ └── StageConfig # Stage 配置注解
|
||||
├── core/ # 核心类
|
||||
│ ├── AbstractPipelineStage # Stage 抽象基类
|
||||
│ ├── PhotoProcessContext # 管线上下文
|
||||
│ ├── Pipeline # 管线执行器
|
||||
│ ├── PipelineBuilder # 管线构建器
|
||||
│ ├── PipelineStage # Stage 接口
|
||||
│ └── StageResult # Stage 执行结果
|
||||
├── enums/ # 枚举定义
|
||||
│ ├── ImageSource # 图片来源枚举
|
||||
│ ├── ImageType # 图片类型枚举
|
||||
│ ├── PipelineScene # 管线场景枚举
|
||||
│ └── StageOptionalMode # Stage 可选模式枚举
|
||||
├── exception/ # 异常类
|
||||
│ ├── PipelineException # 管线异常
|
||||
│ └── StageExecutionException # Stage 执行异常
|
||||
├── stages/ # 具体 Stage 实现
|
||||
│ ├── CleanupStage # 清理临时文件
|
||||
│ ├── ConditionalRotateStage # 条件性旋转
|
||||
│ ├── DownloadStage # 下载图片
|
||||
│ ├── ImageEnhanceStage # 图像增强(超分)
|
||||
│ ├── ImageOrientationStage # 图像方向检测
|
||||
│ ├── ImageQualityCheckStage # 图像质量检测
|
||||
│ ├── PuzzleBorderStage # 拼图边框处理
|
||||
│ ├── RestoreOrientationStage # 恢复图片方向
|
||||
│ ├── SourcePhotoUpdateStage # 源图片更新
|
||||
│ ├── UploadStage # 上传图片
|
||||
│ └── WatermarkStage # 水印处理
|
||||
└── util/ # 工具类
|
||||
└── TempFileManager # 临时文件管理器
|
||||
```
|
||||
|
||||
## 核心组件
|
||||
|
||||
### 1. Pipeline - 管线执行器
|
||||
|
||||
**职责**: 按顺序执行一系列 Stage,管理执行流程和异常处理。
|
||||
|
||||
**关键特性**:
|
||||
- 顺序执行所有 Stage
|
||||
- 支持动态添加后续 Stage
|
||||
- 循环检测(最大执行 100 个 Stage)
|
||||
- 详细的日志输出(带状态图标)
|
||||
|
||||
**使用示例**:
|
||||
```java
|
||||
Pipeline<PhotoProcessContext> pipeline = new PipelineBuilder<>("MyPipeline")
|
||||
.addStage(new DownloadStage())
|
||||
.addStage(new WatermarkStage())
|
||||
.addStage(new UploadStage())
|
||||
.addStage(new CleanupStage())
|
||||
.build();
|
||||
|
||||
boolean success = pipeline.execute(context);
|
||||
```
|
||||
|
||||
### 2. PhotoProcessContext - 管线上下文
|
||||
|
||||
**职责**: 在各个 Stage 之间传递状态和数据,提供临时文件管理。
|
||||
|
||||
**核心字段**:
|
||||
- `processId`: 处理过程唯一标识,用于隔离临时文件
|
||||
- `originalUrl`: 原图 URL
|
||||
- `scenicId`: 景区 ID
|
||||
- `imageType`: 图片类型(普通照片/拼图/手机上传)
|
||||
- `tempFileManager`: 临时文件管理器
|
||||
|
||||
**静态工厂方法**:
|
||||
```java
|
||||
// 从打印订单创建(打印场景)
|
||||
PhotoProcessContext context = PhotoProcessContext.fromPrinterOrderItem(orderItem, scenicId);
|
||||
|
||||
// 为超分辨率场景创建
|
||||
PhotoProcessContext context = PhotoProcessContext.forSuperResolution(itemId, url, scenicId);
|
||||
|
||||
// 使用 Builder 自定义创建
|
||||
PhotoProcessContext context = PhotoProcessContext.builder()
|
||||
.processId("custom-id")
|
||||
.originalUrl("https://example.com/image.jpg")
|
||||
.scenicId(12345L)
|
||||
.imageType(ImageType.NORMAL_PHOTO)
|
||||
.source(ImageSource.IPC)
|
||||
.scene(PipelineScene.IMAGE_PRINT)
|
||||
.build();
|
||||
```
|
||||
|
||||
**重要方法**:
|
||||
- `getCurrentFile()`: 获取当前处理中的文件
|
||||
- `updateProcessedFile(File)`: 更新处理后的文件
|
||||
- `setResultUrl(String)`: 设置最终结果 URL(会触发回调)
|
||||
- `cleanup()`: 清理所有临时文件
|
||||
- `isStageEnabled(stageId, default)`: 判断 Stage 是否启用
|
||||
|
||||
### 3. AbstractPipelineStage - Stage 抽象基类
|
||||
|
||||
**职责**: 提供 Stage 的通用实现和模板方法。
|
||||
|
||||
**执行流程**:
|
||||
```
|
||||
shouldExecute() → beforeExecute() → doExecute() → afterExecute()
|
||||
```
|
||||
|
||||
**子类需要实现**:
|
||||
- `getName()`: 返回 Stage 名称
|
||||
- `doExecute(context)`: 实现具体处理逻辑
|
||||
- `shouldExecuteByBusinessLogic(context)`: (可选)实现条件判断
|
||||
|
||||
**Stage 执行判断逻辑**:
|
||||
1. 检查 `@StageConfig` 注解
|
||||
2. 根据 `optionalMode` 决定是否检查外部配置
|
||||
- `FORCE_ON`: 强制执行,不检查外部配置
|
||||
- `SUPPORT`: 检查外部配置(`context.isStageEnabled()`)
|
||||
- `UNSUPPORT`: 不检查外部配置
|
||||
3. 执行业务逻辑判断(`shouldExecuteByBusinessLogic()`)
|
||||
|
||||
### 4. StageResult - Stage 执行结果
|
||||
|
||||
**状态类型**:
|
||||
- `SUCCESS`: 执行成功
|
||||
- `SKIPPED`: 跳过执行
|
||||
- `FAILED`: 执行失败(会终止管线)
|
||||
- `DEGRADED`: 降级执行(继续管线但记录警告)
|
||||
|
||||
**静态工厂方法**:
|
||||
```java
|
||||
// 成功
|
||||
StageResult.success();
|
||||
StageResult.success("处理完成");
|
||||
|
||||
// 成功并动态添加后续 Stage
|
||||
StageResult.successWithNext("质量不佳,添加增强", new ImageEnhanceStage());
|
||||
|
||||
// 跳过
|
||||
StageResult.skipped("条件不满足");
|
||||
|
||||
// 失败
|
||||
StageResult.failed("下载失败");
|
||||
StageResult.failed("处理失败", exception);
|
||||
|
||||
// 降级
|
||||
StageResult.degraded("使用备用方案");
|
||||
```
|
||||
|
||||
### 5. @StageConfig - Stage 配置注解
|
||||
|
||||
**字段**:
|
||||
- `stageId`: Stage 唯一标识(用于外部配置控制)
|
||||
- `optionalMode`: 可选模式
|
||||
- `FORCE_ON`: 强制执行(如 DownloadStage、CleanupStage)
|
||||
- `SUPPORT`: 支持外部控制(如 WatermarkStage、ImageEnhanceStage)
|
||||
- `UNSUPPORT`: 不支持外部控制(如 RestoreOrientationStage)
|
||||
- `defaultEnabled`: 默认是否启用
|
||||
- `description`: 描述信息
|
||||
|
||||
**示例**:
|
||||
```java
|
||||
@StageConfig(
|
||||
stageId = "watermark",
|
||||
optionalMode = StageOptionalMode.SUPPORT,
|
||||
description = "水印处理",
|
||||
defaultEnabled = true
|
||||
)
|
||||
public class WatermarkStage extends AbstractPipelineStage<PhotoProcessContext> {
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
## Stage 列表
|
||||
|
||||
### 核心 Stage
|
||||
|
||||
| Stage | 职责 | Optional Mode | 执行条件 |
|
||||
|-------|------|---------------|---------|
|
||||
| DownloadStage | 从 URL 下载图片到本地 | FORCE_ON | 总是执行 |
|
||||
| CleanupStage | 清理所有临时文件 | FORCE_ON | 总是执行(优先级 999) |
|
||||
|
||||
### 图片处理 Stage
|
||||
|
||||
| Stage | 职责 | Optional Mode | 执行条件 |
|
||||
|-------|------|---------------|---------|
|
||||
| ImageOrientationStage | 检测图片方向(横竖) | UNSUPPORT | 仅普通照片 |
|
||||
| ConditionalRotateStage | 条件性旋转(竖图变横图) | UNSUPPORT | 仅竖图 |
|
||||
| RestoreOrientationStage | 恢复图片方向(横图变回竖图) | UNSUPPORT | 需要旋转的照片 |
|
||||
| WatermarkStage | 添加水印 | SUPPORT | 仅普通照片 |
|
||||
| PuzzleBorderStage | 处理拼图边框 | UNSUPPORT | 仅拼图 |
|
||||
| ImageEnhanceStage | 图像增强(超分) | SUPPORT | 可配置 |
|
||||
| ImageQualityCheckStage | 图像质量检测 | SUPPORT | 仅普通照片 |
|
||||
|
||||
### 存储 Stage
|
||||
|
||||
| Stage | 职责 | Optional Mode | 执行条件 |
|
||||
|-------|------|---------------|---------|
|
||||
| UploadStage | 上传图片到存储服务 | FORCE_ON | 总是执行 |
|
||||
| SourcePhotoUpdateStage | 更新源图片记录 | UNSUPPORT | 总是执行 |
|
||||
|
||||
## 典型管线示例
|
||||
|
||||
### 1. 打印照片处理管线
|
||||
|
||||
```java
|
||||
Pipeline<PhotoProcessContext> pipeline = new PipelineBuilder<>("PrintPipeline")
|
||||
.addStage(new DownloadStage()) // 1. 下载
|
||||
.addStage(new ImageOrientationStage()) // 2. 检测方向
|
||||
.addStage(new ConditionalRotateStage()) // 3. 旋转竖图
|
||||
.addStage(new WatermarkStage()) // 4. 添加水印
|
||||
.addStage(new RestoreOrientationStage()) // 5. 恢复方向
|
||||
.addStage(new UploadStage()) // 6. 上传
|
||||
.addStage(new CleanupStage()) // 7. 清理
|
||||
.build();
|
||||
```
|
||||
|
||||
### 2. 拼图处理管线
|
||||
|
||||
```java
|
||||
Pipeline<PhotoProcessContext> pipeline = new PipelineBuilder<>("PuzzlePipeline")
|
||||
.addStage(new DownloadStage()) // 1. 下载
|
||||
.addStage(new PuzzleBorderStage()) // 2. 添加拼图边框
|
||||
.addStage(new UploadStage()) // 3. 上传
|
||||
.addStage(new CleanupStage()) // 4. 清理
|
||||
.build();
|
||||
```
|
||||
|
||||
### 3. 超分辨率增强管线
|
||||
|
||||
```java
|
||||
Pipeline<PhotoProcessContext> pipeline = new PipelineBuilder<>("SuperResolutionPipeline")
|
||||
.addStage(new DownloadStage()) // 1. 下载
|
||||
.addStage(new ImageEnhanceStage(config)) // 2. 超分增强
|
||||
.addStage(new SourcePhotoUpdateStage(sourceService, sourceId)) // 3. 更新记录
|
||||
.addStage(new CleanupStage()) // 4. 清理
|
||||
.build();
|
||||
```
|
||||
|
||||
### 4. 带质量检测的管线(动态 Stage)
|
||||
|
||||
```java
|
||||
Pipeline<PhotoProcessContext> pipeline = new PipelineBuilder<>("QualityCheckPipeline")
|
||||
.addStage(new DownloadStage()) // 1. 下载
|
||||
.addStage(new ImageQualityCheckStage()) // 2. 质量检测(可能动态添加 ImageEnhanceStage)
|
||||
.addStage(new WatermarkStage()) // 3. 水印
|
||||
.addStage(new UploadStage()) // 4. 上传
|
||||
.addStage(new CleanupStage()) // 5. 清理
|
||||
.build();
|
||||
```
|
||||
|
||||
## 扩展指南
|
||||
|
||||
### 如何创建新的 Stage
|
||||
|
||||
1. **继承 AbstractPipelineStage**:
|
||||
```java
|
||||
@Slf4j
|
||||
@StageConfig(
|
||||
stageId = "my_stage",
|
||||
optionalMode = StageOptionalMode.SUPPORT,
|
||||
description = "我的自定义 Stage",
|
||||
defaultEnabled = true
|
||||
)
|
||||
public class MyStage extends AbstractPipelineStage<PhotoProcessContext> {
|
||||
|
||||
@Override
|
||||
public String getName() {
|
||||
return "MyStage";
|
||||
}
|
||||
|
||||
@Override
|
||||
protected boolean shouldExecuteByBusinessLogic(PhotoProcessContext context) {
|
||||
// 实现条件判断
|
||||
return context.getImageType() == ImageType.NORMAL_PHOTO;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected StageResult doExecute(PhotoProcessContext context) {
|
||||
File currentFile = context.getCurrentFile();
|
||||
|
||||
try {
|
||||
// 1. 获取输入文件
|
||||
if (currentFile == null || !currentFile.exists()) {
|
||||
return StageResult.failed("当前文件不存在");
|
||||
}
|
||||
|
||||
// 2. 处理图片
|
||||
File outputFile = context.getTempFileManager()
|
||||
.createTempFile("my_output", ".jpg");
|
||||
|
||||
// 执行具体处理逻辑
|
||||
doSomethingWithImage(currentFile, outputFile);
|
||||
|
||||
// 3. 更新 Context
|
||||
context.updateProcessedFile(outputFile);
|
||||
|
||||
// 4. 返回成功结果
|
||||
return StageResult.success("处理完成");
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("处理失败", e);
|
||||
return StageResult.failed("处理失败: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
private void doSomethingWithImage(File input, File output) {
|
||||
// 具体实现
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
2. **添加到管线**:
|
||||
```java
|
||||
pipeline.addStage(new MyStage());
|
||||
```
|
||||
|
||||
### 如何实现降级策略
|
||||
|
||||
参考 `WatermarkStage` 的实现,使用循环尝试多种方案:
|
||||
|
||||
```java
|
||||
@Override
|
||||
protected StageResult doExecute(PhotoProcessContext context) {
|
||||
List<Strategy> strategies = Arrays.asList(
|
||||
Strategy.ADVANCED,
|
||||
Strategy.STANDARD,
|
||||
Strategy.BASIC
|
||||
);
|
||||
|
||||
for (int i = 0; i < strategies.size(); i++) {
|
||||
Strategy strategy = strategies.get(i);
|
||||
try {
|
||||
StageResult result = tryStrategy(context, strategy);
|
||||
|
||||
if (i > 0) {
|
||||
// 使用了降级策略
|
||||
return StageResult.degraded("降级到: " + strategy);
|
||||
}
|
||||
|
||||
return result;
|
||||
|
||||
} catch (Exception e) {
|
||||
log.warn("策略 {} 失败: {}", strategy, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// 所有策略都失败
|
||||
return StageResult.degraded("所有策略失败,跳过处理");
|
||||
}
|
||||
```
|
||||
|
||||
### 如何动态添加后续 Stage
|
||||
|
||||
参考 `ImageQualityCheckStage` 的实现:
|
||||
|
||||
```java
|
||||
@Override
|
||||
protected StageResult doExecute(PhotoProcessContext context) {
|
||||
boolean needsEnhancement = checkQuality(context.getCurrentFile());
|
||||
|
||||
if (needsEnhancement) {
|
||||
ImageEnhanceStage enhanceStage = new ImageEnhanceStage();
|
||||
return StageResult.successWithNext("质量不佳,添加增强", enhanceStage);
|
||||
}
|
||||
|
||||
return StageResult.success("质量良好");
|
||||
}
|
||||
```
|
||||
|
||||
## 最佳实践
|
||||
|
||||
### 1. Context 管理
|
||||
|
||||
- **总是使用静态工厂方法或 Builder**: 避免直接调用构造函数
|
||||
- **及时清理临时文件**: 在 finally 块或使用 CleanupStage
|
||||
- **使用回调更新外部状态**: 通过 `resultUrlCallback` 而非直接操作业务对象
|
||||
|
||||
### 2. Stage 设计
|
||||
|
||||
- **单一职责**: 每个 Stage 只做一件事
|
||||
- **可组合**: Stage 应该可以灵活组合
|
||||
- **幂等性**: 相同输入应产生相同输出
|
||||
- **异常处理**: 捕获异常并返回 `StageResult.failed()` 或 `StageResult.degraded()`
|
||||
- **日志记录**: 在关键操作处记录 debug/info 日志
|
||||
|
||||
### 3. 管线构建
|
||||
|
||||
- **CleanupStage 总是最后**: 确保临时文件总是被清理
|
||||
- **DownloadStage 总是最前**: 确保有本地文件可用
|
||||
- **合理使用 optionalMode**:
|
||||
- 必需的 Stage 使用 `FORCE_ON`
|
||||
- 可选的 Stage 使用 `SUPPORT`
|
||||
- 内部逻辑控制的 Stage 使用 `UNSUPPORT`
|
||||
|
||||
### 4. 性能优化
|
||||
|
||||
- **复用 TempFileManager**: 自动管理临时文件生命周期
|
||||
- **避免重复下载**: 使用 `context.getCurrentFile()` 获取最新文件
|
||||
- **及时更新 processedFile**: 使用 `context.updateProcessedFile()` 通知下一个 Stage
|
||||
|
||||
### 5. 错误处理
|
||||
|
||||
- **失败即停止**: 使用 `StageResult.failed()` 终止管线
|
||||
- **降级继续执行**: 使用 `StageResult.degraded()` 记录问题但继续执行
|
||||
- **跳过非关键 Stage**: 使用 `StageResult.skipped()` 表示条件不满足
|
||||
- **携带异常信息**: `StageResult.failed(message, exception)` 便于排查问题
|
||||
|
||||
## 配置控制
|
||||
|
||||
### 1. 景区级配置
|
||||
|
||||
通过 `ScenicConfigManager` 加载景区配置:
|
||||
|
||||
```java
|
||||
context.setScenicConfigManager(scenicConfigManager);
|
||||
```
|
||||
|
||||
Stage 内部可以获取配置:
|
||||
|
||||
```java
|
||||
ScenicConfigManager config = context.getScenicConfigManager();
|
||||
Boolean enabled = config.getBoolean("my_feature_enabled");
|
||||
String value = config.getString("my_setting");
|
||||
```
|
||||
|
||||
### 2. 请求级配置
|
||||
|
||||
通过 `loadStageConfig()` 加载请求参数:
|
||||
|
||||
```java
|
||||
Map<String, Boolean> stageConfig = new HashMap<>();
|
||||
stageConfig.put("watermark", false); // 禁用水印
|
||||
stageConfig.put("image_enhance", true); // 启用增强
|
||||
|
||||
context.loadStageConfig(scenicConfigManager, stageConfig);
|
||||
```
|
||||
|
||||
### 3. Stage 启用判断
|
||||
|
||||
在 `AbstractPipelineStage.shouldExecute()` 中自动处理:
|
||||
|
||||
```java
|
||||
// 对于 optionalMode = SUPPORT 的 Stage
|
||||
@StageConfig(
|
||||
stageId = "watermark",
|
||||
optionalMode = StageOptionalMode.SUPPORT,
|
||||
defaultEnabled = true
|
||||
)
|
||||
public class WatermarkStage extends AbstractPipelineStage<PhotoProcessContext> {
|
||||
// 如果外部配置禁用了 watermark,则不执行
|
||||
}
|
||||
```
|
||||
|
||||
## 测试指南
|
||||
|
||||
### 单元测试结构
|
||||
|
||||
```java
|
||||
@Test
|
||||
public void testStageSuccess() {
|
||||
// 1. 准备 Context
|
||||
PhotoProcessContext context = PhotoProcessContext.builder()
|
||||
.processId("test-1")
|
||||
.originalUrl("https://example.com/test.jpg")
|
||||
.scenicId(123L)
|
||||
.build();
|
||||
|
||||
// 2. 创建 Stage
|
||||
MyStage stage = new MyStage();
|
||||
|
||||
// 3. 执行
|
||||
StageResult result = stage.execute(context);
|
||||
|
||||
// 4. 断言
|
||||
assertTrue(result.isSuccess());
|
||||
assertNotNull(context.getCurrentFile());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testStageSkipped() {
|
||||
// 测试条件不满足时跳过
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testStageFailed() {
|
||||
// 测试异常情况
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testStageDegraded() {
|
||||
// 测试降级情况
|
||||
}
|
||||
```
|
||||
|
||||
### 管线集成测试
|
||||
|
||||
```java
|
||||
@Test
|
||||
public void testPipelineExecution() {
|
||||
Pipeline<PhotoProcessContext> pipeline = new PipelineBuilder<>("TestPipeline")
|
||||
.addStage(new DownloadStage())
|
||||
.addStage(new WatermarkStage())
|
||||
.addStage(new UploadStage())
|
||||
.addStage(new CleanupStage())
|
||||
.build();
|
||||
|
||||
PhotoProcessContext context = createTestContext();
|
||||
|
||||
boolean success = pipeline.execute(context);
|
||||
|
||||
assertTrue(success);
|
||||
assertNotNull(context.getResultUrl());
|
||||
}
|
||||
```
|
||||
|
||||
## 常见问题
|
||||
|
||||
### Q: 如何跳过某个 Stage?
|
||||
|
||||
A: 有三种方式:
|
||||
1. 使用外部配置(适用于 `optionalMode = SUPPORT` 的 Stage)
|
||||
2. 在 `shouldExecuteByBusinessLogic()` 中返回 false
|
||||
3. 构建管线时不添加该 Stage
|
||||
|
||||
### Q: 如何在运行时决定是否添加某个 Stage?
|
||||
|
||||
A: 使用 `PipelineBuilder.addStageIf()`:
|
||||
```java
|
||||
builder.addStageIf(needWatermark, new WatermarkStage());
|
||||
```
|
||||
|
||||
或者使用动态 Stage 添加(`StageResult.successWithNext()`)。
|
||||
|
||||
### Q: 如何处理 Stage 执行失败?
|
||||
|
||||
A: 返回 `StageResult.failed()`,管线会立即终止。如果希望继续执行,使用 `StageResult.degraded()`。
|
||||
|
||||
### Q: 临时文件什么时候被清理?
|
||||
|
||||
A: 由 `CleanupStage` 负责,通常放在管线最后。也可以手动调用 `context.cleanup()`。
|
||||
|
||||
### Q: 如何获取最终处理结果?
|
||||
|
||||
A: 使用 `context.getResultUrl()`,或者在构建 Context 时提供 `resultUrlCallback`。
|
||||
|
||||
### Q: 如何支持新的图片来源或场景?
|
||||
|
||||
A: 扩展 `ImageSource` 或 `PipelineScene` 枚举,然后在 Stage 中添加相应的判断逻辑。
|
||||
|
||||
## 架构演进
|
||||
|
||||
### 已实现的特性
|
||||
|
||||
- ✅ 责任链模式的基础管线框架
|
||||
- ✅ Builder 模式的管线构建
|
||||
- ✅ 动态 Stage 添加
|
||||
- ✅ 多级降级策略
|
||||
- ✅ 配置驱动的 Stage 控制
|
||||
- ✅ Context 与业务模型解耦
|
||||
- ✅ 类型安全的图片分类
|
||||
|
||||
### 未来可能的改进
|
||||
|
||||
- 🔄 支持并行执行某些 Stage
|
||||
- 🔄 支持 Stage 执行超时控制
|
||||
- 🔄 支持管线执行的暂停/恢复
|
||||
- 🔄 支持更细粒度的性能监控
|
||||
- 🔄 支持 Stage 执行的重试机制
|
||||
- 🔄 支持管线执行的可视化追踪
|
||||
|
||||
## 相关文档
|
||||
|
||||
- [ImageUtils 工具类](../utils/ImageUtils.java)
|
||||
- [StorageFactory 存储工厂](../storage/StorageFactory.java)
|
||||
- [WatermarkFactory 水印工厂](../image/watermark/ImageWatermarkFactory.java)
|
||||
|
||||
## 维护者
|
||||
|
||||
- 图片处理管线 - 基础架构团队
|
||||
@@ -29,7 +29,9 @@ public class StageResult {
|
||||
this.status = status;
|
||||
this.message = message;
|
||||
this.exception = exception;
|
||||
this.nextStages = nextStages != null ? new ArrayList<>(nextStages) : Collections.emptyList();
|
||||
this.nextStages = nextStages != null
|
||||
? Collections.unmodifiableList(new ArrayList<>(nextStages))
|
||||
: Collections.emptyList();
|
||||
}
|
||||
|
||||
public static StageResult success() {
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
package com.ycwl.basic.image.pipeline.core;
|
||||
|
||||
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 org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.File;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* PhotoProcessContext 单元测试
|
||||
*/
|
||||
class PhotoProcessContextTest {
|
||||
|
||||
@Test
|
||||
void testBuilder_WithRequiredFields() {
|
||||
PhotoProcessContext context = PhotoProcessContext.builder()
|
||||
.originalUrl("https://example.com/test.jpg")
|
||||
.scenicId(123L)
|
||||
.build();
|
||||
|
||||
assertNotNull(context);
|
||||
assertEquals("https://example.com/test.jpg", context.getOriginalUrl());
|
||||
assertEquals(123L, context.getScenicId());
|
||||
assertNotNull(context.getProcessId());
|
||||
assertEquals(ImageType.NORMAL_PHOTO, context.getImageType());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testBuilder_WithAllFields() {
|
||||
PhotoProcessContext context = PhotoProcessContext.builder()
|
||||
.processId("test-123")
|
||||
.originalUrl("https://example.com/test.jpg")
|
||||
.scenicId(456L)
|
||||
.imageType(ImageType.PUZZLE)
|
||||
.source(ImageSource.IPC)
|
||||
.scene(PipelineScene.IMAGE_PRINT)
|
||||
.build();
|
||||
|
||||
assertEquals("test-123", context.getProcessId());
|
||||
assertEquals("https://example.com/test.jpg", context.getOriginalUrl());
|
||||
assertEquals(456L, context.getScenicId());
|
||||
assertEquals(ImageType.PUZZLE, context.getImageType());
|
||||
assertEquals(ImageSource.IPC, context.getSource());
|
||||
assertEquals(PipelineScene.IMAGE_PRINT, context.getScene());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testBuilder_MissingOriginalUrl_ShouldThrow() {
|
||||
PhotoProcessContext.Builder builder = PhotoProcessContext.builder()
|
||||
.scenicId(123L);
|
||||
|
||||
Exception exception = assertThrows(IllegalArgumentException.class, builder::build);
|
||||
|
||||
assertEquals("originalUrl is required", exception.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testBuilder_MissingScenicId_ShouldThrow() {
|
||||
PhotoProcessContext.Builder builder = PhotoProcessContext.builder()
|
||||
.originalUrl("https://example.com/test.jpg");
|
||||
|
||||
Exception exception = assertThrows(IllegalArgumentException.class, builder::build);
|
||||
|
||||
assertEquals("scenicId is required", exception.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testBuilder_AutoGenerateProcessId() {
|
||||
PhotoProcessContext context = PhotoProcessContext.builder()
|
||||
.originalUrl("https://example.com/test.jpg")
|
||||
.scenicId(123L)
|
||||
.build();
|
||||
|
||||
assertNotNull(context.getProcessId());
|
||||
assertFalse(context.getProcessId().isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testForSuperResolution() {
|
||||
PhotoProcessContext context = PhotoProcessContext.forSuperResolution(
|
||||
12345L,
|
||||
"https://example.com/photo.jpg",
|
||||
678L
|
||||
);
|
||||
|
||||
assertEquals("12345", context.getProcessId());
|
||||
assertEquals("https://example.com/photo.jpg", context.getOriginalUrl());
|
||||
assertEquals(678L, context.getScenicId());
|
||||
assertEquals(ImageType.NORMAL_PHOTO, context.getImageType());
|
||||
assertEquals(ImageSource.IPC, context.getSource());
|
||||
assertEquals(PipelineScene.SOURCE_PHOTO_SUPER_RESOLUTION, context.getScene());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testResultUrlCallback() {
|
||||
AtomicReference<String> capturedUrl = new AtomicReference<>();
|
||||
|
||||
PhotoProcessContext context = PhotoProcessContext.builder()
|
||||
.originalUrl("https://example.com/test.jpg")
|
||||
.scenicId(123L)
|
||||
.resultUrlCallback(capturedUrl::set)
|
||||
.build();
|
||||
|
||||
context.setResultUrl("https://cdn.example.com/result.jpg");
|
||||
|
||||
assertEquals("https://cdn.example.com/result.jpg", context.getResultUrl());
|
||||
assertEquals("https://cdn.example.com/result.jpg", capturedUrl.get());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testResultUrlCallback_WithoutCallback() {
|
||||
PhotoProcessContext context = PhotoProcessContext.builder()
|
||||
.originalUrl("https://example.com/test.jpg")
|
||||
.scenicId(123L)
|
||||
.build();
|
||||
|
||||
assertDoesNotThrow(() -> {
|
||||
context.setResultUrl("https://cdn.example.com/result.jpg");
|
||||
});
|
||||
|
||||
assertEquals("https://cdn.example.com/result.jpg", context.getResultUrl());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetCurrentFile_NoProcessedFile() {
|
||||
PhotoProcessContext context = PhotoProcessContext.builder()
|
||||
.originalUrl("https://example.com/test.jpg")
|
||||
.scenicId(123L)
|
||||
.build();
|
||||
|
||||
File originalFile = new File("original.jpg");
|
||||
context.setOriginalFile(originalFile);
|
||||
|
||||
assertEquals(originalFile, context.getCurrentFile());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetCurrentFile_WithProcessedFile() {
|
||||
PhotoProcessContext context = PhotoProcessContext.builder()
|
||||
.originalUrl("https://example.com/test.jpg")
|
||||
.scenicId(123L)
|
||||
.build();
|
||||
|
||||
File originalFile = new File("original.jpg");
|
||||
File processedFile = new File("processed.jpg");
|
||||
|
||||
context.setOriginalFile(originalFile);
|
||||
context.setProcessedFile(processedFile);
|
||||
|
||||
assertEquals(processedFile, context.getCurrentFile());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testLoadStageConfig() {
|
||||
PhotoProcessContext context = PhotoProcessContext.builder()
|
||||
.originalUrl("https://example.com/test.jpg")
|
||||
.scenicId(123L)
|
||||
.build();
|
||||
|
||||
Map<String, Boolean> stageConfig = new HashMap<>();
|
||||
stageConfig.put("watermark", false);
|
||||
stageConfig.put("enhance", true);
|
||||
|
||||
context.loadStageConfig(null, stageConfig);
|
||||
|
||||
assertFalse(context.isStageEnabled("watermark", true));
|
||||
assertTrue(context.isStageEnabled("enhance", false));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testIsStageEnabled_WithDefault() {
|
||||
PhotoProcessContext context = PhotoProcessContext.builder()
|
||||
.originalUrl("https://example.com/test.jpg")
|
||||
.scenicId(123L)
|
||||
.build();
|
||||
|
||||
assertTrue(context.isStageEnabled("unknown_stage", true));
|
||||
assertFalse(context.isStageEnabled("unknown_stage", false));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testIsStageEnabled_WithConfig() {
|
||||
PhotoProcessContext context = PhotoProcessContext.builder()
|
||||
.originalUrl("https://example.com/test.jpg")
|
||||
.scenicId(123L)
|
||||
.build();
|
||||
|
||||
Map<String, Boolean> config = new HashMap<>();
|
||||
config.put("watermark", false);
|
||||
context.loadStageConfig(null, config);
|
||||
|
||||
assertFalse(context.isStageEnabled("watermark", true));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testTempFileManager() {
|
||||
PhotoProcessContext context = PhotoProcessContext.builder()
|
||||
.processId("test-temp")
|
||||
.originalUrl("https://example.com/test.jpg")
|
||||
.scenicId(123L)
|
||||
.build();
|
||||
|
||||
assertNotNull(context.getTempFileManager());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testDefaultImageType() {
|
||||
PhotoProcessContext context = PhotoProcessContext.builder()
|
||||
.originalUrl("https://example.com/test.jpg")
|
||||
.scenicId(123L)
|
||||
.build();
|
||||
|
||||
assertEquals(ImageType.NORMAL_PHOTO, context.getImageType());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSetImageType() {
|
||||
PhotoProcessContext context = PhotoProcessContext.builder()
|
||||
.originalUrl("https://example.com/test.jpg")
|
||||
.scenicId(123L)
|
||||
.imageType(ImageType.PUZZLE)
|
||||
.build();
|
||||
|
||||
assertEquals(ImageType.PUZZLE, context.getImageType());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package com.ycwl.basic.image.pipeline.core;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* PipelineBuilder 单元测试
|
||||
*/
|
||||
class PipelineBuilderTest {
|
||||
|
||||
@Test
|
||||
void testBuildEmptyPipeline_ShouldThrowException() {
|
||||
PipelineBuilder<PhotoProcessContext> builder = new PipelineBuilder<>();
|
||||
|
||||
Exception exception = assertThrows(IllegalStateException.class, builder::build);
|
||||
|
||||
assertEquals("管线至少需要一个Stage", exception.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testBuildWithDefaultName() {
|
||||
PipelineBuilder<PhotoProcessContext> builder = new PipelineBuilder<>();
|
||||
builder.addStage(new MockStage("stage1"));
|
||||
|
||||
Pipeline<PhotoProcessContext> pipeline = builder.build();
|
||||
|
||||
assertEquals("DefaultPipeline", pipeline.getName());
|
||||
assertEquals(1, pipeline.getStageCount());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testBuildWithCustomName() {
|
||||
Pipeline<PhotoProcessContext> pipeline = new PipelineBuilder<PhotoProcessContext>("CustomPipeline")
|
||||
.addStage(new MockStage("stage1"))
|
||||
.build();
|
||||
|
||||
assertEquals("CustomPipeline", pipeline.getName());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testAddStage() {
|
||||
PipelineBuilder<PhotoProcessContext> builder = new PipelineBuilder<>();
|
||||
builder.addStage(new MockStage("stage1"));
|
||||
builder.addStage(new MockStage("stage2"));
|
||||
|
||||
Pipeline<PhotoProcessContext> pipeline = builder.build();
|
||||
|
||||
assertEquals(2, pipeline.getStageCount());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testAddStageWithNull_ShouldIgnore() {
|
||||
PipelineBuilder<PhotoProcessContext> builder = new PipelineBuilder<>();
|
||||
builder.addStage(new MockStage("stage1"));
|
||||
builder.addStage(null);
|
||||
builder.addStage(new MockStage("stage2"));
|
||||
|
||||
Pipeline<PhotoProcessContext> pipeline = builder.build();
|
||||
|
||||
assertEquals(2, pipeline.getStageCount());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testAddStageIf_WhenTrue() {
|
||||
PipelineBuilder<PhotoProcessContext> builder = new PipelineBuilder<>();
|
||||
builder.addStageIf(true, new MockStage("stage1"));
|
||||
|
||||
Pipeline<PhotoProcessContext> pipeline = builder.build();
|
||||
|
||||
assertEquals(1, pipeline.getStageCount());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testAddStageIf_WhenFalse() {
|
||||
PipelineBuilder<PhotoProcessContext> builder = new PipelineBuilder<>();
|
||||
builder.addStage(new MockStage("required"));
|
||||
builder.addStageIf(false, new MockStage("optional"));
|
||||
|
||||
Pipeline<PhotoProcessContext> pipeline = builder.build();
|
||||
|
||||
assertEquals(1, pipeline.getStageCount());
|
||||
assertTrue(pipeline.getStageNames().contains("MockStage-required"));
|
||||
assertFalse(pipeline.getStageNames().contains("MockStage-optional"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testFluentAPI() {
|
||||
Pipeline<PhotoProcessContext> pipeline = new PipelineBuilder<PhotoProcessContext>()
|
||||
.name("FluentPipeline")
|
||||
.addStage(new MockStage("stage1"))
|
||||
.addStage(new MockStage("stage2"))
|
||||
.addStageIf(true, new MockStage("stage3"))
|
||||
.build();
|
||||
|
||||
assertEquals("FluentPipeline", pipeline.getName());
|
||||
assertEquals(3, pipeline.getStageCount());
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock Stage for testing
|
||||
*/
|
||||
private static class MockStage extends AbstractPipelineStage<PhotoProcessContext> {
|
||||
private final String id;
|
||||
|
||||
MockStage(String id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getName() {
|
||||
return "MockStage-" + id;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected StageResult doExecute(PhotoProcessContext context) {
|
||||
return StageResult.success();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
package com.ycwl.basic.image.pipeline.core;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* Pipeline 单元测试
|
||||
*/
|
||||
class PipelineTest {
|
||||
|
||||
@Test
|
||||
void testPipelineExecution_AllSuccess() {
|
||||
Pipeline<PhotoProcessContext> pipeline = new PipelineBuilder<PhotoProcessContext>("TestPipeline")
|
||||
.addStage(new MockStage("stage1", StageResult.success("Stage1完成")))
|
||||
.addStage(new MockStage("stage2", StageResult.success("Stage2完成")))
|
||||
.addStage(new MockStage("stage3", StageResult.success("Stage3完成")))
|
||||
.build();
|
||||
|
||||
PhotoProcessContext context = createTestContext();
|
||||
|
||||
boolean success = pipeline.execute(context);
|
||||
|
||||
assertTrue(success);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testPipelineExecution_WithSkippedStage() {
|
||||
Pipeline<PhotoProcessContext> pipeline = new PipelineBuilder<PhotoProcessContext>("TestPipeline")
|
||||
.addStage(new MockStage("stage1", StageResult.success()))
|
||||
.addStage(new ConditionalStage("stage2", false))
|
||||
.addStage(new MockStage("stage3", StageResult.success()))
|
||||
.build();
|
||||
|
||||
PhotoProcessContext context = createTestContext();
|
||||
|
||||
boolean success = pipeline.execute(context);
|
||||
|
||||
assertTrue(success);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testPipelineExecution_WithDegradedStage() {
|
||||
Pipeline<PhotoProcessContext> pipeline = new PipelineBuilder<PhotoProcessContext>("TestPipeline")
|
||||
.addStage(new MockStage("stage1", StageResult.success()))
|
||||
.addStage(new MockStage("stage2", StageResult.degraded("降级执行")))
|
||||
.addStage(new MockStage("stage3", StageResult.success()))
|
||||
.build();
|
||||
|
||||
PhotoProcessContext context = createTestContext();
|
||||
|
||||
boolean success = pipeline.execute(context);
|
||||
|
||||
assertTrue(success);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testPipelineExecution_WithFailedStage() {
|
||||
Pipeline<PhotoProcessContext> pipeline = new PipelineBuilder<PhotoProcessContext>("TestPipeline")
|
||||
.addStage(new MockStage("stage1", StageResult.success()))
|
||||
.addStage(new MockStage("stage2", StageResult.failed("执行失败")))
|
||||
.addStage(new MockStage("stage3", StageResult.success()))
|
||||
.build();
|
||||
|
||||
PhotoProcessContext context = createTestContext();
|
||||
|
||||
boolean success = pipeline.execute(context);
|
||||
|
||||
assertFalse(success);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testPipelineExecution_WithDynamicStageAddition() {
|
||||
MockStage dynamicStage = new MockStage("dynamic", StageResult.success("动态Stage完成"));
|
||||
|
||||
Pipeline<PhotoProcessContext> pipeline = new PipelineBuilder<PhotoProcessContext>("TestPipeline")
|
||||
.addStage(new MockStage("stage1", StageResult.success()))
|
||||
.addStage(new MockStage("stage2", StageResult.successWithNext("添加动态Stage", dynamicStage)))
|
||||
.addStage(new MockStage("stage3", StageResult.success()))
|
||||
.build();
|
||||
|
||||
PhotoProcessContext context = createTestContext();
|
||||
|
||||
boolean success = pipeline.execute(context);
|
||||
|
||||
assertTrue(success);
|
||||
assertEquals(4, pipeline.getStageCount()); // 原3个 + 动态添加1个
|
||||
}
|
||||
|
||||
@Test
|
||||
void testPipelineName() {
|
||||
Pipeline<PhotoProcessContext> pipeline = new PipelineBuilder<PhotoProcessContext>("CustomPipeline")
|
||||
.addStage(new MockStage("stage1", StageResult.success()))
|
||||
.build();
|
||||
|
||||
assertEquals("CustomPipeline", pipeline.getName());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetStageCount() {
|
||||
Pipeline<PhotoProcessContext> pipeline = new PipelineBuilder<PhotoProcessContext>()
|
||||
.addStage(new MockStage("stage1", StageResult.success()))
|
||||
.addStage(new MockStage("stage2", StageResult.success()))
|
||||
.addStage(new MockStage("stage3", StageResult.success()))
|
||||
.build();
|
||||
|
||||
assertEquals(3, pipeline.getStageCount());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetStageNames() {
|
||||
Pipeline<PhotoProcessContext> pipeline = new PipelineBuilder<PhotoProcessContext>()
|
||||
.addStage(new MockStage("stage1", StageResult.success()))
|
||||
.addStage(new MockStage("stage2", StageResult.success()))
|
||||
.build();
|
||||
|
||||
var names = pipeline.getStageNames();
|
||||
|
||||
assertEquals(2, names.size());
|
||||
assertTrue(names.contains("MockStage-stage1"));
|
||||
assertTrue(names.contains("MockStage-stage2"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建测试用的 Context
|
||||
*/
|
||||
private PhotoProcessContext createTestContext() {
|
||||
return PhotoProcessContext.builder()
|
||||
.processId("test-pipeline")
|
||||
.originalUrl("https://example.com/test.jpg")
|
||||
.scenicId(123L)
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock Stage for testing
|
||||
*/
|
||||
private static class MockStage extends AbstractPipelineStage<PhotoProcessContext> {
|
||||
private final String id;
|
||||
private final StageResult result;
|
||||
|
||||
MockStage(String id, StageResult result) {
|
||||
this.id = id;
|
||||
this.result = result;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getName() {
|
||||
return "MockStage-" + id;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected StageResult doExecute(PhotoProcessContext context) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Conditional Stage for testing
|
||||
*/
|
||||
private static class ConditionalStage extends AbstractPipelineStage<PhotoProcessContext> {
|
||||
private final String id;
|
||||
private final boolean shouldExecute;
|
||||
|
||||
ConditionalStage(String id, boolean shouldExecute) {
|
||||
this.id = id;
|
||||
this.shouldExecute = shouldExecute;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getName() {
|
||||
return "ConditionalStage-" + id;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected boolean shouldExecuteByBusinessLogic(PhotoProcessContext context) {
|
||||
return shouldExecute;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected StageResult doExecute(PhotoProcessContext context) {
|
||||
return StageResult.success();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
package com.ycwl.basic.image.pipeline.core;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* StageResult 单元测试
|
||||
*/
|
||||
class StageResultTest {
|
||||
|
||||
@Test
|
||||
void testSuccess() {
|
||||
StageResult result = StageResult.success();
|
||||
|
||||
assertEquals(StageResult.Status.SUCCESS, result.getStatus());
|
||||
assertTrue(result.isSuccess());
|
||||
assertFalse(result.isFailed());
|
||||
assertFalse(result.isSkipped());
|
||||
assertFalse(result.isDegraded());
|
||||
assertTrue(result.canContinue());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSuccessWithMessage() {
|
||||
StageResult result = StageResult.success("处理完成");
|
||||
|
||||
assertTrue(result.isSuccess());
|
||||
assertEquals("处理完成", result.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSuccessWithNextStages() {
|
||||
MockStage nextStage1 = new MockStage("next1");
|
||||
MockStage nextStage2 = new MockStage("next2");
|
||||
|
||||
StageResult result = StageResult.successWithNext("添加后续Stage", nextStage1, nextStage2);
|
||||
|
||||
assertTrue(result.isSuccess());
|
||||
assertEquals("添加后续Stage", result.getMessage());
|
||||
assertEquals(2, result.getNextStages().size());
|
||||
assertEquals("MockStage-next1", result.getNextStages().get(0).getName());
|
||||
assertEquals("MockStage-next2", result.getNextStages().get(1).getName());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSuccessWithNextStagesList() {
|
||||
List<PipelineStage<?>> stages = List.of(
|
||||
new MockStage("next1"),
|
||||
new MockStage("next2")
|
||||
);
|
||||
|
||||
StageResult result = StageResult.successWithNext("添加列表", stages);
|
||||
|
||||
assertEquals(2, result.getNextStages().size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSkipped() {
|
||||
StageResult result = StageResult.skipped();
|
||||
|
||||
assertEquals(StageResult.Status.SKIPPED, result.getStatus());
|
||||
assertTrue(result.isSkipped());
|
||||
assertTrue(result.canContinue());
|
||||
assertEquals("条件不满足,跳过执行", result.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSkippedWithReason() {
|
||||
StageResult result = StageResult.skipped("图片类型不匹配");
|
||||
|
||||
assertTrue(result.isSkipped());
|
||||
assertEquals("图片类型不匹配", result.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testFailed() {
|
||||
StageResult result = StageResult.failed("下载失败");
|
||||
|
||||
assertEquals(StageResult.Status.FAILED, result.getStatus());
|
||||
assertTrue(result.isFailed());
|
||||
assertFalse(result.canContinue());
|
||||
assertEquals("下载失败", result.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testFailedWithException() {
|
||||
Exception exception = new RuntimeException("网络错误");
|
||||
StageResult result = StageResult.failed("下载失败", exception);
|
||||
|
||||
assertTrue(result.isFailed());
|
||||
assertEquals("下载失败", result.getMessage());
|
||||
assertEquals(exception, result.getException());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testDegraded() {
|
||||
StageResult result = StageResult.degraded("使用备用方案");
|
||||
|
||||
assertEquals(StageResult.Status.DEGRADED, result.getStatus());
|
||||
assertTrue(result.isDegraded());
|
||||
assertTrue(result.canContinue());
|
||||
assertEquals("使用备用方案", result.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCanContinue_Success() {
|
||||
assertTrue(StageResult.success().canContinue());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCanContinue_Skipped() {
|
||||
assertTrue(StageResult.skipped().canContinue());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCanContinue_Degraded() {
|
||||
assertTrue(StageResult.degraded("降级").canContinue());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCanContinue_Failed() {
|
||||
assertFalse(StageResult.failed("失败").canContinue());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testNextStagesImmutable() {
|
||||
MockStage stage1 = new MockStage("stage1");
|
||||
StageResult result = StageResult.successWithNext("test", stage1);
|
||||
|
||||
List<PipelineStage<?>> nextStages = result.getNextStages();
|
||||
|
||||
assertThrows(UnsupportedOperationException.class, () -> {
|
||||
nextStages.add(new MockStage("stage2"));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock Stage for testing
|
||||
*/
|
||||
private static class MockStage extends AbstractPipelineStage<PhotoProcessContext> {
|
||||
private final String id;
|
||||
|
||||
MockStage(String id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getName() {
|
||||
return "MockStage-" + id;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected StageResult doExecute(PhotoProcessContext context) {
|
||||
return StageResult.success();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package com.ycwl.basic.image.pipeline.stages;
|
||||
|
||||
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 org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* CleanupStage 单元测试
|
||||
*/
|
||||
class CleanupStageTest {
|
||||
|
||||
@Test
|
||||
void testGetName() {
|
||||
CleanupStage stage = new CleanupStage();
|
||||
|
||||
assertEquals("CleanupStage", stage.getName());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetPriority() {
|
||||
CleanupStage stage = new CleanupStage();
|
||||
|
||||
assertEquals(999, stage.getPriority());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testShouldExecute_AlwaysTrue() {
|
||||
CleanupStage stage = new CleanupStage();
|
||||
|
||||
PhotoProcessContext context = PhotoProcessContext.builder()
|
||||
.originalUrl("https://example.com/test.jpg")
|
||||
.scenicId(123L)
|
||||
.build();
|
||||
|
||||
assertTrue(stage.shouldExecute(context));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testExecute_Success() {
|
||||
CleanupStage stage = new CleanupStage();
|
||||
|
||||
PhotoProcessContext context = PhotoProcessContext.builder()
|
||||
.processId("test-cleanup")
|
||||
.originalUrl("https://example.com/test.jpg")
|
||||
.scenicId(123L)
|
||||
.build();
|
||||
|
||||
StageResult result = stage.execute(context);
|
||||
|
||||
assertTrue(result.isSuccess());
|
||||
assertNotNull(result.getMessage());
|
||||
assertTrue(result.getMessage().contains("已清理"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testExecute_ForAllImageTypes() {
|
||||
CleanupStage stage = new CleanupStage();
|
||||
|
||||
// 测试普通照片
|
||||
PhotoProcessContext normalContext = PhotoProcessContext.builder()
|
||||
.originalUrl("https://example.com/test.jpg")
|
||||
.scenicId(123L)
|
||||
.imageType(ImageType.NORMAL_PHOTO)
|
||||
.build();
|
||||
assertTrue(stage.execute(normalContext).isSuccess());
|
||||
|
||||
// 测试拼图
|
||||
PhotoProcessContext puzzleContext = PhotoProcessContext.builder()
|
||||
.originalUrl("https://example.com/puzzle.png")
|
||||
.scenicId(123L)
|
||||
.imageType(ImageType.PUZZLE)
|
||||
.build();
|
||||
assertTrue(stage.execute(puzzleContext).isSuccess());
|
||||
|
||||
// 测试手机上传
|
||||
PhotoProcessContext mobileContext = PhotoProcessContext.builder()
|
||||
.originalUrl("https://example.com/mobile.jpg")
|
||||
.scenicId(123L)
|
||||
.imageType(ImageType.MOBILE_UPLOAD)
|
||||
.build();
|
||||
assertTrue(stage.execute(mobileContext).isSuccess());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package com.ycwl.basic.image.pipeline.stages;
|
||||
|
||||
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 org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* ConditionalRotateStage 单元测试
|
||||
*/
|
||||
class ConditionalRotateStageTest {
|
||||
|
||||
@Test
|
||||
void testGetName() {
|
||||
ConditionalRotateStage stage = new ConditionalRotateStage();
|
||||
|
||||
assertEquals("ConditionalRotateStage", stage.getName());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testShouldExecute_NormalPhotoAndPortrait() {
|
||||
ConditionalRotateStage stage = new ConditionalRotateStage();
|
||||
|
||||
PhotoProcessContext context = PhotoProcessContext.builder()
|
||||
.originalUrl("https://example.com/test.jpg")
|
||||
.scenicId(123L)
|
||||
.imageType(ImageType.NORMAL_PHOTO)
|
||||
.build();
|
||||
|
||||
context.setLandscape(false); // 竖图
|
||||
|
||||
assertTrue(stage.shouldExecute(context));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testShouldExecute_NormalPhotoButLandscape_ShouldSkip() {
|
||||
ConditionalRotateStage stage = new ConditionalRotateStage();
|
||||
|
||||
PhotoProcessContext context = PhotoProcessContext.builder()
|
||||
.originalUrl("https://example.com/test.jpg")
|
||||
.scenicId(123L)
|
||||
.imageType(ImageType.NORMAL_PHOTO)
|
||||
.build();
|
||||
|
||||
context.setLandscape(true); // 横图
|
||||
|
||||
assertFalse(stage.shouldExecute(context));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testShouldExecute_Puzzle_ShouldSkip() {
|
||||
ConditionalRotateStage stage = new ConditionalRotateStage();
|
||||
|
||||
PhotoProcessContext context = PhotoProcessContext.builder()
|
||||
.originalUrl("https://example.com/puzzle.png")
|
||||
.scenicId(123L)
|
||||
.imageType(ImageType.PUZZLE)
|
||||
.build();
|
||||
|
||||
context.setLandscape(false);
|
||||
|
||||
assertFalse(stage.shouldExecute(context));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testExecute_NoCurrentFile_ShouldFail() {
|
||||
ConditionalRotateStage stage = new ConditionalRotateStage();
|
||||
|
||||
PhotoProcessContext context = PhotoProcessContext.builder()
|
||||
.originalUrl("https://example.com/test.jpg")
|
||||
.scenicId(123L)
|
||||
.imageType(ImageType.NORMAL_PHOTO)
|
||||
.build();
|
||||
|
||||
context.setLandscape(false);
|
||||
|
||||
StageResult result = stage.execute(context);
|
||||
|
||||
assertTrue(result.isFailed());
|
||||
assertEquals("当前文件不存在", result.getMessage());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,420 @@
|
||||
package com.ycwl.basic.image.pipeline.stages;
|
||||
|
||||
import com.ycwl.basic.image.enhancer.entity.BceEnhancerConfig;
|
||||
import com.ycwl.basic.image.pipeline.core.PhotoProcessContext;
|
||||
import com.ycwl.basic.image.pipeline.core.StageResult;
|
||||
import com.ycwl.basic.image.pipeline.enums.ImageSource;
|
||||
import com.ycwl.basic.image.pipeline.enums.ImageType;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileOutputStream;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* ImageEnhanceStage 单元测试
|
||||
*/
|
||||
class ImageEnhanceStageTest {
|
||||
|
||||
@Test
|
||||
void testGetName() {
|
||||
ImageEnhanceStage stage = new ImageEnhanceStage();
|
||||
|
||||
assertEquals("ImageEnhanceStage", stage.getName());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testDefaultConstructor_ShouldSetDefaultConfig() {
|
||||
ImageEnhanceStage stage = new ImageEnhanceStage();
|
||||
|
||||
BceEnhancerConfig config = stage.getEnhancerConfig();
|
||||
|
||||
assertNotNull(config);
|
||||
assertEquals("119554288", config.getAppId());
|
||||
assertEquals("OX6QoijgKio3eVtA0PiUVf7f", config.getApiKey());
|
||||
assertEquals("dYatXReVriPeiktTjUblhfubpcmYfuMk", config.getSecretKey());
|
||||
assertEquals(1.0f, config.getQps());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testConstructorWithConfig() {
|
||||
BceEnhancerConfig customConfig = new BceEnhancerConfig();
|
||||
customConfig.setAppId("custom-app-id");
|
||||
customConfig.setApiKey("custom-api-key");
|
||||
customConfig.setSecretKey("custom-secret-key");
|
||||
customConfig.setQps(2.0f);
|
||||
|
||||
ImageEnhanceStage stage = new ImageEnhanceStage(customConfig);
|
||||
|
||||
BceEnhancerConfig config = stage.getEnhancerConfig();
|
||||
assertEquals("custom-app-id", config.getAppId());
|
||||
assertEquals("custom-api-key", config.getApiKey());
|
||||
assertEquals("custom-secret-key", config.getSecretKey());
|
||||
assertEquals(2.0f, config.getQps());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testShouldExecute_IPCSource() {
|
||||
ImageEnhanceStage stage = new ImageEnhanceStage();
|
||||
|
||||
PhotoProcessContext context = PhotoProcessContext.builder()
|
||||
.originalUrl("https://example.com/test.jpg")
|
||||
.scenicId(123L)
|
||||
.source(ImageSource.IPC)
|
||||
.build();
|
||||
|
||||
// 需要显式启用 image_enhance
|
||||
context.loadStageConfig(null, java.util.Map.of("image_enhance", true));
|
||||
|
||||
assertTrue(stage.shouldExecute(context));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testShouldExecute_PhoneSource_ShouldSkip() {
|
||||
ImageEnhanceStage stage = new ImageEnhanceStage();
|
||||
|
||||
PhotoProcessContext context = PhotoProcessContext.builder()
|
||||
.originalUrl("https://example.com/test.jpg")
|
||||
.scenicId(123L)
|
||||
.source(ImageSource.PHONE)
|
||||
.build();
|
||||
|
||||
context.loadStageConfig(null, java.util.Map.of("image_enhance", true));
|
||||
|
||||
assertFalse(stage.shouldExecute(context));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testShouldExecute_CameraSource_ShouldSkip() {
|
||||
ImageEnhanceStage stage = new ImageEnhanceStage();
|
||||
|
||||
PhotoProcessContext context = PhotoProcessContext.builder()
|
||||
.originalUrl("https://example.com/test.jpg")
|
||||
.scenicId(123L)
|
||||
.source(ImageSource.CAMERA)
|
||||
.build();
|
||||
|
||||
context.loadStageConfig(null, java.util.Map.of("image_enhance", true));
|
||||
|
||||
assertFalse(stage.shouldExecute(context));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testShouldExecute_UnknownSource_ShouldSkip() {
|
||||
ImageEnhanceStage stage = new ImageEnhanceStage();
|
||||
|
||||
PhotoProcessContext context = PhotoProcessContext.builder()
|
||||
.originalUrl("https://example.com/test.jpg")
|
||||
.scenicId(123L)
|
||||
.source(ImageSource.UNKNOWN)
|
||||
.build();
|
||||
|
||||
context.loadStageConfig(null, java.util.Map.of("image_enhance", true));
|
||||
|
||||
assertFalse(stage.shouldExecute(context));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testShouldExecute_NullSource_ShouldSkip() {
|
||||
ImageEnhanceStage stage = new ImageEnhanceStage();
|
||||
|
||||
PhotoProcessContext context = PhotoProcessContext.builder()
|
||||
.originalUrl("https://example.com/test.jpg")
|
||||
.scenicId(123L)
|
||||
.build();
|
||||
|
||||
context.loadStageConfig(null, java.util.Map.of("image_enhance", true));
|
||||
|
||||
assertFalse(stage.shouldExecute(context));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testShouldExecute_DisabledByConfig_ShouldSkip() {
|
||||
ImageEnhanceStage stage = new ImageEnhanceStage();
|
||||
|
||||
PhotoProcessContext context = PhotoProcessContext.builder()
|
||||
.originalUrl("https://example.com/test.jpg")
|
||||
.scenicId(123L)
|
||||
.source(ImageSource.IPC)
|
||||
.build();
|
||||
|
||||
// 默认不启用,不加载配置
|
||||
assertFalse(stage.shouldExecute(context));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testExecute_NoCurrentFile_ShouldFail() {
|
||||
ImageEnhanceStage stage = new ImageEnhanceStage();
|
||||
|
||||
PhotoProcessContext context = PhotoProcessContext.builder()
|
||||
.originalUrl("https://example.com/test.jpg")
|
||||
.scenicId(123L)
|
||||
.source(ImageSource.IPC)
|
||||
.build();
|
||||
|
||||
StageResult result = stage.execute(context);
|
||||
|
||||
assertTrue(result.isFailed());
|
||||
assertEquals("当前文件不存在", result.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testExecute_InvalidConfig_ShouldSkip() {
|
||||
// 创建无效配置(使用 TODO 占位符)
|
||||
BceEnhancerConfig invalidConfig = new BceEnhancerConfig();
|
||||
invalidConfig.setAppId("TODO_YOUR_APP_ID");
|
||||
invalidConfig.setApiKey("TODO_YOUR_API_KEY");
|
||||
invalidConfig.setSecretKey("TODO_YOUR_SECRET_KEY");
|
||||
|
||||
ImageEnhanceStage stage = new ImageEnhanceStage(invalidConfig);
|
||||
|
||||
PhotoProcessContext context = PhotoProcessContext.builder()
|
||||
.processId("test-invalid-config")
|
||||
.originalUrl("https://example.com/test.jpg")
|
||||
.scenicId(123L)
|
||||
.source(ImageSource.IPC)
|
||||
.build();
|
||||
|
||||
// 创建一个临时文件
|
||||
File tempFile = context.getTempFileManager().createTempFile("test", ".jpg");
|
||||
context.setOriginalFile(tempFile);
|
||||
|
||||
StageResult result = stage.execute(context);
|
||||
|
||||
assertTrue(result.isSkipped());
|
||||
assertEquals("配置不完整,跳过增强", result.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testExecute_NullConfig_ShouldSkip(@TempDir Path tempDir) throws Exception {
|
||||
ImageEnhanceStage stage = new ImageEnhanceStage(null);
|
||||
|
||||
PhotoProcessContext context = PhotoProcessContext.builder()
|
||||
.processId("test-null-config")
|
||||
.originalUrl("https://example.com/test.jpg")
|
||||
.scenicId(123L)
|
||||
.source(ImageSource.IPC)
|
||||
.build();
|
||||
|
||||
// 创建一个临时文件
|
||||
File testFile = tempDir.resolve("test.jpg").toFile();
|
||||
try (FileOutputStream fos = new FileOutputStream(testFile)) {
|
||||
fos.write(new byte[]{1, 2, 3, 4, 5});
|
||||
}
|
||||
context.setOriginalFile(testFile);
|
||||
|
||||
StageResult result = stage.execute(context);
|
||||
|
||||
assertTrue(result.isSkipped());
|
||||
assertEquals("配置不完整,跳过增强", result.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testExecute_NullAppId_ShouldSkip(@TempDir Path tempDir) throws Exception {
|
||||
BceEnhancerConfig config = new BceEnhancerConfig();
|
||||
config.setAppId(null);
|
||||
config.setApiKey("valid-api-key");
|
||||
config.setSecretKey("valid-secret-key");
|
||||
|
||||
ImageEnhanceStage stage = new ImageEnhanceStage(config);
|
||||
|
||||
PhotoProcessContext context = PhotoProcessContext.builder()
|
||||
.processId("test-null-appid")
|
||||
.originalUrl("https://example.com/test.jpg")
|
||||
.scenicId(123L)
|
||||
.source(ImageSource.IPC)
|
||||
.build();
|
||||
|
||||
File testFile = tempDir.resolve("test.jpg").toFile();
|
||||
try (FileOutputStream fos = new FileOutputStream(testFile)) {
|
||||
fos.write(new byte[]{1, 2, 3, 4, 5});
|
||||
}
|
||||
context.setOriginalFile(testFile);
|
||||
|
||||
StageResult result = stage.execute(context);
|
||||
|
||||
assertTrue(result.isSkipped());
|
||||
assertEquals("配置不完整,跳过增强", result.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testExecute_TodoPlaceholderAppId_ShouldSkip(@TempDir Path tempDir) throws Exception {
|
||||
BceEnhancerConfig config = new BceEnhancerConfig();
|
||||
config.setAppId("TODO_APP_ID");
|
||||
config.setApiKey("valid-api-key");
|
||||
config.setSecretKey("valid-secret-key");
|
||||
|
||||
ImageEnhanceStage stage = new ImageEnhanceStage(config);
|
||||
|
||||
PhotoProcessContext context = PhotoProcessContext.builder()
|
||||
.processId("test-todo-appid")
|
||||
.originalUrl("https://example.com/test.jpg")
|
||||
.scenicId(123L)
|
||||
.source(ImageSource.IPC)
|
||||
.build();
|
||||
|
||||
File testFile = tempDir.resolve("test.jpg").toFile();
|
||||
try (FileOutputStream fos = new FileOutputStream(testFile)) {
|
||||
fos.write(new byte[]{1, 2, 3, 4, 5});
|
||||
}
|
||||
context.setOriginalFile(testFile);
|
||||
|
||||
StageResult result = stage.execute(context);
|
||||
|
||||
assertTrue(result.isSkipped());
|
||||
assertEquals("配置不完整,跳过增强", result.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSetAndGetEnhancerConfig() {
|
||||
ImageEnhanceStage stage = new ImageEnhanceStage();
|
||||
|
||||
BceEnhancerConfig newConfig = new BceEnhancerConfig();
|
||||
newConfig.setAppId("new-app-id");
|
||||
newConfig.setApiKey("new-api-key");
|
||||
newConfig.setSecretKey("new-secret-key");
|
||||
newConfig.setQps(3.0f);
|
||||
|
||||
stage.setEnhancerConfig(newConfig);
|
||||
|
||||
BceEnhancerConfig retrieved = stage.getEnhancerConfig();
|
||||
assertEquals("new-app-id", retrieved.getAppId());
|
||||
assertEquals("new-api-key", retrieved.getApiKey());
|
||||
assertEquals("new-secret-key", retrieved.getSecretKey());
|
||||
assertEquals(3.0f, retrieved.getQps());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testExecute_AllImageTypes_WithIPCSource(@TempDir Path tempDir) throws Exception {
|
||||
ImageEnhanceStage stage = new ImageEnhanceStage();
|
||||
|
||||
// 测试普通照片
|
||||
testImageType(stage, tempDir, ImageType.NORMAL_PHOTO);
|
||||
|
||||
// 测试拼图
|
||||
testImageType(stage, tempDir, ImageType.PUZZLE);
|
||||
|
||||
// 测试手机上传
|
||||
testImageType(stage, tempDir, ImageType.MOBILE_UPLOAD);
|
||||
}
|
||||
|
||||
private void testImageType(ImageEnhanceStage stage, Path tempDir, ImageType imageType) throws Exception {
|
||||
PhotoProcessContext context = PhotoProcessContext.builder()
|
||||
.processId("test-" + imageType.getCode())
|
||||
.originalUrl("https://example.com/test.jpg")
|
||||
.scenicId(123L)
|
||||
.imageType(imageType)
|
||||
.source(ImageSource.IPC)
|
||||
.build();
|
||||
|
||||
File testFile = tempDir.resolve("test-" + imageType.getCode() + ".jpg").toFile();
|
||||
try (FileOutputStream fos = new FileOutputStream(testFile)) {
|
||||
fos.write(new byte[]{1, 2, 3, 4, 5});
|
||||
}
|
||||
context.setOriginalFile(testFile);
|
||||
|
||||
context.loadStageConfig(null, java.util.Map.of("image_enhance", true));
|
||||
|
||||
// 应该可以执行(因为 source 是 IPC)
|
||||
assertTrue(stage.shouldExecute(context));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testConfigValidation_AllTodoFields() {
|
||||
BceEnhancerConfig config = new BceEnhancerConfig();
|
||||
config.setAppId("TODO_APP_ID");
|
||||
config.setApiKey("TODO_API_KEY");
|
||||
config.setSecretKey("TODO_SECRET_KEY");
|
||||
|
||||
ImageEnhanceStage stage = new ImageEnhanceStage(config);
|
||||
|
||||
PhotoProcessContext context = PhotoProcessContext.builder()
|
||||
.processId("test-all-todo")
|
||||
.originalUrl("https://example.com/test.jpg")
|
||||
.scenicId(123L)
|
||||
.source(ImageSource.IPC)
|
||||
.build();
|
||||
|
||||
File tempFile = context.getTempFileManager().createTempFile("test", ".jpg");
|
||||
context.setOriginalFile(tempFile);
|
||||
|
||||
StageResult result = stage.execute(context);
|
||||
|
||||
assertTrue(result.isSkipped());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testConfigValidation_MixedTodoAndValid() {
|
||||
BceEnhancerConfig config = new BceEnhancerConfig();
|
||||
config.setAppId("valid-app-id");
|
||||
config.setApiKey("TODO_API_KEY");
|
||||
config.setSecretKey("valid-secret-key");
|
||||
|
||||
ImageEnhanceStage stage = new ImageEnhanceStage(config);
|
||||
|
||||
PhotoProcessContext context = PhotoProcessContext.builder()
|
||||
.processId("test-mixed-todo")
|
||||
.originalUrl("https://example.com/test.jpg")
|
||||
.scenicId(123L)
|
||||
.source(ImageSource.IPC)
|
||||
.build();
|
||||
|
||||
File tempFile = context.getTempFileManager().createTempFile("test", ".jpg");
|
||||
context.setOriginalFile(tempFile);
|
||||
|
||||
StageResult result = stage.execute(context);
|
||||
|
||||
assertTrue(result.isSkipped());
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试说明:
|
||||
*
|
||||
* 由于 ImageEnhanceStage 依赖外部 API (BceImageEnhancer),
|
||||
* 以下测试场景需要 Mock 或集成测试环境:
|
||||
*
|
||||
* 1. testExecute_Success_ValidConfig()
|
||||
* - 模拟成功的图像增强调用
|
||||
* - 验证增强后文件大小变化
|
||||
* - 验证 context.updateProcessedFile() 被调用
|
||||
*
|
||||
* 2. testExecute_ApiReturnsNull_ShouldDegrade()
|
||||
* - 模拟 API 返回 null
|
||||
* - 验证返回 StageResult.degraded()
|
||||
*
|
||||
* 3. testExecute_ApiReturnsEmptyFile_ShouldDegrade()
|
||||
* - 模拟 API 返回空文件
|
||||
* - 验证返回 StageResult.degraded()
|
||||
*
|
||||
* 4. testExecute_ApiThrowsException_ShouldDegrade()
|
||||
* - 模拟 API 抛出异常
|
||||
* - 验证返回 StageResult.degraded() 而不是 failed()
|
||||
*
|
||||
* 5. testExecute_SaveFileFails_ShouldDegrade()
|
||||
* - 模拟文件保存失败
|
||||
* - 验证返回 StageResult.degraded()
|
||||
*
|
||||
* 这些测试需要使用 Mockito 或类似的 Mock 框架来模拟 BceImageEnhancer 的行为。
|
||||
*
|
||||
* 示例(使用 Mockito):
|
||||
*
|
||||
* @Test
|
||||
* void testExecute_Success_ValidConfig() {
|
||||
* // 创建 Mock 的 BceImageEnhancer
|
||||
* BceImageEnhancer mockEnhancer = mock(BceImageEnhancer.class);
|
||||
*
|
||||
* // 创建 Mock 的 MultipartFile
|
||||
* MultipartFile mockEnhancedImage = mock(MultipartFile.class);
|
||||
* when(mockEnhancedImage.isEmpty()).thenReturn(false);
|
||||
* when(mockEnhancedImage.getBytes()).thenReturn(new byte[]{1, 2, 3, 4, 5, 6, 7, 8});
|
||||
*
|
||||
* // 配置 Mock 行为
|
||||
* when(mockEnhancer.enhance(anyString())).thenReturn(mockEnhancedImage);
|
||||
*
|
||||
* // 测试逻辑...
|
||||
* }
|
||||
*/
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package com.ycwl.basic.image.pipeline.stages;
|
||||
|
||||
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 org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* ImageOrientationStage 单元测试
|
||||
*/
|
||||
class ImageOrientationStageTest {
|
||||
|
||||
@Test
|
||||
void testGetName() {
|
||||
ImageOrientationStage stage = new ImageOrientationStage();
|
||||
|
||||
assertEquals("ImageOrientationStage", stage.getName());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testShouldExecute_NormalPhoto() {
|
||||
ImageOrientationStage stage = new ImageOrientationStage();
|
||||
|
||||
PhotoProcessContext context = PhotoProcessContext.builder()
|
||||
.originalUrl("https://example.com/test.jpg")
|
||||
.scenicId(123L)
|
||||
.imageType(ImageType.NORMAL_PHOTO)
|
||||
.build();
|
||||
|
||||
assertTrue(stage.shouldExecute(context));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testShouldExecute_Puzzle_ShouldSkip() {
|
||||
ImageOrientationStage stage = new ImageOrientationStage();
|
||||
|
||||
PhotoProcessContext context = PhotoProcessContext.builder()
|
||||
.originalUrl("https://example.com/puzzle.png")
|
||||
.scenicId(123L)
|
||||
.imageType(ImageType.PUZZLE)
|
||||
.build();
|
||||
|
||||
assertFalse(stage.shouldExecute(context));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testShouldExecute_MobileUpload_ShouldSkip() {
|
||||
ImageOrientationStage stage = new ImageOrientationStage();
|
||||
|
||||
PhotoProcessContext context = PhotoProcessContext.builder()
|
||||
.originalUrl("https://example.com/mobile.jpg")
|
||||
.scenicId(123L)
|
||||
.imageType(ImageType.MOBILE_UPLOAD)
|
||||
.build();
|
||||
|
||||
assertFalse(stage.shouldExecute(context));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testExecute_NoCurrentFile_ShouldFail() {
|
||||
ImageOrientationStage stage = new ImageOrientationStage();
|
||||
|
||||
PhotoProcessContext context = PhotoProcessContext.builder()
|
||||
.originalUrl("https://example.com/test.jpg")
|
||||
.scenicId(123L)
|
||||
.build();
|
||||
|
||||
StageResult result = stage.execute(context);
|
||||
|
||||
assertTrue(result.isFailed());
|
||||
assertEquals("当前文件不存在", result.getMessage());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package com.ycwl.basic.image.pipeline.stages;
|
||||
|
||||
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 org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* ImageQualityCheckStage 单元测试
|
||||
*/
|
||||
class ImageQualityCheckStageTest {
|
||||
|
||||
@Test
|
||||
void testGetName() {
|
||||
ImageQualityCheckStage stage = new ImageQualityCheckStage();
|
||||
|
||||
assertEquals("ImageQualityCheckStage", stage.getName());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testShouldExecute_NormalPhoto() {
|
||||
ImageQualityCheckStage stage = new ImageQualityCheckStage();
|
||||
|
||||
PhotoProcessContext context = PhotoProcessContext.builder()
|
||||
.originalUrl("https://example.com/test.jpg")
|
||||
.scenicId(123L)
|
||||
.imageType(ImageType.NORMAL_PHOTO)
|
||||
.build();
|
||||
|
||||
// ImageQualityCheckStage 默认不启用,需要显式启用
|
||||
context.loadStageConfig(null, java.util.Map.of("quality_check", true));
|
||||
|
||||
assertTrue(stage.shouldExecute(context));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testShouldExecute_Puzzle_ShouldSkip() {
|
||||
ImageQualityCheckStage stage = new ImageQualityCheckStage();
|
||||
|
||||
PhotoProcessContext context = PhotoProcessContext.builder()
|
||||
.originalUrl("https://example.com/puzzle.png")
|
||||
.scenicId(123L)
|
||||
.imageType(ImageType.PUZZLE)
|
||||
.build();
|
||||
|
||||
assertFalse(stage.shouldExecute(context));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testShouldExecute_MobileUpload_ShouldSkip() {
|
||||
ImageQualityCheckStage stage = new ImageQualityCheckStage();
|
||||
|
||||
PhotoProcessContext context = PhotoProcessContext.builder()
|
||||
.originalUrl("https://example.com/mobile.jpg")
|
||||
.scenicId(123L)
|
||||
.imageType(ImageType.MOBILE_UPLOAD)
|
||||
.build();
|
||||
|
||||
assertFalse(stage.shouldExecute(context));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testExecute_NoCurrentFile_ShouldFail() {
|
||||
ImageQualityCheckStage stage = new ImageQualityCheckStage();
|
||||
|
||||
PhotoProcessContext context = PhotoProcessContext.builder()
|
||||
.originalUrl("https://example.com/test.jpg")
|
||||
.scenicId(123L)
|
||||
.imageType(ImageType.NORMAL_PHOTO)
|
||||
.build();
|
||||
|
||||
StageResult result = stage.execute(context);
|
||||
|
||||
assertTrue(result.isFailed());
|
||||
assertEquals("当前文件不存在", result.getMessage());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package com.ycwl.basic.image.pipeline.stages;
|
||||
|
||||
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 org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* PuzzleBorderStage 单元测试
|
||||
*/
|
||||
class PuzzleBorderStageTest {
|
||||
|
||||
@Test
|
||||
void testGetName() {
|
||||
PuzzleBorderStage stage = new PuzzleBorderStage();
|
||||
|
||||
assertEquals("PuzzleBorderStage", stage.getName());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testShouldExecute_Puzzle() {
|
||||
PuzzleBorderStage stage = new PuzzleBorderStage();
|
||||
|
||||
PhotoProcessContext context = PhotoProcessContext.builder()
|
||||
.originalUrl("https://example.com/puzzle.png")
|
||||
.scenicId(123L)
|
||||
.imageType(ImageType.PUZZLE)
|
||||
.build();
|
||||
|
||||
assertTrue(stage.shouldExecute(context));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testShouldExecute_NormalPhoto_ShouldSkip() {
|
||||
PuzzleBorderStage stage = new PuzzleBorderStage();
|
||||
|
||||
PhotoProcessContext context = PhotoProcessContext.builder()
|
||||
.originalUrl("https://example.com/test.jpg")
|
||||
.scenicId(123L)
|
||||
.imageType(ImageType.NORMAL_PHOTO)
|
||||
.build();
|
||||
|
||||
assertFalse(stage.shouldExecute(context));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testShouldExecute_MobileUpload_ShouldSkip() {
|
||||
PuzzleBorderStage stage = new PuzzleBorderStage();
|
||||
|
||||
PhotoProcessContext context = PhotoProcessContext.builder()
|
||||
.originalUrl("https://example.com/mobile.jpg")
|
||||
.scenicId(123L)
|
||||
.imageType(ImageType.MOBILE_UPLOAD)
|
||||
.build();
|
||||
|
||||
assertFalse(stage.shouldExecute(context));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testExecute_NoCurrentFile_ShouldFail() {
|
||||
PuzzleBorderStage stage = new PuzzleBorderStage();
|
||||
|
||||
PhotoProcessContext context = PhotoProcessContext.builder()
|
||||
.originalUrl("https://example.com/puzzle.png")
|
||||
.scenicId(123L)
|
||||
.imageType(ImageType.PUZZLE)
|
||||
.build();
|
||||
|
||||
StageResult result = stage.execute(context);
|
||||
|
||||
assertTrue(result.isFailed());
|
||||
assertEquals("当前文件不存在", result.getMessage());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package com.ycwl.basic.image.pipeline.stages;
|
||||
|
||||
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 org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* RestoreOrientationStage 单元测试
|
||||
*/
|
||||
class RestoreOrientationStageTest {
|
||||
|
||||
@Test
|
||||
void testGetName() {
|
||||
RestoreOrientationStage stage = new RestoreOrientationStage();
|
||||
|
||||
assertEquals("RestoreOrientationStage", stage.getName());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testShouldExecute_NormalPhotoAndNeedRotation() {
|
||||
RestoreOrientationStage stage = new RestoreOrientationStage();
|
||||
|
||||
PhotoProcessContext context = PhotoProcessContext.builder()
|
||||
.originalUrl("https://example.com/test.jpg")
|
||||
.scenicId(123L)
|
||||
.imageType(ImageType.NORMAL_PHOTO)
|
||||
.build();
|
||||
|
||||
context.setNeedRotation(true);
|
||||
|
||||
assertTrue(stage.shouldExecute(context));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testShouldExecute_NormalPhotoButNoNeedRotation_ShouldSkip() {
|
||||
RestoreOrientationStage stage = new RestoreOrientationStage();
|
||||
|
||||
PhotoProcessContext context = PhotoProcessContext.builder()
|
||||
.originalUrl("https://example.com/test.jpg")
|
||||
.scenicId(123L)
|
||||
.imageType(ImageType.NORMAL_PHOTO)
|
||||
.build();
|
||||
|
||||
context.setNeedRotation(false);
|
||||
|
||||
assertFalse(stage.shouldExecute(context));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testShouldExecute_Puzzle_ShouldSkip() {
|
||||
RestoreOrientationStage stage = new RestoreOrientationStage();
|
||||
|
||||
PhotoProcessContext context = PhotoProcessContext.builder()
|
||||
.originalUrl("https://example.com/puzzle.png")
|
||||
.scenicId(123L)
|
||||
.imageType(ImageType.PUZZLE)
|
||||
.build();
|
||||
|
||||
context.setNeedRotation(true);
|
||||
|
||||
assertFalse(stage.shouldExecute(context));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testExecute_NoCurrentFile_ShouldFail() {
|
||||
RestoreOrientationStage stage = new RestoreOrientationStage();
|
||||
|
||||
PhotoProcessContext context = PhotoProcessContext.builder()
|
||||
.originalUrl("https://example.com/test.jpg")
|
||||
.scenicId(123L)
|
||||
.imageType(ImageType.NORMAL_PHOTO)
|
||||
.build();
|
||||
|
||||
context.setNeedRotation(true);
|
||||
|
||||
StageResult result = stage.execute(context);
|
||||
|
||||
assertTrue(result.isFailed());
|
||||
assertEquals("当前文件不存在", result.getMessage());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package com.ycwl.basic.image.pipeline.stages;
|
||||
|
||||
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 org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* WatermarkStage 单元测试
|
||||
*/
|
||||
class WatermarkStageTest {
|
||||
|
||||
@Test
|
||||
void testGetName() {
|
||||
WatermarkStage stage = new WatermarkStage();
|
||||
|
||||
assertEquals("WatermarkStage", stage.getName());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testShouldExecute_NormalPhoto() {
|
||||
WatermarkStage stage = new WatermarkStage();
|
||||
|
||||
PhotoProcessContext context = PhotoProcessContext.builder()
|
||||
.originalUrl("https://example.com/test.jpg")
|
||||
.scenicId(123L)
|
||||
.imageType(ImageType.NORMAL_PHOTO)
|
||||
.build();
|
||||
|
||||
assertTrue(stage.shouldExecute(context));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testShouldExecute_Puzzle_ShouldSkip() {
|
||||
WatermarkStage stage = new WatermarkStage();
|
||||
|
||||
PhotoProcessContext context = PhotoProcessContext.builder()
|
||||
.originalUrl("https://example.com/puzzle.png")
|
||||
.scenicId(123L)
|
||||
.imageType(ImageType.PUZZLE)
|
||||
.build();
|
||||
|
||||
assertFalse(stage.shouldExecute(context));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testShouldExecute_MobileUpload_ShouldSkip() {
|
||||
WatermarkStage stage = new WatermarkStage();
|
||||
|
||||
PhotoProcessContext context = PhotoProcessContext.builder()
|
||||
.originalUrl("https://example.com/mobile.jpg")
|
||||
.scenicId(123L)
|
||||
.imageType(ImageType.MOBILE_UPLOAD)
|
||||
.build();
|
||||
|
||||
assertFalse(stage.shouldExecute(context));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testExecute_NoWatermarkType_ShouldSkip() {
|
||||
WatermarkStage stage = new WatermarkStage();
|
||||
|
||||
PhotoProcessContext context = PhotoProcessContext.builder()
|
||||
.originalUrl("https://example.com/test.jpg")
|
||||
.scenicId(123L)
|
||||
.imageType(ImageType.NORMAL_PHOTO)
|
||||
.build();
|
||||
|
||||
StageResult result = stage.execute(context);
|
||||
|
||||
assertTrue(result.isSkipped());
|
||||
assertEquals("未配置水印", result.getMessage());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user