python端登录完成

This commit is contained in:
super
2026-05-27 12:41:59 +08:00
parent 73ac9187a6
commit ca4a2cd07a
13 changed files with 170 additions and 89 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1 +0,0 @@
.module-page[data-v-49f8fcb3]{min-height:100vh;background:#1a1a1a}.main-content[data-v-49f8fcb3]{display:flex;height:calc(100vh - 56px);min-height:calc(100vh - 56px)}.left-panel[data-v-49f8fcb3]{width:400px;background:#1e1e1e;padding:20px;overflow-y:auto;border-right:1px solid #2a2a2a}.right-panel[data-v-49f8fcb3]{flex:1;min-width:0;background:#1a1a1a;display:flex;flex-direction:column}.section-title[data-v-49f8fcb3],.subsection-title[data-v-49f8fcb3]{font-size:13px;color:#bbb;margin-bottom:10px}.upload-zone[data-v-49f8fcb3]{border:1px dashed #3a3a3a;border-radius:10px;padding:18px;background:#252525;margin-bottom:18px}.hint[data-v-49f8fcb3],.loading-msg[data-v-49f8fcb3],.files[data-v-49f8fcb3],.muted[data-v-49f8fcb3]{color:#888;font-size:12px;line-height:1.5}.link[data-v-49f8fcb3]{color:#6ea8fe;text-decoration:none}.link[data-v-49f8fcb3]:hover{color:#9fc5ff}.btns[data-v-49f8fcb3],.run-row[data-v-49f8fcb3]{display:flex;gap:10px;flex-wrap:wrap}.opt-btn[data-v-49f8fcb3],.btn-run[data-v-49f8fcb3],.btn-delete[data-v-49f8fcb3],.download[data-v-49f8fcb3]{border:none;cursor:pointer;border-radius:7px}.opt-btn[data-v-49f8fcb3]{padding:8px 14px;color:#ccc;background:#2a2a2a;border:1px solid #3a3a3a}.btn-run[data-v-49f8fcb3]{padding:10px 18px;color:#fff;background:#3498db;font-weight:600}.btn-queue[data-v-49f8fcb3]{background:#27ae60}.btn-run[data-v-49f8fcb3]:disabled{opacity:.55;cursor:not-allowed}.selected-files[data-v-49f8fcb3]{margin-top:14px;color:#999;font-size:12px;word-break:break-all}.selected-files span[data-v-49f8fcb3]{display:block;margin:4px 0}.parse-card[data-v-49f8fcb3],.queue-payload[data-v-49f8fcb3]{margin-top:14px;padding:12px;border:1px solid #2a2a2a;border-radius:8px;background:#202020;color:#b8c1cc;font-size:12px}.queue-payload[data-v-49f8fcb3]{max-height:220px;overflow:auto;color:#8fd3ff;white-space:pre-wrap}.panel-header[data-v-49f8fcb3]{padding:16px 20px;border-bottom:1px solid #2a2a2a;font-size:15px;font-weight:600;color:#ddd}.task-list-wrap[data-v-49f8fcb3]{flex:1;padding:16px 20px;overflow:auto}.clean-result-summary[data-v-49f8fcb3]{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px;margin-bottom:16px}.summary-card[data-v-49f8fcb3]{padding:14px 16px;border:1px solid #2a2a2a;border-radius:8px;background:#1e1e1e}.summary-card strong[data-v-49f8fcb3]{display:block;margin-top:8px;color:#eaf4ff;font-size:22px}.summary-label[data-v-49f8fcb3]{color:#8d8d8d;font-size:12px}.result-list-wrap[data-v-49f8fcb3]{border:1px solid #2a2a2a;border-radius:8px;background:#1e1e1e;min-height:180px;margin:0 0 16px}.result-list-header[data-v-49f8fcb3]{display:flex;justify-content:space-between;padding:12px 16px;border-bottom:1px solid #2a2a2a;color:#ddd;font-size:14px}.empty-tasks[data-v-49f8fcb3]{color:#666;font-size:13px;padding:18px;text-align:center}.task-list[data-v-49f8fcb3]{list-style:none;margin:0;padding:12px}.task-item[data-v-49f8fcb3]{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;padding:12px 14px;border:1px solid #2a2a2a;border-radius:8px;margin-bottom:8px;background:#222}.left[data-v-49f8fcb3]{flex:1;min-width:0}.id[data-v-49f8fcb3]{color:#e0e0e0;font-size:13px;font-weight:600}.task-right[data-v-49f8fcb3]{display:flex;gap:8px;align-items:center;flex-wrap:wrap}.status[data-v-49f8fcb3]{padding:4px 10px;border-radius:6px;font-size:12px}.status.success[data-v-49f8fcb3]{background:#2ecc712e;color:#2ecc71}.status.failed[data-v-49f8fcb3]{background:#e74c3c2e;color:#ff6b6b}.status.running[data-v-49f8fcb3]{background:#3498db2e;color:#3498db}.status.pending[data-v-49f8fcb3]{background:#95a5a62e;color:#bdc3c7}.result-hint[data-v-49f8fcb3]{margin-top:6px;color:#e0b96d}.file-progress[data-v-49f8fcb3]{margin-top:8px;max-width:520px}.file-progress-meta[data-v-49f8fcb3]{display:flex;justify-content:space-between;gap:12px;color:#d8c278;font-size:12px}.file-progress-track[data-v-49f8fcb3]{margin-top:5px;height:8px;border-radius:999px;overflow:hidden;background:#303030;border:1px solid #3b3b3b}.file-progress-bar[data-v-49f8fcb3]{height:100%;border-radius:inherit;background:linear-gradient(90deg,#4aa3ff,#f0c75e);transition:width .25s ease}.file-progress-count[data-v-49f8fcb3]{margin-top:4px;color:#858585;font-size:11px}.download[data-v-49f8fcb3]{padding:6px 10px;color:#d6ecff;background:#3498db2e}.btn-delete[data-v-49f8fcb3]{padding:6px 10px;color:#ff8f8f;background:#e74c3c1f}@media(max-width:1100px){.main-content[data-v-49f8fcb3]{flex-direction:column;height:auto}.left-panel[data-v-49f8fcb3]{width:100%;border-right:none;border-bottom:1px solid #2a2a2a}.clean-result-summary[data-v-49f8fcb3]{grid-template-columns:repeat(2,minmax(0,1fr))}}

View File

@@ -0,0 +1 @@
.module-page[data-v-a27e815e]{min-height:100vh;background:#1a1a1a}.main-content[data-v-a27e815e]{display:flex;height:calc(100vh - 56px);min-height:calc(100vh - 56px)}.left-panel[data-v-a27e815e]{width:400px;background:#1e1e1e;padding:20px;overflow-y:auto;border-right:1px solid #2a2a2a}.right-panel[data-v-a27e815e]{flex:1;min-width:0;background:#1a1a1a;display:flex;flex-direction:column}.section-title[data-v-a27e815e],.subsection-title[data-v-a27e815e]{font-size:13px;color:#bbb;margin-bottom:10px}.upload-zone[data-v-a27e815e]{border:1px dashed #3a3a3a;border-radius:10px;padding:18px;background:#252525;margin-bottom:18px}.hint[data-v-a27e815e],.loading-msg[data-v-a27e815e],.files[data-v-a27e815e],.muted[data-v-a27e815e]{color:#888;font-size:12px;line-height:1.5}.link[data-v-a27e815e]{color:#6ea8fe;text-decoration:none}.link[data-v-a27e815e]:hover{color:#9fc5ff}.btns[data-v-a27e815e],.run-row[data-v-a27e815e]{display:flex;gap:10px;flex-wrap:wrap}.opt-btn[data-v-a27e815e],.btn-run[data-v-a27e815e],.btn-delete[data-v-a27e815e],.download[data-v-a27e815e]{border:none;cursor:pointer;border-radius:7px}.opt-btn[data-v-a27e815e]{padding:8px 14px;color:#ccc;background:#2a2a2a;border:1px solid #3a3a3a}.btn-run[data-v-a27e815e]{padding:10px 18px;color:#fff;background:#3498db;font-weight:600}.btn-queue[data-v-a27e815e]{background:#27ae60}.btn-run[data-v-a27e815e]:disabled{opacity:.55;cursor:not-allowed}.selected-files[data-v-a27e815e]{margin-top:14px;color:#999;font-size:12px;word-break:break-all}.selected-files span[data-v-a27e815e]{display:block;margin:4px 0}.parse-card[data-v-a27e815e],.queue-payload[data-v-a27e815e]{margin-top:14px;padding:12px;border:1px solid #2a2a2a;border-radius:8px;background:#202020;color:#b8c1cc;font-size:12px}.queue-payload[data-v-a27e815e]{max-height:220px;overflow:auto;color:#8fd3ff;white-space:pre-wrap}.panel-header[data-v-a27e815e]{padding:16px 20px;border-bottom:1px solid #2a2a2a;font-size:15px;font-weight:600;color:#ddd}.task-list-wrap[data-v-a27e815e]{flex:1;padding:16px 20px;overflow:auto}.clean-result-summary[data-v-a27e815e]{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px;margin-bottom:16px}.summary-card[data-v-a27e815e]{padding:14px 16px;border:1px solid #2a2a2a;border-radius:8px;background:#1e1e1e}.summary-card strong[data-v-a27e815e]{display:block;margin-top:8px;color:#eaf4ff;font-size:22px}.summary-label[data-v-a27e815e]{color:#8d8d8d;font-size:12px}.result-list-wrap[data-v-a27e815e]{border:1px solid #2a2a2a;border-radius:8px;background:#1e1e1e;min-height:180px;margin:0 0 16px}.result-list-header[data-v-a27e815e]{display:flex;justify-content:space-between;padding:12px 16px;border-bottom:1px solid #2a2a2a;color:#ddd;font-size:14px}.empty-tasks[data-v-a27e815e]{color:#666;font-size:13px;padding:18px;text-align:center}.task-list[data-v-a27e815e]{list-style:none;margin:0;padding:12px}.task-item[data-v-a27e815e]{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;padding:12px 14px;border:1px solid #2a2a2a;border-radius:8px;margin-bottom:8px;background:#222}.left[data-v-a27e815e]{flex:1;min-width:0}.id[data-v-a27e815e]{color:#e0e0e0;font-size:13px;font-weight:600}.task-right[data-v-a27e815e]{display:flex;gap:8px;align-items:center;flex-wrap:wrap}.status[data-v-a27e815e]{padding:4px 10px;border-radius:6px;font-size:12px}.status.success[data-v-a27e815e]{background:#2ecc712e;color:#2ecc71}.status.failed[data-v-a27e815e]{background:#e74c3c2e;color:#ff6b6b}.status.running[data-v-a27e815e]{background:#3498db2e;color:#3498db}.status.pending[data-v-a27e815e]{background:#95a5a62e;color:#bdc3c7}.result-hint[data-v-a27e815e]{margin-top:6px;color:#e0b96d}.file-progress[data-v-a27e815e]{margin-top:8px;max-width:520px}.file-progress-meta[data-v-a27e815e]{display:flex;justify-content:space-between;gap:12px;color:#d8c278;font-size:12px}.file-progress-track[data-v-a27e815e]{margin-top:5px;height:8px;border-radius:999px;overflow:hidden;background:#303030;border:1px solid #3b3b3b}.file-progress-bar[data-v-a27e815e]{height:100%;border-radius:inherit;background:linear-gradient(90deg,#4aa3ff,#f0c75e);transition:width .25s ease}.file-progress-count[data-v-a27e815e]{margin-top:4px;color:#858585;font-size:11px}.download[data-v-a27e815e]{padding:6px 10px;color:#d6ecff;background:#3498db2e}.btn-delete[data-v-a27e815e]{padding:6px 10px;color:#ff8f8f;background:#e74c3c1f}@media(max-width:1100px){.main-content[data-v-a27e815e]{flex-direction:column;height:auto}.left-panel[data-v-a27e815e]{width:100%;border-right:none;border-bottom:1px solid #2a2a2a}.clean-result-summary[data-v-a27e815e]{grid-template-columns:repeat(2,minmax(0,1fr))}}

File diff suppressed because one or more lines are too long

View File

@@ -4,6 +4,7 @@ JWT 工具:解析 Java 端签发的 token、从请求中提取 token。
"""
import os
import sys
import time
from typing import Optional, Tuple
from flask import request
@@ -25,6 +26,14 @@ except ImportError as _e:
_DEFAULT_SECRET = "please-change-this-secret-please-rotate-at-least-32-bytes"
COOKIE_NAME = os.getenv("AIIMAGE_AUTH_COOKIE_NAME", "aiimage_token")
# 桌面端与 Java 服务器时钟可能漂移(笔记本休眠、用户手动改时间、跨时区等),
# 给 JWT exp/nbf 校验留出容差,避免 /api/auth/sync 因为时间不同步而 401。
# 默认 5 分钟,可通过 AIIMAGE_JWT_LEEWAY_SECONDS 覆盖。
try:
_JWT_LEEWAY_SECONDS = int(os.getenv("AIIMAGE_JWT_LEEWAY_SECONDS", "300"))
except ValueError:
_JWT_LEEWAY_SECONDS = 300
def _signing_key() -> bytes:
"""与 Java JwtService.signingKey 保持一致UTF-8 字节,不足 32 字节右侧补 0。"""
@@ -35,6 +44,26 @@ def _signing_key() -> bytes:
return key_bytes
def _describe_clock_skew(token: str) -> str:
"""解析 token 里的 iat/exp与本地时钟比较返回 ' iat=.. exp=.. now=.. skew=..s' 字符串。
仅供日志使用,不做安全决策;解析失败返回空串,不影响主流程。"""
if pyjwt is None:
return ""
try:
unverified = pyjwt.decode(token, options={"verify_signature": False, "verify_exp": False, "verify_iat": False, "verify_nbf": False})
except Exception:
return ""
iat = unverified.get("iat")
exp = unverified.get("exp")
now = int(time.time())
parts = [f"now={now}"]
if isinstance(iat, (int, float)):
parts.append(f"iat={int(iat)} skew_iat={now - int(iat)}s")
if isinstance(exp, (int, float)):
parts.append(f"exp={int(exp)} skew_exp={now - int(exp)}s")
return ", " + " ".join(parts)
def parse_token_with_reason(token: str) -> Tuple[Optional[dict], Optional[str]]:
"""解析 JWT返回 (payload, error_reason)。
payload 命中时 error_reason 为 None失败时 payload 为 Noneerror_reason 描述根因。"""
@@ -49,10 +78,17 @@ def parse_token_with_reason(token: str) -> Tuple[Optional[dict], Optional[str]]:
return None, f"无法解析 token header: {type(e).__name__}: {e}"
alg = header.get("alg") or "?"
try:
payload = pyjwt.decode(token, _signing_key(), algorithms=["HS256", "HS384", "HS512"])
payload = pyjwt.decode(
token,
_signing_key(),
algorithms=["HS256", "HS384", "HS512"],
leeway=_JWT_LEEWAY_SECONDS,
)
except Exception as e:
# 常见ExpiredSignatureError / InvalidSignatureError / DecodeError / InvalidAlgorithmError
return None, f"{type(e).__name__}: {e} (token alg={alg})"
# 把 token 内 exp/iat 与本地时钟一起打出来,定位"时间漂移"类失败更直接
skew_info = _describe_clock_skew(token)
return None, f"{type(e).__name__}: {e} (token alg={alg}{skew_info})"
sub = payload.get("sub")
try:
user_id = int(sub) if sub is not None else None

View File

@@ -89,7 +89,7 @@ public class SimilarAsinCozeClient {
String dataText = extractResultDataText(pollRoot);
String outputText = dataText.isBlank() ? extractWorkflowOutputText(pollRoot) : "";
String failureMessage = isFailedWorkflowStatus(status)
? firstNonBlank(resolveFailureMessage(pollRoot), "Coze async workflow failed")
? firstNonBlank(resolveFailureMessage(pollRoot), "Coze 异步工作流失败")
: "";
return new CozePollResponse(executeId, status, dataText, outputText, failureMessage, writeJson(pollRoot),
resolvedCredential.name());
@@ -214,19 +214,19 @@ public class SimilarAsinCozeClient {
String status = normalize(resolveWorkflowStatus(pollRoot)).toUpperCase(Locale.ROOT);
if (isFailedWorkflowStatus(status)) {
throw new IllegalStateException(firstNonBlank(resolveFailureMessage(pollRoot), "Coze async workflow failed"));
throw new IllegalStateException(firstNonBlank(resolveFailureMessage(pollRoot), "Coze 异步工作流失败"));
}
if (isSuccessfulWorkflowStatus(status)) {
String outputText = extractWorkflowOutputText(pollRoot);
if (!outputText.isBlank()) {
return wrapDataPayload(outputText);
}
throw new IllegalStateException("Coze async workflow completed without output");
throw new IllegalStateException("Coze 异步工作流已完成但没有输出结果");
}
sleepQuietly(Math.max(200, properties.getCozePollIntervalMillis()));
}
throw new IllegalStateException("Coze async workflow poll timeout");
throw new IllegalStateException("Coze 异步工作流轮询超时");
}
private String postWorkflow(List<SimilarAsinResultRowDto> rows,
@@ -781,8 +781,8 @@ public class SimilarAsinCozeClient {
|| message.contains("429")
|| message.toLowerCase(Locale.ROOT).contains("rate limit")
|| message.toLowerCase(Locale.ROOT).contains("retry later")
|| message.contains("\u9650\u6d41")
|| message.contains("\u7a0d\u540e\u91cd\u8bd5")
|| message.contains("限流")
|| message.contains("稍后重试")
|| message.toLowerCase(Locale.ROOT).contains("timeout");
}
@@ -1095,7 +1095,7 @@ public class SimilarAsinCozeClient {
try {
return objectMapper.writeValueAsString(value);
} catch (Exception ex) {
throw new IllegalStateException("Failed to serialize Coze payload", ex);
throw new IllegalStateException("Coze 请求载荷序列化失败", ex);
}
}
@@ -1118,13 +1118,13 @@ public class SimilarAsinCozeClient {
Thread.sleep(delayMillis);
} catch (InterruptedException interruptedException) {
Thread.currentThread().interrupt();
throw new IllegalStateException("Coze workflow interrupted", interruptedException);
throw new IllegalStateException("Coze 工作流被中断", interruptedException);
}
}
private void ensureNotInterrupted() {
if (Thread.currentThread().isInterrupted()) {
throw new IllegalStateException("Coze workflow interrupted");
throw new IllegalStateException("Coze 工作流被中断");
}
}
@@ -1132,7 +1132,7 @@ public class SimilarAsinCozeClient {
if (ex instanceof RuntimeException runtimeException) {
return runtimeException;
}
return new IllegalStateException(nonBlank(ex.getMessage(), "Coze call failed"), ex);
return new IllegalStateException(nonBlank(ex.getMessage(), "Coze 调用失败"), ex);
}
private JsonNode firstNonNull(JsonNode left, JsonNode right) {
@@ -1227,19 +1227,19 @@ public class SimilarAsinCozeClient {
private String failureMessage(Exception ex) {
if (ex instanceof PartialCozeResultException partial) {
return "Coze result incomplete(" + partial.resolvedCount() + "/" + partial.expectedCount() + ")";
return "Coze 结果不完整(" + partial.resolvedCount() + "/" + partial.expectedCount() + "";
}
String message = ex == null ? null : ex.getMessage();
if (message == null || message.isBlank()) {
return "Coze call failed";
return "Coze 调用失败";
}
if (message.contains("Workflow node execution limit exceeded")) {
return "Coze workflow node execution limit exceeded";
return "Coze 工作流节点执行超限";
}
if (message.contains("Read timed out")) {
return "Coze call timed out";
return "Coze 调用超时";
}
return "Coze call failed: " + message;
return "Coze 调用失败:" + message;
}
private String stripBearer(String token) {

View File

@@ -13,11 +13,13 @@ import com.nanri.aiimage.modules.similarasin.model.vo.SimilarAsinHistoryVo;
import com.nanri.aiimage.modules.similarasin.model.vo.SimilarAsinParseVo;
import com.nanri.aiimage.modules.similarasin.model.vo.SimilarAsinTaskBatchVo;
import com.nanri.aiimage.modules.similarasin.service.SimilarAsinTaskService;
import com.nanri.aiimage.modules.similarasin.service.SimilarAsinTaskService.ResultDownloadInfo;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
@@ -36,6 +38,7 @@ import java.util.List;
@RestController
@RequiredArgsConstructor
@Slf4j
@RequestMapping("/api/similar-asin")
@Tag(
name = "相似 ASIN 检测",
@@ -174,22 +177,30 @@ public class SimilarAsinController {
@Parameter(description = "当前用户 ID", required = true, example = "1")
@RequestParam("user_id") Long userId,
jakarta.servlet.http.HttpServletResponse response) {
String url = service.resolveResultDownloadUrl(resultId, userId);
String filename = service.resolveResultDownloadFilename(resultId, userId);
if (url == null || url.isBlank()) {
ResultDownloadInfo download = service.resolveResultDownloadInfo(resultId, userId);
if (download.url() == null || download.url().isBlank()) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "暂无可下载结果");
}
try {
response.setContentType("application/octet-stream");
DownloadHeaderUtil.setAttachment(response, filename);
try (InputStream in = URI.create(url).toURL().openStream()) {
response.setContentType(download.contentType());
if (download.size() > 0) {
response.setContentLengthLong(download.size());
}
DownloadHeaderUtil.setAttachment(response, download.filename());
long written = 0L;
try (InputStream in = URI.create(download.url()).toURL().openStream()) {
byte[] buffer = new byte[65536];
int read;
while ((read = in.read(buffer)) != -1) {
response.getOutputStream().write(buffer, 0, read);
written += read;
}
response.getOutputStream().flush();
}
if (download.size() > 0 && written != download.size()) {
log.warn("[相似ASIN] 下载文件大小不一致 结果ID={} 预期字节数={} 实际写出字节数={} 文件名={}",
resultId, download.size(), written, download.filename());
}
} catch (Exception ex) {
throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "下载失败");
}

View File

@@ -600,7 +600,7 @@ public class SimilarAsinTaskService {
public void submitResult(Long taskId, SimilarAsinSubmitResultRequest request) {
TaskDistributedLockService.LockHandle lockHandle = acquireTaskLock(taskId, TASK_LOCK_WAIT_MILLIS);
if (lockHandle == null) {
throw new BusinessException(40902, "Task is processing the previous result chunk, please retry later");
throw new BusinessException(40902, "任务正在处理上一批结果,请稍后重试");
}
try (lockHandle) {
submitResultLocked(taskId, request);
@@ -730,7 +730,7 @@ public class SimilarAsinTaskService {
taskScopeStateMapper.delete(new LambdaQueryWrapper<TaskScopeStateEntity>().eq(TaskScopeStateEntity::getTaskId, taskId).eq(TaskScopeStateEntity::getModuleType, MODULE_TYPE));
taskChunkMapper.delete(new LambdaQueryWrapper<TaskChunkEntity>().eq(TaskChunkEntity::getTaskId, taskId).eq(TaskChunkEntity::getModuleType, MODULE_TYPE));
taskCacheService.deleteTaskCache(taskId);
// 同步清理 task_file_job避免被删除任务遗留的 PENDING/FAILED 行被 TaskResultFileJobWorker 反复扫描出 task not found
// 同步清理 task_file_job避免被删除任务遗留的 PENDING/FAILED 行被 TaskResultFileJobWorker 反复扫描出“任务不存在”
taskFileJobService.deleteTaskJobs(taskId, MODULE_TYPE);
fileTaskMapper.deleteById(taskId);
// P1-1任务被删除时一并清理滑窗记录防止内存泄漏。
@@ -743,7 +743,7 @@ public class SimilarAsinTaskService {
if (row == null || !MODULE_TYPE.equals(row.getModuleType()) || !Objects.equals(userId, row.getUserId())) {
throw new BusinessException("记录不存在");
}
// 同步清理 task_file_job避免被删除历史记录残留的 ASSEMBLE_RESULT job 被反复扫描出 result record not found
// 同步清理 task_file_job避免被删除历史记录残留的 ASSEMBLE_RESULT job 被反复扫描出“结果记录不存在”
taskFileJobService.deleteResultJobs(row.getTaskId(), MODULE_TYPE, resultId);
fileResultMapper.deleteById(resultId);
}
@@ -769,6 +769,36 @@ public class SimilarAsinTaskService {
: row.getResultFilename();
}
public ResultDownloadInfo resolveResultDownloadInfo(Long resultId, Long userId) {
FileResultEntity row = fileResultMapper.selectById(resultId);
if (row == null || !MODULE_TYPE.equals(row.getModuleType()) || !Objects.equals(userId, row.getUserId())) {
throw new BusinessException("记录不存在");
}
if (row.getResultFileUrl() == null || row.getResultFileUrl().isBlank()) {
throw new BusinessException("暂无可下载文件");
}
String filename = row.getResultFilename() == null || row.getResultFilename().isBlank()
? "similar-asin-" + resultId + ".xlsx"
: row.getResultFilename();
String contentType = row.getResultContentType() == null || row.getResultContentType().isBlank()
? guessDownloadContentType(filename)
: row.getResultContentType();
return new ResultDownloadInfo(
ossStorageService.generateFreshDownloadUrl(row.getResultFileUrl()),
filename,
contentType,
row.getResultFileSize() == null ? 0L : row.getResultFileSize()
);
}
private String guessDownloadContentType(String filename) {
String normalized = filename == null ? "" : filename.trim().toLowerCase(Locale.ROOT);
if (normalized.endsWith(".zip")) {
return CONTENT_TYPE_ZIP;
}
return CONTENT_TYPE_XLSX;
}
public void finalizeStaleTasks() {
if (transactionManager != null) {
LocalDateTime threshold = LocalDateTime.now().minusMinutes(Math.max(1, properties.getStaleTimeoutMinutes()));
@@ -1251,7 +1281,7 @@ public class SimilarAsinTaskService {
persistedRows.put(rowKey(row), row);
}
String payloadJson = writeJson(rows == null ? List.of() : rows, "结果序列化失败");
payloadJson = writeJson(new ArrayList<>(persistedRows.values()), "similar ASIN chunk payload merge failed");
payloadJson = writeJson(new ArrayList<>(persistedRows.values()), "相似ASIN分片载荷合并失败");
String oldPayload = chunk.getPayloadJson();
String storedPayload = transientPayloadStorageService.storeChunkPayloadVersioned(MODULE_TYPE, taskId, scopeHash, chunkIndex, payloadJson);
chunk.setPayloadJson(storedPayload);
@@ -1260,7 +1290,7 @@ public class SimilarAsinTaskService {
int updated = taskChunkMapper.updateById(chunk);
if (updated <= 0) {
transientPayloadStorageService.deletePayloadIfPresent(storedPayload);
throw new IllegalStateException("similar ASIN chunk payload update failed");
throw new IllegalStateException("相似ASIN分片载荷更新失败");
}
transientPayloadStorageService.deleteReplacedPayloadIfNeeded(oldPayload, storedPayload);
}
@@ -1535,8 +1565,8 @@ public class SimilarAsinTaskService {
flushBufferedCozeResults(task.getId());
assembleResultWorkbook(task, result);
} catch (Exception ex) {
log.warn("[similar-asin] assemble result workbook failed taskId={} err={}", task.getId(), ex.getMessage());
finalError = firstNonBlank(finalError, "Generate similar ASIN result failed");
log.warn("[相似ASIN] 组装结果工作簿失败 任务ID={} 错误={}", task.getId(), ex.getMessage());
finalError = firstNonBlank(finalError, "生成相似ASIN结果失败");
}
}
}
@@ -1564,7 +1594,7 @@ public class SimilarAsinTaskService {
if (shouldAssembleResult) {
enqueueResultAssembly(task, result, true);
} else if (assembleWorkbook && failed) {
log.warn("[similar-asin] skip failed task workbook assembly because no persisted result rows taskId={}", task.getId());
log.warn("[相似ASIN] 跳过失败任务的工作簿组装,因为没有已持久化结果行 任务ID={}", task.getId());
}
}
taskCacheService.deleteTaskCache(task.getId());
@@ -1599,7 +1629,7 @@ public class SimilarAsinTaskService {
&& job != null
&& "RUNNING".equals(job.getStatus())
&& countPendingCozeStates(task.getId()) == 0) {
taskFileJobService.requeue(job.getId(), "Python upload finished, assembling xlsx");
taskFileJobService.requeue(job.getId(), "Python 上传完成,正在组装 xlsx");
}
return job;
}
@@ -1625,22 +1655,22 @@ public class SimilarAsinTaskService {
public boolean processResultFileJob(TaskFileJobEntity job) {
if (job == null || job.getTaskId() == null || job.getResultId() == null) {
throw new BusinessException("result file job arguments are incomplete");
throw new BusinessException("结果文件任务参数不完整");
}
FileTaskEntity task = fileTaskMapper.selectById(job.getTaskId());
if (task == null || !MODULE_TYPE.equals(task.getModuleType())) {
throw new BusinessException("task not found");
throw new BusinessException("任务不存在");
}
ensureTaskOwnedByCurrentInstance(task, "assemble result file");
job.setScopeKey(buildTaskOwnerScopeKey(task));
if (!isJobOwnedByCurrentInstance(job)) {
log.info("[similar-asin] skip result file job because owner is another instance jobId={} taskId={} owner={} current={}",
log.info("[相似ASIN] 跳过结果文件任务,因为归属实例不是当前实例 文件任务ID={} 任务ID={} 归属实例={} 当前实例={}",
job.getId(), job.getTaskId(), ownerFromScopeKey(job.getScopeKey()), currentInstanceId());
return false;
}
FileResultEntity result = fileResultMapper.selectById(job.getResultId());
if (result == null || !MODULE_TYPE.equals(result.getModuleType())) {
throw new BusinessException("result record not found");
throw new BusinessException("结果记录不存在");
}
List<TaskChunkEntity> chunks = taskChunkMapper.selectList(new LambdaQueryWrapper<TaskChunkEntity>()
.eq(TaskChunkEntity::getTaskId, task.getId())
@@ -1653,7 +1683,7 @@ public class SimilarAsinTaskService {
if (countPendingCozeStates(task.getId()) > 0) {
taskFileJobService.touchRunning(job.getId());
touchJavaSideTaskActivity(task.getId());
saveFileBuildProgress(task, job, totalProgressUnits, 1, "Coze submitted, waiting for result");
saveFileBuildProgress(task, job, totalProgressUnits, 1, "Coze 已提交,等待结果");
return false;
}
// P0-3stale-recovery / 异常路径兜底 —— maybeFinalizeCozeJob 可能未能成功 flush
@@ -1661,24 +1691,24 @@ public class SimilarAsinTaskService {
try {
flushBufferedCozeResults(task.getId());
} catch (Exception flushEx) {
log.warn("[similar-asin] result file job flush buffered coze results failed taskId={} jobId={} err={}",
log.warn("[相似ASIN] 结果文件任务刷新缓冲区 Coze 结果失败 任务ID={} 文件任务ID={} 错误={}",
task.getId(), job.getId(), flushEx.getMessage(), flushEx);
throw new BusinessException("flush buffered coze results failed: "
+ firstNonBlank(flushEx.getMessage(), "unknown error"));
throw new BusinessException("刷新缓冲区 Coze 结果失败:"
+ firstNonBlank(flushEx.getMessage(), "未知错误"));
}
saveFileBuildProgress(task, job, totalProgressUnits, 0, "Submitting Coze");
saveFileBuildProgress(task, job, totalProgressUnits, 0, "正在提交 Coze");
boolean pendingCoze = submitCozeBatches(task, result, job, chunks, allRowsByBaseId);
if (pendingCoze) {
taskFileJobService.touchRunning(job.getId());
touchJavaSideTaskActivity(task.getId());
saveFileBuildProgress(task, job, totalProgressUnits, 1, "Coze submitted, waiting for result");
saveFileBuildProgress(task, job, totalProgressUnits, 1, "Coze 已提交,等待结果");
return false;
}
if (STATUS_RUNNING.equals(task.getStatus()) && !isResultSubmissionComplete(task.getId())) {
taskFileJobService.touchRunning(job.getId());
touchJavaSideTaskActivity(task.getId());
saveFileBuildProgress(task, job, totalProgressUnits, Math.max(1, cozeWorkUnits),
"Waiting for Python upload, submitting Coze every " + batchSize + " rows");
"等待 Python 上传,每 " + batchSize + " 行提交一次 Coze");
return false;
}
completeCozeFileJob(task, result, job, totalProgressUnits, cozeWorkUnits);
@@ -2298,16 +2328,16 @@ public class SimilarAsinTaskService {
return;
}
String failureMessage = poll.isFailed()
? firstNonBlank(poll.failureMessage(), "Coze async workflow failed")
? firstNonBlank(poll.failureMessage(), "Coze 异步工作流失败")
: "";
if (!poll.hasPayload() && failureMessage.isBlank()) {
failureMessage = isCozeStateTimedOut(state)
? "Coze async workflow poll timeout"
: "Coze async workflow completed without output";
? "Coze 异步工作流轮询超时"
: "Coze 异步工作流已完成但没有输出结果";
}
List<SimilarAsinResultRowDto> batchRows = readCozeBatchRows(state);
if (batchRows.isEmpty() && failureMessage.isBlank()) {
failureMessage = "Coze batch payload missing";
failureMessage = "Coze 批次载荷缺失";
}
List<SimilarAsinResultRowDto> cozeRows;
if (failureMessage.isBlank()) {
@@ -2911,9 +2941,9 @@ public class SimilarAsinTaskService {
}
/**
* P2-10\uff1aCoze \u5931\u8d25\u7edf\u4e00\u5206\u7c7b\uff08\u66ff\u4ee3 isRetryableCozeFailure / shouldSplitCozeBatchForRetry /
* isCozeThrottleLockTimeout / isDeterministicCozeInputFailure \u7b49\u6563\u843d\u5728\u591a\u5904\u7684\u5173\u952e\u8bcd\u5224\u65ad\uff09\u3002
* \u5173\u952e\u5b57\u96c6\u4e2d\u5728\u6b64\uff0c\u6240\u6709\u8c03\u7528\u65b9\u5171\u4eab\u540c\u4e00\u6765\u6e90\u907f\u514d\u6f02\u79fb\u3002
* P2-10Coze 失败统一分类(替代 isRetryableCozeFailure / shouldSplitCozeBatchForRetry /
* isCozeThrottleLockTimeout / isDeterministicCozeInputFailure 等散落在多处的关键词判断)。
* 关键词集中在此,所有调用方共享同一来源避免漂移。
*/
static final class CozeFailureClassifier {
private CozeFailureClassifier() {}
@@ -2929,7 +2959,7 @@ public class SimilarAsinTaskService {
return trimmed.toLowerCase(Locale.ROOT);
}
/** Coze \u5de5\u4f5c\u6d41\u7a7a\u8f93\u5165\u515c\u5e95 / 720712000 \u663e\u5f0f\u8f93\u5165\u6821\u9a8c\u5931\u8d25\uff1a\u76f4\u63a5 markFailed\uff0c\u4e0d\u8fdb retry\u3002 */
/** Coze 工作流空输入兜底 / 720712000 显式输入校验失败:直接标记失败,不进入重试。 */
static boolean isDeterministicInputFailure(String msg) {
String n = norm(msg);
if (n.isEmpty()) {
@@ -2938,25 +2968,25 @@ public class SimilarAsinTaskService {
return DETERMINISTIC_INPUT_FAILURE_PATTERN.matcher(n).find();
}
/** \u8282\u6d41\u9501\u7b49\u5f85\u8d85\u65f6\uff08\u63d0\u4ea4\u9501\uff09\uff0c\u533a\u522b\u4e8e poll \u7b49\u5f85 Coze \u81ea\u8eab\u8d85\u65f6\u3002 */
/** 节流锁等待超时(提交锁),区别于轮询等待 Coze 自身超时。 */
static boolean isThrottleLockTimeout(String msg) {
return norm(msg).contains("coze submit throttle lock timeout");
}
/**
* P1-5\uff1a720712008 / "node executed out of limit" / "\u5de5\u4f5c\u6d41\u8282\u70b9\u6267\u884c\u8d85\u9650"
* \u8fd9\u4e00\u7c7b\u5f3a\u70c8\u6697\u793a"\u5355\u70b9\u6b7b\u5faa\u73af"\uff0csplit \u65f6\u76f4\u63a5\u62c6 size=1 \u9694\u79bb\u6bd2\u884c\uff0c
* \u907f\u514d\u4e8c\u5206\u591a\u6b21\u6d6a\u8d39\u63d0\u4ea4\u914d\u989d\u3002
* P1-5720712008 / "node executed out of limit" / "工作流节点执行超限"
* 这一类强烈暗示“单点死循环”,拆分时直接拆到 size=1 隔离问题行,
* 避免二分多次浪费提交配额。
*/
static boolean isPoisonRow(String msg) {
String n = norm(msg);
return n.contains("720712008")
|| n.contains("node executed out of limit")
|| n.contains("execution limit")
|| n.contains("\u5de5\u4f5c\u6d41\u8282\u70b9\u6267\u884c\u8d85\u9650");
|| n.contains("工作流节点执行超限");
}
/** \u547d\u4e2d\u540e\u5141\u8bb8"\u540c batch \u91cd\u8bd5"\u3002 */
/** 命中后允许同批次重试。 */
static boolean isRetryable(String msg) {
if (isDeterministicInputFailure(msg)) {
return false;
@@ -2974,13 +3004,13 @@ public class SimilarAsinTaskService {
|| n.contains("720712008")
|| n.contains("720701002")
|| n.contains("plugin limit")
|| n.contains("\u9650\u6d41")
|| n.contains("\u7a0d\u540e\u91cd\u8bd5")
|| n.contains("\u5de5\u4f5c\u6d41\u8282\u70b9\u6267\u884c\u8d85\u9650")
|| n.contains("\u8c03\u7528\u8d85\u65f6");
|| n.contains("限流")
|| n.contains("稍后重试")
|| n.contains("工作流节点执行超限")
|| n.contains("调用超时");
}
/** \u547d\u4e2d\u540e\u4f18\u5148\u8d70 split retry\uff08\u62c6 batch\uff09\u3002 */
/** 命中后优先走拆批重试。 */
static boolean shouldSplitForRetry(String msg) {
String n = norm(msg);
return n.contains("timeout")
@@ -2990,8 +3020,8 @@ public class SimilarAsinTaskService {
|| n.contains("execution limit")
|| n.contains("720712008")
|| n.contains("720701002")
|| n.contains("\u5de5\u4f5c\u6d41\u8282\u70b9\u6267\u884c\u8d85\u9650")
|| n.contains("\u8c03\u7528\u8d85\u65f6");
|| n.contains("工作流节点执行超限")
|| n.contains("调用超时");
}
}
@@ -3153,9 +3183,9 @@ public class SimilarAsinTaskService {
} catch (Exception ex) {
TaskFileJobEntity job = taskFileJobService.findAssembleJob(taskId, MODULE_TYPE, context.resultId());
if (job != null) {
taskFileJobService.markFailed(job, firstNonBlank(ex.getMessage(), "similar ASIN result file build failed"));
taskFileJobService.markFailed(job, firstNonBlank(ex.getMessage(), "相似ASIN结果文件生成失败"));
}
log.warn("[similar-asin] coze async finalize failed taskId={} resultId={} err={}",
log.warn("[相似ASIN] Coze 异步收尾失败 任务ID={} 结果ID={} 错误={}",
taskId, context.resultId(), ex.getMessage(), ex);
}
}
@@ -3188,15 +3218,15 @@ public class SimilarAsinTaskService {
try {
flushBufferedCozeResults(taskId);
} catch (Exception flushEx) {
String message = firstNonBlank(flushEx.getMessage(), "flush buffered coze results failed");
log.warn("[similar-asin] coze finalize flush failed, marking job failed taskId={} jobId={} err={}",
String message = firstNonBlank(flushEx.getMessage(), "刷新缓冲区 Coze 结果失败");
log.warn("[相似ASIN] Coze 收尾刷新缓冲区失败,已标记文件任务失败 任务ID={} 文件任务ID={} 错误={}",
taskId, job.getId(), message, flushEx);
taskFileJobService.markFailed(job, message);
return;
}
boolean requeued = taskFileJobService.requeue(job.getId(), "Coze results ready, assembling xlsx");
boolean requeued = taskFileJobService.requeue(job.getId(), "Coze 结果已就绪,正在组装 xlsx");
if (requeued) {
log.info("[similar-asin] coze async results ready, result file job requeued taskId={} jobId={} resultId={}",
log.info("[相似ASIN] Coze 异步结果已就绪,结果文件任务已重新入队 任务ID={} 文件任务ID={} 结果ID={}",
taskId, job.getId(), context.resultId());
}
}
@@ -3567,7 +3597,7 @@ public class SimilarAsinTaskService {
}
if (failedGroups > 0) {
// 让 finalize 能感知到失败,调用方应避免继续 requeue assemble。
throw new IllegalStateException("flush buffered coze results failed groups=" + failedGroups);
throw new IllegalStateException("刷新缓冲区 Coze 结果失败,失败分组数=" + failedGroups);
}
}
@@ -5451,6 +5481,12 @@ public class SimilarAsinTaskService {
int rowCount) {
}
public record ResultDownloadInfo(String url,
String filename,
String contentType,
long size) {
}
private record ParsedWorkbook(int totalRows, int droppedRows, List<String> headers, List<SimilarAsinParsedRowVo> allRows) {
}
}

View File

@@ -169,7 +169,11 @@ public final class ExcelCellImageWriter {
int idx = 0;
int patchedCount = 0;
for (RegisteredCellImage image : session.images) {
String replacement = "<c r=\"" + image.cellRef + "\" t=\"e\" vm=\"" + idx + "\"><v>#VALUE!</v></c>";
// [MS-XLSX] §2.2.10: cell @vm 是 1-based valueMetadata 索引0 表示"无 metadata"。
// futureMetadata/valueMetadata/rdrichvalue 内部块仍是 0-based所以这里写 idx+1
// 与 buildMetadata/buildRdRichValue 中的 i 解耦。Excel 365 在 vm=0 时会把 cell
// 直接当作无图片的 #VALUE! 错误格渲染(任务 10498 J2 复现),必须改 1-based。
String replacement = "<c r=\"" + image.cellRef + "\" t=\"e\" vm=\"" + (idx + 1) + "\"><v>#VALUE!</v></c>";
Pattern pattern = Pattern.compile("<c\\b(?=[^>]*\\br=\"" + Pattern.quote(image.cellRef)
+ "\")[^>]*(?:/>|>.*?</c>)", Pattern.DOTALL);
Matcher matcher = pattern.matcher(content);

View File

@@ -17,7 +17,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
class ExcelCellImageWriterTest {
@Test
void patchedCellImageValueMetadataUsesZeroBasedVmIndexes() throws Exception {
void patchedCellImageValueMetadataUsesOneBasedVmIndexes() throws Exception {
Path dir = Files.createTempDirectory("excel-cell-image-test-");
Path xlsx = dir.resolve("result.xlsx");
writeMinimalWorkbook(xlsx);
@@ -30,13 +30,15 @@ class ExcelCellImageWriterTest {
try (ZipFile zip = new ZipFile(xlsx.toFile())) {
String sheet = read(zip, "xl/worksheets/sheet1.xml");
assertTrue(sheet.contains("<c r=\"J2\" t=\"e\" vm=\"0\"><v>#VALUE!</v></c>"));
assertTrue(sheet.contains("<c r=\"K2\" t=\"e\" vm=\"1\"><v>#VALUE!</v></c>"));
// cell @vm 是 [MS-XLSX] 规范里的 1-based 索引0 表示"无 metadata")。
assertTrue(sheet.contains("<c r=\"J2\" t=\"e\" vm=\"1\"><v>#VALUE!</v></c>"));
assertTrue(sheet.contains("<c r=\"K2\" t=\"e\" vm=\"2\"><v>#VALUE!</v></c>"));
// futureMetadata 内部块仍然是 0-based。
String metadata = read(zip, "xl/metadata.xml");
assertTrue(metadata.contains("<xlrd:rvb i=\"0\"/>"));
assertTrue(metadata.contains("<xlrd:rvb i=\"1\"/>"));
assertEquals(2, maxVm(sheet) + 1);
assertEquals(2, maxVm(sheet));
}
}

View File

@@ -827,7 +827,7 @@ async function downloadResult(item: SimilarAsinHistoryItem) {
ElMessage.error('当前客户端未提供下载能力')
return
}
const url = item.downloadUrl || getSimilarAsinResultDownloadUrl(item.resultId)
const url = getSimilarAsinResultDownloadUrl(item.resultId)
const filename = item.resultFilename || `${item.sourceFilename || 'similar-asin'}.xlsx`
const result = await api.save_file_from_url_new(url, filename)
if (result.success) {