diff --git a/src/main/java/com/ycwl/basic/config/WebMvcConfig.java b/src/main/java/com/ycwl/basic/config/WebMvcConfig.java index 39f48bc1..04dc0711 100644 --- a/src/main/java/com/ycwl/basic/config/WebMvcConfig.java +++ b/src/main/java/com/ycwl/basic/config/WebMvcConfig.java @@ -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 com.ycwl.basic.stats.interceptor.StatsInterceptor; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Bean; @@ -30,9 +31,13 @@ public class WebMvcConfig implements WebMvcConfigurer { private AuthInterceptor authInterceptor; @Autowired private StatsInterceptor statsInterceptor; + @Autowired + private PuzzleEdgeWorkerIpInterceptor puzzleEdgeWorkerIpInterceptor; @Override public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(puzzleEdgeWorkerIpInterceptor) + .addPathPatterns("/puzzle/render/v1/**"); registry.addInterceptor(authInterceptor) // 拦截除指定接口外的所有请求,通过判断 注解 来决定是否需要做登录验证 .addPathPatterns("/**") diff --git a/src/main/java/com/ycwl/basic/puzzle/edge/config/PuzzleEdgeWorkerSecurityProperties.java b/src/main/java/com/ycwl/basic/puzzle/edge/config/PuzzleEdgeWorkerSecurityProperties.java new file mode 100644 index 00000000..0adccd19 --- /dev/null +++ b/src/main/java/com/ycwl/basic/puzzle/edge/config/PuzzleEdgeWorkerSecurityProperties.java @@ -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"; +} diff --git a/src/main/java/com/ycwl/basic/puzzle/edge/controller/PuzzleEdgeRenderTaskController.java b/src/main/java/com/ycwl/basic/puzzle/edge/controller/PuzzleEdgeRenderTaskController.java new file mode 100644 index 00000000..cd40b441 --- /dev/null +++ b/src/main/java/com/ycwl/basic/puzzle/edge/controller/PuzzleEdgeRenderTaskController.java @@ -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 sync(@RequestBody PuzzleEdgeWorkerSyncRequest req) { + return ApiResponse.success(puzzleEdgeRenderTaskService.sync(req)); + } + + @PostMapping("/task/{taskId}/uploadUrls") + public ApiResponse uploadUrls(@PathVariable Long taskId, + @RequestBody PuzzleEdgeWorkerAuthRequest req) { + return ApiResponse.success(puzzleEdgeRenderTaskService.getUploadUrls(taskId, req != null ? req.getAccessKey() : null)); + } + + @PostMapping("/task/{taskId}/success") + public ApiResponse success(@PathVariable Long taskId, @RequestBody PuzzleEdgeTaskSuccessRequest req) { + puzzleEdgeRenderTaskService.taskSuccess(taskId, req); + return ApiResponse.success("OK"); + } + + @PostMapping("/task/{taskId}/fail") + public ApiResponse fail(@PathVariable Long taskId, @RequestBody PuzzleEdgeTaskFailRequest req) { + puzzleEdgeRenderTaskService.taskFail(taskId, req); + return ApiResponse.success("OK"); + } +} diff --git a/src/main/java/com/ycwl/basic/puzzle/edge/dto/PuzzleEdgeRenderTaskDTO.java b/src/main/java/com/ycwl/basic/puzzle/edge/dto/PuzzleEdgeRenderTaskDTO.java new file mode 100644 index 00000000..e6d6193d --- /dev/null +++ b/src/main/java/com/ycwl/basic/puzzle/edge/dto/PuzzleEdgeRenderTaskDTO.java @@ -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 payload; + /** + * 上传地址(预签名URL),用于 Worker 直接上传产物 + */ + private PuzzleEdgeUploadUrlsResponse upload; +} diff --git a/src/main/java/com/ycwl/basic/puzzle/edge/dto/PuzzleEdgeTaskFailRequest.java b/src/main/java/com/ycwl/basic/puzzle/edge/dto/PuzzleEdgeTaskFailRequest.java new file mode 100644 index 00000000..bdb8eaf7 --- /dev/null +++ b/src/main/java/com/ycwl/basic/puzzle/edge/dto/PuzzleEdgeTaskFailRequest.java @@ -0,0 +1,10 @@ +package com.ycwl.basic.puzzle.edge.dto; + +import lombok.Data; + +@Data +public class PuzzleEdgeTaskFailRequest { + private String accessKey; + private String errorMessage; +} + diff --git a/src/main/java/com/ycwl/basic/puzzle/edge/dto/PuzzleEdgeTaskSuccessRequest.java b/src/main/java/com/ycwl/basic/puzzle/edge/dto/PuzzleEdgeTaskSuccessRequest.java new file mode 100644 index 00000000..7e935778 --- /dev/null +++ b/src/main/java/com/ycwl/basic/puzzle/edge/dto/PuzzleEdgeTaskSuccessRequest.java @@ -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; +} + diff --git a/src/main/java/com/ycwl/basic/puzzle/edge/dto/PuzzleEdgeUploadUrlsResponse.java b/src/main/java/com/ycwl/basic/puzzle/edge/dto/PuzzleEdgeUploadUrlsResponse.java new file mode 100644 index 00000000..476f17e0 --- /dev/null +++ b/src/main/java/com/ycwl/basic/puzzle/edge/dto/PuzzleEdgeUploadUrlsResponse.java @@ -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 headers; + } +} + diff --git a/src/main/java/com/ycwl/basic/puzzle/edge/dto/PuzzleEdgeWorkerAuthRequest.java b/src/main/java/com/ycwl/basic/puzzle/edge/dto/PuzzleEdgeWorkerAuthRequest.java new file mode 100644 index 00000000..147d4781 --- /dev/null +++ b/src/main/java/com/ycwl/basic/puzzle/edge/dto/PuzzleEdgeWorkerAuthRequest.java @@ -0,0 +1,9 @@ +package com.ycwl.basic.puzzle.edge.dto; + +import lombok.Data; + +@Data +public class PuzzleEdgeWorkerAuthRequest { + private String accessKey; +} + diff --git a/src/main/java/com/ycwl/basic/puzzle/edge/dto/PuzzleEdgeWorkerSyncRequest.java b/src/main/java/com/ycwl/basic/puzzle/edge/dto/PuzzleEdgeWorkerSyncRequest.java new file mode 100644 index 00000000..76cb1447 --- /dev/null +++ b/src/main/java/com/ycwl/basic/puzzle/edge/dto/PuzzleEdgeWorkerSyncRequest.java @@ -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; +} + diff --git a/src/main/java/com/ycwl/basic/puzzle/edge/dto/PuzzleEdgeWorkerSyncResponse.java b/src/main/java/com/ycwl/basic/puzzle/edge/dto/PuzzleEdgeWorkerSyncResponse.java new file mode 100644 index 00000000..ac145fb9 --- /dev/null +++ b/src/main/java/com/ycwl/basic/puzzle/edge/dto/PuzzleEdgeWorkerSyncResponse.java @@ -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 tasks = new ArrayList<>(); +} + diff --git a/src/main/java/com/ycwl/basic/puzzle/edge/entity/PuzzleEdgeRenderTaskEntity.java b/src/main/java/com/ycwl/basic/puzzle/edge/entity/PuzzleEdgeRenderTaskEntity.java new file mode 100644 index 00000000..b258bfe5 --- /dev/null +++ b/src/main/java/com/ycwl/basic/puzzle/edge/entity/PuzzleEdgeRenderTaskEntity.java @@ -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; +} + diff --git a/src/main/java/com/ycwl/basic/puzzle/edge/interceptor/PuzzleEdgeWorkerIpInterceptor.java b/src/main/java/com/ycwl/basic/puzzle/edge/interceptor/PuzzleEdgeWorkerIpInterceptor.java new file mode 100644 index 00000000..aad813b7 --- /dev/null +++ b/src/main/java/com/ycwl/basic/puzzle/edge/interceptor/PuzzleEdgeWorkerIpInterceptor.java @@ -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; + } +} + diff --git a/src/main/java/com/ycwl/basic/puzzle/edge/mapper/PuzzleEdgeRenderTaskMapper.java b/src/main/java/com/ycwl/basic/puzzle/edge/mapper/PuzzleEdgeRenderTaskMapper.java new file mode 100644 index 00000000..b4d2a1bd --- /dev/null +++ b/src/main/java/com/ycwl/basic/puzzle/edge/mapper/PuzzleEdgeRenderTaskMapper.java @@ -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); +} + diff --git a/src/main/java/com/ycwl/basic/puzzle/edge/task/PuzzleEdgeRenderTaskService.java b/src/main/java/com/ycwl/basic/puzzle/edge/task/PuzzleEdgeRenderTaskService.java new file mode 100644 index 00000000..6670928d --- /dev/null +++ b/src/main/java/com/ycwl/basic/puzzle/edge/task/PuzzleEdgeRenderTaskService.java @@ -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 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 retryRecordIds = new ArrayList<>(); + Map 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 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 sortedElements, + Map 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 payload = new HashMap<>(); + payload.put("recordId", record.getId()); + + Map 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> elementPayloadList = new ArrayList<>(); + for (PuzzleElementEntity e : sortedElements) { + Map 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 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>() {})); + 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 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 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"; + } +} diff --git a/src/main/java/com/ycwl/basic/puzzle/service/impl/PuzzleGenerateServiceImpl.java b/src/main/java/com/ycwl/basic/puzzle/service/impl/PuzzleGenerateServiceImpl.java index 089f1fbd..91738c8c 100644 --- a/src/main/java/com/ycwl/basic/puzzle/service/impl/PuzzleGenerateServiceImpl.java +++ b/src/main/java/com/ycwl/basic/puzzle/service/impl/PuzzleGenerateServiceImpl.java @@ -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 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 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(); } /** diff --git a/src/main/java/com/ycwl/basic/utils/Ipv4CidrMatcher.java b/src/main/java/com/ycwl/basic/utils/Ipv4CidrMatcher.java new file mode 100644 index 00000000..df57a9d3 --- /dev/null +++ b/src/main/java/com/ycwl/basic/utils/Ipv4CidrMatcher.java @@ -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; + } +} diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index a38bdc7d..bb875ca1 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -44,4 +44,12 @@ logging: com.ycwl.basic.integration.scenic.client: DEBUG zhipu: - api-key: a331e0fcf3f74518818b8e5129b79058.RXuUxUUjKdcxbF4L \ No newline at end of file + api-key: a331e0fcf3f74518818b8e5129b79058.RXuUxUUjKdcxbF4L + +# 边缘 Worker 接口安全(仅允许 100.64.0.0/24 网段访问) +puzzle: + edge: + worker: + security: + enabled: true + allowed-ip-cidr: 100.64.0.0/24 diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 1eb72a0b..e938dd90 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -14,4 +14,12 @@ logging: com.ycwl.basic.integration.scenic.client: WARN zhipu: - api-key: a331e0fcf3f74518818b8e5129b79058.RXuUxUUjKdcxbF4L \ No newline at end of file + api-key: a331e0fcf3f74518818b8e5129b79058.RXuUxUUjKdcxbF4L + +# 边缘 Worker 接口安全(仅允许 100.64.0.0/24 网段访问) +puzzle: + edge: + worker: + security: + enabled: true + allowed-ip-cidr: 100.64.0.0/24 diff --git a/src/main/resources/mapper/PuzzleEdgeRenderTaskMapper.xml b/src/main/resources/mapper/PuzzleEdgeRenderTaskMapper.xml new file mode 100644 index 00000000..9e285f61 --- /dev/null +++ b/src/main/resources/mapper/PuzzleEdgeRenderTaskMapper.xml @@ -0,0 +1,108 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 + + + + + + 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() + ) + + + + + + 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 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 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 + + +