You've already forked FrameTour-BE
Merge branch 'puzzle_edge_w'
# Conflicts: # src/main/java/com/ycwl/basic/config/WebMvcConfig.java
This commit is contained in:
@@ -3,6 +3,7 @@ package com.ycwl.basic.config;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.module.SimpleModule;
|
||||
import com.ycwl.basic.interceptor.AuthInterceptor;
|
||||
import com.ycwl.basic.puzzle.edge.interceptor.PuzzleEdgeWorkerIpInterceptor;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
@@ -26,9 +27,13 @@ import java.util.List;
|
||||
public class WebMvcConfig implements WebMvcConfigurer {
|
||||
@Autowired
|
||||
private AuthInterceptor authInterceptor;
|
||||
@Autowired
|
||||
private PuzzleEdgeWorkerIpInterceptor puzzleEdgeWorkerIpInterceptor;
|
||||
|
||||
@Override
|
||||
public void addInterceptors(InterceptorRegistry registry) {
|
||||
registry.addInterceptor(puzzleEdgeWorkerIpInterceptor)
|
||||
.addPathPatterns("/puzzle/render/v1/**");
|
||||
registry.addInterceptor(authInterceptor)
|
||||
// 拦截除指定接口外的所有请求,通过判断 注解 来决定是否需要做登录验证
|
||||
.addPathPatterns("/**")
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.ycwl.basic.puzzle.edge.config;
|
||||
|
||||
import lombok.Data;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* 边缘 Worker 接入安全配置
|
||||
*/
|
||||
@Data
|
||||
@Component
|
||||
@ConfigurationProperties(prefix = "puzzle.edge.worker.security")
|
||||
public class PuzzleEdgeWorkerSecurityProperties {
|
||||
|
||||
/**
|
||||
* 是否启用访问 IP 校验
|
||||
*/
|
||||
private boolean enabled = true;
|
||||
|
||||
/**
|
||||
* 允许访问的 IPv4 CIDR(默认:100.64.0.0/24)
|
||||
*/
|
||||
private String allowedIpCidr = "100.64.0.0/24";
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package com.ycwl.basic.puzzle.edge.controller;
|
||||
|
||||
import com.ycwl.basic.annotation.IgnoreToken;
|
||||
import com.ycwl.basic.puzzle.edge.dto.PuzzleEdgeTaskFailRequest;
|
||||
import com.ycwl.basic.puzzle.edge.dto.PuzzleEdgeTaskSuccessRequest;
|
||||
import com.ycwl.basic.puzzle.edge.dto.PuzzleEdgeUploadUrlsResponse;
|
||||
import com.ycwl.basic.puzzle.edge.dto.PuzzleEdgeWorkerAuthRequest;
|
||||
import com.ycwl.basic.puzzle.edge.dto.PuzzleEdgeWorkerSyncRequest;
|
||||
import com.ycwl.basic.puzzle.edge.dto.PuzzleEdgeWorkerSyncResponse;
|
||||
import com.ycwl.basic.puzzle.edge.task.PuzzleEdgeRenderTaskService;
|
||||
import com.ycwl.basic.utils.ApiResponse;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* Puzzle 边缘渲染对接接口
|
||||
* 模式:边缘客户端拉取任务(含上传地址) → 上传 → 上报结果
|
||||
*/
|
||||
@Slf4j
|
||||
@IgnoreToken
|
||||
@RestController
|
||||
@RequestMapping("/puzzle/render/v1")
|
||||
@RequiredArgsConstructor
|
||||
public class PuzzleEdgeRenderTaskController {
|
||||
|
||||
private final PuzzleEdgeRenderTaskService puzzleEdgeRenderTaskService;
|
||||
|
||||
@PostMapping("/worker/sync")
|
||||
public ApiResponse<PuzzleEdgeWorkerSyncResponse> sync(@RequestBody PuzzleEdgeWorkerSyncRequest req) {
|
||||
return ApiResponse.success(puzzleEdgeRenderTaskService.sync(req));
|
||||
}
|
||||
|
||||
@PostMapping("/task/{taskId}/uploadUrls")
|
||||
public ApiResponse<PuzzleEdgeUploadUrlsResponse> uploadUrls(@PathVariable Long taskId,
|
||||
@RequestBody PuzzleEdgeWorkerAuthRequest req) {
|
||||
return ApiResponse.success(puzzleEdgeRenderTaskService.getUploadUrls(taskId, req != null ? req.getAccessKey() : null));
|
||||
}
|
||||
|
||||
@PostMapping("/task/{taskId}/success")
|
||||
public ApiResponse<String> success(@PathVariable Long taskId, @RequestBody PuzzleEdgeTaskSuccessRequest req) {
|
||||
puzzleEdgeRenderTaskService.taskSuccess(taskId, req);
|
||||
return ApiResponse.success("OK");
|
||||
}
|
||||
|
||||
@PostMapping("/task/{taskId}/fail")
|
||||
public ApiResponse<String> fail(@PathVariable Long taskId, @RequestBody PuzzleEdgeTaskFailRequest req) {
|
||||
puzzleEdgeRenderTaskService.taskFail(taskId, req);
|
||||
return ApiResponse.success("OK");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.ycwl.basic.puzzle.edge.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
@Data
|
||||
public class PuzzleEdgeRenderTaskDTO {
|
||||
private Long id;
|
||||
private Long recordId;
|
||||
private Long templateId;
|
||||
private String templateCode;
|
||||
private Long scenicId;
|
||||
private Long faceId;
|
||||
private Integer attemptCount;
|
||||
private String outputFormat;
|
||||
private Integer outputQuality;
|
||||
private Map<String, Object> payload;
|
||||
/**
|
||||
* 上传地址(预签名URL),用于 Worker 直接上传产物
|
||||
*/
|
||||
private PuzzleEdgeUploadUrlsResponse upload;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.ycwl.basic.puzzle.edge.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class PuzzleEdgeTaskFailRequest {
|
||||
private String accessKey;
|
||||
private String errorMessage;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.ycwl.basic.puzzle.edge.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class PuzzleEdgeTaskSuccessRequest {
|
||||
private String accessKey;
|
||||
|
||||
private Long resultFileSize;
|
||||
private Integer resultWidth;
|
||||
private Integer resultHeight;
|
||||
private Integer renderDurationMs;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.ycwl.basic.puzzle.edge.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.Date;
|
||||
import java.util.Map;
|
||||
|
||||
@Data
|
||||
public class PuzzleEdgeUploadUrlsResponse {
|
||||
private UploadTarget original;
|
||||
private UploadTarget cropped;
|
||||
private Date expireAt;
|
||||
|
||||
@Data
|
||||
public static class UploadTarget {
|
||||
private String method;
|
||||
private String url;
|
||||
private String publicUrl;
|
||||
private Map<String, String> headers;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.ycwl.basic.puzzle.edge.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class PuzzleEdgeWorkerAuthRequest {
|
||||
private String accessKey;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.ycwl.basic.puzzle.edge.dto;
|
||||
|
||||
import com.ycwl.basic.model.task.req.ClientStatusReqVo;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class PuzzleEdgeWorkerSyncRequest {
|
||||
private String accessKey;
|
||||
private Integer maxTasks;
|
||||
private ClientStatusReqVo clientStatus;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.ycwl.basic.puzzle.edge.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
public class PuzzleEdgeWorkerSyncResponse {
|
||||
private List<PuzzleEdgeRenderTaskDTO> tasks = new ArrayList<>();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
package com.ycwl.basic.puzzle.edge.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.Date;
|
||||
|
||||
/**
|
||||
* Puzzle 边缘渲染任务实体
|
||||
* 对应表:puzzle_edge_render_task
|
||||
*/
|
||||
@Data
|
||||
@TableName("puzzle_edge_render_task")
|
||||
public class PuzzleEdgeRenderTaskEntity {
|
||||
|
||||
@TableId(value = "id", type = IdType.AUTO)
|
||||
private Long id;
|
||||
|
||||
@TableField("record_id")
|
||||
private Long recordId;
|
||||
|
||||
@TableField("template_id")
|
||||
private Long templateId;
|
||||
|
||||
@TableField("template_code")
|
||||
private String templateCode;
|
||||
|
||||
@TableField("scenic_id")
|
||||
private Long scenicId;
|
||||
|
||||
@TableField("face_id")
|
||||
private Long faceId;
|
||||
|
||||
@TableField("content_hash")
|
||||
private String contentHash;
|
||||
|
||||
@TableField("status")
|
||||
private Integer status;
|
||||
|
||||
@TableField("worker_id")
|
||||
private Long workerId;
|
||||
|
||||
@TableField("lease_expire_time")
|
||||
private Date leaseExpireTime;
|
||||
|
||||
@TableField("attempt_count")
|
||||
private Integer attemptCount;
|
||||
|
||||
@TableField("output_format")
|
||||
private String outputFormat;
|
||||
|
||||
@TableField("output_quality")
|
||||
private Integer outputQuality;
|
||||
|
||||
@TableField("original_object_key")
|
||||
private String originalObjectKey;
|
||||
|
||||
@TableField("cropped_object_key")
|
||||
private String croppedObjectKey;
|
||||
|
||||
@TableField("payload_json")
|
||||
private String payloadJson;
|
||||
|
||||
@TableField("error_message")
|
||||
private String errorMessage;
|
||||
|
||||
@TableField("create_time")
|
||||
private Date createTime;
|
||||
|
||||
@TableField("update_time")
|
||||
private Date updateTime;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.ycwl.basic.puzzle.edge.interceptor;
|
||||
|
||||
import com.ycwl.basic.puzzle.edge.config.PuzzleEdgeWorkerSecurityProperties;
|
||||
import com.ycwl.basic.utils.ApiResponse;
|
||||
import com.ycwl.basic.utils.IpUtils;
|
||||
import com.ycwl.basic.utils.Ipv4CidrMatcher;
|
||||
import com.ycwl.basic.utils.JacksonUtil;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.servlet.HandlerInterceptor;
|
||||
|
||||
/**
|
||||
* 边缘 Worker 接口访问 IP 校验
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class PuzzleEdgeWorkerIpInterceptor implements HandlerInterceptor {
|
||||
|
||||
private static final String FORBIDDEN_MESSAGE = "非法来源IP";
|
||||
|
||||
private final PuzzleEdgeWorkerSecurityProperties properties;
|
||||
|
||||
@Override
|
||||
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
|
||||
if (properties == null || !properties.isEnabled()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
String clientIp = IpUtils.getIpAddr(request);
|
||||
if (Ipv4CidrMatcher.matches(clientIp, properties.getAllowedIpCidr())) {
|
||||
return true;
|
||||
}
|
||||
|
||||
log.warn("拒绝边缘 Worker 请求: uri={}, ip={}, allowedIpCidr={}",
|
||||
request != null ? request.getRequestURI() : null,
|
||||
clientIp,
|
||||
properties.getAllowedIpCidr());
|
||||
|
||||
response.setStatus(HttpStatus.FORBIDDEN.value());
|
||||
response.setCharacterEncoding("UTF-8");
|
||||
response.setContentType("application/json;charset=UTF-8");
|
||||
response.getWriter().write(JacksonUtil.toJson(ApiResponse.buildResponse(HttpStatus.FORBIDDEN.value(), FORBIDDEN_MESSAGE)));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.ycwl.basic.puzzle.edge.mapper;
|
||||
|
||||
import com.ycwl.basic.puzzle.edge.entity.PuzzleEdgeRenderTaskEntity;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
import java.util.Date;
|
||||
|
||||
@Mapper
|
||||
public interface PuzzleEdgeRenderTaskMapper {
|
||||
|
||||
PuzzleEdgeRenderTaskEntity getById(@Param("id") Long id);
|
||||
|
||||
int insert(PuzzleEdgeRenderTaskEntity entity);
|
||||
|
||||
/**
|
||||
* 获取下一条可领取任务ID:PENDING 或 RUNNING但租约已过期
|
||||
*/
|
||||
Long findNextClaimableTaskId();
|
||||
|
||||
/**
|
||||
* 领取任务(并写入租约与attempt)
|
||||
*/
|
||||
int claimTask(@Param("taskId") Long taskId,
|
||||
@Param("workerId") Long workerId,
|
||||
@Param("leaseExpireTime") Date leaseExpireTime);
|
||||
|
||||
int markSuccess(@Param("taskId") Long taskId, @Param("workerId") Long workerId);
|
||||
|
||||
int markFail(@Param("taskId") Long taskId,
|
||||
@Param("workerId") Long workerId,
|
||||
@Param("errorMessage") String errorMessage);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,593 @@
|
||||
package com.ycwl.basic.puzzle.edge.task;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import com.github.benmanes.caffeine.cache.Cache;
|
||||
import com.github.benmanes.caffeine.cache.Caffeine;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.ycwl.basic.model.pc.renderWorker.entity.RenderWorkerEntity;
|
||||
import com.ycwl.basic.model.task.req.ClientStatusReqVo;
|
||||
import com.ycwl.basic.puzzle.edge.dto.PuzzleEdgeRenderTaskDTO;
|
||||
import com.ycwl.basic.puzzle.edge.dto.PuzzleEdgeTaskFailRequest;
|
||||
import com.ycwl.basic.puzzle.edge.dto.PuzzleEdgeTaskSuccessRequest;
|
||||
import com.ycwl.basic.puzzle.edge.dto.PuzzleEdgeUploadUrlsResponse;
|
||||
import com.ycwl.basic.puzzle.edge.dto.PuzzleEdgeWorkerSyncRequest;
|
||||
import com.ycwl.basic.puzzle.edge.dto.PuzzleEdgeWorkerSyncResponse;
|
||||
import com.ycwl.basic.puzzle.edge.entity.PuzzleEdgeRenderTaskEntity;
|
||||
import com.ycwl.basic.puzzle.entity.PuzzleElementEntity;
|
||||
import com.ycwl.basic.puzzle.entity.PuzzleGenerationRecordEntity;
|
||||
import com.ycwl.basic.puzzle.entity.PuzzleTemplateEntity;
|
||||
import com.ycwl.basic.puzzle.mapper.PuzzleGenerationRecordMapper;
|
||||
import com.ycwl.basic.puzzle.repository.PuzzleRepository;
|
||||
import com.ycwl.basic.repository.RenderWorkerRepository;
|
||||
import com.ycwl.basic.service.printer.PrinterService;
|
||||
import com.ycwl.basic.storage.StorageFactory;
|
||||
import com.ycwl.basic.storage.adapters.IStorageAdapter;
|
||||
import com.ycwl.basic.utils.JacksonUtil;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
|
||||
import java.util.Date;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
|
||||
/**
|
||||
* Puzzle 边缘渲染任务服务(中心端)
|
||||
* 负责:任务创建、任务拉取、签发上传URL、回报成功/失败落库
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class PuzzleEdgeRenderTaskService {
|
||||
|
||||
private static final int STATUS_PENDING = 0;
|
||||
private static final int STATUS_RUNNING = 1;
|
||||
private static final int STATUS_SUCCESS = 2;
|
||||
private static final int STATUS_FAIL = 3;
|
||||
|
||||
private static final int MAX_SYNC_TASKS = 5;
|
||||
private static final long LEASE_MILLIS = TimeUnit.SECONDS.toMillis(20);
|
||||
private static final long UPLOAD_URL_EXPIRE_MILLIS = TimeUnit.HOURS.toMillis(1);
|
||||
private static final int MAX_RETRY_ATTEMPTS = 3;
|
||||
|
||||
private static final long TASK_CACHE_EXPIRE_HOURS = 6L;
|
||||
private static final long TASK_CACHE_MAX_SIZE = 20000L;
|
||||
|
||||
/**
|
||||
* 任务内存池(单实例、允许丢失):仅用作 Worker 拉取与状态落地的中间态
|
||||
*/
|
||||
private final Cache<Long, PuzzleEdgeRenderTaskEntity> taskCache = Caffeine.newBuilder()
|
||||
.expireAfterWrite(TASK_CACHE_EXPIRE_HOURS, TimeUnit.HOURS)
|
||||
.maximumSize(TASK_CACHE_MAX_SIZE)
|
||||
.build();
|
||||
|
||||
private final AtomicLong taskIdSequence = new AtomicLong(System.currentTimeMillis());
|
||||
private final Object taskLock = new Object();
|
||||
|
||||
private final PuzzleGenerationRecordMapper recordMapper;
|
||||
private final PuzzleRepository puzzleRepository;
|
||||
private final PrinterService printerService;
|
||||
private final RenderWorkerRepository renderWorkerRepository;
|
||||
|
||||
public PuzzleEdgeWorkerSyncResponse sync(PuzzleEdgeWorkerSyncRequest req) {
|
||||
RenderWorkerEntity worker = requireWorker(req != null ? req.getAccessKey() : null);
|
||||
|
||||
ClientStatusReqVo clientStatus = req != null ? req.getClientStatus() : null;
|
||||
if (clientStatus != null) {
|
||||
renderWorkerRepository.setWorkerHostStatus(worker.getId(), clientStatus);
|
||||
}
|
||||
|
||||
int maxTasks = req != null && req.getMaxTasks() != null ? req.getMaxTasks() : 1;
|
||||
if (maxTasks <= 0) {
|
||||
maxTasks = 1;
|
||||
}
|
||||
if (maxTasks > MAX_SYNC_TASKS) {
|
||||
maxTasks = MAX_SYNC_TASKS;
|
||||
}
|
||||
|
||||
PuzzleEdgeWorkerSyncResponse resp = new PuzzleEdgeWorkerSyncResponse();
|
||||
for (int i = 0; i < maxTasks; i++) {
|
||||
PuzzleEdgeRenderTaskEntity task = claimOne(worker.getId());
|
||||
if (task == null) {
|
||||
break;
|
||||
}
|
||||
|
||||
PuzzleEdgeRenderTaskDTO dto = toTaskDTOOrFail(task, worker.getId());
|
||||
if (dto != null) {
|
||||
resp.getTasks().add(dto);
|
||||
}
|
||||
}
|
||||
|
||||
return resp;
|
||||
}
|
||||
|
||||
public PuzzleEdgeUploadUrlsResponse getUploadUrls(Long taskId, String accessKey) {
|
||||
RenderWorkerEntity worker = requireWorker(accessKey);
|
||||
PuzzleEdgeRenderTaskEntity task = getAndCheckRunningTask(taskId, worker.getId());
|
||||
return buildUploadUrls(task);
|
||||
}
|
||||
|
||||
public void taskSuccess(Long taskId, PuzzleEdgeTaskSuccessRequest req) {
|
||||
RenderWorkerEntity worker = requireWorker(req != null ? req.getAccessKey() : null);
|
||||
PuzzleEdgeRenderTaskEntity task = getAndCheckTaskOwned(taskId, worker.getId());
|
||||
if (task.getStatus() != null && task.getStatus() == STATUS_SUCCESS) {
|
||||
return;
|
||||
}
|
||||
if (task.getStatus() == null || task.getStatus() != STATUS_RUNNING) {
|
||||
throw new IllegalArgumentException("任务状态非法");
|
||||
}
|
||||
|
||||
boolean updated = tryMarkSuccess(task, worker.getId());
|
||||
if (!updated) {
|
||||
throw new IllegalStateException("任务状态更新失败");
|
||||
}
|
||||
|
||||
PuzzleGenerationRecordEntity record = recordMapper.getById(task.getRecordId());
|
||||
if (record == null) {
|
||||
log.warn("边缘渲染任务回报成功,但生成记录不存在: taskId={}, recordId={}", taskId, task.getRecordId());
|
||||
return;
|
||||
}
|
||||
|
||||
IStorageAdapter storage = StorageFactory.use();
|
||||
String originalImageUrl = storage.getUrl(task.getOriginalObjectKey());
|
||||
String resultImageUrl = StrUtil.isNotBlank(task.getCroppedObjectKey())
|
||||
? storage.getUrl(task.getCroppedObjectKey())
|
||||
: originalImageUrl;
|
||||
|
||||
Long resultFileSize = req != null ? req.getResultFileSize() : null;
|
||||
Integer resultWidth = req != null ? req.getResultWidth() : null;
|
||||
Integer resultHeight = req != null ? req.getResultHeight() : null;
|
||||
Integer renderDurationMs = req != null ? req.getRenderDurationMs() : null;
|
||||
|
||||
recordMapper.updateSuccess(
|
||||
record.getId(),
|
||||
resultImageUrl,
|
||||
originalImageUrl,
|
||||
resultFileSize,
|
||||
resultWidth,
|
||||
resultHeight,
|
||||
renderDurationMs
|
||||
);
|
||||
|
||||
PuzzleTemplateEntity template = puzzleRepository.getTemplateById(task.getTemplateId());
|
||||
if (template != null && template.getAutoAddPrint() != null && template.getAutoAddPrint() == 1) {
|
||||
try {
|
||||
Integer printRecordId = printerService.addUserPhotoFromPuzzle(
|
||||
record.getUserId(),
|
||||
record.getScenicId(),
|
||||
record.getFaceId(),
|
||||
originalImageUrl,
|
||||
record.getId()
|
||||
);
|
||||
log.info("自动添加到打印队列成功: recordId={}, printRecordId={}", record.getId(), printRecordId);
|
||||
} catch (Exception e) {
|
||||
log.error("自动添加到打印队列失败: recordId={}", record.getId(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void taskFail(Long taskId, PuzzleEdgeTaskFailRequest req) {
|
||||
RenderWorkerEntity worker = requireWorker(req != null ? req.getAccessKey() : null);
|
||||
PuzzleEdgeRenderTaskEntity task = getAndCheckTaskOwned(taskId, worker.getId());
|
||||
if (task.getStatus() != null && task.getStatus() == STATUS_FAIL) {
|
||||
return;
|
||||
}
|
||||
if (task.getStatus() == null || task.getStatus() != STATUS_RUNNING) {
|
||||
throw new IllegalArgumentException("任务状态非法");
|
||||
}
|
||||
|
||||
String errorMessage = req != null && StrUtil.isNotBlank(req.getErrorMessage())
|
||||
? req.getErrorMessage()
|
||||
: "边缘渲染失败";
|
||||
|
||||
boolean updated = tryMarkFail(task, worker.getId(), errorMessage);
|
||||
if (!updated) {
|
||||
throw new IllegalStateException("任务状态更新失败");
|
||||
}
|
||||
recordMapper.updateFail(task.getRecordId(), errorMessage);
|
||||
}
|
||||
|
||||
/**
|
||||
* 运行超时自动失败并重试:
|
||||
* - task.status=RUNNING 且 leaseExpireTime 已过期:视为本次尝试超时
|
||||
* - attemptCount < MAX_RETRY_ATTEMPTS:重置为 PENDING 等待重试
|
||||
* - attemptCount >= MAX_RETRY_ATTEMPTS:最终失败并落库
|
||||
*/
|
||||
@Scheduled(fixedDelay = 1000L)
|
||||
public void timeoutFailAndRetry() {
|
||||
List<Long> retryRecordIds = new ArrayList<>();
|
||||
Map<Long, String> failRecordMessages = new HashMap<>();
|
||||
|
||||
synchronized (taskLock) {
|
||||
long now = System.currentTimeMillis();
|
||||
for (PuzzleEdgeRenderTaskEntity task : taskCache.asMap().values()) {
|
||||
if (task == null || task.getId() == null || task.getStatus() == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (task.getStatus() != STATUS_RUNNING) {
|
||||
continue;
|
||||
}
|
||||
|
||||
Date leaseExpireTime = task.getLeaseExpireTime();
|
||||
if (leaseExpireTime == null || leaseExpireTime.getTime() >= now) {
|
||||
continue;
|
||||
}
|
||||
|
||||
int attemptCount = task.getAttemptCount() != null ? task.getAttemptCount() : 0;
|
||||
if (attemptCount >= MAX_RETRY_ATTEMPTS) {
|
||||
String errorMessage = String.format("边缘渲染任务超时(%d秒),重试次数耗尽: attemptCount=%d",
|
||||
TimeUnit.MILLISECONDS.toSeconds(LEASE_MILLIS), attemptCount);
|
||||
log.warn("边缘渲染任务最终失败: taskId={}, recordId={}, {}", task.getId(), task.getRecordId(), errorMessage);
|
||||
|
||||
task.setStatus(STATUS_FAIL);
|
||||
task.setWorkerId(null);
|
||||
task.setLeaseExpireTime(null);
|
||||
task.setErrorMessage(errorMessage);
|
||||
task.setUpdateTime(new Date(now));
|
||||
if (task.getRecordId() != null) {
|
||||
failRecordMessages.put(task.getRecordId(), errorMessage);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
log.warn("边缘渲染任务超时,准备重试: taskId={}, recordId={}, attemptCount={}",
|
||||
task.getId(), task.getRecordId(), attemptCount);
|
||||
|
||||
task.setStatus(STATUS_PENDING);
|
||||
task.setWorkerId(null);
|
||||
task.setLeaseExpireTime(null);
|
||||
task.setErrorMessage("边缘渲染任务超时,等待重试");
|
||||
task.setUpdateTime(new Date(now));
|
||||
if (task.getRecordId() != null) {
|
||||
retryRecordIds.add(task.getRecordId());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (Long recordId : retryRecordIds) {
|
||||
incrementRecordRetryCount(recordId);
|
||||
}
|
||||
|
||||
for (Map.Entry<Long, String> entry : failRecordMessages.entrySet()) {
|
||||
recordMapper.updateFail(entry.getKey(), entry.getValue());
|
||||
}
|
||||
}
|
||||
|
||||
private void incrementRecordRetryCount(Long recordId) {
|
||||
if (recordId == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
PuzzleGenerationRecordEntity record = recordMapper.getById(recordId);
|
||||
if (record == null) {
|
||||
return;
|
||||
}
|
||||
int currentRetryCount = record.getRetryCount() != null ? record.getRetryCount() : 0;
|
||||
PuzzleGenerationRecordEntity update = new PuzzleGenerationRecordEntity();
|
||||
update.setId(recordId);
|
||||
update.setRetryCount(currentRetryCount + 1);
|
||||
recordMapper.update(update);
|
||||
} catch (Exception e) {
|
||||
log.warn("更新生成记录重试次数失败: recordId={}, error={}", recordId, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建边缘渲染任务(供中心业务侧调用)
|
||||
*/
|
||||
public Long createRenderTask(PuzzleGenerationRecordEntity record,
|
||||
PuzzleTemplateEntity template,
|
||||
List<PuzzleElementEntity> sortedElements,
|
||||
Map<String, String> finalDynamicData,
|
||||
String outputFormat,
|
||||
Integer quality) {
|
||||
if (record == null || record.getId() == null) {
|
||||
throw new IllegalArgumentException("record不能为空");
|
||||
}
|
||||
if (template == null || template.getId() == null) {
|
||||
throw new IllegalArgumentException("template不能为空");
|
||||
}
|
||||
if (sortedElements == null) {
|
||||
sortedElements = List.of();
|
||||
}
|
||||
if (finalDynamicData == null) {
|
||||
finalDynamicData = Map.of();
|
||||
}
|
||||
|
||||
String normalizedFormat = normalizeOutputFormat(outputFormat);
|
||||
Integer outputQuality = quality != null ? quality : 90;
|
||||
String ext = "PNG".equals(normalizedFormat) ? "png" : "jpeg";
|
||||
String fileName = UUID.randomUUID().toString().replace("-", "") + "." + ext;
|
||||
|
||||
String originalObjectKey = String.format("puzzle/%s/%s", template.getCode(), fileName);
|
||||
String croppedObjectKey = StrUtil.isNotBlank(template.getUserArea())
|
||||
? String.format("puzzle/%s_cropped/%s", template.getCode(), fileName)
|
||||
: null;
|
||||
|
||||
Map<String, Object> payload = new HashMap<>();
|
||||
payload.put("recordId", record.getId());
|
||||
|
||||
Map<String, Object> templatePayload = new HashMap<>();
|
||||
templatePayload.put("id", template.getId());
|
||||
templatePayload.put("code", template.getCode());
|
||||
templatePayload.put("canvasWidth", template.getCanvasWidth());
|
||||
templatePayload.put("canvasHeight", template.getCanvasHeight());
|
||||
templatePayload.put("backgroundType", template.getBackgroundType());
|
||||
templatePayload.put("backgroundColor", template.getBackgroundColor());
|
||||
templatePayload.put("backgroundImage", template.getBackgroundImage());
|
||||
templatePayload.put("userArea", template.getUserArea());
|
||||
payload.put("template", templatePayload);
|
||||
|
||||
List<Map<String, Object>> elementPayloadList = new ArrayList<>();
|
||||
for (PuzzleElementEntity e : sortedElements) {
|
||||
Map<String, Object> elementPayload = new HashMap<>();
|
||||
elementPayload.put("id", e.getId());
|
||||
elementPayload.put("type", e.getElementType());
|
||||
elementPayload.put("key", e.getElementKey());
|
||||
elementPayload.put("name", e.getElementName());
|
||||
elementPayload.put("x", e.getXPosition());
|
||||
elementPayload.put("y", e.getYPosition());
|
||||
elementPayload.put("width", e.getWidth());
|
||||
elementPayload.put("height", e.getHeight());
|
||||
elementPayload.put("zIndex", e.getZIndex());
|
||||
elementPayload.put("rotation", e.getRotation());
|
||||
elementPayload.put("opacity", e.getOpacity());
|
||||
elementPayload.put("config", e.getConfig());
|
||||
elementPayloadList.add(elementPayload);
|
||||
}
|
||||
payload.put("elements", elementPayloadList);
|
||||
payload.put("dynamicData", finalDynamicData);
|
||||
|
||||
Map<String, Object> outputPayload = new HashMap<>();
|
||||
outputPayload.put("format", normalizedFormat);
|
||||
outputPayload.put("quality", outputQuality);
|
||||
payload.put("output", outputPayload);
|
||||
|
||||
PuzzleEdgeRenderTaskEntity task = new PuzzleEdgeRenderTaskEntity();
|
||||
task.setRecordId(record.getId());
|
||||
task.setTemplateId(template.getId());
|
||||
task.setTemplateCode(template.getCode());
|
||||
task.setScenicId(record.getScenicId());
|
||||
task.setFaceId(record.getFaceId());
|
||||
task.setContentHash(record.getContentHash());
|
||||
task.setStatus(STATUS_PENDING);
|
||||
task.setAttemptCount(0);
|
||||
task.setOutputFormat(normalizedFormat);
|
||||
task.setOutputQuality(outputQuality);
|
||||
task.setOriginalObjectKey(originalObjectKey);
|
||||
task.setCroppedObjectKey(croppedObjectKey);
|
||||
task.setPayloadJson(JacksonUtil.toJson(payload));
|
||||
|
||||
Long taskId = taskIdSequence.incrementAndGet();
|
||||
Date now = new Date();
|
||||
task.setId(taskId);
|
||||
task.setCreateTime(now);
|
||||
task.setUpdateTime(now);
|
||||
|
||||
taskCache.put(taskId, task);
|
||||
return taskId;
|
||||
}
|
||||
|
||||
private PuzzleEdgeRenderTaskDTO toTaskDTOOrFail(PuzzleEdgeRenderTaskEntity task, Long workerId) {
|
||||
try {
|
||||
PuzzleEdgeRenderTaskDTO dto = new PuzzleEdgeRenderTaskDTO();
|
||||
dto.setId(task.getId());
|
||||
dto.setRecordId(task.getRecordId());
|
||||
dto.setTemplateId(task.getTemplateId());
|
||||
dto.setTemplateCode(task.getTemplateCode());
|
||||
dto.setScenicId(task.getScenicId());
|
||||
dto.setFaceId(task.getFaceId());
|
||||
dto.setAttemptCount(task.getAttemptCount());
|
||||
dto.setOutputFormat(task.getOutputFormat());
|
||||
dto.setOutputQuality(task.getOutputQuality());
|
||||
dto.setPayload(JacksonUtil.fromJson(task.getPayloadJson(), new TypeReference<Map<String, Object>>() {}));
|
||||
dto.setUpload(buildUploadUrls(task));
|
||||
return dto;
|
||||
} catch (Exception e) {
|
||||
String errorMessage = "任务数据组装失败: " + e.getMessage();
|
||||
log.error("边缘渲染任务组装失败: taskId={}, recordId={}", task.getId(), task.getRecordId(), e);
|
||||
tryMarkFail(task, workerId, errorMessage);
|
||||
recordMapper.updateFail(task.getRecordId(), errorMessage);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private PuzzleEdgeUploadUrlsResponse buildUploadUrls(PuzzleEdgeRenderTaskEntity task) {
|
||||
String outputFormat = normalizeOutputFormat(task.getOutputFormat());
|
||||
String contentType = resolveContentType(outputFormat);
|
||||
Date expireAt = new Date(System.currentTimeMillis() + UPLOAD_URL_EXPIRE_MILLIS);
|
||||
|
||||
IStorageAdapter storage = StorageFactory.use();
|
||||
|
||||
PuzzleEdgeUploadUrlsResponse resp = new PuzzleEdgeUploadUrlsResponse();
|
||||
resp.setExpireAt(expireAt);
|
||||
|
||||
Map<String, String> headers = Map.of("Content-Type", contentType);
|
||||
|
||||
PuzzleEdgeUploadUrlsResponse.UploadTarget original = new PuzzleEdgeUploadUrlsResponse.UploadTarget();
|
||||
original.setMethod("PUT");
|
||||
original.setHeaders(headers);
|
||||
original.setPublicUrl(storage.getUrl(task.getOriginalObjectKey()));
|
||||
original.setUrl(storage.getUrlForUpload(expireAt, contentType, task.getOriginalObjectKey()));
|
||||
resp.setOriginal(original);
|
||||
|
||||
if (StrUtil.isNotBlank(task.getCroppedObjectKey())) {
|
||||
PuzzleEdgeUploadUrlsResponse.UploadTarget cropped = new PuzzleEdgeUploadUrlsResponse.UploadTarget();
|
||||
cropped.setMethod("PUT");
|
||||
cropped.setHeaders(headers);
|
||||
cropped.setPublicUrl(storage.getUrl(task.getCroppedObjectKey()));
|
||||
cropped.setUrl(storage.getUrlForUpload(expireAt, contentType, task.getCroppedObjectKey()));
|
||||
resp.setCropped(cropped);
|
||||
}
|
||||
|
||||
return resp;
|
||||
}
|
||||
|
||||
private PuzzleEdgeRenderTaskEntity claimOne(Long workerId) {
|
||||
synchronized (taskLock) {
|
||||
long now = System.currentTimeMillis();
|
||||
|
||||
Long selectedTaskId = null;
|
||||
for (Map.Entry<Long, PuzzleEdgeRenderTaskEntity> entry : taskCache.asMap().entrySet()) {
|
||||
PuzzleEdgeRenderTaskEntity task = entry.getValue();
|
||||
if (!isClaimable(task, now)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
Long id = entry.getKey();
|
||||
if (selectedTaskId == null || id < selectedTaskId) {
|
||||
selectedTaskId = id;
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedTaskId == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
PuzzleEdgeRenderTaskEntity task = taskCache.getIfPresent(selectedTaskId);
|
||||
if (!isClaimable(task, now)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
Date leaseExpireTime = new Date(now + LEASE_MILLIS);
|
||||
task.setWorkerId(workerId);
|
||||
task.setStatus(STATUS_RUNNING);
|
||||
task.setLeaseExpireTime(leaseExpireTime);
|
||||
task.setAttemptCount((task.getAttemptCount() != null ? task.getAttemptCount() : 0) + 1);
|
||||
task.setUpdateTime(new Date(now));
|
||||
return task;
|
||||
}
|
||||
}
|
||||
|
||||
private PuzzleEdgeRenderTaskEntity getAndCheckRunningTask(Long taskId, Long workerId) {
|
||||
if (taskId == null) {
|
||||
throw new IllegalArgumentException("taskId不能为空");
|
||||
}
|
||||
synchronized (taskLock) {
|
||||
PuzzleEdgeRenderTaskEntity task = taskCache.getIfPresent(taskId);
|
||||
if (task == null) {
|
||||
throw new IllegalArgumentException("任务不存在");
|
||||
}
|
||||
if (task.getStatus() == null || task.getStatus() != STATUS_RUNNING) {
|
||||
throw new IllegalArgumentException("任务状态非法");
|
||||
}
|
||||
if (task.getWorkerId() == null || !task.getWorkerId().equals(workerId)) {
|
||||
throw new IllegalArgumentException("任务不属于当前worker");
|
||||
}
|
||||
return task;
|
||||
}
|
||||
}
|
||||
|
||||
private PuzzleEdgeRenderTaskEntity getAndCheckTaskOwned(Long taskId, Long workerId) {
|
||||
if (taskId == null) {
|
||||
throw new IllegalArgumentException("taskId不能为空");
|
||||
}
|
||||
synchronized (taskLock) {
|
||||
PuzzleEdgeRenderTaskEntity task = taskCache.getIfPresent(taskId);
|
||||
if (task == null) {
|
||||
throw new IllegalArgumentException("任务不存在");
|
||||
}
|
||||
if (task.getWorkerId() == null || !task.getWorkerId().equals(workerId)) {
|
||||
throw new IllegalArgumentException("任务不属于当前worker");
|
||||
}
|
||||
return task;
|
||||
}
|
||||
}
|
||||
|
||||
private boolean tryMarkSuccess(PuzzleEdgeRenderTaskEntity task, Long workerId) {
|
||||
synchronized (taskLock) {
|
||||
if (task == null || task.getId() == null) {
|
||||
return false;
|
||||
}
|
||||
if (task.getStatus() == null || task.getStatus() != STATUS_RUNNING) {
|
||||
return false;
|
||||
}
|
||||
if (task.getWorkerId() == null || !task.getWorkerId().equals(workerId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
task.setStatus(STATUS_SUCCESS);
|
||||
task.setLeaseExpireTime(null);
|
||||
task.setErrorMessage(null);
|
||||
task.setUpdateTime(new Date());
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private boolean tryMarkFail(PuzzleEdgeRenderTaskEntity task, Long workerId, String errorMessage) {
|
||||
synchronized (taskLock) {
|
||||
if (task == null || task.getId() == null) {
|
||||
return false;
|
||||
}
|
||||
if (task.getStatus() == null || task.getStatus() != STATUS_RUNNING) {
|
||||
return false;
|
||||
}
|
||||
if (task.getWorkerId() == null || !task.getWorkerId().equals(workerId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
task.setStatus(STATUS_FAIL);
|
||||
task.setLeaseExpireTime(null);
|
||||
task.setErrorMessage(errorMessage);
|
||||
task.setUpdateTime(new Date());
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isClaimable(PuzzleEdgeRenderTaskEntity task, long nowMillis) {
|
||||
if (task == null || task.getId() == null || task.getStatus() == null) {
|
||||
return false;
|
||||
}
|
||||
if (task.getStatus() == STATUS_PENDING) {
|
||||
int attemptCount = task.getAttemptCount() != null ? task.getAttemptCount() : 0;
|
||||
return attemptCount < MAX_RETRY_ATTEMPTS;
|
||||
}
|
||||
if (task.getStatus() != STATUS_RUNNING) {
|
||||
return false;
|
||||
}
|
||||
|
||||
Date leaseExpireTime = task.getLeaseExpireTime();
|
||||
if (leaseExpireTime == null || leaseExpireTime.getTime() >= nowMillis) {
|
||||
return false;
|
||||
}
|
||||
|
||||
int attemptCount = task.getAttemptCount() != null ? task.getAttemptCount() : 0;
|
||||
return attemptCount < MAX_RETRY_ATTEMPTS;
|
||||
}
|
||||
|
||||
private RenderWorkerEntity requireWorker(String accessKey) {
|
||||
if (StrUtil.isBlank(accessKey)) {
|
||||
throw new IllegalArgumentException("accessKey不能为空");
|
||||
}
|
||||
RenderWorkerEntity worker = renderWorkerRepository.getWorkerByAccessKey(accessKey);
|
||||
if (worker == null) {
|
||||
throw new IllegalArgumentException("worker不存在");
|
||||
}
|
||||
if (worker.getStatus() == null || worker.getStatus() != 1) {
|
||||
throw new IllegalArgumentException("worker未启用");
|
||||
}
|
||||
return worker;
|
||||
}
|
||||
|
||||
private String normalizeOutputFormat(String format) {
|
||||
String outputFormat = StrUtil.isNotBlank(format) ? format.toUpperCase() : "PNG";
|
||||
if ("JPG".equals(outputFormat)) {
|
||||
outputFormat = "JPEG";
|
||||
}
|
||||
if (!"PNG".equals(outputFormat) && !"JPEG".equals(outputFormat)) {
|
||||
outputFormat = "PNG";
|
||||
}
|
||||
return outputFormat;
|
||||
}
|
||||
|
||||
private String resolveContentType(String outputFormat) {
|
||||
return "PNG".equals(outputFormat) ? "image/png" : "image/jpeg";
|
||||
}
|
||||
}
|
||||
@@ -5,13 +5,13 @@ import cn.hutool.json.JSONUtil;
|
||||
import com.ycwl.basic.model.pc.mp.MpConfigEntity;
|
||||
import com.ycwl.basic.puzzle.dto.PuzzleGenerateRequest;
|
||||
import com.ycwl.basic.puzzle.dto.PuzzleGenerateResponse;
|
||||
import com.ycwl.basic.puzzle.edge.task.PuzzleEdgeRenderTaskService;
|
||||
import com.ycwl.basic.puzzle.entity.PuzzleElementEntity;
|
||||
import com.ycwl.basic.puzzle.entity.PuzzleGenerationRecordEntity;
|
||||
import com.ycwl.basic.puzzle.entity.PuzzleTemplateEntity;
|
||||
import com.ycwl.basic.puzzle.fill.FillResult;
|
||||
import com.ycwl.basic.puzzle.fill.PuzzleElementFillEngine;
|
||||
import com.ycwl.basic.puzzle.mapper.PuzzleGenerationRecordMapper;
|
||||
import com.ycwl.basic.puzzle.mapper.PuzzleTemplateMapper;
|
||||
import com.ycwl.basic.puzzle.repository.PuzzleRepository;
|
||||
import com.ycwl.basic.puzzle.service.IPuzzleGenerateService;
|
||||
import com.ycwl.basic.puzzle.util.PuzzleDuplicationDetector;
|
||||
@@ -37,9 +37,6 @@ 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;
|
||||
|
||||
/**
|
||||
* 拼图图片生成服务实现
|
||||
@@ -58,17 +55,17 @@ public class PuzzleGenerateServiceImpl implements IPuzzleGenerateService {
|
||||
private final ScenicRepository scenicRepository;
|
||||
private final PuzzleDuplicationDetector duplicationDetector;
|
||||
private final PrinterService printerService;
|
||||
private final ThreadPoolExecutor puzzleGenerateExecutor;
|
||||
private final PuzzleEdgeRenderTaskService puzzleEdgeRenderTaskService;
|
||||
|
||||
public PuzzleGenerateServiceImpl(
|
||||
PuzzleTemplateMapper templateMapper,
|
||||
PuzzleRepository puzzleRepository,
|
||||
PuzzleGenerationRecordMapper recordMapper,
|
||||
@Lazy PuzzleImageRenderer imageRenderer,
|
||||
@Lazy PuzzleElementFillEngine fillEngine,
|
||||
@Lazy ScenicRepository scenicRepository,
|
||||
@Lazy PuzzleDuplicationDetector duplicationDetector,
|
||||
@Lazy PrinterService printerService) {
|
||||
@Lazy PrinterService printerService,
|
||||
PuzzleEdgeRenderTaskService puzzleEdgeRenderTaskService) {
|
||||
this.puzzleRepository = puzzleRepository;
|
||||
this.recordMapper = recordMapper;
|
||||
this.imageRenderer = imageRenderer;
|
||||
@@ -76,14 +73,7 @@ public class PuzzleGenerateServiceImpl implements IPuzzleGenerateService {
|
||||
this.scenicRepository = scenicRepository;
|
||||
this.duplicationDetector = duplicationDetector;
|
||||
this.printerService = printerService;
|
||||
this.puzzleGenerateExecutor = new ThreadPoolExecutor(
|
||||
4,
|
||||
256,
|
||||
30,
|
||||
TimeUnit.SECONDS,
|
||||
new LinkedBlockingQueue<>(256),
|
||||
new ThreadPoolExecutor.CallerRunsPolicy()
|
||||
);;
|
||||
this.puzzleEdgeRenderTaskService = puzzleEdgeRenderTaskService;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -99,6 +89,10 @@ public class PuzzleGenerateServiceImpl implements IPuzzleGenerateService {
|
||||
|
||||
@Override
|
||||
public Long generateAsync(PuzzleGenerateRequest request) {
|
||||
long startTime = System.currentTimeMillis();
|
||||
log.info("开始创建异步拼图边缘渲染任务: templateCode={}, userId={}, faceId={}",
|
||||
request.getTemplateCode(), request.getUserId(), request.getFaceId());
|
||||
|
||||
// 1. 参数校验
|
||||
validateRequest(request);
|
||||
|
||||
@@ -112,27 +106,52 @@ public class PuzzleGenerateServiceImpl implements IPuzzleGenerateService {
|
||||
}
|
||||
Long resolvedScenicId = resolveScenicId(template, request.getScenicId());
|
||||
|
||||
// 3. 创建 PENDING 状态的记录
|
||||
// 3. 查询并排序元素
|
||||
List<PuzzleElementEntity> elements = puzzleRepository.getElementsByTemplateId(template.getId());
|
||||
if (elements.isEmpty()) {
|
||||
throw new IllegalArgumentException("模板没有配置元素: " + request.getTemplateCode());
|
||||
}
|
||||
elements.sort(Comparator.comparing(PuzzleElementEntity::getZIndex,
|
||||
Comparator.nullsFirst(Comparator.naturalOrder())));
|
||||
|
||||
// 4. 构建dynamicData
|
||||
Map<String, String> finalDynamicData = buildDynamicData(template, request, resolvedScenicId, elements);
|
||||
|
||||
// 5. 重复图片检测(可能抛出DuplicateImageException)
|
||||
duplicationDetector.detectDuplicateImages(finalDynamicData, elements);
|
||||
|
||||
// 6. 内容去重检测
|
||||
String contentHash = duplicationDetector.calculateContentHash(finalDynamicData);
|
||||
PuzzleGenerationRecordEntity duplicateRecord = duplicationDetector.findDuplicateRecord(
|
||||
template.getId(), contentHash, resolvedScenicId
|
||||
);
|
||||
if (duplicateRecord != null) {
|
||||
long duration = System.currentTimeMillis() - startTime;
|
||||
log.info("检测到重复内容,复用历史记录: recordId={}, imageUrl={}, duration={}ms",
|
||||
duplicateRecord.getId(), duplicateRecord.getResultImageUrl(), duration);
|
||||
return duplicateRecord.getId();
|
||||
}
|
||||
|
||||
// 7. 创建生成记录
|
||||
PuzzleGenerationRecordEntity record = createRecord(template, request, resolvedScenicId);
|
||||
record.setStatus(0); // 生成中
|
||||
record.setContentHash(contentHash);
|
||||
recordMapper.insert(record);
|
||||
Long recordId = record.getId();
|
||||
|
||||
log.info("异步拼图生成任务已提交: recordId={}, templateCode={}, 当前队列大小={}",
|
||||
recordId, request.getTemplateCode(), puzzleGenerateExecutor.getQueue().size());
|
||||
// 8. 创建边缘渲染任务
|
||||
Long taskId = puzzleEdgeRenderTaskService.createRenderTask(
|
||||
record,
|
||||
template,
|
||||
elements,
|
||||
finalDynamicData,
|
||||
request.getOutputFormat(),
|
||||
request.getQuality()
|
||||
);
|
||||
|
||||
// 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());
|
||||
}
|
||||
});
|
||||
long duration = System.currentTimeMillis() - startTime;
|
||||
log.info("异步拼图任务已进入边缘渲染队列: recordId={}, taskId={}, templateCode={}, duration={}ms",
|
||||
record.getId(), taskId, request.getTemplateCode(), duration);
|
||||
|
||||
return recordId;
|
||||
return record.getId();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
91
src/main/java/com/ycwl/basic/utils/Ipv4CidrMatcher.java
Normal file
91
src/main/java/com/ycwl/basic/utils/Ipv4CidrMatcher.java
Normal file
@@ -0,0 +1,91 @@
|
||||
package com.ycwl.basic.utils;
|
||||
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
|
||||
/**
|
||||
* IPv4 CIDR 匹配工具
|
||||
*/
|
||||
public final class Ipv4CidrMatcher {
|
||||
|
||||
private Ipv4CidrMatcher() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断 IPv4 是否命中 CIDR(如:100.64.0.0/24)。
|
||||
* - 若 cidr 不包含 '/',则按“完全相等”处理。
|
||||
* - 仅支持 IPv4;IPv6 返回 false。
|
||||
*/
|
||||
public static boolean matches(String ip, String cidr) {
|
||||
if (StringUtils.isBlank(ip) || StringUtils.isBlank(cidr)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
String cleanedIp = ip.trim();
|
||||
if (cleanedIp.contains(":")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
String cleanedCidr = cidr.trim();
|
||||
int slashIndex = cleanedCidr.indexOf('/');
|
||||
if (slashIndex < 0) {
|
||||
return cleanedIp.equals(cleanedCidr);
|
||||
}
|
||||
|
||||
String networkPart = cleanedCidr.substring(0, slashIndex).trim();
|
||||
String prefixPart = cleanedCidr.substring(slashIndex + 1).trim();
|
||||
|
||||
Integer ipInt = parseIpv4ToInt(cleanedIp);
|
||||
Integer networkInt = parseIpv4ToInt(networkPart);
|
||||
if (ipInt == null || networkInt == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
Integer prefixLength = parsePrefixLength(prefixPart);
|
||||
if (prefixLength == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
int mask = prefixLength == 0 ? 0 : (-1 << (32 - prefixLength));
|
||||
return (ipInt & mask) == (networkInt & mask);
|
||||
}
|
||||
|
||||
private static Integer parsePrefixLength(String prefixPart) {
|
||||
try {
|
||||
int prefixLength = Integer.parseInt(prefixPart);
|
||||
if (prefixLength < 0 || prefixLength > 32) {
|
||||
return null;
|
||||
}
|
||||
return prefixLength;
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static Integer parseIpv4ToInt(String ip) {
|
||||
if (StringUtils.isBlank(ip)) {
|
||||
return null;
|
||||
}
|
||||
String[] parts = ip.trim().split("\\.");
|
||||
if (parts.length != 4) {
|
||||
return null;
|
||||
}
|
||||
|
||||
long result = 0;
|
||||
for (String part : parts) {
|
||||
int value;
|
||||
try {
|
||||
value = Integer.parseInt(part);
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (value < 0 || value > 255) {
|
||||
return null;
|
||||
}
|
||||
|
||||
result = (result << 8) | value;
|
||||
}
|
||||
|
||||
return (int) result;
|
||||
}
|
||||
}
|
||||
@@ -55,4 +55,12 @@ logging:
|
||||
com.ycwl.basic.integration.scenic.client: DEBUG
|
||||
|
||||
zhipu:
|
||||
api-key: a331e0fcf3f74518818b8e5129b79058.RXuUxUUjKdcxbF4L
|
||||
api-key: a331e0fcf3f74518818b8e5129b79058.RXuUxUUjKdcxbF4L
|
||||
|
||||
# 边缘 Worker 接口安全(仅允许 100.64.0.0/24 网段访问)
|
||||
puzzle:
|
||||
edge:
|
||||
worker:
|
||||
security:
|
||||
enabled: true
|
||||
allowed-ip-cidr: 100.64.0.0/24
|
||||
|
||||
@@ -25,4 +25,12 @@ logging:
|
||||
com.ycwl.basic.integration.scenic.client: WARN
|
||||
|
||||
zhipu:
|
||||
api-key: a331e0fcf3f74518818b8e5129b79058.RXuUxUUjKdcxbF4L
|
||||
api-key: a331e0fcf3f74518818b8e5129b79058.RXuUxUUjKdcxbF4L
|
||||
|
||||
# 边缘 Worker 接口安全(仅允许 100.64.0.0/24 网段访问)
|
||||
puzzle:
|
||||
edge:
|
||||
worker:
|
||||
security:
|
||||
enabled: true
|
||||
allowed-ip-cidr: 100.64.0.0/24
|
||||
|
||||
108
src/main/resources/mapper/PuzzleEdgeRenderTaskMapper.xml
Normal file
108
src/main/resources/mapper/PuzzleEdgeRenderTaskMapper.xml
Normal file
@@ -0,0 +1,108 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="com.ycwl.basic.puzzle.edge.mapper.PuzzleEdgeRenderTaskMapper">
|
||||
|
||||
<resultMap id="BaseResultMap" type="com.ycwl.basic.puzzle.edge.entity.PuzzleEdgeRenderTaskEntity">
|
||||
<id column="id" property="id"/>
|
||||
<result column="record_id" property="recordId"/>
|
||||
<result column="template_id" property="templateId"/>
|
||||
<result column="template_code" property="templateCode"/>
|
||||
<result column="scenic_id" property="scenicId"/>
|
||||
<result column="face_id" property="faceId"/>
|
||||
<result column="content_hash" property="contentHash"/>
|
||||
<result column="status" property="status"/>
|
||||
<result column="worker_id" property="workerId"/>
|
||||
<result column="lease_expire_time" property="leaseExpireTime"/>
|
||||
<result column="attempt_count" property="attemptCount"/>
|
||||
<result column="output_format" property="outputFormat"/>
|
||||
<result column="output_quality" property="outputQuality"/>
|
||||
<result column="original_object_key" property="originalObjectKey"/>
|
||||
<result column="cropped_object_key" property="croppedObjectKey"/>
|
||||
<result column="payload_json" property="payloadJson"/>
|
||||
<result column="error_message" property="errorMessage"/>
|
||||
<result column="create_time" property="createTime"/>
|
||||
<result column="update_time" property="updateTime"/>
|
||||
</resultMap>
|
||||
|
||||
<sql id="Base_Column_List">
|
||||
id, record_id, template_id, template_code, scenic_id, face_id, content_hash,
|
||||
status, worker_id, lease_expire_time, attempt_count,
|
||||
output_format, output_quality,
|
||||
original_object_key, cropped_object_key,
|
||||
payload_json, error_message,
|
||||
create_time, update_time
|
||||
</sql>
|
||||
|
||||
<select id="getById" resultMap="BaseResultMap">
|
||||
SELECT <include refid="Base_Column_List"/>
|
||||
FROM puzzle_edge_render_task
|
||||
WHERE id = #{id}
|
||||
LIMIT 1
|
||||
</select>
|
||||
|
||||
<insert id="insert" parameterType="com.ycwl.basic.puzzle.edge.entity.PuzzleEdgeRenderTaskEntity"
|
||||
useGeneratedKeys="true" keyProperty="id">
|
||||
INSERT INTO puzzle_edge_render_task (
|
||||
record_id, template_id, template_code, scenic_id, face_id, content_hash,
|
||||
status, worker_id, lease_expire_time, attempt_count,
|
||||
output_format, output_quality,
|
||||
original_object_key, cropped_object_key,
|
||||
payload_json, error_message,
|
||||
create_time, update_time
|
||||
) VALUES (
|
||||
#{recordId}, #{templateId}, #{templateCode}, #{scenicId}, #{faceId}, #{contentHash},
|
||||
#{status}, #{workerId}, #{leaseExpireTime}, #{attemptCount},
|
||||
#{outputFormat}, #{outputQuality},
|
||||
#{originalObjectKey}, #{croppedObjectKey},
|
||||
#{payloadJson}, #{errorMessage},
|
||||
NOW(), NOW()
|
||||
)
|
||||
</insert>
|
||||
|
||||
<select id="findNextClaimableTaskId" resultType="java.lang.Long">
|
||||
SELECT id
|
||||
FROM puzzle_edge_render_task
|
||||
WHERE status = 0
|
||||
OR (status = 1 AND lease_expire_time IS NOT NULL AND lease_expire_time < NOW())
|
||||
ORDER BY id ASC
|
||||
LIMIT 1
|
||||
</select>
|
||||
|
||||
<update id="claimTask">
|
||||
UPDATE puzzle_edge_render_task
|
||||
SET worker_id = #{workerId},
|
||||
status = 1,
|
||||
lease_expire_time = #{leaseExpireTime},
|
||||
attempt_count = attempt_count + 1,
|
||||
update_time = NOW()
|
||||
WHERE id = #{taskId}
|
||||
AND (
|
||||
status = 0
|
||||
OR (status = 1 AND lease_expire_time IS NOT NULL AND lease_expire_time < NOW())
|
||||
)
|
||||
</update>
|
||||
|
||||
<update id="markSuccess">
|
||||
UPDATE puzzle_edge_render_task
|
||||
SET status = 2,
|
||||
lease_expire_time = NULL,
|
||||
error_message = NULL,
|
||||
update_time = NOW()
|
||||
WHERE id = #{taskId}
|
||||
AND worker_id = #{workerId}
|
||||
AND status = 1
|
||||
</update>
|
||||
|
||||
<update id="markFail">
|
||||
UPDATE puzzle_edge_render_task
|
||||
SET status = 3,
|
||||
lease_expire_time = NULL,
|
||||
error_message = #{errorMessage},
|
||||
update_time = NOW()
|
||||
WHERE id = #{taskId}
|
||||
AND worker_id = #{workerId}
|
||||
AND status = 1
|
||||
</update>
|
||||
|
||||
</mapper>
|
||||
Reference in New Issue
Block a user