修改完善这个专利部分

This commit is contained in:
super
2026-05-15 16:06:12 +08:00
parent 18b0c2d211
commit 1dc2cac19a
72 changed files with 2930 additions and 2643 deletions

File diff suppressed because one or more lines are too long

View File

@@ -1,6 +1,6 @@
base_url=http://8.136.19.173:15124 base_url=http://47.110.241.161:15124
workflow_id=7608812635877900322 workflow_id=7608812635877900322
mysql_host=8.136.19.173 mysql_host=47.110.241.161
mysql_user=aiimage mysql_user=aiimage
proxy_url=https://api.jikip.com/ip-get?num=1&minute=1&format=json&area=all&protocol=1&mode=2&key=t24g6gi44ubufd8 proxy_url=https://api.jikip.com/ip-get?num=1&minute=1&format=json&area=all&protocol=1&mode=2&key=t24g6gi44ubufd8
@@ -13,7 +13,7 @@ client_name=ShuFuAI
# java_api_base=http://47.111.163.154:18080 # java_api_base=http://47.111.163.154:18080
java_api_base=http://127.0.0.1:18080 # java_api_base=http://127.0.0.1:18080
# java_api_base=http://121.196.149.225:18080 java_api_base=http://121.196.149.225:18080

View File

@@ -1,6 +1,6 @@
base_url=http://8.136.19.173:15124 base_url=http://47.111.163.154:15124
workflow_id=7608812635877900322 workflow_id=7608812635877900322
mysql_host=8.136.19.173 mysql_host=47.111.163.154
mysql_user=aiimage mysql_user=aiimage
proxy_url=https://api.jikip.com/ip-get?num=1&minute=1&format=json&area=all&protocol=1&mode=2&key=t24g6gi44ubufd8 proxy_url=https://api.jikip.com/ip-get?num=1&minute=1&format=json&area=all&protocol=1&mode=2&key=t24g6gi44ubufd8
@@ -10,6 +10,6 @@ client_name=ShuFuAI
java_api_base=http://127.0.0.1:18080 java_api_base=http://127.0.0.1:18080
# java_api_base=http://8.136.19.173:18080 # java_api_base=http://47.111.163.154:18080

View File

@@ -1,5 +1,5 @@
workflow_id=7608812635877900322 workflow_id=7608812635877900322
mysql_host=8.136.19.173 mysql_host=47.111.163.154
mysql_user=aiimage mysql_user=aiimage
proxy_url=https://api.jikip.com/ip-get?num=1&minute=1&format=json&area=all&protocol=1&mode=2&key=t24g6gi44ubufd8 proxy_url=https://api.jikip.com/ip-get?num=1&minute=1&format=json&area=all&protocol=1&mode=2&key=t24g6gi44ubufd8
@@ -13,6 +13,6 @@ client_name=ShuFuAI
java_api_base=http://47.111.163.154:18080 java_api_base=http://47.111.163.154:18080
# java_api_base=http://127.0.0.1:18080 # java_api_base=http://127.0.0.1:18080
# java_api_base=http://8.136.19.173:18080 # java_api_base=http://47.111.163.154:18080

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1 +1 @@
import{be as r}from"./pywebview-ZMN052YE.js";const n="";function s(e){return r(`${n}/api/brand/expand-folder-recursive`,{folder:e})}export{s as e}; import{bb as r}from"./pywebview-84Lhn3TI.js";const n="";function s(e){return r(`${n}/api/brand/expand-folder-recursive`,{folder:e})}export{s as e};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -5,9 +5,9 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>格式转换 - 数富AI</title> <title>格式转换 - 数富AI</title>
<script type="module" crossorigin src="/assets/convert.js"></script> <script type="module" crossorigin src="/assets/convert.js"></script>
<link rel="modulepreload" crossorigin href="/assets/pywebview-ZMN052YE.js"> <link rel="modulepreload" crossorigin href="/assets/pywebview-84Lhn3TI.js">
<link rel="modulepreload" crossorigin href="/assets/brand-DfxdJ4w6.js"> <link rel="modulepreload" crossorigin href="/assets/brand-DIMFUl0j.js">
<link rel="stylesheet" crossorigin href="/assets/pywebview-DNJAt1gR.css"> <link rel="stylesheet" crossorigin href="/assets/pywebview-B7UKcPVJ.css">
<link rel="stylesheet" crossorigin href="/assets/convert-BKSNvX8i.css"> <link rel="stylesheet" crossorigin href="/assets/convert-BKSNvX8i.css">
</head> </head>
<body> <body>

View File

@@ -5,9 +5,9 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>数据去重 - 数富AI</title> <title>数据去重 - 数富AI</title>
<script type="module" crossorigin src="/assets/dedupe.js"></script> <script type="module" crossorigin src="/assets/dedupe.js"></script>
<link rel="modulepreload" crossorigin href="/assets/pywebview-ZMN052YE.js"> <link rel="modulepreload" crossorigin href="/assets/pywebview-84Lhn3TI.js">
<link rel="modulepreload" crossorigin href="/assets/brand-DfxdJ4w6.js"> <link rel="modulepreload" crossorigin href="/assets/brand-DIMFUl0j.js">
<link rel="stylesheet" crossorigin href="/assets/pywebview-DNJAt1gR.css"> <link rel="stylesheet" crossorigin href="/assets/pywebview-B7UKcPVJ.css">
<link rel="stylesheet" crossorigin href="/assets/dedupe-DNlVfFj-.css"> <link rel="stylesheet" crossorigin href="/assets/dedupe-DNlVfFj-.css">
</head> </head>
<body> <body>

View File

@@ -5,10 +5,10 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>删除品牌 - 数富AI</title> <title>删除品牌 - 数富AI</title>
<script type="module" crossorigin src="/assets/delete-brand.js"></script> <script type="module" crossorigin src="/assets/delete-brand.js"></script>
<link rel="modulepreload" crossorigin href="/assets/pywebview-ZMN052YE.js"> <link rel="modulepreload" crossorigin href="/assets/pywebview-84Lhn3TI.js">
<link rel="modulepreload" crossorigin href="/assets/brand-DfxdJ4w6.js"> <link rel="modulepreload" crossorigin href="/assets/brand-DIMFUl0j.js">
<link rel="modulepreload" crossorigin href="/assets/categorized-timers-JPA-olTr.js"> <link rel="modulepreload" crossorigin href="/assets/categorized-timers-JPA-olTr.js">
<link rel="stylesheet" crossorigin href="/assets/pywebview-DNJAt1gR.css"> <link rel="stylesheet" crossorigin href="/assets/pywebview-B7UKcPVJ.css">
<link rel="stylesheet" crossorigin href="/assets/delete-brand-BJod9K65.css"> <link rel="stylesheet" crossorigin href="/assets/delete-brand-BJod9K65.css">
</head> </head>
<body> <body>

View File

@@ -5,9 +5,9 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>数据拆分 - 数富AI</title> <title>数据拆分 - 数富AI</title>
<script type="module" crossorigin src="/assets/split.js"></script> <script type="module" crossorigin src="/assets/split.js"></script>
<link rel="modulepreload" crossorigin href="/assets/pywebview-ZMN052YE.js"> <link rel="modulepreload" crossorigin href="/assets/pywebview-84Lhn3TI.js">
<link rel="modulepreload" crossorigin href="/assets/brand-DfxdJ4w6.js"> <link rel="modulepreload" crossorigin href="/assets/brand-DIMFUl0j.js">
<link rel="stylesheet" crossorigin href="/assets/pywebview-DNJAt1gR.css"> <link rel="stylesheet" crossorigin href="/assets/pywebview-B7UKcPVJ.css">
<link rel="stylesheet" crossorigin href="/assets/split-BoBVrLdC.css"> <link rel="stylesheet" crossorigin href="/assets/split-BoBVrLdC.css">
</head> </head>
<body> <body>

View File

@@ -6,6 +6,9 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.context.request.async.AsyncRequestNotUsableException;
import java.io.IOException;
@Slf4j @Slf4j
@RestControllerAdvice @RestControllerAdvice
@@ -34,9 +37,33 @@ public class GlobalExceptionHandler {
return ApiResponse.fail(ex.getMessage()); return ApiResponse.fail(ex.getMessage());
} }
@ExceptionHandler(AsyncRequestNotUsableException.class)
public void handleAsyncRequestNotUsableException(AsyncRequestNotUsableException ex) {
log.debug("Client disconnected before response completed: {}", ex.getMessage());
}
@ExceptionHandler(Exception.class) @ExceptionHandler(Exception.class)
public ApiResponse<Void> handleException(Exception ex) { public ApiResponse<Void> handleException(Exception ex) {
if (isClientAbort(ex)) {
log.debug("Client disconnected before response completed: {}", ex.getMessage());
return ApiResponse.fail("客户端已断开连接");
}
log.error("Unhandled exception", ex); log.error("Unhandled exception", ex);
return ApiResponse.fail("服务异常: " + ex.getMessage()); return ApiResponse.fail("服务异常: " + ex.getMessage());
} }
private boolean isClientAbort(Throwable ex) {
Throwable cursor = ex;
while (cursor != null) {
String className = cursor.getClass().getName();
String message = cursor.getMessage() == null ? "" : cursor.getMessage().toLowerCase();
if (cursor instanceof AsyncRequestNotUsableException
|| cursor instanceof IOException && (message.contains("broken pipe") || message.contains("connection reset"))
|| className.contains("ClientAbortException")) {
return true;
}
cursor = cursor.getCause();
}
return false;
}
} }

View File

@@ -3,6 +3,9 @@ package com.nanri.aiimage.config;
import lombok.Data; import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.boot.context.properties.ConfigurationProperties;
import java.util.ArrayList;
import java.util.List;
@Data @Data
@ConfigurationProperties(prefix = "aiimage.appearance-patent") @ConfigurationProperties(prefix = "aiimage.appearance-patent")
public class AppearancePatentProperties { public class AppearancePatentProperties {
@@ -11,6 +14,8 @@ public class AppearancePatentProperties {
private String cozeWorkflowHistoryPath = "/v1/workflows/{workflow_id}/run_histories/{execute_id}"; private String cozeWorkflowHistoryPath = "/v1/workflows/{workflow_id}/run_histories/{execute_id}";
private String cozeWorkflowId = "7632683471312355338"; private String cozeWorkflowId = "7632683471312355338";
private String cozeToken = ""; private String cozeToken = "";
private List<CozeCredential> cozeCredentials = new ArrayList<>();
private int cozeCredentialStripeSize = 5;
private int cozeBatchSize = 50; private int cozeBatchSize = 50;
private int cozeConnectTimeoutMillis = 10000; private int cozeConnectTimeoutMillis = 10000;
private int cozeReadTimeoutMillis = 60000; private int cozeReadTimeoutMillis = 60000;
@@ -18,4 +23,11 @@ public class AppearancePatentProperties {
private int cozePollTimeoutMillis = 600000; private int cozePollTimeoutMillis = 600000;
private int staleTimeoutMinutes = 20; private int staleTimeoutMinutes = 20;
private String staleFinalizeCron = "0 */2 * * * *"; private String staleFinalizeCron = "0 */2 * * * *";
@Data
public static class CozeCredential {
private String name;
private String workflowId;
private String token;
}
} }

View File

@@ -3,6 +3,9 @@ package com.nanri.aiimage.config;
import lombok.Data; import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.boot.context.properties.ConfigurationProperties;
import java.util.ArrayList;
import java.util.List;
@Data @Data
@ConfigurationProperties(prefix = "aiimage.similar-asin") @ConfigurationProperties(prefix = "aiimage.similar-asin")
public class SimilarAsinProperties { public class SimilarAsinProperties {
@@ -11,6 +14,8 @@ public class SimilarAsinProperties {
private String cozeWorkflowHistoryPath = "/v1/workflows/{workflow_id}/run_histories/{execute_id}"; private String cozeWorkflowHistoryPath = "/v1/workflows/{workflow_id}/run_histories/{execute_id}";
private String cozeWorkflowId = "7635328462404583478"; private String cozeWorkflowId = "7635328462404583478";
private String cozeToken = ""; private String cozeToken = "";
private List<CozeCredential> cozeCredentials = new ArrayList<>();
private int cozeCredentialStripeSize = 5;
private int cozeBatchSize = 50; private int cozeBatchSize = 50;
private int cozeConnectTimeoutMillis = 10000; private int cozeConnectTimeoutMillis = 10000;
private int cozeReadTimeoutMillis = 60000; private int cozeReadTimeoutMillis = 60000;
@@ -18,4 +23,11 @@ public class SimilarAsinProperties {
private int cozePollTimeoutMillis = 600000; private int cozePollTimeoutMillis = 600000;
private int staleTimeoutMinutes = 20; private int staleTimeoutMinutes = 20;
private String staleFinalizeCron = "0 */2 * * * *"; private String staleFinalizeCron = "0 */2 * * * *";
@Data
public static class CozeCredential {
private String name;
private String workflowId;
private String token;
}
} }

View File

@@ -9,6 +9,7 @@ import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import java.util.concurrent.ExecutorService; import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors; import java.util.concurrent.Executors;
import java.util.concurrent.Semaphore;
@Configuration @Configuration
public class TaskFileJobConfig { public class TaskFileJobConfig {
@@ -37,7 +38,23 @@ public class TaskFileJobConfig {
} }
@Bean("cozeTaskExecutor") @Bean("cozeTaskExecutor")
public TaskExecutor cozeTaskExecutor(ExecutorService cozeVirtualThreadExecutor) { public TaskExecutor cozeTaskExecutor(
return new ConcurrentTaskExecutor(cozeVirtualThreadExecutor); ExecutorService cozeVirtualThreadExecutor,
@Value("${aiimage.coze-task.max-concurrent:8}") int maxConcurrent) {
Semaphore semaphore = new Semaphore(Math.max(1, maxConcurrent));
return new ConcurrentTaskExecutor(command -> cozeVirtualThreadExecutor.execute(() -> {
boolean acquired = false;
try {
semaphore.acquire();
acquired = true;
command.run();
} catch (InterruptedException ex) {
Thread.currentThread().interrupt();
} finally {
if (acquired) {
semaphore.release();
}
}
}));
} }
} }

View File

@@ -34,7 +34,7 @@ public class TaskOperationLockConfig implements WebMvcConfigurer {
private static final Pattern TASK_PATH = Pattern.compile(".*/api/([^/]+)/tasks/(\\d+)(?:/.*)?$"); private static final Pattern TASK_PATH = Pattern.compile(".*/api/([^/]+)/tasks/(\\d+)(?:/.*)?$");
private static final Pattern TASK_RESULT_PATH = Pattern.compile(".*/api/([^/]+)/tasks/(\\d+)/result/?$"); private static final Pattern TASK_RESULT_PATH = Pattern.compile(".*/api/([^/]+)/tasks/(\\d+)/result/?$");
private static final Set<String> MUTATING_METHODS = Set.of("POST", "PUT", "PATCH", "DELETE"); private static final Set<String> MUTATING_METHODS = Set.of("POST", "PUT", "PATCH", "DELETE");
private static final long RESULT_SUBMIT_WAIT_MILLIS = 10 * 60 * 1000L; private static final long RESULT_SUBMIT_WAIT_MILLIS = 30 * 1000L;
private static final long DELETE_WAIT_MILLIS = 60 * 1000L; private static final long DELETE_WAIT_MILLIS = 60 * 1000L;
private static final String LOCK_ATTRIBUTE = TaskOperationLockInterceptor.class.getName() + ".LOCK"; private static final String LOCK_ATTRIBUTE = TaskOperationLockInterceptor.class.getName() + ".LOCK";

View File

@@ -4,6 +4,7 @@ import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.nanri.aiimage.config.AppearancePatentProperties; import com.nanri.aiimage.config.AppearancePatentProperties;
import com.nanri.aiimage.modules.appearancepatent.model.dto.AppearancePatentResultRowDto; import com.nanri.aiimage.modules.appearancepatent.model.dto.AppearancePatentResultRowDto;
import com.nanri.aiimage.modules.coze.service.CozeCredentialPoolService;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpHeaders; import org.springframework.http.HttpHeaders;
@@ -19,22 +20,26 @@ import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Locale; import java.util.Locale;
import java.util.Map; import java.util.Map;
import java.util.concurrent.atomic.AtomicLong;
@Component @Component
@RequiredArgsConstructor @RequiredArgsConstructor
@Slf4j @Slf4j
public class AppearancePatentCozeClient { public class AppearancePatentCozeClient {
private static final String MODULE_TYPE = "APPEARANCE_PATENT";
private static final MediaType APPLICATION_JSON_UTF8 = new MediaType("application", "json", StandardCharsets.UTF_8); private static final MediaType APPLICATION_JSON_UTF8 = new MediaType("application", "json", StandardCharsets.UTF_8);
private final AppearancePatentProperties properties; private final AppearancePatentProperties properties;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
private final CozeCredentialPoolService cozeCredentialPoolService;
private final AtomicLong credentialCursor = new AtomicLong();
public List<AppearancePatentResultRowDto> inspect(List<AppearancePatentResultRowDto> rows, String prompt, String apiKey) { public List<AppearancePatentResultRowDto> inspect(List<AppearancePatentResultRowDto> rows, String prompt, String apiKey) {
if (rows == null || rows.isEmpty()) { if (rows == null || rows.isEmpty()) {
return List.of(); return List.of();
} }
if (properties.getCozeToken() == null || properties.getCozeToken().isBlank()) { if (!hasConfiguredCredential()) {
log.warn("[appearance-patent] coze token not configured, keep raw rows size={}", rows.size()); log.warn("[appearance-patent] coze token not configured, keep raw rows size={}", rows.size());
return rows.stream().map(this::copy).toList(); return rows.stream().map(this::copy).toList();
} }
@@ -48,17 +53,31 @@ public class AppearancePatentCozeClient {
} }
public CozeSubmitResponse submitWorkflow(List<AppearancePatentResultRowDto> rows, String prompt, String apiKey) throws Exception { public CozeSubmitResponse submitWorkflow(List<AppearancePatentResultRowDto> rows, String prompt, String apiKey) throws Exception {
JsonNode submitRoot = objectMapper.readTree(postWorkflow(rows, prompt, apiKey)); return submitWorkflow(rows, prompt, apiKey, nextCredential());
}
public CozeSubmitResponse submitWorkflow(List<AppearancePatentResultRowDto> rows,
String prompt,
String apiKey,
CozeCredentialRef credential) throws Exception {
CozeCredentialRef resolvedCredential = resolveCredential(credential);
JsonNode submitRoot = objectMapper.readTree(postWorkflow(rows, prompt, apiKey, resolvedCredential));
ensureSuccess(submitRoot); ensureSuccess(submitRoot);
return new CozeSubmitResponse( return new CozeSubmitResponse(
extractExecuteId(submitRoot), extractExecuteId(submitRoot),
extractResultDataText(submitRoot), extractResultDataText(submitRoot),
writeJson(submitRoot) writeJson(submitRoot),
resolvedCredential.name()
); );
} }
public CozePollResponse pollWorkflow(String executeId) throws Exception { public CozePollResponse pollWorkflow(String executeId) throws Exception {
JsonNode pollRoot = objectMapper.readTree(getWorkflowHistory(executeId)); return pollWorkflow(executeId, null);
}
public CozePollResponse pollWorkflow(String executeId, CozeCredentialRef credential) throws Exception {
CozeCredentialRef resolvedCredential = resolveCredential(credential);
JsonNode pollRoot = objectMapper.readTree(getWorkflowHistory(executeId, resolvedCredential));
ensureSuccess(pollRoot); ensureSuccess(pollRoot);
String status = normalize(resolveWorkflowStatus(pollRoot)).toUpperCase(Locale.ROOT); String status = normalize(resolveWorkflowStatus(pollRoot)).toUpperCase(Locale.ROOT);
String dataText = extractResultDataText(pollRoot); String dataText = extractResultDataText(pollRoot);
@@ -66,7 +85,8 @@ public class AppearancePatentCozeClient {
String failureMessage = isFailedWorkflowStatus(status) String failureMessage = isFailedWorkflowStatus(status)
? firstNonBlank(resolveFailureMessage(pollRoot), "Coze async workflow failed") ? firstNonBlank(resolveFailureMessage(pollRoot), "Coze async workflow failed")
: ""; : "";
return new CozePollResponse(executeId, status, dataText, outputText, failureMessage, writeJson(pollRoot)); return new CozePollResponse(executeId, status, dataText, outputText, failureMessage, writeJson(pollRoot),
resolvedCredential.name());
} }
public List<AppearancePatentResultRowDto> mergeRowsFromDataText(List<AppearancePatentResultRowDto> rows, String dataText) throws Exception { public List<AppearancePatentResultRowDto> mergeRowsFromDataText(List<AppearancePatentResultRowDto> rows, String dataText) throws Exception {
@@ -164,7 +184,8 @@ public class AppearancePatentCozeClient {
} }
private String runWorkflowAsyncAndWait(List<AppearancePatentResultRowDto> rows, String prompt, String apiKey) throws Exception { private String runWorkflowAsyncAndWait(List<AppearancePatentResultRowDto> rows, String prompt, String apiKey) throws Exception {
JsonNode submitRoot = objectMapper.readTree(postWorkflow(rows, prompt, apiKey)); CozeCredentialRef credential = nextCredential();
JsonNode submitRoot = objectMapper.readTree(postWorkflow(rows, prompt, apiKey, credential));
ensureSuccess(submitRoot); ensureSuccess(submitRoot);
String immediateData = extractResultDataText(submitRoot); String immediateData = extractResultDataText(submitRoot);
@@ -180,7 +201,7 @@ public class AppearancePatentCozeClient {
long deadline = System.currentTimeMillis() + Math.max(10000, properties.getCozePollTimeoutMillis()); long deadline = System.currentTimeMillis() + Math.max(10000, properties.getCozePollTimeoutMillis());
while (System.currentTimeMillis() < deadline) { while (System.currentTimeMillis() < deadline) {
ensureNotInterrupted(); ensureNotInterrupted();
JsonNode pollRoot = objectMapper.readTree(getWorkflowHistory(executeId)); JsonNode pollRoot = objectMapper.readTree(getWorkflowHistory(executeId, credential));
ensureSuccess(pollRoot); ensureSuccess(pollRoot);
String dataText = extractResultDataText(pollRoot); String dataText = extractResultDataText(pollRoot);
@@ -205,20 +226,24 @@ public class AppearancePatentCozeClient {
throw new IllegalStateException("Coze async workflow poll timeout"); throw new IllegalStateException("Coze async workflow poll timeout");
} }
private String postWorkflow(List<AppearancePatentResultRowDto> rows, String prompt, String apiKey) { private String postWorkflow(List<AppearancePatentResultRowDto> rows,
String prompt,
String apiKey,
CozeCredentialRef credential) {
Map<String, Object> parameters = buildParameters(rows, prompt, apiKey); Map<String, Object> parameters = buildParameters(rows, prompt, apiKey);
Map<String, Object> body = new LinkedHashMap<>(); Map<String, Object> body = new LinkedHashMap<>();
body.put("workflow_id", properties.getCozeWorkflowId()); body.put("workflow_id", credential.workflowId());
body.put("parameters", parameters); body.put("parameters", parameters);
body.put("is_async", Boolean.TRUE); body.put("is_async", Boolean.TRUE);
log.info("[appearance-patent] coze request url={} body={}", log.info("[appearance-patent] coze request credential={} url={} body={}",
credential.name(),
joinUrl(properties.getCozeBaseUrl(), properties.getCozeWorkflowPath()), joinUrl(properties.getCozeBaseUrl(), properties.getCozeWorkflowPath()),
writeJson(maskCozeRequestBody(body))); writeJson(maskCozeRequestBody(body)));
RestClient.RequestBodySpec request = restClient().post() RestClient.RequestBodySpec request = restClient().post()
.uri(joinUrl(properties.getCozeBaseUrl(), properties.getCozeWorkflowPath())) .uri(joinUrl(properties.getCozeBaseUrl(), properties.getCozeWorkflowPath()))
.headers(headers -> { .headers(headers -> {
headers.setBearerAuth(stripBearer(properties.getCozeToken())); headers.setBearerAuth(stripBearer(credential.token()));
headers.setContentType(APPLICATION_JSON_UTF8); headers.setContentType(APPLICATION_JSON_UTF8);
headers.set(HttpHeaders.ACCEPT_CHARSET, StandardCharsets.UTF_8.name()); headers.set(HttpHeaders.ACCEPT_CHARSET, StandardCharsets.UTF_8.name());
}); });
@@ -233,28 +258,98 @@ public class AppearancePatentCozeClient {
}); });
} }
private String getWorkflowHistory(String executeId) { private String getWorkflowHistory(String executeId, CozeCredentialRef credential) {
String path = properties.getCozeWorkflowHistoryPath() String path = properties.getCozeWorkflowHistoryPath()
.replace("{workflow_id}", properties.getCozeWorkflowId()) .replace("{workflow_id}", credential.workflowId())
.replace("{execute_id}", executeId); .replace("{execute_id}", executeId);
return restClient().get() return restClient().get()
.uri(joinUrl(properties.getCozeBaseUrl(), path)) .uri(joinUrl(properties.getCozeBaseUrl(), path))
.headers(headers -> { .headers(headers -> {
headers.setBearerAuth(stripBearer(properties.getCozeToken())); headers.setBearerAuth(stripBearer(credential.token()));
headers.setContentType(APPLICATION_JSON_UTF8); headers.setContentType(APPLICATION_JSON_UTF8);
headers.set(HttpHeaders.ACCEPT_CHARSET, StandardCharsets.UTF_8.name()); headers.set(HttpHeaders.ACCEPT_CHARSET, StandardCharsets.UTF_8.name());
}) })
.exchange((clientRequest, clientResponse) -> { .exchange((clientRequest, clientResponse) -> {
byte[] responseBytes = StreamUtils.copyToByteArray(clientResponse.getBody()); byte[] responseBytes = StreamUtils.copyToByteArray(clientResponse.getBody());
String responseText = responseBytes.length == 0 ? "" : new String(responseBytes, StandardCharsets.UTF_8); String responseText = responseBytes.length == 0 ? "" : new String(responseBytes, StandardCharsets.UTF_8);
log.info("[appearance-patent] coze history response executeId={} status={} body={}", log.info("[appearance-patent] coze history response credential={} executeId={} status={} body={}",
executeId, credential.name(), executeId,
clientResponse.getStatusCode(), clientResponse.getStatusCode(),
responseText); responseText);
return responseText; return responseText;
}); });
} }
public CozeCredentialRef nextCredential() {
List<CozeCredentialPoolService.CozeCredential> pooledCredentials = cozeCredentialPoolService.listEnabled(MODULE_TYPE);
CozeCredentialPoolService.CozeCredential pooledCredential =
cozeCredentialPoolService.chooseRoundRobin(MODULE_TYPE, pooledCredentials, properties.getCozeCredentialStripeSize());
if (pooledCredential != null) {
return new CozeCredentialRef(pooledCredential.name(), pooledCredential.workflowId(), pooledCredential.token(),
pooledCredential.maxConcurrent());
}
List<CozeCredentialRef> credentials = configuredCredentials();
int stripeSize = Math.max(1, properties.getCozeCredentialStripeSize());
long cursor = Math.max(0L, credentialCursor.getAndIncrement());
int index = (int) ((cursor / stripeSize) % credentials.size());
return credentials.get(index);
}
public CozeCredentialRef credentialByName(String name) {
if (name == null || name.isBlank()) {
return nextCredential();
}
String normalizedName = normalize(name);
for (CozeCredentialRef credential : configuredCredentials()) {
if (normalize(credential.name()).equals(normalizedName)) {
return credential;
}
}
return nextCredential();
}
public boolean hasConfiguredCredential() {
return !configuredCredentials().isEmpty();
}
public int configuredCredentialCount() {
return configuredCredentials().size();
}
private CozeCredentialRef resolveCredential(CozeCredentialRef credential) {
return credential == null ? nextCredential() : credential;
}
private List<CozeCredentialRef> configuredCredentials() {
List<CozeCredentialRef> credentials = new ArrayList<>();
for (CozeCredentialPoolService.CozeCredential credential : cozeCredentialPoolService.listEnabled(MODULE_TYPE)) {
credentials.add(new CozeCredentialRef(credential.name(), credential.workflowId(), credential.token(),
credential.maxConcurrent()));
}
if (!credentials.isEmpty()) {
return credentials;
}
if (properties.getCozeCredentials() != null) {
int index = 1;
for (AppearancePatentProperties.CozeCredential credential : properties.getCozeCredentials()) {
if (credential == null
|| normalize(credential.getWorkflowId()).isBlank()
|| normalize(credential.getToken()).isBlank()) {
continue;
}
String name = firstNonBlank(credential.getName(), "credential-" + index);
credentials.add(new CozeCredentialRef(name, credential.getWorkflowId(), credential.getToken(), Integer.MAX_VALUE));
index++;
}
}
if (credentials.isEmpty()
&& properties.getCozeWorkflowId() != null && !properties.getCozeWorkflowId().isBlank()
&& properties.getCozeToken() != null && !properties.getCozeToken().isBlank()) {
credentials.add(new CozeCredentialRef("default", properties.getCozeWorkflowId(), properties.getCozeToken(), Integer.MAX_VALUE));
}
return credentials;
}
private Map<String, Object> buildParameters(List<AppearancePatentResultRowDto> rows, String prompt, String apiKey) { private Map<String, Object> buildParameters(List<AppearancePatentResultRowDto> rows, String prompt, String apiKey) {
List<String> groupKeys = rows.stream().map(row -> nonBlank(row.getGroupKey(), rowKey(row))).toList(); List<String> groupKeys = rows.stream().map(row -> nonBlank(row.getGroupKey(), rowKey(row))).toList();
List<String> rowIds = rows.stream().map(row -> nonBlank(row.getId(), "")).toList(); List<String> rowIds = rows.stream().map(row -> nonBlank(row.getId(), "")).toList();
@@ -965,7 +1060,8 @@ public class AppearancePatentCozeClient {
public record CozeSubmitResponse( public record CozeSubmitResponse(
String executeId, String executeId,
String immediateData, String immediateData,
String rawResponse String rawResponse,
String credentialName
) { ) {
} }
@@ -975,7 +1071,8 @@ public class AppearancePatentCozeClient {
String dataText, String dataText,
String outputText, String outputText,
String failureMessage, String failureMessage,
String rawResponse String rawResponse,
String credentialName
) { ) {
public boolean hasPayload() { public boolean hasPayload() {
return dataText != null && !dataText.isBlank() || outputText != null && !outputText.isBlank(); return dataText != null && !dataText.isBlank() || outputText != null && !outputText.isBlank();
@@ -1001,6 +1098,14 @@ public class AppearancePatentCozeClient {
} }
} }
public record CozeCredentialRef(
String name,
String workflowId,
String token,
int maxConcurrent
) {
}
private static final class PartialCozeResultException extends RuntimeException { private static final class PartialCozeResultException extends RuntimeException {
private final int resolvedCount; private final int resolvedCount;

View File

@@ -28,6 +28,7 @@ import com.nanri.aiimage.modules.appearancepatent.model.vo.AppearancePatentParse
import com.nanri.aiimage.modules.appearancepatent.model.vo.AppearancePatentTaskBatchVo; import com.nanri.aiimage.modules.appearancepatent.model.vo.AppearancePatentTaskBatchVo;
import com.nanri.aiimage.modules.appearancepatent.model.vo.AppearancePatentTaskDetailVo; import com.nanri.aiimage.modules.appearancepatent.model.vo.AppearancePatentTaskDetailVo;
import com.nanri.aiimage.modules.appearancepatent.model.vo.AppearancePatentTaskItemVo; import com.nanri.aiimage.modules.appearancepatent.model.vo.AppearancePatentTaskItemVo;
import com.nanri.aiimage.modules.coze.service.CozeCredentialPoolService;
import com.nanri.aiimage.modules.file.service.LocalFileStorageService; import com.nanri.aiimage.modules.file.service.LocalFileStorageService;
import com.nanri.aiimage.modules.file.service.oss.OssStorageService; import com.nanri.aiimage.modules.file.service.oss.OssStorageService;
import com.nanri.aiimage.modules.task.mapper.FileResultMapper; import com.nanri.aiimage.modules.task.mapper.FileResultMapper;
@@ -69,6 +70,7 @@ import org.springframework.transaction.support.TransactionTemplate;
import java.io.File; import java.io.File;
import java.io.FileInputStream; import java.io.FileInputStream;
import java.io.FileOutputStream; import java.io.FileOutputStream;
import java.io.IOException;
import java.time.Duration; import java.time.Duration;
import java.time.Instant; import java.time.Instant;
import java.time.LocalDateTime; import java.time.LocalDateTime;
@@ -84,6 +86,8 @@ import java.util.Objects;
import java.util.Set; import java.util.Set;
import java.util.UUID; import java.util.UUID;
import java.util.function.Supplier; import java.util.function.Supplier;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
@Service @Service
@RequiredArgsConstructor @RequiredArgsConstructor
@@ -100,15 +104,17 @@ public class AppearancePatentTaskService {
private static final String COZE_STATUS_DONE = "DONE"; private static final String COZE_STATUS_DONE = "DONE";
private static final String COZE_STATUS_FAILED = "FAILED"; private static final String COZE_STATUS_FAILED = "FAILED";
private static final String CONTENT_TYPE_XLSX = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; private static final String CONTENT_TYPE_XLSX = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
private static final String CONTENT_TYPE_ZIP = "application/zip";
private static final int RESULT_ROWS_READ_RETRY_LIMIT = 3; private static final int RESULT_ROWS_READ_RETRY_LIMIT = 3;
private static final long RESULT_ROWS_READ_RETRY_DELAY_MS = 500L; private static final long RESULT_ROWS_READ_RETRY_DELAY_MS = 500L;
private static final Duration TASK_LOCK_TTL = Duration.ofMinutes(5); private static final Duration TASK_LOCK_TTL = Duration.ofMinutes(5);
private static final long TASK_LOCK_WAIT_MILLIS = 10000L; private static final long TASK_LOCK_WAIT_MILLIS = 10000L;
private static final long TASK_LOCK_RETRY_DELAY_MILLIS = 200L; private static final long TASK_LOCK_RETRY_DELAY_MILLIS = 200L;
private static final Duration COZE_SUBMIT_LOCK_TTL = Duration.ofMinutes(2); private static final Duration COZE_SUBMIT_LOCK_TTL = Duration.ofMinutes(2);
private static final long COZE_SUBMIT_LOCK_WAIT_MILLIS = 180000L; private static final long COZE_SUBMIT_LOCK_WAIT_MILLIS = 1000L;
private static final long COZE_SUBMIT_LOCK_RETRY_DELAY_MILLIS = 500L; private static final long COZE_SUBMIT_LOCK_RETRY_DELAY_MILLIS = 500L;
private static final long COZE_SUBMIT_MIN_INTERVAL_MILLIS = 30000L; private static final long COZE_SUBMIT_MIN_INTERVAL_MILLIS = 30000L;
private static final long PENDING_COZE_RETRY_INTERVAL_MILLIS = 30000L;
private static final int MAX_COZE_SUBMIT_RETRY_COUNT = 5; private static final int MAX_COZE_SUBMIT_RETRY_COUNT = 5;
private static final List<String> RESULT_HEADERS = List.of( private static final List<String> RESULT_HEADERS = List.of(
"id", "id",
@@ -119,7 +125,7 @@ public class AppearancePatentTaskService {
"外观维度(外观设计专利)", "外观维度(外观设计专利)",
"专利维度(发明/实用新型专利)", "专利维度(发明/实用新型专利)",
"结论", "结论",
"status" "状态"
); );
private final LocalFileStorageService localFileStorageService; private final LocalFileStorageService localFileStorageService;
@@ -140,6 +146,7 @@ public class AppearancePatentTaskService {
private final DistributedJobLockService distributedJobLockService; private final DistributedJobLockService distributedJobLockService;
private final TaskDistributedLockService taskDistributedLockService; private final TaskDistributedLockService taskDistributedLockService;
private final InstanceMetadata instanceMetadata; private final InstanceMetadata instanceMetadata;
private final CozeCredentialPoolService cozeCredentialPoolService;
@Autowired @Autowired
@Qualifier("cozeTaskExecutor") @Qualifier("cozeTaskExecutor")
private TaskExecutor cozeTaskExecutor; private TaskExecutor cozeTaskExecutor;
@@ -748,7 +755,7 @@ public class AppearancePatentTaskService {
touchJavaSideTaskActivity(task.getId()); touchJavaSideTaskActivity(task.getId());
} else if (isResultSubmissionComplete(task.getId())) { } else if (isResultSubmissionComplete(task.getId())) {
maybeFinalizeCozeJobLocked(task.getId(), new CozeBatchContext( maybeFinalizeCozeJobLocked(task.getId(), new CozeBatchContext(
job.getId(), result.getId(), null, null, 1, 1, currentInstanceId(), 0)); job.getId(), result.getId(), null, null, 1, 1, currentInstanceId(), 0, null));
} }
} }
@@ -1429,18 +1436,7 @@ public class AppearancePatentTaskService {
@Scheduled(fixedDelayString = "${aiimage.appearance-patent.coze-poll-delay-ms:30000}") @Scheduled(fixedDelayString = "${aiimage.appearance-patent.coze-poll-delay-ms:30000}")
public void pollPendingCozeJobs() { public void pollPendingCozeJobs() {
List<TaskScopeStateEntity> states = taskScopeStateMapper.selectList(new LambdaQueryWrapper<TaskScopeStateEntity>() List<TaskScopeStateEntity> states = listOwnedPendingCozeStates();
.eq(TaskScopeStateEntity::getModuleType, MODULE_TYPE)
.in(TaskScopeStateEntity::getCozeStatus, List.of(COZE_STATUS_SUBMITTED, COZE_STATUS_RUNNING))
.isNotNull(TaskScopeStateEntity::getCozeExecuteId)
.and(wrapper -> wrapper
.apply("JSON_UNQUOTE(JSON_EXTRACT(state_json, '$.ownerInstanceId')) IS NULL")
.or()
.apply("JSON_UNQUOTE(JSON_EXTRACT(state_json, '$.ownerInstanceId')) = ''")
.or()
.apply("JSON_UNQUOTE(JSON_EXTRACT(state_json, '$.ownerInstanceId')) = {0}", currentInstanceId()))
.orderByDesc(TaskScopeStateEntity::getUpdatedAt)
.last("limit 50"));
if (states == null || states.isEmpty()) { if (states == null || states.isEmpty()) {
return; return;
} }
@@ -1464,6 +1460,41 @@ public class AppearancePatentTaskService {
} }
} }
private List<TaskScopeStateEntity> listOwnedPendingCozeStates() {
Map<Long, TaskScopeStateEntity> merged = new LinkedHashMap<>();
for (TaskScopeStateEntity state : queryOwnedPendingCozeStates(false, 50)) {
if (state != null && state.getId() != null) {
merged.put(state.getId(), state);
}
}
for (TaskScopeStateEntity state : queryOwnedPendingCozeStates(true, 50)) {
if (state != null && state.getId() != null) {
merged.putIfAbsent(state.getId(), state);
}
}
return new ArrayList<>(merged.values());
}
private List<TaskScopeStateEntity> queryOwnedPendingCozeStates(boolean oldestFirst, int limit) {
LambdaQueryWrapper<TaskScopeStateEntity> wrapper = new LambdaQueryWrapper<TaskScopeStateEntity>()
.eq(TaskScopeStateEntity::getModuleType, MODULE_TYPE)
.in(TaskScopeStateEntity::getCozeStatus, List.of(COZE_STATUS_SUBMITTED, COZE_STATUS_RUNNING))
.and(ownerWrapper -> ownerWrapper
.apply("JSON_UNQUOTE(JSON_EXTRACT(state_json, '$.ownerInstanceId')) IS NULL")
.or()
.apply("JSON_UNQUOTE(JSON_EXTRACT(state_json, '$.ownerInstanceId')) = ''")
.or()
.apply("JSON_UNQUOTE(JSON_EXTRACT(state_json, '$.ownerInstanceId')) = {0}", currentInstanceId()))
.last("limit " + Math.max(1, Math.min(limit, 100)));
if (oldestFirst) {
wrapper.orderByAsc(TaskScopeStateEntity::getUpdatedAt);
} else {
wrapper.orderByDesc(TaskScopeStateEntity::getUpdatedAt);
}
List<TaskScopeStateEntity> states = taskScopeStateMapper.selectList(wrapper);
return states == null ? List.of() : states;
}
private List<TaskChunkEntity> loadSubmittedChunks(Long taskId) { private List<TaskChunkEntity> loadSubmittedChunks(Long taskId) {
if (taskId == null || taskId <= 0) { if (taskId == null || taskId <= 0) {
return List.of(); return List.of();
@@ -1492,6 +1523,10 @@ public class AppearancePatentTaskService {
if (key.isBlank() || !queuedRowKeys.add(key)) { if (key.isBlank() || !queuedRowKeys.add(key)) {
continue; continue;
} }
AppearancePatentResultRowDto persistedRow = persistedRows.get(key);
if (hasResolvedCozeFields(persistedRow)) {
continue;
}
candidates.add(new CozeCandidate(chunk.getScopeHash(), chunk.getChunkIndex(), row)); candidates.add(new CozeCandidate(chunk.getScopeHash(), chunk.getChunkIndex(), row));
} }
} }
@@ -1530,7 +1565,7 @@ public class AppearancePatentTaskService {
if (chunks == null || chunks.isEmpty()) { if (chunks == null || chunks.isEmpty()) {
return countPendingCozeStates(task.getId()) > 0; return countPendingCozeStates(task.getId()) > 0;
} }
if (properties.getCozeToken() == null || properties.getCozeToken().isBlank()) { if (!cozeClient.hasConfiguredCredential()) {
log.warn("[appearance-patent] coze token not configured, skip async coze taskId={} jobId={}", log.warn("[appearance-patent] coze token not configured, skip async coze taskId={} jobId={}",
task.getId(), job.getId()); task.getId(), job.getId());
return false; return false;
@@ -1586,8 +1621,10 @@ public class AppearancePatentTaskService {
return COZE_STATUS_SUBMITTED.equals(existing.getCozeStatus()) return COZE_STATUS_SUBMITTED.equals(existing.getCozeStatus())
|| COZE_STATUS_RUNNING.equals(existing.getCozeStatus()); || COZE_STATUS_RUNNING.equals(existing.getCozeStatus());
} }
AppearancePatentCozeClient.CozeCredentialRef credential = cozeClient.nextCredential();
try { try {
AppearancePatentCozeClient.CozeSubmitResponse submit = submitCozeWorkflowThrottled(batchRows, prompt, apiKey); AppearancePatentCozeClient.CozeSubmitResponse submit = submitCozeWorkflowThrottled(
batchRows, prompt, apiKey, credential, true);
if (submit.immediateData() != null && !submit.immediateData().isBlank()) { if (submit.immediateData() != null && !submit.immediateData().isBlank()) {
List<AppearancePatentResultRowDto> cozeRows = cozeClient.mergeRowsFromDataText(batchRows, submit.immediateData()); List<AppearancePatentResultRowDto> cozeRows = cozeClient.mergeRowsFromDataText(batchRows, submit.immediateData());
mergeCozeRowsIntoSubmittedChunks(task, cozeRows, allRowsByBaseId); mergeCozeRowsIntoSubmittedChunks(task, cozeRows, allRowsByBaseId);
@@ -1600,19 +1637,84 @@ public class AppearancePatentTaskService {
return false; return false;
} }
saveCozeBatchState(task, result, job, batchRows, batchScopeKey, batchScopeHash, saveCozeBatchState(task, result, job, batchRows, batchScopeKey, batchScopeHash,
batchIndex, batchTotal, submit.executeId()); batchIndex, batchTotal, submit.executeId(), submit.credentialName());
log.info("[appearance-patent] coze async submitted taskId={} jobId={} rows={} batch={}/{} executeId={}", log.info("[appearance-patent] coze async submitted taskId={} jobId={} rows={} batch={}/{} credential={} executeId={}",
task.getId(), job.getId(), batchRows.size(), batchIndex, batchTotal, submit.executeId()); task.getId(), job.getId(), batchRows.size(), batchIndex, batchTotal,
submit.credentialName(), submit.executeId());
return true; return true;
} catch (Exception ex) { } catch (Exception ex) {
String message = firstNonBlank(ex.getMessage(), "Coze submit failed"); String message = firstNonBlank(ex.getMessage(), "Coze submit failed");
log.warn("[appearance-patent] coze async submit failed taskId={} jobId={} rows={} batch={}/{} err={}", log.warn("[appearance-patent] coze async submit failed taskId={} jobId={} rows={} batch={}/{} err={}",
task.getId(), job.getId(), batchRows.size(), batchIndex, batchTotal, message); task.getId(), job.getId(), batchRows.size(), batchIndex, batchTotal, message);
if (isCozeThrottleLockTimeout(message)) {
savePendingCozeBatchState(task, result, job, batchRows, batchScopeKey, batchScopeHash,
batchIndex, batchTotal, message, credential.name());
taskFileJobService.touchRunning(job.getId());
touchJavaSideTaskActivity(task.getId());
return true;
}
mergeCozeRowsIntoSubmittedChunks(task, cozeClient.markRowsFailed(batchRows, message), allRowsByBaseId); mergeCozeRowsIntoSubmittedChunks(task, cozeClient.markRowsFailed(batchRows, message), allRowsByBaseId);
return false; return false;
} }
} }
private boolean isCozeThrottleLockTimeout(String message) {
return normalize(message).toLowerCase(Locale.ROOT).contains("coze submit throttle lock timeout");
}
private void savePendingCozeBatchState(FileTaskEntity task,
FileResultEntity result,
TaskFileJobEntity job,
List<AppearancePatentResultRowDto> batchRows,
String batchScopeKey,
String batchScopeHash,
int batchIndex,
int batchTotal,
String pendingReason,
String credentialName) {
LocalDateTime now = LocalDateTime.now();
CozeBatchContext context = new CozeBatchContext(
job.getId(),
result.getId(),
null,
null,
batchIndex,
batchTotal,
currentInstanceId(),
0,
credentialName
);
String batchPayload = writeJson(batchRows, "serialize pending coze batch payload failed");
String storedBatchPayload = storeSharedCozeBatchPayload(task.getId(), batchScopeHash, batchPayload);
TaskScopeStateEntity state = new TaskScopeStateEntity();
state.setTaskId(task.getId());
state.setModuleType(MODULE_TYPE);
state.setScopeKey(batchScopeKey);
state.setScopeHash(batchScopeHash);
state.setParsedPayloadJson(storedBatchPayload);
state.setStateJson(writeJson(context, "serialize pending coze batch context failed"));
state.setCozeStatus(COZE_STATUS_RUNNING);
state.setCozeSubmittedAt(now);
state.setCozeLastPolledAt(null);
state.setCozeAttemptCount(0);
state.setCozeError(firstNonBlank(pendingReason, "Coze submit queued by throttle"));
state.setChunkTotal(batchTotal);
state.setReceivedChunkCount(batchIndex);
state.setCompleted(0);
state.setCreatedAt(now);
state.setUpdatedAt(now);
try {
taskScopeStateMapper.insert(state);
touchJavaSideTaskActivity(task.getId());
log.info("[appearance-patent] coze async submit queued by throttle taskId={} jobId={} rows={} batch={}/{}",
task.getId(), job.getId(), batchRows.size(), batchIndex, batchTotal);
} catch (DuplicateKeyException ex) {
transientPayloadStorageService.deletePayloadIfPresent(storedBatchPayload);
log.info("[appearance-patent] duplicate pending coze batch state ignored taskId={} scope={}",
task.getId(), batchScopeKey);
}
}
private void saveCozeBatchState(FileTaskEntity task, private void saveCozeBatchState(FileTaskEntity task,
FileResultEntity result, FileResultEntity result,
TaskFileJobEntity job, TaskFileJobEntity job,
@@ -1621,7 +1723,8 @@ public class AppearancePatentTaskService {
String batchScopeHash, String batchScopeHash,
int batchIndex, int batchIndex,
int batchTotal, int batchTotal,
String executeId) { String executeId,
String credentialName) {
LocalDateTime now = LocalDateTime.now(); LocalDateTime now = LocalDateTime.now();
CozeBatchContext context = new CozeBatchContext( CozeBatchContext context = new CozeBatchContext(
job.getId(), job.getId(),
@@ -1631,7 +1734,8 @@ public class AppearancePatentTaskService {
batchIndex, batchIndex,
batchTotal, batchTotal,
currentInstanceId(), currentInstanceId(),
0 0,
credentialName
); );
String batchPayload = writeJson(batchRows, "serialize coze batch payload failed"); String batchPayload = writeJson(batchRows, "serialize coze batch payload failed");
String storedBatchPayload = storeSharedCozeBatchPayload(task.getId(), batchScopeHash, batchPayload); String storedBatchPayload = storeSharedCozeBatchPayload(task.getId(), batchScopeHash, batchPayload);
@@ -1689,6 +1793,10 @@ public class AppearancePatentTaskService {
try { try {
TaskScopeStateEntity state = taskScopeStateMapper.selectById(stateId); TaskScopeStateEntity state = taskScopeStateMapper.selectById(stateId);
if (state == null || state.getCozeExecuteId() == null || state.getCozeExecuteId().isBlank()) { if (state == null || state.getCozeExecuteId() == null || state.getCozeExecuteId().isBlank()) {
if (state != null && state.getCozeExecuteId() == null
&& (COZE_STATUS_SUBMITTED.equals(state.getCozeStatus()) || COZE_STATUS_RUNNING.equals(state.getCozeStatus()))) {
retryPendingCozeSubmitState(state);
}
return; return;
} }
if (!(COZE_STATUS_SUBMITTED.equals(state.getCozeStatus()) || COZE_STATUS_RUNNING.equals(state.getCozeStatus()))) { if (!(COZE_STATUS_SUBMITTED.equals(state.getCozeStatus()) || COZE_STATUS_RUNNING.equals(state.getCozeStatus()))) {
@@ -1715,7 +1823,9 @@ public class AppearancePatentTaskService {
log.info("[appearance-patent] coze poll start taskId={} stateId={} executeId={} jobId={} chunk={} batch={}/{}", log.info("[appearance-patent] coze poll start taskId={} stateId={} executeId={} jobId={} chunk={} batch={}/{}",
state.getTaskId(), state.getId(), state.getCozeExecuteId(), state.getTaskId(), state.getId(), state.getCozeExecuteId(),
context.jobId(), context.chunkIndex(), context.batchIndex(), context.batchTotal()); context.jobId(), context.chunkIndex(), context.batchIndex(), context.batchTotal());
AppearancePatentCozeClient.CozePollResponse poll = cozeClient.pollWorkflow(state.getCozeExecuteId()); AppearancePatentCozeClient.CozePollResponse poll = cozeClient.pollWorkflow(
state.getCozeExecuteId(),
cozeClient.credentialByName(context.credentialName()));
if (!poll.hasPayload() && !poll.isFinished() && !isCozeStateTimedOut(state)) { if (!poll.hasPayload() && !poll.isFinished() && !isCozeStateTimedOut(state)) {
log.info("[appearance-patent] coze poll pending taskId={} stateId={} executeId={} status={}", log.info("[appearance-patent] coze poll pending taskId={} stateId={} executeId={} status={}",
state.getTaskId(), state.getId(), state.getCozeExecuteId(), poll.status()); state.getTaskId(), state.getId(), state.getCozeExecuteId(), poll.status());
@@ -1804,7 +1914,8 @@ public class AppearancePatentTaskService {
} }
try { try {
AppearancePatentCozeClient.CozeSubmitResponse submit = AppearancePatentCozeClient.CozeSubmitResponse submit =
submitCozeWorkflowThrottled(batchRows, readAiPrompt(task), readApiKey(task)); submitCozeWorkflowThrottled(batchRows, readAiPrompt(task), readApiKey(task),
cozeClient.credentialByName(context.credentialName()), false);
Map<String, List<AppearancePatentParsedRowVo>> allRowsByBaseId = loadAllRowsByBaseId(task); Map<String, List<AppearancePatentParsedRowVo>> allRowsByBaseId = loadAllRowsByBaseId(task);
if (submit.immediateData() != null && !submit.immediateData().isBlank()) { if (submit.immediateData() != null && !submit.immediateData().isBlank()) {
List<AppearancePatentResultRowDto> cozeRows = List<AppearancePatentResultRowDto> cozeRows =
@@ -1853,23 +1964,54 @@ public class AppearancePatentTaskService {
private AppearancePatentCozeClient.CozeSubmitResponse submitCozeWorkflowThrottled( private AppearancePatentCozeClient.CozeSubmitResponse submitCozeWorkflowThrottled(
List<AppearancePatentResultRowDto> rows, List<AppearancePatentResultRowDto> rows,
String prompt, String prompt,
String apiKey) throws Exception { String apiKey,
DistributedJobLockService.LockHandle lockHandle = acquireCozeSubmitLock(); AppearancePatentCozeClient.CozeCredentialRef credential,
boolean allowCredentialFallback) throws Exception {
int attempts = allowCredentialFallback ? Math.max(1, cozeClient.configuredCredentialCount()) : 1;
AppearancePatentCozeClient.CozeCredentialRef currentCredential =
credential == null ? cozeClient.nextCredential() : credential;
Exception lastFailure = null;
for (int i = 0; i < attempts; i++) {
DistributedJobLockService.LockHandle lockHandle = acquireCozeSubmitLock(currentCredential);
if (lockHandle == null) { if (lockHandle == null) {
throw new IllegalStateException("Coze submit throttle lock timeout"); lastFailure = new IllegalStateException("Coze submit throttle lock timeout");
currentCredential = cozeClient.nextCredential();
continue;
} }
try (lockHandle) { CozeCredentialPoolService.BorrowedCredential borrowedCredential =
AppearancePatentCozeClient.CozeSubmitResponse response = cozeClient.submitWorkflow(rows, prompt, apiKey); cozeCredentialPoolService.borrow(MODULE_TYPE, toPoolCredential(currentCredential));
if (borrowedCredential == null) {
lockHandle.close();
lastFailure = new IllegalStateException("Coze credential concurrency limit reached");
currentCredential = cozeClient.nextCredential();
continue;
}
try (lockHandle; borrowedCredential) {
return cozeClient.submitWorkflow(rows, prompt, apiKey, currentCredential);
} finally {
sleepQuietly(COZE_SUBMIT_MIN_INTERVAL_MILLIS); sleepQuietly(COZE_SUBMIT_MIN_INTERVAL_MILLIS);
return response;
} }
} }
throw lastFailure == null ? new IllegalStateException("Coze submit failed") : lastFailure;
}
private DistributedJobLockService.LockHandle acquireCozeSubmitLock() { private CozeCredentialPoolService.CozeCredential toPoolCredential(AppearancePatentCozeClient.CozeCredentialRef credential) {
if (credential == null) {
return null;
}
return new CozeCredentialPoolService.CozeCredential(
credential.name(),
credential.workflowId(),
credential.token(),
credential.maxConcurrent());
}
private DistributedJobLockService.LockHandle acquireCozeSubmitLock(AppearancePatentCozeClient.CozeCredentialRef credential) {
long deadline = System.currentTimeMillis() + COZE_SUBMIT_LOCK_WAIT_MILLIS; long deadline = System.currentTimeMillis() + COZE_SUBMIT_LOCK_WAIT_MILLIS;
String credentialName = credential == null ? "default" : firstNonBlank(credential.name(), "default");
while (System.currentTimeMillis() <= deadline) { while (System.currentTimeMillis() <= deadline) {
DistributedJobLockService.LockHandle lockHandle = DistributedJobLockService.LockHandle lockHandle =
distributedJobLockService.tryLock("appearance-patent:coze-submit", COZE_SUBMIT_LOCK_TTL); distributedJobLockService.tryLock("appearance-patent:coze-submit:" + credentialName, COZE_SUBMIT_LOCK_TTL);
if (lockHandle != null) { if (lockHandle != null) {
return lockHandle; return lockHandle;
} }
@@ -1915,14 +2057,16 @@ public class AppearancePatentTaskService {
int partIndex = 1; int partIndex = 1;
for (List<AppearancePatentResultRowDto> partRows : partitions) { for (List<AppearancePatentResultRowDto> partRows : partitions) {
AppearancePatentCozeClient.CozeSubmitResponse submit = AppearancePatentCozeClient.CozeSubmitResponse submit =
submitCozeWorkflowThrottled(partRows, readAiPrompt(task), readApiKey(task)); submitCozeWorkflowThrottled(partRows, readAiPrompt(task), readApiKey(task),
cozeClient.credentialByName(context.credentialName()), false);
if (submit.immediateData() != null && !submit.immediateData().isBlank()) { if (submit.immediateData() != null && !submit.immediateData().isBlank()) {
List<AppearancePatentResultRowDto> cozeRows = List<AppearancePatentResultRowDto> cozeRows =
cozeClient.mergeRowsFromDataText(partRows, submit.immediateData()); cozeClient.mergeRowsFromDataText(partRows, submit.immediateData());
mergeCozeRowsIntoSubmittedChunks(task, cozeRows, allRowsByBaseId); mergeCozeRowsIntoSubmittedChunks(task, cozeRows, allRowsByBaseId);
submittedAny = true; submittedAny = true;
} else if (submit.executeId() != null && !submit.executeId().isBlank()) { } else if (submit.executeId() != null && !submit.executeId().isBlank()) {
saveSplitRetryCozeBatchState(state, context, partRows, partIndex, partitions.size(), retryCount, submit.executeId()); saveSplitRetryCozeBatchState(state, context, partRows, partIndex, partitions.size(), retryCount,
submit.executeId(), submit.credentialName());
submittedAny = true; submittedAny = true;
} }
partIndex++; partIndex++;
@@ -1950,7 +2094,8 @@ public class AppearancePatentTaskService {
int partIndex, int partIndex,
int partTotal, int partTotal,
int retryCount, int retryCount,
String executeId) { String executeId,
String credentialName) {
String scopeKey = parent.getScopeKey() + ":split:" + retryCount + ":" + partIndex; String scopeKey = parent.getScopeKey() + ":split:" + retryCount + ":" + partIndex;
String scopeHash = DigestUtil.sha256Hex(scopeKey); String scopeHash = DigestUtil.sha256Hex(scopeKey);
CozeBatchContext context = new CozeBatchContext( CozeBatchContext context = new CozeBatchContext(
@@ -1961,7 +2106,8 @@ public class AppearancePatentTaskService {
partIndex, partIndex,
partTotal, partTotal,
parentContext.ownerInstanceId(), parentContext.ownerInstanceId(),
retryCount retryCount,
firstNonBlank(credentialName, parentContext.credentialName())
); );
LocalDateTime now = LocalDateTime.now(); LocalDateTime now = LocalDateTime.now();
String batchPayload = writeJson(batchRows, "serialize split coze batch payload failed"); String batchPayload = writeJson(batchRows, "serialize split coze batch payload failed");
@@ -2058,6 +2204,24 @@ public class AppearancePatentTaskService {
.set(TaskScopeStateEntity::getUpdatedAt, now)) > 0; .set(TaskScopeStateEntity::getUpdatedAt, now)) > 0;
} }
private boolean tryClaimPendingCozeSubmitState(TaskScopeStateEntity state) {
if (state == null || state.getId() == null) {
return false;
}
LocalDateTime now = LocalDateTime.now();
long intervalMillis = Math.max(1000L, PENDING_COZE_RETRY_INTERVAL_MILLIS);
return taskScopeStateMapper.update(null, new LambdaUpdateWrapper<TaskScopeStateEntity>()
.eq(TaskScopeStateEntity::getId, state.getId())
.in(TaskScopeStateEntity::getCozeStatus, List.of(COZE_STATUS_SUBMITTED, COZE_STATUS_RUNNING))
.and(wrapper -> wrapper
.isNull(TaskScopeStateEntity::getCozeLastPolledAt)
.or()
.le(TaskScopeStateEntity::getCozeLastPolledAt, now.minus(Duration.ofMillis(intervalMillis))))
.set(TaskScopeStateEntity::getCozeStatus, COZE_STATUS_RUNNING)
.set(TaskScopeStateEntity::getCozeLastPolledAt, now)
.set(TaskScopeStateEntity::getUpdatedAt, now)) > 0;
}
private void markCozeStateTerminal(TaskScopeStateEntity state, String status, String error) { private void markCozeStateTerminal(TaskScopeStateEntity state, String status, String error) {
taskScopeStateMapper.update(null, new LambdaUpdateWrapper<TaskScopeStateEntity>() taskScopeStateMapper.update(null, new LambdaUpdateWrapper<TaskScopeStateEntity>()
.eq(TaskScopeStateEntity::getId, state.getId()) .eq(TaskScopeStateEntity::getId, state.getId())
@@ -2275,7 +2439,8 @@ public class AppearancePatentTaskService {
context.batchIndex(), context.batchIndex(),
context.batchTotal(), context.batchTotal(),
context.ownerInstanceId(), context.ownerInstanceId(),
submitRetryCount submitRetryCount,
context.credentialName()
); );
} }
@@ -2374,6 +2539,115 @@ public class AppearancePatentTaskService {
return "task:" + taskId + ":owner:" + firstNonBlank(ownerFromTask(task), currentInstanceId()); return "task:" + taskId + ":owner:" + firstNonBlank(ownerFromTask(task), currentInstanceId());
} }
private void retryPendingCozeSubmitState(TaskScopeStateEntity state) {
if (state == null || state.getId() == null) {
return;
}
if (!tryClaimPendingCozeSubmitState(state)) {
log.info("[appearance-patent] coze submit retry skipped by claim guard taskId={} stateId={} lastPolledAt={}",
state.getTaskId(), state.getId(), state.getCozeLastPolledAt());
return;
}
CozeBatchContext context = readCozeBatchContext(state);
if (context == null || context.jobId() == null || context.resultId() == null) {
markCozeStateTerminal(state, COZE_STATUS_FAILED, "Coze pending submit context missing");
return;
}
List<AppearancePatentResultRowDto> batchRows = readCozeBatchRows(state);
if (batchRows.isEmpty()) {
markCozeStateTerminal(state, COZE_STATUS_FAILED, "Coze pending submit payload missing");
maybeFinalizeCozeJobLocked(state.getTaskId(), context);
return;
}
FileTaskEntity task = fileTaskMapper.selectById(state.getTaskId());
if (task == null || !MODULE_TYPE.equals(task.getModuleType())) {
markCozeStateTerminal(state, COZE_STATUS_FAILED, "Coze pending submit task missing");
return;
}
Map<String, AppearancePatentResultRowDto> currentRows = loadPersistedResultRows(task.getId());
List<AppearancePatentResultRowDto> currentBatchRows = batchRows.stream()
.map(row -> currentRows.get(rowKey(row)))
.filter(Objects::nonNull)
.toList();
if (currentBatchRows.size() == batchRows.size()
&& currentBatchRows.stream().allMatch(this::hasResolvedCozeFields)) {
markCozeStateTerminal(state, COZE_STATUS_DONE, "Coze rows already resolved by another batch");
maybeFinalizeCozeJobLocked(state.getTaskId(), context);
log.info("[appearance-patent] coze pending submit skipped because rows already resolved taskId={} stateId={} jobId={} rows={}",
state.getTaskId(), state.getId(), context.jobId(), batchRows.size());
return;
}
try {
taskFileJobService.touchRunning(context.jobId());
AppearancePatentCozeClient.CozeSubmitResponse submit =
submitCozeWorkflowThrottled(batchRows, readAiPrompt(task), readApiKey(task),
cozeClient.credentialByName(context.credentialName()), false);
Map<String, List<AppearancePatentParsedRowVo>> allRowsByBaseId = loadAllRowsByBaseId(task);
if (submit.immediateData() != null && !submit.immediateData().isBlank()) {
List<AppearancePatentResultRowDto> cozeRows =
cozeClient.mergeRowsFromDataText(batchRows, submit.immediateData());
mergeCozeRowsIntoSubmittedChunks(task, cozeRows, allRowsByBaseId);
markCozeStateTerminal(state, COZE_STATUS_DONE, null);
maybeFinalizeCozeJobLocked(state.getTaskId(), context);
return;
}
if (submit.executeId() == null || submit.executeId().isBlank()) {
markCozeStateTerminal(state, COZE_STATUS_FAILED, "Coze async execute_id missing");
mergeCozeRowsIntoSubmittedChunks(task,
cozeClient.markRowsFailed(batchRows, "Coze async execute_id missing"),
allRowsByBaseId);
maybeFinalizeCozeJobLocked(state.getTaskId(), context);
return;
}
LocalDateTime now = LocalDateTime.now();
taskScopeStateMapper.update(null, new LambdaUpdateWrapper<TaskScopeStateEntity>()
.eq(TaskScopeStateEntity::getId, state.getId())
.in(TaskScopeStateEntity::getCozeStatus, List.of(COZE_STATUS_SUBMITTED, COZE_STATUS_RUNNING))
.set(TaskScopeStateEntity::getCozeExecuteId, submit.executeId())
.set(TaskScopeStateEntity::getCozeStatus, COZE_STATUS_SUBMITTED)
.set(TaskScopeStateEntity::getCozeSubmittedAt, now)
.set(TaskScopeStateEntity::getCozeLastPolledAt, null)
.set(TaskScopeStateEntity::getCozeError, null)
.set(TaskScopeStateEntity::getUpdatedAt, now));
touchJavaSideTaskActivity(state.getTaskId());
log.info("[appearance-patent] coze pending submit retried taskId={} stateId={} jobId={} rows={} executeId={}",
state.getTaskId(), state.getId(), context.jobId(), batchRows.size(), submit.executeId());
} catch (Exception ex) {
String message = firstNonBlank(ex.getMessage(), "Coze pending submit retry failed");
log.warn("[appearance-patent] coze pending submit retry failed taskId={} stateId={} jobId={} rows={} err={}",
state.getTaskId(), state.getId(), context.jobId(), batchRows.size(), message);
if (isCozeThrottleLockTimeout(message)) {
keepPendingCozeSubmitState(state, message);
return;
}
int nextAttemptCount = cozeAttemptCount(state) + 1;
if (nextAttemptCount >= MAX_COZE_SUBMIT_RETRY_COUNT) {
Map<String, List<AppearancePatentParsedRowVo>> allRowsByBaseId = loadAllRowsByBaseId(task);
String finalMessage = message + " after " + nextAttemptCount + " submit attempts";
mergeCozeRowsIntoSubmittedChunks(task,
cozeClient.markRowsFailed(batchRows, finalMessage),
allRowsByBaseId);
markCozeStateTerminal(state, COZE_STATUS_FAILED, finalMessage);
maybeFinalizeCozeJobLocked(state.getTaskId(), context);
return;
}
updateCozeStateRunning(state, message);
}
}
private void keepPendingCozeSubmitState(TaskScopeStateEntity state, String error) {
int updated = taskScopeStateMapper.update(null, new LambdaUpdateWrapper<TaskScopeStateEntity>()
.eq(TaskScopeStateEntity::getId, state.getId())
.in(TaskScopeStateEntity::getCozeStatus, List.of(COZE_STATUS_SUBMITTED, COZE_STATUS_RUNNING))
.set(TaskScopeStateEntity::getCozeStatus, COZE_STATUS_RUNNING)
.set(TaskScopeStateEntity::getCozeExecuteId, null)
.set(TaskScopeStateEntity::getCozeError, error)
.set(TaskScopeStateEntity::getUpdatedAt, LocalDateTime.now()));
if (updated > 0) {
touchJavaSideTaskActivity(state.getTaskId());
}
}
private String currentInstanceId() { private String currentInstanceId() {
String instanceId = instanceMetadata == null ? null : instanceMetadata.getInstanceId(); String instanceId = instanceMetadata == null ? null : instanceMetadata.getInstanceId();
return firstNonBlank(instanceId, "unknown-instance"); return firstNonBlank(instanceId, "unknown-instance");
@@ -2475,6 +2749,12 @@ public class AppearancePatentTaskService {
} }
int safeTotal = Math.max(1, total); int safeTotal = Math.max(1, total);
int safeCompleted = Math.max(0, Math.min(completed, safeTotal)); int safeCompleted = Math.max(0, Math.min(completed, safeTotal));
TaskProgressSnapshotEntity existing = taskProgressSnapshotService.find(task.getId(), MODULE_TYPE);
int displayPercent = calculateDisplayProgressPercent(safeCompleted, safeTotal, job, LocalDateTime.now());
int previousDisplayPercent = calculateSnapshotDisplayPercent(existing, job);
if (previousDisplayPercent > displayPercent && safeCompleted < safeTotal) {
displayPercent = previousDisplayPercent;
}
taskProgressSnapshotService.save( taskProgressSnapshotService.save(
task.getId(), task.getId(),
MODULE_TYPE, MODULE_TYPE,
@@ -2484,10 +2764,57 @@ public class AppearancePatentTaskService {
0, 0,
job.getScopeKey(), job.getScopeKey(),
message, message,
Map.of("phase", "RESULT_FILE", "jobId", job.getId()) Map.of("phase", "RESULT_FILE", "jobId", job.getId(), "displayPercent", displayPercent)
); );
} }
private int calculateDisplayProgressPercent(int current,
int total,
TaskFileJobEntity job,
LocalDateTime baseTime) {
if (total <= 0) {
return 0;
}
current = Math.max(0, Math.min(current, total));
int percent = Math.max(1, Math.min(99, (int) Math.floor(current * 100.0 / total)));
if (job != null && STATUS_RUNNING.equals(job.getStatus())) {
long elapsedSeconds = baseTime == null ? 0 : Math.max(0, Duration.between(baseTime, LocalDateTime.now()).getSeconds());
if (current <= 0) {
int firstRealProgressPercent = Math.max(1, Math.min(99, (int) Math.floor(100.0 / total)));
int waitingCap = Math.max(8, Math.min(35, firstRealProgressPercent - 1));
percent = Math.max(percent, Math.min(waitingCap, 8 + (int) (elapsedSeconds / 6)));
} else if (current < total) {
percent = Math.max(percent, Math.min(92, percent + (int) (elapsedSeconds / 10)));
}
}
return percent;
}
private int calculateSnapshotDisplayPercent(TaskProgressSnapshotEntity snapshot, TaskFileJobEntity job) {
if (snapshot == null) {
return 0;
}
int total = snapshot.getTotalCount() == null ? 0 : snapshot.getTotalCount();
int current = snapshot.getSuccessCount() == null ? 0 : snapshot.getSuccessCount();
int percent = total <= 0 ? 0 : calculateDisplayProgressPercent(current, total, job, snapshot.getUpdatedAt());
return Math.max(percent, extractSnapshotDisplayPercent(snapshot));
}
private int extractSnapshotDisplayPercent(TaskProgressSnapshotEntity snapshot) {
if (snapshot == null || snapshot.getSnapshotJson() == null || snapshot.getSnapshotJson().isBlank()) {
return 0;
}
try {
JsonNode displayPercent = objectMapper.readTree(snapshot.getSnapshotJson()).path("displayPercent");
if (!displayPercent.isNumber()) {
return 0;
}
return Math.max(0, Math.min(100, displayPercent.asInt()));
} catch (Exception ignored) {
return 0;
}
}
private void saveCozePipelineProgress(FileTaskEntity task, TaskFileJobEntity job) { private void saveCozePipelineProgress(FileTaskEntity task, TaskFileJobEntity job) {
if (task == null || task.getId() == null || job == null || job.getId() == null) { if (task == null || task.getId() == null || job == null || job.getId() == null) {
return; return;
@@ -2565,26 +2892,158 @@ public class AppearancePatentTaskService {
if (!outputDir.exists() && !outputDir.mkdirs()) { if (!outputDir.exists() && !outputDir.mkdirs()) {
throw new BusinessException("创建结果目录失败"); throw new BusinessException("创建结果目录失败");
} }
String filename = safeFileStem(result.getSourceFilename()) + "-result.xlsx"; List<SourceRows> sourceRows = splitRowsBySourceFile(parsed, receivedRows, result.getSourceFilename());
String tempFilename = safeFileStem(result.getSourceFilename()) List<SourceResultWorkbook> workbooks = new ArrayList<>();
File zip = null;
try {
for (SourceRows item : sourceRows) {
String filename = safeFileStem(item.sourceFilename()) + "-result.xlsx";
String tempFilename = safeFileStem(item.sourceFilename())
+ "-" + task.getId() + "-" + task.getId()
+ "-" + result.getId() + "-" + result.getId()
+ "-" + UUID.randomUUID() + "-" + UUID.randomUUID()
+ "-result.xlsx"; + "-result.xlsx";
File xlsx = new File(outputDir, tempFilename); File xlsx = new File(outputDir, tempFilename);
try { writeResultWorkbook(xlsx, parsed, item.rows(), resultMap);
writeResultWorkbook(xlsx, parsed, receivedRows, resultMap); workbooks.add(new SourceResultWorkbook(xlsx, filename, item.rows().size()));
String objectKey = ossStorageService.uploadResultFile(xlsx, MODULE_TYPE); }
if (workbooks.isEmpty()) {
throw new BusinessException("外观专利检测结果为空,请稍后重试生成结果文件");
}
File uploadFile;
String filename;
String contentType;
if (workbooks.size() == 1) {
SourceResultWorkbook workbook = workbooks.get(0);
uploadFile = workbook.file();
filename = workbook.filename();
contentType = CONTENT_TYPE_XLSX;
} else {
filename = safeFileStem(result.getSourceFilename()) + "-result.zip";
String tempFilename = safeFileStem(result.getSourceFilename())
+ "-" + task.getId()
+ "-" + result.getId()
+ "-" + UUID.randomUUID()
+ "-result.zip";
zip = new File(outputDir, tempFilename);
packageResultWorkbooksAsZip(zip, workbooks);
uploadFile = zip;
contentType = CONTENT_TYPE_ZIP;
}
String objectKey = ossStorageService.uploadResultFile(uploadFile, MODULE_TYPE);
result.setResultFilename(filename); result.setResultFilename(filename);
result.setResultFileUrl(objectKey); result.setResultFileUrl(objectKey);
result.setResultFileSize(xlsx.length()); result.setResultFileSize(uploadFile.length());
result.setResultContentType(CONTENT_TYPE_XLSX); result.setResultContentType(contentType);
result.setRowCount(receivedRows.size()); result.setRowCount(receivedRows.size());
} finally { } finally {
if (xlsx.exists() && !xlsx.delete()) { for (SourceResultWorkbook workbook : workbooks) {
log.warn("[appearance-patent] delete temp xlsx failed file={}", xlsx); if (workbook.file().exists() && !workbook.file().delete()) {
log.warn("[appearance-patent] delete temp xlsx failed file={}", workbook.file());
} }
} }
if (zip != null && zip.exists() && !zip.delete()) {
log.warn("[appearance-patent] delete temp zip failed file={}", zip);
}
}
}
private List<SourceRows> splitRowsBySourceFile(AppearancePatentParsedPayloadDto parsed,
List<AppearancePatentParsedRowVo> receivedRows,
String fallbackFilename) {
String defaultFilename = firstNonBlank(fallbackFilename, "appearance-patent");
Map<String, SourceRowsBuilder> builders = new LinkedHashMap<>();
if (parsed != null && parsed.getSourceFiles() != null) {
for (AppearancePatentSourceFileDto sourceFile : parsed.getSourceFiles()) {
if (sourceFile == null) {
continue;
}
String sourceFileKey = normalize(sourceFile.getFileKey());
if (sourceFileKey.isBlank()) {
continue;
}
builders.putIfAbsent(sourceFileKey,
new SourceRowsBuilder(sourceFileKey, firstNonBlank(sourceFile.getOriginalFilename(), defaultFilename)));
}
}
for (AppearancePatentParsedRowVo row : receivedRows == null ? List.<AppearancePatentParsedRowVo>of() : receivedRows) {
if (row == null) {
continue;
}
String sourceFileKey = normalize(row.getSourceFileKey());
if (sourceFileKey.isBlank()) {
sourceFileKey = normalize(row.getSourceFilename());
}
if (sourceFileKey.isBlank()) {
sourceFileKey = "__default__";
}
SourceRowsBuilder builder = builders.computeIfAbsent(sourceFileKey,
key -> new SourceRowsBuilder(key, firstNonBlank(row.getSourceFilename(), defaultFilename)));
if (builder.sourceFilename().isBlank() || Objects.equals(builder.sourceFilename(), defaultFilename)) {
builder.setSourceFilename(firstNonBlank(row.getSourceFilename(), defaultFilename));
}
builder.rows().add(row);
}
return builders.values().stream()
.filter(builder -> !builder.rows().isEmpty())
.map(builder -> new SourceRows(builder.sourceFileKey(), firstNonBlank(builder.sourceFilename(), defaultFilename), builder.rows()))
.toList();
}
private void packageResultWorkbooksAsZip(File zip, List<SourceResultWorkbook> workbooks) {
Set<String> entryNames = new LinkedHashSet<>();
try (ZipOutputStream zos = new ZipOutputStream(new FileOutputStream(zip))) {
byte[] buffer = new byte[8192];
for (SourceResultWorkbook workbook : workbooks) {
writeResultZipEntry(zos, buffer, workbook.file(), uniqueZipEntryName(workbook.filename(), entryNames));
}
} catch (IOException ex) {
throw new BusinessException("生成外观专利检测结果压缩包失败");
}
}
private void writeResultZipEntry(ZipOutputStream zos,
byte[] buffer,
File file,
String entryName) throws IOException {
zos.putNextEntry(new ZipEntry(entryName));
try (FileInputStream inputStream = new FileInputStream(file)) {
int len;
while ((len = inputStream.read(buffer)) > 0) {
zos.write(buffer, 0, len);
}
}
zos.closeEntry();
}
private String uniqueZipEntryName(String filename, Set<String> entryNames) {
String safeFilename = normalizeZipFilename(filename);
if (entryNames.add(safeFilename)) {
return safeFilename;
}
int dotIndex = safeFilename.lastIndexOf('.');
String stem = dotIndex > 0 ? safeFilename.substring(0, dotIndex) : safeFilename;
String extension = dotIndex > 0 ? safeFilename.substring(dotIndex) : "";
int index = 2;
while (true) {
String candidate = stem + "(" + index + ")" + extension;
if (entryNames.add(candidate)) {
return candidate;
}
index++;
}
}
private String normalizeZipFilename(String filename) {
String normalized = firstNonBlank(filename, "appearance-patent-result.xlsx").replace('\\', '/');
while (normalized.startsWith("/")) {
normalized = normalized.substring(1);
}
return normalized.isBlank() ? "appearance-patent-result.xlsx" : normalized;
} }
private List<AppearancePatentParsedRowVo> filterReceivedParsedRows(List<AppearancePatentParsedRowVo> parsedRows, private List<AppearancePatentParsedRowVo> filterReceivedParsedRows(List<AppearancePatentParsedRowVo> parsedRows,
@@ -3141,19 +3600,10 @@ public class AppearancePatentTaskService {
if (total <= 0) { if (total <= 0) {
return; return;
} }
current = Math.max(0, Math.min(current, total)); LocalDateTime baseTime = snapshot.getUpdatedAt() != null ? snapshot.getUpdatedAt() : (job == null ? null : job.getUpdatedAt());
int percent = Math.max(1, Math.min(99, (int) Math.floor(current * 100.0 / total))); int percent = calculateDisplayProgressPercent(current, total, job, baseTime);
if (job != null && STATUS_RUNNING.equals(job.getStatus())) { percent = Math.max(percent, extractSnapshotDisplayPercent(snapshot));
LocalDateTime baseTime = snapshot.getUpdatedAt() != null ? snapshot.getUpdatedAt() : job.getUpdatedAt(); percent = Boolean.TRUE.equals(vo.getFileReady()) ? 100 : Math.min(99, percent);
long elapsedSeconds = baseTime == null ? 0 : Math.max(0, Duration.between(baseTime, LocalDateTime.now()).getSeconds());
if (current <= 0) {
int firstRealProgressPercent = Math.max(1, Math.min(99, (int) Math.floor(100.0 / total)));
int waitingCap = Math.max(8, Math.min(35, firstRealProgressPercent - 1));
percent = Math.max(percent, Math.min(waitingCap, 8 + (int) (elapsedSeconds / 6)));
} else if (current < total) {
percent = Math.max(percent, Math.min(92, percent + (int) (elapsedSeconds / 10)));
}
}
vo.setFileProgressCurrent(current); vo.setFileProgressCurrent(current);
vo.setFileProgressTotal(total); vo.setFileProgressTotal(total);
vo.setFileProgressPercent(percent); vo.setFileProgressPercent(percent);
@@ -3514,7 +3964,8 @@ public class AppearancePatentTaskService {
Integer batchIndex, Integer batchIndex,
Integer batchTotal, Integer batchTotal,
String ownerInstanceId, String ownerInstanceId,
Integer submitRetryCount) { Integer submitRetryCount,
String credentialName) {
} }
private record CozeCandidate(String chunkScopeHash, private record CozeCandidate(String chunkScopeHash,
@@ -3522,6 +3973,43 @@ public class AppearancePatentTaskService {
AppearancePatentResultRowDto row) { AppearancePatentResultRowDto row) {
} }
private static class SourceRowsBuilder {
private final String sourceFileKey;
private String sourceFilename;
private final List<AppearancePatentParsedRowVo> rows = new ArrayList<>();
private SourceRowsBuilder(String sourceFileKey, String sourceFilename) {
this.sourceFileKey = sourceFileKey;
this.sourceFilename = sourceFilename;
}
private String sourceFileKey() {
return sourceFileKey;
}
private String sourceFilename() {
return sourceFilename;
}
private void setSourceFilename(String sourceFilename) {
this.sourceFilename = sourceFilename;
}
private List<AppearancePatentParsedRowVo> rows() {
return rows;
}
}
private record SourceRows(String sourceFileKey,
String sourceFilename,
List<AppearancePatentParsedRowVo> rows) {
}
private record SourceResultWorkbook(File file,
String filename,
int rowCount) {
}
private record ParsedWorkbook(int totalRows, int droppedRows, List<String> headers, List<AppearancePatentParsedRowVo> allRows) { private record ParsedWorkbook(int totalRows, int droppedRows, List<String> headers, List<AppearancePatentParsedRowVo> allRows) {
} }
} }

View File

@@ -0,0 +1,9 @@
package com.nanri.aiimage.modules.coze.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.nanri.aiimage.modules.coze.model.entity.CozeCredentialEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface CozeCredentialMapper extends BaseMapper<CozeCredentialEntity> {
}

View File

@@ -0,0 +1,25 @@
package com.nanri.aiimage.modules.coze.model.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("biz_coze_credential")
public class CozeCredentialEntity {
@TableId(type = IdType.AUTO)
private Long id;
private String moduleType;
private String credentialName;
private String workflowId;
private String token;
private Integer enabled;
private Integer maxConcurrent;
private Integer sortOrder;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}

View File

@@ -0,0 +1,182 @@
package com.nanri.aiimage.modules.coze.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.nanri.aiimage.modules.coze.mapper.CozeCredentialMapper;
import com.nanri.aiimage.modules.coze.model.entity.CozeCredentialEntity;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import java.time.Duration;
import java.util.List;
import java.util.Objects;
@Slf4j
@Service
@RequiredArgsConstructor
public class CozeCredentialPoolService {
private static final Duration INFLIGHT_TTL = Duration.ofMinutes(30);
private final CozeCredentialMapper cozeCredentialMapper;
private final StringRedisTemplate stringRedisTemplate;
public List<CozeCredential> listEnabled(String moduleType) {
if (moduleType == null || moduleType.isBlank()) {
return List.of();
}
try {
List<CozeCredentialEntity> rows = cozeCredentialMapper.selectList(new LambdaQueryWrapper<CozeCredentialEntity>()
.eq(CozeCredentialEntity::getModuleType, moduleType)
.eq(CozeCredentialEntity::getEnabled, 1)
.orderByAsc(CozeCredentialEntity::getSortOrder)
.orderByAsc(CozeCredentialEntity::getId));
if (rows == null || rows.isEmpty()) {
return List.of();
}
return rows.stream()
.filter(Objects::nonNull)
.filter(row -> !blank(row.getCredentialName())
&& !blank(row.getWorkflowId())
&& !blank(row.getToken()))
.map(row -> new CozeCredential(
row.getCredentialName().trim(),
row.getWorkflowId().trim(),
row.getToken().trim(),
row.getMaxConcurrent() == null || row.getMaxConcurrent() <= 0
? Integer.MAX_VALUE
: row.getMaxConcurrent()))
.toList();
} catch (Exception ex) {
log.warn("[coze-credential] list enabled failed moduleType={} err={}", moduleType, ex.getMessage());
return List.of();
}
}
public CozeCredential chooseLeastInflight(String moduleType, List<CozeCredential> credentials) {
return chooseRoundRobin(moduleType, credentials, 1);
}
public CozeCredential chooseRoundRobin(String moduleType, List<CozeCredential> credentials, int stripeSize) {
if (credentials == null || credentials.isEmpty()) {
return null;
}
int safeStripeSize = Math.max(1, stripeSize);
long cursor = nextCursor(moduleType);
int index = (int) ((Math.max(0L, cursor) / safeStripeSize) % credentials.size());
return credentials.get(index);
}
public BorrowedCredential borrow(String moduleType, CozeCredential credential) {
if (credential == null) {
return null;
}
String key = inflightKey(moduleType, credential.name());
try {
Long value = stringRedisTemplate.opsForValue().increment(key);
stringRedisTemplate.expire(key, INFLIGHT_TTL);
long inflight = value == null ? 0L : value;
if (inflight > credential.maxConcurrent()) {
release(moduleType, credential.name());
return null;
}
return new BorrowedCredential(this, moduleType, credential.name());
} catch (Exception ex) {
log.warn("[coze-credential] borrow failed moduleType={} credential={} err={}",
moduleType, credential.name(), ex.getMessage());
return BorrowedCredential.noop();
}
}
public void release(String moduleType, String credentialName) {
if (blank(moduleType) || blank(credentialName)) {
return;
}
try {
Long value = stringRedisTemplate.opsForValue().decrement(inflightKey(moduleType, credentialName));
if (value != null && value <= 0L) {
stringRedisTemplate.delete(inflightKey(moduleType, credentialName));
}
} catch (Exception ex) {
log.warn("[coze-credential] release failed moduleType={} credential={} err={}",
moduleType, credentialName, ex.getMessage());
}
}
private long inflight(String moduleType, String credentialName) {
try {
String raw = stringRedisTemplate.opsForValue().get(inflightKey(moduleType, credentialName));
return raw == null || raw.isBlank() ? 0L : Long.parseLong(raw);
} catch (Exception ex) {
return 0L;
}
}
private long nextCursor(String moduleType) {
if (blank(moduleType)) {
return 0L;
}
try {
Long value = stringRedisTemplate.opsForValue().increment(cursorKey(moduleType));
stringRedisTemplate.expire(cursorKey(moduleType), Duration.ofDays(7));
return value == null ? 0L : Math.max(0L, value - 1L);
} catch (Exception ex) {
log.warn("[coze-credential] cursor increment failed moduleType={} err={}", moduleType, ex.getMessage());
return System.nanoTime();
}
}
private String inflightKey(String moduleType, String credentialName) {
return "coze:credential:inflight:" + moduleType + ":" + credentialName;
}
private String cursorKey(String moduleType) {
return "coze:credential:cursor:" + moduleType;
}
private boolean blank(String value) {
return value == null || value.isBlank();
}
public record CozeCredential(String name,
String workflowId,
String token,
int maxConcurrent) {
}
public static final class BorrowedCredential implements AutoCloseable {
private final CozeCredentialPoolService owner;
private final String moduleType;
private final String credentialName;
private final boolean noop;
private boolean released;
private BorrowedCredential(CozeCredentialPoolService owner, String moduleType, String credentialName) {
this.owner = owner;
this.moduleType = moduleType;
this.credentialName = credentialName;
this.noop = false;
}
private BorrowedCredential() {
this.owner = null;
this.moduleType = null;
this.credentialName = null;
this.noop = true;
}
private static BorrowedCredential noop() {
return new BorrowedCredential();
}
@Override
public void close() {
if (released || noop) {
return;
}
released = true;
owner.release(moduleType, credentialName);
}
}
}

View File

@@ -16,6 +16,7 @@ import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
@RestController @RestController
@@ -28,8 +29,26 @@ public class ProductCategoryController {
@GetMapping("/product-categories") @GetMapping("/product-categories")
@Operation(summary = "查询商品类目树") @Operation(summary = "查询商品类目树")
public ApiResponse<ProductCategoryListVo> list() { public ApiResponse<ProductCategoryListVo> list(@RequestParam(value = "keyword", required = false) String keyword) {
return ApiResponse.success(productCategoryService.list()); return ApiResponse.success(productCategoryService.list(keyword));
}
@GetMapping("/product-categories/children")
@Operation(summary = "分页查询指定父级下的商品类目")
public ApiResponse<ProductCategoryListVo> children(
@RequestParam(value = "parentId", required = false) Long parentId,
@RequestParam(value = "page", defaultValue = "1") Long page,
@RequestParam(value = "pageSize", defaultValue = "20") Long pageSize) {
return ApiResponse.success(productCategoryService.children(parentId, page, pageSize));
}
@GetMapping("/product-categories/search")
@Operation(summary = "分页搜索商品类目")
public ApiResponse<ProductCategoryListVo> search(
@RequestParam(value = "keyword", required = false) String keyword,
@RequestParam(value = "page", defaultValue = "1") Long page,
@RequestParam(value = "pageSize", defaultValue = "20") Long pageSize) {
return ApiResponse.success(productCategoryService.search(keyword, page, pageSize));
} }
@PostMapping("/product-category") @PostMapping("/product-category")

View File

@@ -8,4 +8,8 @@ import java.util.List;
public class ProductCategoryListVo { public class ProductCategoryListVo {
private List<ProductCategoryItemVo> tree; private List<ProductCategoryItemVo> tree;
private List<ProductCategoryItemVo> items; private List<ProductCategoryItemVo> items;
private Long total;
private Long page;
private Long pageSize;
private Boolean hasMore;
} }

View File

@@ -17,6 +17,7 @@ import java.util.Comparator;
import java.util.HashMap; import java.util.HashMap;
import java.util.HashSet; import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Locale;
import java.util.Map; import java.util.Map;
import java.util.Set; import java.util.Set;
@@ -27,13 +28,52 @@ public class ProductCategoryService {
private final ProductCategoryMapper productCategoryMapper; private final ProductCategoryMapper productCategoryMapper;
public ProductCategoryListVo list() { public ProductCategoryListVo list() {
return list(null);
}
public ProductCategoryListVo list(String keyword) {
List<ProductCategoryEntity> rows = productCategoryMapper.selectList(new LambdaQueryWrapper<ProductCategoryEntity>() List<ProductCategoryEntity> rows = productCategoryMapper.selectList(new LambdaQueryWrapper<ProductCategoryEntity>()
.orderByAsc(ProductCategoryEntity::getParentId) .orderByAsc(ProductCategoryEntity::getParentId)
.orderByAsc(ProductCategoryEntity::getSortOrder) .orderByAsc(ProductCategoryEntity::getSortOrder)
.orderByAsc(ProductCategoryEntity::getId)); .orderByAsc(ProductCategoryEntity::getId));
rows = filterRowsByKeyword(rows, keyword);
return buildListVo(rows); return buildListVo(rows);
} }
public ProductCategoryListVo children(Long parentId, long page, long pageSize) {
long safePage = Math.max(page, 1);
long safePageSize = normalizePageSize(pageSize);
Long normalizedParentId = normalizeParentId(parentId);
LambdaQueryWrapper<ProductCategoryEntity> countQuery = new LambdaQueryWrapper<>();
applyParentFilter(countQuery, normalizedParentId);
Long total = productCategoryMapper.selectCount(countQuery);
LambdaQueryWrapper<ProductCategoryEntity> pageQuery = new LambdaQueryWrapper<ProductCategoryEntity>()
.orderByAsc(ProductCategoryEntity::getSortOrder)
.orderByAsc(ProductCategoryEntity::getId)
.last("LIMIT " + ((safePage - 1) * safePageSize) + ", " + safePageSize);
applyParentFilter(pageQuery, normalizedParentId);
List<ProductCategoryEntity> rows = productCategoryMapper.selectList(pageQuery);
return buildPageVo(rows, total, safePage, safePageSize);
}
public ProductCategoryListVo search(String keyword, long page, long pageSize) {
String normalized = normalizeSearchKeyword(keyword);
if (normalized.isBlank()) {
return emptyPageVo(Math.max(page, 1), normalizePageSize(pageSize));
}
long safePage = Math.max(page, 1);
long safePageSize = normalizePageSize(pageSize);
LambdaQueryWrapper<ProductCategoryEntity> countQuery = buildSearchQuery(normalized);
Long total = productCategoryMapper.selectCount(countQuery);
List<ProductCategoryEntity> rows = productCategoryMapper.selectList(buildSearchQuery(normalized)
.orderByAsc(ProductCategoryEntity::getParentId)
.orderByAsc(ProductCategoryEntity::getSortOrder)
.orderByAsc(ProductCategoryEntity::getId)
.last("LIMIT " + ((safePage - 1) * safePageSize) + ", " + safePageSize));
return buildSearchPageVo(rows, total, safePage, safePageSize);
}
@Transactional @Transactional
public ProductCategoryItemVo create(ProductCategorySaveRequest request) { public ProductCategoryItemVo create(ProductCategorySaveRequest request) {
String name = normalizeRequired(request.getName(), "类目名称不能为空"); String name = normalizeRequired(request.getName(), "类目名称不能为空");
@@ -80,11 +120,13 @@ public class ProductCategoryService {
} }
private ProductCategoryItemVo findItem(Long id) { private ProductCategoryItemVo findItem(Long id) {
ProductCategoryListVo listVo = list(); ProductCategoryEntity entity = getById(id);
return listVo.getItems().stream() Long childCount = productCategoryMapper.selectCount(new LambdaQueryWrapper<ProductCategoryEntity>()
.filter(item -> id.equals(item.getId())) .eq(ProductCategoryEntity::getParentId, entity.getId()));
.findFirst() ProductCategoryItemVo item = toItemVo(entity, childCount == null ? 0 : childCount.intValue());
.orElseThrow(() -> new BusinessException("类目不存在")); item.setLevel(resolveLevel(entity));
item.setPath(resolvePath(entity));
return item;
} }
private ProductCategoryListVo buildListVo(List<ProductCategoryEntity> rows) { private ProductCategoryListVo buildListVo(List<ProductCategoryEntity> rows) {
@@ -105,9 +147,153 @@ public class ProductCategoryService {
ProductCategoryListVo vo = new ProductCategoryListVo(); ProductCategoryListVo vo = new ProductCategoryListVo();
vo.setTree(tree); vo.setTree(tree);
vo.setItems(flat); vo.setItems(flat);
vo.setTotal((long) flat.size());
vo.setPage(1L);
vo.setPageSize((long) flat.size());
vo.setHasMore(false);
return vo; return vo;
} }
private ProductCategoryListVo buildPageVo(List<ProductCategoryEntity> rows, Long total, long page, long pageSize) {
Map<Long, Integer> childCountById = buildChildCountById(rows);
List<ProductCategoryItemVo> items = rows.stream()
.map(row -> toItemVo(row, childCountById.getOrDefault(row.getId(), 0)))
.toList();
ProductCategoryListVo vo = new ProductCategoryListVo();
vo.setItems(items);
vo.setTree(List.of());
vo.setTotal(total == null ? 0L : total);
vo.setPage(page);
vo.setPageSize(pageSize);
vo.setHasMore(page * pageSize < vo.getTotal());
return vo;
}
private ProductCategoryListVo buildSearchPageVo(List<ProductCategoryEntity> rows, Long total, long page, long pageSize) {
Map<Long, Integer> childCountById = buildChildCountById(rows);
List<ProductCategoryItemVo> items = rows.stream()
.map(row -> {
ProductCategoryItemVo item = toItemVo(row, childCountById.getOrDefault(row.getId(), 0));
item.setLevel(resolveLevel(row));
item.setPath(resolvePath(row));
return item;
})
.toList();
ProductCategoryListVo vo = new ProductCategoryListVo();
vo.setItems(items);
vo.setTree(List.of());
vo.setTotal(total == null ? 0L : total);
vo.setPage(page);
vo.setPageSize(pageSize);
vo.setHasMore(page * pageSize < vo.getTotal());
return vo;
}
private ProductCategoryListVo emptyPageVo(long page, long pageSize) {
ProductCategoryListVo vo = new ProductCategoryListVo();
vo.setItems(List.of());
vo.setTree(List.of());
vo.setTotal(0L);
vo.setPage(page);
vo.setPageSize(pageSize);
vo.setHasMore(false);
return vo;
}
private Map<Long, Integer> buildChildCountById(List<ProductCategoryEntity> rows) {
Map<Long, Integer> childCountById = new HashMap<>();
if (rows.isEmpty()) {
return childCountById;
}
for (ProductCategoryEntity row : rows) {
Long childCount = productCategoryMapper.selectCount(new LambdaQueryWrapper<ProductCategoryEntity>()
.eq(ProductCategoryEntity::getParentId, row.getId()));
childCountById.put(row.getId(), childCount == null ? 0 : childCount.intValue());
}
return childCountById;
}
private void applyParentFilter(LambdaQueryWrapper<ProductCategoryEntity> query, Long parentId) {
if (parentId == null) {
query.isNull(ProductCategoryEntity::getParentId);
} else {
query.eq(ProductCategoryEntity::getParentId, parentId);
}
}
private LambdaQueryWrapper<ProductCategoryEntity> buildSearchQuery(String keyword) {
return new LambdaQueryWrapper<ProductCategoryEntity>()
.like(ProductCategoryEntity::getName, keyword)
.or()
.like(ProductCategoryEntity::getCategoryKey, keyword)
.or()
.like(ProductCategoryEntity::getDescription, keyword);
}
private List<ProductCategoryEntity> filterRowsByKeyword(List<ProductCategoryEntity> rows, String keyword) {
String normalized = normalizeSearchKeyword(keyword);
if (normalized.isBlank()) {
return rows;
}
Map<Long, ProductCategoryEntity> byId = new HashMap<>();
Map<Long, List<ProductCategoryEntity>> byParent = new HashMap<>();
for (ProductCategoryEntity row : rows) {
byId.put(row.getId(), row);
byParent.computeIfAbsent(row.getParentId(), ignored -> new ArrayList<>()).add(row);
}
Set<Long> includedIds = new HashSet<>();
for (ProductCategoryEntity row : rows) {
if (!matchesKeyword(row, normalized)) {
continue;
}
includeAncestors(row, byId, includedIds);
includeDescendants(row, byParent, includedIds);
}
if (includedIds.isEmpty()) {
return List.of();
}
return rows.stream()
.filter(row -> includedIds.contains(row.getId()))
.toList();
}
private String normalizeSearchKeyword(String keyword) {
return keyword == null ? "" : keyword.trim().toLowerCase(Locale.ROOT);
}
private boolean matchesKeyword(ProductCategoryEntity row, String keyword) {
return containsKeyword(row.getName(), keyword)
|| containsKeyword(row.getCategoryKey(), keyword)
|| containsKeyword(row.getDescription(), keyword);
}
private boolean containsKeyword(String value, String keyword) {
return value != null && value.toLowerCase(Locale.ROOT).contains(keyword);
}
private void includeAncestors(ProductCategoryEntity row,
Map<Long, ProductCategoryEntity> byId,
Set<Long> includedIds) {
ProductCategoryEntity cursor = row;
Set<Long> visited = new HashSet<>();
while (cursor != null && cursor.getId() != null && visited.add(cursor.getId())) {
includedIds.add(cursor.getId());
cursor = cursor.getParentId() == null ? null : byId.get(cursor.getParentId());
}
}
private void includeDescendants(ProductCategoryEntity row,
Map<Long, List<ProductCategoryEntity>> byParent,
Set<Long> includedIds) {
if (row == null || row.getId() == null) {
return;
}
includedIds.add(row.getId());
for (ProductCategoryEntity child : byParent.getOrDefault(row.getId(), List.of())) {
includeDescendants(child, byParent, includedIds);
}
}
private List<ProductCategoryItemVo> buildChildren( private List<ProductCategoryItemVo> buildChildren(
Long parentId, Long parentId,
int level, int level,
@@ -159,6 +345,36 @@ public class ProductCategoryService {
return vo; return vo;
} }
private int resolveLevel(ProductCategoryEntity entity) {
int level = 0;
ProductCategoryEntity cursor = entity;
Set<Long> visited = new HashSet<>();
while (cursor != null && cursor.getParentId() != null && visited.add(cursor.getId())) {
ProductCategoryEntity parent = productCategoryMapper.selectById(cursor.getParentId());
if (parent == null) {
break;
}
level += 1;
cursor = parent;
}
return level;
}
private String resolvePath(ProductCategoryEntity entity) {
List<String> names = new ArrayList<>();
ProductCategoryEntity cursor = entity;
Set<Long> visited = new HashSet<>();
while (cursor != null && cursor.getId() != null && visited.add(cursor.getId())) {
names.add(cursor.getName());
cursor = cursor.getParentId() == null ? null : productCategoryMapper.selectById(cursor.getParentId());
}
List<String> ordered = new ArrayList<>();
for (int i = names.size() - 1; i >= 0; i--) {
ordered.add(names.get(i));
}
return String.join(" / ", ordered);
}
private ProductCategoryEntity getById(Long id) { private ProductCategoryEntity getById(Long id) {
ProductCategoryEntity entity = productCategoryMapper.selectById(id); ProductCategoryEntity entity = productCategoryMapper.selectById(id);
if (entity == null) { if (entity == null) {
@@ -225,6 +441,10 @@ public class ProductCategoryService {
return value == null ? 100 : value; return value == null ? 100 : value;
} }
private long normalizePageSize(long value) {
return Math.min(Math.max(value, 1), 100);
}
private String normalizeDescription(String value) { private String normalizeDescription(String value) {
String normalized = value == null ? "" : value.trim(); String normalized = value == null ? "" : value.trim();
return normalized.length() > 512 ? normalized.substring(0, 512) : normalized; return normalized.length() > 512 ? normalized.substring(0, 512) : normalized;

View File

@@ -1,6 +1,7 @@
package com.nanri.aiimage.modules.shopkey.controller; package com.nanri.aiimage.modules.shopkey.controller;
import com.nanri.aiimage.common.api.ApiResponse; import com.nanri.aiimage.common.api.ApiResponse;
import com.nanri.aiimage.common.util.DownloadHeaderUtil;
import com.nanri.aiimage.modules.shopkey.model.dto.QueryAsinCountryUpdateRequest; import com.nanri.aiimage.modules.shopkey.model.dto.QueryAsinCountryUpdateRequest;
import com.nanri.aiimage.modules.shopkey.model.dto.QueryAsinCreateRequest; import com.nanri.aiimage.modules.shopkey.model.dto.QueryAsinCreateRequest;
import com.nanri.aiimage.modules.shopkey.model.vo.QueryAsinImportProgressVo; import com.nanri.aiimage.modules.shopkey.model.vo.QueryAsinImportProgressVo;
@@ -13,6 +14,9 @@ import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PathVariable;
@@ -24,12 +28,17 @@ import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
@RestController @RestController
@RequiredArgsConstructor @RequiredArgsConstructor
@RequestMapping("/api/admin/query-asins") @RequestMapping("/api/admin/query-asins")
@Tag(name = "查询 ASIN", description = "维护店铺按国家查询的 ASIN") @Tag(name = "查询 ASIN", description = "维护店铺按国家查询的 ASIN")
public class QueryAsinController { public class QueryAsinController {
private static final DateTimeFormatter EXPORT_FILENAME_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss");
private final QueryAsinService queryAsinService; private final QueryAsinService queryAsinService;
@GetMapping @GetMapping
@@ -52,6 +61,23 @@ public class QueryAsinController {
Boolean.TRUE.equals(superAdmin))); Boolean.TRUE.equals(superAdmin)));
} }
@GetMapping("/export")
@Operation(summary = "导出查询 ASIN")
public ResponseEntity<byte[]> export(
@Parameter(description = "分组ID") @RequestParam(required = false) Long groupId,
@Parameter(description = "店铺名") @RequestParam(required = false) String shopName,
@Parameter(description = "ASIN") @RequestParam(required = false) String asin,
@Parameter(description = "当前操作人用户ID") @RequestParam(required = false) Long operatorId,
@Parameter(description = "是否超级管理员") @RequestParam(defaultValue = "false") Boolean superAdmin) {
byte[] bytes = queryAsinService.export(groupId, shopName, asin, operatorId, Boolean.TRUE.equals(superAdmin));
String filename = "query-asin-" + LocalDateTime.now().format(EXPORT_FILENAME_FORMATTER) + ".xlsx";
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, DownloadHeaderUtil.contentDisposition(filename))
.contentType(MediaType.parseMediaType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"))
.contentLength(bytes.length)
.body(bytes);
}
@PostMapping @PostMapping
@Operation(summary = "新增或覆盖查询 ASIN") @Operation(summary = "新增或覆盖查询 ASIN")
public ApiResponse<QueryAsinItemVo> create( public ApiResponse<QueryAsinItemVo> create(

View File

@@ -16,12 +16,18 @@ import com.nanri.aiimage.modules.shopkey.model.vo.QueryAsinPageVo;
import com.nanri.aiimage.modules.shopkey.model.vo.ShopManageGroupItemVo; import com.nanri.aiimage.modules.shopkey.model.vo.ShopManageGroupItemVo;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.xssf.streaming.SXSSFWorkbook;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
import java.io.ByteArrayOutputStream;
import java.io.File; import java.io.File;
import java.nio.file.Files; import java.nio.file.Files;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.HashMap; import java.util.HashMap;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
@@ -41,6 +47,7 @@ public class QueryAsinService {
private static final List<String> SUPPORTED_COUNTRIES = List.of("DE", "UK", "FR", "IT", "ES"); private static final List<String> SUPPORTED_COUNTRIES = List.of("DE", "UK", "FR", "IT", "ES");
private static final String KEY_SEPARATOR = Character.toString((char) 1); private static final String KEY_SEPARATOR = Character.toString((char) 1);
private static final String UTF8_BOM = String.valueOf((char) 0xFEFF); private static final String UTF8_BOM = String.valueOf((char) 0xFEFF);
private static final DateTimeFormatter EXPORT_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
private final QueryAsinMapper queryAsinMapper; private final QueryAsinMapper queryAsinMapper;
private final ShopManageGroupService shopManageGroupService; private final ShopManageGroupService shopManageGroupService;
@@ -52,32 +59,15 @@ public class QueryAsinService {
Long operatorId, boolean superAdmin) { Long operatorId, boolean superAdmin) {
long safePage = Math.max(page, 1); long safePage = Math.max(page, 1);
long safePageSize = Math.min(Math.max(pageSize, 1), 100); long safePageSize = Math.min(Math.max(pageSize, 1), 100);
String safeShopName = normalizeBlank(shopName); Long total = countFilteredRows(groupId, shopName, asin, operatorId, superAdmin);
String safeAsin = normalizeBlank(asin); List<QueryAsinEntity> rows = listFilteredRows(
Set<Long> accessibleGroupIds = superAdmin ? Set.of() : shopManageGroupService.listAccessibleGroupIds(operatorId, false); groupId,
shopName,
LambdaQueryWrapper<QueryAsinEntity> query = new LambdaQueryWrapper<QueryAsinEntity>() asin,
.eq(groupId != null && groupId > 0, QueryAsinEntity::getGroupId, groupId) operatorId,
.like(!safeShopName.isEmpty(), QueryAsinEntity::getShopName, safeShopName) superAdmin,
.and(!safeAsin.isEmpty(), wrapper -> wrapper (safePage - 1) * safePageSize,
.like(QueryAsinEntity::getAsinDe, safeAsin) safePageSize);
.or().like(QueryAsinEntity::getAsinUk, safeAsin)
.or().like(QueryAsinEntity::getAsinFr, safeAsin)
.or().like(QueryAsinEntity::getAsinIt, safeAsin)
.or().like(QueryAsinEntity::getAsinEs, safeAsin))
.orderByDesc(QueryAsinEntity::getId);
if (!superAdmin) {
if (accessibleGroupIds.isEmpty()) {
query.eq(QueryAsinEntity::getId, -1L);
} else {
query.in(QueryAsinEntity::getGroupId, accessibleGroupIds);
}
}
Long total = queryAsinMapper.selectCount(query);
List<QueryAsinEntity> rows = queryAsinMapper
.selectList(query.last("LIMIT " + ((safePage - 1) * safePageSize) + ", " + safePageSize));
Map<Long, String> groupNameById = shopManageGroupService.buildGroupNameMap(rows.stream() Map<Long, String> groupNameById = shopManageGroupService.buildGroupNameMap(rows.stream()
.map(QueryAsinEntity::getGroupId) .map(QueryAsinEntity::getGroupId)
@@ -93,6 +83,110 @@ public class QueryAsinService {
return vo; return vo;
} }
public byte[] export(Long groupId, String shopName, String asin, Long operatorId, boolean superAdmin) {
List<QueryAsinEntity> rows = listFilteredRows(groupId, shopName, asin, operatorId, superAdmin, null, null);
Map<Long, String> groupNameById = shopManageGroupService.buildGroupNameMap(rows.stream()
.map(QueryAsinEntity::getGroupId)
.filter(id -> id != null && id > 0)
.distinct()
.toList());
try (SXSSFWorkbook workbook = new SXSSFWorkbook(100);
ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
Sheet sheet = workbook.createSheet("QueryAsin");
writeExportHeader(sheet);
for (int index = 0; index < rows.size(); index++) {
writeExportRow(sheet, index + 1, rows.get(index), groupNameById.get(rows.get(index).getGroupId()));
}
applyExportColumnWidths(sheet);
workbook.write(outputStream);
workbook.dispose();
return outputStream.toByteArray();
} catch (Exception ex) {
throw new BusinessException("导出查询 ASIN 失败");
}
}
private Long countFilteredRows(Long groupId, String shopName, String asin, Long operatorId, boolean superAdmin) {
return queryAsinMapper.selectCount(buildFilterQuery(groupId, shopName, asin, operatorId, superAdmin));
}
private List<QueryAsinEntity> listFilteredRows(Long groupId, String shopName, String asin,
Long operatorId, boolean superAdmin,
Long offset, Long limit) {
LambdaQueryWrapper<QueryAsinEntity> query = buildFilterQuery(groupId, shopName, asin, operatorId, superAdmin);
if (offset != null && limit != null) {
query.last("LIMIT " + Math.max(0L, offset) + ", " + Math.max(1L, limit));
}
return queryAsinMapper.selectList(query);
}
private LambdaQueryWrapper<QueryAsinEntity> buildFilterQuery(Long groupId, String shopName, String asin,
Long operatorId, boolean superAdmin) {
String safeShopName = normalizeBlank(shopName);
String safeAsin = normalizeBlank(asin);
Set<Long> accessibleGroupIds = superAdmin ? Set.of() : shopManageGroupService.listAccessibleGroupIds(operatorId, false);
LambdaQueryWrapper<QueryAsinEntity> query = new LambdaQueryWrapper<QueryAsinEntity>()
.eq(groupId != null && groupId > 0, QueryAsinEntity::getGroupId, groupId)
.like(!safeShopName.isEmpty(), QueryAsinEntity::getShopName, safeShopName)
.and(!safeAsin.isEmpty(), wrapper -> wrapper
.like(QueryAsinEntity::getAsinDe, safeAsin)
.or().like(QueryAsinEntity::getAsinUk, safeAsin)
.or().like(QueryAsinEntity::getAsinFr, safeAsin)
.or().like(QueryAsinEntity::getAsinIt, safeAsin)
.or().like(QueryAsinEntity::getAsinEs, safeAsin))
.orderByDesc(QueryAsinEntity::getId);
if (!superAdmin) {
if (accessibleGroupIds.isEmpty()) {
query.eq(QueryAsinEntity::getId, -1L);
} else {
query.in(QueryAsinEntity::getGroupId, accessibleGroupIds);
}
}
return query;
}
private void writeExportHeader(Sheet sheet) {
Row header = sheet.createRow(0);
String[] headers = {
"序号", "分组", "店铺名",
"德国ASIN", "英国ASIN", "法国ASIN", "意大利ASIN", "西班牙ASIN",
"创建时间", "更新时间"
};
for (int i = 0; i < headers.length; i++) {
header.createCell(i).setCellValue(headers[i]);
}
}
private void writeExportRow(Sheet sheet, int rowNo, QueryAsinEntity entity, String groupName) {
Row row = sheet.createRow(rowNo);
int col = 0;
row.createCell(col++).setCellValue(rowNo);
row.createCell(col++).setCellValue(blankToEmpty(groupName));
row.createCell(col++).setCellValue(blankToEmpty(entity.getShopName()));
row.createCell(col++).setCellValue(blankToEmpty(entity.getAsinDe()));
row.createCell(col++).setCellValue(blankToEmpty(entity.getAsinUk()));
row.createCell(col++).setCellValue(blankToEmpty(entity.getAsinFr()));
row.createCell(col++).setCellValue(blankToEmpty(entity.getAsinIt()));
row.createCell(col++).setCellValue(blankToEmpty(entity.getAsinEs()));
row.createCell(col++).setCellValue(formatExportTime(entity.getCreatedAt()));
row.createCell(col).setCellValue(formatExportTime(entity.getUpdatedAt()));
}
private void applyExportColumnWidths(Sheet sheet) {
int[] widths = {
2800, 5200, 5200,
4600, 4600, 4600, 4600, 4600,
5200, 5200
};
for (int i = 0; i < widths.length; i++) {
sheet.setColumnWidth(i, widths[i]);
}
}
private String formatExportTime(LocalDateTime value) {
return value == null ? "" : value.format(EXPORT_TIME_FORMATTER);
}
@Transactional @Transactional
public QueryAsinItemVo createOrUpdate(QueryAsinCreateRequest request, Long operatorId, boolean superAdmin) { public QueryAsinItemVo createOrUpdate(QueryAsinCreateRequest request, Long operatorId, boolean superAdmin) {
ShopManageGroupEntity group = shopManageGroupService.getAccessibleById(request.getGroupId(), operatorId, superAdmin); ShopManageGroupEntity group = shopManageGroupService.getAccessibleById(request.getGroupId(), operatorId, superAdmin);

View File

@@ -3,6 +3,7 @@ package com.nanri.aiimage.modules.similarasin.client;
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.nanri.aiimage.config.SimilarAsinProperties; import com.nanri.aiimage.config.SimilarAsinProperties;
import com.nanri.aiimage.modules.coze.service.CozeCredentialPoolService;
import com.nanri.aiimage.modules.similarasin.model.dto.SimilarAsinResultRowDto; import com.nanri.aiimage.modules.similarasin.model.dto.SimilarAsinResultRowDto;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
@@ -19,22 +20,26 @@ import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Locale; import java.util.Locale;
import java.util.Map; import java.util.Map;
import java.util.concurrent.atomic.AtomicLong;
@Component @Component
@RequiredArgsConstructor @RequiredArgsConstructor
@Slf4j @Slf4j
public class SimilarAsinCozeClient { public class SimilarAsinCozeClient {
private static final String MODULE_TYPE = "SIMILAR_ASIN";
private static final MediaType APPLICATION_JSON_UTF8 = new MediaType("application", "json", StandardCharsets.UTF_8); private static final MediaType APPLICATION_JSON_UTF8 = new MediaType("application", "json", StandardCharsets.UTF_8);
private final SimilarAsinProperties properties; private final SimilarAsinProperties properties;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
private final CozeCredentialPoolService cozeCredentialPoolService;
private final AtomicLong credentialCursor = new AtomicLong();
public List<SimilarAsinResultRowDto> inspect(List<SimilarAsinResultRowDto> rows, String prompt, String apiKey) { public List<SimilarAsinResultRowDto> inspect(List<SimilarAsinResultRowDto> rows, String prompt, String apiKey) {
if (rows == null || rows.isEmpty()) { if (rows == null || rows.isEmpty()) {
return List.of(); return List.of();
} }
if (properties.getCozeToken() == null || properties.getCozeToken().isBlank()) { if (!hasConfiguredCredential()) {
log.warn("[similar-asin] coze token not configured, keep raw rows size={}", rows.size()); log.warn("[similar-asin] coze token not configured, keep raw rows size={}", rows.size());
return rows.stream().map(this::copy).toList(); return rows.stream().map(this::copy).toList();
} }
@@ -48,17 +53,31 @@ public class SimilarAsinCozeClient {
} }
public CozeSubmitResponse submitWorkflow(List<SimilarAsinResultRowDto> rows, String prompt, String apiKey) throws Exception { public CozeSubmitResponse submitWorkflow(List<SimilarAsinResultRowDto> rows, String prompt, String apiKey) throws Exception {
JsonNode submitRoot = objectMapper.readTree(postWorkflow(rows, prompt, apiKey)); return submitWorkflow(rows, prompt, apiKey, nextCredential());
}
public CozeSubmitResponse submitWorkflow(List<SimilarAsinResultRowDto> rows,
String prompt,
String apiKey,
CozeCredentialRef credential) throws Exception {
CozeCredentialRef resolvedCredential = resolveCredential(credential);
JsonNode submitRoot = objectMapper.readTree(postWorkflow(rows, prompt, apiKey, resolvedCredential));
ensureSuccess(submitRoot); ensureSuccess(submitRoot);
return new CozeSubmitResponse( return new CozeSubmitResponse(
extractExecuteId(submitRoot), extractExecuteId(submitRoot),
extractResultDataText(submitRoot), extractResultDataText(submitRoot),
writeJson(submitRoot) writeJson(submitRoot),
resolvedCredential.name()
); );
} }
public CozePollResponse pollWorkflow(String executeId) throws Exception { public CozePollResponse pollWorkflow(String executeId) throws Exception {
JsonNode pollRoot = objectMapper.readTree(getWorkflowHistory(executeId)); return pollWorkflow(executeId, null);
}
public CozePollResponse pollWorkflow(String executeId, CozeCredentialRef credential) throws Exception {
CozeCredentialRef resolvedCredential = resolveCredential(credential);
JsonNode pollRoot = objectMapper.readTree(getWorkflowHistory(executeId, resolvedCredential));
ensureSuccess(pollRoot); ensureSuccess(pollRoot);
String status = normalize(resolveWorkflowStatus(pollRoot)).toUpperCase(Locale.ROOT); String status = normalize(resolveWorkflowStatus(pollRoot)).toUpperCase(Locale.ROOT);
String dataText = extractResultDataText(pollRoot); String dataText = extractResultDataText(pollRoot);
@@ -66,7 +85,8 @@ public class SimilarAsinCozeClient {
String failureMessage = isFailedWorkflowStatus(status) String failureMessage = isFailedWorkflowStatus(status)
? firstNonBlank(resolveFailureMessage(pollRoot), "Coze async workflow failed") ? firstNonBlank(resolveFailureMessage(pollRoot), "Coze async workflow failed")
: ""; : "";
return new CozePollResponse(executeId, status, dataText, outputText, failureMessage, writeJson(pollRoot)); return new CozePollResponse(executeId, status, dataText, outputText, failureMessage, writeJson(pollRoot),
resolvedCredential.name());
} }
public List<SimilarAsinResultRowDto> mergeRowsFromDataText(List<SimilarAsinResultRowDto> rows, String dataText) throws Exception { public List<SimilarAsinResultRowDto> mergeRowsFromDataText(List<SimilarAsinResultRowDto> rows, String dataText) throws Exception {
@@ -161,7 +181,8 @@ public class SimilarAsinCozeClient {
} }
private String runWorkflowAsyncAndWait(List<SimilarAsinResultRowDto> rows, String prompt, String apiKey) throws Exception { private String runWorkflowAsyncAndWait(List<SimilarAsinResultRowDto> rows, String prompt, String apiKey) throws Exception {
JsonNode submitRoot = objectMapper.readTree(postWorkflow(rows, prompt, apiKey)); CozeCredentialRef credential = nextCredential();
JsonNode submitRoot = objectMapper.readTree(postWorkflow(rows, prompt, apiKey, credential));
ensureSuccess(submitRoot); ensureSuccess(submitRoot);
String immediateData = extractResultDataText(submitRoot); String immediateData = extractResultDataText(submitRoot);
@@ -177,7 +198,7 @@ public class SimilarAsinCozeClient {
long deadline = System.currentTimeMillis() + Math.max(10000, properties.getCozePollTimeoutMillis()); long deadline = System.currentTimeMillis() + Math.max(10000, properties.getCozePollTimeoutMillis());
while (System.currentTimeMillis() < deadline) { while (System.currentTimeMillis() < deadline) {
ensureNotInterrupted(); ensureNotInterrupted();
JsonNode pollRoot = objectMapper.readTree(getWorkflowHistory(executeId)); JsonNode pollRoot = objectMapper.readTree(getWorkflowHistory(executeId, credential));
ensureSuccess(pollRoot); ensureSuccess(pollRoot);
String dataText = extractResultDataText(pollRoot); String dataText = extractResultDataText(pollRoot);
@@ -202,23 +223,27 @@ public class SimilarAsinCozeClient {
throw new IllegalStateException("Coze async workflow poll timeout"); throw new IllegalStateException("Coze async workflow poll timeout");
} }
private String postWorkflow(List<SimilarAsinResultRowDto> rows, String prompt, String apiKey) { private String postWorkflow(List<SimilarAsinResultRowDto> rows,
String prompt,
String apiKey,
CozeCredentialRef credential) {
Map<String, Object> parameters = buildParameters(rows, prompt, apiKey); Map<String, Object> parameters = buildParameters(rows, prompt, apiKey);
Map<String, Object> body = new LinkedHashMap<>(); Map<String, Object> body = new LinkedHashMap<>();
body.put("workflow_id", properties.getCozeWorkflowId()); body.put("workflow_id", credential.workflowId());
body.put("parameters", parameters); body.put("parameters", parameters);
if (apiKey != null && !apiKey.isBlank()) { if (apiKey != null && !apiKey.isBlank()) {
body.put("api_key", apiKey.trim()); body.put("api_key", apiKey.trim());
} }
body.put("is_async", Boolean.TRUE); body.put("is_async", Boolean.TRUE);
log.info("[similar-asin] coze request url={} body={}", log.info("[similar-asin] coze request credential={} url={} body={}",
credential.name(),
joinUrl(properties.getCozeBaseUrl(), properties.getCozeWorkflowPath()), joinUrl(properties.getCozeBaseUrl(), properties.getCozeWorkflowPath()),
writeJson(maskCozeRequestBody(body))); writeJson(maskCozeRequestBody(body)));
RestClient.RequestBodySpec request = restClient().post() RestClient.RequestBodySpec request = restClient().post()
.uri(joinUrl(properties.getCozeBaseUrl(), properties.getCozeWorkflowPath())) .uri(joinUrl(properties.getCozeBaseUrl(), properties.getCozeWorkflowPath()))
.headers(headers -> { .headers(headers -> {
headers.setBearerAuth(stripBearer(properties.getCozeToken())); headers.setBearerAuth(stripBearer(credential.token()));
headers.setContentType(APPLICATION_JSON_UTF8); headers.setContentType(APPLICATION_JSON_UTF8);
headers.set(HttpHeaders.ACCEPT_CHARSET, StandardCharsets.UTF_8.name()); headers.set(HttpHeaders.ACCEPT_CHARSET, StandardCharsets.UTF_8.name());
}); });
@@ -233,28 +258,98 @@ public class SimilarAsinCozeClient {
}); });
} }
private String getWorkflowHistory(String executeId) { private String getWorkflowHistory(String executeId, CozeCredentialRef credential) {
String path = properties.getCozeWorkflowHistoryPath() String path = properties.getCozeWorkflowHistoryPath()
.replace("{workflow_id}", properties.getCozeWorkflowId()) .replace("{workflow_id}", credential.workflowId())
.replace("{execute_id}", executeId); .replace("{execute_id}", executeId);
return restClient().get() return restClient().get()
.uri(joinUrl(properties.getCozeBaseUrl(), path)) .uri(joinUrl(properties.getCozeBaseUrl(), path))
.headers(headers -> { .headers(headers -> {
headers.setBearerAuth(stripBearer(properties.getCozeToken())); headers.setBearerAuth(stripBearer(credential.token()));
headers.setContentType(APPLICATION_JSON_UTF8); headers.setContentType(APPLICATION_JSON_UTF8);
headers.set(HttpHeaders.ACCEPT_CHARSET, StandardCharsets.UTF_8.name()); headers.set(HttpHeaders.ACCEPT_CHARSET, StandardCharsets.UTF_8.name());
}) })
.exchange((clientRequest, clientResponse) -> { .exchange((clientRequest, clientResponse) -> {
byte[] responseBytes = StreamUtils.copyToByteArray(clientResponse.getBody()); byte[] responseBytes = StreamUtils.copyToByteArray(clientResponse.getBody());
String responseText = responseBytes.length == 0 ? "" : new String(responseBytes, StandardCharsets.UTF_8); String responseText = responseBytes.length == 0 ? "" : new String(responseBytes, StandardCharsets.UTF_8);
log.info("[similar-asin] coze history response executeId={} status={} body={}", log.info("[similar-asin] coze history response credential={} executeId={} status={} body={}",
executeId, credential.name(), executeId,
clientResponse.getStatusCode(), clientResponse.getStatusCode(),
responseText); responseText);
return responseText; return responseText;
}); });
} }
public CozeCredentialRef nextCredential() {
List<CozeCredentialPoolService.CozeCredential> pooledCredentials = cozeCredentialPoolService.listEnabled(MODULE_TYPE);
CozeCredentialPoolService.CozeCredential pooledCredential =
cozeCredentialPoolService.chooseRoundRobin(MODULE_TYPE, pooledCredentials, properties.getCozeCredentialStripeSize());
if (pooledCredential != null) {
return new CozeCredentialRef(pooledCredential.name(), pooledCredential.workflowId(), pooledCredential.token(),
pooledCredential.maxConcurrent());
}
List<CozeCredentialRef> credentials = configuredCredentials();
int stripeSize = Math.max(1, properties.getCozeCredentialStripeSize());
long cursor = Math.max(0L, credentialCursor.getAndIncrement());
int index = (int) ((cursor / stripeSize) % credentials.size());
return credentials.get(index);
}
public CozeCredentialRef credentialByName(String name) {
if (name == null || name.isBlank()) {
return nextCredential();
}
String normalizedName = normalize(name);
for (CozeCredentialRef credential : configuredCredentials()) {
if (normalize(credential.name()).equals(normalizedName)) {
return credential;
}
}
return nextCredential();
}
public boolean hasConfiguredCredential() {
return !configuredCredentials().isEmpty();
}
public int configuredCredentialCount() {
return configuredCredentials().size();
}
private CozeCredentialRef resolveCredential(CozeCredentialRef credential) {
return credential == null ? nextCredential() : credential;
}
private List<CozeCredentialRef> configuredCredentials() {
List<CozeCredentialRef> credentials = new ArrayList<>();
for (CozeCredentialPoolService.CozeCredential credential : cozeCredentialPoolService.listEnabled(MODULE_TYPE)) {
credentials.add(new CozeCredentialRef(credential.name(), credential.workflowId(), credential.token(),
credential.maxConcurrent()));
}
if (!credentials.isEmpty()) {
return credentials;
}
if (properties.getCozeCredentials() != null) {
int index = 1;
for (SimilarAsinProperties.CozeCredential credential : properties.getCozeCredentials()) {
if (credential == null
|| normalize(credential.getWorkflowId()).isBlank()
|| normalize(credential.getToken()).isBlank()) {
continue;
}
String name = firstNonBlank(credential.getName(), "credential-" + index);
credentials.add(new CozeCredentialRef(name, credential.getWorkflowId(), credential.getToken(), Integer.MAX_VALUE));
index++;
}
}
if (credentials.isEmpty()
&& properties.getCozeWorkflowId() != null && !properties.getCozeWorkflowId().isBlank()
&& properties.getCozeToken() != null && !properties.getCozeToken().isBlank()) {
credentials.add(new CozeCredentialRef("default", properties.getCozeWorkflowId(), properties.getCozeToken(), Integer.MAX_VALUE));
}
return credentials;
}
private Map<String, Object> buildParameters(List<SimilarAsinResultRowDto> rows, String prompt, String apiKey) { private Map<String, Object> buildParameters(List<SimilarAsinResultRowDto> rows, String prompt, String apiKey) {
List<String> asins = rows.stream().map(row -> nonBlank(row.getAsin(), "")).toList(); List<String> asins = rows.stream().map(row -> nonBlank(row.getAsin(), "")).toList();
List<String> skus = rows.stream().map(row -> nonBlank(row.getSku(), "")).toList(); List<String> skus = rows.stream().map(row -> nonBlank(row.getSku(), "")).toList();
@@ -1045,7 +1140,8 @@ public class SimilarAsinCozeClient {
public record CozeSubmitResponse( public record CozeSubmitResponse(
String executeId, String executeId,
String immediateData, String immediateData,
String rawResponse String rawResponse,
String credentialName
) { ) {
} }
@@ -1055,7 +1151,8 @@ public class SimilarAsinCozeClient {
String dataText, String dataText,
String outputText, String outputText,
String failureMessage, String failureMessage,
String rawResponse String rawResponse,
String credentialName
) { ) {
public boolean hasPayload() { public boolean hasPayload() {
return dataText != null && !dataText.isBlank() || outputText != null && !outputText.isBlank(); return dataText != null && !dataText.isBlank() || outputText != null && !outputText.isBlank();
@@ -1081,6 +1178,14 @@ public class SimilarAsinCozeClient {
} }
} }
public record CozeCredentialRef(
String name,
String workflowId,
String token,
int maxConcurrent
) {
}
private static final class PartialCozeResultException extends RuntimeException { private static final class PartialCozeResultException extends RuntimeException {
private final int resolvedCount; private final int resolvedCount;

View File

@@ -11,6 +11,7 @@ import com.nanri.aiimage.common.service.DistributedJobLockService;
import com.nanri.aiimage.config.InstanceMetadata; import com.nanri.aiimage.config.InstanceMetadata;
import com.nanri.aiimage.config.SimilarAsinProperties; import com.nanri.aiimage.config.SimilarAsinProperties;
import com.nanri.aiimage.config.StorageProperties; import com.nanri.aiimage.config.StorageProperties;
import com.nanri.aiimage.modules.coze.service.CozeCredentialPoolService;
import com.nanri.aiimage.modules.similarasin.mapper.SimilarAsinFilterConditionMapper; import com.nanri.aiimage.modules.similarasin.mapper.SimilarAsinFilterConditionMapper;
import com.nanri.aiimage.modules.similarasin.client.SimilarAsinCozeClient; import com.nanri.aiimage.modules.similarasin.client.SimilarAsinCozeClient;
import com.nanri.aiimage.modules.similarasin.model.dto.SimilarAsinFilterConditionAddRequest; import com.nanri.aiimage.modules.similarasin.model.dto.SimilarAsinFilterConditionAddRequest;
@@ -72,6 +73,7 @@ import org.springframework.transaction.support.TransactionTemplate;
import java.io.File; import java.io.File;
import java.io.FileInputStream; import java.io.FileInputStream;
import java.io.FileOutputStream; import java.io.FileOutputStream;
import java.io.IOException;
import java.time.Duration; import java.time.Duration;
import java.time.Instant; import java.time.Instant;
import java.time.LocalDateTime; import java.time.LocalDateTime;
@@ -86,6 +88,8 @@ import java.util.Objects;
import java.util.Set; import java.util.Set;
import java.util.UUID; import java.util.UUID;
import java.util.function.Supplier; import java.util.function.Supplier;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
@Service @Service
@RequiredArgsConstructor @RequiredArgsConstructor
@@ -102,12 +106,13 @@ public class SimilarAsinTaskService {
private static final String COZE_STATUS_DONE = "DONE"; private static final String COZE_STATUS_DONE = "DONE";
private static final String COZE_STATUS_FAILED = "FAILED"; private static final String COZE_STATUS_FAILED = "FAILED";
private static final String CONTENT_TYPE_XLSX = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; private static final String CONTENT_TYPE_XLSX = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
private static final String CONTENT_TYPE_ZIP = "application/zip";
private static final int RESULT_ROWS_READ_RETRY_LIMIT = 3; private static final int RESULT_ROWS_READ_RETRY_LIMIT = 3;
private static final long RESULT_ROWS_READ_RETRY_DELAY_MS = 500L; private static final long RESULT_ROWS_READ_RETRY_DELAY_MS = 500L;
private static final Duration TASK_LOCK_TTL = Duration.ofMinutes(5); private static final Duration TASK_LOCK_TTL = Duration.ofMinutes(5);
private static final long TASK_LOCK_WAIT_MILLIS = 10000L; private static final long TASK_LOCK_WAIT_MILLIS = 10000L;
private static final Duration COZE_SUBMIT_LOCK_TTL = Duration.ofMinutes(2); private static final Duration COZE_SUBMIT_LOCK_TTL = Duration.ofMinutes(2);
private static final long COZE_SUBMIT_LOCK_WAIT_MILLIS = 180000L; private static final long COZE_SUBMIT_LOCK_WAIT_MILLIS = 1000L;
private static final long COZE_SUBMIT_LOCK_RETRY_DELAY_MILLIS = 500L; private static final long COZE_SUBMIT_LOCK_RETRY_DELAY_MILLIS = 500L;
private static final long COZE_SUBMIT_MIN_INTERVAL_MILLIS = 30000L; private static final long COZE_SUBMIT_MIN_INTERVAL_MILLIS = 30000L;
private static final int MAX_COZE_SUBMIT_RETRY_COUNT = 5; private static final int MAX_COZE_SUBMIT_RETRY_COUNT = 5;
@@ -122,7 +127,7 @@ public class SimilarAsinTaskService {
"是否符合类目", "是否符合类目",
"不符合理由", "不符合理由",
"产品类目", "产品类目",
"status" "状态"
); );
private final LocalFileStorageService localFileStorageService; private final LocalFileStorageService localFileStorageService;
@@ -144,6 +149,7 @@ public class SimilarAsinTaskService {
private final PlatformTransactionManager transactionManager; private final PlatformTransactionManager transactionManager;
private final DistributedJobLockService distributedJobLockService; private final DistributedJobLockService distributedJobLockService;
private final InstanceMetadata instanceMetadata; private final InstanceMetadata instanceMetadata;
private final CozeCredentialPoolService cozeCredentialPoolService;
@Autowired @Autowired
@Qualifier("cozeTaskExecutor") @Qualifier("cozeTaskExecutor")
private TaskExecutor cozeTaskExecutor; private TaskExecutor cozeTaskExecutor;
@@ -821,7 +827,7 @@ public class SimilarAsinTaskService {
touchJavaSideTaskActivity(task.getId()); touchJavaSideTaskActivity(task.getId());
} else if (isResultSubmissionComplete(task.getId())) { } else if (isResultSubmissionComplete(task.getId())) {
maybeFinalizeCozeJobLocked(task.getId(), new CozeBatchContext( maybeFinalizeCozeJobLocked(task.getId(), new CozeBatchContext(
job.getId(), result.getId(), context.scopeHash(), context.chunkIndex(), 1, 1, currentInstanceId(), 0)); job.getId(), result.getId(), context.scopeHash(), context.chunkIndex(), 1, 1, currentInstanceId(), 0, null));
} }
} }
@@ -1431,12 +1437,7 @@ public class SimilarAsinTaskService {
return; return;
} }
try (lockHandle) { try (lockHandle) {
List<TaskScopeStateEntity> states = taskScopeStateMapper.selectList(new LambdaQueryWrapper<TaskScopeStateEntity>() List<TaskScopeStateEntity> states = listOwnedPendingCozeStates();
.eq(TaskScopeStateEntity::getModuleType, MODULE_TYPE)
.in(TaskScopeStateEntity::getCozeStatus, List.of(COZE_STATUS_SUBMITTED, COZE_STATUS_RUNNING))
.isNotNull(TaskScopeStateEntity::getCozeExecuteId)
.orderByAsc(TaskScopeStateEntity::getUpdatedAt)
.last("limit 50"));
if (states == null || states.isEmpty()) { if (states == null || states.isEmpty()) {
return; return;
} }
@@ -1458,7 +1459,7 @@ public class SimilarAsinTaskService {
if (chunks == null || chunks.isEmpty()) { if (chunks == null || chunks.isEmpty()) {
return countPendingCozeStates(task.getId()) > 0; return countPendingCozeStates(task.getId()) > 0;
} }
if (properties.getCozeToken() == null || properties.getCozeToken().isBlank()) { if (!cozeClient.hasConfiguredCredential()) {
log.warn("[similar-asin] coze token not configured, skip async coze taskId={} jobId={}", log.warn("[similar-asin] coze token not configured, skip async coze taskId={} jobId={}",
task.getId(), job.getId()); task.getId(), job.getId());
return false; return false;
@@ -1512,8 +1513,10 @@ public class SimilarAsinTaskService {
return COZE_STATUS_SUBMITTED.equals(existing.getCozeStatus()) return COZE_STATUS_SUBMITTED.equals(existing.getCozeStatus())
|| COZE_STATUS_RUNNING.equals(existing.getCozeStatus()); || COZE_STATUS_RUNNING.equals(existing.getCozeStatus());
} }
SimilarAsinCozeClient.CozeCredentialRef credential = cozeClient.nextCredential();
try { try {
SimilarAsinCozeClient.CozeSubmitResponse submit = submitCozeWorkflowThrottled(batchRows, prompt, apiKey); SimilarAsinCozeClient.CozeSubmitResponse submit = submitCozeWorkflowThrottled(
batchRows, prompt, apiKey, credential, true);
if (submit.immediateData() != null && !submit.immediateData().isBlank()) { if (submit.immediateData() != null && !submit.immediateData().isBlank()) {
List<SimilarAsinResultRowDto> cozeRows = cozeClient.mergeRowsFromDataText(batchRows, submit.immediateData()); List<SimilarAsinResultRowDto> cozeRows = cozeClient.mergeRowsFromDataText(batchRows, submit.immediateData());
String emptyResultMessage = emptyCozeResultMessage(cozeRows, batchRows.size()); String emptyResultMessage = emptyCozeResultMessage(cozeRows, batchRows.size());
@@ -1532,14 +1535,22 @@ public class SimilarAsinTaskService {
return false; return false;
} }
saveCozeBatchState(task, result, job, chunk, batchRows, batchScopeKey, batchScopeHash, saveCozeBatchState(task, result, job, chunk, batchRows, batchScopeKey, batchScopeHash,
batchIndex, batchTotal, submit.executeId()); batchIndex, batchTotal, submit.executeId(), submit.credentialName());
log.info("[similar-asin] coze async submitted taskId={} jobId={} chunk={} batch={}/{} executeId={}", log.info("[similar-asin] coze async submitted taskId={} jobId={} chunk={} batch={}/{} credential={} executeId={}",
task.getId(), job.getId(), chunk.getChunkIndex(), batchIndex, batchTotal, submit.executeId()); task.getId(), job.getId(), chunk.getChunkIndex(), batchIndex, batchTotal,
submit.credentialName(), submit.executeId());
return true; return true;
} catch (Exception ex) { } catch (Exception ex) {
String message = firstNonBlank(ex.getMessage(), "Coze submit failed"); String message = firstNonBlank(ex.getMessage(), "Coze submit failed");
log.warn("[similar-asin] coze async submit failed taskId={} jobId={} chunk={} batch={}/{} err={}", log.warn("[similar-asin] coze async submit failed taskId={} jobId={} chunk={} batch={}/{} err={}",
task.getId(), job.getId(), chunk.getChunkIndex(), batchIndex, batchTotal, message); task.getId(), job.getId(), chunk.getChunkIndex(), batchIndex, batchTotal, message);
if (isCozeThrottleLockTimeout(message)) {
savePendingCozeBatchState(task, result, job, chunk, batchRows, batchScopeKey, batchScopeHash,
batchIndex, batchTotal, message, credential.name());
taskFileJobService.touchRunning(job.getId());
touchJavaSideTaskActivity(task.getId());
return true;
}
mergeCozeRowsIntoChunk(task, mergeCozeRowsIntoChunk(task,
chunk.getScopeHash(), chunk.getScopeHash(),
chunk.getChunkIndex(), chunk.getChunkIndex(),
@@ -1549,6 +1560,65 @@ public class SimilarAsinTaskService {
} }
} }
private boolean isCozeThrottleLockTimeout(String message) {
return normalize(message).toLowerCase(Locale.ROOT).contains("coze submit throttle lock timeout");
}
private void savePendingCozeBatchState(FileTaskEntity task,
FileResultEntity result,
TaskFileJobEntity job,
TaskChunkEntity chunk,
List<SimilarAsinResultRowDto> batchRows,
String batchScopeKey,
String batchScopeHash,
int batchIndex,
int batchTotal,
String pendingReason,
String credentialName) {
LocalDateTime now = LocalDateTime.now();
CozeBatchContext context = new CozeBatchContext(
job.getId(),
result.getId(),
chunk.getScopeHash(),
chunk.getChunkIndex(),
batchIndex,
batchTotal,
currentInstanceId(),
0,
credentialName
);
String batchPayload = writeJson(batchRows, "serialize pending coze batch payload failed");
String storedBatchPayload = transientPayloadStorageService.storeParsedPayloadFast(
MODULE_TYPE, task.getId(), batchScopeHash, batchPayload, true);
TaskScopeStateEntity state = new TaskScopeStateEntity();
state.setTaskId(task.getId());
state.setModuleType(MODULE_TYPE);
state.setScopeKey(batchScopeKey);
state.setScopeHash(batchScopeHash);
state.setParsedPayloadJson(storedBatchPayload);
state.setStateJson(writeJson(context, "serialize pending coze batch context failed"));
state.setCozeStatus(COZE_STATUS_RUNNING);
state.setCozeSubmittedAt(now);
state.setCozeLastPolledAt(null);
state.setCozeAttemptCount(0);
state.setCozeError(firstNonBlank(pendingReason, "Coze submit queued by throttle"));
state.setChunkTotal(batchTotal);
state.setReceivedChunkCount(batchIndex);
state.setCompleted(0);
state.setCreatedAt(now);
state.setUpdatedAt(now);
try {
taskScopeStateMapper.insert(state);
touchJavaSideTaskActivity(task.getId());
log.info("[similar-asin] coze async submit queued by throttle taskId={} jobId={} chunk={} rows={} batch={}/{}",
task.getId(), job.getId(), chunk.getChunkIndex(), batchRows.size(), batchIndex, batchTotal);
} catch (DuplicateKeyException ex) {
transientPayloadStorageService.deletePayloadIfPresent(storedBatchPayload);
log.info("[similar-asin] duplicate pending coze batch state ignored taskId={} scope={}",
task.getId(), batchScopeKey);
}
}
private void saveCozeBatchState(FileTaskEntity task, private void saveCozeBatchState(FileTaskEntity task,
FileResultEntity result, FileResultEntity result,
TaskFileJobEntity job, TaskFileJobEntity job,
@@ -1558,7 +1628,8 @@ public class SimilarAsinTaskService {
String batchScopeHash, String batchScopeHash,
int batchIndex, int batchIndex,
int batchTotal, int batchTotal,
String executeId) { String executeId,
String credentialName) {
LocalDateTime now = LocalDateTime.now(); LocalDateTime now = LocalDateTime.now();
CozeBatchContext context = new CozeBatchContext( CozeBatchContext context = new CozeBatchContext(
job.getId(), job.getId(),
@@ -1568,7 +1639,8 @@ public class SimilarAsinTaskService {
batchIndex, batchIndex,
batchTotal, batchTotal,
currentInstanceId(), currentInstanceId(),
0 0,
credentialName
); );
String batchPayload = writeJson(batchRows, "serialize coze batch payload failed"); String batchPayload = writeJson(batchRows, "serialize coze batch payload failed");
String storedBatchPayload = transientPayloadStorageService.storeParsedPayloadFast( String storedBatchPayload = transientPayloadStorageService.storeParsedPayloadFast(
@@ -1602,6 +1674,10 @@ public class SimilarAsinTaskService {
private void pollPendingCozeState(Long stateId) { private void pollPendingCozeState(Long stateId) {
TaskScopeStateEntity lockState = taskScopeStateMapper.selectById(stateId); TaskScopeStateEntity lockState = taskScopeStateMapper.selectById(stateId);
if (lockState == null || lockState.getCozeExecuteId() == null || lockState.getCozeExecuteId().isBlank()) { if (lockState == null || lockState.getCozeExecuteId() == null || lockState.getCozeExecuteId().isBlank()) {
if (lockState != null && lockState.getCozeExecuteId() == null
&& (COZE_STATUS_SUBMITTED.equals(lockState.getCozeStatus()) || COZE_STATUS_RUNNING.equals(lockState.getCozeStatus()))) {
retryPendingCozeSubmitState(lockState);
}
return; return;
} }
if (!isCozeStateOwnedByCurrentInstance(lockState)) { if (!isCozeStateOwnedByCurrentInstance(lockState)) {
@@ -1621,6 +1697,10 @@ public class SimilarAsinTaskService {
private void pollPendingCozeStateLocked(Long stateId) { private void pollPendingCozeStateLocked(Long stateId) {
TaskScopeStateEntity state = taskScopeStateMapper.selectById(stateId); TaskScopeStateEntity state = taskScopeStateMapper.selectById(stateId);
if (state == null || state.getCozeExecuteId() == null || state.getCozeExecuteId().isBlank()) { if (state == null || state.getCozeExecuteId() == null || state.getCozeExecuteId().isBlank()) {
if (state != null && state.getCozeExecuteId() == null
&& (COZE_STATUS_SUBMITTED.equals(state.getCozeStatus()) || COZE_STATUS_RUNNING.equals(state.getCozeStatus()))) {
retryPendingCozeSubmitState(state);
}
return; return;
} }
if (!(COZE_STATUS_SUBMITTED.equals(state.getCozeStatus()) || COZE_STATUS_RUNNING.equals(state.getCozeStatus()))) { if (!(COZE_STATUS_SUBMITTED.equals(state.getCozeStatus()) || COZE_STATUS_RUNNING.equals(state.getCozeStatus()))) {
@@ -1644,7 +1724,9 @@ public class SimilarAsinTaskService {
} }
taskFileJobService.touchRunning(context.jobId()); taskFileJobService.touchRunning(context.jobId());
try { try {
SimilarAsinCozeClient.CozePollResponse poll = cozeClient.pollWorkflow(state.getCozeExecuteId()); SimilarAsinCozeClient.CozePollResponse poll = cozeClient.pollWorkflow(
state.getCozeExecuteId(),
cozeClient.credentialByName(context.credentialName()));
if (!poll.hasPayload() && !poll.isFinished() && !isCozeStateTimedOut(state)) { if (!poll.hasPayload() && !poll.isFinished() && !isCozeStateTimedOut(state)) {
updateCozeStateRunning(state, null); updateCozeStateRunning(state, null);
return; return;
@@ -1725,7 +1807,8 @@ public class SimilarAsinTaskService {
} }
try { try {
SimilarAsinCozeClient.CozeSubmitResponse submit = SimilarAsinCozeClient.CozeSubmitResponse submit =
submitCozeWorkflowThrottled(batchRows, readAiPrompt(task), readApiKey(task)); submitCozeWorkflowThrottled(batchRows, readAiPrompt(task), readApiKey(task),
cozeClient.credentialByName(context.credentialName()), false);
Map<String, List<SimilarAsinParsedRowVo>> allRowsByBaseId = loadAllRowsByBaseId(task); Map<String, List<SimilarAsinParsedRowVo>> allRowsByBaseId = loadAllRowsByBaseId(task);
if (submit.immediateData() != null && !submit.immediateData().isBlank()) { if (submit.immediateData() != null && !submit.immediateData().isBlank()) {
List<SimilarAsinResultRowDto> cozeRows = List<SimilarAsinResultRowDto> cozeRows =
@@ -1774,23 +1857,54 @@ public class SimilarAsinTaskService {
private SimilarAsinCozeClient.CozeSubmitResponse submitCozeWorkflowThrottled( private SimilarAsinCozeClient.CozeSubmitResponse submitCozeWorkflowThrottled(
List<SimilarAsinResultRowDto> rows, List<SimilarAsinResultRowDto> rows,
String prompt, String prompt,
String apiKey) throws Exception { String apiKey,
DistributedJobLockService.LockHandle lockHandle = acquireCozeSubmitLock(); SimilarAsinCozeClient.CozeCredentialRef credential,
boolean allowCredentialFallback) throws Exception {
int attempts = allowCredentialFallback ? Math.max(1, cozeClient.configuredCredentialCount()) : 1;
SimilarAsinCozeClient.CozeCredentialRef currentCredential =
credential == null ? cozeClient.nextCredential() : credential;
Exception lastFailure = null;
for (int i = 0; i < attempts; i++) {
DistributedJobLockService.LockHandle lockHandle = acquireCozeSubmitLock(currentCredential);
if (lockHandle == null) { if (lockHandle == null) {
throw new IllegalStateException("Coze submit throttle lock timeout"); lastFailure = new IllegalStateException("Coze submit throttle lock timeout");
currentCredential = cozeClient.nextCredential();
continue;
} }
try (lockHandle) { CozeCredentialPoolService.BorrowedCredential borrowedCredential =
SimilarAsinCozeClient.CozeSubmitResponse response = cozeClient.submitWorkflow(rows, prompt, apiKey); cozeCredentialPoolService.borrow(MODULE_TYPE, toPoolCredential(currentCredential));
if (borrowedCredential == null) {
lockHandle.close();
lastFailure = new IllegalStateException("Coze credential concurrency limit reached");
currentCredential = cozeClient.nextCredential();
continue;
}
try (lockHandle; borrowedCredential) {
return cozeClient.submitWorkflow(rows, prompt, apiKey, currentCredential);
} finally {
sleepQuietly(COZE_SUBMIT_MIN_INTERVAL_MILLIS); sleepQuietly(COZE_SUBMIT_MIN_INTERVAL_MILLIS);
return response;
} }
} }
throw lastFailure == null ? new IllegalStateException("Coze submit failed") : lastFailure;
}
private DistributedJobLockService.LockHandle acquireCozeSubmitLock() { private CozeCredentialPoolService.CozeCredential toPoolCredential(SimilarAsinCozeClient.CozeCredentialRef credential) {
if (credential == null) {
return null;
}
return new CozeCredentialPoolService.CozeCredential(
credential.name(),
credential.workflowId(),
credential.token(),
credential.maxConcurrent());
}
private DistributedJobLockService.LockHandle acquireCozeSubmitLock(SimilarAsinCozeClient.CozeCredentialRef credential) {
long deadline = System.currentTimeMillis() + COZE_SUBMIT_LOCK_WAIT_MILLIS; long deadline = System.currentTimeMillis() + COZE_SUBMIT_LOCK_WAIT_MILLIS;
String credentialName = credential == null ? "default" : firstNonBlank(credential.name(), "default");
while (System.currentTimeMillis() <= deadline) { while (System.currentTimeMillis() <= deadline) {
DistributedJobLockService.LockHandle lockHandle = DistributedJobLockService.LockHandle lockHandle =
distributedJobLockService.tryLock("similar-asin:coze-submit", COZE_SUBMIT_LOCK_TTL); distributedJobLockService.tryLock("similar-asin:coze-submit:" + credentialName, COZE_SUBMIT_LOCK_TTL);
if (lockHandle != null) { if (lockHandle != null) {
return lockHandle; return lockHandle;
} }
@@ -1836,14 +1950,16 @@ public class SimilarAsinTaskService {
int partIndex = 1; int partIndex = 1;
for (List<SimilarAsinResultRowDto> partRows : partitions) { for (List<SimilarAsinResultRowDto> partRows : partitions) {
SimilarAsinCozeClient.CozeSubmitResponse submit = SimilarAsinCozeClient.CozeSubmitResponse submit =
submitCozeWorkflowThrottled(partRows, readAiPrompt(task), readApiKey(task)); submitCozeWorkflowThrottled(partRows, readAiPrompt(task), readApiKey(task),
cozeClient.credentialByName(context.credentialName()), false);
if (submit.immediateData() != null && !submit.immediateData().isBlank()) { if (submit.immediateData() != null && !submit.immediateData().isBlank()) {
List<SimilarAsinResultRowDto> cozeRows = List<SimilarAsinResultRowDto> cozeRows =
cozeClient.mergeRowsFromDataText(partRows, submit.immediateData()); cozeClient.mergeRowsFromDataText(partRows, submit.immediateData());
mergeCozeRowsIntoChunk(task, context.chunkScopeHash(), context.chunkIndex(), cozeRows, allRowsByBaseId); mergeCozeRowsIntoChunk(task, context.chunkScopeHash(), context.chunkIndex(), cozeRows, allRowsByBaseId);
submittedAny = true; submittedAny = true;
} else if (submit.executeId() != null && !submit.executeId().isBlank()) { } else if (submit.executeId() != null && !submit.executeId().isBlank()) {
saveSplitRetryCozeBatchState(state, context, partRows, partIndex, partitions.size(), retryCount, submit.executeId()); saveSplitRetryCozeBatchState(state, context, partRows, partIndex, partitions.size(), retryCount,
submit.executeId(), submit.credentialName());
submittedAny = true; submittedAny = true;
} }
partIndex++; partIndex++;
@@ -1871,7 +1987,8 @@ public class SimilarAsinTaskService {
int partIndex, int partIndex,
int partTotal, int partTotal,
int retryCount, int retryCount,
String executeId) { String executeId,
String credentialName) {
String scopeKey = parent.getScopeKey() + ":split:" + retryCount + ":" + partIndex; String scopeKey = parent.getScopeKey() + ":split:" + retryCount + ":" + partIndex;
String scopeHash = DigestUtil.sha256Hex(scopeKey); String scopeHash = DigestUtil.sha256Hex(scopeKey);
CozeBatchContext context = new CozeBatchContext( CozeBatchContext context = new CozeBatchContext(
@@ -1882,7 +1999,8 @@ public class SimilarAsinTaskService {
partIndex, partIndex,
partTotal, partTotal,
parentContext.ownerInstanceId(), parentContext.ownerInstanceId(),
retryCount retryCount,
firstNonBlank(credentialName, parentContext.credentialName())
); );
LocalDateTime now = LocalDateTime.now(); LocalDateTime now = LocalDateTime.now();
String batchPayload = writeJson(batchRows, "serialize split coze batch payload failed"); String batchPayload = writeJson(batchRows, "serialize split coze batch payload failed");
@@ -1926,6 +2044,130 @@ public class SimilarAsinTaskService {
|| normalized.contains("\u8c03\u7528\u8d85\u65f6"); || normalized.contains("\u8c03\u7528\u8d85\u65f6");
} }
private void retryPendingCozeSubmitState(TaskScopeStateEntity state) {
if (state == null || state.getId() == null) {
return;
}
if (!tryClaimCozeStateForPoll(state)) {
log.info("[similar-asin] coze submit retry skipped by claim guard taskId={} stateId={} lastPolledAt={}",
state.getTaskId(), state.getId(), state.getCozeLastPolledAt());
return;
}
CozeBatchContext context = readCozeBatchContext(state);
if (context == null || context.jobId() == null || context.resultId() == null) {
markCozeStateTerminal(state, COZE_STATUS_FAILED, "Coze pending submit context missing");
return;
}
List<SimilarAsinResultRowDto> batchRows = readCozeBatchRows(state);
if (batchRows.isEmpty()) {
markCozeStateTerminal(state, COZE_STATUS_FAILED, "Coze pending submit payload missing");
maybeFinalizeCozeJobLocked(state.getTaskId(), context);
return;
}
FileTaskEntity task = fileTaskMapper.selectById(state.getTaskId());
if (task == null || !MODULE_TYPE.equals(task.getModuleType())) {
markCozeStateTerminal(state, COZE_STATUS_FAILED, "Coze pending submit task missing");
return;
}
TaskChunkEntity currentChunk = taskChunkMapper.selectOne(new LambdaQueryWrapper<TaskChunkEntity>()
.eq(TaskChunkEntity::getTaskId, task.getId())
.eq(TaskChunkEntity::getModuleType, MODULE_TYPE)
.eq(TaskChunkEntity::getScopeHash, context.chunkScopeHash())
.eq(TaskChunkEntity::getChunkIndex, context.chunkIndex())
.last("limit 1"));
Map<String, SimilarAsinResultRowDto> currentRows = readChunkRows(currentChunk);
List<SimilarAsinResultRowDto> currentBatchRows = batchRows.stream()
.map(row -> currentRows.get(rowKey(row)))
.filter(Objects::nonNull)
.toList();
if (currentBatchRows.size() == batchRows.size()
&& currentBatchRows.stream().allMatch(this::hasResolvedCozeFields)) {
markCozeStateTerminal(state, COZE_STATUS_DONE, "Coze rows already resolved by another batch");
maybeFinalizeCozeJobLocked(state.getTaskId(), context);
log.info("[similar-asin] coze pending submit skipped because rows already resolved taskId={} stateId={} jobId={} rows={}",
state.getTaskId(), state.getId(), context.jobId(), batchRows.size());
return;
}
try {
taskFileJobService.touchRunning(context.jobId());
SimilarAsinCozeClient.CozeSubmitResponse submit =
submitCozeWorkflowThrottled(batchRows, readAiPrompt(task), readApiKey(task),
cozeClient.credentialByName(context.credentialName()), false);
Map<String, List<SimilarAsinParsedRowVo>> allRowsByBaseId = loadAllRowsByBaseId(task);
if (submit.immediateData() != null && !submit.immediateData().isBlank()) {
List<SimilarAsinResultRowDto> cozeRows =
cozeClient.mergeRowsFromDataText(batchRows, submit.immediateData());
String emptyResultMessage = emptyCozeResultMessage(cozeRows, batchRows.size());
if (!emptyResultMessage.isBlank()) {
throw new IllegalStateException(emptyResultMessage);
}
mergeCozeRowsIntoChunk(task, context.chunkScopeHash(), context.chunkIndex(), cozeRows, allRowsByBaseId);
markCozeStateTerminal(state, COZE_STATUS_DONE, null);
maybeFinalizeCozeJobLocked(state.getTaskId(), context);
return;
}
if (submit.executeId() == null || submit.executeId().isBlank()) {
markCozeStateTerminal(state, COZE_STATUS_FAILED, "Coze async execute_id missing");
mergeCozeRowsIntoChunk(task,
context.chunkScopeHash(),
context.chunkIndex(),
cozeClient.markRowsFailed(batchRows, "Coze async execute_id missing"),
allRowsByBaseId);
maybeFinalizeCozeJobLocked(state.getTaskId(), context);
return;
}
LocalDateTime now = LocalDateTime.now();
taskScopeStateMapper.update(null, new LambdaUpdateWrapper<TaskScopeStateEntity>()
.eq(TaskScopeStateEntity::getId, state.getId())
.in(TaskScopeStateEntity::getCozeStatus, List.of(COZE_STATUS_SUBMITTED, COZE_STATUS_RUNNING))
.set(TaskScopeStateEntity::getCozeExecuteId, submit.executeId())
.set(TaskScopeStateEntity::getCozeStatus, COZE_STATUS_SUBMITTED)
.set(TaskScopeStateEntity::getCozeSubmittedAt, now)
.set(TaskScopeStateEntity::getCozeLastPolledAt, null)
.set(TaskScopeStateEntity::getCozeError, null)
.set(TaskScopeStateEntity::getUpdatedAt, now));
touchJavaSideTaskActivity(state.getTaskId());
log.info("[similar-asin] coze pending submit retried taskId={} stateId={} jobId={} rows={} executeId={}",
state.getTaskId(), state.getId(), context.jobId(), batchRows.size(), submit.executeId());
} catch (Exception ex) {
String message = firstNonBlank(ex.getMessage(), "Coze pending submit retry failed");
log.warn("[similar-asin] coze pending submit retry failed taskId={} stateId={} jobId={} rows={} err={}",
state.getTaskId(), state.getId(), context.jobId(), batchRows.size(), message);
if (isCozeThrottleLockTimeout(message)) {
keepPendingCozeSubmitState(state, message);
return;
}
int nextAttemptCount = cozeAttemptCount(state) + 1;
if (nextAttemptCount >= MAX_COZE_SUBMIT_RETRY_COUNT) {
Map<String, List<SimilarAsinParsedRowVo>> allRowsByBaseId = loadAllRowsByBaseId(task);
String finalMessage = message + " after " + nextAttemptCount + " submit attempts";
mergeCozeRowsIntoChunk(task,
context.chunkScopeHash(),
context.chunkIndex(),
cozeClient.markRowsFailed(batchRows, finalMessage),
allRowsByBaseId);
markCozeStateTerminal(state, COZE_STATUS_FAILED, finalMessage);
maybeFinalizeCozeJobLocked(state.getTaskId(), context);
return;
}
updateCozeStateRunning(state, message);
}
}
private void keepPendingCozeSubmitState(TaskScopeStateEntity state, String error) {
int updated = taskScopeStateMapper.update(null, new LambdaUpdateWrapper<TaskScopeStateEntity>()
.eq(TaskScopeStateEntity::getId, state.getId())
.in(TaskScopeStateEntity::getCozeStatus, List.of(COZE_STATUS_SUBMITTED, COZE_STATUS_RUNNING))
.set(TaskScopeStateEntity::getCozeStatus, COZE_STATUS_RUNNING)
.set(TaskScopeStateEntity::getCozeExecuteId, null)
.set(TaskScopeStateEntity::getCozeLastPolledAt, LocalDateTime.now())
.set(TaskScopeStateEntity::getCozeError, error)
.set(TaskScopeStateEntity::getUpdatedAt, LocalDateTime.now()));
if (updated > 0) {
touchJavaSideTaskActivity(state.getTaskId());
}
}
private boolean isRetryableCozeFailure(String failureMessage) { private boolean isRetryableCozeFailure(String failureMessage) {
String normalized = normalize(failureMessage).toLowerCase(Locale.ROOT); String normalized = normalize(failureMessage).toLowerCase(Locale.ROOT);
return normalized.contains("rate limit") return normalized.contains("rate limit")
@@ -2173,7 +2415,8 @@ public class SimilarAsinTaskService {
context.batchIndex(), context.batchIndex(),
context.batchTotal(), context.batchTotal(),
context.ownerInstanceId(), context.ownerInstanceId(),
submitRetryCount submitRetryCount,
context.credentialName()
); );
} }
@@ -2338,6 +2581,12 @@ public class SimilarAsinTaskService {
} }
int safeTotal = Math.max(1, total); int safeTotal = Math.max(1, total);
int safeCompleted = Math.max(0, Math.min(completed, safeTotal)); int safeCompleted = Math.max(0, Math.min(completed, safeTotal));
TaskProgressSnapshotEntity existing = taskProgressSnapshotService.find(task.getId(), MODULE_TYPE);
int displayPercent = calculateDisplayProgressPercent(safeCompleted, safeTotal, job, LocalDateTime.now());
int previousDisplayPercent = calculateSnapshotDisplayPercent(existing, job);
if (previousDisplayPercent > displayPercent && safeCompleted < safeTotal) {
displayPercent = previousDisplayPercent;
}
taskProgressSnapshotService.save( taskProgressSnapshotService.save(
task.getId(), task.getId(),
MODULE_TYPE, MODULE_TYPE,
@@ -2347,10 +2596,57 @@ public class SimilarAsinTaskService {
0, 0,
job.getScopeKey(), job.getScopeKey(),
message, message,
Map.of("phase", "RESULT_FILE", "jobId", job.getId()) Map.of("phase", "RESULT_FILE", "jobId", job.getId(), "displayPercent", displayPercent)
); );
} }
private int calculateDisplayProgressPercent(int current,
int total,
TaskFileJobEntity job,
LocalDateTime baseTime) {
if (total <= 0) {
return 0;
}
current = Math.max(0, Math.min(current, total));
int percent = Math.max(1, Math.min(99, (int) Math.floor(current * 100.0 / total)));
if (job != null && STATUS_RUNNING.equals(job.getStatus())) {
long elapsedSeconds = baseTime == null ? 0 : Math.max(0, Duration.between(baseTime, LocalDateTime.now()).getSeconds());
if (current <= 0) {
int firstRealProgressPercent = Math.max(1, Math.min(99, (int) Math.floor(100.0 / total)));
int waitingCap = Math.max(8, Math.min(35, firstRealProgressPercent - 1));
percent = Math.max(percent, Math.min(waitingCap, 8 + (int) (elapsedSeconds / 6)));
} else if (current < total) {
percent = Math.max(percent, Math.min(92, percent + (int) (elapsedSeconds / 10)));
}
}
return percent;
}
private int calculateSnapshotDisplayPercent(TaskProgressSnapshotEntity snapshot, TaskFileJobEntity job) {
if (snapshot == null) {
return 0;
}
int total = snapshot.getTotalCount() == null ? 0 : snapshot.getTotalCount();
int current = snapshot.getSuccessCount() == null ? 0 : snapshot.getSuccessCount();
int percent = total <= 0 ? 0 : calculateDisplayProgressPercent(current, total, job, snapshot.getUpdatedAt());
return Math.max(percent, extractSnapshotDisplayPercent(snapshot));
}
private int extractSnapshotDisplayPercent(TaskProgressSnapshotEntity snapshot) {
if (snapshot == null || snapshot.getSnapshotJson() == null || snapshot.getSnapshotJson().isBlank()) {
return 0;
}
try {
JsonNode displayPercent = objectMapper.readTree(snapshot.getSnapshotJson()).path("displayPercent");
if (!displayPercent.isNumber()) {
return 0;
}
return Math.max(0, Math.min(100, displayPercent.asInt()));
} catch (Exception ignored) {
return 0;
}
}
public void cleanupResultFileJob(TaskFileJobEntity job) { public void cleanupResultFileJob(TaskFileJobEntity job) {
if (job == null || job.getTaskId() == null) { if (job == null || job.getTaskId() == null) {
return; return;
@@ -2379,26 +2675,193 @@ public class SimilarAsinTaskService {
if (!outputDir.exists() && !outputDir.mkdirs()) { if (!outputDir.exists() && !outputDir.mkdirs()) {
throw new BusinessException("创建结果目录失败"); throw new BusinessException("创建结果目录失败");
} }
String filename = safeFileStem(result.getSourceFilename()) + "-result.xlsx"; List<SourceRows> sourceRows = splitRowsBySourceFile(parsed, parsed.getAllItems(), result.getSourceFilename());
String tempFilename = safeFileStem(result.getSourceFilename()) List<SourceResultWorkbook> workbooks = new ArrayList<>();
File zip = null;
try {
for (SourceRows item : sourceRows) {
String filename = safeFileStem(item.sourceFilename()) + "-result.xlsx";
String tempFilename = safeFileStem(item.sourceFilename())
+ "-" + task.getId() + "-" + task.getId()
+ "-" + result.getId() + "-" + result.getId()
+ "-" + UUID.randomUUID() + "-" + UUID.randomUUID()
+ "-result.xlsx"; + "-result.xlsx";
File xlsx = new File(outputDir, tempFilename); File xlsx = new File(outputDir, tempFilename);
try { writeResultWorkbook(xlsx, item.rows(), resultMap);
writeResultWorkbook(xlsx, parsed, resultMap); workbooks.add(new SourceResultWorkbook(xlsx, filename, item.rows().size()));
String objectKey = ossStorageService.uploadResultFile(xlsx, MODULE_TYPE); }
if (workbooks.isEmpty()) {
throw new BusinessException("相似ASIN检测结果为空请稍后重试生成结果文件");
}
File uploadFile;
String filename;
String contentType;
if (workbooks.size() == 1) {
SourceResultWorkbook workbook = workbooks.get(0);
uploadFile = workbook.file();
filename = workbook.filename();
contentType = CONTENT_TYPE_XLSX;
} else {
filename = safeFileStem(result.getSourceFilename()) + "-result.zip";
String tempFilename = safeFileStem(result.getSourceFilename())
+ "-" + task.getId()
+ "-" + result.getId()
+ "-" + UUID.randomUUID()
+ "-result.zip";
zip = new File(outputDir, tempFilename);
packageResultWorkbooksAsZip(zip, workbooks);
uploadFile = zip;
contentType = CONTENT_TYPE_ZIP;
}
String objectKey = ossStorageService.uploadResultFile(uploadFile, MODULE_TYPE);
result.setResultFilename(filename); result.setResultFilename(filename);
result.setResultFileUrl(objectKey); result.setResultFileUrl(objectKey);
result.setResultFileSize(xlsx.length()); result.setResultFileSize(uploadFile.length());
result.setResultContentType(CONTENT_TYPE_XLSX); result.setResultContentType(contentType);
result.setRowCount(parsed.getAllItems().size()); result.setRowCount(parsed.getAllItems().size());
} finally { } finally {
if (xlsx.exists() && !xlsx.delete()) { for (SourceResultWorkbook workbook : workbooks) {
log.warn("[similar-asin] delete temp xlsx failed file={}", xlsx); if (workbook.file().exists() && !workbook.file().delete()) {
log.warn("[similar-asin] delete temp xlsx failed file={}", workbook.file());
} }
} }
if (zip != null && zip.exists() && !zip.delete()) {
log.warn("[similar-asin] delete temp zip failed file={}", zip);
}
}
}
private List<TaskScopeStateEntity> listOwnedPendingCozeStates() {
Map<Long, TaskScopeStateEntity> merged = new LinkedHashMap<>();
for (TaskScopeStateEntity state : queryOwnedPendingCozeStates(false, 50)) {
if (state != null && state.getId() != null) {
merged.put(state.getId(), state);
}
}
for (TaskScopeStateEntity state : queryOwnedPendingCozeStates(true, 50)) {
if (state != null && state.getId() != null) {
merged.putIfAbsent(state.getId(), state);
}
}
return new ArrayList<>(merged.values());
}
private List<TaskScopeStateEntity> queryOwnedPendingCozeStates(boolean oldestFirst, int limit) {
LambdaQueryWrapper<TaskScopeStateEntity> wrapper = new LambdaQueryWrapper<TaskScopeStateEntity>()
.eq(TaskScopeStateEntity::getModuleType, MODULE_TYPE)
.in(TaskScopeStateEntity::getCozeStatus, List.of(COZE_STATUS_SUBMITTED, COZE_STATUS_RUNNING))
.and(query -> query
.apply("JSON_UNQUOTE(JSON_EXTRACT(state_json, '$.ownerInstanceId')) IS NULL")
.or()
.apply("JSON_UNQUOTE(JSON_EXTRACT(state_json, '$.ownerInstanceId')) = ''")
.or()
.apply("JSON_UNQUOTE(JSON_EXTRACT(state_json, '$.ownerInstanceId')) = {0}", currentInstanceId()))
.last("limit " + Math.max(1, Math.min(limit, 100)));
if (oldestFirst) {
wrapper.orderByAsc(TaskScopeStateEntity::getUpdatedAt);
} else {
wrapper.orderByDesc(TaskScopeStateEntity::getUpdatedAt);
}
List<TaskScopeStateEntity> states = taskScopeStateMapper.selectList(wrapper);
return states == null ? List.of() : states;
}
private List<SourceRows> splitRowsBySourceFile(SimilarAsinParsedPayloadDto parsed,
List<SimilarAsinParsedRowVo> rows,
String fallbackFilename) {
String defaultFilename = firstNonBlank(fallbackFilename, "similar-asin");
Map<String, SourceRowsBuilder> builders = new LinkedHashMap<>();
if (parsed != null && parsed.getSourceFiles() != null) {
for (SimilarAsinSourceFileDto sourceFile : parsed.getSourceFiles()) {
if (sourceFile == null) {
continue;
}
String sourceFileKey = normalize(sourceFile.getFileKey());
if (sourceFileKey.isBlank()) {
continue;
}
builders.putIfAbsent(sourceFileKey,
new SourceRowsBuilder(sourceFileKey, firstNonBlank(sourceFile.getOriginalFilename(), defaultFilename)));
}
}
for (SimilarAsinParsedRowVo row : rows == null ? List.<SimilarAsinParsedRowVo>of() : rows) {
if (row == null) {
continue;
}
String sourceFileKey = normalize(row.getSourceFileKey());
if (sourceFileKey.isBlank()) {
sourceFileKey = normalize(row.getSourceFilename());
}
if (sourceFileKey.isBlank()) {
sourceFileKey = "__default__";
}
SourceRowsBuilder builder = builders.computeIfAbsent(sourceFileKey,
key -> new SourceRowsBuilder(key, firstNonBlank(row.getSourceFilename(), defaultFilename)));
if (builder.sourceFilename().isBlank() || Objects.equals(builder.sourceFilename(), defaultFilename)) {
builder.setSourceFilename(firstNonBlank(row.getSourceFilename(), defaultFilename));
}
builder.rows().add(row);
}
return builders.values().stream()
.filter(builder -> !builder.rows().isEmpty())
.map(builder -> new SourceRows(builder.sourceFileKey(), firstNonBlank(builder.sourceFilename(), defaultFilename), builder.rows()))
.toList();
}
private void packageResultWorkbooksAsZip(File zip, List<SourceResultWorkbook> workbooks) {
Set<String> entryNames = new LinkedHashSet<>();
try (ZipOutputStream zos = new ZipOutputStream(new FileOutputStream(zip))) {
byte[] buffer = new byte[8192];
for (SourceResultWorkbook workbook : workbooks) {
writeResultZipEntry(zos, buffer, workbook.file(), uniqueZipEntryName(workbook.filename(), entryNames));
}
} catch (IOException ex) {
throw new BusinessException("生成相似ASIN检测结果压缩包失败");
}
}
private void writeResultZipEntry(ZipOutputStream zos,
byte[] buffer,
File file,
String entryName) throws IOException {
zos.putNextEntry(new ZipEntry(entryName));
try (FileInputStream inputStream = new FileInputStream(file)) {
int len;
while ((len = inputStream.read(buffer)) > 0) {
zos.write(buffer, 0, len);
}
}
zos.closeEntry();
}
private String uniqueZipEntryName(String filename, Set<String> entryNames) {
String safeFilename = normalizeZipFilename(filename);
if (entryNames.add(safeFilename)) {
return safeFilename;
}
int dotIndex = safeFilename.lastIndexOf('.');
String stem = dotIndex > 0 ? safeFilename.substring(0, dotIndex) : safeFilename;
String extension = dotIndex > 0 ? safeFilename.substring(dotIndex) : "";
int index = 2;
while (true) {
String candidate = stem + "(" + index + ")" + extension;
if (entryNames.add(candidate)) {
return candidate;
}
index++;
}
}
private String normalizeZipFilename(String filename) {
String normalized = firstNonBlank(filename, "similar-asin-result.xlsx").replace('\\', '/');
while (normalized.startsWith("/")) {
normalized = normalized.substring(1);
}
return normalized.isBlank() ? "similar-asin-result.xlsx" : normalized;
} }
private Map<String, SimilarAsinResultRowDto> loadPersistedResultRows(Long taskId) { private Map<String, SimilarAsinResultRowDto> loadPersistedResultRows(Long taskId) {
@@ -2450,7 +2913,9 @@ public class SimilarAsinTaskService {
} }
} }
private void writeResultWorkbook(File xlsx, SimilarAsinParsedPayloadDto parsed, Map<String, SimilarAsinResultRowDto> resultMap) { private void writeResultWorkbook(File xlsx,
List<SimilarAsinParsedRowVo> rowsToWrite,
Map<String, SimilarAsinResultRowDto> resultMap) {
try (SXSSFWorkbook workbook = new SXSSFWorkbook(200); FileOutputStream fos = new FileOutputStream(xlsx)) { try (SXSSFWorkbook workbook = new SXSSFWorkbook(200); FileOutputStream fos = new FileOutputStream(xlsx)) {
Sheet sheet = workbook.createSheet("相似asin检测"); Sheet sheet = workbook.createSheet("相似asin检测");
CellStyle headerStyle = workbook.createCellStyle(); CellStyle headerStyle = workbook.createCellStyle();
@@ -2467,7 +2932,7 @@ public class SimilarAsinTaskService {
} }
int rowIndex = 1; int rowIndex = 1;
for (SimilarAsinParsedRowVo parsedRow : parsed.getAllItems()) { for (SimilarAsinParsedRowVo parsedRow : rowsToWrite == null ? List.<SimilarAsinParsedRowVo>of() : rowsToWrite) {
SimilarAsinResultRowDto resultRow = findResultRow(parsedRow, resultMap); SimilarAsinResultRowDto resultRow = findResultRow(parsedRow, resultMap);
Row row = sheet.createRow(rowIndex++); Row row = sheet.createRow(rowIndex++);
int col = 0; int col = 0;
@@ -2807,23 +3272,25 @@ public class SimilarAsinTaskService {
} }
int cozeCompleted = countCompletedCozeStates(taskId); int cozeCompleted = countCompletedCozeStates(taskId);
int cozePending = countPendingCozeStates(taskId); int cozePending = countPendingCozeStates(taskId);
TaskProgressSnapshotEntity snapshot = taskProgressSnapshotService.find(taskId, MODULE_TYPE);
if (job != null && STATUS_RUNNING.equals(job.getStatus()) && cozePending > 0) { if (job != null && STATUS_RUNNING.equals(job.getStatus()) && cozePending > 0) {
int total = Math.max(1, cozeCompleted + cozePending); int total = Math.max(1, cozeCompleted + cozePending);
int current = Math.max(0, Math.min(cozeCompleted, total)); int current = Math.max(0, Math.min(cozeCompleted, total));
vo.setFileProgressCurrent(current); vo.setFileProgressCurrent(current);
vo.setFileProgressTotal(total); vo.setFileProgressTotal(total);
vo.setFileProgressPercent(Math.max(0, Math.min(100, (int) Math.floor(current * 100.0 / total)))); int percent = calculateDisplayProgressPercent(current, total, job, snapshot == null ? null : snapshot.getUpdatedAt());
percent = Math.max(percent, calculateSnapshotDisplayPercent(snapshot, job));
vo.setFileProgressPercent(Math.min(99, percent));
vo.setFileProgressMessage("Coze 已完成 " + current + "/" + total + ",等待回流"); vo.setFileProgressMessage("Coze 已完成 " + current + "/" + total + ",等待回流");
return; return;
} }
TaskProgressSnapshotEntity snapshot = taskProgressSnapshotService.find(taskId, MODULE_TYPE);
if (snapshot == null) { if (snapshot == null) {
if (job != null && STATUS_RUNNING.equals(job.getStatus()) && cozeCompleted + cozePending > 0) { if (job != null && STATUS_RUNNING.equals(job.getStatus()) && cozeCompleted + cozePending > 0) {
int total = Math.max(1, cozeCompleted + cozePending); int total = Math.max(1, cozeCompleted + cozePending);
int current = Math.max(0, Math.min(cozeCompleted, total)); int current = Math.max(0, Math.min(cozeCompleted, total));
vo.setFileProgressCurrent(current); vo.setFileProgressCurrent(current);
vo.setFileProgressTotal(total); vo.setFileProgressTotal(total);
vo.setFileProgressPercent(Math.max(0, Math.min(100, (int) Math.floor(current * 100.0 / total)))); vo.setFileProgressPercent(Math.min(99, calculateDisplayProgressPercent(current, total, job, job.getUpdatedAt())));
vo.setFileProgressMessage(cozePending > 0 vo.setFileProgressMessage(cozePending > 0
? "Coze 已完成 " + current + "/" + total + ",等待回流" ? "Coze 已完成 " + current + "/" + total + ",等待回流"
: "Coze 处理中"); : "Coze 处理中");
@@ -2835,8 +3302,10 @@ public class SimilarAsinTaskService {
if (total <= 0) { if (total <= 0) {
return; return;
} }
current = Math.max(0, Math.min(current, total)); LocalDateTime baseTime = snapshot.getUpdatedAt() != null ? snapshot.getUpdatedAt() : (job == null ? null : job.getUpdatedAt());
int percent = Math.max(0, Math.min(100, (int) Math.floor(current * 100.0 / total))); int percent = calculateDisplayProgressPercent(current, total, job, baseTime);
percent = Math.max(percent, extractSnapshotDisplayPercent(snapshot));
percent = Boolean.TRUE.equals(vo.getFileReady()) ? 100 : Math.min(99, percent);
vo.setFileProgressCurrent(current); vo.setFileProgressCurrent(current);
vo.setFileProgressTotal(total); vo.setFileProgressTotal(total);
vo.setFileProgressPercent(percent); vo.setFileProgressPercent(percent);
@@ -3138,7 +3607,45 @@ public class SimilarAsinTaskService {
Integer batchIndex, Integer batchIndex,
Integer batchTotal, Integer batchTotal,
String ownerInstanceId, String ownerInstanceId,
Integer submitRetryCount) { Integer submitRetryCount,
String credentialName) {
}
private static class SourceRowsBuilder {
private final String sourceFileKey;
private String sourceFilename;
private final List<SimilarAsinParsedRowVo> rows = new ArrayList<>();
private SourceRowsBuilder(String sourceFileKey, String sourceFilename) {
this.sourceFileKey = sourceFileKey;
this.sourceFilename = sourceFilename;
}
private String sourceFileKey() {
return sourceFileKey;
}
private String sourceFilename() {
return sourceFilename;
}
private void setSourceFilename(String sourceFilename) {
this.sourceFilename = sourceFilename;
}
private List<SimilarAsinParsedRowVo> rows() {
return rows;
}
}
private record SourceRows(String sourceFileKey,
String sourceFilename,
List<SimilarAsinParsedRowVo> rows) {
}
private record SourceResultWorkbook(File file,
String filename,
int rowCount) {
} }
private record ParsedWorkbook(int totalRows, int droppedRows, List<String> headers, List<SimilarAsinParsedRowVo> allRows) { private record ParsedWorkbook(int totalRows, int droppedRows, List<String> headers, List<SimilarAsinParsedRowVo> allRows) {

View File

@@ -4,6 +4,8 @@ import com.nanri.aiimage.modules.task.model.dto.TaskFileJobDispatchEvent;
import com.nanri.aiimage.modules.task.model.dto.TaskFileJobMessage; import com.nanri.aiimage.modules.task.model.dto.TaskFileJobMessage;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.core.task.TaskExecutor;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.transaction.event.TransactionPhase; import org.springframework.transaction.event.TransactionPhase;
import org.springframework.transaction.event.TransactionalEventListener; import org.springframework.transaction.event.TransactionalEventListener;
@@ -15,6 +17,8 @@ public class TaskFileJobDispatchCoordinator {
private final TaskFileJobPublisher taskFileJobPublisher; private final TaskFileJobPublisher taskFileJobPublisher;
private final TaskFileJobLocalDispatcher taskFileJobLocalDispatcher; private final TaskFileJobLocalDispatcher taskFileJobLocalDispatcher;
@Qualifier("taskFileJobDispatchExecutor")
private final TaskExecutor taskFileJobDispatchExecutor;
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true) @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true)
public void onTaskFileJobDispatch(TaskFileJobDispatchEvent event) { public void onTaskFileJobDispatch(TaskFileJobDispatchEvent event) {
@@ -29,6 +33,16 @@ public class TaskFileJobDispatchCoordinator {
message.setScopeKey(event.scopeKey()); message.setScopeKey(event.scopeKey());
message.setJobType(event.jobType()); message.setJobType(event.jobType());
message.setCreatedAt(event.createdAt()); message.setCreatedAt(event.createdAt());
try {
taskFileJobDispatchExecutor.execute(() -> dispatchAfterCommit(event, message));
} catch (RuntimeException ex) {
log.warn("[task-file-job] dispatch executor rejected after commit jobId={} taskId={} moduleType={} msg={}",
event.jobId(), event.taskId(), event.moduleType(), ex.getMessage(), ex);
dispatchAfterCommit(event, message);
}
}
private void dispatchAfterCommit(TaskFileJobDispatchEvent event, TaskFileJobMessage message) {
boolean published = taskFileJobPublisher.publish(message); boolean published = taskFileJobPublisher.publish(message);
if (published) { if (published) {
return; return;

View File

@@ -8,12 +8,17 @@ import org.springframework.beans.factory.ObjectProvider;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLong;
@Slf4j @Slf4j
@Service @Service
@RequiredArgsConstructor @RequiredArgsConstructor
public class TaskFileJobPublisher { public class TaskFileJobPublisher {
private final ObjectProvider<RocketMQTemplate> rocketMQTemplateProvider; private final ObjectProvider<RocketMQTemplate> rocketMQTemplateProvider;
private final AtomicInteger consecutiveFailures = new AtomicInteger();
private final AtomicLong circuitOpenUntilMillis = new AtomicLong();
@Value("${aiimage.result-file-job.mq-enabled:false}") @Value("${aiimage.result-file-job.mq-enabled:false}")
private boolean mqEnabled; private boolean mqEnabled;
@@ -21,6 +26,15 @@ public class TaskFileJobPublisher {
@Value("${aiimage.result-file-job.topic:aiimage-result-file-job}") @Value("${aiimage.result-file-job.topic:aiimage-result-file-job}")
private String topic; private String topic;
@Value("${aiimage.result-file-job.mq-send-timeout-ms:1000}")
private long mqSendTimeoutMillis;
@Value("${aiimage.result-file-job.mq-failure-threshold:3}")
private int mqFailureThreshold;
@Value("${aiimage.result-file-job.mq-circuit-open-ms:60000}")
private long mqCircuitOpenMillis;
public boolean publish(TaskFileJobMessage message) { public boolean publish(TaskFileJobMessage message) {
if (message == null || message.getJobId() == null) { if (message == null || message.getJobId() == null) {
return false; return false;
@@ -30,6 +44,13 @@ public class TaskFileJobPublisher {
message.getJobId(), message.getTaskId(), message.getModuleType()); message.getJobId(), message.getTaskId(), message.getModuleType());
return false; return false;
} }
long now = System.currentTimeMillis();
long openUntil = circuitOpenUntilMillis.get();
if (openUntil > now) {
log.info("[task-file-job] RocketMQ publish circuit open, fallback to local dispatcher/scheduled worker jobId={} taskId={} moduleType={} remainingMs={}",
message.getJobId(), message.getTaskId(), message.getModuleType(), openUntil - now);
return false;
}
RocketMQTemplate template = rocketMQTemplateProvider.getIfAvailable(); RocketMQTemplate template = rocketMQTemplateProvider.getIfAvailable();
if (template == null) { if (template == null) {
log.warn("[task-file-job] RocketMQTemplate unavailable, jobId={} taskId={} moduleType={}", log.warn("[task-file-job] RocketMQTemplate unavailable, jobId={} taskId={} moduleType={}",
@@ -37,13 +58,25 @@ public class TaskFileJobPublisher {
return false; return false;
} }
try { try {
template.convertAndSend(topic, message); template.syncSend(topic, message, Math.max(100L, mqSendTimeoutMillis));
consecutiveFailures.set(0);
circuitOpenUntilMillis.set(0L);
log.info("[task-file-job] mq published jobId={} taskId={} moduleType={} topic={}", log.info("[task-file-job] mq published jobId={} taskId={} moduleType={} topic={}",
message.getJobId(), message.getTaskId(), message.getModuleType(), topic); message.getJobId(), message.getTaskId(), message.getModuleType(), topic);
return true; return true;
} catch (Exception ex) { } catch (Exception ex) {
log.warn("[task-file-job] publish RocketMQ failed, fallback to local dispatcher/scheduled worker jobId={} taskId={} moduleType={} msg={}", int failures = consecutiveFailures.incrementAndGet();
message.getJobId(), message.getTaskId(), message.getModuleType(), ex.getMessage()); int threshold = Math.max(1, mqFailureThreshold);
if (failures >= threshold) {
long nextOpenUntil = System.currentTimeMillis() + Math.max(1000L, mqCircuitOpenMillis);
circuitOpenUntilMillis.set(nextOpenUntil);
log.warn("[task-file-job] publish RocketMQ failed, circuit opened and fallback to local dispatcher/scheduled worker jobId={} taskId={} moduleType={} failures={} openMs={} msg={}",
message.getJobId(), message.getTaskId(), message.getModuleType(), failures,
Math.max(1000L, mqCircuitOpenMillis), ex.getMessage());
} else {
log.warn("[task-file-job] publish RocketMQ failed, fallback to local dispatcher/scheduled worker jobId={} taskId={} moduleType={} failures={}/{} msg={}",
message.getJobId(), message.getTaskId(), message.getModuleType(), failures, threshold, ex.getMessage());
}
return false; return false;
} }
} }

View File

@@ -12,6 +12,7 @@ import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@@ -76,6 +77,41 @@ public class TaskFileJobService {
.last("limit " + Math.max(1, Math.min(limit, 100)))); .last("limit " + Math.max(1, Math.min(limit, 100))));
} }
public List<TaskFileJobEntity> listRunnableJobsForOwner(int limit, String owner) {
String normalizedOwner = owner == null ? "" : owner.trim();
if (normalizedOwner.isBlank()) {
return listRunnableJobs(limit);
}
String ownerMarker = ":owner:" + normalizedOwner;
int safeLimit = Math.max(1, Math.min(limit, 100));
List<TaskFileJobEntity> ownerJobs = taskFileJobMapper.selectList(new LambdaQueryWrapper<TaskFileJobEntity>()
.in(TaskFileJobEntity::getStatus, List.of("PENDING", "FAILED"))
.lt(TaskFileJobEntity::getRetryCount, 5)
.in(TaskFileJobEntity::getModuleType, List.of("APPEARANCE_PATENT", "SIMILAR_ASIN"))
.like(TaskFileJobEntity::getScopeKey, ownerMarker)
.orderByAsc(TaskFileJobEntity::getUpdatedAt)
.last("limit " + safeLimit));
if (ownerJobs.size() >= safeLimit) {
return ownerJobs;
}
List<TaskFileJobEntity> jobs = new ArrayList<>(ownerJobs);
LambdaQueryWrapper<TaskFileJobEntity> genericWrapper = new LambdaQueryWrapper<TaskFileJobEntity>()
.in(TaskFileJobEntity::getStatus, List.of("PENDING", "FAILED"))
.lt(TaskFileJobEntity::getRetryCount, 5)
.and(wrapper -> wrapper
.notIn(TaskFileJobEntity::getModuleType, List.of("APPEARANCE_PATENT", "SIMILAR_ASIN"))
.or()
.isNull(TaskFileJobEntity::getScopeKey)
.or()
.notLike(TaskFileJobEntity::getScopeKey, ":owner:"))
.orderByAsc(TaskFileJobEntity::getUpdatedAt)
.last("limit " + (safeLimit - jobs.size()));
jobs.addAll(taskFileJobMapper.selectList(genericWrapper));
return jobs;
}
public List<TaskFileJobEntity> listJobs(String status, String moduleType, Long taskId, int limit) { public List<TaskFileJobEntity> listJobs(String status, String moduleType, Long taskId, int limit) {
LambdaQueryWrapper<TaskFileJobEntity> wrapper = new LambdaQueryWrapper<TaskFileJobEntity>() LambdaQueryWrapper<TaskFileJobEntity> wrapper = new LambdaQueryWrapper<TaskFileJobEntity>()
.orderByDesc(TaskFileJobEntity::getUpdatedAt) .orderByDesc(TaskFileJobEntity::getUpdatedAt)
@@ -130,7 +166,8 @@ public class TaskFileJobService {
.eq(TaskFileJobEntity::getStatus, "RUNNING") .eq(TaskFileJobEntity::getStatus, "RUNNING")
.set(TaskFileJobEntity::getStatus, "FAILED") .set(TaskFileJobEntity::getStatus, "FAILED")
.set(TaskFileJobEntity::getErrorMessage, "文件生成任务运行超时") .set(TaskFileJobEntity::getErrorMessage, "文件生成任务运行超时")
.set(TaskFileJobEntity::getUpdatedAt, LocalDateTime.now())); .set(TaskFileJobEntity::getUpdatedAt, LocalDateTime.now())
.set(TaskFileJobEntity::getFinishedAt, LocalDateTime.now()));
continue; continue;
} }
int updated = taskFileJobMapper.update(null, new LambdaUpdateWrapper<TaskFileJobEntity>() int updated = taskFileJobMapper.update(null, new LambdaUpdateWrapper<TaskFileJobEntity>()
@@ -168,6 +205,19 @@ public class TaskFileJobService {
.set(TaskFileJobEntity::getUpdatedAt, LocalDateTime.now())); .set(TaskFileJobEntity::getUpdatedAt, LocalDateTime.now()));
} }
public boolean deferRunning(Long jobId, String message) {
if (jobId == null || jobId <= 0) {
return false;
}
return taskFileJobMapper.update(null, new LambdaUpdateWrapper<TaskFileJobEntity>()
.eq(TaskFileJobEntity::getId, jobId)
.eq(TaskFileJobEntity::getStatus, "RUNNING")
.set(TaskFileJobEntity::getStatus, "PENDING")
.set(TaskFileJobEntity::getErrorMessage, message)
.set(TaskFileJobEntity::getUpdatedAt, LocalDateTime.now())
.set(TaskFileJobEntity::getFinishedAt, null)) > 0;
}
@Transactional @Transactional
public boolean requeue(Long jobId, String message) { public boolean requeue(Long jobId, String message) {
if (jobId == null || jobId <= 0) { if (jobId == null || jobId <= 0) {
@@ -198,14 +248,23 @@ public class TaskFileJobService {
.set(TaskFileJobEntity::getFinishedAt, LocalDateTime.now())); .set(TaskFileJobEntity::getFinishedAt, LocalDateTime.now()));
} }
public TaskFileJobEntity findById(Long jobId) {
if (jobId == null || jobId <= 0) {
return null;
}
return taskFileJobMapper.selectById(jobId);
}
public void markFailed(TaskFileJobEntity job, String message) { public void markFailed(TaskFileJobEntity job, String message) {
int retryCount = job.getRetryCount() == null ? 0 : job.getRetryCount() + 1; int retryCount = job.getRetryCount() == null ? 0 : job.getRetryCount() + 1;
taskFileJobMapper.update(null, new LambdaUpdateWrapper<TaskFileJobEntity>() taskFileJobMapper.update(null, new LambdaUpdateWrapper<TaskFileJobEntity>()
.eq(TaskFileJobEntity::getId, job.getId()) .eq(TaskFileJobEntity::getId, job.getId())
.ne(TaskFileJobEntity::getStatus, "SUCCESS")
.set(TaskFileJobEntity::getStatus, "FAILED") .set(TaskFileJobEntity::getStatus, "FAILED")
.set(TaskFileJobEntity::getRetryCount, retryCount) .set(TaskFileJobEntity::getRetryCount, retryCount)
.set(TaskFileJobEntity::getErrorMessage, message == null ? "结果文件生成失败" : message) .set(TaskFileJobEntity::getErrorMessage, message == null ? "结果文件生成失败" : message)
.set(TaskFileJobEntity::getUpdatedAt, LocalDateTime.now())); .set(TaskFileJobEntity::getUpdatedAt, LocalDateTime.now())
.set(TaskFileJobEntity::getFinishedAt, retryCount >= 5 ? LocalDateTime.now() : null));
} }
public TaskFileJobEntity findAssembleJob(Long taskId, String moduleType, Long resultId) { public TaskFileJobEntity findAssembleJob(Long taskId, String moduleType, Long resultId) {

View File

@@ -62,7 +62,7 @@ public class TaskResultFileJobWorker {
if (!localWorkerEnabled) { if (!localWorkerEnabled) {
return; return;
} }
List<TaskFileJobEntity> jobs = taskFileJobService.listRunnableJobs(batchSize); List<TaskFileJobEntity> jobs = taskFileJobService.listRunnableJobsForOwner(batchSize, currentInstanceId());
if (jobs == null || jobs.isEmpty()) { if (jobs == null || jobs.isEmpty()) {
return; return;
} }
@@ -111,6 +111,12 @@ public class TaskResultFileJobWorker {
private void processInternal(TaskFileJobEntity job) { private void processInternal(TaskFileJobEntity job) {
long startedAt = System.currentTimeMillis(); long startedAt = System.currentTimeMillis();
try { try {
TaskFileJobEntity latest = taskFileJobService.findById(job.getId());
if (latest != null && "SUCCESS".equals(latest.getStatus())) {
log.info("[task-file-job] process skipped because job already succeeded jobId={} taskId={} moduleType={} resultId={}",
job.getId(), job.getTaskId(), job.getModuleType(), job.getResultId());
return;
}
TaskDistributedLockService.LockHandle lockHandle = TaskDistributedLockService.LockHandle lockHandle =
taskDistributedLockService.acquire(job.getModuleType(), job.getTaskId(), TaskDistributedLockService.DEFAULT_WAIT_MILLIS); taskDistributedLockService.acquire(job.getModuleType(), job.getTaskId(), TaskDistributedLockService.DEFAULT_WAIT_MILLIS);
if (lockHandle == null) { if (lockHandle == null) {
@@ -120,9 +126,22 @@ public class TaskResultFileJobWorker {
return; return;
} }
try (lockHandle) { try (lockHandle) {
latest = taskFileJobService.findById(job.getId());
if (latest != null && "SUCCESS".equals(latest.getStatus())) {
log.info("[task-file-job] process skipped after lock because job already succeeded jobId={} taskId={} moduleType={} resultId={}",
job.getId(), job.getTaskId(), job.getModuleType(), job.getResultId());
return;
}
boolean completed = dispatch(job); boolean completed = dispatch(job);
if (!completed) { if (!completed) {
if (isOwnerScopedCozeJob(job)) {
taskFileJobService.touchRunning(job.getId()); taskFileJobService.touchRunning(job.getId());
log.info("[task-file-job] process waiting for async coze result jobId={} taskId={} moduleType={} resultId={} elapsedMs={}",
job.getId(), job.getTaskId(), job.getModuleType(), job.getResultId(),
System.currentTimeMillis() - startedAt);
return;
}
taskFileJobService.deferRunning(job.getId(), "Waiting for Coze/file assembly to continue");
log.info("[task-file-job] process deferred jobId={} taskId={} moduleType={} resultId={} elapsedMs={}", log.info("[task-file-job] process deferred jobId={} taskId={} moduleType={} resultId={} elapsedMs={}",
job.getId(), job.getTaskId(), job.getModuleType(), job.getResultId(), job.getId(), job.getTaskId(), job.getModuleType(), job.getResultId(),
System.currentTimeMillis() - startedAt); System.currentTimeMillis() - startedAt);
@@ -136,6 +155,12 @@ public class TaskResultFileJobWorker {
System.currentTimeMillis() - startedAt, resultFileUrl); System.currentTimeMillis() - startedAt, resultFileUrl);
} }
} catch (Exception ex) { } catch (Exception ex) {
TaskFileJobEntity latest = taskFileJobService.findById(job.getId());
if (latest != null && "SUCCESS".equals(latest.getStatus())) {
log.info("[task-file-job] process failure ignored because job already succeeded jobId={} taskId={} moduleType={} resultId={} msg={}",
job.getId(), job.getTaskId(), job.getModuleType(), job.getResultId(), ex.getMessage());
return;
}
String message = ex.getMessage() == null ? "结果文件生成失败" : ex.getMessage(); String message = ex.getMessage() == null ? "结果文件生成失败" : ex.getMessage();
log.warn("[task-file-job] process failed jobId={} taskId={} moduleType={} resultId={} msg={}", log.warn("[task-file-job] process failed jobId={} taskId={} moduleType={} resultId={} msg={}",
job.getId(), job.getTaskId(), job.getModuleType(), job.getResultId(), message); job.getId(), job.getTaskId(), job.getModuleType(), job.getResultId(), message);

View File

@@ -136,6 +136,9 @@ aiimage:
mq-enabled: ${AIIMAGE_RESULT_FILE_JOB_MQ_ENABLED:true} mq-enabled: ${AIIMAGE_RESULT_FILE_JOB_MQ_ENABLED:true}
topic: ${AIIMAGE_RESULT_FILE_JOB_TOPIC:aiimage-result-file-job} topic: ${AIIMAGE_RESULT_FILE_JOB_TOPIC:aiimage-result-file-job}
consumer-group: ${AIIMAGE_RESULT_FILE_JOB_CONSUMER_GROUP:aiimage-result-file-job-consumer} consumer-group: ${AIIMAGE_RESULT_FILE_JOB_CONSUMER_GROUP:aiimage-result-file-job-consumer}
mq-send-timeout-ms: ${AIIMAGE_RESULT_FILE_JOB_MQ_SEND_TIMEOUT_MS:1000}
mq-failure-threshold: ${AIIMAGE_RESULT_FILE_JOB_MQ_FAILURE_THRESHOLD:3}
mq-circuit-open-ms: ${AIIMAGE_RESULT_FILE_JOB_MQ_CIRCUIT_OPEN_MS:60000}
local-dispatch-enabled: ${AIIMAGE_RESULT_FILE_JOB_LOCAL_DISPATCH_ENABLED:true} local-dispatch-enabled: ${AIIMAGE_RESULT_FILE_JOB_LOCAL_DISPATCH_ENABLED:true}
local-dispatch-pool-size: ${AIIMAGE_RESULT_FILE_JOB_LOCAL_DISPATCH_POOL_SIZE:2} local-dispatch-pool-size: ${AIIMAGE_RESULT_FILE_JOB_LOCAL_DISPATCH_POOL_SIZE:2}
local-dispatch-queue-capacity: ${AIIMAGE_RESULT_FILE_JOB_LOCAL_DISPATCH_QUEUE_CAPACITY:200} local-dispatch-queue-capacity: ${AIIMAGE_RESULT_FILE_JOB_LOCAL_DISPATCH_QUEUE_CAPACITY:200}
@@ -144,12 +147,14 @@ aiimage:
stuck-scan-delay-ms: ${AIIMAGE_RESULT_FILE_JOB_STUCK_SCAN_DELAY_MS:60000} stuck-scan-delay-ms: ${AIIMAGE_RESULT_FILE_JOB_STUCK_SCAN_DELAY_MS:60000}
stuck-timeout-minutes: ${AIIMAGE_RESULT_FILE_JOB_STUCK_TIMEOUT_MINUTES:30} stuck-timeout-minutes: ${AIIMAGE_RESULT_FILE_JOB_STUCK_TIMEOUT_MINUTES:30}
batch-size: ${AIIMAGE_RESULT_FILE_JOB_BATCH_SIZE:20} batch-size: ${AIIMAGE_RESULT_FILE_JOB_BATCH_SIZE:20}
coze-task:
max-concurrent: ${AIIMAGE_COZE_TASK_MAX_CONCURRENT:8}
appearance-patent: appearance-patent:
coze-base-url: ${AIIMAGE_APPEARANCE_PATENT_COZE_BASE_URL:https://api.coze.cn} coze-base-url: ${AIIMAGE_APPEARANCE_PATENT_COZE_BASE_URL:https://api.coze.cn}
coze-workflow-path: ${AIIMAGE_APPEARANCE_PATENT_COZE_WORKFLOW_PATH:/v1/workflow/run} coze-workflow-path: ${AIIMAGE_APPEARANCE_PATENT_COZE_WORKFLOW_PATH:/v1/workflow/run}
coze-workflow-id: ${AIIMAGE_APPEARANCE_PATENT_COZE_WORKFLOW_ID:7632683471312355338} coze-workflow-id: ${AIIMAGE_APPEARANCE_PATENT_COZE_WORKFLOW_ID:7639685157562089513}
coze-token: ${AIIMAGE_APPEARANCE_PATENT_COZE_TOKEN:Bearer sat_gsInckdsN0qqhnZSK7K4Zevw6neJCHmYHyIbm85oGgJIGEmSdkH5OFctSKLsAHvT} coze-token: ${AIIMAGE_APPEARANCE_PATENT_COZE_TOKEN:Bearer sat_CztofPRhIKFKeaPZBIRxocfckqhsdCZZ45NPhkf7WOZjbX36vnVSfVV30T6u2rSX}
coze-batch-size: ${AIIMAGE_APPEARANCE_PATENT_COZE_BATCH_SIZE:50} coze-batch-size: ${AIIMAGE_APPEARANCE_PATENT_COZE_BATCH_SIZE:20}
coze-connect-timeout-millis: ${AIIMAGE_APPEARANCE_PATENT_COZE_CONNECT_TIMEOUT_MILLIS:10000} coze-connect-timeout-millis: ${AIIMAGE_APPEARANCE_PATENT_COZE_CONNECT_TIMEOUT_MILLIS:10000}
coze-read-timeout-millis: ${AIIMAGE_APPEARANCE_PATENT_COZE_READ_TIMEOUT_MILLIS:60000} coze-read-timeout-millis: ${AIIMAGE_APPEARANCE_PATENT_COZE_READ_TIMEOUT_MILLIS:60000}
coze-poll-interval-millis: ${AIIMAGE_APPEARANCE_PATENT_COZE_POLL_INTERVAL_MILLIS:30000} coze-poll-interval-millis: ${AIIMAGE_APPEARANCE_PATENT_COZE_POLL_INTERVAL_MILLIS:30000}
@@ -159,9 +164,9 @@ aiimage:
similar-asin: similar-asin:
coze-base-url: ${AIIMAGE_SIMILAR_ASIN_COZE_BASE_URL:https://api.coze.cn} coze-base-url: ${AIIMAGE_SIMILAR_ASIN_COZE_BASE_URL:https://api.coze.cn}
coze-workflow-path: ${AIIMAGE_SIMILAR_ASIN_COZE_WORKFLOW_PATH:/v1/workflow/run} coze-workflow-path: ${AIIMAGE_SIMILAR_ASIN_COZE_WORKFLOW_PATH:/v1/workflow/run}
coze-workflow-id: ${AIIMAGE_SIMILAR_ASIN_COZE_WORKFLOW_ID:7635328462404583478} coze-workflow-id: ${AIIMAGE_SIMILAR_ASIN_COZE_WORKFLOW_ID:7639708860686024756}
coze-token: ${AIIMAGE_SIMILAR_ASIN_COZE_TOKEN:Bearer sat_gsInckdsN0qqhnZSK7K4Zevw6neJCHmYHyIbm85oGgJIGEmSdkH5OFctSKLsAHvT} coze-token: ${AIIMAGE_SIMILAR_ASIN_COZE_TOKEN:Bearer sat_vqmb97BiXbMW3XVyG9htWQKjZ2F55CaW2FRrT9bDdPha7a3hxmXnMNCE1XNHdCVU}
coze-batch-size: ${AIIMAGE_SIMILAR_ASIN_COZE_BATCH_SIZE:50} coze-batch-size: ${AIIMAGE_SIMILAR_ASIN_COZE_BATCH_SIZE:20}
coze-connect-timeout-millis: ${AIIMAGE_SIMILAR_ASIN_COZE_CONNECT_TIMEOUT_MILLIS:10000} coze-connect-timeout-millis: ${AIIMAGE_SIMILAR_ASIN_COZE_CONNECT_TIMEOUT_MILLIS:10000}
coze-read-timeout-millis: ${AIIMAGE_SIMILAR_ASIN_COZE_READ_TIMEOUT_MILLIS:60000} coze-read-timeout-millis: ${AIIMAGE_SIMILAR_ASIN_COZE_READ_TIMEOUT_MILLIS:60000}
coze-poll-interval-millis: ${AIIMAGE_SIMILAR_ASIN_COZE_POLL_INTERVAL_MILLIS:30000} coze-poll-interval-millis: ${AIIMAGE_SIMILAR_ASIN_COZE_POLL_INTERVAL_MILLIS:30000}

View File

@@ -0,0 +1,29 @@
CREATE TABLE IF NOT EXISTS biz_coze_credential (
id BIGINT NOT NULL AUTO_INCREMENT,
module_type VARCHAR(64) NOT NULL,
credential_name VARCHAR(128) NOT NULL,
workflow_id VARCHAR(128) NOT NULL,
token VARCHAR(512) NOT NULL,
enabled TINYINT NOT NULL DEFAULT 1,
max_concurrent INT NOT NULL DEFAULT 0,
sort_order INT NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uk_coze_credential_module_name (module_type, credential_name),
KEY idx_coze_credential_module_enabled (module_type, enabled, sort_order, id)
);
INSERT INTO biz_coze_credential (module_type, credential_name, workflow_id, token, enabled, max_concurrent, sort_order)
VALUES
('APPEARANCE_PATENT', 'appearance-1', '7639685157562089513', 'Bearer sat_CztofPRhIKFKeaPZBIRxocfckqhsdCZZ45NPhkf7WOZjbX36vnVSfVV30T6u2rSX', 1, 0, 10),
('APPEARANCE_PATENT', 'appearance-2', '7639688221823778850', 'Bearer sat_2VsTpfWXYG3EkCo8OQfLwJEWqhduCL4NEw7sSCsABppkW61kkSRPIac9coNlEnHE', 1, 0, 20),
('SIMILAR_ASIN', 'similar-1', '7639708860686024756', 'Bearer sat_vqmb97BiXbMW3XVyG9htWQKjZ2F55CaW2FRrT9bDdPha7a3hxmXnMNCE1XNHdCVU', 1, 0, 10),
('SIMILAR_ASIN', 'similar-2', '7635328462404583478', 'Bearer sat_gsInckdsN0qqhnZSK7K4Zevw6neJCHmYHyIbm85oGgJIGEmSdkH5OFctSKLsAHvT', 1, 0, 20)
ON DUPLICATE KEY UPDATE
workflow_id = VALUES(workflow_id),
token = VALUES(token),
enabled = VALUES(enabled),
max_concurrent = VALUES(max_concurrent),
sort_order = VALUES(sort_order),
updated_at = CURRENT_TIMESTAMP;

View File

@@ -4,7 +4,7 @@
Source Server : AI-线 Source Server : AI-线
Source Server Type : MySQL Source Server Type : MySQL
Source Server Version : 80045 (8.0.45) Source Server Version : 80045 (8.0.45)
Source Host : 8.136.19.173:3306 Source Host : 47.111.163.154:3306
Source Schema : aiimage Source Schema : aiimage
Target Server Type : MySQL Target Server Type : MySQL

View File

@@ -5,6 +5,7 @@ import json
import os import os
import re import re
import threading import threading
from urllib.parse import quote
import requests import requests
from requests.adapters import HTTPAdapter from requests.adapters import HTTPAdapter
@@ -1217,7 +1218,9 @@ def list_product_categories():
_, _, denied = _ensure_product_category_access() _, _, denied = _ensure_product_category_access()
if denied: if denied:
return denied return denied
result, error_response, status = _proxy_backend_java('GET', '/api/admin/product-categories') keyword = (request.args.get('keyword') or '').strip()
query = f'?keyword={quote(keyword)}' if keyword else ''
result, error_response, status = _proxy_backend_java('GET', f'/api/admin/product-categories{query}')
if error_response is not None: if error_response is not None:
return error_response, status return error_response, status
payload = result.get('data') or {} payload = result.get('data') or {}
@@ -1225,6 +1228,76 @@ def list_product_categories():
'success': True, 'success': True,
'tree': [_format_product_category_item(item) for item in (payload.get('tree') or [])], 'tree': [_format_product_category_item(item) for item in (payload.get('tree') or [])],
'items': [_format_product_category_item(item, include_children=False) for item in (payload.get('items') or [])], 'items': [_format_product_category_item(item, include_children=False) for item in (payload.get('items') or [])],
'total': payload.get('total') or 0,
'page': payload.get('page') or 1,
'page_size': payload.get('pageSize') or 0,
'has_more': bool(payload.get('hasMore')),
})
@admin_api.route('/product-categories/children')
@login_required
def list_product_category_children():
_ensure_product_category_schema()
_, _, denied = _ensure_product_category_access()
if denied:
return denied
parent_id_raw = (request.args.get('parent_id') or request.args.get('parentId') or '').strip()
page = max(1, int(request.args.get('page', 1)))
page_size = min(100, max(1, int(request.args.get('page_size', request.args.get('pageSize', 20)))))
params = {
'page': page,
'pageSize': page_size,
}
if parent_id_raw:
params['parentId'] = parent_id_raw
result, error_response, status = _proxy_backend_java(
'GET',
'/api/admin/product-categories/children',
params=params,
)
if error_response is not None:
return error_response, status
payload = result.get('data') or {}
return jsonify({
'success': True,
'items': [_format_product_category_item(item, include_children=False) for item in (payload.get('items') or [])],
'total': payload.get('total') or 0,
'page': payload.get('page') or page,
'page_size': payload.get('pageSize') or page_size,
'has_more': bool(payload.get('hasMore')),
})
@admin_api.route('/product-categories/search')
@login_required
def search_product_categories():
_ensure_product_category_schema()
_, _, denied = _ensure_product_category_access()
if denied:
return denied
keyword = (request.args.get('keyword') or '').strip()
page = max(1, int(request.args.get('page', 1)))
page_size = min(100, max(1, int(request.args.get('page_size', request.args.get('pageSize', 20)))))
result, error_response, status = _proxy_backend_java(
'GET',
'/api/admin/product-categories/search',
params={
'keyword': keyword,
'page': page,
'pageSize': page_size,
},
)
if error_response is not None:
return error_response, status
payload = result.get('data') or {}
return jsonify({
'success': True,
'items': [_format_product_category_item(item, include_children=False) for item in (payload.get('items') or [])],
'total': payload.get('total') or 0,
'page': payload.get('page') or page,
'page_size': payload.get('pageSize') or page_size,
'has_more': bool(payload.get('hasMore')),
}) })
@@ -2415,6 +2488,54 @@ def list_query_asins():
}) })
@admin_api.route('/query-asins/export')
@login_required
def export_query_asins():
role, current_row, denied = _ensure_backend_menu_access('query-asin')
if denied:
return denied
params = {
'operatorId': current_row.get('id') if current_row else None,
'superAdmin': 'true' if role == 'super_admin' else 'false',
}
group_id_raw = (request.args.get('group_id') or request.args.get('groupId') or '').strip()
shop_name = (request.args.get('shop_name') or request.args.get('shopName') or '').strip()
asin = (request.args.get('asin') or '').strip()
if group_id_raw:
params['groupId'] = group_id_raw
if shop_name:
params['shopName'] = shop_name
if asin:
params['asin'] = asin
url = f"{backend_java_base_url}/api/admin/query-asins/export"
try:
resp = _get_backend_java_session().get(url, params=params, timeout=60)
except requests.RequestException:
return jsonify({'success': False, 'error': 'backend-java 服务不可用'}), 502
if resp.status_code >= 400:
try:
data = resp.json()
error = data.get('message') or data.get('error') or '导出失败'
except ValueError:
error = '导出失败'
return jsonify({'success': False, 'error': error}), resp.status_code
headers = {}
disposition = resp.headers.get('Content-Disposition')
if disposition:
headers['Content-Disposition'] = disposition
return Response(
resp.content,
status=resp.status_code,
headers=headers,
content_type=resp.headers.get(
'Content-Type',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
),
)
def _format_query_asin_import_progress(progress): def _format_query_asin_import_progress(progress):
return { return {
'status': progress.get('status') or 'pending', 'status': progress.get('status') or 'pending',

View File

@@ -1,14 +1,48 @@
import os
from pathlib import Path
try:
from dotenv import load_dotenv
except ImportError:
def load_dotenv(*args, **kwargs):
return False
base_url = "https://api.coze.cn/v1" base_url = "https://api.coze.cn/v1"
coze_token = "sat_12nW40INoJxArrDXbY4lSCoudbqkOYTcphC99BP2efWyzxmsk4q81WDX3ezWgqZ5" coze_token = "sat_12nW40INoJxArrDXbY4lSCoudbqkOYTcphC99BP2efWyzxmsk4q81WDX3ezWgqZ5"
workflow_id = "7608812635877900322" workflow_id = "7608812635877900322"
STITCH_WORKFLOW_ID = "7608813873483300907" STITCH_WORKFLOW_ID = "7608813873483300907"
BASE_DIR = Path(__file__).resolve().parent
LOADED_ENV_FILES = []
def _load_env_files():
candidates = (
BASE_DIR / ".env",
BASE_DIR.parent / "app" / ".env",
BASE_DIR.parent / ".env",
)
for env_path in candidates:
if env_path.is_file():
load_dotenv(dotenv_path=env_path, override=False)
LOADED_ENV_FILES.append(str(env_path))
def _get_env(*names, default=None):
for name in names:
value = os.getenv(name)
if value not in (None, ""):
return value, name
return default, "config.default"
_load_env_files()
# MySQL 配置 # MySQL 配置
mysql_host = "8.136.19.173" mysql_host, mysql_host_source = _get_env("mysql_host", "MYSQL_HOST", default="47.110.241.161")
mysql_user = "aiimage" mysql_user, mysql_user_source = _get_env("mysql_user", "MYSQL_USER", default="aiimage")
mysql_password = "WTFrb5y6hNLz6hNy" # 请修改为您的数据库密码 mysql_password, mysql_password_source = _get_env("mysql_password", "MYSQL_PASSWORD", default="WTFrb5y6hNLz6hNy")
mysql_database = "aiimage" mysql_database, mysql_database_source = _get_env("mysql_database", "MYSQL_DATABASE", default="aiimage")
cache_path = "./user_data" cache_path = "./user_data"
@@ -22,20 +56,20 @@ bucket_path = "nanri-image/"
file_url_pre = f"https://{bucket}.oss-cn-hangzhou.aliyuncs.com/" file_url_pre = f"https://{bucket}.oss-cn-hangzhou.aliyuncs.com/"
import os backend_java_base_url, backend_java_base_url_source = _get_env(
backend_java_base_url = os.environ.get('BACKEND_JAVA_BASE_URL', 'http://127.0.0.1:18080').rstrip('/') "java_api_base",
# backend_java_base_url = os.environ.get('BACKEND_JAVA_BASE_URL', 'http://8.136.19.173:18080').rstrip('/') "BACKEND_JAVA_BASE_URL",
# backend_java_base_url = os.environ.get('BACKEND_JAVA_BASE_URL', 'http://47.111.163.154:18080').rstrip('/') default="http://127.0.0.1:18080",
# backend_java_base_url = os.environ.get('BACKEND_JAVA_BASE_URL', 'http://121.196.149.225:18080').rstrip('/') # default="http://47.111.163.154:18080",
os.environ['OSS_ACCESS_KEY_ID'] = accessKeyId )
os.environ['OSS_ACCESS_KEY_SECRET'] = accessKeySecret backend_java_base_url = backend_java_base_url.rstrip("/")
os.environ['SECRET_KEY'] = "ddffc7c1d02121d9554d7b080b2511b6" os.environ["OSS_ACCESS_KEY_ID"] = accessKeyId
os.environ["OSS_ACCESS_KEY_SECRET"] = accessKeySecret
os.environ["SECRET_KEY"] = "ddffc7c1d02121d9554d7b080b2511b6"
debug = True debug = True
version = "1.0.0" version = "1.0.0"
APP_UPDATE_URL = "" APP_UPDATE_URL = ""
os.environ['APP_VERSION'] = version os.environ["APP_VERSION"] = version
os.environ['APP_UPDATE_URL'] = version os.environ["APP_UPDATE_URL"] = version

View File

@@ -44,6 +44,7 @@ PyQt5==5.15.11
PyQt5-Qt5==5.15.2 PyQt5-Qt5==5.15.2
PyQt5_sip==12.17.1 PyQt5_sip==12.17.1
python-dateutil==2.9.0.post0 python-dateutil==2.9.0.post0
python-dotenv==1.0.1
pythonnet==3.0.5 pythonnet==3.0.5
pytz==2026.1.post1 pytz==2026.1.post1
pywebview==6.1 pywebview==6.1

View File

@@ -2563,14 +2563,47 @@
} }
function buildQueryAsinQuery(page) { function buildQueryAsinQuery(page) {
var query = 'page=' + (page || 1) + '&page_size=' + queryAsinPageSize; var query = 'page=' + (page || 1) + '&page_size=' + queryAsinPageSize;
var filterQuery = buildQueryAsinFilterQuery();
if (filterQuery) query += '&' + filterQuery;
return query;
}
function buildQueryAsinFilterQuery() {
var query = '';
var groupId = (document.getElementById('queryAsinFilterGroupId').value || '').trim(); var groupId = (document.getElementById('queryAsinFilterGroupId').value || '').trim();
var shopName = (document.getElementById('queryAsinFilterShopName').value || '').trim(); var shopName = (document.getElementById('queryAsinFilterShopName').value || '').trim();
var asin = (document.getElementById('queryAsinFilterAsin').value || '').trim(); var asin = (document.getElementById('queryAsinFilterAsin').value || '').trim();
if (groupId) query += '&group_id=' + encodeURIComponent(groupId); if (groupId) query += (query ? '&' : '') + 'group_id=' + encodeURIComponent(groupId);
if (shopName) query += '&shop_name=' + encodeURIComponent(shopName); if (shopName) query += (query ? '&' : '') + 'shop_name=' + encodeURIComponent(shopName);
if (asin) query += '&asin=' + encodeURIComponent(asin); if (asin) query += (query ? '&' : '') + 'asin=' + encodeURIComponent(asin);
return query; return query;
} }
function exportQueryAsin() {
var query = buildQueryAsinFilterQuery();
var url = '/api/admin/query-asins/export' + (query ? ('?' + query) : '');
fetch(url)
.then(function (response) {
var contentType = response.headers.get('content-type') || '';
if (!response.ok || contentType.indexOf('application/json') >= 0) {
return response.json().then(function (res) {
throw new Error((res && (res.error || res.msg)) || '导出失败');
}).catch(function (err) {
throw err instanceof Error ? err : new Error('导出失败');
});
}
return response.blob().then(function (blob) {
return {
blob: blob,
filename: extractDownloadFilename(response.headers.get('content-disposition'), 'query-asin.xlsx')
};
});
})
.then(function (payload) {
triggerBrowserDownload(payload.blob, payload.filename);
})
.catch(function (err) {
alert((err && err.message) || '导出失败');
});
}
function renderQueryAsinCell(item, country) { function renderQueryAsinCell(item, country) {
var asinValue = item[country.field] || ''; var asinValue = item[country.field] || '';
var infoHtml = asinValue ? '<span>' + asinValue + '</span>' : '<span style="color:#999;">-</span>'; var infoHtml = asinValue ? '<span>' + asinValue + '</span>' : '<span style="color:#999;">-</span>';
@@ -2883,6 +2916,9 @@
document.getElementById('btnSearchQueryAsin').onclick = function () { document.getElementById('btnSearchQueryAsin').onclick = function () {
loadQueryAsin(1); loadQueryAsin(1);
}; };
document.getElementById('btnExportQueryAsin').onclick = function () {
exportQueryAsin();
};
document.getElementById('queryAsinFilterShopName').addEventListener('keydown', function (e) { document.getElementById('queryAsinFilterShopName').addEventListener('keydown', function (e) {
if (e.key === 'Enter') { if (e.key === 'Enter') {
e.preventDefault(); e.preventDefault();
@@ -2964,6 +3000,11 @@
renderQueryAsinInputs(); renderQueryAsinInputs();
var productCategoryItems = []; var productCategoryItems = [];
var productCategoryNodeMap = {};
var productCategoryPageSize = 20;
var productCategoryRootState = { children: [], page: 0, total: 0, hasMore: true, loaded: false, loading: false };
var productCategorySearchState = { items: [], page: 0, total: 0, hasMore: false, loading: false };
var productCategoryKeyword = '';
var editingProductCategoryId = null; var editingProductCategoryId = null;
function resetProductCategoryForm() { function resetProductCategoryForm() {
@@ -2983,27 +3024,102 @@
var selected = selectedValue == null ? select.value : String(selectedValue || ''); var selected = selectedValue == null ? select.value : String(selectedValue || '');
var excluded = excludedId == null ? editingProductCategoryId : excludedId; var excluded = excludedId == null ? editingProductCategoryId : excludedId;
var options = ['<option value="">顶级类目</option>']; var options = ['<option value="">顶级类目</option>'];
productCategoryItems.forEach(function (item) { productCategoryItems.slice().sort(function (a, b) {
return String(a.path || a.name || '').localeCompare(String(b.path || b.name || ''), 'zh-CN');
}).forEach(function (item) {
if (excluded && String(item.id) === String(excluded)) return; if (excluded && String(item.id) === String(excluded)) return;
var prefix = new Array((item.level || 0) + 1).join('  '); var prefix = new Array((item.level || 0) + 1).join('  ');
options.push('<option value="' + escapeHtml(item.id) + '">' + prefix + escapeHtml(item.name || '') + '</option>'); options.push('<option value="' + escapeHtml(item.id) + '">' + prefix + escapeHtml(item.name || '') + '</option>');
}); });
if (selected && !productCategoryItems.some(function (item) { return String(item.id) === selected; })) {
options.push('<option value="' + escapeHtml(selected) + '">当前父级 #' + escapeHtml(selected) + '</option>');
}
select.innerHTML = options.join(''); select.innerHTML = options.join('');
select.value = selected; select.value = selected;
} }
function renderProductCategoryRows(items) { function normalizeProductCategoryItem(item, parent) {
var id = String(item.id || '');
var existing = productCategoryNodeMap[id] || {};
var level = item.level != null ? item.level : (parent ? (parent.level || 0) + 1 : 0);
var path = item.path || (parent && parent.path ? parent.path + ' / ' + (item.name || '') : (item.name || ''));
var normalized = Object.assign(existing, item, {
id: item.id,
parent_id: item.parent_id == null ? null : item.parent_id,
child_count: item.child_count || 0,
level: level,
path: path,
children: existing.children || [],
page: existing.page || 0,
total: existing.total || 0,
hasMore: existing.hasMore !== false,
loaded: existing.loaded || false,
loading: false,
expanded: existing.expanded || false
});
productCategoryNodeMap[id] = normalized;
productCategoryItems = Object.keys(productCategoryNodeMap).map(function (key) { return productCategoryNodeMap[key]; });
return normalized;
}
function flattenVisibleProductCategories(items, output) {
(items || []).forEach(function (item) {
output.push({ type: 'item', item: item });
if ((item.child_count || 0) > 0 && item.expanded) {
flattenVisibleProductCategories(item.children || [], output);
if (item.hasMore) {
output.push({ type: 'more', parent: item });
}
}
});
return output;
}
function renderProductCategoryRows() {
var tbody = document.getElementById('productCategoryListBody'); var tbody = document.getElementById('productCategoryListBody');
if (!tbody) return; if (!tbody) return;
if (!items.length) { var rows = productCategoryKeyword
? productCategorySearchState.items.map(function (item) { return { type: 'item', item: item, search: true }; })
: flattenVisibleProductCategories(productCategoryRootState.children, []);
if (!productCategoryKeyword && productCategoryRootState.hasMore) {
rows.push({ type: 'more', parent: null });
}
if (productCategoryKeyword && productCategorySearchState.hasMore) {
rows.push({ type: 'searchMore' });
}
if (!rows.length) {
tbody.innerHTML = '<tr><td colspan="6" class="empty-tip">暂无商品类目</td></tr>'; tbody.innerHTML = '<tr><td colspan="6" class="empty-tip">暂无商品类目</td></tr>';
return; return;
} }
tbody.innerHTML = items.map(function (item) { tbody.innerHTML = rows.map(function (row) {
if (row.type === 'more') {
var parent = row.parent;
var level = parent ? (parent.level || 0) + 1 : 0;
var indentMore = Math.max(0, level) * 24;
var loading = parent ? parent.loading : productCategoryRootState.loading;
var loadedCount = parent ? (parent.children || []).length : (productCategoryRootState.children || []).length;
var totalCount = parent ? (parent.total || 0) : (productCategoryRootState.total || 0);
return '<tr><td colspan="6"><div class="tree-name-cell"><span class="tree-indent" style="--indent:' + indentMore + 'px;"></span>' +
'<button class="btn btn-sm btn-secondary" type="button" data-product-category-load-more="' + escapeHtml(parent ? parent.id : '') + '"' + (loading ? ' disabled' : '') + '>' +
(loading ? '加载中...' : '加载更多') + '</button>' +
'<span class="empty-tip" style="padding:0;">已加载 ' + escapeHtml(loadedCount) + ' / ' + escapeHtml(totalCount) + '</span></div></td></tr>';
}
if (row.type === 'searchMore') {
var searchLoadedCount = (productCategorySearchState.items || []).length;
var searchTotalCount = productCategorySearchState.total || 0;
return '<tr><td colspan="6"><button class="btn btn-sm btn-secondary" type="button" data-product-category-search-more' + (productCategorySearchState.loading ? ' disabled' : '') + '>' +
(productCategorySearchState.loading ? '加载中...' : '加载更多搜索结果') + '</button> ' +
'<span class="empty-tip" style="padding:0;">已加载 ' + escapeHtml(searchLoadedCount) + ' / ' + escapeHtml(searchTotalCount) + '</span></td></tr>';
}
var item = row.item;
var indent = Math.max(0, item.level || 0) * 24; var indent = Math.max(0, item.level || 0) * 24;
var mark = item.child_count > 0 ? '+' : '-'; var hasChildren = (item.child_count || 0) > 0;
var expanded = !!item.expanded;
var mark = hasChildren ? (expanded ? '-' : '+') : '';
return '<tr>' + return '<tr>' +
'<td><div class="tree-name-cell"><span class="tree-indent" style="--indent:' + indent + 'px;"></span><span class="tree-node-mark">' + mark + '</span><strong>' + escapeHtml(item.name || '') + '</strong></div></td>' + '<td><div class="tree-name-cell"><span class="tree-indent" style="--indent:' + indent + 'px;"></span>' +
'<button type="button" class="tree-node-mark' + (hasChildren ? '' : ' is-leaf') + '" data-product-category-toggle="' + escapeHtml(item.id) + '"' + (hasChildren ? '' : ' disabled') + '>' + mark + '</button>' +
'<strong>' + escapeHtml(item.name || '') + '</strong></div></td>' +
'<td><div>' + escapeHtml(item.path || item.name || '') + '</div><div class="category-path">' + escapeHtml(item.category_key || '') + '</div></td>' + '<td><div>' + escapeHtml(item.path || item.name || '') + '</div><div class="category-path">' + escapeHtml(item.category_key || '') + '</div></td>' +
'<td>' + escapeHtml(item.sort_order || 0) + '</td>' + '<td>' + escapeHtml(item.sort_order || 0) + '</td>' +
'<td><span class="category-tag">' + (item.is_builtin ? '内置' : '自定义') + '</span></td>' + '<td><span class="category-tag">' + (item.is_builtin ? '内置' : '自定义') + '</span></td>' +
@@ -3012,6 +3128,24 @@
'<button class="btn btn-sm btn-danger" data-product-category-delete="' + escapeHtml(item.id) + '" data-product-category-name="' + escapeHtml(item.name || '') + '"' + (item.child_count > 0 ? ' disabled' : '') + '>删除</button></td>' + '<button class="btn btn-sm btn-danger" data-product-category-delete="' + escapeHtml(item.id) + '" data-product-category-name="' + escapeHtml(item.name || '') + '"' + (item.child_count > 0 ? ' disabled' : '') + '>删除</button></td>' +
'</tr>'; '</tr>';
}).join(''); }).join('');
document.querySelectorAll('[data-product-category-toggle]').forEach(function (btn) {
btn.onclick = function () {
var id = String(btn.dataset.productCategoryToggle || '');
if (!id || btn.disabled) return;
toggleProductCategoryNode(id);
};
});
document.querySelectorAll('[data-product-category-load-more]').forEach(function (btn) {
btn.onclick = function () {
var parentId = (btn.dataset.productCategoryLoadMore || '').trim();
loadProductCategoryChildren(parentId || null, true);
};
});
document.querySelectorAll('[data-product-category-search-more]').forEach(function (btn) {
btn.onclick = function () {
loadProductCategorySearch(true);
};
});
document.querySelectorAll('[data-product-category-edit]').forEach(function (btn) { document.querySelectorAll('[data-product-category-edit]').forEach(function (btn) {
btn.onclick = function () { btn.onclick = function () {
var item = productCategoryItems.find(function (row) { return String(row.id) === String(btn.dataset.productCategoryEdit); }); var item = productCategoryItems.find(function (row) { return String(row.id) === String(btn.dataset.productCategoryEdit); });
@@ -3047,28 +3181,136 @@
} }
function loadProductCategories() { function loadProductCategories() {
fetch('/api/admin/product-categories') if (productCategoryKeyword) {
productCategorySearchState = { items: [], page: 0, total: 0, hasMore: false, loading: false };
loadProductCategorySearch(false);
return;
}
productCategoryRootState = { children: [], page: 0, total: 0, hasMore: true, loaded: false, loading: false };
productCategoryNodeMap = {};
productCategoryItems = [];
renderProductCategoryParentOptions();
loadProductCategoryChildren(null, false);
}
function loadProductCategoryChildren(parentId, append) {
var parent = parentId ? productCategoryNodeMap[String(parentId)] : null;
var state = parent || productCategoryRootState;
if (state.loading) return;
state.loading = true;
var failed = false;
renderProductCategoryRows();
var nextPage = append ? (state.page || 0) + 1 : 1;
var query = '?page=' + nextPage + '&page_size=' + productCategoryPageSize;
if (parentId) query += '&parent_id=' + encodeURIComponent(parentId);
fetch('/api/admin/product-categories/children' + query)
.then(function (r) { return r.json(); }) .then(function (r) { return r.json(); })
.then(function (res) { .then(function (res) {
var tbody = document.getElementById('productCategoryListBody'); var tbody = document.getElementById('productCategoryListBody');
if (!res.success) { if (!res.success) {
failed = true;
if (tbody) tbody.innerHTML = '<tr><td colspan="6" class="empty-tip">加载失败: ' + escapeHtml(res.error || '') + '</td></tr>'; if (tbody) tbody.innerHTML = '<tr><td colspan="6" class="empty-tip">加载失败: ' + escapeHtml(res.error || '') + '</td></tr>';
return; return;
} }
productCategoryItems = res.items || []; var children = (res.items || []).map(function (item) {
return normalizeProductCategoryItem(item, parent);
});
if (!append) {
state.children = [];
}
state.children = state.children.concat(children);
state.page = res.page || nextPage;
state.total = res.total || 0;
state.hasMore = !!res.has_more;
state.loaded = true;
renderProductCategoryParentOptions(); renderProductCategoryParentOptions();
renderProductCategoryRows(productCategoryItems); renderProductCategoryRows();
}) })
.catch(function () { .catch(function () {
failed = true;
var tbody = document.getElementById('productCategoryListBody'); var tbody = document.getElementById('productCategoryListBody');
if (tbody) tbody.innerHTML = '<tr><td colspan="6" class="empty-tip">请求失败</td></tr>'; if (tbody) tbody.innerHTML = '<tr><td colspan="6" class="empty-tip">请求失败</td></tr>';
})
.finally(function () {
state.loading = false;
if (!failed) renderProductCategoryRows();
}); });
} }
function loadProductCategorySearch(append) {
if (productCategorySearchState.loading) return;
productCategorySearchState.loading = true;
var failed = false;
renderProductCategoryRows();
var nextPage = append ? (productCategorySearchState.page || 0) + 1 : 1;
fetch('/api/admin/product-categories/search?keyword=' + encodeURIComponent(productCategoryKeyword) +
'&page=' + nextPage + '&page_size=' + productCategoryPageSize)
.then(function (r) { return r.json(); })
.then(function (res) {
var tbody = document.getElementById('productCategoryListBody');
if (!res.success) {
failed = true;
if (tbody) tbody.innerHTML = '<tr><td colspan="6" class="empty-tip">加载失败: ' + escapeHtml(res.error || '') + '</td></tr>';
return;
}
var items = (res.items || []).map(function (item) {
var normalized = normalizeProductCategoryItem(item, null);
normalized.expanded = false;
return normalized;
});
productCategorySearchState.items = append
? productCategorySearchState.items.concat(items)
: items;
productCategorySearchState.page = res.page || nextPage;
productCategorySearchState.total = res.total || 0;
productCategorySearchState.hasMore = !!res.has_more;
renderProductCategoryParentOptions();
renderProductCategoryRows();
})
.catch(function () {
failed = true;
var tbody = document.getElementById('productCategoryListBody');
if (tbody) tbody.innerHTML = '<tr><td colspan="6" class="empty-tip">请求失败</td></tr>';
})
.finally(function () {
productCategorySearchState.loading = false;
if (!failed) renderProductCategoryRows();
});
}
function toggleProductCategoryNode(id) {
var item = productCategoryNodeMap[String(id)];
if (!item) return;
item.expanded = !item.expanded;
if (item.expanded && !item.loaded) {
loadProductCategoryChildren(id, false);
return;
}
renderProductCategoryRows();
}
document.getElementById('btnCancelProductCategoryEdit').onclick = function () { document.getElementById('btnCancelProductCategoryEdit').onclick = function () {
resetProductCategoryForm(); resetProductCategoryForm();
}; };
document.getElementById('btnSearchProductCategory').onclick = function () {
productCategoryKeyword = (document.getElementById('productCategoryKeyword').value || '').trim();
loadProductCategories();
};
document.getElementById('btnClearProductCategorySearch').onclick = function () {
productCategoryKeyword = '';
document.getElementById('productCategoryKeyword').value = '';
loadProductCategories();
};
document.getElementById('productCategoryKeyword').onkeydown = function (event) {
if (event.key === 'Enter') {
event.preventDefault();
document.getElementById('btnSearchProductCategory').click();
}
};
document.getElementById('btnCreateProductCategory').onclick = function () { document.getElementById('btnCreateProductCategory').onclick = function () {
var msgEl = document.getElementById('msgProductCategory'); var msgEl = document.getElementById('msgProductCategory');
var name = (document.getElementById('productCategoryName').value || '').trim(); var name = (document.getElementById('productCategoryName').value || '').trim();

View File

@@ -10,16 +10,32 @@ try:
from config import mysql_user as config_mysql_user from config import mysql_user as config_mysql_user
from config import mysql_password as config_mysql_password from config import mysql_password as config_mysql_password
from config import mysql_database as config_mysql_database from config import mysql_database as config_mysql_database
from config import mysql_host_source as config_mysql_host_source
from config import mysql_user_source as config_mysql_user_source
from config import mysql_database_source as config_mysql_database_source
except ImportError: except ImportError:
config_mysql_host = 'localhost' config_mysql_host = 'localhost'
config_mysql_user = 'root' config_mysql_user = 'root'
config_mysql_password = '' config_mysql_password = ''
config_mysql_database = 'maixiang_ai' config_mysql_database = 'maixiang_ai'
config_mysql_host_source = 'fallback.default'
config_mysql_user_source = 'fallback.default'
config_mysql_database_source = 'fallback.default'
mysql_host = os.environ.get('MYSQL_HOST', config_mysql_host) mysql_host = config_mysql_host
mysql_user = os.environ.get('MYSQL_USER', config_mysql_user) mysql_user = config_mysql_user
mysql_password = os.environ.get('MYSQL_PASSWORD', config_mysql_password) mysql_password = config_mysql_password
mysql_database = os.environ.get('MYSQL_DATABASE', config_mysql_database) mysql_database = config_mysql_database
mysql_host_source = config_mysql_host_source
mysql_user_source = config_mysql_user_source
mysql_database_source = config_mysql_database_source
def describe_db_target():
return (
f"{mysql_user}@{mysql_host}/{mysql_database} "
f"(host={mysql_host_source}, user={mysql_user_source}, database={mysql_database_source})"
)
def get_db(): def get_db():

View File

@@ -371,6 +371,7 @@
.tree-node-mark { .tree-node-mark {
width: 18px; width: 18px;
height: 18px; height: 18px;
border: 0;
border-radius: 4px; border-radius: 4px;
background: #eef1ff; background: #eef1ff;
color: #667eea; color: #667eea;
@@ -379,6 +380,14 @@
justify-content: center; justify-content: center;
font-size: 12px; font-size: 12px;
font-weight: 700; font-weight: 700;
cursor: pointer;
padding: 0;
line-height: 1;
}
.tree-node-mark.is-leaf {
background: transparent;
cursor: default;
} }
.category-path { .category-path {
@@ -1317,6 +1326,7 @@
<input type="text" id="queryAsinFilterAsin" placeholder="请输入 ASIN"> <input type="text" id="queryAsinFilterAsin" placeholder="请输入 ASIN">
</div> </div>
<button class="btn" id="btnSearchQueryAsin">查询</button> <button class="btn" id="btnSearchQueryAsin">查询</button>
<button class="btn btn-secondary" id="btnExportQueryAsin" type="button">导出 XLSX</button>
</div> </div>
<table> <table>
<thead> <thead>
@@ -1365,7 +1375,16 @@
<p class="msg" id="msgProductCategory"></p> <p class="msg" id="msgProductCategory"></p>
</div> </div>
<div class="panel-box"> <div class="panel-box">
<h3 style="margin-bottom:16px;font-size:15px;">类目树</h3> <div style="display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:16px;">
<h3 style="font-size:15px;">类目树</h3>
<div class="form-row" style="gap:8px;margin:0;flex:0 1 420px;">
<div class="form-group" style="min-width:220px;flex:1;margin:0;">
<input type="text" id="productCategoryKeyword" placeholder="搜索类目名称、编码或备注">
</div>
<button class="btn btn-sm" id="btnSearchProductCategory" type="button">搜索</button>
<button class="btn btn-sm btn-secondary" id="btnClearProductCategorySearch" type="button">清空</button>
</div>
</div>
<table> <table>
<thead> <thead>
<tr> <tr>

View File

@@ -8,33 +8,33 @@
<el-dialog v-model="dialogVisible" width="560px" class="secret-settings-dialog" :append-to-body="true"> <el-dialog v-model="dialogVisible" width="560px" class="secret-settings-dialog" :append-to-body="true">
<template #header> <template #header>
<div class="dialog-header"> <div class="dialog-header">
<div class="dialog-title">设置</div> <div class="dialog-title">密钥设置</div>
<div class="dialog-subtitle">按当前登录用户保存在本机可分别管理外观专利和货源查询密钥</div> <div class="dialog-subtitle">按当前登录用户保存在本机外观专利和货源查询共用同一个 Coze 接口密钥</div>
</div> </div>
</template> </template>
<div class="secret-settings-body"> <div class="secret-settings-body">
<section v-for="item in moduleStates" :key="item.moduleKey" class="secret-card"> <section class="secret-card">
<div class="secret-card-head"> <div class="secret-card-head">
<div> <div>
<div class="secret-card-title">{{ item.title }}</div> <div class="secret-card-title">通用 Coze 密钥</div>
<div class="secret-card-desc">{{ item.description }}</div> <div class="secret-card-desc">用于外观专利检测和货源查询</div>
</div> </div>
<button <button
v-if="item.exists" v-if="secretState.exists"
type="button" type="button"
class="link-danger" class="link-danger"
@click="clearSecret(item.moduleKey)" @click="clearSecret"
> >
清空 清空
</button> </button>
</div> </div>
<input <input
v-model="item.value" v-model="secretState.value"
class="secret-input" class="secret-input"
type="password" type="password"
:placeholder="`请输入${item.title}密钥`" placeholder="请输入 Coze 接口密钥"
autocomplete="off" autocomplete="off"
spellcheck="false" spellcheck="false"
/> />
@@ -43,14 +43,14 @@
<div class="retention-label">保留时长</div> <div class="retention-label">保留时长</div>
<div class="retention-options"> <div class="retention-options">
<label v-for="option in retentionOptions" :key="option.value" class="retention-option"> <label v-for="option in retentionOptions" :key="option.value" class="retention-option">
<input v-model="item.retention" type="radio" :value="option.value" /> <input v-model="secretState.retention" type="radio" :value="option.value" />
<span>{{ option.label }}</span> <span>{{ option.label }}</span>
</label> </label>
</div> </div>
</div> </div>
<div class="secret-meta"> <div class="secret-meta">
<span v-if="item.exists">{{ formatRetentionText(item.retention, item.expiresAt) }}</span> <span v-if="secretState.exists">{{ formatRetentionText(secretState.retention, secretState.expiresAt) }}</span>
<span v-else>当前未保存</span> <span v-else>当前未保存</span>
</div> </div>
</section> </section>
@@ -73,14 +73,10 @@ import {
clearStoredApiSecret, clearStoredApiSecret,
getStoredApiSecretSnapshot, getStoredApiSecretSnapshot,
saveStoredApiSecret, saveStoredApiSecret,
type ApiSecretModuleKey,
type ApiSecretRetention, type ApiSecretRetention,
} from '@/shared/utils/api-secret-store' } from '@/shared/utils/api-secret-store'
type ModuleState = { type SecretState = {
moduleKey: ApiSecretModuleKey
title: string
description: string
value: string value: string
retention: ApiSecretRetention retention: ApiSecretRetention
expiresAt: number | null expiresAt: number | null
@@ -90,25 +86,18 @@ type ModuleState = {
const dialogVisible = ref(false) const dialogVisible = ref(false)
const retentionOptions: Array<{ value: ApiSecretRetention; label: string }> = [ const retentionOptions: Array<{ value: ApiSecretRetention; label: string }> = [
{ value: 'session', label: '本次打开有效' }, { value: 'session', label: '本次打开有效' },
{ value: '1d', label: '1天' }, { value: '1d', label: '1 天' },
{ value: '7d', label: '7天' }, { value: '7d', label: '7 天' },
{ value: '30d', label: '30天' }, { value: '30d', label: '30 天' },
{ value: 'forever', label: '长期保留' }, { value: 'forever', label: '长期保留' },
] ]
const moduleStates = ref<ModuleState[]>([]) const secretState = ref<SecretState>(emptySecretState())
function buildModuleState( function loadStates() {
moduleKey: ApiSecretModuleKey, const snapshot = getStoredApiSecretSnapshot()
title: string, secretState.value = {
description: string,
): ModuleState {
const snapshot = getStoredApiSecretSnapshot(moduleKey)
return {
moduleKey,
title,
description,
value: snapshot.value, value: snapshot.value,
retention: snapshot.retention, retention: snapshot.retention,
expiresAt: snapshot.expiresAt, expiresAt: snapshot.expiresAt,
@@ -116,11 +105,13 @@ function buildModuleState(
} }
} }
function loadStates() { function emptySecretState(): SecretState {
moduleStates.value = [ return {
buildModuleState('appearance-patent', '外观专利', '用于外观专利检测的 Coze 接口密钥。'), value: '',
buildModuleState('similar-asin', '货源查询', '用于货源查询的 Coze 接口密钥。'), retention: 'session',
] expiresAt: null,
exists: false,
}
} }
function formatRetentionText(retention: ApiSecretRetention, expiresAt: number | null) { function formatRetentionText(retention: ApiSecretRetention, expiresAt: number | null) {
@@ -136,16 +127,14 @@ function formatRetentionText(retention: ApiSecretRetention, expiresAt: number |
return `有效期至 ${year}-${month}-${day} ${hour}:${minute}` return `有效期至 ${year}-${month}-${day} ${hour}:${minute}`
} }
function clearSecret(moduleKey: ApiSecretModuleKey) { function clearSecret() {
clearStoredApiSecret(moduleKey) clearStoredApiSecret()
loadStates() loadStates()
ElMessage.success('已清空密钥') ElMessage.success('已清空密钥')
} }
function saveAll() { function saveAll() {
for (const item of moduleStates.value) { saveStoredApiSecret(secretState.value.value, secretState.value.retention)
saveStoredApiSecret(item.moduleKey, item.value, item.retention)
}
loadStates() loadStates()
dialogVisible.value = false dialogVisible.value = false
ElMessage.success('密钥设置已保存') ElMessage.success('密钥设置已保存')
@@ -192,6 +181,45 @@ loadStates()
line-height: 1; line-height: 1;
} }
:global(.secret-settings-dialog) {
--el-dialog-bg-color: #171b20;
--el-dialog-padding-primary: 20px;
overflow: hidden;
border: 1px solid #2c3540;
border-radius: 14px;
background: #171b20;
box-shadow: 0 24px 70px rgba(0, 0, 0, .52);
}
:global(.secret-settings-dialog .el-dialog__header) {
padding: 22px 22px 12px;
margin: 0;
background: #171b20;
}
:global(.secret-settings-dialog .el-dialog__body) {
padding: 12px 22px 16px;
background: #171b20;
}
:global(.secret-settings-dialog .el-dialog__footer) {
padding: 0 22px 22px;
background: #171b20;
}
:global(.secret-settings-dialog .el-dialog__headerbtn) {
top: 18px;
right: 18px;
}
:global(.secret-settings-dialog .el-dialog__close) {
color: #8793a0;
}
:global(.secret-settings-dialog .el-dialog__close:hover) {
color: #d8e3ee;
}
.dialog-header { .dialog-header {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -217,10 +245,10 @@ loadStates()
} }
.secret-card { .secret-card {
padding: 16px; padding: 18px;
border: 1px solid #2f363f; border: 1px solid #313b46;
border-radius: 12px; border-radius: 10px;
background: #22272d; background: #20252b;
} }
.secret-card-head { .secret-card-head {
@@ -331,7 +359,11 @@ loadStates()
} }
.footer-btn-primary { .footer-btn-primary {
background: #5aa2ea; background: #4f8fda;
color: #fff; color: #fff;
} }
.footer-btn-primary:hover {
background: #67a6ed;
}
</style> </style>

View File

@@ -18,10 +18,8 @@
</div> </div>
<div class="prompt-card"> <div class="prompt-card">
<div class="section-title">AI 提示词</div> <div class="section-title">新增条件要求</div>
<textarea v-model="aiPrompt" class="prompt-input" rows="8" placeholder="可选,留空时使用下方默认提示词" /> <textarea v-model="aiPrompt" class="prompt-input" rows="8" placeholder="可选,填写后会追加到默认检测提示词" />
<div class="prompt-default-label">留空时默认使用以下提示词</div>
<div class="prompt-preview">{{ defaultAiPrompt }}</div>
</div> </div>
<div class="run-row"> <div class="run-row">
@@ -251,11 +249,13 @@ function mergeCurrentTaskItem(item: AppearancePatentHistoryItem) {
} }
function effectiveAiPrompt() { function effectiveAiPrompt() {
return aiPrompt.value.trim() || defaultAiPrompt const extraPrompt = aiPrompt.value.trim()
if (!extraPrompt) return defaultAiPrompt
return `${defaultAiPrompt}\n\n新增条件要求\n${extraPrompt}`
} }
function effectiveCozeApiKey() { function effectiveCozeApiKey() {
return getStoredApiSecret('appearance-patent').trim() return getStoredApiSecret().trim()
} }
function maskSecret(secret: string) { function maskSecret(secret: string) {
@@ -937,8 +937,6 @@ onUnmounted(() => {
.prompt-input { width: 100%; box-sizing: border-box; resize: vertical; min-height: 180px; padding: 10px 12px; border: 1px solid #333; border-radius: 8px; background: #202020; color: #d8d8d8; font-size: 12px; line-height: 1.6; outline: none; } .prompt-input { width: 100%; box-sizing: border-box; resize: vertical; min-height: 180px; padding: 10px 12px; border: 1px solid #333; border-radius: 8px; background: #202020; color: #d8d8d8; font-size: 12px; line-height: 1.6; outline: none; }
.secret-input { width: 100%; box-sizing: border-box; height: 38px; padding: 0 12px; border: 1px solid #333; border-radius: 8px; background: #202020; color: #d8d8d8; font-size: 12px; outline: none; } .secret-input { width: 100%; box-sizing: border-box; height: 38px; padding: 0 12px; border: 1px solid #333; border-radius: 8px; background: #202020; color: #d8d8d8; font-size: 12px; outline: none; }
.prompt-input:focus, .secret-input:focus { border-color: #3498db; } .prompt-input:focus, .secret-input:focus { border-color: #3498db; }
.prompt-default-label { margin-top: 10px; color: #8d8d8d; font-size: 12px; }
.prompt-preview { margin-top: 10px; padding: 12px; border: 1px solid #2a2a2a; border-radius: 8px; background: #202020; color: #9ea7b3; font-size: 12px; line-height: 1.6; white-space: pre-wrap; }
.parse-card, .queue-payload { margin-top: 14px; padding: 12px; border: 1px solid #2a2a2a; border-radius: 8px; background: #202020; color: #b8c1cc; font-size: 12px; } .parse-card, .queue-payload { margin-top: 14px; padding: 12px; border: 1px solid #2a2a2a; border-radius: 8px; background: #202020; color: #b8c1cc; font-size: 12px; }
.queue-payload { max-height: 220px; overflow: auto; color: #8fd3ff; white-space: pre-wrap; } .queue-payload { max-height: 220px; overflow: auto; color: #8fd3ff; white-space: pre-wrap; }
.panel-header { padding: 16px 20px; border-bottom: 1px solid #2a2a2a; font-size: 15px; font-weight: 600; color: #ddd; } .panel-header { padding: 16px 20px; border-bottom: 1px solid #2a2a2a; font-size: 15px; font-weight: 600; color: #ddd; }

View File

@@ -44,29 +44,6 @@
</ul> </ul>
</div> </div>
<div class="category-card">
<div class="category-card-head">
<div class="section-title">商品类目</div>
<div class="category-actions">
<button type="button" class="opt-btn" @click="openCategoryDialog">选择类目</button>
<button
v-if="selectedProductCategoryIds.length"
type="button"
class="link-danger"
@click="clearSelectedProductCategories"
>
清空
</button>
</div>
</div>
<div v-if="!selectedProductCategories.length" class="empty-conditions">暂未选择商品类目</div>
<div v-else class="selected-category-list">
<span v-for="category in selectedProductCategories" :key="category.id" class="category-chip">
{{ category.path || category.name }}
</span>
</div>
</div>
<div class="run-row"> <div class="run-row">
<button type="button" class="btn-run" :disabled="parsing || !uploadedFiles.length" @click="parseFiles"> <button type="button" class="btn-run" :disabled="parsing || !uploadedFiles.length" @click="parseFiles">
{{ parsing ? '解析中...' : '解析并创建任务' }} {{ parsing ? '解析中...' : '解析并创建任务' }}
@@ -167,40 +144,6 @@
</section> </section>
</div> </div>
<el-dialog
v-model="categoryDialogVisible"
title="选择商品类目"
width="560px"
class="category-dialog"
>
<div class="category-dialog-toolbar">
<span class="muted">可多选确认后会追加到筛选条件 prompt 后面</span>
<button type="button" class="link-danger" @click="clearCategoryTreeSelection">清空选择</button>
</div>
<div v-if="loadingProductCategories" class="empty-tasks">正在加载商品类目...</div>
<div v-else-if="!productCategoryTree.length" class="empty-tasks">暂无商品类目</div>
<el-tree
v-else
ref="categoryTreeRef"
class="category-tree"
:data="productCategoryTree"
node-key="id"
show-checkbox
default-expand-all
:props="{ label: 'name', children: 'children' }"
>
<template #default="{ data }">
<span class="category-tree-node">
<span>{{ data.name }}</span>
<span v-if="data.description" class="category-description">{{ data.description }}</span>
</span>
</template>
</el-tree>
<template #footer>
<button type="button" class="opt-btn" @click="categoryDialogVisible = false">取消</button>
<button type="button" class="btn-run category-confirm" @click="confirmCategorySelection">确定</button>
</template>
</el-dialog>
</div> </div>
</template> </template>
@@ -218,10 +161,8 @@ import {
getSimilarAsinHistory, getSimilarAsinHistory,
getSimilarAsinResultDownloadUrl, getSimilarAsinResultDownloadUrl,
getSimilarAsinTaskProgressBatch, getSimilarAsinTaskProgressBatch,
listProductCategories,
listSimilarAsinFilterConditions, listSimilarAsinFilterConditions,
parseSimilarAsin, parseSimilarAsin,
type ProductCategoryItemVo,
type SimilarAsinFilterConditionVo, type SimilarAsinFilterConditionVo,
type SimilarAsinDashboardVo, type SimilarAsinDashboardVo,
type SimilarAsinHistoryItem, type SimilarAsinHistoryItem,
@@ -244,12 +185,6 @@ const queuedTaskSummary = ref<TaskSummary | null>(null)
const filterConditionInput = ref('') const filterConditionInput = ref('')
const filterConditions = ref<SimilarAsinFilterConditionVo[]>([]) const filterConditions = ref<SimilarAsinFilterConditionVo[]>([])
const selectedFilterConditionId = ref<number | null>(null) const selectedFilterConditionId = ref<number | null>(null)
const productCategoryTree = ref<ProductCategoryItemVo[]>([])
const productCategoryItems = ref<ProductCategoryItemVo[]>([])
const selectedProductCategoryIds = ref<number[]>([])
const categoryDialogVisible = ref(false)
const loadingProductCategories = ref(false)
const categoryTreeRef = ref<any>(null)
const parsing = ref(false) const parsing = ref(false)
const pushing = ref(false) const pushing = ref(false)
const savingCondition = ref(false) const savingCondition = ref(false)
@@ -310,78 +245,11 @@ function selectedFilterConditionText() {
} }
function effectiveFilterCondition() { function effectiveFilterCondition() {
return appendProductCategoriesToPrompt(filterConditionInput.value.trim() || selectedFilterConditionText()) return filterConditionInput.value.trim() || selectedFilterConditionText()
} }
function effectiveCozeApiKey() { function effectiveCozeApiKey() {
return getStoredApiSecret('similar-asin').trim() return getStoredApiSecret().trim()
}
const selectedProductCategories = computed(() => {
const selected = new Set(selectedProductCategoryIds.value)
return productCategoryItems.value.filter((item) => selected.has(item.id))
})
function appendProductCategoriesToPrompt(prompt: string) {
const lines = selectedProductCategories.value
.map((item) => {
const path = item.path || item.name
const description = item.description?.trim()
return description ? `${path}${description}` : path
})
.filter(Boolean)
if (!lines.length) return prompt
const categoryBlock = [
'商品类目要求:',
...lines.map((line) => `- ${line}`),
].join('\n')
return prompt ? `${prompt}\n\n${categoryBlock}` : categoryBlock
}
async function loadProductCategories() {
if (loadingProductCategories.value) return
loadingProductCategories.value = true
try {
const res = await listProductCategories()
productCategoryTree.value = res.tree || []
productCategoryItems.value = res.items || []
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '加载商品类目失败')
} finally {
loadingProductCategories.value = false
}
}
async function openCategoryDialog() {
categoryDialogVisible.value = true
if (!productCategoryTree.value.length) {
await loadProductCategories()
}
requestAnimationFrame(() => {
categoryTreeRef.value?.setCheckedKeys?.(selectedProductCategoryIds.value)
})
}
function hasCheckedDescendant(item: ProductCategoryItemVo, checkedIds: Set<number>): boolean {
return Boolean(item.children?.some((child) => checkedIds.has(child.id) || hasCheckedDescendant(child, checkedIds)))
}
function confirmCategorySelection() {
const checkedNodes = (categoryTreeRef.value?.getCheckedNodes?.(false, false) || []) as ProductCategoryItemVo[]
const checkedIds = new Set(checkedNodes.map((item) => item.id))
selectedProductCategoryIds.value = checkedNodes
.filter((item) => !hasCheckedDescendant(item, checkedIds))
.map((item) => item.id)
categoryDialogVisible.value = false
}
function clearCategoryTreeSelection() {
categoryTreeRef.value?.setCheckedKeys?.([])
}
function clearSelectedProductCategories() {
selectedProductCategoryIds.value = []
categoryTreeRef.value?.setCheckedKeys?.([])
} }
function maskSecret(secret: string) { function maskSecret(secret: string) {
@@ -911,7 +779,6 @@ onMounted(async () => {
loadPollingIds() loadPollingIds()
await Promise.all([ await Promise.all([
loadFilterConditions().catch(() => undefined), loadFilterConditions().catch(() => undefined),
loadProductCategories().catch(() => undefined),
loadDashboard().catch(() => undefined), loadDashboard().catch(() => undefined),
loadHistory().catch(() => undefined), loadHistory().catch(() => undefined),
]) ])
@@ -958,18 +825,6 @@ onUnmounted(() => {
.condition-check input { width: 16px; height: 16px; margin: 0; flex: 0 0 auto; } .condition-check input { width: 16px; height: 16px; margin: 0; flex: 0 0 auto; }
.condition-check span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .condition-check span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.link-danger { border: none; background: transparent; color: #ff8f8f; cursor: pointer; font-size: 12px; padding: 2px 0; white-space: nowrap; } .link-danger { border: none; background: transparent; color: #ff8f8f; cursor: pointer; font-size: 12px; padding: 2px 0; white-space: nowrap; }
.category-card { margin: 0 0 14px; padding: 12px; border: 1px solid #2a2a2a; border-radius: 8px; background: #202020; }
.category-card-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
.category-card-head .section-title { margin-bottom: 0; }
.category-actions { display: flex; align-items: center; gap: 10px; }
.category-actions .opt-btn { padding: 6px 12px; }
.selected-category-list { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; max-height: 96px; overflow: auto; }
.category-chip { max-width: 100%; padding: 4px 8px; border-radius: 6px; background: rgba(52, 152, 219, .16); color: #cfe7ff; font-size: 12px; line-height: 1.4; }
.category-dialog-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 10px; }
.category-tree { max-height: 420px; overflow: auto; padding: 8px; border: 1px solid #2f2f2f; border-radius: 8px; background: #202020; --el-tree-bg-color: #202020; --el-tree-text-color: #d8d8d8; --el-tree-node-hover-bg-color: #2a2a2a; }
.category-tree-node { display: inline-flex; align-items: center; gap: 8px; min-width: 0; }
.category-description { color: #888; font-size: 12px; }
.category-confirm { padding: 8px 16px; }
.parse-card, .queue-payload { margin-top: 14px; padding: 12px; border: 1px solid #2a2a2a; border-radius: 8px; background: #202020; color: #b8c1cc; font-size: 12px; } .parse-card, .queue-payload { margin-top: 14px; padding: 12px; border: 1px solid #2a2a2a; border-radius: 8px; background: #202020; color: #b8c1cc; font-size: 12px; }
.queue-payload { max-height: 220px; overflow: auto; color: #8fd3ff; white-space: pre-wrap; } .queue-payload { max-height: 220px; overflow: auto; color: #8fd3ff; white-space: pre-wrap; }
.panel-header { padding: 16px 20px; border-bottom: 1px solid #2a2a2a; font-size: 15px; font-weight: 600; color: #ddd; } .panel-header { padding: 16px 20px; border-bottom: 1px solid #2a2a2a; font-size: 15px; font-weight: 600; color: #ddd; }

View File

@@ -1680,31 +1680,6 @@ export function getAppearancePatentResultDownloadUrl(resultId: number) {
// ========== 货源查询 ========== // ========== 货源查询 ==========
export interface ProductCategoryItemVo {
id: number;
parentId?: number | null;
name: string;
categoryKey?: string;
sortOrder?: number;
description?: string;
isBuiltin?: boolean;
childCount?: number;
level?: number;
path?: string;
children?: ProductCategoryItemVo[];
}
export interface ProductCategoryListVo {
tree: ProductCategoryItemVo[];
items: ProductCategoryItemVo[];
}
export function listProductCategories() {
return unwrapJavaResponse(
get<JavaApiResponse<ProductCategoryListVo>>(`${JAVA_API_PREFIX}/admin/product-categories`),
);
}
export interface SimilarAsinFilterConditionVo { export interface SimilarAsinFilterConditionVo {
id: number; id: number;
conditionText: string; conditionText: string;

View File

@@ -1,4 +1,4 @@
export type ApiSecretModuleKey = 'appearance-patent' | 'similar-asin' type LegacyApiSecretModuleKey = 'appearance-patent' | 'similar-asin'
export type ApiSecretRetention = 'session' | '1d' | '7d' | '30d' | 'forever' export type ApiSecretRetention = 'session' | '1d' | '7d' | '30d' | 'forever'
@@ -18,17 +18,19 @@ export type ApiSecretSnapshot = {
} }
const STORAGE_PREFIX = 'brand:api-secret' const STORAGE_PREFIX = 'brand:api-secret'
const COMMON_SECRET_KEY = 'common'
const LEGACY_SECRET_KEYS: LegacyApiSecretModuleKey[] = ['appearance-patent', 'similar-asin']
function currentUserStorageId() { function currentUserStorageId() {
if (typeof window === 'undefined') return '0' if (typeof window === 'undefined') return '0'
return window.localStorage.getItem('uid') || '0' return window.localStorage.getItem('uid') || '0'
} }
function buildStorageKey(moduleKey: ApiSecretModuleKey) { function buildStorageKey(moduleKey: string) {
return `${STORAGE_PREFIX}:${currentUserStorageId()}:${moduleKey}` return `${STORAGE_PREFIX}:${currentUserStorageId()}:${moduleKey}`
} }
function readStorageRecord(storage: Storage, moduleKey: ApiSecretModuleKey): ApiSecretRecord | null { function readStorageRecord(storage: Storage, moduleKey: string): ApiSecretRecord | null {
const raw = storage.getItem(buildStorageKey(moduleKey)) const raw = storage.getItem(buildStorageKey(moduleKey))
if (!raw) return null if (!raw) return null
try { try {
@@ -81,11 +83,11 @@ function isExpired(record: ApiSecretRecord) {
return record.expiresAt != null && record.expiresAt <= Date.now() return record.expiresAt != null && record.expiresAt <= Date.now()
} }
function clearStorageRecord(storage: Storage, moduleKey: ApiSecretModuleKey) { function clearStorageRecord(storage: Storage, moduleKey: string) {
storage.removeItem(buildStorageKey(moduleKey)) storage.removeItem(buildStorageKey(moduleKey))
} }
function getLiveRecord(moduleKey: ApiSecretModuleKey): ApiSecretRecord | null { function getLiveRecordFromKey(moduleKey: string): ApiSecretRecord | null {
if (typeof window === 'undefined') return null if (typeof window === 'undefined') return null
const sessionRecord = readStorageRecord(window.sessionStorage, moduleKey) const sessionRecord = readStorageRecord(window.sessionStorage, moduleKey)
@@ -102,12 +104,42 @@ function getLiveRecord(moduleKey: ApiSecretModuleKey): ApiSecretRecord | null {
return localRecord return localRecord
} }
export function getStoredApiSecret(moduleKey: ApiSecretModuleKey) { function clearLegacyStoredApiSecrets() {
return getLiveRecord(moduleKey)?.value || '' if (typeof window === 'undefined') return
for (const moduleKey of LEGACY_SECRET_KEYS) {
clearStorageRecord(window.sessionStorage, moduleKey)
clearStorageRecord(window.localStorage, moduleKey)
}
} }
export function getStoredApiSecretSnapshot(moduleKey: ApiSecretModuleKey): ApiSecretSnapshot { function migrateLegacyRecord(record: ApiSecretRecord) {
const record = getLiveRecord(moduleKey) if (typeof window === 'undefined') return
const storage = record.retention === 'session' ? window.sessionStorage : window.localStorage
storage.setItem(buildStorageKey(COMMON_SECRET_KEY), JSON.stringify(record))
clearLegacyStoredApiSecrets()
}
function getLiveRecord(): ApiSecretRecord | null {
const commonRecord = getLiveRecordFromKey(COMMON_SECRET_KEY)
if (commonRecord) return commonRecord
for (const moduleKey of LEGACY_SECRET_KEYS) {
const legacyRecord = getLiveRecordFromKey(moduleKey)
if (legacyRecord) {
migrateLegacyRecord(legacyRecord)
return legacyRecord
}
}
return null
}
export function getStoredApiSecret() {
return getLiveRecord()?.value || ''
}
export function getStoredApiSecretSnapshot(): ApiSecretSnapshot {
const record = getLiveRecord()
if (!record) { if (!record) {
return { return {
value: '', value: '',
@@ -127,14 +159,13 @@ export function getStoredApiSecretSnapshot(moduleKey: ApiSecretModuleKey): ApiSe
} }
export function saveStoredApiSecret( export function saveStoredApiSecret(
moduleKey: ApiSecretModuleKey,
value: string, value: string,
retention: ApiSecretRetention, retention: ApiSecretRetention,
) { ) {
if (typeof window === 'undefined') return if (typeof window === 'undefined') return
const trimmedValue = value.trim() const trimmedValue = value.trim()
clearStoredApiSecret(moduleKey) clearStoredApiSecret()
if (!trimmedValue) return if (!trimmedValue) return
const now = Date.now() const now = Date.now()
@@ -146,11 +177,12 @@ export function saveStoredApiSecret(
} }
const storage = retention === 'session' ? window.sessionStorage : window.localStorage const storage = retention === 'session' ? window.sessionStorage : window.localStorage
storage.setItem(buildStorageKey(moduleKey), JSON.stringify(record)) storage.setItem(buildStorageKey(COMMON_SECRET_KEY), JSON.stringify(record))
} }
export function clearStoredApiSecret(moduleKey: ApiSecretModuleKey) { export function clearStoredApiSecret() {
if (typeof window === 'undefined') return if (typeof window === 'undefined') return
clearStorageRecord(window.sessionStorage, moduleKey) clearStorageRecord(window.sessionStorage, COMMON_SECRET_KEY)
clearStorageRecord(window.localStorage, moduleKey) clearStorageRecord(window.localStorage, COMMON_SECRET_KEY)
clearLegacyStoredApiSecrets()
} }

Binary file not shown.

Binary file not shown.