You've already forked FrameTour-BE
feat(puzzle): 添加拼图生成异步处理能力
- 移除 @RequiredArgsConstructor 注解,改用手动构造函数注入 - 添加 ThreadPoolExecutor 实现拼图生成异步处理 - 新增 generateAsync 方法支持异步生成拼图 - 新增 generateSync 方法支持同步生成拼图 - 重构核心生成逻辑为 doGenerateInternal 方法供同步异步共用 - 在 FaceMatchingOrchestrator 中优化拼图模板生成逻辑 - 支持根据场景选择同步或异步生成模式 - 添加线程池队列大小监控和日志记录
This commit is contained in:
@@ -12,10 +12,29 @@ import com.ycwl.basic.puzzle.dto.PuzzleGenerateResponse;
|
||||
public interface IPuzzleGenerateService {
|
||||
|
||||
/**
|
||||
* 生成拼图图片
|
||||
* 生成拼图图片(默认同步模式)
|
||||
*
|
||||
* @param request 生成请求
|
||||
* @return 生成结果(包含图片URL等信息)
|
||||
*/
|
||||
PuzzleGenerateResponse generate(PuzzleGenerateRequest request);
|
||||
|
||||
/**
|
||||
* 同步生成拼图图片
|
||||
* <p>立即执行并阻塞等待结果返回</p>
|
||||
*
|
||||
* @param request 生成请求
|
||||
* @return 生成结果(包含图片URL等信息)
|
||||
*/
|
||||
PuzzleGenerateResponse generateSync(PuzzleGenerateRequest request);
|
||||
|
||||
/**
|
||||
* 异步生成拼图图片
|
||||
* <p>提交到队列,由固定大小的线程池异步处理,不等待结果</p>
|
||||
* <p>队列满时会降级为同步执行(CallerRunsPolicy)</p>
|
||||
*
|
||||
* @param request 生成请求
|
||||
* @return 生成记录ID(可用于后续追踪状态)
|
||||
*/
|
||||
Long generateAsync(PuzzleGenerateRequest request);
|
||||
}
|
||||
|
||||
@@ -20,7 +20,6 @@ import com.ycwl.basic.repository.ScenicRepository;
|
||||
import com.ycwl.basic.service.printer.PrinterService;
|
||||
import com.ycwl.basic.storage.StorageFactory;
|
||||
import com.ycwl.basic.utils.WxMpUtil;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.context.annotation.Lazy;
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -38,6 +37,9 @@ import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.LinkedBlockingQueue;
|
||||
import java.util.concurrent.ThreadPoolExecutor;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* 拼图图片生成服务实现
|
||||
@@ -47,33 +49,104 @@ import java.util.UUID;
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class PuzzleGenerateServiceImpl implements IPuzzleGenerateService {
|
||||
|
||||
private final PuzzleTemplateMapper templateMapper;
|
||||
private final PuzzleElementMapper elementMapper;
|
||||
private final PuzzleGenerationRecordMapper recordMapper;
|
||||
@Lazy
|
||||
private final PuzzleImageRenderer imageRenderer;
|
||||
@Lazy
|
||||
private final PuzzleElementFillEngine fillEngine;
|
||||
@Lazy
|
||||
private final ScenicRepository scenicRepository;
|
||||
@Lazy
|
||||
private final PuzzleDuplicationDetector duplicationDetector;
|
||||
@Lazy
|
||||
private final PrinterService printerService;
|
||||
private final ThreadPoolExecutor puzzleGenerateExecutor;
|
||||
|
||||
public PuzzleGenerateServiceImpl(
|
||||
PuzzleTemplateMapper templateMapper,
|
||||
PuzzleElementMapper elementMapper,
|
||||
PuzzleGenerationRecordMapper recordMapper,
|
||||
@Lazy PuzzleImageRenderer imageRenderer,
|
||||
@Lazy PuzzleElementFillEngine fillEngine,
|
||||
@Lazy ScenicRepository scenicRepository,
|
||||
@Lazy PuzzleDuplicationDetector duplicationDetector,
|
||||
@Lazy PrinterService printerService) {
|
||||
this.templateMapper = templateMapper;
|
||||
this.elementMapper = elementMapper;
|
||||
this.recordMapper = recordMapper;
|
||||
this.imageRenderer = imageRenderer;
|
||||
this.fillEngine = fillEngine;
|
||||
this.scenicRepository = scenicRepository;
|
||||
this.duplicationDetector = duplicationDetector;
|
||||
this.printerService = printerService;
|
||||
this.puzzleGenerateExecutor = new ThreadPoolExecutor(
|
||||
4,
|
||||
256,
|
||||
30,
|
||||
TimeUnit.SECONDS,
|
||||
new LinkedBlockingQueue<>(256),
|
||||
new ThreadPoolExecutor.CallerRunsPolicy()
|
||||
);;
|
||||
}
|
||||
|
||||
@Override
|
||||
public PuzzleGenerateResponse generate(PuzzleGenerateRequest request) {
|
||||
// 默认使用同步模式
|
||||
return generateSync(request);
|
||||
}
|
||||
|
||||
@Override
|
||||
public PuzzleGenerateResponse generateSync(PuzzleGenerateRequest request) {
|
||||
return doGenerate(request);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Long generateAsync(PuzzleGenerateRequest request) {
|
||||
// 1. 参数校验
|
||||
validateRequest(request);
|
||||
|
||||
// 2. 查询模板
|
||||
PuzzleTemplateEntity template = templateMapper.getByCode(request.getTemplateCode());
|
||||
if (template == null) {
|
||||
throw new IllegalArgumentException("模板不存在: " + request.getTemplateCode());
|
||||
}
|
||||
if (template.getStatus() != 1) {
|
||||
throw new IllegalArgumentException("模板已禁用: " + request.getTemplateCode());
|
||||
}
|
||||
Long resolvedScenicId = resolveScenicId(template, request.getScenicId());
|
||||
|
||||
// 3. 创建 PENDING 状态的记录
|
||||
PuzzleGenerationRecordEntity record = createRecord(template, request, resolvedScenicId);
|
||||
record.setStatus(0); // 生成中
|
||||
recordMapper.insert(record);
|
||||
Long recordId = record.getId();
|
||||
|
||||
log.info("异步拼图生成任务已提交: recordId={}, templateCode={}, 当前队列大小={}",
|
||||
recordId, request.getTemplateCode(), puzzleGenerateExecutor.getQueue().size());
|
||||
|
||||
// 4. 提交到线程池异步执行
|
||||
puzzleGenerateExecutor.execute(() -> {
|
||||
try {
|
||||
doGenerateInternal(request, template, resolvedScenicId, record);
|
||||
} catch (Exception e) {
|
||||
log.error("异步拼图生成失败: recordId={}, templateCode={}",
|
||||
recordId, request.getTemplateCode(), e);
|
||||
recordMapper.updateFail(recordId, e.getMessage());
|
||||
}
|
||||
});
|
||||
|
||||
return recordId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 核心生成逻辑(同步执行)
|
||||
*/
|
||||
private PuzzleGenerateResponse doGenerate(PuzzleGenerateRequest request) {
|
||||
long startTime = System.currentTimeMillis();
|
||||
log.info("开始生成拼图: templateCode={}, userId={}, faceId={}",
|
||||
request.getTemplateCode(), request.getUserId(), request.getFaceId());
|
||||
|
||||
// 业务层校验:faceId 必填
|
||||
if (request.getFaceId() == null) {
|
||||
throw new IllegalArgumentException("人脸ID不能为空");
|
||||
}
|
||||
// 参数校验
|
||||
validateRequest(request);
|
||||
|
||||
// 1. 查询模板和元素
|
||||
PuzzleTemplateEntity template = templateMapper.getByCode(request.getTemplateCode());
|
||||
@@ -135,16 +208,66 @@ public class PuzzleGenerateServiceImpl implements IPuzzleGenerateService {
|
||||
record.setContentHash(contentHash);
|
||||
recordMapper.insert(record);
|
||||
|
||||
// 9. 执行核心生成逻辑
|
||||
return doGenerateInternal(request, template, resolvedScenicId, record, startTime);
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验请求参数
|
||||
*/
|
||||
private void validateRequest(PuzzleGenerateRequest request) {
|
||||
if (request.getFaceId() == null) {
|
||||
throw new IllegalArgumentException("人脸ID不能为空");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 核心生成逻辑(内部方法,同步/异步共用)
|
||||
* 注意:此方法会在调用线程中执行渲染和上传操作
|
||||
*
|
||||
* @param request 生成请求
|
||||
* @param template 模板
|
||||
* @param resolvedScenicId 景区ID
|
||||
* @param record 生成记录(已插入数据库)
|
||||
* @return 生成结果(异步模式下不关心返回值)
|
||||
*/
|
||||
private PuzzleGenerateResponse doGenerateInternal(PuzzleGenerateRequest request,
|
||||
PuzzleTemplateEntity template,
|
||||
Long resolvedScenicId,
|
||||
PuzzleGenerationRecordEntity record) {
|
||||
return doGenerateInternal(request, template, resolvedScenicId, record, System.currentTimeMillis());
|
||||
}
|
||||
|
||||
/**
|
||||
* 核心生成逻辑(内部方法,同步/异步共用)
|
||||
*/
|
||||
private PuzzleGenerateResponse doGenerateInternal(PuzzleGenerateRequest request,
|
||||
PuzzleTemplateEntity template,
|
||||
Long resolvedScenicId,
|
||||
PuzzleGenerationRecordEntity record,
|
||||
long startTime) {
|
||||
List<PuzzleElementEntity> elements = elementMapper.getByTemplateId(template.getId());
|
||||
if (elements.isEmpty()) {
|
||||
throw new IllegalArgumentException("模板没有配置元素: " + request.getTemplateCode());
|
||||
}
|
||||
|
||||
// 按z-index排序元素
|
||||
elements.sort(Comparator.comparing(PuzzleElementEntity::getZIndex,
|
||||
Comparator.nullsFirst(Comparator.naturalOrder())));
|
||||
|
||||
// 准备dynamicData
|
||||
Map<String, String> finalDynamicData = buildDynamicData(template, request, resolvedScenicId, elements);
|
||||
|
||||
try {
|
||||
// 9. 渲染图片
|
||||
// 渲染图片
|
||||
BufferedImage resultImage = imageRenderer.render(template, elements, finalDynamicData);
|
||||
|
||||
// 10. 上传原图到OSS(未裁切)
|
||||
// 上传原图到OSS(未裁切)
|
||||
String originalImageUrl = uploadImage(resultImage, template.getCode(), request.getOutputFormat(), request.getQuality());
|
||||
log.info("原图上传成功: url={}", originalImageUrl);
|
||||
|
||||
// 11. 处理用户区域裁切
|
||||
String finalImageUrl = originalImageUrl; // 默认使用原图
|
||||
// 处理用户区域裁切
|
||||
String finalImageUrl = originalImageUrl;
|
||||
BufferedImage finalImage = resultImage;
|
||||
|
||||
if (StrUtil.isNotBlank(template.getUserArea())) {
|
||||
@@ -155,12 +278,11 @@ public class PuzzleGenerateServiceImpl implements IPuzzleGenerateService {
|
||||
log.info("裁切后图片上传成功: userArea={}, url={}", template.getUserArea(), finalImageUrl);
|
||||
} catch (Exception e) {
|
||||
log.error("图片裁切失败,使用原图: userArea={}", template.getUserArea(), e);
|
||||
// 裁切失败时使用原图
|
||||
}
|
||||
}
|
||||
|
||||
// 12. 更新记录为成功
|
||||
long duration = (int) (System.currentTimeMillis() - startTime);
|
||||
// 更新记录为成功
|
||||
long duration = System.currentTimeMillis() - startTime;
|
||||
long fileSize = estimateFileSize(finalImage, request.getOutputFormat());
|
||||
recordMapper.updateSuccess(
|
||||
record.getId(),
|
||||
@@ -172,23 +294,22 @@ public class PuzzleGenerateServiceImpl implements IPuzzleGenerateService {
|
||||
(int) duration
|
||||
);
|
||||
|
||||
log.info("拼图生成成功(新生成): recordId={}, originalUrl={}, finalUrl={}, duration={}ms",
|
||||
log.info("拼图生成成功: recordId={}, originalUrl={}, finalUrl={}, duration={}ms",
|
||||
record.getId(), originalImageUrl, finalImageUrl, duration);
|
||||
|
||||
// 13. 检查是否自动添加到打印队列
|
||||
// 检查是否自动添加到打印队列
|
||||
if (template.getAutoAddPrint() != null && template.getAutoAddPrint() == 1) {
|
||||
try {
|
||||
Integer printRecordId = printerService.addUserPhotoFromPuzzle(
|
||||
request.getUserId(),
|
||||
resolvedScenicId,
|
||||
request.getFaceId(),
|
||||
originalImageUrl, // 使用原图URL添加到打印队列
|
||||
originalImageUrl,
|
||||
record.getId()
|
||||
);
|
||||
log.info("自动添加到打印队列成功: recordId={}, printRecordId={}", record.getId(), printRecordId);
|
||||
} catch (Exception e) {
|
||||
log.error("自动添加到打印队列失败: recordId={}", record.getId(), e);
|
||||
// 添加失败不影响拼图生成流程
|
||||
}
|
||||
}
|
||||
|
||||
@@ -199,13 +320,12 @@ public class PuzzleGenerateServiceImpl implements IPuzzleGenerateService {
|
||||
finalImage.getHeight(),
|
||||
(int) duration,
|
||||
record.getId(),
|
||||
false, // isDuplicate=false
|
||||
null // originalRecordId=null
|
||||
false,
|
||||
null
|
||||
);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("拼图生成失败: templateCode={}", request.getTemplateCode(), e);
|
||||
// 更新记录为失败
|
||||
recordMapper.updateFail(record.getId(), e.getMessage());
|
||||
throw new RuntimeException("图片生成失败: " + e.getMessage(), e);
|
||||
}
|
||||
|
||||
@@ -151,12 +151,7 @@ public class FaceMatchingOrchestrator {
|
||||
processSourceRelations(context, searchResult, faceId, isNew);
|
||||
|
||||
// 步骤7: 异步生成拼图模板
|
||||
Thread thread = asyncGeneratePuzzleTemplate(context.face.getScenicId(), faceId, context.face.getMemberId());
|
||||
if (Strings.CI.equals(scene, "printer")) {
|
||||
if (thread != null) {
|
||||
thread.join();
|
||||
}
|
||||
}
|
||||
asyncGeneratePuzzleTemplate(context.face.getScenicId(), faceId, context.face.getMemberId(), scene);
|
||||
|
||||
return searchResult;
|
||||
|
||||
@@ -367,101 +362,74 @@ public class FaceMatchingOrchestrator {
|
||||
*
|
||||
* @return
|
||||
*/
|
||||
private Thread asyncGeneratePuzzleTemplate(Long scenicId, Long faceId, Long memberId) {
|
||||
private void asyncGeneratePuzzleTemplate(Long scenicId, Long faceId, Long memberId, String scene) {
|
||||
if (redisTemplate.hasKey("puzzle_generated:face:" + faceId)) {
|
||||
return null;
|
||||
return;
|
||||
}
|
||||
redisTemplate.opsForValue().set(
|
||||
"puzzle_generated:face:" + faceId,
|
||||
"1",
|
||||
60 * 10, TimeUnit.SECONDS);
|
||||
Thread thread = new Thread(() -> {
|
||||
try {
|
||||
log.info("开始异步生成景区拼图模板: scenicId={}, faceId={}", scenicId, faceId);
|
||||
try {
|
||||
log.info("开始异步生成景区拼图模板: scenicId={}, faceId={}", scenicId, faceId);
|
||||
|
||||
// 查询该景区所有启用状态的拼图模板
|
||||
List<PuzzleTemplateDTO> templateList = puzzleTemplateService.listTemplates(
|
||||
scenicId, null, 1); // 查询启用状态的模板
|
||||
// 查询该景区所有启用状态的拼图模板
|
||||
List<PuzzleTemplateDTO> templateList = puzzleTemplateService.listTemplates(
|
||||
scenicId, null, 1); // 查询启用状态的模板
|
||||
|
||||
if (templateList == null || templateList.isEmpty()) {
|
||||
log.info("景区不存在启用的拼图模板,跳过生成: scenicId={}", scenicId);
|
||||
return;
|
||||
}
|
||||
|
||||
log.info("景区存在 {} 个启用的拼图模板,开始逐个生成: scenicId={}", templateList.size(), scenicId);
|
||||
|
||||
// 获取人脸信息用于动态数据
|
||||
FaceEntity face = faceRepository.getFace(faceId);
|
||||
if (face == null) {
|
||||
log.warn("人脸信息不存在,无法生成拼图: faceId={}", faceId);
|
||||
return;
|
||||
}
|
||||
ScenicV2DTO scenicBasic = scenicRepository.getScenicBasic(face.getScenicId());
|
||||
|
||||
// 准备公共动态数据
|
||||
Map<String, String> baseDynamicData = new HashMap<>();
|
||||
if (face.getFaceUrl() != null) {
|
||||
baseDynamicData.put("faceImage", face.getFaceUrl());
|
||||
baseDynamicData.put("userAvatar", face.getFaceUrl());
|
||||
}
|
||||
baseDynamicData.put("faceId", String.valueOf(faceId));
|
||||
baseDynamicData.put("scenicName", scenicBasic.getName());
|
||||
baseDynamicData.put("scenicText", scenicBasic.getName());
|
||||
baseDynamicData.put("dateStr", DateUtil.format(new Date(), "yyyy.MM.dd"));
|
||||
|
||||
// 使用虚拟线程池并行生成所有模板
|
||||
AtomicInteger successCount = new AtomicInteger(0);
|
||||
AtomicInteger failCount = new AtomicInteger(0);
|
||||
|
||||
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
|
||||
// 为每个模板创建一个异步任务
|
||||
List<CompletableFuture<Void>> futures = templateList.stream()
|
||||
.map(template -> CompletableFuture.runAsync(() -> {
|
||||
try {
|
||||
log.info("开始生成拼图: scenicId={}, templateCode={}, templateName={}",
|
||||
scenicId, template.getCode(), template.getName());
|
||||
|
||||
// 构建生成请求
|
||||
PuzzleGenerateRequest generateRequest = new PuzzleGenerateRequest();
|
||||
generateRequest.setScenicId(scenicId);
|
||||
generateRequest.setUserId(memberId);
|
||||
generateRequest.setFaceId(faceId);
|
||||
generateRequest.setBusinessType("face_matching");
|
||||
generateRequest.setTemplateCode(template.getCode());
|
||||
generateRequest.setOutputFormat("PNG");
|
||||
generateRequest.setQuality(90);
|
||||
generateRequest.setDynamicData(new HashMap<>(baseDynamicData));
|
||||
generateRequest.setRequireRuleMatch(true);
|
||||
|
||||
// 调用拼图生成服务
|
||||
PuzzleGenerateResponse response = puzzleGenerateService.generate(generateRequest);
|
||||
|
||||
log.info("拼图生成成功: scenicId={}, templateCode={}, imageUrl={}",
|
||||
scenicId, template.getCode(), response.getImageUrl());
|
||||
successCount.incrementAndGet();
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("拼图生成失败: scenicId={}, templateCode={}, templateName={}",
|
||||
scenicId, template.getCode(), template.getName(), e);
|
||||
failCount.incrementAndGet();
|
||||
}
|
||||
}, executor))
|
||||
.toList();
|
||||
|
||||
// 等待所有任务完成
|
||||
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
|
||||
}
|
||||
|
||||
log.info("景区拼图模板批量生成完成: scenicId={}, 总数={}, 成功={}, 失败={}",
|
||||
scenicId, templateList.size(), successCount.get(), failCount.get());
|
||||
|
||||
} catch (Exception e) {
|
||||
// 异步任务失败不影响主流程,仅记录日志
|
||||
log.error("异步生成拼图模板失败: scenicId={}, faceId={}", scenicId, faceId, e);
|
||||
if (templateList == null || templateList.isEmpty()) {
|
||||
log.info("景区不存在启用的拼图模板,跳过生成: scenicId={}", scenicId);
|
||||
return;
|
||||
}
|
||||
}, "PuzzleTemplateGenerator-" + scenicId);
|
||||
thread.start();
|
||||
return thread;
|
||||
|
||||
log.info("景区存在 {} 个启用的拼图模板,开始逐个生成: scenicId={}", templateList.size(), scenicId);
|
||||
|
||||
// 获取人脸信息用于动态数据
|
||||
FaceEntity face = faceRepository.getFace(faceId);
|
||||
if (face == null) {
|
||||
log.warn("人脸信息不存在,无法生成拼图: faceId={}", faceId);
|
||||
return;
|
||||
}
|
||||
ScenicV2DTO scenicBasic = scenicRepository.getScenicBasic(face.getScenicId());
|
||||
|
||||
// 准备公共动态数据
|
||||
Map<String, String> baseDynamicData = new HashMap<>();
|
||||
if (face.getFaceUrl() != null) {
|
||||
baseDynamicData.put("faceImage", face.getFaceUrl());
|
||||
baseDynamicData.put("userAvatar", face.getFaceUrl());
|
||||
}
|
||||
baseDynamicData.put("faceId", String.valueOf(faceId));
|
||||
baseDynamicData.put("scenicName", scenicBasic.getName());
|
||||
baseDynamicData.put("scenicText", scenicBasic.getName());
|
||||
baseDynamicData.put("dateStr", DateUtil.format(new Date(), "yyyy.MM.dd"));
|
||||
|
||||
templateList
|
||||
.forEach(template -> {
|
||||
log.info("开始生成拼图: scenicId={}, templateCode={}, templateName={}",
|
||||
scenicId, template.getCode(), template.getName());
|
||||
|
||||
// 构建生成请求
|
||||
PuzzleGenerateRequest generateRequest = new PuzzleGenerateRequest();
|
||||
generateRequest.setScenicId(scenicId);
|
||||
generateRequest.setUserId(memberId);
|
||||
generateRequest.setFaceId(faceId);
|
||||
generateRequest.setBusinessType("face_matching");
|
||||
generateRequest.setTemplateCode(template.getCode());
|
||||
generateRequest.setOutputFormat("PNG");
|
||||
generateRequest.setQuality(90);
|
||||
generateRequest.setDynamicData(new HashMap<>(baseDynamicData));
|
||||
generateRequest.setRequireRuleMatch(true);
|
||||
if (template.getAutoAddPrint() > 0 && Strings.CI.equals(scene, "printer")) {
|
||||
puzzleGenerateService.generateSync(generateRequest);
|
||||
} else {
|
||||
puzzleGenerateService.generateAsync(generateRequest);
|
||||
}
|
||||
});
|
||||
} catch (Exception e) {
|
||||
// 异步任务失败不影响主流程,仅记录日志
|
||||
log.error("异步生成拼图模板失败: scenicId={}, faceId={}", scenicId, faceId, e);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user