合并错误信息

This commit is contained in:
super
2026-04-28 15:25:34 +08:00
parent b96b136fa3
commit 58df800685
24 changed files with 1272 additions and 2398 deletions

View File

@@ -347,10 +347,14 @@ class SpiderTask:
# 'image_url': "",
# 'title': ""
item_data = {
"id": i.get("id"),
"asin": asin,
"country": country,
item_data = {
"sourceFileKey": i.get("sourceFileKey"),
"sourceFilename": i.get("sourceFilename"),
"rowToken": i.get("rowToken"),
"groupKey": i.get("groupKey"),
"id": i.get("id"),
"asin": asin,
"country": country,
"url": return_data.get("image_url"),
"title": return_data.get("title"),
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -8,11 +8,14 @@ import org.springframework.boot.context.properties.ConfigurationProperties;
public class AppearancePatentProperties {
private String cozeBaseUrl = "https://api.coze.cn";
private String cozeWorkflowPath = "/v1/workflow/run";
private String cozeWorkflowHistoryPath = "/v1/workflows/{workflow_id}/run_histories/{execute_id}";
private String cozeWorkflowId = "7632683471312355338";
private String cozeToken = "";
private int cozeBatchSize = 10;
private int cozeConnectTimeoutMillis = 10000;
private int cozeReadTimeoutMillis = 60000;
private int cozePollIntervalMillis = 2000;
private int cozePollTimeoutMillis = 120000;
private int staleTimeoutMinutes = 20;
private String staleFinalizeCron = "0 */2 * * * *";
}

View File

@@ -72,43 +72,88 @@ public class AppearancePatentCozeClient {
AppearancePatentResultRowDto row = rows.getFirst();
PartialCozeResultException lastFailure = null;
for (int attemptIndex = 1; attemptIndex <= 3; attemptIndex++) {
InspectAttempt attempt = inspectOnce(rows, prompt);
if (attempt.resolvedCount() == rows.size()) {
return attempt.mergedRows();
try {
InspectAttempt attempt = inspectOnce(rows, prompt);
if (attempt.resolvedCount() == rows.size()) {
return attempt.mergedRows();
}
log.warn("[appearance-patent] coze single unresolved attempt={} rowId={} asin={} country={} title={} url={} raw={}",
attemptIndex,
row.getId(),
row.getAsin(),
row.getCountry(),
abbreviate(row.getTitle(), 120),
abbreviate(row.getUrl(), 120),
abbreviate(attempt.raw(), 500));
lastFailure = new PartialCozeResultException(attempt.resolvedCount(), rows.size(), attempt.rawResultCount());
} catch (Exception ex) {
if (attemptIndex >= 3 || !isRetryableBatchFailure(ex)) {
throw ex;
}
log.warn("[appearance-patent] coze single retryable failure attempt={} rowId={} asin={} country={} err={}",
attemptIndex,
row.getId(),
row.getAsin(),
row.getCountry(),
failureMessage(ex));
}
if (attemptIndex < 3) {
sleepBeforeRetry(attemptIndex);
}
log.warn("[appearance-patent] coze single unresolved attempt={} rowId={} asin={} country={} title={} url={} raw={}",
attemptIndex,
row.getId(),
row.getAsin(),
row.getCountry(),
abbreviate(row.getTitle(), 120),
abbreviate(row.getUrl(), 120),
abbreviate(attempt.raw(), 500));
lastFailure = new PartialCozeResultException(attempt.resolvedCount(), rows.size(), attempt.rawResultCount());
}
throw lastFailure == null ? new PartialCozeResultException(0, rows.size(), 0) : lastFailure;
}
private InspectAttempt inspectOnce(List<AppearancePatentResultRowDto> rows, String prompt) throws Exception {
String raw = postWorkflow(rows, prompt);
String raw = runWorkflowAsyncAndWait(rows, prompt);
List<CozeResult> results = parseResults(raw);
if (rows.size() > 1 && !results.isEmpty() && results.stream().noneMatch(this::hasIdentity)) {
throw new PartialCozeResultException(0, rows.size(), results.size());
}
List<AppearancePatentResultRowDto> merged = mergeRows(rows, results);
return new InspectAttempt(raw, merged, resolvedCount(merged), results.size());
}
private String postWorkflow(List<AppearancePatentResultRowDto> rows, String prompt) {
Map<String, Object> parameters = new LinkedHashMap<>();
parameters.put("row_id_list", rows.stream().map(row -> nonBlank(row.getId(), "")).toList());
parameters.put("asin_list", rows.stream().map(row -> nonBlank(row.getAsin(), "")).toList());
parameters.put("country_list", rows.stream().map(row -> nonBlank(row.getCountry(), "")).toList());
parameters.put("row_key_list", rows.stream().map(this::rowKey).toList());
parameters.put("title_list", rows.stream().map(row -> nonBlank(row.getTitle(), row.getAsin())).toList());
parameters.put("url_list", rows.stream().map(row -> nonBlank(row.getUrl(), "")).toList());
parameters.put("prompt", prompt == null ? "" : prompt);
private String runWorkflowAsyncAndWait(List<AppearancePatentResultRowDto> rows, String prompt) throws Exception {
JsonNode submitRoot = objectMapper.readTree(postWorkflow(rows, prompt));
ensureSuccess(submitRoot);
String immediateData = extractResultDataText(submitRoot);
if (!immediateData.isBlank()) {
return wrapDataPayload(immediateData);
}
String executeId = extractExecuteId(submitRoot);
if (executeId == null || executeId.isBlank()) {
throw new IllegalStateException("Coze async execute_id missing");
}
long deadline = System.currentTimeMillis() + Math.max(10000, properties.getCozePollTimeoutMillis());
while (System.currentTimeMillis() < deadline) {
JsonNode pollRoot = objectMapper.readTree(getWorkflowHistory(executeId));
ensureSuccess(pollRoot);
String dataText = extractResultDataText(pollRoot);
if (!dataText.isBlank()) {
return wrapDataPayload(dataText);
}
String status = normalize(resolveWorkflowStatus(pollRoot)).toUpperCase(Locale.ROOT);
if (status.contains("FAIL") || status.contains("ERROR") || status.contains("CANCEL")) {
throw new IllegalStateException(firstNonBlank(resolveFailureMessage(pollRoot), "Coze async workflow failed"));
}
sleepQuietly(Math.max(200, properties.getCozePollIntervalMillis()));
}
throw new IllegalStateException("Coze async workflow poll timeout");
}
private String postWorkflow(List<AppearancePatentResultRowDto> rows, String prompt) {
Map<String, Object> parameters = buildParameters(rows, prompt);
Map<String, Object> body = new LinkedHashMap<>();
body.put("workflow_id", properties.getCozeWorkflowId());
body.put("parameters", parameters);
body.put("is_async", Boolean.TRUE);
RestClient.RequestBodySpec request = restClient().post()
.uri(joinUrl(properties.getCozeBaseUrl(), properties.getCozeWorkflowPath()))
@@ -123,12 +168,63 @@ public class AppearancePatentCozeClient {
});
}
private String getWorkflowHistory(String executeId) {
String path = properties.getCozeWorkflowHistoryPath()
.replace("{workflow_id}", properties.getCozeWorkflowId())
.replace("{execute_id}", executeId);
return restClient().get()
.uri(joinUrl(properties.getCozeBaseUrl(), path))
.headers(headers -> {
headers.setBearerAuth(stripBearer(properties.getCozeToken()));
headers.setContentType(MediaType.APPLICATION_JSON);
})
.exchange((clientRequest, clientResponse) -> {
byte[] responseBytes = StreamUtils.copyToByteArray(clientResponse.getBody());
return responseBytes.length == 0 ? "" : new String(responseBytes, StandardCharsets.UTF_8);
});
}
private Map<String, Object> buildParameters(List<AppearancePatentResultRowDto> rows, String prompt) {
List<String> groupKeys = rows.stream().map(row -> nonBlank(row.getGroupKey(), rowKey(row))).toList();
List<String> rowIds = rows.stream().map(row -> nonBlank(row.getId(), "")).toList();
List<String> asins = rows.stream().map(row -> nonBlank(row.getAsin(), "")).toList();
List<String> countries = rows.stream().map(row -> nonBlank(row.getCountry(), "")).toList();
List<String> titles = rows.stream().map(row -> nonBlank(row.getTitle(), row.getAsin())).toList();
List<String> urls = rows.stream().map(row -> nonBlank(row.getUrl(), "")).toList();
Map<String, Object> parameters = new LinkedHashMap<>();
parameters.put("title_list", titles);
parameters.put("url_list", urls);
parameters.put("items", buildItemObjects(rows, groupKeys, rowIds, asins, countries, titles, urls));
parameters.put("prompt", prompt == null ? "" : prompt);
return parameters;
}
private List<Map<String, Object>> buildItemObjects(List<AppearancePatentResultRowDto> rows,
List<String> groupKeys,
List<String> rowIds,
List<String> asins,
List<String> countries,
List<String> titles,
List<String> urls) {
List<Map<String, Object>> items = new ArrayList<>(rows.size());
for (int i = 0; i < rows.size(); i++) {
Map<String, Object> item = new LinkedHashMap<>();
item.put("group_key", groupKeys.get(i));
item.put("row_id", rowIds.get(i));
item.put("asin", asins.get(i));
item.put("country", countries.get(i));
item.put("title", titles.get(i));
item.put("url", urls.get(i));
items.add(item);
}
return items;
}
private List<CozeResult> parseResults(String raw) throws Exception {
JsonNode root = objectMapper.readTree(raw);
if (root.path("code").asInt(-1) != 0) {
throw new IllegalStateException(root.path("msg").asText("Coze response code is not 0"));
}
String dataText = root.path("data").asText("");
ensureSuccess(root);
String dataText = extractResultDataText(root);
if (dataText.isBlank()) {
return List.of();
}
@@ -137,10 +233,18 @@ public class AppearancePatentCozeClient {
List<CozeResult> results = new ArrayList<>();
if (array.isArray()) {
for (JsonNode node : array) {
JsonNode itemNode = resultItemNode(node);
results.add(new CozeResult(
text(firstNonNull(node.get("row_id"), firstNonNull(node.get("rowId"), node.get("id")))),
text(node.get("asin")),
text(firstNonNull(node.get("country"), node.get("site"))),
text(firstNonNull(
firstNonNull(node.get("group_key"), node.get("groupKey")),
firstNonNull(itemNode.get("group_key"), itemNode.get("groupKey")))),
text(firstNonNull(
firstNonNull(node.get("row_id"), firstNonNull(node.get("rowId"), node.get("id"))),
firstNonNull(itemNode.get("row_id"), firstNonNull(itemNode.get("rowId"), itemNode.get("id"))))),
text(firstNonNull(node.get("asin"), itemNode.get("asin"))),
text(firstNonNull(
firstNonNull(node.get("country"), node.get("site")),
firstNonNull(itemNode.get("country"), itemNode.get("site")))),
text(node.get("title")),
text(node.get("appearance")),
text(firstNonNull(node.get("patent"), node.get("patent "))),
@@ -151,14 +255,43 @@ public class AppearancePatentCozeClient {
return results;
}
private JsonNode resultItemNode(JsonNode node) {
if (node == null || node.isMissingNode() || node.isNull()) {
return objectMapper.missingNode();
}
JsonNode item = firstNonNull(node.get("item"), node.get("items"));
if (item == null || item.isMissingNode() || item.isNull()) {
return objectMapper.missingNode();
}
if (item.isArray()) {
return item.isEmpty() ? objectMapper.missingNode() : item.get(0);
}
return item;
}
private List<AppearancePatentResultRowDto> mergeRows(List<AppearancePatentResultRowDto> rows, List<CozeResult> results) {
Map<String, CozeResult> resultByGroupKey = new LinkedHashMap<>();
Map<String, CozeResult> resultByCompositeKey = new LinkedHashMap<>();
Map<String, CozeResult> resultByAsinCountry = new LinkedHashMap<>();
Map<String, CozeResult> resultByAsin = new LinkedHashMap<>();
Map<String, CozeResult> resultByRowId = new LinkedHashMap<>();
for (CozeResult result : results) {
String groupKey = normalize(result.groupKey());
if (!groupKey.isBlank()) {
resultByGroupKey.putIfAbsent(groupKey, result);
}
String compositeKey = rowKey(result.rowId(), result.asin(), result.country());
if (!compositeKey.isBlank()) {
resultByCompositeKey.putIfAbsent(compositeKey, result);
}
String asinCountryKey = asinCountryKey(result.asin(), result.country());
if (!asinCountryKey.isBlank()) {
resultByAsinCountry.putIfAbsent(asinCountryKey, result);
}
String asinKey = normalize(result.asin()).toUpperCase(Locale.ROOT);
if (!asinKey.isBlank()) {
resultByAsin.putIfAbsent(asinKey, result);
}
String rowIdKey = normalize(result.rowId());
if (!rowIdKey.isBlank()) {
resultByRowId.putIfAbsent(rowIdKey, result);
@@ -166,10 +299,19 @@ public class AppearancePatentCozeClient {
}
List<AppearancePatentResultRowDto> merged = new ArrayList<>(rows.size());
boolean allowIndexFallback = results.size() == rows.size();
boolean allowIndexFallback = results.size() == rows.size() && results.stream().noneMatch(this::hasIdentity);
for (int i = 0; i < rows.size(); i++) {
AppearancePatentResultRowDto row = copy(rows.get(i));
CozeResult result = resultByCompositeKey.get(rowKey(row.getId(), row.getAsin(), row.getCountry()));
CozeResult result = resultByGroupKey.get(normalize(row.getGroupKey()));
if (result == null) {
result = resultByCompositeKey.get(rowKey(row.getId(), row.getAsin(), row.getCountry()));
}
if (result == null) {
result = resultByAsinCountry.get(asinCountryKey(row.getAsin(), row.getCountry()));
}
if (result == null) {
result = resultByAsin.get(normalize(row.getAsin()).toUpperCase(Locale.ROOT));
}
if (result == null) {
result = resultByRowId.get(normalize(row.getId()));
}
@@ -182,6 +324,23 @@ public class AppearancePatentCozeClient {
return merged;
}
private String asinCountryKey(String asin, String country) {
String normalizedAsin = normalize(asin).toUpperCase(Locale.ROOT);
if (normalizedAsin.isBlank()) {
return "";
}
return normalizedAsin + "::" + normalize(country);
}
private boolean hasIdentity(CozeResult result) {
if (result == null) {
return false;
}
return !normalize(result.groupKey()).isBlank()
|| !normalize(result.rowId()).isBlank()
|| !normalize(result.asin()).isBlank();
}
private void applyResult(AppearancePatentResultRowDto row, CozeResult result) {
if (row == null || result == null) {
return;
@@ -201,6 +360,10 @@ public class AppearancePatentCozeClient {
private AppearancePatentResultRowDto copy(AppearancePatentResultRowDto source) {
AppearancePatentResultRowDto row = new AppearancePatentResultRowDto();
row.setSourceFileKey(source.getSourceFileKey());
row.setSourceFilename(source.getSourceFilename());
row.setRowToken(source.getRowToken());
row.setGroupKey(source.getGroupKey());
row.setId(source.getId());
row.setAsin(source.getAsin());
row.setCountry(source.getCountry());
@@ -261,6 +424,186 @@ public class AppearancePatentCozeClient {
|| !normalize(row.getConclusion()).isBlank();
}
private void ensureSuccess(JsonNode root) {
if (root.path("code").asInt(-1) != 0) {
throw new IllegalStateException(root.path("msg").asText("Coze response code is not 0"));
}
}
private String extractResultDataText(JsonNode root) {
if (root == null || root.isMissingNode() || root.isNull()) {
return "";
}
JsonNode dataNode = root.path("data");
if (dataNode.isTextual()) {
String value = dataNode.asText("");
if (looksLikeResultDataPayload(value)) {
return value;
}
return discoverEmbeddedData(parseJsonOrMissing(value));
}
if (dataNode.isObject()) {
String nested = text(firstNonNull(dataNode.get("data"), firstNonNull(dataNode.get("output"), dataNode.get("result"))));
if (nested != null && !nested.isBlank() && looksLikeResultDataPayload(nested)) {
return nested;
}
JsonNode outputs = firstNonNull(dataNode.get("outputs"), dataNode.get("details"));
String discovered = discoverEmbeddedData(outputs);
if (!discovered.isBlank()) {
return discovered;
}
}
return discoverEmbeddedData(root);
}
private String discoverEmbeddedData(JsonNode node) {
if (node == null || node.isNull() || node.isMissingNode()) {
return "";
}
if (node.isTextual()) {
String value = node.asText("");
if (looksLikeResultDataPayload(value)) {
return value;
}
return discoverEmbeddedData(parseJsonOrMissing(value));
}
if (node.isArray()) {
for (JsonNode child : node) {
String discovered = discoverEmbeddedData(child);
if (!discovered.isBlank()) {
return discovered;
}
}
return "";
}
if (node.isObject()) {
if (isResultDataPayload(node)) {
return node.toString();
}
for (java.util.Iterator<Map.Entry<String, JsonNode>> it = node.fields(); it.hasNext(); ) {
Map.Entry<String, JsonNode> entry = it.next();
String discovered = discoverEmbeddedData(entry.getValue());
if (!discovered.isBlank()) {
return discovered;
}
}
}
return "";
}
private boolean looksLikeResultDataPayload(String value) {
JsonNode parsed = parseJsonOrMissing(value);
return isResultDataPayload(parsed);
}
private boolean isResultDataPayload(JsonNode node) {
if (node == null || node.isMissingNode() || node.isNull()) {
return false;
}
JsonNode array = node.path("data");
if (!array.isArray()) {
return false;
}
for (JsonNode item : array) {
if (item.has("appearance") || item.has("patent") || item.has("patent ") || item.has("result")) {
return true;
}
}
return false;
}
private JsonNode parseJsonOrMissing(String value) {
String normalized = normalize(value);
if (!(normalized.startsWith("{") || normalized.startsWith("["))) {
return objectMapper.missingNode();
}
try {
return objectMapper.readTree(normalized);
} catch (Exception ignored) {
return objectMapper.missingNode();
}
}
private String resolveWorkflowStatus(JsonNode root) {
JsonNode dataNode = root.path("data");
JsonNode statusNode = firstNonNull(
firstNonNull(dataNode.get("status"), dataNode.get("execute_status")),
firstNonNull(root.get("status"), root.get("execute_status")));
String status = text(statusNode);
if (status != null && !status.isBlank()) {
return status;
}
return findTextByFieldName(root, "execute_status", "status");
}
private String resolveFailureMessage(JsonNode root) {
JsonNode dataNode = root.path("data");
String message = text(firstNonNull(dataNode.get("error_message"), firstNonNull(dataNode.get("msg"), root.get("msg"))));
return message == null ? "" : message;
}
private String extractExecuteId(JsonNode root) {
return findTextByFieldName(root, "execute_id", "executeId");
}
private String findTextByFieldName(JsonNode node, String... names) {
if (node == null || node.isMissingNode() || node.isNull()) {
return "";
}
if (node.isTextual()) {
return findTextByFieldName(parseJsonOrMissing(node.asText("")), names);
}
if (node.isArray()) {
for (JsonNode child : node) {
String found = findTextByFieldName(child, names);
if (!found.isBlank()) {
return found;
}
}
return "";
}
if (node.isObject()) {
for (String name : names) {
String value = text(node.get(name));
if (value != null && !value.isBlank()) {
return value;
}
}
JsonNode dataNode = node.get("data");
if (dataNode != null && dataNode.isTextual()) {
String found = findTextByFieldName(parseJsonOrMissing(dataNode.asText("")), names);
if (!found.isBlank()) {
return found;
}
}
for (java.util.Iterator<Map.Entry<String, JsonNode>> it = node.fields(); it.hasNext(); ) {
Map.Entry<String, JsonNode> entry = it.next();
String found = findTextByFieldName(entry.getValue(), names);
if (!found.isBlank()) {
return found;
}
}
}
return "";
}
private String wrapDataPayload(String dataText) {
Map<String, Object> payload = new LinkedHashMap<>();
payload.put("code", 0);
payload.put("data", dataText);
return writeJson(payload);
}
private String writeJson(Object value) {
try {
return objectMapper.writeValueAsString(value);
} catch (Exception ex) {
throw new IllegalStateException("Failed to serialize Coze payload", ex);
}
}
private String abbreviate(String value, int maxLength) {
String normalized = value == null ? "" : value.trim();
if (normalized.length() <= maxLength) {
@@ -269,6 +612,19 @@ public class AppearancePatentCozeClient {
return normalized.substring(0, Math.max(0, maxLength - 3)) + "...";
}
private void sleepBeforeRetry(int attemptIndex) {
long delayMillis = Math.max(1, attemptIndex) * 1500L;
sleepQuietly(delayMillis);
}
private void sleepQuietly(long delayMillis) {
try {
Thread.sleep(delayMillis);
} catch (InterruptedException interruptedException) {
Thread.currentThread().interrupt();
}
}
private RuntimeException propagate(Exception ex) {
if (ex instanceof RuntimeException runtimeException) {
return runtimeException;
@@ -288,6 +644,10 @@ public class AppearancePatentCozeClient {
return value == null || value.isBlank() ? fallback : value;
}
private String firstNonBlank(String preferred, String fallback) {
return preferred == null || preferred.isBlank() ? fallback : preferred.trim();
}
private String normalize(String value) {
return value == null ? "" : value.replace("\ufeff", "").replace("\u3000", " ").trim();
}
@@ -305,19 +665,19 @@ public class AppearancePatentCozeClient {
private String failureMessage(Exception ex) {
if (ex instanceof PartialCozeResultException partial) {
return "Coze返回结果不完整(" + partial.resolvedCount() + "/" + partial.expectedCount() + ")";
return "Coze result incomplete(" + partial.resolvedCount() + "/" + partial.expectedCount() + ")";
}
String message = ex == null ? null : ex.getMessage();
if (message == null || message.isBlank()) {
return "Coze调用失败";
return "Coze call failed";
}
if (message.contains("Workflow node execution limit exceeded")) {
return "Coze工作流节点执行超限,请检查工作流配置";
return "Coze workflow node execution limit exceeded";
}
if (message.contains("Read timed out")) {
return "Coze调用超时,请稍后重试";
return "Coze call timed out";
}
return "Coze调用失败: " + message;
return "Coze call failed: " + message;
}
private String stripBearer(String token) {
@@ -338,6 +698,7 @@ public class AppearancePatentCozeClient {
}
private record CozeResult(
String groupKey,
String rowId,
String asin,
String country,

View File

@@ -18,7 +18,7 @@ public class AppearancePatentParseRequest {
private Long userId;
@NotEmpty
@Schema(description = "已上传的 Excel 文件列表。当前外观专利检测只读取第一个文件;文件对象来自统一上传接口返回值。", requiredMode = Schema.RequiredMode.REQUIRED)
@Schema(description = "已上传的 Excel 文件列表。支持多文件聚合解析;文件对象来自统一上传接口返回值。", requiredMode = Schema.RequiredMode.REQUIRED)
private List<AppearancePatentSourceFileDto> files;
@JsonProperty("ai_prompt")

View File

@@ -1,5 +1,6 @@
package com.nanri.aiimage.modules.appearancepatent.model.dto;
import com.nanri.aiimage.modules.appearancepatent.model.vo.AppearancePatentParsedGroupVo;
import com.nanri.aiimage.modules.appearancepatent.model.vo.AppearancePatentParsedRowVo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@@ -8,14 +9,23 @@ import java.util.ArrayList;
import java.util.List;
@Data
@Schema(description = "外观专利检测解析载荷。通常保存到 OSS数据库只保存 oss 指针。")
@Schema(description = "外观专利解析载荷")
public class AppearancePatentParsedPayloadDto {
@Schema(description = "AI 提示词")
@Schema(description = "AI 提示词")
private String aiPrompt;
@Schema(description = "Excel 原始表头列表。")
@Schema(description = "本次解析的源文件列表")
private List<AppearancePatentSourceFileDto> sourceFiles = new ArrayList<>();
@Schema(description = "Excel 原始表头列表")
private List<String> headers = new ArrayList<>();
@Schema(description = "返回前端和推给 Python 的代表行,只包含整数 id 和 n_1 行。")
@Schema(description = "兼容旧链路的平铺有效行,现为全部有效行")
private List<AppearancePatentParsedRowVo> items = new ArrayList<>();
@Schema(description = "完整有效行,包含 n_2、n_3 等解析后被前端过滤但最终需要补回的子行。")
@Schema(description = "按相邻主 ID 块分组后的完整数据")
private List<AppearancePatentParsedGroupVo> groups = new ArrayList<>();
@Schema(description = "完整有效行")
private List<AppearancePatentParsedRowVo> allItems = new ArrayList<>();
}

View File

@@ -0,0 +1,29 @@
package com.nanri.aiimage.modules.appearancepatent.model.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
@Data
@Schema(description = "Python 回传的外观专利分组结果")
public class AppearancePatentResultGroupDto {
@Schema(description = "来源文件 key")
private String sourceFileKey;
@Schema(description = "来源文件名")
private String sourceFilename;
@Schema(description = "相邻主 ID 块的分组 key")
private String groupKey;
@Schema(description = "主 ID例如 1、2、10")
private String baseId;
@Schema(description = "分组首条展示 ID")
private String displayId;
@Schema(description = "分组内全部抓取结果")
private List<AppearancePatentResultRowDto> items = new ArrayList<>();
}

View File

@@ -7,27 +7,49 @@ import lombok.Data;
@Data
@Schema(description = "外观专利检测单行商品数据")
public class AppearancePatentResultRowDto {
@Schema(description = "来源文件 key。多文件回传时用于避免行结果串到别的文件。", example = "uploads/20260426/appearance_patent_17.xlsx")
private String sourceFileKey;
@Schema(description = "来源文件名。仅用于调试与追踪。", example = "17.xlsx")
private String sourceFilename;
@Schema(description = "行级唯一 token。Python 应从解析结果原样透传。", example = "uploads/20260426/appearance_patent_17.xlsx::row::2")
private String rowToken;
@Schema(description = "主数据分组 key。Python 应从解析结果原样透传,用于把同组子行补回。", example = "uploads/20260426/appearance_patent_17.xlsx::2@2")
private String groupKey;
@Schema(description = "Excel 中的 id。代表行通常是整数 id 或 n_1例如 2_1最终生成 xlsx 时2_2、2_3 会复用同组 2_1 的 Coze 检测结果。", example = "2_1")
private String id;
@Schema(description = "亚马逊 ASIN。后端会统一按大写处理和匹配。", example = "B0CJ8SNXXV")
private String asin;
@Schema(description = "站点或国家。来自 Excel 的国家列,例如英国、德国、法国。", example = "英国")
private String country;
@Schema(description = "商品主图或待检测图片 URL。Java 调用 Coze 时会放入 url_list。", example = "https://webstatic.aiproxy.vip/output/20260425/103322/demo.jpg")
private String url;
@Schema(description = "商品标题。Java 调用 Coze 时会放入 title_list为空时会回退使用 ASIN。", example = "Women Floral Dress Summer Casual")
private String title;
@Schema(description = "单行错误信息。通常用于记录 Python 单行处理异常Coze 失败时后端会尽量保留原始行,不强行写入风险结果。", example = "图片地址为空")
private String error;
@Schema(description = "单行完成标记。当前主要使用请求体顶层 done 控制任务收尾,该字段仅作兼容。", example = "true")
private Boolean done;
@Schema(description = "Java 调用 Coze 后生成的标题维度检测结果,对应最终 xlsx 的“标题维度商标”列。Python 回传请求不要传该字段;即使传入,后端也会以 Java/Coze 处理结果为准。", example = "标题未发现明显商标侵权风险。", accessMode = Schema.AccessMode.READ_ONLY)
@Schema(description = "Java 调用 Coze 后生成的标题维度检测结果,对应最终 xlsx 的“标题维度商标”列。Python 回传请求中不要传该字段;即使传入,后端也会以 Java/Coze 处理结果为准。", example = "标题未发现明显商标侵权风险。", accessMode = Schema.AccessMode.READ_ONLY)
private String titleRisk;
@Schema(description = "Java 调用 Coze 后生成的外观维度检测结果,对应最终 xlsx 的“外观维度外观设计专利”列。Python 回传请求不要传该字段。", example = "未发现明显外观设计专利侵权风险。", accessMode = Schema.AccessMode.READ_ONLY)
@Schema(description = "Java 调用 Coze 后生成的外观维度检测结果,对应最终 xlsx 的“外观维度外观设计专利”列。Python 回传请求中不要传该字段。", example = "未发现明显外观设计专利侵权风险。", accessMode = Schema.AccessMode.READ_ONLY)
private String appearanceRisk;
@JsonAlias({"patent ", "patent"})
@Schema(description = "Java 调用 Coze 后生成的专利维度检测结果,对应最终 xlsx 的“专利维度(发明/实用新型专利)”列。兼容 Coze 返回字段 patent 和 patent 后带空格的情况Python 回传请求不要传该字段。", example = "未发现明显发明或实用新型专利侵权风险。", accessMode = Schema.AccessMode.READ_ONLY)
@Schema(description = "Java 调用 Coze 后生成的专利维度检测结果,对应最终 xlsx 的“专利维度(发明/实用新型专利)”列。兼容 Coze 返回字段 patent 和 patent 后带空格的情况Python 回传请求不要传该字段。", example = "未发现明显发明或实用新型专利侵权风险。", accessMode = Schema.AccessMode.READ_ONLY)
private String patentRisk;
@Schema(description = "Java 调用 Coze 后生成的最终结论,对应最终 xlsx 的“结论”列。Python 回传请求不要传该字段。", example = "未发现明显侵权风险", accessMode = Schema.AccessMode.READ_ONLY)
@Schema(description = "Java 调用 Coze 后生成的最终结论,对应最终 xlsx 的“结论”列。Python 回传请求中不要传该字段。", example = "未发现明显侵权风险", accessMode = Schema.AccessMode.READ_ONLY)
private String conclusion;
}

View File

@@ -9,16 +9,24 @@ import java.util.List;
@Data
@Schema(description = "Python 回传外观专利检测结果请求")
public class AppearancePatentSubmitResultRequest {
@Schema(description = "本次 Python 回传的提交批次标识。建议同一个任务固定使用同一个值,例如 appearance-patent-{taskId};后端会结合该值和 chunkIndex 做分片幂等。", example = "appearance-patent-3938")
@Schema(description = "本次 Python 回传的提交批次标识", example = "appearance-patent-3938")
private String submissionId;
@Schema(description = "当前回传分片序号。建议从 1 开始递增;同一个 submissionId 下相同 chunkIndex 重复提交会被后端识别为重复分片并跳过重复处理。", example = "1")
@Schema(description = "当前回传分片序号", example = "1")
private Integer chunkIndex;
@Schema(description = "本任务预计总分片数。如果 Python 是一条一条回传,可设置为总商品数;如果无法预估,可传 0 或 1最终以 done=true 触发收尾。", example = "458")
@Schema(description = "本任务预计总分片数", example = "36")
private Integer chunkTotal;
@Schema(description = "是否为最后一次回传。true 表示 Python 已完成该任务全部数据回传Java 会处理剩余不足 10 条的数据、组装最终 xlsx、上传 OSS 并收尾任务。", example = "false")
@Schema(description = "是否为最后一次回传", example = "false")
private Boolean done;
@Schema(description = "Python 侧任务级错误信息。非空时 Java 会记录错误并按失败任务收尾;普通单行 Coze 失败不建议写这里。", example = "浏览器执行异常,任务提前结束")
@Schema(description = "Python 侧任务级错误信息", example = "浏览器执行异常,任务提前结束")
private String error;
@Schema(description = "本次回传的商品原始数据列表。可以一条一条传也可以一次多条传Python 只需要传 id、asin、country、url、title、error、done 等原始/执行字段。titleRisk、appearanceRisk、patentRisk、conclusion 由 Java 攒够 10 条调用 Coze 后生成Python 不要传。")
@Schema(description = "本次回传的分组结果列表,推荐优先使用")
private List<AppearancePatentResultGroupDto> groups = new ArrayList<>();
@Schema(description = "兼容旧链路的平铺结果列表")
private List<AppearancePatentResultRowDto> items = new ArrayList<>();
}

View File

@@ -9,18 +9,33 @@ import java.util.List;
@Data
@Schema(description = "外观专利检测解析结果")
public class AppearancePatentParseVo {
@Schema(description = "新创建的任务 ID。后续手动推 Python、激活任务、回传结果、查询进度都使用该 ID。", example = "3938")
@Schema(description = "新创建的任务 ID", example = "3938")
private Long taskId;
@Schema(description = "源 Excel 文件名。", example = "17.xlsx")
@Schema(description = "来源 Excel 文件名,多个文件时为聚合展示文案", example = "17.xlsx 等 2 个文件")
private String sourceFilename;
@Schema(description = "Excel 中检测到的有效数据总行数,不含空行。", example = "716")
@Schema(description = "参与本次解析的源文件数量", example = "2")
private Integer sourceFileCount;
@Schema(description = "Excel 中检测到的非空数据总行数", example = "716")
private Integer totalRows;
@Schema(description = "返回前端并准备推给 Python 的代表行数量。只包含整数 id 和 n_1 行。", example = "458")
@Schema(description = "解析出的有效行数", example = "458")
private Integer acceptedRows;
@Schema(description = "解析时被过滤或缺少必要字段的行数。n_2、n_3 等子行会计入过滤数,但仍会保存在 OSS 解析载荷中用于最终补齐。", example = "258")
@Schema(description = "因缺少必要字段而被丢弃的行数", example = "12")
private Integer droppedRows;
@Schema(description = "本任务最终使用的 AI 提示词。", example = "请排查这些亚马逊商品在英国及欧洲地区是否存在知识产权侵权风险。")
@Schema(description = "按相邻主 ID 块生成的分组数", example = "36")
private Integer groupCount;
@Schema(description = "本任务最终使用的 AI 提示词")
private String aiPrompt;
@Schema(description = "返回前端的代表行列表。前端只展示样例,不展示完整明细;完整 allItems 已保存在 OSS 解析载荷中。")
@Schema(description = "兼容旧前端的平铺有效行列表,现为全部有效行")
private List<AppearancePatentParsedRowVo> items = new ArrayList<>();
@Schema(description = "返回前端和推送 Python 的分组列表")
private List<AppearancePatentParsedGroupVo> groups = new ArrayList<>();
}

View File

@@ -0,0 +1,32 @@
package com.nanri.aiimage.modules.appearancepatent.model.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
@Data
@Schema(description = "外观专利解析后的分组数据")
public class AppearancePatentParsedGroupVo {
@Schema(description = "来源文件 key")
private String sourceFileKey;
@Schema(description = "来源文件名")
private String sourceFilename;
@Schema(description = "相邻主 ID 块的分组 key")
private String groupKey;
@Schema(description = "主 ID例如 1、2、10")
private String baseId;
@Schema(description = "分组首条展示 ID例如 1 或 2_1")
private String displayId;
@Schema(description = "分组内行数")
private Integer itemCount;
@Schema(description = "分组内全部行,顺序与原 Excel 保持一致")
private List<AppearancePatentParsedRowVo> items = new ArrayList<>();
}

View File

@@ -9,21 +9,39 @@ import java.util.Map;
@Data
@Schema(description = "外观专利检测解析出的单行数据")
public class AppearancePatentParsedRowVo {
@Schema(description = "来源文件 key。用于多文件场景下区分同名/同 ID 行。", example = "uploads/20260426/appearance_patent_17.xlsx")
private String sourceFileKey;
@Schema(description = "来源文件名。用于调试和结果追踪。", example = "17.xlsx")
private String sourceFilename;
@Schema(description = "Excel 原始行号,从 1 开始。", example = "2")
private Integer rowIndex;
@Schema(description = "Excel 中原始 id 值。", example = "2_1")
private String sourceId;
@Schema(description = "前端展示和 Python 回传使用的 id。整数 id 原样保留,子数据第一条如 2_1 原样保留。", example = "2_1")
private String displayId;
@Schema(description = "亚马逊 ASIN。", example = "B0CJ8SNXXV")
@Schema(description = "行级唯一 token。多文件、重复主数据时用于精确匹配回传结果。", example = "uploads/20260426/appearance_patent_17.xlsx::row::2")
private String rowToken;
@Schema(description = "同一主数据块的分组 key。用于把 2_1、2_2、2_3 等子行重新补回同一组结果。", example = "uploads/20260426/appearance_patent_17.xlsx::2@2")
private String groupKey;
@Schema(description = "亚马逊 ASIN。", example = "B0CJ8SNXXV")
private String asin;
@Schema(description = "国家或站点。", example = "英国")
private String country;
@Schema(description = "商品图片 URL 或商品 URL供 Coze 检测使用。", example = "https://webstatic.aiproxy.vip/output/demo.jpg")
private String url;
@Schema(description = "商品标题。", example = "Women Floral Dress Summer Casual")
private String title;
@Schema(description = "该 Excel 行的原始列值映射。最终生成 xlsx 时可从这里读取价格等字段。", example = "{\"id\":\"2_1\",\"asin\":\"B0CJ8SNXXV\",\"国家\":\"英国\",\"价格\":\"12.99\"}")
@Schema(description = "该 Excel 行的原始列值映射。最终生成 xlsx 时可以从这里读取价格等字段。", example = "{\"id\":\"2_1\",\"asin\":\"B0CJ8SNXXV\",\"国家\":\"英国\",\"价格\":\"12.99\"}")
private Map<String, String> values = new LinkedHashMap<>();
}

View File

@@ -11,12 +11,14 @@ import com.nanri.aiimage.config.StorageProperties;
import com.nanri.aiimage.modules.appearancepatent.client.AppearancePatentCozeClient;
import com.nanri.aiimage.modules.appearancepatent.model.dto.AppearancePatentParseRequest;
import com.nanri.aiimage.modules.appearancepatent.model.dto.AppearancePatentParsedPayloadDto;
import com.nanri.aiimage.modules.appearancepatent.model.dto.AppearancePatentResultGroupDto;
import com.nanri.aiimage.modules.appearancepatent.model.dto.AppearancePatentResultRowDto;
import com.nanri.aiimage.modules.appearancepatent.model.dto.AppearancePatentSourceFileDto;
import com.nanri.aiimage.modules.appearancepatent.model.dto.AppearancePatentSubmitResultRequest;
import com.nanri.aiimage.modules.appearancepatent.model.vo.AppearancePatentDashboardVo;
import com.nanri.aiimage.modules.appearancepatent.model.vo.AppearancePatentHistoryItemVo;
import com.nanri.aiimage.modules.appearancepatent.model.vo.AppearancePatentHistoryVo;
import com.nanri.aiimage.modules.appearancepatent.model.vo.AppearancePatentParsedGroupVo;
import com.nanri.aiimage.modules.appearancepatent.model.vo.AppearancePatentParsedRowVo;
import com.nanri.aiimage.modules.appearancepatent.model.vo.AppearancePatentParseVo;
import com.nanri.aiimage.modules.appearancepatent.model.vo.AppearancePatentTaskBatchVo;
@@ -115,8 +117,18 @@ public class AppearancePatentTaskService {
if (request.getFiles() == null || request.getFiles().isEmpty()) {
throw new BusinessException("请先上传 Excel 文件");
}
AppearancePatentSourceFileDto source = request.getFiles().getFirst();
if (source.getFileKey() == null || source.getFileKey().isBlank()) {
List<AppearancePatentSourceFileDto> sourceFiles = request.getFiles().stream()
.filter(Objects::nonNull)
.toList();
if (sourceFiles.isEmpty()) {
throw new BusinessException("璇峰厛涓婁紶 Excel 鏂囦欢");
}
List<AppearancePatentParsedRowVo> allRows = new ArrayList<>();
List<String> mergedHeaders = new ArrayList<>();
int totalRows = 0;
int droppedRows = 0;
for (AppearancePatentSourceFileDto source : sourceFiles) {
if (source.getFileKey() == null || source.getFileKey().isBlank()) {
throw new BusinessException("fileKey 不能为空");
}
File input = localFileStorageService.findLocalSourceFile(source.getFileKey());
@@ -124,17 +136,23 @@ public class AppearancePatentTaskService {
throw new BusinessException("源文件不存在");
}
ParsedWorkbook parsed = parseWorkbook(input);
if (parsed.acceptedRows.isEmpty()) {
ParsedWorkbook parsed = parseWorkbook(input, source);
totalRows += parsed.totalRows();
droppedRows += parsed.droppedRows();
allRows.addAll(parsed.allRows());
mergeHeaders(mergedHeaders, parsed.headers());
}
if (allRows.isEmpty()) {
throw new BusinessException("未解析到有效 ASIN 数据");
}
List<AppearancePatentParsedGroupVo> groups = buildParsedGroups(allRows);
FileTaskEntity task = new FileTaskEntity();
task.setTaskNo(MODULE_TYPE + "-" + IdUtil.getSnowflakeNextIdStr());
task.setModuleType(MODULE_TYPE);
task.setTaskMode("PYTHON_QUEUE");
task.setStatus(STATUS_PENDING);
task.setSourceFileCount(parsed.acceptedRows.size());
task.setSourceFileCount(sourceFiles.size());
task.setSuccessFileCount(0);
task.setFailedFileCount(0);
task.setCreatedBy("user:" + request.getUserId());
@@ -149,20 +167,21 @@ public class AppearancePatentTaskService {
}
fileTaskMapper.insert(task);
String sourceScopeHash = DigestUtil.sha256Hex(source.getFileKey());
String parsedPayload = buildParsedPayloadJson(request.getAiPrompt(), parsed.headers, parsed.acceptedRows, parsed.allRows);
String aggregateScopeKey = buildAggregateScopeKey(sourceFiles);
String sourceScopeHash = DigestUtil.sha256Hex(aggregateScopeKey);
String parsedPayload = buildParsedPayloadJson(request.getAiPrompt(), sourceFiles, mergedHeaders, groups, allRows);
String parsedPayloadPointer = storeParsedPayload(task.getId(), sourceScopeHash, parsedPayload);
task.setResultJson(buildTaskResultJson(request.getAiPrompt(), source.getFileKey(), parsedPayloadPointer));
task.setResultJson(buildTaskResultJson(request.getAiPrompt(), sourceFiles, parsedPayloadPointer));
task.setUpdatedAt(LocalDateTime.now());
fileTaskMapper.updateById(task);
String sourceFilename = firstNonBlank(source.getOriginalFilename(), input.getName());
String sourceFilename = buildAggregateSourceFilenameLabel(sourceFiles);
FileResultEntity result = new FileResultEntity();
result.setTaskId(task.getId());
result.setModuleType(MODULE_TYPE);
result.setSourceFilename(sourceFilename);
result.setSourceFileUrl(source.getFileKey());
result.setRowCount(parsed.acceptedRows.size());
result.setSourceFileUrl(aggregateScopeKey);
result.setRowCount(allRows.size());
result.setUserId(request.getUserId());
result.setCreatedAt(LocalDateTime.now());
fileResultMapper.insert(result);
@@ -170,7 +189,7 @@ public class AppearancePatentTaskService {
TaskScopeStateEntity scope = new TaskScopeStateEntity();
scope.setTaskId(task.getId());
scope.setModuleType(MODULE_TYPE);
scope.setScopeKey(source.getFileKey());
scope.setScopeKey(aggregateScopeKey);
scope.setScopeHash(sourceScopeHash);
scope.setParsedPayloadJson(writeJson(parsedPayloadPointer, "保存解析载荷指针失败"));
scope.setStateJson("{\"phase\":\"PARSED\"}");
@@ -184,11 +203,14 @@ public class AppearancePatentTaskService {
AppearancePatentParseVo vo = new AppearancePatentParseVo();
vo.setTaskId(task.getId());
vo.setSourceFilename(sourceFilename);
vo.setTotalRows(parsed.totalRows);
vo.setAcceptedRows(parsed.acceptedRows.size());
vo.setDroppedRows(parsed.droppedRows);
vo.setSourceFileCount(sourceFiles.size());
vo.setTotalRows(totalRows);
vo.setAcceptedRows(allRows.size());
vo.setDroppedRows(droppedRows);
vo.setGroupCount(groups.size());
vo.setAiPrompt(normalize(request.getAiPrompt()));
vo.setItems(parsed.acceptedRows);
vo.setItems(allRows);
vo.setGroups(groups);
return vo;
}
@@ -267,7 +289,6 @@ public class AppearancePatentTaskService {
public void submitResult(Long taskId, AppearancePatentSubmitResultRequest request) {
if (transactionManager != null) {
SubmitContext context = inNewTransaction(() -> persistSubmittedChunk(taskId, request));
flushPendingRowsIfNeeded(context.task(), context.forceFlush());
inNewTransaction(() -> {
completeSubmittedChunk(context);
return null;
@@ -295,7 +316,7 @@ public class AppearancePatentTaskService {
.eq(TaskChunkEntity::getChunkIndex, chunkIndex)
.last("limit 1"));
if (existing == null) {
List<AppearancePatentResultRowDto> rawRows = request.getItems() == null ? List.of() : request.getItems();
List<AppearancePatentResultRowDto> rawRows = flattenSubmittedRows(request);
String payloadJson = writeJson(rawRows, "结果序列化失败");
TaskChunkEntity chunk = new TaskChunkEntity();
@@ -310,27 +331,18 @@ public class AppearancePatentTaskService {
chunk.setPayloadHash(DigestUtil.sha256Hex(payloadJson));
chunk.setCreatedAt(LocalDateTime.now());
chunk.setUpdatedAt(LocalDateTime.now());
boolean inserted = true;
try {
taskChunkMapper.insert(chunk);
} catch (DuplicateKeyException ex) {
inserted = false;
// storeChunkPayload uses a deterministic key like chunk-{index}. When two
// concurrent callbacks submit the same chunk, deleting the loser payload here
// can also remove the winner's shared object and break later assembly.
log.info("[appearance-patent] duplicate chunk inserted concurrently taskId={} scope={} chunk={}", taskId, scopeKey, chunkIndex);
}
if (inserted) {
for (AppearancePatentResultRowDto rawRow : rawRows) {
taskCacheService.appendPendingRow(taskId, scopeHash, chunkIndex, rawRow);
}
}
} else {
log.info("[appearance-patent] duplicate chunk ignored taskId={} scope={} chunk={}", taskId, scopeKey, chunkIndex);
}
flushPendingRowsIfNeeded(task, Boolean.TRUE.equals(request.getDone()));
TaskScopeStateEntity scope = taskScopeStateMapper.selectOne(new LambdaQueryWrapper<TaskScopeStateEntity>()
.eq(TaskScopeStateEntity::getTaskId, taskId)
.eq(TaskScopeStateEntity::getModuleType, MODULE_TYPE)
@@ -350,7 +362,7 @@ public class AppearancePatentTaskService {
scope.setLastError(request.getError());
scope.setCompleted(Boolean.TRUE.equals(request.getDone()) ? 1 : 0);
scope.setUpdatedAt(LocalDateTime.now());
scope.setStateJson("{\"phase\":\"RECEIVED\",\"coze\":\"DONE\"}");
scope.setStateJson("{\"phase\":\"RECEIVED\",\"coze\":\"PENDING\"}");
if (scope.getId() == null) {
taskScopeStateMapper.insert(scope);
} else {
@@ -423,7 +435,6 @@ public class AppearancePatentTaskService {
if (heartbeatMillis > thresholdMillis) {
continue;
}
flushPendingRowsIfNeeded(task, true);
inNewTransaction(() -> {
finalizeStaleTask(task.getId(), "Python interrupted before uploading final appearance patent result");
return null;
@@ -443,7 +454,6 @@ public class AppearancePatentTaskService {
if (heartbeatMillis > thresholdMillis) {
continue;
}
flushPendingRowsIfNeeded(task, true);
finalizeTask(task, "Python interrupted before uploading final appearance patent result", allRowCount(task), true);
}
}
@@ -471,7 +481,7 @@ public class AppearancePatentTaskService {
.eq(TaskChunkEntity::getChunkIndex, chunkIndex)
.last("limit 1"));
if (existing == null) {
List<AppearancePatentResultRowDto> rawRows = request.getItems() == null ? List.of() : request.getItems();
List<AppearancePatentResultRowDto> rawRows = flattenSubmittedRows(request);
String payloadJson = writeJson(rawRows, "缁撴灉搴忓垪鍖栧け璐?");
TaskChunkEntity chunk = new TaskChunkEntity();
@@ -486,18 +496,11 @@ public class AppearancePatentTaskService {
chunk.setPayloadHash(DigestUtil.sha256Hex(payloadJson));
chunk.setCreatedAt(LocalDateTime.now());
chunk.setUpdatedAt(LocalDateTime.now());
boolean inserted = true;
try {
taskChunkMapper.insert(chunk);
} catch (DuplicateKeyException ex) {
inserted = false;
log.info("[appearance-patent] duplicate chunk inserted concurrently taskId={} scope={} chunk={}", taskId, scopeKey, chunkIndex);
}
if (inserted) {
for (AppearancePatentResultRowDto rawRow : rawRows) {
taskCacheService.appendPendingRow(taskId, scopeHash, chunkIndex, rawRow);
}
}
} else {
log.info("[appearance-patent] duplicate chunk ignored taskId={} scope={} chunk={}", taskId, scopeKey, chunkIndex);
}
@@ -518,7 +521,7 @@ public class AppearancePatentTaskService {
task.getId(), task.getStatus());
return;
}
upsertScopeState(task.getId(), context.scopeKey(), context.scopeHash(), null, context.error(), context.forceFlush(), true);
upsertScopeState(task.getId(), context.scopeKey(), context.scopeHash(), null, context.error(), context.forceFlush(), false);
if (context.forceFlush() || context.error() != null && !context.error().isBlank()) {
finalizeTask(task, context.error(), allRowCount(task), true);
return;
@@ -606,6 +609,12 @@ public class AppearancePatentTaskService {
}
private List<AppearancePatentResultRowDto> applyCozeInBatches(List<AppearancePatentResultRowDto> items, FileTaskEntity task) {
return applyCozeInBatches(items, task, null);
}
private List<AppearancePatentResultRowDto> applyCozeInBatches(List<AppearancePatentResultRowDto> items,
FileTaskEntity task,
Runnable progressHook) {
if (items == null || items.isEmpty()) {
return List.of();
}
@@ -613,46 +622,46 @@ public class AppearancePatentTaskService {
int batchSize = Math.max(1, properties.getCozeBatchSize());
List<AppearancePatentResultRowDto> result = new ArrayList<>();
for (int i = 0; i < items.size(); i += batchSize) {
if (progressHook != null) {
progressHook.run();
}
result.addAll(cozeClient.inspect(items.subList(i, Math.min(i + batchSize, items.size())), prompt));
}
return result;
}
private void flushPendingRowsIfNeeded(FileTaskEntity task, boolean force) {
int batchSize = Math.max(1, properties.getCozeBatchSize());
private void applyCozeToPersistedChunks(FileTaskEntity task, Runnable progressHook) {
if (task == null || task.getId() == null) {
return;
}
Map<String, List<AppearancePatentParsedRowVo>> allRowsByBaseId = loadAllRowsByBaseId(task);
String prompt = readAiPrompt(task);
while (force ? taskCacheService.pendingRowCount(task.getId()) > 0 : taskCacheService.pendingRowCount(task.getId()) >= batchSize) {
int drainSize = force ? batchSize : Math.max(1, batchSize);
List<AppearancePatentTaskCacheService.PendingRow> pendingRows = taskCacheService.drainPendingRows(task.getId(), drainSize);
if (pendingRows.isEmpty()) {
return;
List<TaskChunkEntity> chunks = taskChunkMapper.selectList(new LambdaQueryWrapper<TaskChunkEntity>()
.eq(TaskChunkEntity::getTaskId, task.getId())
.eq(TaskChunkEntity::getModuleType, MODULE_TYPE)
.orderByAsc(TaskChunkEntity::getChunkIndex));
log.info("[appearance-patent] async coze start taskId={} chunks={}", task.getId(), chunks.size());
for (TaskChunkEntity chunk : chunks) {
if (progressHook != null) {
progressHook.run();
}
List<AppearancePatentResultRowDto> rawRows = pendingRows.stream()
.map(AppearancePatentTaskCacheService.PendingRow::row)
.toList();
List<AppearancePatentResultRowDto> cozeRows = cozeClient.inspect(rawRows, prompt);
Map<String, Map<String, AppearancePatentResultRowDto>> chunkRows = new LinkedHashMap<>();
for (int i = 0; i < pendingRows.size(); i++) {
AppearancePatentTaskCacheService.PendingRow pending = pendingRows.get(i);
AppearancePatentResultRowDto resultRow = i < cozeRows.size() ? cozeRows.get(i) : pending.row();
List<AppearancePatentResultRowDto> expandedRows = expandRows(List.of(resultRow), allRowsByBaseId);
String chunkKey = pending.scopeHash() + "::" + pending.chunkIndex();
Map<String, AppearancePatentResultRowDto> mergedRows =
chunkRows.computeIfAbsent(chunkKey, key -> new LinkedHashMap<>());
for (AppearancePatentResultRowDto expandedRow : expandedRows) {
mergedRows.put(rowKey(expandedRow.getId(), expandedRow.getAsin(), expandedRow.getCountry()), expandedRow);
Map<String, AppearancePatentResultRowDto> persistedRows = readChunkRows(chunk);
if (persistedRows.isEmpty()) {
continue;
}
List<AppearancePatentResultRowDto> unresolvedRows = pickGroupRepresentativesForCoze(persistedRows.values());
if (unresolvedRows.isEmpty()) {
continue;
}
List<AppearancePatentResultRowDto> cozeRows = applyCozeInBatches(unresolvedRows, task, progressHook);
Map<String, AppearancePatentResultRowDto> mergedRows = new LinkedHashMap<>();
for (AppearancePatentResultRowDto resultRow : cozeRows) {
for (AppearancePatentResultRowDto expandedRow : expandRows(List.of(resultRow), allRowsByBaseId)) {
mergedRows.put(rowKey(expandedRow), expandedRow);
}
}
for (Map.Entry<String, Map<String, AppearancePatentResultRowDto>> entry : chunkRows.entrySet()) {
int delimiter = entry.getKey().lastIndexOf("::");
if (delimiter <= 0) {
continue;
}
String scopeHash = entry.getKey().substring(0, delimiter);
Integer chunkIndex = Integer.parseInt(entry.getKey().substring(delimiter + 2));
mergeChunkPayload(task.getId(), scopeHash, chunkIndex, new ArrayList<>(entry.getValue().values()));
}
mergeChunkPayload(task.getId(), chunk.getScopeHash(), chunk.getChunkIndex(), new ArrayList<>(mergedRows.values()));
log.info("[appearance-patent] async coze chunk merged taskId={} chunk={} unresolved={} merged={}",
task.getId(), chunk.getChunkIndex(), unresolvedRows.size(), mergedRows.size());
}
}
@@ -668,7 +677,7 @@ public class AppearancePatentTaskService {
}
Map<String, AppearancePatentResultRowDto> persistedRows = readChunkRows(chunk);
for (AppearancePatentResultRowDto row : rows) {
persistedRows.put(rowKey(row.getId(), row.getAsin(), row.getCountry()), row);
persistedRows.put(rowKey(row), row);
}
String payloadJson = writeJson(rows == null ? List.of() : rows, "结果序列化失败");
payloadJson = writeJson(new ArrayList<>(persistedRows.values()), "appearance patent chunk payload merge failed");
@@ -694,7 +703,7 @@ public class AppearancePatentTaskService {
continue;
}
for (AppearancePatentParsedRowVo sibling : siblings) {
String key = sibling.getDisplayId() + "::" + sibling.getAsin() + "::" + sibling.getCountry();
String key = rowKey(sibling);
if (!emittedKeys.add(key)) {
continue;
}
@@ -709,11 +718,16 @@ public class AppearancePatentTaskService {
if (representative == null || groupedRows == null || groupedRows.isEmpty()) {
return List.of();
}
String representativeKey = rowKey(representative.getId(), representative.getAsin(), representative.getCountry());
String representativeGroupKey = normalize(representative.getGroupKey());
if (!representativeGroupKey.isBlank()) {
return groupedRows.getOrDefault(representativeGroupKey, List.of());
}
String representativeKey = rowKey(representative);
String representativeLegacyKey = legacyRowKey(representative);
for (List<AppearancePatentParsedRowVo> rows : groupedRows.values()) {
for (AppearancePatentParsedRowVo row : rows) {
String rowKey = rowKey(row.getDisplayId(), row.getAsin(), row.getCountry());
if (Objects.equals(representativeKey, rowKey)) {
if (Objects.equals(representativeKey, rowKey(row))
|| Objects.equals(representativeLegacyKey, legacyRowKey(row))) {
return rows;
}
}
@@ -723,13 +737,76 @@ public class AppearancePatentTaskService {
private Map<String, List<AppearancePatentParsedRowVo>> loadAllRowsByBaseId(FileTaskEntity task) {
try {
return groupRowsByBaseId(readParsedPayload(task).getAllItems());
AppearancePatentParsedPayloadDto payload = readParsedPayload(task);
List<AppearancePatentParsedRowVo> rows = payload.getAllItems();
if (rows == null || rows.isEmpty()) {
rows = payload.getItems();
}
if ((rows == null || rows.isEmpty()) && payload.getGroups() != null) {
rows = payload.getGroups().stream()
.filter(Objects::nonNull)
.flatMap(group -> group.getItems() == null ? java.util.stream.Stream.empty() : group.getItems().stream())
.toList();
}
return groupRowsByBaseId(rows);
} catch (Exception ex) {
log.warn("[appearance-patent] read all rows failed taskId={} err={}", task.getId(), ex.getMessage());
return new LinkedHashMap<>();
}
}
private List<AppearancePatentResultRowDto> pickGroupRepresentativesForCoze(java.util.Collection<AppearancePatentResultRowDto> rows) {
Map<String, List<AppearancePatentResultRowDto>> groupedRows = new LinkedHashMap<>();
if (rows == null) {
return List.of();
}
for (AppearancePatentResultRowDto row : rows) {
if (row == null) {
continue;
}
String key = firstNonBlank(normalize(row.getGroupKey()), rowKey(row));
groupedRows.computeIfAbsent(key, ignored -> new ArrayList<>()).add(row);
}
List<AppearancePatentResultRowDto> representatives = new ArrayList<>();
for (List<AppearancePatentResultRowDto> siblings : groupedRows.values()) {
boolean alreadyResolved = siblings.stream().anyMatch(this::hasResolvedCozeFields);
if (alreadyResolved) {
continue;
}
AppearancePatentResultRowDto candidate = null;
for (AppearancePatentResultRowDto sibling : siblings) {
if (shouldInspectRow(sibling)) {
candidate = sibling;
break;
}
}
if (candidate != null) {
representatives.add(candidate);
}
}
return representatives;
}
private List<AppearancePatentParsedGroupVo> buildParsedGroups(List<AppearancePatentParsedRowVo> rows) {
List<AppearancePatentParsedGroupVo> groups = new ArrayList<>();
for (List<AppearancePatentParsedRowVo> siblings : groupRowsByBaseId(rows).values()) {
if (siblings == null || siblings.isEmpty()) {
continue;
}
AppearancePatentParsedRowVo first = siblings.getFirst();
AppearancePatentParsedGroupVo group = new AppearancePatentParsedGroupVo();
group.setSourceFileKey(first.getSourceFileKey());
group.setSourceFilename(first.getSourceFilename());
group.setGroupKey(firstNonBlank(first.getGroupKey(), buildGroupKey(first.getSourceFileKey(), baseId(first.getDisplayId()), first.getRowIndex())));
group.setBaseId(baseId(first.getDisplayId()));
group.setDisplayId(firstNonBlank(first.getDisplayId(), first.getSourceId()));
group.setItemCount(siblings.size());
group.setItems(new ArrayList<>(siblings));
groups.add(group);
}
return groups;
}
private Map<String, List<AppearancePatentParsedRowVo>> groupRowsByBaseId(List<AppearancePatentParsedRowVo> rows) {
Map<String, List<AppearancePatentParsedRowVo>> result = new LinkedHashMap<>();
if (rows == null) {
@@ -742,9 +819,55 @@ public class AppearancePatentTaskService {
return result;
}
private List<AppearancePatentResultRowDto> flattenSubmittedRows(AppearancePatentSubmitResultRequest request) {
if (request == null) {
return List.of();
}
List<AppearancePatentResultGroupDto> groups = request.getGroups();
if (groups != null && !groups.isEmpty()) {
List<AppearancePatentResultRowDto> rows = new ArrayList<>();
for (AppearancePatentResultGroupDto group : groups) {
if (group == null || group.getItems() == null || group.getItems().isEmpty()) {
continue;
}
for (AppearancePatentResultRowDto row : group.getItems()) {
if (row == null) {
continue;
}
if (normalize(row.getGroupKey()).isBlank()) {
row.setGroupKey(group.getGroupKey());
}
if (normalize(row.getSourceFileKey()).isBlank()) {
row.setSourceFileKey(group.getSourceFileKey());
}
if (normalize(row.getSourceFilename()).isBlank()) {
row.setSourceFilename(group.getSourceFilename());
}
rows.add(row);
}
}
return rows;
}
return request.getItems() == null ? List.of() : request.getItems();
}
private int allRowCount(FileTaskEntity task) {
try {
return readParsedPayload(task).getAllItems().size();
AppearancePatentParsedPayloadDto payload = readParsedPayload(task);
if (payload.getAllItems() != null && !payload.getAllItems().isEmpty()) {
return payload.getAllItems().size();
}
if (payload.getItems() != null && !payload.getItems().isEmpty()) {
return payload.getItems().size();
}
if (payload.getGroups() != null && !payload.getGroups().isEmpty()) {
return payload.getGroups().stream()
.map(AppearancePatentParsedGroupVo::getItems)
.filter(Objects::nonNull)
.mapToInt(List::size)
.sum();
}
return 0;
} catch (Exception ex) {
log.warn("[appearance-patent] read all row count failed taskId={} err={}", task.getId(), ex.getMessage());
return 0;
@@ -753,6 +876,10 @@ public class AppearancePatentTaskService {
private AppearancePatentResultRowDto copyResultToSibling(AppearancePatentResultRowDto representative, AppearancePatentParsedRowVo sibling) {
AppearancePatentResultRowDto row = new AppearancePatentResultRowDto();
row.setSourceFileKey(sibling.getSourceFileKey());
row.setSourceFilename(sibling.getSourceFilename());
row.setRowToken(sibling.getRowToken());
row.setGroupKey(sibling.getGroupKey());
row.setId(sibling.getDisplayId());
row.setAsin(sibling.getAsin());
row.setCountry(sibling.getCountry());
@@ -835,7 +962,11 @@ public class AppearancePatentTaskService {
if (result == null || !MODULE_TYPE.equals(result.getModuleType())) {
throw new BusinessException("结果记录不存在");
}
Runnable progressHook = () -> taskFileJobService.touchRunning(job.getId());
applyCozeToPersistedChunks(task, progressHook);
progressHook.run();
assembleResultWorkbook(task, result);
progressHook.run();
fileResultMapper.updateById(result);
}
@@ -908,7 +1039,7 @@ public class AppearancePatentTaskService {
int rowIndex = 1;
for (AppearancePatentParsedRowVo parsedRow : parsed.getAllItems()) {
AppearancePatentResultRowDto resultRow = resultMap.get(rowKey(parsedRow.getDisplayId(), parsedRow.getAsin(), parsedRow.getCountry()));
AppearancePatentResultRowDto resultRow = findResultRow(parsedRow, resultMap);
String missingReason = "";
if (resultRow == null) {
missingReason = hasPromptFields(parsedRow) ? "未匹配到检测结果" : "未送检:缺少标题或图片";
@@ -921,10 +1052,10 @@ public class AppearancePatentTaskService {
row.createCell(col++).setCellValue(readValueByHeader(parsedRow, "价格", "price"));
row.createCell(col++).setCellValue(resultRow == null ? firstNonBlank(parsedRow.getTitle(), "") : firstNonBlank(resultRow.getTitle(), parsedRow.getTitle()));
row.createCell(col++).setCellValue(resultRow == null ? firstNonBlank(parsedRow.getUrl(), "") : firstNonBlank(resultRow.getUrl(), parsedRow.getUrl()));
row.createCell(col++).setCellValue(resultRow == null ? missingReason : firstNonBlank(resultRow.getTitleRisk(), ""));
row.createCell(col++).setCellValue(resultRow == null ? missingReason : firstNonBlank(resultRow.getAppearanceRisk(), ""));
row.createCell(col++).setCellValue(resultRow == null ? missingReason : firstNonBlank(resultRow.getPatentRisk(), ""));
row.createCell(col).setCellValue(resultRow == null ? "未送检" : firstNonBlank(resultRow.getConclusion(), ""));
row.createCell(col++).setCellValue(resultRow == null ? missingReason : userFacingCozeCellValue(resultRow, resultRow.getTitleRisk()));
row.createCell(col++).setCellValue(resultRow == null ? missingReason : userFacingCozeCellValue(resultRow, resultRow.getAppearanceRisk()));
row.createCell(col++).setCellValue(resultRow == null ? missingReason : userFacingCozeCellValue(resultRow, resultRow.getPatentRisk()));
row.createCell(col).setCellValue(resultRow == null ? "未送检" : userFacingConclusion(resultRow));
}
workbook.write(fos);
workbook.dispose();
@@ -933,7 +1064,7 @@ public class AppearancePatentTaskService {
}
}
private ParsedWorkbook parseWorkbook(File input) {
private ParsedWorkbook parseWorkbook(File input, AppearancePatentSourceFileDto source) {
DataFormatter formatter = new DataFormatter();
try (FileInputStream fis = new FileInputStream(input); Workbook workbook = WorkbookFactory.create(fis)) {
Sheet sheet = workbook.getSheetAt(0);
@@ -952,7 +1083,6 @@ public class AppearancePatentTaskService {
int titleCol = findOptionalHeaderExact(headerMap,
"标题", "title", "listing title", "product title", "商品标题", "商品名称", "产品名称");
List<AppearancePatentParsedRowVo> accepted = new ArrayList<>();
List<AppearancePatentParsedRowVo> allRows = new ArrayList<>();
int total = 0;
int dropped = 0;
@@ -975,32 +1105,30 @@ public class AppearancePatentTaskService {
continue;
}
AppearancePatentParsedRowVo vo = new AppearancePatentParsedRowVo();
vo.setSourceFileKey(source.getFileKey());
vo.setSourceFilename(firstNonBlank(source.getOriginalFilename(), input.getName()));
vo.setRowIndex(i + 1);
vo.setSourceId(id);
vo.setDisplayId(normalizeDisplayId(id));
String rowBaseId = baseId(vo.getDisplayId());
if (!Objects.equals(currentBlockBaseId, rowBaseId)) {
currentBlockBaseId = rowBaseId;
currentGroupKey = rowBaseId + "@" + vo.getRowIndex();
currentGroupKey = buildGroupKey(source.getFileKey(), rowBaseId, vo.getRowIndex());
}
vo.setGroupKey(currentGroupKey);
vo.setRowToken(buildRowToken(source.getFileKey(), vo.getRowIndex()));
vo.setAsin(asin);
vo.setCountry(country);
vo.setUrl(urlCol >= 0 ? cell(row, urlCol, formatter) : "");
vo.setTitle(titleCol >= 0 ? cell(row, titleCol, formatter) : "");
vo.setValues(readRowValues(row, headers, formatter));
allRows.add(vo);
if (isAcceptedId(id)) {
accepted.add(vo);
} else {
dropped++;
}
}
hydratePromptFields(allRows);
if (accepted.isEmpty()) {
if (allRows.isEmpty()) {
throw new BusinessException("no valid appearance patent rows");
}
return new ParsedWorkbook(total, dropped, headers, accepted, allRows);
return new ParsedWorkbook(total, dropped, headers, allRows);
} catch (BusinessException ex) {
throw ex;
} catch (Exception ex) {
@@ -1036,6 +1164,25 @@ public class AppearancePatentTaskService {
return row != null && (!normalize(row.getTitle()).isBlank() || !normalize(row.getUrl()).isBlank());
}
private boolean shouldInspectRow(AppearancePatentResultRowDto row) {
return row != null && (!normalize(row.getTitle()).isBlank() || !normalize(row.getUrl()).isBlank());
}
private boolean hasResolvedCozeFields(AppearancePatentResultRowDto row) {
if (row == null) {
return false;
}
return hasUsableCozeField(row.getTitleRisk())
|| hasUsableCozeField(row.getAppearanceRisk())
|| hasUsableCozeField(row.getPatentRisk())
|| hasUsableCozeField(row.getConclusion());
}
private boolean hasUsableCozeField(String value) {
String normalized = normalize(value);
return !normalized.isBlank() && !isTechnicalCozeFailure(normalized);
}
private Map<String, Integer> buildHeaderMap(Row header, DataFormatter formatter) {
Map<String, Integer> map = new LinkedHashMap<>();
for (int i = 0; i < header.getLastCellNum(); i++) {
@@ -1104,11 +1251,6 @@ public class AppearancePatentTaskService {
return col < 0 ? "" : normalize(formatter.formatCellValue(row.getCell(col)));
}
private boolean isAcceptedId(String id) {
String s = id.trim();
return s.matches("\\d+") || s.matches("\\d+_1");
}
private String baseId(String id) {
String s = normalize(id);
int idx = s.indexOf('_');
@@ -1119,6 +1261,59 @@ public class AppearancePatentTaskService {
return id == null ? "" : id.trim();
}
private void mergeHeaders(List<String> mergedHeaders, List<String> headers) {
if (mergedHeaders == null || headers == null || headers.isEmpty()) {
return;
}
Set<String> existing = new LinkedHashSet<>(mergedHeaders);
for (String header : headers) {
if (existing.add(header)) {
mergedHeaders.add(header);
}
}
}
private String buildAggregateScopeKey(List<AppearancePatentSourceFileDto> sourceFiles) {
List<String> fileKeys = sourceFiles == null ? List.of() : sourceFiles.stream()
.map(AppearancePatentSourceFileDto::getFileKey)
.filter(Objects::nonNull)
.map(String::trim)
.filter(value -> !value.isBlank())
.toList();
if (fileKeys.isEmpty()) {
return "";
}
if (fileKeys.size() == 1) {
return fileKeys.get(0);
}
return "aggregate:" + DigestUtil.sha256Hex(String.join("|", fileKeys));
}
private String buildAggregateSourceFilenameLabel(List<AppearancePatentSourceFileDto> sourceFiles) {
if (sourceFiles == null || sourceFiles.isEmpty()) {
return "appearance-patent";
}
String firstFilename = sourceFiles.stream()
.map(AppearancePatentSourceFileDto::getOriginalFilename)
.filter(Objects::nonNull)
.map(String::trim)
.filter(value -> !value.isBlank())
.findFirst()
.orElse("appearance-patent");
if (sourceFiles.size() == 1) {
return firstFilename;
}
return firstFilename + "" + sourceFiles.size() + " 个文件";
}
private String buildRowToken(String sourceFileKey, Integer rowIndex) {
return normalize(sourceFileKey) + "::row::" + (rowIndex == null ? 0 : rowIndex);
}
private String buildGroupKey(String sourceFileKey, String rowBaseId, Integer rowIndex) {
return normalize(sourceFileKey) + "::" + normalize(rowBaseId) + "@" + (rowIndex == null ? 0 : rowIndex);
}
private long countTask(Long userId, String status) {
Long count = fileTaskMapper.selectCount(new LambdaQueryWrapper<FileTaskEntity>()
.eq(FileTaskEntity::getModuleType, MODULE_TYPE)
@@ -1187,19 +1382,24 @@ public class AppearancePatentTaskService {
return val == null ? "" : val.replace("\ufeff", "").replace("\u3000", " ").trim().replaceAll("\\s+", " ");
}
private String buildParsedPayloadJson(String aiPrompt, List<String> headers, List<AppearancePatentParsedRowVo> rows, List<AppearancePatentParsedRowVo> allRows) {
private String buildParsedPayloadJson(String aiPrompt, List<AppearancePatentSourceFileDto> sourceFiles, List<String> headers, List<AppearancePatentParsedGroupVo> groups, List<AppearancePatentParsedRowVo> allRows) {
AppearancePatentParsedPayloadDto payload = new AppearancePatentParsedPayloadDto();
payload.setAiPrompt(normalize(aiPrompt));
payload.setSourceFiles(sourceFiles == null ? List.of() : sourceFiles);
payload.setHeaders(headers == null ? List.of() : headers);
payload.setItems(rows == null ? List.of() : rows);
payload.setItems(allRows == null ? List.of() : allRows);
payload.setGroups(groups == null ? List.of() : groups);
payload.setAllItems(allRows == null ? List.of() : allRows);
return writeJson(payload, "保存解析结果失败");
}
private String buildTaskResultJson(String aiPrompt, String sourceFileKey, String parsedPayloadPointer) {
private String buildTaskResultJson(String aiPrompt, List<AppearancePatentSourceFileDto> sourceFiles, String parsedPayloadPointer) {
Map<String, Object> payload = new LinkedHashMap<>();
payload.put("aiPrompt", normalize(aiPrompt));
payload.put("sourceFileKey", sourceFileKey);
payload.put("sourceFileKeys", sourceFiles == null ? List.of() : sourceFiles.stream()
.map(AppearancePatentSourceFileDto::getFileKey)
.filter(Objects::nonNull)
.toList());
payload.put("parsedPayloadRef", parsedPayloadPointer);
return writeJson(payload, "保存任务结果索引失败");
}
@@ -1238,7 +1438,7 @@ public class AppearancePatentTaskService {
}
for (JsonNode node : array) {
AppearancePatentResultRowDto row = objectMapper.treeToValue(node, AppearancePatentResultRowDto.class);
rows.put(rowKey(row.getId(), row.getAsin(), row.getCountry()), row);
rows.put(rowKey(row), row);
}
} catch (Exception ex) {
log.warn("[appearance-patent] read chunk payload failed taskId={} chunk={} err={}",
@@ -1247,6 +1447,58 @@ public class AppearancePatentTaskService {
return rows;
}
private String rowKey(AppearancePatentParsedRowVo row) {
if (row == null) {
return "";
}
String rowToken = normalize(row.getRowToken());
if (!rowToken.isBlank()) {
return rowToken;
}
return legacyRowKey(row);
}
private String rowKey(AppearancePatentResultRowDto row) {
if (row == null) {
return "";
}
String rowToken = normalize(row.getRowToken());
if (!rowToken.isBlank()) {
return rowToken;
}
return legacyRowKey(row);
}
private AppearancePatentResultRowDto findResultRow(AppearancePatentParsedRowVo parsedRow,
Map<String, AppearancePatentResultRowDto> resultMap) {
if (parsedRow == null || resultMap == null || resultMap.isEmpty()) {
return null;
}
AppearancePatentResultRowDto resultRow = resultMap.get(rowKey(parsedRow));
if (resultRow != null) {
return resultRow;
}
String legacyKey = legacyRowKey(parsedRow);
if (legacyKey.isBlank()) {
return null;
}
return resultMap.get(legacyKey);
}
private String legacyRowKey(AppearancePatentParsedRowVo row) {
if (row == null) {
return "";
}
return rowKey(row.getDisplayId(), row.getAsin(), row.getCountry());
}
private String legacyRowKey(AppearancePatentResultRowDto row) {
if (row == null) {
return "";
}
return rowKey(row.getId(), row.getAsin(), row.getCountry());
}
private String rowKey(String id, String asin, String country) {
return normalize(id) + "::" + normalize(asin).toUpperCase(Locale.ROOT) + "::" + normalize(country);
}
@@ -1267,6 +1519,40 @@ public class AppearancePatentTaskService {
return "";
}
private String userFacingCozeCellValue(AppearancePatentResultRowDto row, String value) {
String normalizedValue = normalize(value);
if (!normalizedValue.isBlank() && !isTechnicalCozeFailure(normalizedValue)) {
return value;
}
if (row != null && isTechnicalCozeFailure(row.getError())) {
return "待人工复核";
}
return firstNonBlank(value, "");
}
private String userFacingConclusion(AppearancePatentResultRowDto row) {
if (row == null) {
return "";
}
String conclusion = normalize(row.getConclusion());
if (!conclusion.isBlank() && !isTechnicalCozeFailure(conclusion)) {
return row.getConclusion();
}
if (isTechnicalCozeFailure(row.getError())) {
return "待人工复核";
}
return firstNonBlank(row.getConclusion(), "");
}
private boolean isTechnicalCozeFailure(String value) {
String normalized = normalize(value).toLowerCase(Locale.ROOT);
return normalized.contains("coze")
|| normalized.contains("结果不完整")
|| normalized.contains("工作流节点执行超限")
|| normalized.contains("调用超时")
|| normalized.contains("timeout");
}
private String safeFileStem(String filename) {
String name = filename == null || filename.isBlank() ? "appearance-patent" : filename;
int idx = name.lastIndexOf('.');
@@ -1317,6 +1603,6 @@ public class AppearancePatentTaskService {
String error) {
}
private record ParsedWorkbook(int totalRows, int droppedRows, List<String> headers, List<AppearancePatentParsedRowVo> acceptedRows, List<AppearancePatentParsedRowVo> allRows) {
private record ParsedWorkbook(int totalRows, int droppedRows, List<String> headers, List<AppearancePatentParsedRowVo> allRows) {
}
}

View File

@@ -81,13 +81,32 @@ public class ShopManageGroupService {
.map(ShopManageGroupEntity::getId)
.filter(id -> id != null && id > 0)
.collect(Collectors.toCollection(LinkedHashSet::new));
groupIds.addAll(groupMemberMapper.selectList(new LambdaQueryWrapper<ShopManageGroupMemberEntity>()
List<Long> memberGroupIds = groupMemberMapper.selectList(new LambdaQueryWrapper<ShopManageGroupMemberEntity>()
.select(ShopManageGroupMemberEntity::getGroupId)
.eq(ShopManageGroupMemberEntity::getUserId, normalizedOperatorId))
.stream()
.map(ShopManageGroupMemberEntity::getGroupId)
.filter(id -> id != null && id > 0)
.collect(Collectors.toCollection(LinkedHashSet::new)));
.toList();
groupIds.addAll(memberGroupIds);
if (!memberGroupIds.isEmpty()) {
LinkedHashSet<Long> leaderIds = groupMapper.selectList(new LambdaQueryWrapper<ShopManageGroupEntity>()
.select(ShopManageGroupEntity::getCreatedById)
.in(ShopManageGroupEntity::getId, memberGroupIds))
.stream()
.map(ShopManageGroupEntity::getCreatedById)
.filter(id -> id != null && id > 0)
.collect(Collectors.toCollection(LinkedHashSet::new));
if (!leaderIds.isEmpty()) {
groupIds.addAll(groupMapper.selectList(new LambdaQueryWrapper<ShopManageGroupEntity>()
.select(ShopManageGroupEntity::getId)
.in(ShopManageGroupEntity::getCreatedById, leaderIds))
.stream()
.map(ShopManageGroupEntity::getId)
.filter(id -> id != null && id > 0)
.toList());
}
}
return groupIds;
}
@@ -178,9 +197,7 @@ public class ShopManageGroupService {
return;
}
Long normalizedOperatorId = normalizePositiveId(operatorId, "操作人不能为空");
boolean createdBySelf = entity.getCreatedById() != null && entity.getCreatedById().equals(normalizedOperatorId);
boolean memberOfGroup = isGroupMember(entity.getId(), normalizedOperatorId);
if (!createdBySelf && !memberOfGroup) {
if (!listAccessibleGroupIds(normalizedOperatorId, false).contains(entity.getId())) {
throw new BusinessException("只能操作自己有权限的分组数据");
}
}

View File

@@ -158,6 +158,17 @@ public class TaskFileJobService {
.set(TaskFileJobEntity::getUpdatedAt, LocalDateTime.now())) > 0;
}
@Transactional
public void touchRunning(Long jobId) {
if (jobId == null || jobId <= 0) {
return;
}
taskFileJobMapper.update(null, new LambdaUpdateWrapper<TaskFileJobEntity>()
.eq(TaskFileJobEntity::getId, jobId)
.eq(TaskFileJobEntity::getStatus, "RUNNING")
.set(TaskFileJobEntity::getUpdatedAt, LocalDateTime.now()));
}
@Transactional
public void markSuccess(TaskFileJobEntity job, String resultFileUrl) {
taskFileJobMapper.update(null, new LambdaUpdateWrapper<TaskFileJobEntity>()

View File

@@ -134,7 +134,7 @@ aiimage:
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:}
coze-token: ${AIIMAGE_APPEARANCE_PATENT_COZE_TOKEN:Bearer sat_gsInckdsN0qqhnZSK7K4Zevw6neJCHmYHyIbm85oGgJIGEmSdkH5OFctSKLsAHvT}
coze-batch-size: ${AIIMAGE_APPEARANCE_PATENT_COZE_BATCH_SIZE:10}
coze-connect-timeout-millis: ${AIIMAGE_APPEARANCE_PATENT_COZE_CONNECT_TIMEOUT_MILLIS:10000}
coze-read-timeout-millis: ${AIIMAGE_APPEARANCE_PATENT_COZE_READ_TIMEOUT_MILLIS:60000}

View File

@@ -1441,6 +1441,77 @@ def delete_dedupe_total_data(item_id):
# ---------- 店铺管理 ----------
def _format_shop_manage_item(item):
return {
'id': item.get('id'),
'group_id': item.get('groupId'),
'group_name': item.get('groupName') or '',
'shop_name': item.get('shopName') or '',
'mall_name': item.get('mallName') or '',
'account': item.get('account') or '',
'password': item.get('passwordMasked') or '',
'created_at': (item.get('createdAt') or '').replace('T', ' ')[:16],
'updated_at': (item.get('updatedAt') or '').replace('T', ' ')[:16],
}
def _format_shop_manage_group_item(item):
return {
'id': item.get('id'),
'group_name': item.get('groupName') or '',
'leader_user_id': item.get('leaderUserId'),
'leader_username': item.get('leaderUsername') or '',
'member_user_ids': item.get('memberUserIds') or [],
'member_usernames': item.get('memberUsernames') or [],
'member_count': item.get('memberCount') or 0,
'user_id': item.get('userId'),
'username': item.get('username') or '',
'created_at': (item.get('createdAt') or '').replace('T', ' ')[:16],
'updated_at': (item.get('updatedAt') or '').replace('T', ' ')[:16],
}
def _load_expanded_shop_manage_groups(role, current_row):
params = {}
if current_row and current_row.get('id'):
params['operatorId'] = current_row.get('id')
params['superAdmin'] = 'true' if role == 'super_admin' else 'false'
result, error_response, status = _proxy_backend_java(
'GET',
'/api/admin/shop-manages/groups',
params=params,
)
if error_response is not None:
return None, error_response, status
direct_groups = result.get('data') or []
if role == 'super_admin':
return direct_groups, None, 200
direct_ids = {item.get('id') for item in direct_groups if item.get('id')}
leader_ids = {item.get('leaderUserId') for item in direct_groups if item.get('leaderUserId')}
if not leader_ids:
return direct_groups, None, 200
all_result, error_response, status = _proxy_backend_java(
'GET',
'/api/admin/shop-manages/groups',
params={'superAdmin': 'true'},
)
if error_response is not None:
return None, error_response, status
merged = []
seen_ids = set()
for item in all_result.get('data') or []:
item_id = item.get('id')
if item_id in direct_ids or item.get('leaderUserId') in leader_ids:
if item_id not in seen_ids:
merged.append(item)
seen_ids.add(item_id)
return merged, None, 200
@admin_api.route('/shop-manages')
@login_required
def list_shop_manages():
@@ -1452,6 +1523,59 @@ def list_shop_manages():
group_id_raw = (request.args.get('group_id') or '').strip()
shop_name = (request.args.get('shop_name') or '').strip()
if role != 'super_admin':
groups, error_response, status = _load_expanded_shop_manage_groups(role, current_row)
if error_response is not None:
return error_response, status
accessible_group_ids = [item.get('id') for item in (groups or []) if item.get('id')]
selected_group_id = None
if group_id_raw:
try:
selected_group_id = int(group_id_raw)
except (TypeError, ValueError):
selected_group_id = None
if selected_group_id and selected_group_id not in accessible_group_ids:
return jsonify({'success': True, 'items': [], 'total': 0, 'page': page, 'page_size': page_size})
query_group_ids = [selected_group_id] if selected_group_id else accessible_group_ids
all_items = []
for group_id in query_group_ids:
group_page = 1
while True:
group_params = {
'page': group_page,
'pageSize': 100,
'groupId': group_id,
'superAdmin': 'true',
}
if current_row and current_row.get('id'):
group_params['operatorId'] = current_row.get('id')
if shop_name:
group_params['shopName'] = shop_name
result, error_response, status = _proxy_backend_java(
'GET',
'/api/admin/shop-manages',
params=group_params,
)
if error_response is not None:
return error_response, status
payload = result.get('data') or {}
rows = payload.get('items') or []
all_items.extend(rows)
total = payload.get('total') or 0
if group_page * 100 >= total or not rows:
break
group_page += 1
all_items.sort(key=lambda item: item.get('id') or 0, reverse=True)
total = len(all_items)
offset = (page - 1) * page_size
return jsonify({
'success': True,
'items': [_format_shop_manage_item(item) for item in all_items[offset:offset + page_size]],
'total': total,
'page': page,
'page_size': page_size,
})
params = {'page': page, 'pageSize': page_size}
if current_row and current_row.get('id'):
params['operatorId'] = current_row.get('id')
@@ -1474,23 +1598,9 @@ def list_shop_manages():
if error_response is not None:
return error_response, status
payload = result.get('data') or {}
items = [
{
'id': item.get('id'),
'group_id': item.get('groupId'),
'group_name': item.get('groupName') or '',
'shop_name': item.get('shopName') or '',
'mall_name': item.get('mallName') or '',
'account': item.get('account') or '',
'password': item.get('passwordMasked') or '',
'created_at': (item.get('createdAt') or '').replace('T', ' ')[:16],
'updated_at': (item.get('updatedAt') or '').replace('T', ' ')[:16],
}
for item in (payload.get('items') or [])
]
return jsonify({
'success': True,
'items': items,
'items': [_format_shop_manage_item(item) 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,
@@ -1611,36 +1721,10 @@ def list_shop_manage_groups():
role, current_row, denied = _ensure_backend_menu_access('shop-manage', 'skip-price-asin', 'query-asin')
if denied:
return denied
params = {}
if current_row and current_row.get('id'):
params['operatorId'] = current_row.get('id')
params['superAdmin'] = 'true' if role == 'super_admin' else 'false'
if role != 'super_admin' and current_row and current_row.get('id'):
params['createdById'] = current_row.get('id')
result, error_response, status = _proxy_backend_java(
'GET',
'/api/admin/shop-manages/groups',
params=params,
)
groups, error_response, status = _load_expanded_shop_manage_groups(role, current_row)
if error_response is not None:
return error_response, status
items = [
{
'id': item.get('id'),
'group_name': item.get('groupName') or '',
'leader_user_id': item.get('leaderUserId'),
'leader_username': item.get('leaderUsername') or '',
'member_user_ids': item.get('memberUserIds') or [],
'member_usernames': item.get('memberUsernames') or [],
'member_count': item.get('memberCount') or 0,
'user_id': item.get('userId'),
'username': item.get('username') or '',
'created_at': (item.get('createdAt') or '').replace('T', ' ')[:16],
'updated_at': (item.get('updatedAt') or '').replace('T', ' ')[:16],
}
for item in (result.get('data') or [])
]
return jsonify({'success': True, 'items': items})
return jsonify({'success': True, 'items': [_format_shop_manage_group_item(item) for item in (groups or [])]})
@admin_api.route('/shop-manage-group', methods=['POST'])

View File

@@ -23,8 +23,9 @@ 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://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('/')
os.environ['OSS_ACCESS_KEY_ID'] = accessKeyId
os.environ['OSS_ACCESS_KEY_SECRET'] = accessKeySecret
os.environ['SECRET_KEY'] = "ddffc7c1d02121d9554d7b080b2511b6"

View File

@@ -9,6 +9,7 @@
<div class="hint">选择 Excel 后点击解析后端会提取 idASIN国家并保留整数 id 与子数据的第一条</div>
<div class="btns">
<button type="button" class="opt-btn" @click="selectFiles">选择 Excel</button>
<button type="button" class="opt-btn" @click="selectFolder">选择文件夹</button>
</div>
<div class="selected-files">
<span v-if="!selectedFileNames.length">暂未选择文件</span>
@@ -35,7 +36,7 @@
<div v-if="parseResult" class="parse-card">
<div>任务 ID{{ parseResult.taskId }}</div>
<div>总行数{{ parseResult.totalRows }}保留{{ parseResult.acceptedRows }}过滤{{ parseResult.droppedRows }}</div>
<div>总行数{{ parseResult.totalRows }}有效{{ parseResult.acceptedRows }}分组{{ parseResult.groupCount || 0 }}过滤{{ parseResult.droppedRows }}</div>
</div>
<pre v-if="queuePayloadText" class="queue-payload">{{ queuePayloadText }}</pre>
</aside>
@@ -66,7 +67,7 @@
<div class="result-list-wrap preview-wrap">
<div class="result-list-header">
<span>解析结果</span>
<span v-if="parsedRows.length" class="muted">显示 1 样例 / {{ parsedRows.length }} </span>
<span v-if="parsedRows.length" class="muted">显示首组 {{ previewRows.length }} / {{ parseResult?.groupCount || parsedGroups.length }} {{ parsedRows.length }} </span>
</div>
<div v-if="!parsedRows.length" class="empty-tasks">暂无解析结果</div>
<el-table v-else :data="previewRows" height="120" class="result-table">
@@ -111,10 +112,11 @@
<div v-if="item.resultFilename" class="files">
{{ item.resultFilename || '下载结果' }}
</div>
<div v-if="pendingResultHint(item)" class="files result-hint">{{ pendingResultHint(item) }}</div>
<div v-if="item.error" class="files">错误{{ item.error }}</div>
</div>
<div class="task-right">
<span class="status" :class="item.success ? 'success' : 'failed'">{{ statusText(item) }}</span>
<span class="status" :class="statusClass(item)">{{ statusText(item) }}</span>
<button v-if="canDownload(item)" type="button" class="download" @click="downloadResult(item)">下载</button>
<button v-if="item.resultId" type="button" class="btn-delete" @click="deleteTaskRecord(item)">删除</button>
</div>
@@ -140,6 +142,7 @@ import {
getAppearancePatentResultDownloadUrl,
getAppearancePatentTaskProgressBatch,
parseAppearancePatent,
type AppearancePatentParsedGroup,
type AppearancePatentDashboardVo,
type AppearancePatentHistoryItem,
type AppearancePatentParsedRow,
@@ -147,13 +150,14 @@ import {
type UploadedFileRef,
type UploadFileVo,
} from '@/shared/api/java-modules'
import { expandBrandFolderRecursive, type BrandExpandFolderItem } from '@/shared/api/brand'
import { getPywebviewApi } from '@/shared/bridges/pywebview'
import { getTaskPollIntervalMs } from '@/shared/task-progress-config'
const selectedFileNames = ref<string[]>([])
const uploadedFiles = ref<UploadFileVo[]>([])
const parseResult = ref<AppearancePatentParseVo | null>(null)
const parsedRows = ref<AppearancePatentParsedRow[]>([])
const parsedGroups = ref<AppearancePatentParsedGroup[]>([])
const defaultAiPrompt = `请帮我排查以下亚马逊商品在英国及欧洲地区是否存在知识产权侵权风险。
请直接给出明确结论(有侵权风险 或 未发现明显侵权风险),并严格按照以下三个维度提供精简的排查理由:
外观维度: 评估产品外形、图案设计是否与欧洲/英国常见外观专利雷同。
@@ -176,7 +180,10 @@ const dashboard = ref<AppearancePatentDashboardVo>({
})
const historyItems = ref<AppearancePatentHistoryItem[]>([])
const previewRows = computed(() => parsedRows.value.slice(0, 1))
const parsedRows = computed<AppearancePatentParsedRow[]>(() =>
parsedGroups.value.flatMap((group) => group.items || []),
)
const previewRows = computed(() => parsedGroups.value[0]?.items || [])
const currentItems = computed(() => historyItems.value.filter((i) => i.taskId != null && pollingTaskIds.value.includes(i.taskId)))
const historyOnlyItems = computed(() => historyItems.value.filter((i) => (i.taskStatus || '') !== 'RUNNING'))
@@ -204,6 +211,24 @@ function clearPollingIds() {
stopPolling()
}
async function uploadAppearancePathsToJava(paths: Array<string | BrandExpandFolderItem>) {
const api = getPywebviewApi()
if (!api?.upload_file_to_java) {
throw new Error('当前桌面端未提供文件上传能力')
}
const files: UploadFileVo[] = []
for (const item of paths) {
const filePath = typeof item === 'string' ? item : item.absolutePath
const relativePath = typeof item === 'string' ? undefined : item.relativePath
const uploaded = await api.upload_file_to_java(filePath, relativePath)
if (!uploaded?.success || !uploaded.data) {
throw new Error(uploaded?.error || uploaded?.message || `上传失败:${filePath}`)
}
files.push(uploaded.data)
}
return files
}
async function selectFiles() {
const api = getPywebviewApi()
if (!api?.select_brand_xlsx_files || !api.upload_file_to_java) {
@@ -212,21 +237,40 @@ async function selectFiles() {
}
const paths = await api.select_brand_xlsx_files()
if (!paths?.length) return
const files: UploadFileVo[] = []
for (const path of paths) {
const uploaded = await api.upload_file_to_java(path)
if (!uploaded?.success || !uploaded.data) {
throw new Error(uploaded?.error || `上传失败:${path}`)
}
files.push(uploaded.data)
}
const files = await uploadAppearancePathsToJava(paths)
uploadedFiles.value = files
selectedFileNames.value = files.map((f) => f.originalFilename || f.fileKey)
selectedFileNames.value = files.map((f) => f.relativePath || f.originalFilename || f.fileKey)
parseResult.value = null
parsedRows.value = []
parsedGroups.value = []
queuePayloadText.value = ''
}
async function selectFolder() {
const api = getPywebviewApi()
if (!api?.select_brand_folder) {
ElMessage.warning('当前环境不支持文件夹选择,请在本地客户端中打开')
return
}
try {
const folder = await api.select_brand_folder()
if (!folder) return
const result = await expandBrandFolderRecursive(folder)
if (!result.success || !result.items?.length) {
ElMessage.warning(result.error || '该文件夹下没有可用的 xlsx 文件')
return
}
const files = await uploadAppearancePathsToJava(result.items)
uploadedFiles.value = files
selectedFileNames.value = result.items.map((item) => item.relativePath || item.absolutePath)
parseResult.value = null
parsedGroups.value = []
queuePayloadText.value = ''
ElMessage.success(`已选择文件夹内 ${result.items.length} 个 Excel 文件`)
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '选择失败')
}
}
async function parseFiles() {
if (!uploadedFiles.value.length) {
ElMessage.warning('请先选择 Excel')
@@ -241,11 +285,23 @@ async function parseFiles() {
}))
const res = await parseAppearancePatent(files, effectiveAiPrompt())
parseResult.value = res
parsedRows.value = res.items || []
parsedGroups.value = res.groups?.length
? res.groups
: (res.items?.length
? [{
sourceFileKey: res.items[0]?.sourceFileKey,
sourceFilename: res.items[0]?.sourceFilename,
groupKey: res.items[0]?.groupKey,
baseId: '',
displayId: res.items[0]?.displayId,
itemCount: res.items.length,
items: res.items,
}]
: [])
queuePayloadText.value = ''
await loadDashboard()
await loadHistory()
ElMessage.success(`解析完成,保留 ${res.acceptedRows}`)
ElMessage.success(`解析完成,${res.groupCount || 0} 组 / ${res.acceptedRows}`)
} catch (e) {
ElMessage.error(e instanceof Error ? e.message : '解析失败')
} finally {
@@ -272,7 +328,29 @@ async function pushToPythonQueue() {
data: {
taskId,
prompt: parseResult.value?.aiPrompt || effectiveAiPrompt(),
groups: parsedGroups.value.map((group) => ({
sourceFileKey: group.sourceFileKey || '',
sourceFilename: group.sourceFilename || '',
groupKey: group.groupKey || '',
baseId: group.baseId || '',
displayId: group.displayId || '',
items: (group.items || []).map((row) => ({
sourceFileKey: row.sourceFileKey || '',
sourceFilename: row.sourceFilename || '',
rowToken: row.rowToken || '',
groupKey: row.groupKey || '',
id: row.displayId,
asin: row.asin,
country: row.country,
url: row.url || '',
title: row.title || '',
})),
})),
rows: parsedRows.value.map((row) => ({
sourceFileKey: row.sourceFileKey || '',
sourceFilename: row.sourceFilename || '',
rowToken: row.rowToken || '',
groupKey: row.groupKey || '',
id: row.displayId,
asin: row.asin,
country: row.country,
@@ -381,11 +459,49 @@ async function loadHistory() {
function statusText(item: AppearancePatentHistoryItem) {
const status = item.taskStatus || ''
if (status === 'RUNNING') return '执行中'
if (isResultPreparing(item)) return '结果生成中'
if (isResultBuildFailed(item)) return '结果生成失败'
if (status === 'SUCCESS' || item.success) return '已完成'
if (status === 'FAILED') return '失败'
return '等待中'
}
function statusClass(item: AppearancePatentHistoryItem) {
const status = item.taskStatus || ''
if (status === 'RUNNING' || isResultPreparing(item)) return 'running'
if (isResultBuildFailed(item) || status === 'FAILED') return 'failed'
if (status === 'SUCCESS' || item.success) return 'success'
return 'running'
}
function isResultPreparing(item: AppearancePatentHistoryItem) {
const status = item.taskStatus || ''
const fileStatus = (item.fileStatus || '').toUpperCase()
if (!(status === 'SUCCESS' || item.success)) return false
if (canDownload(item)) return false
if (fileStatus === 'FAILED' || fileStatus === 'SUCCESS') return false
return true
}
function isResultBuildFailed(item: AppearancePatentHistoryItem) {
return (item.fileStatus || '').toUpperCase() === 'FAILED'
}
function pendingResultHint(item: AppearancePatentHistoryItem) {
if (isResultBuildFailed(item)) {
return item.fileError || '结果文件生成失败,请稍后重试或检查后端日志'
}
if (!isResultPreparing(item)) return ''
const fileStatus = (item.fileStatus || '').toUpperCase()
if (fileStatus === 'RUNNING') {
return '任务已完成,正在请求 Coze 或组装结果文件,下载按钮稍后出现'
}
if (fileStatus === 'PENDING') {
return '任务已完成,结果文件已入队,正在等待后端生成'
}
return '任务已完成,正在生成结果文件,下载按钮稍后出现'
}
function canDownload(item: AppearancePatentHistoryItem) {
return Boolean(item.resultId && (item.fileReady || item.downloadUrl))
}
@@ -445,7 +561,7 @@ onUnmounted(() => stopPolling())
.link { color: #6ea8fe; text-decoration: none; }
.link:hover { color: #9fc5ff; }
.btns, .run-row { display: flex; gap: 10px; flex-wrap: wrap; }
.opt-btn, .btn-run, .btn-delete { border: none; cursor: pointer; border-radius: 7px; }
.opt-btn, .btn-run, .btn-delete, .download { border: none; cursor: pointer; border-radius: 7px; }
.opt-btn { padding: 8px 14px; color: #ccc; background: #2a2a2a; border: 1px solid #3a3a3a; }
.btn-run { padding: 10px 18px; color: #fff; background: #3498db; font-weight: 600; }
.btn-queue { background: #27ae60; }
@@ -478,6 +594,8 @@ onUnmounted(() => stopPolling())
.status.success { background: rgba(46, 204, 113, .18); color: #2ecc71; }
.status.failed { background: rgba(231, 76, 60, .18); color: #ff6b6b; }
.status.running { background: rgba(52, 152, 219, .18); color: #3498db; }
.result-hint { margin-top: 6px; color: #e0b96d; }
.download { padding: 6px 10px; color: #d6ecff; background: rgba(52, 152, 219, .18); }
.btn-delete { padding: 6px 10px; color: #ff8f8f; background: rgba(231, 76, 60, .12); }
@media (max-width: 1100px) {
.main-content { flex-direction: column; height: auto; }

View File

@@ -1390,22 +1390,39 @@ export function deleteQueryAsinHistory(resultId: number) {
export interface AppearancePatentParsedRow {
rowIndex: number;
sourceFileKey?: string;
sourceFilename?: string;
sourceId: string;
displayId: string;
rowToken?: string;
groupKey?: string;
asin: string;
country: string;
url?: string;
title?: string;
}
export interface AppearancePatentParsedGroup {
sourceFileKey?: string;
sourceFilename?: string;
groupKey?: string;
baseId?: string;
displayId?: string;
itemCount?: number;
items: AppearancePatentParsedRow[];
}
export interface AppearancePatentParseVo {
taskId: number;
sourceFilename?: string;
sourceFileCount?: number;
totalRows: number;
acceptedRows: number;
droppedRows: number;
groupCount?: number;
aiPrompt?: string;
items: AppearancePatentParsedRow[];
groups?: AppearancePatentParsedGroup[];
}
export interface AppearancePatentDashboardVo {