Merge branch 'puzzle_edge_w'

# Conflicts:
#	src/main/java/com/ycwl/basic/config/WebMvcConfig.java
This commit is contained in:
2026-01-05 11:58:56 +08:00
19 changed files with 1207 additions and 33 deletions

View File

@@ -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("/**")

View File

@@ -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";
}

View File

@@ -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");
}
}

View File

@@ -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;
}

View File

@@ -0,0 +1,10 @@
package com.ycwl.basic.puzzle.edge.dto;
import lombok.Data;
@Data
public class PuzzleEdgeTaskFailRequest {
private String accessKey;
private String errorMessage;
}

View File

@@ -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;
}

View File

@@ -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;
}
}

View File

@@ -0,0 +1,9 @@
package com.ycwl.basic.puzzle.edge.dto;
import lombok.Data;
@Data
public class PuzzleEdgeWorkerAuthRequest {
private String accessKey;
}

View File

@@ -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;
}

View File

@@ -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<>();
}

View File

@@ -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;
}

View File

@@ -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;
}
}

View File

@@ -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);
}

View File

@@ -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";
}
}

View File

@@ -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 状态的记录
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());
// 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())));
return recordId;
// 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.setContentHash(contentHash);
recordMapper.insert(record);
// 8. 创建边缘渲染任务
Long taskId = puzzleEdgeRenderTaskService.createRenderTask(
record,
template,
elements,
finalDynamicData,
request.getOutputFormat(),
request.getQuality()
);
long duration = System.currentTimeMillis() - startTime;
log.info("异步拼图任务已进入边缘渲染队列: recordId={}, taskId={}, templateCode={}, duration={}ms",
record.getId(), taskId, request.getTemplateCode(), duration);
return record.getId();
}
/**

View 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;
}
}

View File

@@ -56,3 +56,11 @@ logging:
zhipu:
api-key: a331e0fcf3f74518818b8e5129b79058.RXuUxUUjKdcxbF4L
# 边缘 Worker 接口安全(仅允许 100.64.0.0/24 网段访问)
puzzle:
edge:
worker:
security:
enabled: true
allowed-ip-cidr: 100.64.0.0/24

View File

@@ -26,3 +26,11 @@ logging:
zhipu:
api-key: a331e0fcf3f74518818b8e5129b79058.RXuUxUUjKdcxbF4L
# 边缘 Worker 接口安全(仅允许 100.64.0.0/24 网段访问)
puzzle:
edge:
worker:
security:
enabled: true
allowed-ip-cidr: 100.64.0.0/24

View 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 &lt; 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 &lt; 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>