feat: update task routing and coze handling

This commit is contained in:
super
2026-06-03 17:14:50 +08:00
parent ea37d82d73
commit e98a1a1207
37 changed files with 1883 additions and 254 deletions
@@ -1,8 +1,13 @@
package com.nanri.aiimage.common.exception;
import com.nanri.aiimage.common.api.ApiResponse;
import com.nanri.aiimage.common.service.TaskOwnerForwardService;
import com.nanri.aiimage.config.TaskOperationLockConfig;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.ConstraintViolationException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@@ -11,9 +16,32 @@ import org.springframework.web.context.request.async.AsyncRequestNotUsableExcept
import java.io.IOException;
@Slf4j
@RequiredArgsConstructor
@RestControllerAdvice
public class GlobalExceptionHandler {
private final TaskOwnerForwardService taskOwnerForwardService;
@ExceptionHandler(TaskOwnerMismatchException.class)
public Object handleTaskOwnerMismatchException(TaskOwnerMismatchException ex, HttpServletRequest request) {
TaskOperationLockConfig.releaseRequestLock(request);
try {
ResponseEntity<byte[]> response = taskOwnerForwardService.forwardCurrentRequest(ex, request);
return ResponseEntity.status(response.getStatusCode())
.headers(response.getHeaders())
.body(response.getBody());
} catch (BusinessException forwardEx) {
return forwardEx.getCode() == null
? ApiResponse.fail(forwardEx.getMessage())
: ApiResponse.fail(forwardEx.getCode(), forwardEx.getMessage());
} catch (Exception forwardEx) {
log.warn("[instance-routing] forward failed taskId={} operation={} owner={} current={} msg={}",
ex.getTaskId(), ex.getOperation(), ex.getOwnerInstanceId(), ex.getCurrentInstanceId(),
forwardEx.getMessage(), forwardEx);
return ApiResponse.fail(40903, "任务归属实例转发失败: " + forwardEx.getMessage());
}
}
@ExceptionHandler(BusinessException.class)
public ApiResponse<Void> handleBusinessException(BusinessException ex) {
if (Integer.valueOf(40901).equals(ex.getCode())) {
@@ -0,0 +1,36 @@
package com.nanri.aiimage.common.exception;
public class TaskOwnerMismatchException extends BusinessException {
private final Long taskId;
private final String operation;
private final String ownerInstanceId;
private final String currentInstanceId;
public TaskOwnerMismatchException(Long taskId,
String operation,
String ownerInstanceId,
String currentInstanceId) {
super(40903, "该任务已绑定到另一台服务实例处理,请通过原实例继续处理");
this.taskId = taskId;
this.operation = operation;
this.ownerInstanceId = ownerInstanceId;
this.currentInstanceId = currentInstanceId;
}
public Long getTaskId() {
return taskId;
}
public String getOperation() {
return operation;
}
public String getOwnerInstanceId() {
return ownerInstanceId;
}
public String getCurrentInstanceId() {
return currentInstanceId;
}
}
@@ -0,0 +1,156 @@
package com.nanri.aiimage.common.service;
import com.nanri.aiimage.common.exception.BusinessException;
import com.nanri.aiimage.common.exception.TaskOwnerMismatchException;
import com.nanri.aiimage.config.InstanceRoutingProperties;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.http.client.SimpleClientHttpRequestFactory;
import org.springframework.stereotype.Service;
import org.springframework.util.StreamUtils;
import org.springframework.web.client.RestClient;
import org.springframework.web.util.ContentCachingRequestWrapper;
import java.io.IOException;
import java.util.Enumeration;
import java.util.List;
import java.util.Set;
@Slf4j
@Service
@RequiredArgsConstructor
public class TaskOwnerForwardService {
public static final String FORWARDED_HEADER = "X-AIIMAGE-Owner-Forwarded";
private static final Set<String> HOP_BY_HOP_HEADERS = Set.of(
"connection",
"keep-alive",
"proxy-authenticate",
"proxy-authorization",
"te",
"trailer",
"transfer-encoding",
"upgrade",
"host",
"content-length"
);
private final InstanceRoutingProperties properties;
private volatile RestClient sharedRestClient;
public ResponseEntity<byte[]> forwardCurrentRequest(TaskOwnerMismatchException ex, HttpServletRequest request) {
if (request == null) {
throw new BusinessException(40903, "无法获取当前请求,不能转发到任务归属实例");
}
if (hasAlreadyForwarded(request)) {
throw new BusinessException(40903, "任务归属实例转发检测到循环,请检查实例路由配置");
}
HttpMethod method = HttpMethod.valueOf(request.getMethod());
String url = resolveUrl(ex, currentPathAndQuery(request));
byte[] body = requestBody(request);
HttpHeaders headers = copyForwardHeaders(request, ex.getCurrentInstanceId());
log.info("[instance-routing] forward {} taskId={} operation={} owner={} current={} url={}",
method, ex.getTaskId(), ex.getOperation(), ex.getOwnerInstanceId(), ex.getCurrentInstanceId(), url);
return restClient().method(method)
.uri(url)
.headers(target -> target.addAll(headers))
.body(body)
.retrieve()
.toEntity(byte[].class);
}
private String resolveUrl(TaskOwnerMismatchException ex, String path) {
String owner = ex.getOwnerInstanceId();
String baseUrl = properties.getRoutes().get(owner);
if (baseUrl == null || baseUrl.isBlank()) {
throw new BusinessException(40903, "任务归属实例未配置服务路由:" + owner);
}
return stripTrailingSlash(baseUrl) + ensureLeadingSlash(path);
}
private RestClient restClient() {
RestClient client = sharedRestClient;
if (client != null) {
return client;
}
synchronized (this) {
if (sharedRestClient == null) {
SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory();
requestFactory.setConnectTimeout(properties.getConnectTimeoutMillis());
requestFactory.setReadTimeout(properties.getReadTimeoutMillis());
sharedRestClient = RestClient.builder().requestFactory(requestFactory).build();
}
return sharedRestClient;
}
}
private static boolean hasAlreadyForwarded(HttpServletRequest request) {
String value = request.getHeader(FORWARDED_HEADER);
return value != null && !value.isBlank();
}
private static String currentPathAndQuery(HttpServletRequest request) {
String path = request.getRequestURI();
String query = request.getQueryString();
return query == null || query.isBlank() ? path : path + "?" + query;
}
private static byte[] requestBody(HttpServletRequest request) {
if (request instanceof ContentCachingRequestWrapper wrapper) {
byte[] body = wrapper.getContentAsByteArray();
return body == null ? new byte[0] : body;
}
try {
return StreamUtils.copyToByteArray(request.getInputStream());
} catch (IOException ex) {
throw new BusinessException("读取转发请求体失败:" + ex.getMessage());
}
}
private static HttpHeaders copyForwardHeaders(HttpServletRequest request, String currentInstanceId) {
HttpHeaders headers = new HttpHeaders();
Enumeration<String> headerNames = request.getHeaderNames();
while (headerNames != null && headerNames.hasMoreElements()) {
String headerName = headerNames.nextElement();
if (headerName == null || HOP_BY_HOP_HEADERS.contains(headerName.toLowerCase())) {
continue;
}
Enumeration<String> values = request.getHeaders(headerName);
while (values != null && values.hasMoreElements()) {
headers.add(headerName, values.nextElement());
}
}
headers.set(FORWARDED_HEADER, currentInstanceId == null ? "unknown-instance" : currentInstanceId);
if (!headers.containsKey(HttpHeaders.ACCEPT)) {
headers.setAccept(List.of(MediaType.APPLICATION_JSON));
}
if (!headers.containsKey(HttpHeaders.CONTENT_TYPE)
&& request.getContentType() != null
&& !request.getContentType().isBlank()) {
headers.set(HttpHeaders.CONTENT_TYPE, request.getContentType());
}
return headers;
}
private static String stripTrailingSlash(String value) {
String result = value == null ? "" : value.trim();
while (result.endsWith("/")) {
result = result.substring(0, result.length() - 1);
}
return result;
}
private static String ensureLeadingSlash(String value) {
if (value == null || value.isBlank()) {
return "/";
}
return value.startsWith("/") ? value : "/" + value;
}
}
@@ -50,9 +50,9 @@ public final class CozeGroupResultPropagator {
/**
* 否定前缀关键字。若当前值同时包含 standard 和这些关键字之一,则不视为命中。
* 用于规避"没有侵权""不侵权""未发现明显侵权风险"等被 contains 误判为命中"侵权"的情况。
* 用于规避"没有侵权""无侵权""不侵权""未发现明显侵权风险"等被 contains 误判为命中"侵权"的情况。
*/
private static final List<String> NEGATIVE_KEYWORDS = Arrays.asList("没有", "", "");
private static final List<String> NEGATIVE_KEYWORDS = Arrays.asList("没有", "", "", "");
private CozeGroupResultPropagator() {
}
@@ -134,8 +134,8 @@ public final class CozeGroupResultPropagator {
}
// 按 hitValues 顺序检测组内是否有命中(包含语义),命中即定下"标准值"。
// 命中需同时满足:当前值包含 standard 且不含任何否定关键字("没有"/"不"/"未"),
// 避免"没有侵权""不侵权""未发现明显侵权风险"这类被 contains 误判为命中。
// 命中需同时满足:当前值包含 standard 且不含任何否定关键字("没有"/"无"/"不"/"未"),
// 避免"没有侵权""无侵权""不侵权""未发现明显侵权风险"这类被 contains 误判为命中。
String matchedStandardValue = null;
outer:
for (String standard : hitValues) {