修改完善这个专利部分
This commit is contained in:
2054
8095-lines.txt
2054
8095-lines.txt
File diff suppressed because one or more lines are too long
8
app/.env
8
app/.env
@@ -1,6 +1,6 @@
|
||||
base_url=http://8.136.19.173:15124
|
||||
base_url=http://47.110.241.161:15124
|
||||
workflow_id=7608812635877900322
|
||||
mysql_host=8.136.19.173
|
||||
mysql_host=47.110.241.161
|
||||
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
|
||||
@@ -13,7 +13,7 @@ client_name=ShuFuAI
|
||||
|
||||
|
||||
# java_api_base=http://47.111.163.154:18080
|
||||
java_api_base=http://127.0.0.1:18080
|
||||
# java_api_base=http://121.196.149.225:18080
|
||||
# java_api_base=http://127.0.0.1:18080
|
||||
java_api_base=http://121.196.149.225:18080
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
base_url=http://8.136.19.173:15124
|
||||
base_url=http://47.111.163.154:15124
|
||||
workflow_id=7608812635877900322
|
||||
mysql_host=8.136.19.173
|
||||
mysql_host=47.111.163.154
|
||||
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
|
||||
@@ -10,6 +10,6 @@ client_name=ShuFuAI
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
workflow_id=7608812635877900322
|
||||
mysql_host=8.136.19.173
|
||||
mysql_host=47.111.163.154
|
||||
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
|
||||
@@ -13,6 +13,6 @@ client_name=ShuFuAI
|
||||
|
||||
java_api_base=http://47.111.163.154: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
|
||||
|
||||
|
||||
|
||||
@@ -6,6 +6,9 @@ import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
import org.springframework.web.context.request.async.AsyncRequestNotUsableException;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
@Slf4j
|
||||
@RestControllerAdvice
|
||||
@@ -34,9 +37,33 @@ public class GlobalExceptionHandler {
|
||||
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)
|
||||
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);
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,9 @@ package com.nanri.aiimage.config;
|
||||
import lombok.Data;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@ConfigurationProperties(prefix = "aiimage.appearance-patent")
|
||||
public class AppearancePatentProperties {
|
||||
@@ -11,6 +14,8 @@ public class AppearancePatentProperties {
|
||||
private String cozeWorkflowHistoryPath = "/v1/workflows/{workflow_id}/run_histories/{execute_id}";
|
||||
private String cozeWorkflowId = "7632683471312355338";
|
||||
private String cozeToken = "";
|
||||
private List<CozeCredential> cozeCredentials = new ArrayList<>();
|
||||
private int cozeCredentialStripeSize = 5;
|
||||
private int cozeBatchSize = 50;
|
||||
private int cozeConnectTimeoutMillis = 10000;
|
||||
private int cozeReadTimeoutMillis = 60000;
|
||||
@@ -18,4 +23,11 @@ public class AppearancePatentProperties {
|
||||
private int cozePollTimeoutMillis = 600000;
|
||||
private int staleTimeoutMinutes = 20;
|
||||
private String staleFinalizeCron = "0 */2 * * * *";
|
||||
|
||||
@Data
|
||||
public static class CozeCredential {
|
||||
private String name;
|
||||
private String workflowId;
|
||||
private String token;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,9 @@ package com.nanri.aiimage.config;
|
||||
import lombok.Data;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@ConfigurationProperties(prefix = "aiimage.similar-asin")
|
||||
public class SimilarAsinProperties {
|
||||
@@ -11,6 +14,8 @@ public class SimilarAsinProperties {
|
||||
private String cozeWorkflowHistoryPath = "/v1/workflows/{workflow_id}/run_histories/{execute_id}";
|
||||
private String cozeWorkflowId = "7635328462404583478";
|
||||
private String cozeToken = "";
|
||||
private List<CozeCredential> cozeCredentials = new ArrayList<>();
|
||||
private int cozeCredentialStripeSize = 5;
|
||||
private int cozeBatchSize = 50;
|
||||
private int cozeConnectTimeoutMillis = 10000;
|
||||
private int cozeReadTimeoutMillis = 60000;
|
||||
@@ -18,4 +23,11 @@ public class SimilarAsinProperties {
|
||||
private int cozePollTimeoutMillis = 600000;
|
||||
private int staleTimeoutMinutes = 20;
|
||||
private String staleFinalizeCron = "0 */2 * * * *";
|
||||
|
||||
@Data
|
||||
public static class CozeCredential {
|
||||
private String name;
|
||||
private String workflowId;
|
||||
private String token;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
|
||||
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.Semaphore;
|
||||
|
||||
@Configuration
|
||||
public class TaskFileJobConfig {
|
||||
@@ -37,7 +38,23 @@ public class TaskFileJobConfig {
|
||||
}
|
||||
|
||||
@Bean("cozeTaskExecutor")
|
||||
public TaskExecutor cozeTaskExecutor(ExecutorService cozeVirtualThreadExecutor) {
|
||||
return new ConcurrentTaskExecutor(cozeVirtualThreadExecutor);
|
||||
public TaskExecutor cozeTaskExecutor(
|
||||
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();
|
||||
}
|
||||
}
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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_RESULT_PATH = Pattern.compile(".*/api/([^/]+)/tasks/(\\d+)/result/?$");
|
||||
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 String LOCK_ATTRIBUTE = TaskOperationLockInterceptor.class.getName() + ".LOCK";
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.nanri.aiimage.config.AppearancePatentProperties;
|
||||
import com.nanri.aiimage.modules.appearancepatent.model.dto.AppearancePatentResultRowDto;
|
||||
import com.nanri.aiimage.modules.coze.service.CozeCredentialPoolService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
@@ -19,22 +20,26 @@ import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
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 final AppearancePatentProperties properties;
|
||||
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) {
|
||||
if (rows == null || rows.isEmpty()) {
|
||||
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());
|
||||
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 {
|
||||
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);
|
||||
return new CozeSubmitResponse(
|
||||
extractExecuteId(submitRoot),
|
||||
extractResultDataText(submitRoot),
|
||||
writeJson(submitRoot)
|
||||
writeJson(submitRoot),
|
||||
resolvedCredential.name()
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
String status = normalize(resolveWorkflowStatus(pollRoot)).toUpperCase(Locale.ROOT);
|
||||
String dataText = extractResultDataText(pollRoot);
|
||||
@@ -66,7 +85,8 @@ public class AppearancePatentCozeClient {
|
||||
String failureMessage = isFailedWorkflowStatus(status)
|
||||
? 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 {
|
||||
@@ -164,7 +184,8 @@ public class AppearancePatentCozeClient {
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
String immediateData = extractResultDataText(submitRoot);
|
||||
@@ -180,7 +201,7 @@ public class AppearancePatentCozeClient {
|
||||
long deadline = System.currentTimeMillis() + Math.max(10000, properties.getCozePollTimeoutMillis());
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
ensureNotInterrupted();
|
||||
JsonNode pollRoot = objectMapper.readTree(getWorkflowHistory(executeId));
|
||||
JsonNode pollRoot = objectMapper.readTree(getWorkflowHistory(executeId, credential));
|
||||
ensureSuccess(pollRoot);
|
||||
|
||||
String dataText = extractResultDataText(pollRoot);
|
||||
@@ -205,20 +226,24 @@ public class AppearancePatentCozeClient {
|
||||
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> body = new LinkedHashMap<>();
|
||||
body.put("workflow_id", properties.getCozeWorkflowId());
|
||||
body.put("workflow_id", credential.workflowId());
|
||||
body.put("parameters", parameters);
|
||||
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()),
|
||||
writeJson(maskCozeRequestBody(body)));
|
||||
|
||||
RestClient.RequestBodySpec request = restClient().post()
|
||||
.uri(joinUrl(properties.getCozeBaseUrl(), properties.getCozeWorkflowPath()))
|
||||
.headers(headers -> {
|
||||
headers.setBearerAuth(stripBearer(properties.getCozeToken()));
|
||||
headers.setBearerAuth(stripBearer(credential.token()));
|
||||
headers.setContentType(APPLICATION_JSON_UTF8);
|
||||
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()
|
||||
.replace("{workflow_id}", properties.getCozeWorkflowId())
|
||||
.replace("{workflow_id}", credential.workflowId())
|
||||
.replace("{execute_id}", executeId);
|
||||
return restClient().get()
|
||||
.uri(joinUrl(properties.getCozeBaseUrl(), path))
|
||||
.headers(headers -> {
|
||||
headers.setBearerAuth(stripBearer(properties.getCozeToken()));
|
||||
headers.setBearerAuth(stripBearer(credential.token()));
|
||||
headers.setContentType(APPLICATION_JSON_UTF8);
|
||||
headers.set(HttpHeaders.ACCEPT_CHARSET, StandardCharsets.UTF_8.name());
|
||||
})
|
||||
.exchange((clientRequest, clientResponse) -> {
|
||||
byte[] responseBytes = StreamUtils.copyToByteArray(clientResponse.getBody());
|
||||
String responseText = responseBytes.length == 0 ? "" : new String(responseBytes, StandardCharsets.UTF_8);
|
||||
log.info("[appearance-patent] coze history response executeId={} status={} body={}",
|
||||
executeId,
|
||||
log.info("[appearance-patent] coze history response credential={} executeId={} status={} body={}",
|
||||
credential.name(), executeId,
|
||||
clientResponse.getStatusCode(),
|
||||
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) {
|
||||
List<String> groupKeys = rows.stream().map(row -> nonBlank(row.getGroupKey(), rowKey(row))).toList();
|
||||
List<String> rowIds = rows.stream().map(row -> nonBlank(row.getId(), "")).toList();
|
||||
@@ -965,7 +1060,8 @@ public class AppearancePatentCozeClient {
|
||||
public record CozeSubmitResponse(
|
||||
String executeId,
|
||||
String immediateData,
|
||||
String rawResponse
|
||||
String rawResponse,
|
||||
String credentialName
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -975,7 +1071,8 @@ public class AppearancePatentCozeClient {
|
||||
String dataText,
|
||||
String outputText,
|
||||
String failureMessage,
|
||||
String rawResponse
|
||||
String rawResponse,
|
||||
String credentialName
|
||||
) {
|
||||
public boolean hasPayload() {
|
||||
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 final int resolvedCount;
|
||||
|
||||
@@ -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.AppearancePatentTaskDetailVo;
|
||||
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.oss.OssStorageService;
|
||||
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.FileInputStream;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDateTime;
|
||||
@@ -84,6 +86,8 @@ import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Supplier;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipOutputStream;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@@ -100,15 +104,17 @@ public class AppearancePatentTaskService {
|
||||
private static final String COZE_STATUS_DONE = "DONE";
|
||||
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_ZIP = "application/zip";
|
||||
private static final int RESULT_ROWS_READ_RETRY_LIMIT = 3;
|
||||
private static final long RESULT_ROWS_READ_RETRY_DELAY_MS = 500L;
|
||||
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_RETRY_DELAY_MILLIS = 200L;
|
||||
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_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 List<String> RESULT_HEADERS = List.of(
|
||||
"id",
|
||||
@@ -119,7 +125,7 @@ public class AppearancePatentTaskService {
|
||||
"外观维度(外观设计专利)",
|
||||
"专利维度(发明/实用新型专利)",
|
||||
"结论",
|
||||
"status"
|
||||
"状态"
|
||||
);
|
||||
|
||||
private final LocalFileStorageService localFileStorageService;
|
||||
@@ -140,6 +146,7 @@ public class AppearancePatentTaskService {
|
||||
private final DistributedJobLockService distributedJobLockService;
|
||||
private final TaskDistributedLockService taskDistributedLockService;
|
||||
private final InstanceMetadata instanceMetadata;
|
||||
private final CozeCredentialPoolService cozeCredentialPoolService;
|
||||
@Autowired
|
||||
@Qualifier("cozeTaskExecutor")
|
||||
private TaskExecutor cozeTaskExecutor;
|
||||
@@ -165,12 +172,12 @@ public class AppearancePatentTaskService {
|
||||
long parseStartedAt = System.nanoTime();
|
||||
for (AppearancePatentSourceFileDto source : sourceFiles) {
|
||||
if (source.getFileKey() == null || source.getFileKey().isBlank()) {
|
||||
throw new BusinessException("fileKey 不能为空");
|
||||
}
|
||||
File input = localFileStorageService.findLocalSourceFile(source.getFileKey());
|
||||
if (input == null || !input.exists()) {
|
||||
throw new BusinessException("源文件不存在");
|
||||
}
|
||||
throw new BusinessException("fileKey 不能为空");
|
||||
}
|
||||
File input = localFileStorageService.findLocalSourceFile(source.getFileKey());
|
||||
if (input == null || !input.exists()) {
|
||||
throw new BusinessException("源文件不存在");
|
||||
}
|
||||
|
||||
ParsedWorkbook parsed = parseWorkbook(input, source);
|
||||
totalRows += parsed.totalRows();
|
||||
@@ -748,7 +755,7 @@ public class AppearancePatentTaskService {
|
||||
touchJavaSideTaskActivity(task.getId());
|
||||
} else if (isResultSubmissionComplete(task.getId())) {
|
||||
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}")
|
||||
public void pollPendingCozeJobs() {
|
||||
List<TaskScopeStateEntity> states = taskScopeStateMapper.selectList(new LambdaQueryWrapper<TaskScopeStateEntity>()
|
||||
.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"));
|
||||
List<TaskScopeStateEntity> states = listOwnedPendingCozeStates();
|
||||
if (states == null || states.isEmpty()) {
|
||||
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) {
|
||||
if (taskId == null || taskId <= 0) {
|
||||
return List.of();
|
||||
@@ -1492,6 +1523,10 @@ public class AppearancePatentTaskService {
|
||||
if (key.isBlank() || !queuedRowKeys.add(key)) {
|
||||
continue;
|
||||
}
|
||||
AppearancePatentResultRowDto persistedRow = persistedRows.get(key);
|
||||
if (hasResolvedCozeFields(persistedRow)) {
|
||||
continue;
|
||||
}
|
||||
candidates.add(new CozeCandidate(chunk.getScopeHash(), chunk.getChunkIndex(), row));
|
||||
}
|
||||
}
|
||||
@@ -1530,7 +1565,7 @@ public class AppearancePatentTaskService {
|
||||
if (chunks == null || chunks.isEmpty()) {
|
||||
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={}",
|
||||
task.getId(), job.getId());
|
||||
return false;
|
||||
@@ -1586,8 +1621,10 @@ public class AppearancePatentTaskService {
|
||||
return COZE_STATUS_SUBMITTED.equals(existing.getCozeStatus())
|
||||
|| COZE_STATUS_RUNNING.equals(existing.getCozeStatus());
|
||||
}
|
||||
AppearancePatentCozeClient.CozeCredentialRef credential = cozeClient.nextCredential();
|
||||
try {
|
||||
AppearancePatentCozeClient.CozeSubmitResponse submit = submitCozeWorkflowThrottled(batchRows, prompt, apiKey);
|
||||
AppearancePatentCozeClient.CozeSubmitResponse submit = submitCozeWorkflowThrottled(
|
||||
batchRows, prompt, apiKey, credential, true);
|
||||
if (submit.immediateData() != null && !submit.immediateData().isBlank()) {
|
||||
List<AppearancePatentResultRowDto> cozeRows = cozeClient.mergeRowsFromDataText(batchRows, submit.immediateData());
|
||||
mergeCozeRowsIntoSubmittedChunks(task, cozeRows, allRowsByBaseId);
|
||||
@@ -1600,19 +1637,84 @@ public class AppearancePatentTaskService {
|
||||
return false;
|
||||
}
|
||||
saveCozeBatchState(task, result, job, batchRows, batchScopeKey, batchScopeHash,
|
||||
batchIndex, batchTotal, submit.executeId());
|
||||
log.info("[appearance-patent] coze async submitted taskId={} jobId={} rows={} batch={}/{} executeId={}",
|
||||
task.getId(), job.getId(), batchRows.size(), batchIndex, batchTotal, submit.executeId());
|
||||
batchIndex, batchTotal, submit.executeId(), submit.credentialName());
|
||||
log.info("[appearance-patent] coze async submitted taskId={} jobId={} rows={} batch={}/{} credential={} executeId={}",
|
||||
task.getId(), job.getId(), batchRows.size(), batchIndex, batchTotal,
|
||||
submit.credentialName(), submit.executeId());
|
||||
return true;
|
||||
} catch (Exception ex) {
|
||||
String message = firstNonBlank(ex.getMessage(), "Coze submit failed");
|
||||
log.warn("[appearance-patent] coze async submit failed taskId={} jobId={} rows={} batch={}/{} err={}",
|
||||
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);
|
||||
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,
|
||||
FileResultEntity result,
|
||||
TaskFileJobEntity job,
|
||||
@@ -1621,7 +1723,8 @@ public class AppearancePatentTaskService {
|
||||
String batchScopeHash,
|
||||
int batchIndex,
|
||||
int batchTotal,
|
||||
String executeId) {
|
||||
String executeId,
|
||||
String credentialName) {
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
CozeBatchContext context = new CozeBatchContext(
|
||||
job.getId(),
|
||||
@@ -1631,7 +1734,8 @@ public class AppearancePatentTaskService {
|
||||
batchIndex,
|
||||
batchTotal,
|
||||
currentInstanceId(),
|
||||
0
|
||||
0,
|
||||
credentialName
|
||||
);
|
||||
String batchPayload = writeJson(batchRows, "serialize coze batch payload failed");
|
||||
String storedBatchPayload = storeSharedCozeBatchPayload(task.getId(), batchScopeHash, batchPayload);
|
||||
@@ -1689,6 +1793,10 @@ public class AppearancePatentTaskService {
|
||||
try {
|
||||
TaskScopeStateEntity state = taskScopeStateMapper.selectById(stateId);
|
||||
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;
|
||||
}
|
||||
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={}/{}",
|
||||
state.getTaskId(), state.getId(), state.getCozeExecuteId(),
|
||||
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)) {
|
||||
log.info("[appearance-patent] coze poll pending taskId={} stateId={} executeId={} status={}",
|
||||
state.getTaskId(), state.getId(), state.getCozeExecuteId(), poll.status());
|
||||
@@ -1804,7 +1914,8 @@ public class AppearancePatentTaskService {
|
||||
}
|
||||
try {
|
||||
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);
|
||||
if (submit.immediateData() != null && !submit.immediateData().isBlank()) {
|
||||
List<AppearancePatentResultRowDto> cozeRows =
|
||||
@@ -1853,23 +1964,54 @@ public class AppearancePatentTaskService {
|
||||
private AppearancePatentCozeClient.CozeSubmitResponse submitCozeWorkflowThrottled(
|
||||
List<AppearancePatentResultRowDto> rows,
|
||||
String prompt,
|
||||
String apiKey) throws Exception {
|
||||
DistributedJobLockService.LockHandle lockHandle = acquireCozeSubmitLock();
|
||||
if (lockHandle == null) {
|
||||
throw new IllegalStateException("Coze submit throttle lock timeout");
|
||||
}
|
||||
try (lockHandle) {
|
||||
AppearancePatentCozeClient.CozeSubmitResponse response = cozeClient.submitWorkflow(rows, prompt, apiKey);
|
||||
sleepQuietly(COZE_SUBMIT_MIN_INTERVAL_MILLIS);
|
||||
return response;
|
||||
String apiKey,
|
||||
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) {
|
||||
lastFailure = new IllegalStateException("Coze submit throttle lock timeout");
|
||||
currentCredential = cozeClient.nextCredential();
|
||||
continue;
|
||||
}
|
||||
CozeCredentialPoolService.BorrowedCredential borrowedCredential =
|
||||
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);
|
||||
}
|
||||
}
|
||||
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;
|
||||
String credentialName = credential == null ? "default" : firstNonBlank(credential.name(), "default");
|
||||
while (System.currentTimeMillis() <= deadline) {
|
||||
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) {
|
||||
return lockHandle;
|
||||
}
|
||||
@@ -1915,14 +2057,16 @@ public class AppearancePatentTaskService {
|
||||
int partIndex = 1;
|
||||
for (List<AppearancePatentResultRowDto> partRows : partitions) {
|
||||
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()) {
|
||||
List<AppearancePatentResultRowDto> cozeRows =
|
||||
cozeClient.mergeRowsFromDataText(partRows, submit.immediateData());
|
||||
mergeCozeRowsIntoSubmittedChunks(task, cozeRows, allRowsByBaseId);
|
||||
submittedAny = true;
|
||||
} 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;
|
||||
}
|
||||
partIndex++;
|
||||
@@ -1950,7 +2094,8 @@ public class AppearancePatentTaskService {
|
||||
int partIndex,
|
||||
int partTotal,
|
||||
int retryCount,
|
||||
String executeId) {
|
||||
String executeId,
|
||||
String credentialName) {
|
||||
String scopeKey = parent.getScopeKey() + ":split:" + retryCount + ":" + partIndex;
|
||||
String scopeHash = DigestUtil.sha256Hex(scopeKey);
|
||||
CozeBatchContext context = new CozeBatchContext(
|
||||
@@ -1961,7 +2106,8 @@ public class AppearancePatentTaskService {
|
||||
partIndex,
|
||||
partTotal,
|
||||
parentContext.ownerInstanceId(),
|
||||
retryCount
|
||||
retryCount,
|
||||
firstNonBlank(credentialName, parentContext.credentialName())
|
||||
);
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
String batchPayload = writeJson(batchRows, "serialize split coze batch payload failed");
|
||||
@@ -2058,6 +2204,24 @@ public class AppearancePatentTaskService {
|
||||
.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) {
|
||||
taskScopeStateMapper.update(null, new LambdaUpdateWrapper<TaskScopeStateEntity>()
|
||||
.eq(TaskScopeStateEntity::getId, state.getId())
|
||||
@@ -2275,7 +2439,8 @@ public class AppearancePatentTaskService {
|
||||
context.batchIndex(),
|
||||
context.batchTotal(),
|
||||
context.ownerInstanceId(),
|
||||
submitRetryCount
|
||||
submitRetryCount,
|
||||
context.credentialName()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2374,6 +2539,115 @@ public class AppearancePatentTaskService {
|
||||
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() {
|
||||
String instanceId = instanceMetadata == null ? null : instanceMetadata.getInstanceId();
|
||||
return firstNonBlank(instanceId, "unknown-instance");
|
||||
@@ -2475,6 +2749,12 @@ public class AppearancePatentTaskService {
|
||||
}
|
||||
int safeTotal = Math.max(1, total);
|
||||
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(
|
||||
task.getId(),
|
||||
MODULE_TYPE,
|
||||
@@ -2484,10 +2764,57 @@ public class AppearancePatentTaskService {
|
||||
0,
|
||||
job.getScopeKey(),
|
||||
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) {
|
||||
if (task == null || task.getId() == null || job == null || job.getId() == null) {
|
||||
return;
|
||||
@@ -2565,28 +2892,160 @@ public class AppearancePatentTaskService {
|
||||
if (!outputDir.exists() && !outputDir.mkdirs()) {
|
||||
throw new BusinessException("创建结果目录失败");
|
||||
}
|
||||
String filename = safeFileStem(result.getSourceFilename()) + "-result.xlsx";
|
||||
String tempFilename = safeFileStem(result.getSourceFilename())
|
||||
+ "-" + task.getId()
|
||||
+ "-" + result.getId()
|
||||
+ "-" + UUID.randomUUID()
|
||||
+ "-result.xlsx";
|
||||
File xlsx = new File(outputDir, tempFilename);
|
||||
List<SourceRows> sourceRows = splitRowsBySourceFile(parsed, receivedRows, result.getSourceFilename());
|
||||
List<SourceResultWorkbook> workbooks = new ArrayList<>();
|
||||
File zip = null;
|
||||
try {
|
||||
writeResultWorkbook(xlsx, parsed, receivedRows, resultMap);
|
||||
String objectKey = ossStorageService.uploadResultFile(xlsx, MODULE_TYPE);
|
||||
for (SourceRows item : sourceRows) {
|
||||
String filename = safeFileStem(item.sourceFilename()) + "-result.xlsx";
|
||||
String tempFilename = safeFileStem(item.sourceFilename())
|
||||
+ "-" + task.getId()
|
||||
+ "-" + result.getId()
|
||||
+ "-" + UUID.randomUUID()
|
||||
+ "-result.xlsx";
|
||||
File xlsx = new File(outputDir, tempFilename);
|
||||
writeResultWorkbook(xlsx, parsed, item.rows(), resultMap);
|
||||
workbooks.add(new SourceResultWorkbook(xlsx, filename, item.rows().size()));
|
||||
}
|
||||
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.setResultFileUrl(objectKey);
|
||||
result.setResultFileSize(xlsx.length());
|
||||
result.setResultContentType(CONTENT_TYPE_XLSX);
|
||||
result.setResultFileSize(uploadFile.length());
|
||||
result.setResultContentType(contentType);
|
||||
result.setRowCount(receivedRows.size());
|
||||
} finally {
|
||||
if (xlsx.exists() && !xlsx.delete()) {
|
||||
log.warn("[appearance-patent] delete temp xlsx failed file={}", xlsx);
|
||||
for (SourceResultWorkbook workbook : workbooks) {
|
||||
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,
|
||||
Map<String, AppearancePatentResultRowDto> resultMap) {
|
||||
if (parsedRows == null || parsedRows.isEmpty() || resultMap == null || resultMap.isEmpty()) {
|
||||
@@ -3141,19 +3600,10 @@ public class AppearancePatentTaskService {
|
||||
if (total <= 0) {
|
||||
return;
|
||||
}
|
||||
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())) {
|
||||
LocalDateTime baseTime = snapshot.getUpdatedAt() != null ? snapshot.getUpdatedAt() : job.getUpdatedAt();
|
||||
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)));
|
||||
}
|
||||
}
|
||||
LocalDateTime baseTime = snapshot.getUpdatedAt() != null ? snapshot.getUpdatedAt() : (job == null ? null : job.getUpdatedAt());
|
||||
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.setFileProgressTotal(total);
|
||||
vo.setFileProgressPercent(percent);
|
||||
@@ -3514,7 +3964,8 @@ public class AppearancePatentTaskService {
|
||||
Integer batchIndex,
|
||||
Integer batchTotal,
|
||||
String ownerInstanceId,
|
||||
Integer submitRetryCount) {
|
||||
Integer submitRetryCount,
|
||||
String credentialName) {
|
||||
}
|
||||
|
||||
private record CozeCandidate(String chunkScopeHash,
|
||||
@@ -3522,6 +3973,43 @@ public class AppearancePatentTaskService {
|
||||
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) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@RestController
|
||||
@@ -28,8 +29,26 @@ public class ProductCategoryController {
|
||||
|
||||
@GetMapping("/product-categories")
|
||||
@Operation(summary = "查询商品类目树")
|
||||
public ApiResponse<ProductCategoryListVo> list() {
|
||||
return ApiResponse.success(productCategoryService.list());
|
||||
public ApiResponse<ProductCategoryListVo> list(@RequestParam(value = "keyword", required = false) String keyword) {
|
||||
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")
|
||||
|
||||
@@ -8,4 +8,8 @@ import java.util.List;
|
||||
public class ProductCategoryListVo {
|
||||
private List<ProductCategoryItemVo> tree;
|
||||
private List<ProductCategoryItemVo> items;
|
||||
private Long total;
|
||||
private Long page;
|
||||
private Long pageSize;
|
||||
private Boolean hasMore;
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
@@ -27,13 +28,52 @@ public class ProductCategoryService {
|
||||
private final ProductCategoryMapper productCategoryMapper;
|
||||
|
||||
public ProductCategoryListVo list() {
|
||||
return list(null);
|
||||
}
|
||||
|
||||
public ProductCategoryListVo list(String keyword) {
|
||||
List<ProductCategoryEntity> rows = productCategoryMapper.selectList(new LambdaQueryWrapper<ProductCategoryEntity>()
|
||||
.orderByAsc(ProductCategoryEntity::getParentId)
|
||||
.orderByAsc(ProductCategoryEntity::getSortOrder)
|
||||
.orderByAsc(ProductCategoryEntity::getId));
|
||||
rows = filterRowsByKeyword(rows, keyword);
|
||||
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
|
||||
public ProductCategoryItemVo create(ProductCategorySaveRequest request) {
|
||||
String name = normalizeRequired(request.getName(), "类目名称不能为空");
|
||||
@@ -80,11 +120,13 @@ public class ProductCategoryService {
|
||||
}
|
||||
|
||||
private ProductCategoryItemVo findItem(Long id) {
|
||||
ProductCategoryListVo listVo = list();
|
||||
return listVo.getItems().stream()
|
||||
.filter(item -> id.equals(item.getId()))
|
||||
.findFirst()
|
||||
.orElseThrow(() -> new BusinessException("类目不存在"));
|
||||
ProductCategoryEntity entity = getById(id);
|
||||
Long childCount = productCategoryMapper.selectCount(new LambdaQueryWrapper<ProductCategoryEntity>()
|
||||
.eq(ProductCategoryEntity::getParentId, entity.getId()));
|
||||
ProductCategoryItemVo item = toItemVo(entity, childCount == null ? 0 : childCount.intValue());
|
||||
item.setLevel(resolveLevel(entity));
|
||||
item.setPath(resolvePath(entity));
|
||||
return item;
|
||||
}
|
||||
|
||||
private ProductCategoryListVo buildListVo(List<ProductCategoryEntity> rows) {
|
||||
@@ -105,9 +147,153 @@ public class ProductCategoryService {
|
||||
ProductCategoryListVo vo = new ProductCategoryListVo();
|
||||
vo.setTree(tree);
|
||||
vo.setItems(flat);
|
||||
vo.setTotal((long) flat.size());
|
||||
vo.setPage(1L);
|
||||
vo.setPageSize((long) flat.size());
|
||||
vo.setHasMore(false);
|
||||
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(
|
||||
Long parentId,
|
||||
int level,
|
||||
@@ -159,6 +345,36 @@ public class ProductCategoryService {
|
||||
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) {
|
||||
ProductCategoryEntity entity = productCategoryMapper.selectById(id);
|
||||
if (entity == null) {
|
||||
@@ -225,6 +441,10 @@ public class ProductCategoryService {
|
||||
return value == null ? 100 : value;
|
||||
}
|
||||
|
||||
private long normalizePageSize(long value) {
|
||||
return Math.min(Math.max(value, 1), 100);
|
||||
}
|
||||
|
||||
private String normalizeDescription(String value) {
|
||||
String normalized = value == null ? "" : value.trim();
|
||||
return normalized.length() > 512 ? normalized.substring(0, 512) : normalized;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.nanri.aiimage.modules.shopkey.controller;
|
||||
|
||||
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.QueryAsinCreateRequest;
|
||||
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 jakarta.validation.Valid;
|
||||
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.GetMapping;
|
||||
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.multipart.MultipartFile;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@RequestMapping("/api/admin/query-asins")
|
||||
@Tag(name = "查询 ASIN", description = "维护店铺按国家查询的 ASIN")
|
||||
public class QueryAsinController {
|
||||
|
||||
private static final DateTimeFormatter EXPORT_FILENAME_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss");
|
||||
|
||||
private final QueryAsinService queryAsinService;
|
||||
|
||||
@GetMapping
|
||||
@@ -52,6 +61,23 @@ public class QueryAsinController {
|
||||
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
|
||||
@Operation(summary = "新增或覆盖查询 ASIN")
|
||||
public ApiResponse<QueryAsinItemVo> create(
|
||||
|
||||
@@ -16,12 +16,18 @@ import com.nanri.aiimage.modules.shopkey.model.vo.QueryAsinPageVo;
|
||||
import com.nanri.aiimage.modules.shopkey.model.vo.ShopManageGroupItemVo;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
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.transaction.annotation.Transactional;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.File;
|
||||
import java.nio.file.Files;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
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 String KEY_SEPARATOR = Character.toString((char) 1);
|
||||
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 ShopManageGroupService shopManageGroupService;
|
||||
@@ -52,32 +59,15 @@ public class QueryAsinService {
|
||||
Long operatorId, boolean superAdmin) {
|
||||
long safePage = Math.max(page, 1);
|
||||
long safePageSize = Math.min(Math.max(pageSize, 1), 100);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
Long total = queryAsinMapper.selectCount(query);
|
||||
List<QueryAsinEntity> rows = queryAsinMapper
|
||||
.selectList(query.last("LIMIT " + ((safePage - 1) * safePageSize) + ", " + safePageSize));
|
||||
Long total = countFilteredRows(groupId, shopName, asin, operatorId, superAdmin);
|
||||
List<QueryAsinEntity> rows = listFilteredRows(
|
||||
groupId,
|
||||
shopName,
|
||||
asin,
|
||||
operatorId,
|
||||
superAdmin,
|
||||
(safePage - 1) * safePageSize,
|
||||
safePageSize);
|
||||
|
||||
Map<Long, String> groupNameById = shopManageGroupService.buildGroupNameMap(rows.stream()
|
||||
.map(QueryAsinEntity::getGroupId)
|
||||
@@ -93,6 +83,110 @@ public class QueryAsinService {
|
||||
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
|
||||
public QueryAsinItemVo createOrUpdate(QueryAsinCreateRequest request, Long operatorId, boolean superAdmin) {
|
||||
ShopManageGroupEntity group = shopManageGroupService.getAccessibleById(request.getGroupId(), operatorId, superAdmin);
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.nanri.aiimage.modules.similarasin.client;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.nanri.aiimage.config.SimilarAsinProperties;
|
||||
import com.nanri.aiimage.modules.coze.service.CozeCredentialPoolService;
|
||||
import com.nanri.aiimage.modules.similarasin.model.dto.SimilarAsinResultRowDto;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
@@ -19,22 +20,26 @@ import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
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 final SimilarAsinProperties properties;
|
||||
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) {
|
||||
if (rows == null || rows.isEmpty()) {
|
||||
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());
|
||||
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 {
|
||||
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);
|
||||
return new CozeSubmitResponse(
|
||||
extractExecuteId(submitRoot),
|
||||
extractResultDataText(submitRoot),
|
||||
writeJson(submitRoot)
|
||||
writeJson(submitRoot),
|
||||
resolvedCredential.name()
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
String status = normalize(resolveWorkflowStatus(pollRoot)).toUpperCase(Locale.ROOT);
|
||||
String dataText = extractResultDataText(pollRoot);
|
||||
@@ -66,7 +85,8 @@ public class SimilarAsinCozeClient {
|
||||
String failureMessage = isFailedWorkflowStatus(status)
|
||||
? 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 {
|
||||
@@ -161,7 +181,8 @@ public class SimilarAsinCozeClient {
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
String immediateData = extractResultDataText(submitRoot);
|
||||
@@ -177,7 +198,7 @@ public class SimilarAsinCozeClient {
|
||||
long deadline = System.currentTimeMillis() + Math.max(10000, properties.getCozePollTimeoutMillis());
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
ensureNotInterrupted();
|
||||
JsonNode pollRoot = objectMapper.readTree(getWorkflowHistory(executeId));
|
||||
JsonNode pollRoot = objectMapper.readTree(getWorkflowHistory(executeId, credential));
|
||||
ensureSuccess(pollRoot);
|
||||
|
||||
String dataText = extractResultDataText(pollRoot);
|
||||
@@ -202,23 +223,27 @@ public class SimilarAsinCozeClient {
|
||||
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> body = new LinkedHashMap<>();
|
||||
body.put("workflow_id", properties.getCozeWorkflowId());
|
||||
body.put("workflow_id", credential.workflowId());
|
||||
body.put("parameters", parameters);
|
||||
if (apiKey != null && !apiKey.isBlank()) {
|
||||
body.put("api_key", apiKey.trim());
|
||||
}
|
||||
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()),
|
||||
writeJson(maskCozeRequestBody(body)));
|
||||
|
||||
RestClient.RequestBodySpec request = restClient().post()
|
||||
.uri(joinUrl(properties.getCozeBaseUrl(), properties.getCozeWorkflowPath()))
|
||||
.headers(headers -> {
|
||||
headers.setBearerAuth(stripBearer(properties.getCozeToken()));
|
||||
headers.setBearerAuth(stripBearer(credential.token()));
|
||||
headers.setContentType(APPLICATION_JSON_UTF8);
|
||||
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()
|
||||
.replace("{workflow_id}", properties.getCozeWorkflowId())
|
||||
.replace("{workflow_id}", credential.workflowId())
|
||||
.replace("{execute_id}", executeId);
|
||||
return restClient().get()
|
||||
.uri(joinUrl(properties.getCozeBaseUrl(), path))
|
||||
.headers(headers -> {
|
||||
headers.setBearerAuth(stripBearer(properties.getCozeToken()));
|
||||
headers.setBearerAuth(stripBearer(credential.token()));
|
||||
headers.setContentType(APPLICATION_JSON_UTF8);
|
||||
headers.set(HttpHeaders.ACCEPT_CHARSET, StandardCharsets.UTF_8.name());
|
||||
})
|
||||
.exchange((clientRequest, clientResponse) -> {
|
||||
byte[] responseBytes = StreamUtils.copyToByteArray(clientResponse.getBody());
|
||||
String responseText = responseBytes.length == 0 ? "" : new String(responseBytes, StandardCharsets.UTF_8);
|
||||
log.info("[similar-asin] coze history response executeId={} status={} body={}",
|
||||
executeId,
|
||||
log.info("[similar-asin] coze history response credential={} executeId={} status={} body={}",
|
||||
credential.name(), executeId,
|
||||
clientResponse.getStatusCode(),
|
||||
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) {
|
||||
List<String> asins = rows.stream().map(row -> nonBlank(row.getAsin(), "")).toList();
|
||||
List<String> skus = rows.stream().map(row -> nonBlank(row.getSku(), "")).toList();
|
||||
@@ -1045,7 +1140,8 @@ public class SimilarAsinCozeClient {
|
||||
public record CozeSubmitResponse(
|
||||
String executeId,
|
||||
String immediateData,
|
||||
String rawResponse
|
||||
String rawResponse,
|
||||
String credentialName
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -1055,7 +1151,8 @@ public class SimilarAsinCozeClient {
|
||||
String dataText,
|
||||
String outputText,
|
||||
String failureMessage,
|
||||
String rawResponse
|
||||
String rawResponse,
|
||||
String credentialName
|
||||
) {
|
||||
public boolean hasPayload() {
|
||||
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 final int resolvedCount;
|
||||
|
||||
@@ -11,6 +11,7 @@ import com.nanri.aiimage.common.service.DistributedJobLockService;
|
||||
import com.nanri.aiimage.config.InstanceMetadata;
|
||||
import com.nanri.aiimage.config.SimilarAsinProperties;
|
||||
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.client.SimilarAsinCozeClient;
|
||||
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.FileInputStream;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDateTime;
|
||||
@@ -86,6 +88,8 @@ import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Supplier;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipOutputStream;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@@ -102,12 +106,13 @@ public class SimilarAsinTaskService {
|
||||
private static final String COZE_STATUS_DONE = "DONE";
|
||||
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_ZIP = "application/zip";
|
||||
private static final int RESULT_ROWS_READ_RETRY_LIMIT = 3;
|
||||
private static final long RESULT_ROWS_READ_RETRY_DELAY_MS = 500L;
|
||||
private static final Duration TASK_LOCK_TTL = Duration.ofMinutes(5);
|
||||
private static final long TASK_LOCK_WAIT_MILLIS = 10000L;
|
||||
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_MIN_INTERVAL_MILLIS = 30000L;
|
||||
private static final int MAX_COZE_SUBMIT_RETRY_COUNT = 5;
|
||||
@@ -122,7 +127,7 @@ public class SimilarAsinTaskService {
|
||||
"是否符合类目",
|
||||
"不符合理由",
|
||||
"产品类目",
|
||||
"status"
|
||||
"状态"
|
||||
);
|
||||
|
||||
private final LocalFileStorageService localFileStorageService;
|
||||
@@ -144,6 +149,7 @@ public class SimilarAsinTaskService {
|
||||
private final PlatformTransactionManager transactionManager;
|
||||
private final DistributedJobLockService distributedJobLockService;
|
||||
private final InstanceMetadata instanceMetadata;
|
||||
private final CozeCredentialPoolService cozeCredentialPoolService;
|
||||
@Autowired
|
||||
@Qualifier("cozeTaskExecutor")
|
||||
private TaskExecutor cozeTaskExecutor;
|
||||
@@ -821,7 +827,7 @@ public class SimilarAsinTaskService {
|
||||
touchJavaSideTaskActivity(task.getId());
|
||||
} else if (isResultSubmissionComplete(task.getId())) {
|
||||
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;
|
||||
}
|
||||
try (lockHandle) {
|
||||
List<TaskScopeStateEntity> states = taskScopeStateMapper.selectList(new LambdaQueryWrapper<TaskScopeStateEntity>()
|
||||
.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"));
|
||||
List<TaskScopeStateEntity> states = listOwnedPendingCozeStates();
|
||||
if (states == null || states.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
@@ -1458,7 +1459,7 @@ public class SimilarAsinTaskService {
|
||||
if (chunks == null || chunks.isEmpty()) {
|
||||
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={}",
|
||||
task.getId(), job.getId());
|
||||
return false;
|
||||
@@ -1512,8 +1513,10 @@ public class SimilarAsinTaskService {
|
||||
return COZE_STATUS_SUBMITTED.equals(existing.getCozeStatus())
|
||||
|| COZE_STATUS_RUNNING.equals(existing.getCozeStatus());
|
||||
}
|
||||
SimilarAsinCozeClient.CozeCredentialRef credential = cozeClient.nextCredential();
|
||||
try {
|
||||
SimilarAsinCozeClient.CozeSubmitResponse submit = submitCozeWorkflowThrottled(batchRows, prompt, apiKey);
|
||||
SimilarAsinCozeClient.CozeSubmitResponse submit = submitCozeWorkflowThrottled(
|
||||
batchRows, prompt, apiKey, credential, true);
|
||||
if (submit.immediateData() != null && !submit.immediateData().isBlank()) {
|
||||
List<SimilarAsinResultRowDto> cozeRows = cozeClient.mergeRowsFromDataText(batchRows, submit.immediateData());
|
||||
String emptyResultMessage = emptyCozeResultMessage(cozeRows, batchRows.size());
|
||||
@@ -1532,14 +1535,22 @@ public class SimilarAsinTaskService {
|
||||
return false;
|
||||
}
|
||||
saveCozeBatchState(task, result, job, chunk, batchRows, batchScopeKey, batchScopeHash,
|
||||
batchIndex, batchTotal, submit.executeId());
|
||||
log.info("[similar-asin] coze async submitted taskId={} jobId={} chunk={} batch={}/{} executeId={}",
|
||||
task.getId(), job.getId(), chunk.getChunkIndex(), batchIndex, batchTotal, submit.executeId());
|
||||
batchIndex, batchTotal, submit.executeId(), submit.credentialName());
|
||||
log.info("[similar-asin] coze async submitted taskId={} jobId={} chunk={} batch={}/{} credential={} executeId={}",
|
||||
task.getId(), job.getId(), chunk.getChunkIndex(), batchIndex, batchTotal,
|
||||
submit.credentialName(), submit.executeId());
|
||||
return true;
|
||||
} catch (Exception ex) {
|
||||
String message = firstNonBlank(ex.getMessage(), "Coze submit failed");
|
||||
log.warn("[similar-asin] coze async submit failed taskId={} jobId={} chunk={} batch={}/{} err={}",
|
||||
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,
|
||||
chunk.getScopeHash(),
|
||||
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,
|
||||
FileResultEntity result,
|
||||
TaskFileJobEntity job,
|
||||
@@ -1558,7 +1628,8 @@ public class SimilarAsinTaskService {
|
||||
String batchScopeHash,
|
||||
int batchIndex,
|
||||
int batchTotal,
|
||||
String executeId) {
|
||||
String executeId,
|
||||
String credentialName) {
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
CozeBatchContext context = new CozeBatchContext(
|
||||
job.getId(),
|
||||
@@ -1568,7 +1639,8 @@ public class SimilarAsinTaskService {
|
||||
batchIndex,
|
||||
batchTotal,
|
||||
currentInstanceId(),
|
||||
0
|
||||
0,
|
||||
credentialName
|
||||
);
|
||||
String batchPayload = writeJson(batchRows, "serialize coze batch payload failed");
|
||||
String storedBatchPayload = transientPayloadStorageService.storeParsedPayloadFast(
|
||||
@@ -1602,6 +1674,10 @@ public class SimilarAsinTaskService {
|
||||
private void pollPendingCozeState(Long stateId) {
|
||||
TaskScopeStateEntity lockState = taskScopeStateMapper.selectById(stateId);
|
||||
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;
|
||||
}
|
||||
if (!isCozeStateOwnedByCurrentInstance(lockState)) {
|
||||
@@ -1621,6 +1697,10 @@ public class SimilarAsinTaskService {
|
||||
private void pollPendingCozeStateLocked(Long stateId) {
|
||||
TaskScopeStateEntity state = taskScopeStateMapper.selectById(stateId);
|
||||
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;
|
||||
}
|
||||
if (!(COZE_STATUS_SUBMITTED.equals(state.getCozeStatus()) || COZE_STATUS_RUNNING.equals(state.getCozeStatus()))) {
|
||||
@@ -1644,7 +1724,9 @@ public class SimilarAsinTaskService {
|
||||
}
|
||||
taskFileJobService.touchRunning(context.jobId());
|
||||
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)) {
|
||||
updateCozeStateRunning(state, null);
|
||||
return;
|
||||
@@ -1725,7 +1807,8 @@ public class SimilarAsinTaskService {
|
||||
}
|
||||
try {
|
||||
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);
|
||||
if (submit.immediateData() != null && !submit.immediateData().isBlank()) {
|
||||
List<SimilarAsinResultRowDto> cozeRows =
|
||||
@@ -1774,23 +1857,54 @@ public class SimilarAsinTaskService {
|
||||
private SimilarAsinCozeClient.CozeSubmitResponse submitCozeWorkflowThrottled(
|
||||
List<SimilarAsinResultRowDto> rows,
|
||||
String prompt,
|
||||
String apiKey) throws Exception {
|
||||
DistributedJobLockService.LockHandle lockHandle = acquireCozeSubmitLock();
|
||||
if (lockHandle == null) {
|
||||
throw new IllegalStateException("Coze submit throttle lock timeout");
|
||||
}
|
||||
try (lockHandle) {
|
||||
SimilarAsinCozeClient.CozeSubmitResponse response = cozeClient.submitWorkflow(rows, prompt, apiKey);
|
||||
sleepQuietly(COZE_SUBMIT_MIN_INTERVAL_MILLIS);
|
||||
return response;
|
||||
String apiKey,
|
||||
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) {
|
||||
lastFailure = new IllegalStateException("Coze submit throttle lock timeout");
|
||||
currentCredential = cozeClient.nextCredential();
|
||||
continue;
|
||||
}
|
||||
CozeCredentialPoolService.BorrowedCredential borrowedCredential =
|
||||
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);
|
||||
}
|
||||
}
|
||||
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;
|
||||
String credentialName = credential == null ? "default" : firstNonBlank(credential.name(), "default");
|
||||
while (System.currentTimeMillis() <= deadline) {
|
||||
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) {
|
||||
return lockHandle;
|
||||
}
|
||||
@@ -1836,14 +1950,16 @@ public class SimilarAsinTaskService {
|
||||
int partIndex = 1;
|
||||
for (List<SimilarAsinResultRowDto> partRows : partitions) {
|
||||
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()) {
|
||||
List<SimilarAsinResultRowDto> cozeRows =
|
||||
cozeClient.mergeRowsFromDataText(partRows, submit.immediateData());
|
||||
mergeCozeRowsIntoChunk(task, context.chunkScopeHash(), context.chunkIndex(), cozeRows, allRowsByBaseId);
|
||||
submittedAny = true;
|
||||
} 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;
|
||||
}
|
||||
partIndex++;
|
||||
@@ -1871,7 +1987,8 @@ public class SimilarAsinTaskService {
|
||||
int partIndex,
|
||||
int partTotal,
|
||||
int retryCount,
|
||||
String executeId) {
|
||||
String executeId,
|
||||
String credentialName) {
|
||||
String scopeKey = parent.getScopeKey() + ":split:" + retryCount + ":" + partIndex;
|
||||
String scopeHash = DigestUtil.sha256Hex(scopeKey);
|
||||
CozeBatchContext context = new CozeBatchContext(
|
||||
@@ -1882,7 +1999,8 @@ public class SimilarAsinTaskService {
|
||||
partIndex,
|
||||
partTotal,
|
||||
parentContext.ownerInstanceId(),
|
||||
retryCount
|
||||
retryCount,
|
||||
firstNonBlank(credentialName, parentContext.credentialName())
|
||||
);
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
String batchPayload = writeJson(batchRows, "serialize split coze batch payload failed");
|
||||
@@ -1926,6 +2044,130 @@ public class SimilarAsinTaskService {
|
||||
|| 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) {
|
||||
String normalized = normalize(failureMessage).toLowerCase(Locale.ROOT);
|
||||
return normalized.contains("rate limit")
|
||||
@@ -2173,7 +2415,8 @@ public class SimilarAsinTaskService {
|
||||
context.batchIndex(),
|
||||
context.batchTotal(),
|
||||
context.ownerInstanceId(),
|
||||
submitRetryCount
|
||||
submitRetryCount,
|
||||
context.credentialName()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2338,6 +2581,12 @@ public class SimilarAsinTaskService {
|
||||
}
|
||||
int safeTotal = Math.max(1, total);
|
||||
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(
|
||||
task.getId(),
|
||||
MODULE_TYPE,
|
||||
@@ -2347,10 +2596,57 @@ public class SimilarAsinTaskService {
|
||||
0,
|
||||
job.getScopeKey(),
|
||||
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) {
|
||||
if (job == null || job.getTaskId() == null) {
|
||||
return;
|
||||
@@ -2379,28 +2675,195 @@ public class SimilarAsinTaskService {
|
||||
if (!outputDir.exists() && !outputDir.mkdirs()) {
|
||||
throw new BusinessException("创建结果目录失败");
|
||||
}
|
||||
String filename = safeFileStem(result.getSourceFilename()) + "-result.xlsx";
|
||||
String tempFilename = safeFileStem(result.getSourceFilename())
|
||||
+ "-" + task.getId()
|
||||
+ "-" + result.getId()
|
||||
+ "-" + UUID.randomUUID()
|
||||
+ "-result.xlsx";
|
||||
File xlsx = new File(outputDir, tempFilename);
|
||||
List<SourceRows> sourceRows = splitRowsBySourceFile(parsed, parsed.getAllItems(), result.getSourceFilename());
|
||||
List<SourceResultWorkbook> workbooks = new ArrayList<>();
|
||||
File zip = null;
|
||||
try {
|
||||
writeResultWorkbook(xlsx, parsed, resultMap);
|
||||
String objectKey = ossStorageService.uploadResultFile(xlsx, MODULE_TYPE);
|
||||
for (SourceRows item : sourceRows) {
|
||||
String filename = safeFileStem(item.sourceFilename()) + "-result.xlsx";
|
||||
String tempFilename = safeFileStem(item.sourceFilename())
|
||||
+ "-" + task.getId()
|
||||
+ "-" + result.getId()
|
||||
+ "-" + UUID.randomUUID()
|
||||
+ "-result.xlsx";
|
||||
File xlsx = new File(outputDir, tempFilename);
|
||||
writeResultWorkbook(xlsx, item.rows(), resultMap);
|
||||
workbooks.add(new SourceResultWorkbook(xlsx, filename, item.rows().size()));
|
||||
}
|
||||
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.setResultFileUrl(objectKey);
|
||||
result.setResultFileSize(xlsx.length());
|
||||
result.setResultContentType(CONTENT_TYPE_XLSX);
|
||||
result.setResultFileSize(uploadFile.length());
|
||||
result.setResultContentType(contentType);
|
||||
result.setRowCount(parsed.getAllItems().size());
|
||||
} finally {
|
||||
if (xlsx.exists() && !xlsx.delete()) {
|
||||
log.warn("[similar-asin] delete temp xlsx failed file={}", xlsx);
|
||||
for (SourceResultWorkbook workbook : workbooks) {
|
||||
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) {
|
||||
Map<String, SimilarAsinResultRowDto> result = new LinkedHashMap<>();
|
||||
List<TaskChunkEntity> chunks = taskChunkMapper.selectList(new LambdaQueryWrapper<TaskChunkEntity>()
|
||||
@@ -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)) {
|
||||
Sheet sheet = workbook.createSheet("相似asin检测");
|
||||
CellStyle headerStyle = workbook.createCellStyle();
|
||||
@@ -2467,7 +2932,7 @@ public class SimilarAsinTaskService {
|
||||
}
|
||||
|
||||
int rowIndex = 1;
|
||||
for (SimilarAsinParsedRowVo parsedRow : parsed.getAllItems()) {
|
||||
for (SimilarAsinParsedRowVo parsedRow : rowsToWrite == null ? List.<SimilarAsinParsedRowVo>of() : rowsToWrite) {
|
||||
SimilarAsinResultRowDto resultRow = findResultRow(parsedRow, resultMap);
|
||||
Row row = sheet.createRow(rowIndex++);
|
||||
int col = 0;
|
||||
@@ -2807,23 +3272,25 @@ public class SimilarAsinTaskService {
|
||||
}
|
||||
int cozeCompleted = countCompletedCozeStates(taskId);
|
||||
int cozePending = countPendingCozeStates(taskId);
|
||||
TaskProgressSnapshotEntity snapshot = taskProgressSnapshotService.find(taskId, MODULE_TYPE);
|
||||
if (job != null && STATUS_RUNNING.equals(job.getStatus()) && cozePending > 0) {
|
||||
int total = Math.max(1, cozeCompleted + cozePending);
|
||||
int current = Math.max(0, Math.min(cozeCompleted, total));
|
||||
vo.setFileProgressCurrent(current);
|
||||
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 + ",等待回流");
|
||||
return;
|
||||
}
|
||||
TaskProgressSnapshotEntity snapshot = taskProgressSnapshotService.find(taskId, MODULE_TYPE);
|
||||
if (snapshot == null) {
|
||||
if (job != null && STATUS_RUNNING.equals(job.getStatus()) && cozeCompleted + cozePending > 0) {
|
||||
int total = Math.max(1, cozeCompleted + cozePending);
|
||||
int current = Math.max(0, Math.min(cozeCompleted, total));
|
||||
vo.setFileProgressCurrent(current);
|
||||
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
|
||||
? "Coze 已完成 " + current + "/" + total + ",等待回流"
|
||||
: "Coze 处理中");
|
||||
@@ -2835,8 +3302,10 @@ public class SimilarAsinTaskService {
|
||||
if (total <= 0) {
|
||||
return;
|
||||
}
|
||||
current = Math.max(0, Math.min(current, total));
|
||||
int percent = Math.max(0, Math.min(100, (int) Math.floor(current * 100.0 / total)));
|
||||
LocalDateTime baseTime = snapshot.getUpdatedAt() != null ? snapshot.getUpdatedAt() : (job == null ? null : job.getUpdatedAt());
|
||||
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.setFileProgressTotal(total);
|
||||
vo.setFileProgressPercent(percent);
|
||||
@@ -3138,7 +3607,45 @@ public class SimilarAsinTaskService {
|
||||
Integer batchIndex,
|
||||
Integer batchTotal,
|
||||
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) {
|
||||
|
||||
@@ -4,6 +4,8 @@ import com.nanri.aiimage.modules.task.model.dto.TaskFileJobDispatchEvent;
|
||||
import com.nanri.aiimage.modules.task.model.dto.TaskFileJobMessage;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
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.transaction.event.TransactionPhase;
|
||||
import org.springframework.transaction.event.TransactionalEventListener;
|
||||
@@ -15,6 +17,8 @@ public class TaskFileJobDispatchCoordinator {
|
||||
|
||||
private final TaskFileJobPublisher taskFileJobPublisher;
|
||||
private final TaskFileJobLocalDispatcher taskFileJobLocalDispatcher;
|
||||
@Qualifier("taskFileJobDispatchExecutor")
|
||||
private final TaskExecutor taskFileJobDispatchExecutor;
|
||||
|
||||
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true)
|
||||
public void onTaskFileJobDispatch(TaskFileJobDispatchEvent event) {
|
||||
@@ -29,6 +33,16 @@ public class TaskFileJobDispatchCoordinator {
|
||||
message.setScopeKey(event.scopeKey());
|
||||
message.setJobType(event.jobType());
|
||||
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);
|
||||
if (published) {
|
||||
return;
|
||||
|
||||
@@ -8,12 +8,17 @@ import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class TaskFileJobPublisher {
|
||||
|
||||
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}")
|
||||
private boolean mqEnabled;
|
||||
@@ -21,6 +26,15 @@ public class TaskFileJobPublisher {
|
||||
@Value("${aiimage.result-file-job.topic:aiimage-result-file-job}")
|
||||
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) {
|
||||
if (message == null || message.getJobId() == null) {
|
||||
return false;
|
||||
@@ -30,6 +44,13 @@ public class TaskFileJobPublisher {
|
||||
message.getJobId(), message.getTaskId(), message.getModuleType());
|
||||
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();
|
||||
if (template == null) {
|
||||
log.warn("[task-file-job] RocketMQTemplate unavailable, jobId={} taskId={} moduleType={}",
|
||||
@@ -37,13 +58,25 @@ public class TaskFileJobPublisher {
|
||||
return false;
|
||||
}
|
||||
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={}",
|
||||
message.getJobId(), message.getTaskId(), message.getModuleType(), topic);
|
||||
return true;
|
||||
} catch (Exception ex) {
|
||||
log.warn("[task-file-job] publish RocketMQ failed, fallback to local dispatcher/scheduled worker jobId={} taskId={} moduleType={} msg={}",
|
||||
message.getJobId(), message.getTaskId(), message.getModuleType(), ex.getMessage());
|
||||
int failures = consecutiveFailures.incrementAndGet();
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
@@ -76,6 +77,41 @@ public class TaskFileJobService {
|
||||
.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) {
|
||||
LambdaQueryWrapper<TaskFileJobEntity> wrapper = new LambdaQueryWrapper<TaskFileJobEntity>()
|
||||
.orderByDesc(TaskFileJobEntity::getUpdatedAt)
|
||||
@@ -130,7 +166,8 @@ public class TaskFileJobService {
|
||||
.eq(TaskFileJobEntity::getStatus, "RUNNING")
|
||||
.set(TaskFileJobEntity::getStatus, "FAILED")
|
||||
.set(TaskFileJobEntity::getErrorMessage, "文件生成任务运行超时")
|
||||
.set(TaskFileJobEntity::getUpdatedAt, LocalDateTime.now()));
|
||||
.set(TaskFileJobEntity::getUpdatedAt, LocalDateTime.now())
|
||||
.set(TaskFileJobEntity::getFinishedAt, LocalDateTime.now()));
|
||||
continue;
|
||||
}
|
||||
int updated = taskFileJobMapper.update(null, new LambdaUpdateWrapper<TaskFileJobEntity>()
|
||||
@@ -168,6 +205,19 @@ public class TaskFileJobService {
|
||||
.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
|
||||
public boolean requeue(Long jobId, String message) {
|
||||
if (jobId == null || jobId <= 0) {
|
||||
@@ -198,14 +248,23 @@ public class TaskFileJobService {
|
||||
.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) {
|
||||
int retryCount = job.getRetryCount() == null ? 0 : job.getRetryCount() + 1;
|
||||
taskFileJobMapper.update(null, new LambdaUpdateWrapper<TaskFileJobEntity>()
|
||||
.eq(TaskFileJobEntity::getId, job.getId())
|
||||
.ne(TaskFileJobEntity::getStatus, "SUCCESS")
|
||||
.set(TaskFileJobEntity::getStatus, "FAILED")
|
||||
.set(TaskFileJobEntity::getRetryCount, retryCount)
|
||||
.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) {
|
||||
|
||||
@@ -62,7 +62,7 @@ public class TaskResultFileJobWorker {
|
||||
if (!localWorkerEnabled) {
|
||||
return;
|
||||
}
|
||||
List<TaskFileJobEntity> jobs = taskFileJobService.listRunnableJobs(batchSize);
|
||||
List<TaskFileJobEntity> jobs = taskFileJobService.listRunnableJobsForOwner(batchSize, currentInstanceId());
|
||||
if (jobs == null || jobs.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
@@ -111,6 +111,12 @@ public class TaskResultFileJobWorker {
|
||||
private void processInternal(TaskFileJobEntity job) {
|
||||
long startedAt = System.currentTimeMillis();
|
||||
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.acquire(job.getModuleType(), job.getTaskId(), TaskDistributedLockService.DEFAULT_WAIT_MILLIS);
|
||||
if (lockHandle == null) {
|
||||
@@ -120,9 +126,22 @@ public class TaskResultFileJobWorker {
|
||||
return;
|
||||
}
|
||||
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);
|
||||
if (!completed) {
|
||||
taskFileJobService.touchRunning(job.getId());
|
||||
if (isOwnerScopedCozeJob(job)) {
|
||||
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={}",
|
||||
job.getId(), job.getTaskId(), job.getModuleType(), job.getResultId(),
|
||||
System.currentTimeMillis() - startedAt);
|
||||
@@ -136,6 +155,12 @@ public class TaskResultFileJobWorker {
|
||||
System.currentTimeMillis() - startedAt, resultFileUrl);
|
||||
}
|
||||
} 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();
|
||||
log.warn("[task-file-job] process failed jobId={} taskId={} moduleType={} resultId={} msg={}",
|
||||
job.getId(), job.getTaskId(), job.getModuleType(), job.getResultId(), message);
|
||||
|
||||
@@ -136,6 +136,9 @@ aiimage:
|
||||
mq-enabled: ${AIIMAGE_RESULT_FILE_JOB_MQ_ENABLED:true}
|
||||
topic: ${AIIMAGE_RESULT_FILE_JOB_TOPIC:aiimage-result-file-job}
|
||||
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-pool-size: ${AIIMAGE_RESULT_FILE_JOB_LOCAL_DISPATCH_POOL_SIZE:2}
|
||||
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-timeout-minutes: ${AIIMAGE_RESULT_FILE_JOB_STUCK_TIMEOUT_MINUTES:30}
|
||||
batch-size: ${AIIMAGE_RESULT_FILE_JOB_BATCH_SIZE:20}
|
||||
coze-task:
|
||||
max-concurrent: ${AIIMAGE_COZE_TASK_MAX_CONCURRENT:8}
|
||||
appearance-patent:
|
||||
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-id: ${AIIMAGE_APPEARANCE_PATENT_COZE_WORKFLOW_ID:7632683471312355338}
|
||||
coze-token: ${AIIMAGE_APPEARANCE_PATENT_COZE_TOKEN:Bearer sat_gsInckdsN0qqhnZSK7K4Zevw6neJCHmYHyIbm85oGgJIGEmSdkH5OFctSKLsAHvT}
|
||||
coze-batch-size: ${AIIMAGE_APPEARANCE_PATENT_COZE_BATCH_SIZE:50}
|
||||
coze-workflow-id: ${AIIMAGE_APPEARANCE_PATENT_COZE_WORKFLOW_ID:7639685157562089513}
|
||||
coze-token: ${AIIMAGE_APPEARANCE_PATENT_COZE_TOKEN:Bearer sat_CztofPRhIKFKeaPZBIRxocfckqhsdCZZ45NPhkf7WOZjbX36vnVSfVV30T6u2rSX}
|
||||
coze-batch-size: ${AIIMAGE_APPEARANCE_PATENT_COZE_BATCH_SIZE:20}
|
||||
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-poll-interval-millis: ${AIIMAGE_APPEARANCE_PATENT_COZE_POLL_INTERVAL_MILLIS:30000}
|
||||
@@ -159,9 +164,9 @@ aiimage:
|
||||
similar-asin:
|
||||
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-id: ${AIIMAGE_SIMILAR_ASIN_COZE_WORKFLOW_ID:7635328462404583478}
|
||||
coze-token: ${AIIMAGE_SIMILAR_ASIN_COZE_TOKEN:Bearer sat_gsInckdsN0qqhnZSK7K4Zevw6neJCHmYHyIbm85oGgJIGEmSdkH5OFctSKLsAHvT}
|
||||
coze-batch-size: ${AIIMAGE_SIMILAR_ASIN_COZE_BATCH_SIZE:50}
|
||||
coze-workflow-id: ${AIIMAGE_SIMILAR_ASIN_COZE_WORKFLOW_ID:7639708860686024756}
|
||||
coze-token: ${AIIMAGE_SIMILAR_ASIN_COZE_TOKEN:Bearer sat_vqmb97BiXbMW3XVyG9htWQKjZ2F55CaW2FRrT9bDdPha7a3hxmXnMNCE1XNHdCVU}
|
||||
coze-batch-size: ${AIIMAGE_SIMILAR_ASIN_COZE_BATCH_SIZE:20}
|
||||
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-poll-interval-millis: ${AIIMAGE_SIMILAR_ASIN_COZE_POLL_INTERVAL_MILLIS:30000}
|
||||
|
||||
@@ -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;
|
||||
@@ -4,7 +4,7 @@
|
||||
Source Server : 南日AI-线上
|
||||
Source Server Type : MySQL
|
||||
Source Server Version : 80045 (8.0.45)
|
||||
Source Host : 8.136.19.173:3306
|
||||
Source Host : 47.111.163.154:3306
|
||||
Source Schema : aiimage
|
||||
|
||||
Target Server Type : MySQL
|
||||
|
||||
@@ -5,6 +5,7 @@ import json
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
from urllib.parse import quote
|
||||
|
||||
import requests
|
||||
from requests.adapters import HTTPAdapter
|
||||
@@ -1217,7 +1218,9 @@ def list_product_categories():
|
||||
_, _, denied = _ensure_product_category_access()
|
||||
if 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:
|
||||
return error_response, status
|
||||
payload = result.get('data') or {}
|
||||
@@ -1225,6 +1228,76 @@ def list_product_categories():
|
||||
'success': True,
|
||||
'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 [])],
|
||||
'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):
|
||||
return {
|
||||
'status': progress.get('status') or 'pending',
|
||||
|
||||
@@ -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"
|
||||
coze_token = "sat_12nW40INoJxArrDXbY4lSCoudbqkOYTcphC99BP2efWyzxmsk4q81WDX3ezWgqZ5"
|
||||
workflow_id = "7608812635877900322"
|
||||
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_host = "8.136.19.173"
|
||||
mysql_user = "aiimage"
|
||||
mysql_password = "WTFrb5y6hNLz6hNy" # 请修改为您的数据库密码
|
||||
mysql_database = "aiimage"
|
||||
mysql_host, mysql_host_source = _get_env("mysql_host", "MYSQL_HOST", default="47.110.241.161")
|
||||
mysql_user, mysql_user_source = _get_env("mysql_user", "MYSQL_USER", default="aiimage")
|
||||
mysql_password, mysql_password_source = _get_env("mysql_password", "MYSQL_PASSWORD", default="WTFrb5y6hNLz6hNy")
|
||||
mysql_database, mysql_database_source = _get_env("mysql_database", "MYSQL_DATABASE", default="aiimage")
|
||||
|
||||
|
||||
cache_path = "./user_data"
|
||||
@@ -22,20 +56,20 @@ bucket_path = "nanri-image/"
|
||||
|
||||
file_url_pre = f"https://{bucket}.oss-cn-hangzhou.aliyuncs.com/"
|
||||
|
||||
import os
|
||||
backend_java_base_url = os.environ.get('BACKEND_JAVA_BASE_URL', 'http://127.0.0.1:18080').rstrip('/')
|
||||
# backend_java_base_url = os.environ.get('BACKEND_JAVA_BASE_URL', 'http://8.136.19.173:18080').rstrip('/')
|
||||
# backend_java_base_url = os.environ.get('BACKEND_JAVA_BASE_URL', 'http://47.111.163.154:18080').rstrip('/')
|
||||
# backend_java_base_url = os.environ.get('BACKEND_JAVA_BASE_URL', 'http://121.196.149.225:18080').rstrip('/')
|
||||
os.environ['OSS_ACCESS_KEY_ID'] = accessKeyId
|
||||
os.environ['OSS_ACCESS_KEY_SECRET'] = accessKeySecret
|
||||
os.environ['SECRET_KEY'] = "ddffc7c1d02121d9554d7b080b2511b6"
|
||||
backend_java_base_url, backend_java_base_url_source = _get_env(
|
||||
"java_api_base",
|
||||
"BACKEND_JAVA_BASE_URL",
|
||||
default="http://127.0.0.1:18080",
|
||||
# default="http://47.111.163.154:18080",
|
||||
)
|
||||
backend_java_base_url = backend_java_base_url.rstrip("/")
|
||||
os.environ["OSS_ACCESS_KEY_ID"] = accessKeyId
|
||||
os.environ["OSS_ACCESS_KEY_SECRET"] = accessKeySecret
|
||||
os.environ["SECRET_KEY"] = "ddffc7c1d02121d9554d7b080b2511b6"
|
||||
|
||||
|
||||
debug = True
|
||||
version = "1.0.0"
|
||||
APP_UPDATE_URL = ""
|
||||
os.environ['APP_VERSION'] = version
|
||||
os.environ['APP_UPDATE_URL'] = version
|
||||
|
||||
|
||||
os.environ["APP_VERSION"] = version
|
||||
os.environ["APP_UPDATE_URL"] = version
|
||||
|
||||
@@ -44,6 +44,7 @@ PyQt5==5.15.11
|
||||
PyQt5-Qt5==5.15.2
|
||||
PyQt5_sip==12.17.1
|
||||
python-dateutil==2.9.0.post0
|
||||
python-dotenv==1.0.1
|
||||
pythonnet==3.0.5
|
||||
pytz==2026.1.post1
|
||||
pywebview==6.1
|
||||
|
||||
@@ -2563,14 +2563,47 @@
|
||||
}
|
||||
function buildQueryAsinQuery(page) {
|
||||
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 shopName = (document.getElementById('queryAsinFilterShopName').value || '').trim();
|
||||
var asin = (document.getElementById('queryAsinFilterAsin').value || '').trim();
|
||||
if (groupId) query += '&group_id=' + encodeURIComponent(groupId);
|
||||
if (shopName) query += '&shop_name=' + encodeURIComponent(shopName);
|
||||
if (asin) query += '&asin=' + encodeURIComponent(asin);
|
||||
if (groupId) query += (query ? '&' : '') + 'group_id=' + encodeURIComponent(groupId);
|
||||
if (shopName) query += (query ? '&' : '') + 'shop_name=' + encodeURIComponent(shopName);
|
||||
if (asin) query += (query ? '&' : '') + 'asin=' + encodeURIComponent(asin);
|
||||
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) {
|
||||
var asinValue = item[country.field] || '';
|
||||
var infoHtml = asinValue ? '<span>' + asinValue + '</span>' : '<span style="color:#999;">-</span>';
|
||||
@@ -2883,6 +2916,9 @@
|
||||
document.getElementById('btnSearchQueryAsin').onclick = function () {
|
||||
loadQueryAsin(1);
|
||||
};
|
||||
document.getElementById('btnExportQueryAsin').onclick = function () {
|
||||
exportQueryAsin();
|
||||
};
|
||||
document.getElementById('queryAsinFilterShopName').addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
@@ -2964,6 +3000,11 @@
|
||||
renderQueryAsinInputs();
|
||||
|
||||
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;
|
||||
|
||||
function resetProductCategoryForm() {
|
||||
@@ -2983,27 +3024,102 @@
|
||||
var selected = selectedValue == null ? select.value : String(selectedValue || '');
|
||||
var excluded = excludedId == null ? editingProductCategoryId : excludedId;
|
||||
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;
|
||||
var prefix = new Array((item.level || 0) + 1).join(' ');
|
||||
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.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');
|
||||
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>';
|
||||
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 mark = item.child_count > 0 ? '+' : '-';
|
||||
var hasChildren = (item.child_count || 0) > 0;
|
||||
var expanded = !!item.expanded;
|
||||
var mark = hasChildren ? (expanded ? '-' : '+') : '';
|
||||
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>' + escapeHtml(item.sort_order || 0) + '</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>' +
|
||||
'</tr>';
|
||||
}).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) {
|
||||
btn.onclick = function () {
|
||||
var item = productCategoryItems.find(function (row) { return String(row.id) === String(btn.dataset.productCategoryEdit); });
|
||||
@@ -3047,28 +3181,136 @@
|
||||
}
|
||||
|
||||
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 (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;
|
||||
}
|
||||
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();
|
||||
renderProductCategoryRows(productCategoryItems);
|
||||
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 () {
|
||||
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 () {
|
||||
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 () {
|
||||
var msgEl = document.getElementById('msgProductCategory');
|
||||
var name = (document.getElementById('productCategoryName').value || '').trim();
|
||||
|
||||
@@ -10,16 +10,32 @@ try:
|
||||
from config import mysql_user as config_mysql_user
|
||||
from config import mysql_password as config_mysql_password
|
||||
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:
|
||||
config_mysql_host = 'localhost'
|
||||
config_mysql_user = 'root'
|
||||
config_mysql_password = ''
|
||||
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_user = os.environ.get('MYSQL_USER', config_mysql_user)
|
||||
mysql_password = os.environ.get('MYSQL_PASSWORD', config_mysql_password)
|
||||
mysql_database = os.environ.get('MYSQL_DATABASE', config_mysql_database)
|
||||
mysql_host = config_mysql_host
|
||||
mysql_user = config_mysql_user
|
||||
mysql_password = config_mysql_password
|
||||
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():
|
||||
|
||||
@@ -371,6 +371,7 @@
|
||||
.tree-node-mark {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
background: #eef1ff;
|
||||
color: #667eea;
|
||||
@@ -379,6 +380,14 @@
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.tree-node-mark.is-leaf {
|
||||
background: transparent;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.category-path {
|
||||
@@ -1317,6 +1326,7 @@
|
||||
<input type="text" id="queryAsinFilterAsin" placeholder="请输入 ASIN">
|
||||
</div>
|
||||
<button class="btn" id="btnSearchQueryAsin">查询</button>
|
||||
<button class="btn btn-secondary" id="btnExportQueryAsin" type="button">导出 XLSX</button>
|
||||
</div>
|
||||
<table>
|
||||
<thead>
|
||||
@@ -1365,7 +1375,16 @@
|
||||
<p class="msg" id="msgProductCategory"></p>
|
||||
</div>
|
||||
<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>
|
||||
<thead>
|
||||
<tr>
|
||||
|
||||
@@ -8,33 +8,33 @@
|
||||
<el-dialog v-model="dialogVisible" width="560px" class="secret-settings-dialog" :append-to-body="true">
|
||||
<template #header>
|
||||
<div class="dialog-header">
|
||||
<div class="dialog-title">设置</div>
|
||||
<div class="dialog-subtitle">按当前登录用户保存在本机,可分别管理外观专利和货源查询密钥。</div>
|
||||
<div class="dialog-title">密钥设置</div>
|
||||
<div class="dialog-subtitle">按当前登录用户保存在本机,外观专利和货源查询共用同一个 Coze 接口密钥。</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<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>
|
||||
<div class="secret-card-title">{{ item.title }}</div>
|
||||
<div class="secret-card-desc">{{ item.description }}</div>
|
||||
<div class="secret-card-title">通用 Coze 密钥</div>
|
||||
<div class="secret-card-desc">用于外观专利检测和货源查询。</div>
|
||||
</div>
|
||||
<button
|
||||
v-if="item.exists"
|
||||
v-if="secretState.exists"
|
||||
type="button"
|
||||
class="link-danger"
|
||||
@click="clearSecret(item.moduleKey)"
|
||||
@click="clearSecret"
|
||||
>
|
||||
清空
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<input
|
||||
v-model="item.value"
|
||||
v-model="secretState.value"
|
||||
class="secret-input"
|
||||
type="password"
|
||||
:placeholder="`请输入${item.title}密钥`"
|
||||
placeholder="请输入 Coze 接口密钥"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
/>
|
||||
@@ -43,14 +43,14 @@
|
||||
<div class="retention-label">保留时长</div>
|
||||
<div class="retention-options">
|
||||
<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>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
</section>
|
||||
@@ -73,14 +73,10 @@ import {
|
||||
clearStoredApiSecret,
|
||||
getStoredApiSecretSnapshot,
|
||||
saveStoredApiSecret,
|
||||
type ApiSecretModuleKey,
|
||||
type ApiSecretRetention,
|
||||
} from '@/shared/utils/api-secret-store'
|
||||
|
||||
type ModuleState = {
|
||||
moduleKey: ApiSecretModuleKey
|
||||
title: string
|
||||
description: string
|
||||
type SecretState = {
|
||||
value: string
|
||||
retention: ApiSecretRetention
|
||||
expiresAt: number | null
|
||||
@@ -90,25 +86,18 @@ type ModuleState = {
|
||||
const dialogVisible = ref(false)
|
||||
|
||||
const retentionOptions: Array<{ value: ApiSecretRetention; label: string }> = [
|
||||
{ value: 'session', label: '仅本次打开有效' },
|
||||
{ value: '1d', label: '1天' },
|
||||
{ value: '7d', label: '7天' },
|
||||
{ value: '30d', label: '30天' },
|
||||
{ value: 'session', label: '本次打开有效' },
|
||||
{ value: '1d', label: '1 天' },
|
||||
{ value: '7d', label: '7 天' },
|
||||
{ value: '30d', label: '30 天' },
|
||||
{ value: 'forever', label: '长期保留' },
|
||||
]
|
||||
|
||||
const moduleStates = ref<ModuleState[]>([])
|
||||
const secretState = ref<SecretState>(emptySecretState())
|
||||
|
||||
function buildModuleState(
|
||||
moduleKey: ApiSecretModuleKey,
|
||||
title: string,
|
||||
description: string,
|
||||
): ModuleState {
|
||||
const snapshot = getStoredApiSecretSnapshot(moduleKey)
|
||||
return {
|
||||
moduleKey,
|
||||
title,
|
||||
description,
|
||||
function loadStates() {
|
||||
const snapshot = getStoredApiSecretSnapshot()
|
||||
secretState.value = {
|
||||
value: snapshot.value,
|
||||
retention: snapshot.retention,
|
||||
expiresAt: snapshot.expiresAt,
|
||||
@@ -116,11 +105,13 @@ function buildModuleState(
|
||||
}
|
||||
}
|
||||
|
||||
function loadStates() {
|
||||
moduleStates.value = [
|
||||
buildModuleState('appearance-patent', '外观专利', '用于外观专利检测的 Coze 接口密钥。'),
|
||||
buildModuleState('similar-asin', '货源查询', '用于货源查询的 Coze 接口密钥。'),
|
||||
]
|
||||
function emptySecretState(): SecretState {
|
||||
return {
|
||||
value: '',
|
||||
retention: 'session',
|
||||
expiresAt: null,
|
||||
exists: false,
|
||||
}
|
||||
}
|
||||
|
||||
function formatRetentionText(retention: ApiSecretRetention, expiresAt: number | null) {
|
||||
@@ -136,16 +127,14 @@ function formatRetentionText(retention: ApiSecretRetention, expiresAt: number |
|
||||
return `有效期至 ${year}-${month}-${day} ${hour}:${minute}`
|
||||
}
|
||||
|
||||
function clearSecret(moduleKey: ApiSecretModuleKey) {
|
||||
clearStoredApiSecret(moduleKey)
|
||||
function clearSecret() {
|
||||
clearStoredApiSecret()
|
||||
loadStates()
|
||||
ElMessage.success('已清空密钥')
|
||||
}
|
||||
|
||||
function saveAll() {
|
||||
for (const item of moduleStates.value) {
|
||||
saveStoredApiSecret(item.moduleKey, item.value, item.retention)
|
||||
}
|
||||
saveStoredApiSecret(secretState.value.value, secretState.value.retention)
|
||||
loadStates()
|
||||
dialogVisible.value = false
|
||||
ElMessage.success('密钥设置已保存')
|
||||
@@ -192,6 +181,45 @@ loadStates()
|
||||
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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -217,10 +245,10 @@ loadStates()
|
||||
}
|
||||
|
||||
.secret-card {
|
||||
padding: 16px;
|
||||
border: 1px solid #2f363f;
|
||||
border-radius: 12px;
|
||||
background: #22272d;
|
||||
padding: 18px;
|
||||
border: 1px solid #313b46;
|
||||
border-radius: 10px;
|
||||
background: #20252b;
|
||||
}
|
||||
|
||||
.secret-card-head {
|
||||
@@ -331,7 +359,11 @@ loadStates()
|
||||
}
|
||||
|
||||
.footer-btn-primary {
|
||||
background: #5aa2ea;
|
||||
background: #4f8fda;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.footer-btn-primary:hover {
|
||||
background: #67a6ed;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -18,10 +18,8 @@
|
||||
</div>
|
||||
|
||||
<div class="prompt-card">
|
||||
<div class="section-title">AI 提示词</div>
|
||||
<textarea v-model="aiPrompt" class="prompt-input" rows="8" placeholder="可选,留空时使用下方默认提示词" />
|
||||
<div class="prompt-default-label">留空时默认使用以下提示词</div>
|
||||
<div class="prompt-preview">{{ defaultAiPrompt }}</div>
|
||||
<div class="section-title">新增条件要求</div>
|
||||
<textarea v-model="aiPrompt" class="prompt-input" rows="8" placeholder="可选,填写后会追加到默认检测提示词中" />
|
||||
</div>
|
||||
|
||||
<div class="run-row">
|
||||
@@ -251,11 +249,13 @@ function mergeCurrentTaskItem(item: AppearancePatentHistoryItem) {
|
||||
}
|
||||
|
||||
function effectiveAiPrompt() {
|
||||
return aiPrompt.value.trim() || defaultAiPrompt
|
||||
const extraPrompt = aiPrompt.value.trim()
|
||||
if (!extraPrompt) return defaultAiPrompt
|
||||
return `${defaultAiPrompt}\n\n新增条件要求:\n${extraPrompt}`
|
||||
}
|
||||
|
||||
function effectiveCozeApiKey() {
|
||||
return getStoredApiSecret('appearance-patent').trim()
|
||||
return getStoredApiSecret().trim()
|
||||
}
|
||||
|
||||
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; }
|
||||
.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-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; }
|
||||
.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; }
|
||||
|
||||
@@ -44,29 +44,6 @@
|
||||
</ul>
|
||||
</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">
|
||||
<button type="button" class="btn-run" :disabled="parsing || !uploadedFiles.length" @click="parseFiles">
|
||||
{{ parsing ? '解析中...' : '解析并创建任务' }}
|
||||
@@ -167,40 +144,6 @@
|
||||
</section>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
@@ -218,10 +161,8 @@ import {
|
||||
getSimilarAsinHistory,
|
||||
getSimilarAsinResultDownloadUrl,
|
||||
getSimilarAsinTaskProgressBatch,
|
||||
listProductCategories,
|
||||
listSimilarAsinFilterConditions,
|
||||
parseSimilarAsin,
|
||||
type ProductCategoryItemVo,
|
||||
type SimilarAsinFilterConditionVo,
|
||||
type SimilarAsinDashboardVo,
|
||||
type SimilarAsinHistoryItem,
|
||||
@@ -244,12 +185,6 @@ const queuedTaskSummary = ref<TaskSummary | null>(null)
|
||||
const filterConditionInput = ref('')
|
||||
const filterConditions = ref<SimilarAsinFilterConditionVo[]>([])
|
||||
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 pushing = ref(false)
|
||||
const savingCondition = ref(false)
|
||||
@@ -310,78 +245,11 @@ function selectedFilterConditionText() {
|
||||
}
|
||||
|
||||
function effectiveFilterCondition() {
|
||||
return appendProductCategoriesToPrompt(filterConditionInput.value.trim() || selectedFilterConditionText())
|
||||
return filterConditionInput.value.trim() || selectedFilterConditionText()
|
||||
}
|
||||
|
||||
function effectiveCozeApiKey() {
|
||||
return getStoredApiSecret('similar-asin').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?.([])
|
||||
return getStoredApiSecret().trim()
|
||||
}
|
||||
|
||||
function maskSecret(secret: string) {
|
||||
@@ -911,7 +779,6 @@ onMounted(async () => {
|
||||
loadPollingIds()
|
||||
await Promise.all([
|
||||
loadFilterConditions().catch(() => undefined),
|
||||
loadProductCategories().catch(() => undefined),
|
||||
loadDashboard().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 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; }
|
||||
.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; }
|
||||
.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; }
|
||||
|
||||
@@ -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 {
|
||||
id: number;
|
||||
conditionText: string;
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -18,17 +18,19 @@ export type ApiSecretSnapshot = {
|
||||
}
|
||||
|
||||
const STORAGE_PREFIX = 'brand:api-secret'
|
||||
const COMMON_SECRET_KEY = 'common'
|
||||
const LEGACY_SECRET_KEYS: LegacyApiSecretModuleKey[] = ['appearance-patent', 'similar-asin']
|
||||
|
||||
function currentUserStorageId() {
|
||||
if (typeof window === 'undefined') return '0'
|
||||
return window.localStorage.getItem('uid') || '0'
|
||||
}
|
||||
|
||||
function buildStorageKey(moduleKey: ApiSecretModuleKey) {
|
||||
function buildStorageKey(moduleKey: string) {
|
||||
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))
|
||||
if (!raw) return null
|
||||
try {
|
||||
@@ -81,11 +83,11 @@ function isExpired(record: ApiSecretRecord) {
|
||||
return record.expiresAt != null && record.expiresAt <= Date.now()
|
||||
}
|
||||
|
||||
function clearStorageRecord(storage: Storage, moduleKey: ApiSecretModuleKey) {
|
||||
function clearStorageRecord(storage: Storage, moduleKey: string) {
|
||||
storage.removeItem(buildStorageKey(moduleKey))
|
||||
}
|
||||
|
||||
function getLiveRecord(moduleKey: ApiSecretModuleKey): ApiSecretRecord | null {
|
||||
function getLiveRecordFromKey(moduleKey: string): ApiSecretRecord | null {
|
||||
if (typeof window === 'undefined') return null
|
||||
|
||||
const sessionRecord = readStorageRecord(window.sessionStorage, moduleKey)
|
||||
@@ -102,12 +104,42 @@ function getLiveRecord(moduleKey: ApiSecretModuleKey): ApiSecretRecord | null {
|
||||
return localRecord
|
||||
}
|
||||
|
||||
export function getStoredApiSecret(moduleKey: ApiSecretModuleKey) {
|
||||
return getLiveRecord(moduleKey)?.value || ''
|
||||
function clearLegacyStoredApiSecrets() {
|
||||
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 {
|
||||
const record = getLiveRecord(moduleKey)
|
||||
function migrateLegacyRecord(record: ApiSecretRecord) {
|
||||
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) {
|
||||
return {
|
||||
value: '',
|
||||
@@ -127,14 +159,13 @@ export function getStoredApiSecretSnapshot(moduleKey: ApiSecretModuleKey): ApiSe
|
||||
}
|
||||
|
||||
export function saveStoredApiSecret(
|
||||
moduleKey: ApiSecretModuleKey,
|
||||
value: string,
|
||||
retention: ApiSecretRetention,
|
||||
) {
|
||||
if (typeof window === 'undefined') return
|
||||
|
||||
const trimmedValue = value.trim()
|
||||
clearStoredApiSecret(moduleKey)
|
||||
clearStoredApiSecret()
|
||||
if (!trimmedValue) return
|
||||
|
||||
const now = Date.now()
|
||||
@@ -146,11 +177,12 @@ export function saveStoredApiSecret(
|
||||
}
|
||||
|
||||
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
|
||||
clearStorageRecord(window.sessionStorage, moduleKey)
|
||||
clearStorageRecord(window.localStorage, moduleKey)
|
||||
clearStorageRecord(window.sessionStorage, COMMON_SECRET_KEY)
|
||||
clearStorageRecord(window.localStorage, COMMON_SECRET_KEY)
|
||||
clearLegacyStoredApiSecrets()
|
||||
}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user