feat: update task routing and coze handling
This commit is contained in:
+28
@@ -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())) {
|
||||
|
||||
+36
@@ -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;
|
||||
}
|
||||
}
|
||||
+156
@@ -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;
|
||||
}
|
||||
}
|
||||
+4
-4
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user