后台管理接口修复 后端BUG修复

This commit is contained in:
super
2026-05-12 21:03:42 +08:00
parent 289b1c67ce
commit 648e7d2f14
33 changed files with 3447 additions and 397 deletions

2054
8095-lines.txt Normal file

File diff suppressed because one or more lines are too long

View File

@@ -13,8 +13,8 @@ client_name=ShuFuAI
# java_api_base=http://47.111.163.154:18080 # java_api_base=http://47.111.163.154:18080
java_api_base=http://127.0.0.1:18080 # java_api_base=http://127.0.0.1:18080
# java_api_base=http://8.136.19.173:18080 # java_api_base=http://8.136.19.173:18080
# java_api_base=http://121.196.149.225:18080 java_api_base=http://121.196.149.225:18080

View File

@@ -594,9 +594,168 @@ class SimilarAsinTask(TaskBase):
runing_task[task_id]["status"] = "failed" runing_task[task_id]["status"] = "failed"
runing_task[task_id]["error"] = str(e) runing_task[task_id]["error"] = str(e)
def post_result(self, task_id: int, chunkIndex:int,chunkTotal: int, asin: str, error:str="", def process_task(self, task_data: dict):
item_data:dict={}, """Process similar ASIN task rows one by one."""
is_done: bool = False): try:
data = task_data.get("data", {})
task_id = data.get("taskId")
if task_id:
queued_rows = self._count_payload_rows(data)
try:
parsed_payload = self.fetch_parsed_payload(task_id, data.get("user_id") or data.get("userId") or 1)
data = self._merge_parsed_payload(data, parsed_payload)
self.log(
f"similar asin task {task_id} loaded full parsed payload from Java, queuedRows={queued_rows}, fullRows={self._count_payload_rows(data)}"
)
except Exception as e:
if not queued_rows:
raise
self.log(
f"similar asin task {task_id} failed to load full parsed payload, fallback to queued rows={queued_rows}: {e}",
"WARNING"
)
groups = self.normalize_groups(data)
if not task_id:
self.log("similar asin task_id is empty, skip", "WARNING")
return
self.log(f"similar asin task {task_id} start, groups={len(groups)}")
if not groups:
self.log("similar asin groups/rows is empty, skip", "WARNING")
return
runing_task[task_id] = {
"status": "running",
"start_time": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"total_shops": 1,
"processed_shops": 0,
"total_countries": len(groups),
"processed_countries": 0,
"total_asins": 0,
"processed_asins": 0,
"success_count": 0,
"failed_count": 0,
"stop_requested": False
}
if task_id in runing_task and runing_task[task_id].get("stop_requested", False):
self.log(f"similar asin task {task_id} stop requested before processing", "WARNING")
runing_task[task_id]["status"] = "stopped"
return
max_retry = 3
"""
show_notification("开始抓取数据", "info")
"""
show_notification("Start fetching similar ASIN data", "info")
chrome = ChromeAmzone()
try:
result = []
last_asin = ""
for gp_index, gp in enumerate(groups):
items = gp.get("items", [])
group_item = []
for value in items:
asin = value.get("asin")
country = value.get("country")
last_asin = asin or last_asin
return_data = {
"image_url": "",
"title": "",
"similar_data": []
}
for _ in range(max_retry):
try:
return_data = chrome.run(country, asin) or {}
self.log(f"similar asin crawl result -> {return_data}")
break
except Exception as e:
if "涓庨〉闈㈢殑杩炴帴宸叉柇寮€" in str(e):
chrome = ChromeAmzone()
if not isinstance(return_data, dict):
return_data = {}
similar_data = return_data.get("similar_data")
if not isinstance(similar_data, list):
similar_data = []
group_item.append({
"sourceFileKey": value.get("sourceFileKey"),
"sourceFilename": value.get("sourceFilename"),
"rowToken": value.get("rowToken"),
"groupKey": value.get("groupKey"),
"id": value.get("id") or value.get("displayId"),
"asin": value.get("asin"),
"country": value.get("country"),
"sku": value.get("sku"),
"url": return_data.get("image_url"),
"title": return_data.get("title"),
"done": False,
"urls": [
item.get("ori_picture")
for item in similar_data
if isinstance(item, dict) and item.get("ori_picture")
]
})
result.append({
"sourceFileKey": gp.get("sourceFileKey"),
"sourceFilename": gp.get("sourceFilename"),
"groupKey": gp.get("groupKey"),
"baseId": gp.get("baseId"),
"displayId": gp.get("displayId"),
"items": group_item
})
is_done = gp_index == len(groups) - 1
if len(result) > 20 or is_done:
self.post_result(
task_id=task_id,
chunkIndex=gp_index + 1,
chunkTotal=len(groups),
asin=last_asin,
item_data=result,
is_done=is_done
)
result = []
if result:
self.post_result(
task_id=task_id,
chunkIndex=len(groups),
chunkTotal=len(groups),
asin=last_asin,
item_data=result,
is_done=True
)
try:
chrome.close()
except Exception as e:
print("close browser failed", e)
if task_id in runing_task:
runing_task[task_id]["processed_shops"] += 1
except Exception as e:
self.log(f"similar asin task {task_id} failed: {str(e)}", "ERROR")
self.log(traceback.format_exc(), "ERROR")
if task_id in runing_task:
if runing_task[task_id].get("stop_requested", False):
runing_task[task_id]["status"] = "stopped"
self.log(f"similar asin task {task_id} stopped")
else:
runing_task[task_id]["status"] = "completed"
self.log(f"similar asin task {task_id} completed")
except Exception as e:
self.log(f"similar asin task failed: {traceback.format_exc()}", "ERROR")
if task_id and task_id in runing_task:
runing_task[task_id]["status"] = "failed"
runing_task[task_id]["error"] = str(e)
def post_result(self, task_id: int, chunkIndex:int,chunkTotal: int, asin: str, error:str="",
item_data:dict={},
is_done: bool = False):
"""回传处理结果到API """回传处理结果到API
""" """

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

@@ -0,0 +1,59 @@
# Multi-Server Start Commands
Use the same packaged jar on both servers. Do not switch code comments before packaging.
Requirements:
- Build once: `mvn clean package -DskipTests`
- Use profile: `server`
- Put JVM options before `-jar`
- Pass `AIIMAGE_INSTANCE_ID` and Redis address from the startup command
- Result file job MQ is expected to stay enabled in packaged `server` deployments
1Panel server 121:
Environment variables:
```env
SPRING_PROFILES_ACTIVE=server
AIIMAGE_INSTANCE_ID=server-121
AIIMAGE_REDIS_HOST=192.168.0.172
AIIMAGE_REDIS_PORT=16379
AIIMAGE_REDIS_PASSWORD=B6COTcY094TYe545
AIIMAGE_REDIS_DATABASE=0
AIIMAGE_SERVER_PORT=18080
AIIMAGE_STORAGE_LOCAL_TEMP_DIR=/app/data/tmp
AIIMAGE_RESULT_FILE_JOB_MQ_ENABLED=true
AIIMAGE_ROCKETMQ_NAME_SERVER=121.196.149.225:9876
```
Recommended startup command:
```bash
java -Xmx2048M -Xms2048M -jar /app/aiimage-backend-0.0.1-SNAPSHOT.jar
```
Baota server 111:
```bash
/www/server/java/jdk-21.0.2/bin/java -Xmx1024M -Xms256M -jar /app/java/aiimage-backend-0.0.1-SNAPSHOT.jar --spring.profiles.active=server --aiimage.instance-id=server-111 --spring.data.redis.host=47.111.163.154 --spring.data.redis.port=16379 --spring.data.redis.password=B6COTcY094TYe545 --server.port=18080 --aiimage.storage.local-temp-dir=/app/data/tmp
```
Optional Baota server 121 command:
```bash
/www/server/java/jdk-21.0.2/bin/java -Xmx1024M -Xms256M -jar /app/java/aiimage-backend-0.0.1-SNAPSHOT.jar --spring.profiles.active=server --aiimage.instance-id=server-121 --spring.data.redis.host=192.168.0.172 --spring.data.redis.port=16379 --spring.data.redis.password=B6COTcY094TYe545 --server.port=18080 --aiimage.storage.local-temp-dir=/app/data/tmp
```
Successful startup should show a line similar to:
```text
[instance] resolved instanceId=server-121 source=configured
```
or:
```text
[instance] resolved instanceId=server-111 source=configured
```
If the instanceId in the log does not match the startup command, the actual process was not started with the expected command.

View File

@@ -1,21 +1,38 @@
package com.nanri.aiimage.config; package com.nanri.aiimage.config;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import java.net.InetAddress; import java.net.InetAddress;
@Slf4j
@Component @Component
public class InstanceMetadata { public class InstanceMetadata {
private final String instanceId; private final String instanceId;
private final String hostname; private final String hostname;
private final String source;
private final boolean stable;
public InstanceMetadata(@Value("${aiimage.instance-id:}") String configuredInstanceId) { public InstanceMetadata(@Value("${aiimage.instance-id:}") String configuredInstanceId) {
this.hostname = resolveHostname(); this.hostname = resolveHostname();
this.instanceId = configuredInstanceId == null || configuredInstanceId.isBlank() String normalizedConfiguredInstanceId = configuredInstanceId == null ? "" : configuredInstanceId.trim();
? this.hostname if (!normalizedConfiguredInstanceId.isBlank()) {
: configuredInstanceId.trim(); this.instanceId = normalizedConfiguredInstanceId;
this.source = "configured";
this.stable = true;
log.info("[instance] resolved instanceId={} source={} hostname={} continuityRisk=LOW",
this.instanceId, this.source, this.hostname);
} else {
this.instanceId = this.hostname;
this.source = detectFallbackSource(this.hostname);
this.stable = false;
log.warn("[instance] aiimage.instance-id is not configured, falling back to {}={} as instanceId. "
+ "This may change after restart/redeploy and break task owner continuity. "
+ "Set AIIMAGE_INSTANCE_ID in 1Panel/Docker env, or set -Daiimage.instance-id=<stable-id> in IDEA/local startup.",
this.source, this.hostname);
}
} }
public String getInstanceId() { public String getInstanceId() {
@@ -26,6 +43,14 @@ public class InstanceMetadata {
return hostname; return hostname;
} }
public String getSource() {
return source;
}
public boolean isStable() {
return stable;
}
private static String resolveHostname() { private static String resolveHostname() {
try { try {
return InetAddress.getLocalHost().getHostName(); return InetAddress.getLocalHost().getHostName();
@@ -34,4 +59,12 @@ public class InstanceMetadata {
return (envHostname == null || envHostname.isBlank()) ? "unknown-host" : envHostname; return (envHostname == null || envHostname.isBlank()) ? "unknown-host" : envHostname;
} }
} }
private static String detectFallbackSource(String hostname) {
String envHostname = System.getenv("HOSTNAME");
if (envHostname != null && !envHostname.isBlank() && envHostname.equalsIgnoreCase(hostname)) {
return "env.HOSTNAME";
}
return "os.hostname";
}
} }

View File

@@ -43,14 +43,18 @@ public class RequestTraceFilter extends OncePerRequestFilter {
response.setHeader("X-AIIMAGE-Instance", instanceMetadata.getInstanceId()); response.setHeader("X-AIIMAGE-Instance", instanceMetadata.getInstanceId());
response.setHeader("X-AIIMAGE-Host", instanceMetadata.getHostname()); response.setHeader("X-AIIMAGE-Host", instanceMetadata.getHostname());
response.setHeader("X-AIIMAGE-Instance-Source", instanceMetadata.getSource());
response.setHeader("X-AIIMAGE-Instance-Stable", String.valueOf(instanceMetadata.isStable()));
try { try {
filterChain.doFilter(request, response); filterChain.doFilter(request, response);
} finally { } finally {
long costMs = System.currentTimeMillis() - start; long costMs = System.currentTimeMillis() - start;
log.info( log.info(
"request-trace instance={} host={} method={} uri={} status={} remote={} forwardedHost={} forwardedProto={} forwardedPort={} requestId={} userAgent={} costMs={}", "request-trace instance={} source={} stable={} host={} method={} uri={} status={} remote={} forwardedHost={} forwardedProto={} forwardedPort={} requestId={} userAgent={} costMs={}",
instanceMetadata.getInstanceId(), instanceMetadata.getInstanceId(),
instanceMetadata.getSource(),
instanceMetadata.isStable(),
instanceMetadata.getHostname(), instanceMetadata.getHostname(),
request.getMethod(), request.getMethod(),
request.getRequestURI(), request.getRequestURI(),

View File

@@ -3,6 +3,8 @@ package com.nanri.aiimage.config;
import lombok.Data; import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.boot.context.properties.ConfigurationProperties;
import java.nio.file.Path;
@Data @Data
@ConfigurationProperties(prefix = "aiimage.storage") @ConfigurationProperties(prefix = "aiimage.storage")
public class StorageProperties { public class StorageProperties {
@@ -12,4 +14,25 @@ public class StorageProperties {
private long sourceRetentionHours = 24; private long sourceRetentionHours = 24;
private long resultRetentionHours = 24; private long resultRetentionHours = 24;
private long transientPayloadRetentionHours = 72; private long transientPayloadRetentionHours = 72;
public String getLocalTempDir() {
String configured = localTempDir == null ? "" : localTempDir.trim();
if (configured.isBlank()) {
configured = "./data/tmp";
}
try {
Path raw = Path.of(configured);
if (raw.isAbsolute()) {
return raw.normalize().toString();
}
} catch (Exception ignored) {
// Fall through to resolve against a stable base directory.
}
String userDir = System.getProperty("user.dir");
if (userDir == null || userDir.isBlank()) {
userDir = System.getProperty("java.io.tmpdir", ".");
}
return Path.of(userDir).resolve(configured).normalize().toString();
}
} }

View File

@@ -355,6 +355,9 @@ public class AppearancePatentCozeClient {
text(firstNonNull(node.get("result"), text(firstNonNull(node.get("result"),
firstNonNull(node.get("conclusion"), firstNonNull(node.get("conclusion"),
firstNonNull(itemNode.get("result"), itemNode.get("conclusion"))))), firstNonNull(itemNode.get("result"), itemNode.get("conclusion"))))),
text(firstNonNull(
firstNonNull(node.get("status"), firstNonNull(node.get("row_status"), node.get("rowStatus"))),
firstNonNull(itemNode.get("status"), firstNonNull(itemNode.get("row_status"), itemNode.get("rowStatus"))))),
text(firstNonNull(node.get("title_reason"), text(firstNonNull(node.get("title_reason"),
firstNonNull(node.get("titleReason"), firstNonNull(node.get("titleReason"),
firstNonNull(itemNode.get("title_reason"), itemNode.get("titleReason"))))), firstNonNull(itemNode.get("title_reason"), itemNode.get("titleReason"))))),
@@ -465,6 +468,7 @@ public class AppearancePatentCozeClient {
row.setAppearanceRisk(result.appearance()); row.setAppearanceRisk(result.appearance());
row.setPatentRisk(result.patent()); row.setPatentRisk(result.patent());
row.setConclusion(result.result()); row.setConclusion(result.result());
row.setStatus(result.status());
row.setTitleReason(result.titleReason()); row.setTitleReason(result.titleReason());
row.setAppearanceReason(result.appearanceReason()); row.setAppearanceReason(result.appearanceReason());
row.setPatentReason(result.patentReason()); row.setPatentReason(result.patentReason());
@@ -490,6 +494,7 @@ public class AppearancePatentCozeClient {
row.setTitle(source.getTitle()); row.setTitle(source.getTitle());
row.setError(source.getError()); row.setError(source.getError());
row.setDone(source.getDone()); row.setDone(source.getDone());
row.setStatus(source.getStatus());
row.setTitleRisk(source.getTitleRisk()); row.setTitleRisk(source.getTitleRisk());
row.setAppearanceRisk(source.getAppearanceRisk()); row.setAppearanceRisk(source.getAppearanceRisk());
row.setPatentRisk(source.getPatentRisk()); row.setPatentRisk(source.getPatentRisk());
@@ -942,6 +947,7 @@ public class AppearancePatentCozeClient {
String appearance, String appearance,
String patent, String patent,
String result, String result,
String status,
String titleReason, String titleReason,
String appearanceReason, String appearanceReason,
String patentReason String patentReason

View File

@@ -48,6 +48,10 @@ public class AppearancePatentResultRowDto {
@Schema(description = "单行完成标记。当前主要使用请求体顶层 done 控制任务收尾,该字段仅作兼容。", example = "true") @Schema(description = "单行完成标记。当前主要使用请求体顶层 done 控制任务收尾,该字段仅作兼容。", example = "true")
private Boolean done; private Boolean done;
@JsonAlias({"row_status", "rowStatus", "Status"})
@Schema(description = "Coze row status", example = "success", accessMode = Schema.AccessMode.READ_ONLY)
private String status;
@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; private String titleRisk;

View File

@@ -118,7 +118,8 @@ public class AppearancePatentTaskService {
"标题维度(商标)", "标题维度(商标)",
"外观维度(外观设计专利)", "外观维度(外观设计专利)",
"专利维度(发明/实用新型专利)", "专利维度(发明/实用新型专利)",
"结论" "结论",
"status"
); );
private final LocalFileStorageService localFileStorageService; private final LocalFileStorageService localFileStorageService;
@@ -424,7 +425,7 @@ public class AppearancePatentTaskService {
completeSubmittedChunk(context); completeSubmittedChunk(context);
return null; return null;
}); });
submitCozeForSubmittedChunk(context); scheduleCozePipelineForSubmittedChunk(context);
return; return;
} }
FileTaskEntity task = fileTaskMapper.selectById(taskId); FileTaskEntity task = fileTaskMapper.selectById(taskId);
@@ -510,7 +511,7 @@ public class AppearancePatentTaskService {
task.setUpdatedAt(LocalDateTime.now()); task.setUpdatedAt(LocalDateTime.now());
fileTaskMapper.updateById(task); fileTaskMapper.updateById(task);
} }
submitCozeForSubmittedChunk(context); scheduleCozePipelineForSubmittedChunk(context);
} }
@Transactional @Transactional
@@ -560,14 +561,9 @@ public class AppearancePatentTaskService {
if (transactionManager != null) { if (transactionManager != null) {
LocalDateTime threshold = LocalDateTime.now().minusMinutes(Math.max(1, properties.getStaleTimeoutMinutes())); LocalDateTime threshold = LocalDateTime.now().minusMinutes(Math.max(1, properties.getStaleTimeoutMinutes()));
long thresholdMillis = threshold.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli(); long thresholdMillis = threshold.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli();
List<FileTaskEntity> tasks = fileTaskMapper.selectList(new LambdaQueryWrapper<FileTaskEntity>() List<FileTaskEntity> tasks = listStaleFinalizeCandidates(threshold);
.eq(FileTaskEntity::getModuleType, MODULE_TYPE)
.eq(FileTaskEntity::getStatus, STATUS_RUNNING)
.lt(FileTaskEntity::getUpdatedAt, threshold)
.last("limit 50"));
for (FileTaskEntity task : tasks) { for (FileTaskEntity task : tasks) {
if (isJavaSideProcessing(task.getId())) { if (!isOwnerCurrent(ownerFromTask(task))) {
touchJavaSideTaskActivity(task.getId());
continue; continue;
} }
long heartbeatMillis = taskCacheService.getTaskHeartbeatMillis(task.getId()); long heartbeatMillis = taskCacheService.getTaskHeartbeatMillis(task.getId());
@@ -589,14 +585,9 @@ public class AppearancePatentTaskService {
} }
LocalDateTime threshold = LocalDateTime.now().minusMinutes(Math.max(1, properties.getStaleTimeoutMinutes())); LocalDateTime threshold = LocalDateTime.now().minusMinutes(Math.max(1, properties.getStaleTimeoutMinutes()));
long thresholdMillis = threshold.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli(); long thresholdMillis = threshold.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli();
List<FileTaskEntity> tasks = fileTaskMapper.selectList(new LambdaQueryWrapper<FileTaskEntity>() List<FileTaskEntity> tasks = listStaleFinalizeCandidates(threshold);
.eq(FileTaskEntity::getModuleType, MODULE_TYPE)
.eq(FileTaskEntity::getStatus, STATUS_RUNNING)
.lt(FileTaskEntity::getUpdatedAt, threshold)
.last("limit 50"));
for (FileTaskEntity task : tasks) { for (FileTaskEntity task : tasks) {
if (isJavaSideProcessing(task.getId())) { if (!isOwnerCurrent(ownerFromTask(task))) {
touchJavaSideTaskActivity(task.getId());
continue; continue;
} }
long heartbeatMillis = taskCacheService.getTaskHeartbeatMillis(task.getId()); long heartbeatMillis = taskCacheService.getTaskHeartbeatMillis(task.getId());
@@ -608,14 +599,46 @@ public class AppearancePatentTaskService {
continue; continue;
} }
try (lockHandle) { try (lockHandle) {
FileTaskEntity latestTask = fileTaskMapper.selectById(task.getId()); finalizeStaleTask(task.getId(), "Python interrupted before uploading final appearance patent result");
if (latestTask != null) {
finalizeTask(latestTask, "Python interrupted before uploading final appearance patent result", allRowCount(latestTask), true);
}
} }
} }
} }
public void debugFinalizeStaleTask(Long taskId) {
if (taskId == null || taskId <= 0) {
return;
}
if (transactionManager != null) {
TaskDistributedLockService.LockHandle lockHandle = acquireTaskLock(taskId, 0L);
if (lockHandle == null) {
return;
}
try (lockHandle) {
inNewTransaction(() -> {
finalizeStaleTask(taskId, "Python interrupted before uploading final appearance patent result");
return null;
});
}
return;
}
TaskDistributedLockService.LockHandle lockHandle = acquireTaskLock(taskId, 0L);
if (lockHandle == null) {
return;
}
try (lockHandle) {
finalizeStaleTask(taskId, "Python interrupted before uploading final appearance patent result");
}
}
private List<FileTaskEntity> listStaleFinalizeCandidates(LocalDateTime threshold) {
return fileTaskMapper.selectList(new LambdaQueryWrapper<FileTaskEntity>()
.eq(FileTaskEntity::getModuleType, MODULE_TYPE)
.eq(FileTaskEntity::getStatus, STATUS_RUNNING)
.lt(FileTaskEntity::getCreatedAt, threshold)
.orderByAsc(FileTaskEntity::getCreatedAt)
.last("limit 200"));
}
private SubmitContext persistSubmittedChunk(Long taskId, AppearancePatentSubmitResultRequest request) { private SubmitContext persistSubmittedChunk(Long taskId, AppearancePatentSubmitResultRequest request) {
FileTaskEntity task = fileTaskMapper.selectById(taskId); FileTaskEntity task = fileTaskMapper.selectById(taskId);
if (task == null || !MODULE_TYPE.equals(task.getModuleType())) { if (task == null || !MODULE_TYPE.equals(task.getModuleType())) {
@@ -699,17 +722,24 @@ public class AppearancePatentTaskService {
} }
List<TaskChunkEntity> chunks = loadSubmittedChunks(task.getId()); List<TaskChunkEntity> chunks = loadSubmittedChunks(task.getId());
if (chunks.isEmpty()) { if (chunks.isEmpty()) {
log.info("[appearance-patent] skip stale recovery coze submission because no submitted chunks remain taskId={}",
task.getId());
return; return;
} }
FileResultEntity result = findOrCreateResultRecordForAssembly(task, allRowCount(task)); FileResultEntity result = findOrCreateResultRecordForAssembly(task, allRowCount(task));
if (result == null) { if (result == null) {
log.warn("[appearance-patent] stale recovery could not create result record taskId={}", task.getId());
return; return;
} }
TaskFileJobEntity job = taskFileJobService.enqueueAssembleResult( TaskFileJobEntity job = taskFileJobService.enqueueAssembleResult(
task.getId(), MODULE_TYPE, result.getId(), buildTaskOwnerScopeKey(task.getId())); task.getId(), MODULE_TYPE, result.getId(), buildTaskOwnerScopeKey(task));
if (job == null || "SUCCESS".equals(job.getStatus())) { if (job == null || "SUCCESS".equals(job.getStatus())) {
log.info("[appearance-patent] stale recovery skipped coze submission because result job unavailable taskId={} resultId={} jobStatus={}",
task.getId(), result.getId(), job == null ? null : job.getStatus());
return; return;
} }
log.info("[appearance-patent] stale recovery created assemble job taskId={} resultId={} jobId={} jobStatus={} chunkCount={}",
task.getId(), result.getId(), job.getId(), job.getStatus(), chunks.size());
Map<String, List<AppearancePatentParsedRowVo>> allRowsByBaseId = loadAllRowsByBaseId(task); Map<String, List<AppearancePatentParsedRowVo>> allRowsByBaseId = loadAllRowsByBaseId(task);
boolean pendingCoze = submitCozeBatches(task, result, job, chunks, allRowsByBaseId); boolean pendingCoze = submitCozeBatches(task, result, job, chunks, allRowsByBaseId);
saveCozePipelineProgress(task, job); saveCozePipelineProgress(task, job);
@@ -722,14 +752,127 @@ public class AppearancePatentTaskService {
} }
} }
private void scheduleCozePipelineForSubmittedChunk(SubmitContext context) {
if (context == null || context.task() == null || context.task().getId() == null) {
return;
}
FileTaskEntity task = fileTaskMapper.selectById(context.task().getId());
if (task == null || !MODULE_TYPE.equals(task.getModuleType()) || STATUS_SUCCESS.equals(task.getStatus())) {
return;
}
List<TaskChunkEntity> chunks = loadSubmittedChunks(task.getId());
if (chunks.isEmpty()) {
return;
}
FileResultEntity result = findOrCreateResultRecordForAssembly(task, allRowCount(task));
if (result == null) {
return;
}
TaskFileJobEntity job = taskFileJobService.enqueueAssembleResult(
task.getId(), MODULE_TYPE, result.getId(), buildTaskOwnerScopeKey(task));
if (job == null || "SUCCESS".equals(job.getStatus())) {
return;
}
taskFileJobService.requeue(job.getId(), "Appearance patent result uploaded, scheduling Coze/file assembly");
touchJavaSideTaskActivity(task.getId());
}
private void finalizeStaleTask(Long taskId, String error) { private void finalizeStaleTask(Long taskId, String error) {
FileTaskEntity task = fileTaskMapper.selectById(taskId); FileTaskEntity task = fileTaskMapper.selectById(taskId);
if (task == null || !MODULE_TYPE.equals(task.getModuleType()) || !STATUS_RUNNING.equals(task.getStatus())) { if (task == null || !MODULE_TYPE.equals(task.getModuleType()) || !STATUS_RUNNING.equals(task.getStatus())) {
return; return;
} }
if (tryRecoverTimedOutPythonTask(task)) {
return;
}
finalizeTask(task, error, allRowCount(task), true); finalizeTask(task, error, allRowCount(task), true);
} }
private boolean tryRecoverTimedOutPythonTask(FileTaskEntity task) {
if (task == null || task.getId() == null) {
return false;
}
Long taskId = task.getId();
boolean uploadComplete = isResultSubmissionComplete(taskId);
long pendingCozeStates = countPendingCozeStates(taskId);
long activeAssembleJobs = taskFileJobService.countActiveAssembleJobs(taskId, MODULE_TYPE);
log.info("[appearance-patent] stale recovery probe taskId={} uploadComplete={} pendingCozeStates={} activeAssembleJobs={} persistedRows={}",
taskId, uploadComplete, pendingCozeStates, activeAssembleJobs, hasPersistedResultRows(taskId));
if (uploadComplete && (pendingCozeStates > 0 || activeAssembleJobs > 0)) {
touchJavaSideTaskActivity(taskId);
return true;
}
if (!hasPersistedResultRows(taskId)) {
log.info("[appearance-patent] stale recovery aborted because no persisted rows taskId={}", taskId);
return false;
}
if (!uploadComplete) {
int forcedScopes = markSubmissionCompleteOnPythonTimeout(taskId);
if (forcedScopes <= 0) {
log.warn("[appearance-patent] stale recovery aborted because submission could not be marked complete taskId={}",
taskId);
return false;
}
int pendingRows = collectPendingCozeCandidates(task, loadSubmittedChunks(taskId)).size();
log.warn("[appearance-patent] python heartbeat timed out, forcing coze flush taskId={} pendingRows={} pendingCozeStates={} activeAssembleJobs={} forcedScopes={}",
taskId, pendingRows, pendingCozeStates, activeAssembleJobs, forcedScopes);
} else {
log.warn("[appearance-patent] stale running task resuming coze/file assembly after python timeout taskId={} pendingCozeStates={} activeAssembleJobs={}",
taskId, pendingCozeStates, activeAssembleJobs);
}
submitCozeForSubmittedChunk(new SubmitContext(task, null, null, 0, true, null));
touchJavaSideTaskActivity(taskId);
return true;
}
private int markSubmissionCompleteOnPythonTimeout(Long taskId) {
if (taskId == null || taskId <= 0) {
return 0;
}
List<TaskScopeStateEntity> inputStates = taskScopeStateMapper.selectList(new LambdaQueryWrapper<TaskScopeStateEntity>()
.eq(TaskScopeStateEntity::getTaskId, taskId)
.eq(TaskScopeStateEntity::getModuleType, MODULE_TYPE)
.isNull(TaskScopeStateEntity::getCozeStatus)
.orderByDesc(TaskScopeStateEntity::getUpdatedAt));
if (inputStates == null || inputStates.isEmpty()) {
TaskChunkEntity latestChunk = taskChunkMapper.selectOne(new LambdaQueryWrapper<TaskChunkEntity>()
.eq(TaskChunkEntity::getTaskId, taskId)
.eq(TaskChunkEntity::getModuleType, MODULE_TYPE)
.orderByDesc(TaskChunkEntity::getUpdatedAt)
.last("limit 1"));
if (latestChunk == null || latestChunk.getScopeHash() == null || latestChunk.getScopeHash().isBlank()) {
log.warn("[appearance-patent] stale recovery found no latest chunk to complete taskId={}", taskId);
return 0;
}
log.info("[appearance-patent] stale recovery recreating input scope from chunk taskId={} scopeKey={} scopeHash={} chunkTotal={}",
taskId, latestChunk.getScopeKey(), latestChunk.getScopeHash(), latestChunk.getChunkTotal());
upsertScopeState(taskId,
firstNonBlank(latestChunk.getScopeKey(), "task:" + taskId),
latestChunk.getScopeHash(),
latestChunk.getChunkTotal(),
null,
true,
false);
return 1;
}
LocalDateTime now = LocalDateTime.now();
int updated = 0;
for (TaskScopeStateEntity state : inputStates) {
if (state == null || state.getId() == null) {
continue;
}
state.setCompleted(1);
if (state.getLastChunkAt() == null) {
state.setLastChunkAt(now);
}
state.setUpdatedAt(now);
state.setStateJson("{\"phase\":\"RECEIVED\",\"coze\":\"PENDING\"}");
taskScopeStateMapper.updateById(state);
updated++;
}
return updated;
}
private void upsertScopeState(Long taskId, private void upsertScopeState(Long taskId,
String scopeKey, String scopeKey,
String scopeHash, String scopeHash,
@@ -765,6 +908,8 @@ public class AppearancePatentTaskService {
if (scope.getId() == null) { if (scope.getId() == null) {
try { try {
taskScopeStateMapper.insert(scope); taskScopeStateMapper.insert(scope);
log.info("[appearance-patent] scope state inserted taskId={} scope={} scopeHash={} completed={} cozeDone={}",
taskId, scopeKey, scopeHash, completed, cozeDone);
return; return;
} catch (DuplicateKeyException ex) { } catch (DuplicateKeyException ex) {
log.info("[appearance-patent] duplicate scope state inserted concurrently taskId={} scope={}", taskId, scopeKey); log.info("[appearance-patent] duplicate scope state inserted concurrently taskId={} scope={}", taskId, scopeKey);
@@ -792,6 +937,8 @@ public class AppearancePatentTaskService {
} }
} }
taskScopeStateMapper.updateById(scope); taskScopeStateMapper.updateById(scope);
log.info("[appearance-patent] scope state updated taskId={} scope={} scopeHash={} completed={} cozeDone={}",
taskId, scopeKey, scopeHash, completed, cozeDone);
} }
private <T> T inNewTransaction(Supplier<T> action) { private <T> T inNewTransaction(Supplier<T> action) {
@@ -1085,6 +1232,7 @@ public class AppearancePatentTaskService {
row.setUrl(firstNonBlank(representative.getUrl(), sibling.getUrl())); row.setUrl(firstNonBlank(representative.getUrl(), sibling.getUrl()));
row.setTitle(firstNonBlank(representative.getTitle(), sibling.getTitle())); row.setTitle(firstNonBlank(representative.getTitle(), sibling.getTitle()));
row.setError(representative.getError()); row.setError(representative.getError());
row.setStatus(representative.getStatus());
row.setTitleRisk(representative.getTitleRisk()); row.setTitleRisk(representative.getTitleRisk());
row.setAppearanceRisk(representative.getAppearanceRisk()); row.setAppearanceRisk(representative.getAppearanceRisk());
row.setPatentRisk(representative.getPatentRisk()); row.setPatentRisk(representative.getPatentRisk());
@@ -1185,7 +1333,7 @@ public class AppearancePatentTaskService {
return null; return null;
} }
TaskFileJobEntity job = taskFileJobService.enqueueAssembleResult( TaskFileJobEntity job = taskFileJobService.enqueueAssembleResult(
task.getId(), MODULE_TYPE, result.getId(), buildTaskOwnerScopeKey(task.getId())); task.getId(), MODULE_TYPE, result.getId(), buildTaskOwnerScopeKey(task));
if (dispatchWhenIdle if (dispatchWhenIdle
&& job != null && job != null
&& "RUNNING".equals(job.getStatus()) && "RUNNING".equals(job.getStatus())
@@ -1220,11 +1368,6 @@ public class AppearancePatentTaskService {
if (job == null || job.getTaskId() == null || job.getResultId() == null) { if (job == null || job.getTaskId() == null || job.getResultId() == null) {
throw new BusinessException("result file job arguments are incomplete"); throw new BusinessException("result file job arguments are incomplete");
} }
if (!isJobOwnedByCurrentInstance(job)) {
log.info("[appearance-patent] skip result file job because owner is another instance jobId={} taskId={} owner={} current={}",
job.getId(), job.getTaskId(), ownerFromScopeKey(job.getScopeKey()), currentInstanceId());
return false;
}
TaskDistributedLockService.LockHandle lockHandle = acquireTaskLock(job.getTaskId(), TASK_LOCK_WAIT_MILLIS); TaskDistributedLockService.LockHandle lockHandle = acquireTaskLock(job.getTaskId(), TASK_LOCK_WAIT_MILLIS);
if (lockHandle == null) { if (lockHandle == null) {
taskFileJobService.requeue(job.getId(), "Task is busy, waiting for appearance patent result merge"); taskFileJobService.requeue(job.getId(), "Task is busy, waiting for appearance patent result merge");
@@ -1241,6 +1384,13 @@ public class AppearancePatentTaskService {
if (task == null || !MODULE_TYPE.equals(task.getModuleType())) { if (task == null || !MODULE_TYPE.equals(task.getModuleType())) {
throw new BusinessException("task not found"); throw new BusinessException("task not found");
} }
ensureTaskOwnedByCurrentInstance(task, "assemble result file");
job.setScopeKey(buildTaskOwnerScopeKey(task));
if (!isJobOwnedByCurrentInstance(job)) {
log.info("[appearance-patent] skip result file job because owner is another instance jobId={} taskId={} owner={} current={}",
job.getId(), job.getTaskId(), ownerFromScopeKey(job.getScopeKey()), currentInstanceId());
return false;
}
FileResultEntity result = fileResultMapper.selectById(job.getResultId()); FileResultEntity result = fileResultMapper.selectById(job.getResultId());
if (result == null || !MODULE_TYPE.equals(result.getModuleType())) { if (result == null || !MODULE_TYPE.equals(result.getModuleType())) {
throw new BusinessException("result record not found"); throw new BusinessException("result record not found");
@@ -1250,7 +1400,8 @@ public class AppearancePatentTaskService {
.eq(TaskChunkEntity::getModuleType, MODULE_TYPE) .eq(TaskChunkEntity::getModuleType, MODULE_TYPE)
.orderByAsc(TaskChunkEntity::getChunkIndex)); .orderByAsc(TaskChunkEntity::getChunkIndex));
int cozeWorkUnits = countCozeWorkUnits(chunks, Math.max(1, properties.getCozeBatchSize())); int cozeWorkUnits = countCozeWorkUnits(chunks, Math.max(1, properties.getCozeBatchSize()));
int totalProgressUnits = Math.max(3, cozeWorkUnits + 3); int plannedCozeUnits = Math.max(cozeWorkUnits, countAllCozeStates(task.getId()));
int totalProgressUnits = Math.max(3, plannedCozeUnits + 3);
if (countPendingCozeStates(task.getId()) > 0) { if (countPendingCozeStates(task.getId()) > 0) {
taskFileJobService.touchRunning(job.getId()); taskFileJobService.touchRunning(job.getId());
touchJavaSideTaskActivity(task.getId()); touchJavaSideTaskActivity(task.getId());
@@ -1269,10 +1420,10 @@ public class AppearancePatentTaskService {
if (STATUS_RUNNING.equals(task.getStatus()) && !isResultSubmissionComplete(task.getId())) { if (STATUS_RUNNING.equals(task.getStatus()) && !isResultSubmissionComplete(task.getId())) {
taskFileJobService.touchRunning(job.getId()); taskFileJobService.touchRunning(job.getId());
touchJavaSideTaskActivity(task.getId()); touchJavaSideTaskActivity(task.getId());
saveFileBuildProgress(task, job, totalProgressUnits, Math.max(1, cozeWorkUnits), "等待 Python 继续回传数据"); saveFileBuildProgress(task, job, totalProgressUnits, Math.max(1, plannedCozeUnits), "等待 Python 继续回传数据");
return false; return false;
} }
completeCozeFileJob(task, result, job, totalProgressUnits, cozeWorkUnits); completeCozeFileJob(task, result, job, totalProgressUnits);
return true; return true;
} }
@@ -1555,10 +1706,10 @@ public class AppearancePatentTaskService {
markCozeStateTerminal(state, COZE_STATUS_FAILED, "Coze batch context missing"); markCozeStateTerminal(state, COZE_STATUS_FAILED, "Coze batch context missing");
return; return;
} }
if (!isOwnerCurrent(context.ownerInstanceId())) { if (!isOwnerCurrent(context.ownerInstanceId())) {
log.info("[appearance-patent] coze poll skipped after context refresh because owner is another instance taskId={} stateId={} owner={} current={}", log.info("[appearance-patent] coze poll skipped after context refresh because owner is another instance taskId={} stateId={} owner={} current={}",
state.getTaskId(), state.getId(), context.ownerInstanceId(), currentInstanceId()); state.getTaskId(), state.getId(), context.ownerInstanceId(), currentInstanceId());
return; return;
} }
taskFileJobService.touchRunning(context.jobId()); taskFileJobService.touchRunning(context.jobId());
log.info("[appearance-patent] coze poll start taskId={} stateId={} executeId={} jobId={} chunk={} batch={}/{}", log.info("[appearance-patent] coze poll start taskId={} stateId={} executeId={} jobId={} chunk={} batch={}/{}",
@@ -1924,6 +2075,9 @@ public class AppearancePatentTaskService {
if (taskId == null || context == null || countPendingCozeStates(taskId) > 0) { if (taskId == null || context == null || countPendingCozeStates(taskId) > 0) {
return; return;
} }
if (!isOwnerCurrent(context.ownerInstanceId())) {
return;
}
TaskDistributedLockService.LockHandle taskLockHandle = acquireTaskLock(taskId, 0L); TaskDistributedLockService.LockHandle taskLockHandle = acquireTaskLock(taskId, 0L);
if (taskLockHandle == null) { if (taskLockHandle == null) {
return; return;
@@ -1974,12 +2128,11 @@ public class AppearancePatentTaskService {
private void completeCozeFileJob(FileTaskEntity task, private void completeCozeFileJob(FileTaskEntity task,
FileResultEntity result, FileResultEntity result,
TaskFileJobEntity job, TaskFileJobEntity job,
int totalProgressUnits, int totalProgressUnits) {
int cozeWorkUnits) {
if (countPendingCozeStates(task.getId()) > 0) { if (countPendingCozeStates(task.getId()) > 0) {
throw new BusinessException("Coze 结果仍在处理中,暂不能生成结果文件"); throw new BusinessException("Coze 结果仍在处理中,暂不能生成结果文件");
} }
int assembleProgress = Math.max(1, Math.min(totalProgressUnits - 2, cozeWorkUnits)); int assembleProgress = Math.max(1, totalProgressUnits - 2);
saveFileBuildProgress(task, job, totalProgressUnits, assembleProgress, "正在组装 xlsx"); saveFileBuildProgress(task, job, totalProgressUnits, assembleProgress, "正在组装 xlsx");
assembleResultWorkbook(task, result); assembleResultWorkbook(task, result);
saveFileBuildProgress(task, job, totalProgressUnits, totalProgressUnits - 1, "正在上传结果文件"); saveFileBuildProgress(task, job, totalProgressUnits, totalProgressUnits - 1, "正在上传结果文件");
@@ -2216,8 +2369,9 @@ public class AppearancePatentTaskService {
return "coze:task:" + taskId + ":rows:" + DigestUtil.sha256Hex(rowKeys.toString()); return "coze:task:" + taskId + ":rows:" + DigestUtil.sha256Hex(rowKeys.toString());
} }
private String buildTaskOwnerScopeKey(Long taskId) { private String buildTaskOwnerScopeKey(FileTaskEntity task) {
return "task:" + taskId + ":owner:" + currentInstanceId(); Long taskId = task == null ? null : task.getId();
return "task:" + taskId + ":owner:" + firstNonBlank(ownerFromTask(task), currentInstanceId());
} }
private String currentInstanceId() { private String currentInstanceId() {
@@ -2577,7 +2731,8 @@ public class AppearancePatentTaskService {
row.createCell(col++).setCellValue(resultRow == null ? missingReason : userFacingCozeCellValue(resultRow, resultRow.getTitleRisk())); 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.getAppearanceRisk()));
row.createCell(col++).setCellValue(resultRow == null ? missingReason : userFacingCozeCellValue(resultRow, resultRow.getPatentRisk())); row.createCell(col++).setCellValue(resultRow == null ? missingReason : userFacingCozeCellValue(resultRow, resultRow.getPatentRisk()));
row.createCell(col).setCellValue(resultRow == null ? "未送检" : userFacingConclusion(resultRow)); row.createCell(col++).setCellValue(resultRow == null ? "未送检" : userFacingConclusion(resultRow));
row.createCell(col).setCellValue(resultRow == null ? "" : firstNonBlank(resultRow.getStatus(), ""));
} }
writeReasonSheet(workbook, headerStyle, rowsToWrite, resultMap); writeReasonSheet(workbook, headerStyle, rowsToWrite, resultMap);
workbook.write(fos); workbook.write(fos);
@@ -2962,7 +3117,7 @@ public class AppearancePatentTaskService {
} }
vo.setFileJobId(job.getId()); vo.setFileJobId(job.getId());
vo.setFileStatus(job.getStatus()); vo.setFileStatus(job.getStatus());
vo.setFileError(job.getErrorMessage()); vo.setFileError(STATUS_FAILED.equals(job.getStatus()) ? firstNonBlank(job.getErrorMessage(), null) : null);
attachFileProgress(vo, row, job); attachFileProgress(vo, row, job);
} }

View File

@@ -34,6 +34,8 @@ public class DebugRequestInfoController {
) { ) {
Map<String, Object> result = new LinkedHashMap<>(); Map<String, Object> result = new LinkedHashMap<>();
result.put("instanceId", instanceMetadata.getInstanceId()); result.put("instanceId", instanceMetadata.getInstanceId());
result.put("instanceSource", instanceMetadata.getSource());
result.put("instanceStable", instanceMetadata.isStable());
result.put("hostname", instanceMetadata.getHostname()); result.put("hostname", instanceMetadata.getHostname());
result.put("serverName", request.getServerName()); result.put("serverName", request.getServerName());
result.put("serverPort", request.getServerPort()); result.put("serverPort", request.getServerPort());

View File

@@ -0,0 +1,44 @@
package com.nanri.aiimage.modules.debug.controller;
import com.nanri.aiimage.modules.appearancepatent.service.AppearancePatentTaskService;
import com.nanri.aiimage.modules.similarasin.service.SimilarAsinTaskService;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.LinkedHashMap;
import java.util.Locale;
import java.util.Map;
@RestController
@RequestMapping("/debug/task-recovery")
public class DebugTaskRecoveryController {
private final AppearancePatentTaskService appearancePatentTaskService;
private final SimilarAsinTaskService similarAsinTaskService;
public DebugTaskRecoveryController(AppearancePatentTaskService appearancePatentTaskService,
SimilarAsinTaskService similarAsinTaskService) {
this.appearancePatentTaskService = appearancePatentTaskService;
this.similarAsinTaskService = similarAsinTaskService;
}
@PostMapping("/run-stale-finalize")
public Map<String, Object> runStaleFinalize(@RequestParam("module") String module,
@RequestParam("taskId") Long taskId) {
String normalizedModule = module == null ? "" : module.trim().toUpperCase(Locale.ROOT);
if ("APPEARANCE_PATENT".equals(normalizedModule)) {
appearancePatentTaskService.debugFinalizeStaleTask(taskId);
} else if ("SIMILAR_ASIN".equals(normalizedModule)) {
similarAsinTaskService.debugFinalizeStaleTask(taskId);
} else {
throw new IllegalArgumentException("Unsupported module: " + module);
}
Map<String, Object> result = new LinkedHashMap<>();
result.put("module", normalizedModule);
result.put("taskId", taskId);
result.put("accepted", true);
return result;
}
}

View File

@@ -1,6 +1,7 @@
package com.nanri.aiimage.modules.shopkey.controller; package com.nanri.aiimage.modules.shopkey.controller;
import com.nanri.aiimage.common.api.ApiResponse; import com.nanri.aiimage.common.api.ApiResponse;
import com.nanri.aiimage.common.util.DownloadHeaderUtil;
import com.nanri.aiimage.modules.shopkey.model.dto.SkipPriceAsinCountryUpdateRequest; import com.nanri.aiimage.modules.shopkey.model.dto.SkipPriceAsinCountryUpdateRequest;
import com.nanri.aiimage.modules.shopkey.model.dto.SkipPriceAsinCreateRequest; import com.nanri.aiimage.modules.shopkey.model.dto.SkipPriceAsinCreateRequest;
import com.nanri.aiimage.modules.shopkey.model.vo.QueryAsinImportProgressVo; import com.nanri.aiimage.modules.shopkey.model.vo.QueryAsinImportProgressVo;
@@ -13,6 +14,9 @@ import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PathVariable;
@@ -24,24 +28,36 @@ import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
@RestController @RestController
@RequiredArgsConstructor @RequiredArgsConstructor
@RequestMapping("/api/admin/skip-price-asins") @RequestMapping({
@Tag(name = "跳过跟价 ASIN", description = "维护店铺按国家跳过跟价的 ASIN") "/admin/skip-price-asins",
"/admin/skip-price-asin",
"/api/admin/skip-price-asins",
"/api/admin/skip-price-asin",
"/skip-price-asins",
"/skip-price-asin"
})
@Tag(name = "Skip Price ASIN", description = "Manage skip price asin data")
public class SkipPriceAsinController { public class SkipPriceAsinController {
private static final DateTimeFormatter EXPORT_FILENAME_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss");
private final SkipPriceAsinService skipPriceAsinService; private final SkipPriceAsinService skipPriceAsinService;
@GetMapping @GetMapping
@Operation(summary = "分页查询跳过跟价 ASIN") @Operation(summary = "Page query skip price asins")
public ApiResponse<SkipPriceAsinPageVo> page( public ApiResponse<SkipPriceAsinPageVo> page(
@Parameter(description = "页码") @RequestParam(defaultValue = "1") Long page, @Parameter(description = "page") @RequestParam(name = "page", defaultValue = "1") Long page,
@Parameter(description = "每页数量") @RequestParam(defaultValue = "15") Long pageSize, @Parameter(description = "page size") @RequestParam(name = "page_size", defaultValue = "15") Long pageSize,
@Parameter(description = "分组ID") @RequestParam(required = false) Long groupId, @Parameter(description = "group id") @RequestParam(name = "group_id", required = false) Long groupId,
@Parameter(description = "店铺名") @RequestParam(required = false) String shopName, @Parameter(description = "shop name") @RequestParam(name = "shop_name", required = false) String shopName,
@Parameter(description = "ASIN") @RequestParam(required = false) String asin, @Parameter(description = "asin") @RequestParam(name = "asin", required = false) String asin,
@Parameter(description = "当前操作人用户ID") @RequestParam(required = false) Long operatorId, @Parameter(description = "operator user id") @RequestParam(name = "operator_id", required = false) Long operatorId,
@Parameter(description = "是否超级管理员") @RequestParam(defaultValue = "false") Boolean superAdmin) { @Parameter(description = "is super admin") @RequestParam(name = "super_admin", defaultValue = "false") Boolean superAdmin) {
return ApiResponse.success(skipPriceAsinService.page( return ApiResponse.success(skipPriceAsinService.page(
page, page,
pageSize, pageSize,
@@ -52,59 +68,76 @@ public class SkipPriceAsinController {
Boolean.TRUE.equals(superAdmin))); Boolean.TRUE.equals(superAdmin)));
} }
@GetMapping("/export")
@Operation(summary = "Export skip price asins")
public ResponseEntity<byte[]> export(
@Parameter(description = "group id") @RequestParam(name = "group_id", required = false) Long groupId,
@Parameter(description = "shop name") @RequestParam(name = "shop_name", required = false) String shopName,
@Parameter(description = "asin") @RequestParam(name = "asin", required = false) String asin,
@Parameter(description = "operator user id") @RequestParam(name = "operator_id", required = false) Long operatorId,
@Parameter(description = "is super admin") @RequestParam(name = "super_admin", defaultValue = "false") Boolean superAdmin) {
byte[] bytes = skipPriceAsinService.export(groupId, shopName, asin, operatorId, Boolean.TRUE.equals(superAdmin));
String filename = "skip-price-asin-" + LocalDateTime.now().format(EXPORT_FILENAME_FORMATTER) + ".xlsx";
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, DownloadHeaderUtil.contentDisposition(filename))
.contentType(MediaType.parseMediaType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"))
.contentLength(bytes.length)
.body(bytes);
}
@PostMapping @PostMapping
@Operation(summary = "新增或覆盖跳过跟价 ASIN") @Operation(summary = "Create or update skip price asin")
public ApiResponse<SkipPriceAsinItemVo> create( public ApiResponse<SkipPriceAsinItemVo> create(
@Parameter(description = "当前操作人用户ID") @RequestParam Long operatorId, @Parameter(description = "operator user id") @RequestParam(name = "operator_id") Long operatorId,
@Parameter(description = "是否超级管理员") @RequestParam(defaultValue = "false") Boolean superAdmin, @Parameter(description = "is super admin") @RequestParam(name = "super_admin", defaultValue = "false") Boolean superAdmin,
@Valid @RequestBody SkipPriceAsinCreateRequest request) { @Valid @RequestBody SkipPriceAsinCreateRequest request) {
return ApiResponse.success("保存成功", return ApiResponse.success("\u4fdd\u5b58\u6210\u529f",
skipPriceAsinService.createOrUpdate(request, operatorId, Boolean.TRUE.equals(superAdmin))); skipPriceAsinService.createOrUpdate(request, operatorId, Boolean.TRUE.equals(superAdmin)));
} }
@PostMapping("/import") @PostMapping("/import")
@Operation(summary = "导入新增跳过跟价 ASIN") @Operation(summary = "Import skip price asin")
public ApiResponse<QueryAsinImportStartVo> importExcel( public ApiResponse<QueryAsinImportStartVo> importExcel(
@Parameter(description = "xlsx/xls 文件,文件名必须等于店铺名", required = true) @RequestParam("file") MultipartFile file, @Parameter(description = "xlsx or xls file", required = true) @RequestParam("file") MultipartFile file,
@Parameter(description = "页面选择的分组ID", required = true) @RequestParam Long groupId, @Parameter(description = "group id", required = true) @RequestParam(name = "group_id") Long groupId,
@Parameter(description = "当前操作人用户ID") @RequestParam Long operatorId, @Parameter(description = "operator user id") @RequestParam(name = "operator_id") Long operatorId,
@Parameter(description = "是否超级管理员") @RequestParam(defaultValue = "false") Boolean superAdmin) { @Parameter(description = "is super admin") @RequestParam(name = "super_admin", defaultValue = "false") Boolean superAdmin) {
return ApiResponse.success("开始导入", return ApiResponse.success("\u5f00\u59cb\u5bfc\u5165",
skipPriceAsinService.startImport(file, groupId, operatorId, Boolean.TRUE.equals(superAdmin))); skipPriceAsinService.startImport(file, groupId, operatorId, Boolean.TRUE.equals(superAdmin)));
} }
@GetMapping("/import/{importId}") @GetMapping("/import/{importId}")
@Operation(summary = "查询导入新增进度") @Operation(summary = "Query import progress")
public ApiResponse<QueryAsinImportProgressVo> importProgress(@PathVariable String importId) { public ApiResponse<QueryAsinImportProgressVo> importProgress(@PathVariable String importId) {
return ApiResponse.success(skipPriceAsinService.getImportProgress(importId)); return ApiResponse.success(skipPriceAsinService.getImportProgress(importId));
} }
@PostMapping("/delete-import") @PostMapping("/delete-import")
@Operation(summary = "导入删除跳过跟价 ASIN") @Operation(summary = "Import delete skip price asin")
public ApiResponse<QueryAsinImportStartVo> deleteImportExcel( public ApiResponse<QueryAsinImportStartVo> deleteImportExcel(
@Parameter(description = "xlsx/xls 文件,文件名必须等于店铺名", required = true) @RequestParam("file") MultipartFile file, @Parameter(description = "xlsx or xls file", required = true) @RequestParam("file") MultipartFile file,
@Parameter(description = "页面选择的分组ID", required = true) @RequestParam Long groupId, @Parameter(description = "group id", required = true) @RequestParam(name = "group_id") Long groupId,
@Parameter(description = "当前操作人用户ID") @RequestParam Long operatorId, @Parameter(description = "operator user id") @RequestParam(name = "operator_id") Long operatorId,
@Parameter(description = "是否超级管理员") @RequestParam(defaultValue = "false") Boolean superAdmin) { @Parameter(description = "is super admin") @RequestParam(name = "super_admin", defaultValue = "false") Boolean superAdmin) {
return ApiResponse.success("开始删除", return ApiResponse.success("\u5f00\u59cb\u5220\u9664",
skipPriceAsinService.startDeleteImport(file, groupId, operatorId, Boolean.TRUE.equals(superAdmin))); skipPriceAsinService.startDeleteImport(file, groupId, operatorId, Boolean.TRUE.equals(superAdmin)));
} }
@GetMapping("/delete-import/{importId}") @GetMapping("/delete-import/{importId}")
@Operation(summary = "查询导入删除进度") @Operation(summary = "Query delete import progress")
public ApiResponse<QueryAsinImportProgressVo> deleteImportProgress(@PathVariable String importId) { public ApiResponse<QueryAsinImportProgressVo> deleteImportProgress(@PathVariable String importId) {
return ApiResponse.success(skipPriceAsinService.getDeleteImportProgress(importId)); return ApiResponse.success(skipPriceAsinService.getDeleteImportProgress(importId));
} }
@PutMapping("/{id}/countries/{country}") @PutMapping({"/{id}/countries/{country}", "/{id}/country/{country}"})
@Operation(summary = "编辑指定国家的跳过跟价 ASIN") @Operation(summary = "Update one country skip price asin")
public ApiResponse<SkipPriceAsinItemVo> updateCountry( public ApiResponse<SkipPriceAsinItemVo> updateCountry(
@Parameter(description = "主键ID", required = true) @PathVariable Long id, @Parameter(description = "id", required = true) @PathVariable Long id,
@Parameter(description = "国家编码", required = true) @PathVariable String country, @Parameter(description = "country code", required = true) @PathVariable String country,
@Parameter(description = "当前操作人用户ID") @RequestParam Long operatorId, @Parameter(description = "operator user id") @RequestParam(name = "operator_id") Long operatorId,
@Parameter(description = "是否超级管理员") @RequestParam(defaultValue = "false") Boolean superAdmin, @Parameter(description = "is super admin") @RequestParam(name = "super_admin", defaultValue = "false") Boolean superAdmin,
@Valid @RequestBody SkipPriceAsinCountryUpdateRequest request) { @Valid @RequestBody SkipPriceAsinCountryUpdateRequest request) {
return ApiResponse.success("保存成功", skipPriceAsinService.updateCountry( return ApiResponse.success("\u4fdd\u5b58\u6210\u529f", skipPriceAsinService.updateCountry(
id, id,
country, country,
request.getAsin(), request.getAsin(),
@@ -113,14 +146,14 @@ public class SkipPriceAsinController {
Boolean.TRUE.equals(superAdmin))); Boolean.TRUE.equals(superAdmin)));
} }
@DeleteMapping("/{id}/countries/{country}") @DeleteMapping({"/{id}/countries/{country}", "/{id}/country/{country}"})
@Operation(summary = "删除指定国家的跳过跟价 ASIN") @Operation(summary = "Delete one country skip price asin")
public ApiResponse<Void> deleteCountry( public ApiResponse<Void> deleteCountry(
@Parameter(description = "主键ID", required = true) @PathVariable Long id, @Parameter(description = "id", required = true) @PathVariable Long id,
@Parameter(description = "国家编码", required = true) @PathVariable String country, @Parameter(description = "country code", required = true) @PathVariable String country,
@Parameter(description = "当前操作人用户ID") @RequestParam Long operatorId, @Parameter(description = "operator user id") @RequestParam(name = "operator_id") Long operatorId,
@Parameter(description = "是否超级管理员") @RequestParam(defaultValue = "false") Boolean superAdmin) { @Parameter(description = "is super admin") @RequestParam(name = "super_admin", defaultValue = "false") Boolean superAdmin) {
skipPriceAsinService.deleteCountry(id, country, operatorId, Boolean.TRUE.equals(superAdmin)); skipPriceAsinService.deleteCountry(id, country, operatorId, Boolean.TRUE.equals(superAdmin));
return ApiResponse.success("删除成功", null); return ApiResponse.success("\u5220\u9664\u6210\u529f", null);
} }
} }

View File

@@ -22,16 +22,19 @@ import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.ss.usermodel.Sheet; import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.ss.usermodel.Workbook; import org.apache.poi.ss.usermodel.Workbook;
import org.apache.poi.ss.usermodel.WorkbookFactory; import org.apache.poi.ss.usermodel.WorkbookFactory;
import org.apache.poi.xssf.streaming.SXSSFWorkbook;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
import java.io.ByteArrayOutputStream;
import java.io.File; import java.io.File;
import java.io.FileInputStream; import java.io.FileInputStream;
import java.nio.file.Files; import java.nio.file.Files;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.math.RoundingMode; import java.math.RoundingMode;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.LinkedHashSet; import java.util.LinkedHashSet;
import java.util.List; import java.util.List;
@@ -46,6 +49,7 @@ import java.util.concurrent.ConcurrentHashMap;
public class SkipPriceAsinService { public class SkipPriceAsinService {
private static final List<String> SUPPORTED_COUNTRIES = List.of("DE", "UK", "FR", "IT", "ES"); private static final List<String> SUPPORTED_COUNTRIES = List.of("DE", "UK", "FR", "IT", "ES");
private static final DateTimeFormatter EXPORT_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
private final SkipPriceAsinMapper skipPriceAsinMapper; private final SkipPriceAsinMapper skipPriceAsinMapper;
private final ShopManageMapper shopManageMapper; private final ShopManageMapper shopManageMapper;
@@ -57,32 +61,15 @@ public class SkipPriceAsinService {
Long operatorId, boolean superAdmin) { Long operatorId, boolean superAdmin) {
long safePage = Math.max(page, 1); long safePage = Math.max(page, 1);
long safePageSize = Math.min(Math.max(pageSize, 1), 100); long safePageSize = Math.min(Math.max(pageSize, 1), 100);
String safeShopName = normalizeBlank(shopName); Long total = countFilteredRows(groupId, shopName, asin, operatorId, superAdmin);
String safeAsin = normalizeBlank(asin); List<SkipPriceAsinEntity> rows = listFilteredRows(
Set<Long> accessibleGroupIds = superAdmin ? Set.of() : shopManageGroupService.listAccessibleGroupIds(operatorId, false); groupId,
shopName,
LambdaQueryWrapper<SkipPriceAsinEntity> query = new LambdaQueryWrapper<SkipPriceAsinEntity>() asin,
.eq(groupId != null && groupId > 0, SkipPriceAsinEntity::getGroupId, groupId) operatorId,
.like(!safeShopName.isEmpty(), SkipPriceAsinEntity::getShopName, safeShopName) superAdmin,
.and(!safeAsin.isEmpty(), wrapper -> wrapper (safePage - 1) * safePageSize,
.like(SkipPriceAsinEntity::getAsinDe, safeAsin) safePageSize);
.or().like(SkipPriceAsinEntity::getAsinUk, safeAsin)
.or().like(SkipPriceAsinEntity::getAsinFr, safeAsin)
.or().like(SkipPriceAsinEntity::getAsinIt, safeAsin)
.or().like(SkipPriceAsinEntity::getAsinEs, safeAsin))
.orderByDesc(SkipPriceAsinEntity::getId);
if (!superAdmin) {
if (accessibleGroupIds.isEmpty()) {
query.eq(SkipPriceAsinEntity::getId, -1L);
} else {
query.in(SkipPriceAsinEntity::getGroupId, accessibleGroupIds);
}
}
Long total = skipPriceAsinMapper.selectCount(query);
List<SkipPriceAsinEntity> rows = skipPriceAsinMapper
.selectList(query.last("LIMIT " + ((safePage - 1) * safePageSize) + ", " + safePageSize));
Map<Long, String> groupNameById = shopManageGroupService.buildGroupNameMap(rows.stream() Map<Long, String> groupNameById = shopManageGroupService.buildGroupNameMap(rows.stream()
.map(SkipPriceAsinEntity::getGroupId) .map(SkipPriceAsinEntity::getGroupId)
@@ -98,6 +85,127 @@ public class SkipPriceAsinService {
return vo; return vo;
} }
public byte[] export(Long groupId, String shopName, String asin, Long operatorId, boolean superAdmin) {
List<SkipPriceAsinEntity> rows = listFilteredRows(groupId, shopName, asin, operatorId, superAdmin, null, null);
Map<Long, String> groupNameById = shopManageGroupService.buildGroupNameMap(rows.stream()
.map(SkipPriceAsinEntity::getGroupId)
.filter(id -> id != null && id > 0)
.distinct()
.toList());
try (SXSSFWorkbook workbook = new SXSSFWorkbook(100);
ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
Sheet sheet = workbook.createSheet("SkipPriceAsin");
writeExportHeader(sheet);
for (int index = 0; index < rows.size(); index++) {
writeExportRow(sheet, index + 1, rows.get(index), groupNameById.get(rows.get(index).getGroupId()));
}
applyExportColumnWidths(sheet);
workbook.write(outputStream);
workbook.dispose();
return outputStream.toByteArray();
} catch (Exception ex) {
throw new BusinessException("导出跳过跟价 ASIN 失败");
}
}
private Long countFilteredRows(Long groupId, String shopName, String asin, Long operatorId, boolean superAdmin) {
return skipPriceAsinMapper.selectCount(buildFilterQuery(groupId, shopName, asin, operatorId, superAdmin));
}
private List<SkipPriceAsinEntity> listFilteredRows(Long groupId, String shopName, String asin,
Long operatorId, boolean superAdmin,
Long offset, Long limit) {
LambdaQueryWrapper<SkipPriceAsinEntity> query = buildFilterQuery(groupId, shopName, asin, operatorId, superAdmin);
if (offset != null && limit != null) {
query.last("LIMIT " + Math.max(0L, offset) + ", " + Math.max(1L, limit));
}
return skipPriceAsinMapper.selectList(query);
}
private LambdaQueryWrapper<SkipPriceAsinEntity> buildFilterQuery(Long groupId, String shopName, String asin,
Long operatorId, boolean superAdmin) {
String safeShopName = normalizeBlank(shopName);
String safeAsin = normalizeBlank(asin);
Set<Long> accessibleGroupIds = superAdmin ? Set.of() : shopManageGroupService.listAccessibleGroupIds(operatorId, false);
LambdaQueryWrapper<SkipPriceAsinEntity> query = new LambdaQueryWrapper<SkipPriceAsinEntity>()
.eq(groupId != null && groupId > 0, SkipPriceAsinEntity::getGroupId, groupId)
.like(!safeShopName.isEmpty(), SkipPriceAsinEntity::getShopName, safeShopName)
.and(!safeAsin.isEmpty(), wrapper -> wrapper
.like(SkipPriceAsinEntity::getAsinDe, safeAsin)
.or().like(SkipPriceAsinEntity::getAsinUk, safeAsin)
.or().like(SkipPriceAsinEntity::getAsinFr, safeAsin)
.or().like(SkipPriceAsinEntity::getAsinIt, safeAsin)
.or().like(SkipPriceAsinEntity::getAsinEs, safeAsin))
.orderByDesc(SkipPriceAsinEntity::getId);
if (!superAdmin) {
if (accessibleGroupIds.isEmpty()) {
query.eq(SkipPriceAsinEntity::getId, -1L);
} else {
query.in(SkipPriceAsinEntity::getGroupId, accessibleGroupIds);
}
}
return query;
}
private void writeExportHeader(Sheet sheet) {
Row header = sheet.createRow(0);
String[] headers = {
"序号", "分组", "店铺名",
"德国ASIN", "德国最低价",
"英国ASIN", "英国最低价",
"法国ASIN", "法国最低价",
"意大利ASIN", "意大利最低价",
"西班牙ASIN", "西班牙最低价",
"创建时间", "更新时间"
};
for (int i = 0; i < headers.length; i++) {
header.createCell(i).setCellValue(headers[i]);
}
}
private void writeExportRow(Sheet sheet, int rowNo, SkipPriceAsinEntity entity, String groupName) {
Row row = sheet.createRow(rowNo);
int col = 0;
row.createCell(col++).setCellValue(rowNo);
row.createCell(col++).setCellValue(blankToEmpty(groupName));
row.createCell(col++).setCellValue(blankToEmpty(entity.getShopName()));
row.createCell(col++).setCellValue(blankToEmpty(entity.getAsinDe()));
row.createCell(col++).setCellValue(formatExportPrice(entity.getMinimumPriceDe()));
row.createCell(col++).setCellValue(blankToEmpty(entity.getAsinUk()));
row.createCell(col++).setCellValue(formatExportPrice(entity.getMinimumPriceUk()));
row.createCell(col++).setCellValue(blankToEmpty(entity.getAsinFr()));
row.createCell(col++).setCellValue(formatExportPrice(entity.getMinimumPriceFr()));
row.createCell(col++).setCellValue(blankToEmpty(entity.getAsinIt()));
row.createCell(col++).setCellValue(formatExportPrice(entity.getMinimumPriceIt()));
row.createCell(col++).setCellValue(blankToEmpty(entity.getAsinEs()));
row.createCell(col++).setCellValue(formatExportPrice(entity.getMinimumPriceEs()));
row.createCell(col++).setCellValue(formatExportTime(entity.getCreatedAt()));
row.createCell(col).setCellValue(formatExportTime(entity.getUpdatedAt()));
}
private void applyExportColumnWidths(Sheet sheet) {
int[] widths = {
2800, 5200, 5200,
4600, 3600,
4600, 3600,
4600, 3600,
4600, 3600,
4600, 3600,
5200, 5200
};
for (int i = 0; i < widths.length; i++) {
sheet.setColumnWidth(i, widths[i]);
}
}
private String formatExportPrice(BigDecimal value) {
return value == null ? "" : value.stripTrailingZeros().toPlainString();
}
private String formatExportTime(LocalDateTime value) {
return value == null ? "" : value.format(EXPORT_TIME_FORMATTER);
}
@Transactional @Transactional
public SkipPriceAsinItemVo createOrUpdate(SkipPriceAsinCreateRequest request, Long operatorId, boolean superAdmin) { public SkipPriceAsinItemVo createOrUpdate(SkipPriceAsinCreateRequest request, Long operatorId, boolean superAdmin) {
ShopManageGroupEntity group = shopManageGroupService.getAccessibleById(request.getGroupId(), operatorId, superAdmin); ShopManageGroupEntity group = shopManageGroupService.getAccessibleById(request.getGroupId(), operatorId, superAdmin);

View File

@@ -259,12 +259,18 @@ public class SimilarAsinCozeClient {
} }
private Map<String, Object> buildParameters(List<SimilarAsinResultRowDto> rows, String prompt, String apiKey) { private Map<String, Object> buildParameters(List<SimilarAsinResultRowDto> rows, String prompt, String apiKey) {
List<String> groupKeys = rows.stream().map(row -> nonBlank(row.getGroupKey(), rowKey(row.getId(), row.getAsin(), row.getCountry()))).toList();
List<String> rowTokens = rows.stream().map(row -> nonBlank(row.getRowToken(), "")).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> asins = rows.stream().map(row -> nonBlank(row.getAsin(), "")).toList();
List<String> countries = rows.stream().map(row -> nonBlank(row.getCountry(), "")).toList();
List<String> skus = rows.stream().map(row -> nonBlank(row.getSku(), "")).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(); List<String> urls = rows.stream().map(row -> nonBlank(row.getUrl(), "")).toList();
List<List<String>> urlLists = rows.stream().map(SimilarAsinResultRowDto::getUrls).toList(); List<List<String>> urlLists = rows.stream().map(SimilarAsinResultRowDto::getUrls).toList();
Map<String, Object> parameters = new LinkedHashMap<>(); Map<String, Object> parameters = new LinkedHashMap<>();
parameters.put("items", buildItemObjects(rows, asins, urls, urlLists)); parameters.put("items", buildItemObjects(groupKeys, rowTokens, rowIds, asins, countries, skus, titles, urls, urlLists));
parameters.put("prompt", prompt == null ? "" : prompt); parameters.put("prompt", prompt == null ? "" : prompt);
if (apiKey != null && !apiKey.isBlank()) { if (apiKey != null && !apiKey.isBlank()) {
parameters.put("api_key", apiKey.trim()); parameters.put("api_key", apiKey.trim());
@@ -302,14 +308,25 @@ public class SimilarAsinCozeClient {
return normalized.substring(0, 6) + "***" + normalized.substring(normalized.length() - 4); return normalized.substring(0, 6) + "***" + normalized.substring(normalized.length() - 4);
} }
private List<Map<String, Object>> buildItemObjects(List<SimilarAsinResultRowDto> rows, private List<Map<String, Object>> buildItemObjects(List<String> groupKeys,
List<String> rowTokens,
List<String> rowIds,
List<String> asins, List<String> asins,
List<String> countries,
List<String> skus,
List<String> titles,
List<String> urls, List<String> urls,
List<List<String>> urlLists) { List<List<String>> urlLists) {
List<Map<String, Object>> items = new ArrayList<>(rows.size()); List<Map<String, Object>> items = new ArrayList<>(asins.size());
for (int i = 0; i < rows.size(); i++) { for (int i = 0; i < asins.size(); i++) {
Map<String, Object> item = new LinkedHashMap<>(); Map<String, Object> item = new LinkedHashMap<>();
item.put("group_key", groupKeys.get(i));
item.put("row_token", rowTokens.get(i));
item.put("row_id", rowIds.get(i));
item.put("asin", asins.get(i)); item.put("asin", asins.get(i));
item.put("country", countries.get(i));
item.put("sku", skus.get(i));
item.put("title", titles.get(i));
item.put("url", urls.get(i)); item.put("url", urls.get(i));
item.put("target_urls", urlLists.get(i)); item.put("target_urls", urlLists.get(i));
items.add(item); items.add(item);
@@ -334,6 +351,9 @@ public class SimilarAsinCozeClient {
text(firstNonNull( text(firstNonNull(
firstNonNull(node.get("group_key"), node.get("groupKey")), firstNonNull(node.get("group_key"), node.get("groupKey")),
firstNonNull(itemNode.get("group_key"), itemNode.get("groupKey")))), firstNonNull(itemNode.get("group_key"), itemNode.get("groupKey")))),
text(firstNonNull(
firstNonNull(node.get("row_token"), node.get("rowToken")),
firstNonNull(itemNode.get("row_token"), itemNode.get("rowToken")))),
text(firstNonNull( text(firstNonNull(
firstNonNull(node.get("row_id"), firstNonNull(node.get("rowId"), node.get("id"))), firstNonNull(node.get("row_id"), firstNonNull(node.get("rowId"), node.get("id"))),
firstNonNull(itemNode.get("row_id"), firstNonNull(itemNode.get("rowId"), itemNode.get("id"))))), firstNonNull(itemNode.get("row_id"), firstNonNull(itemNode.get("rowId"), itemNode.get("id"))))),
@@ -341,6 +361,9 @@ public class SimilarAsinCozeClient {
text(firstNonNull( text(firstNonNull(
firstNonNull(node.get("country"), node.get("site")), firstNonNull(node.get("country"), node.get("site")),
firstNonNull(itemNode.get("country"), itemNode.get("site")))), firstNonNull(itemNode.get("country"), itemNode.get("site")))),
text(firstNonNull(
firstNonNull(node.get("sku"), firstNonNull(node.get("SKU"), firstNonNull(node.get("seller_sku"), node.get("sellerSku")))),
firstNonNull(itemNode.get("sku"), firstNonNull(itemNode.get("SKU"), firstNonNull(itemNode.get("seller_sku"), itemNode.get("sellerSku")))))),
text(firstNonNull( text(firstNonNull(
firstNonNull(node.get("price"), node.get("价格")), firstNonNull(node.get("price"), node.get("价格")),
firstNonNull(itemNode.get("price"), itemNode.get("价格")))), firstNonNull(itemNode.get("price"), itemNode.get("价格")))),
@@ -351,6 +374,15 @@ public class SimilarAsinCozeClient {
text(firstNonNull( text(firstNonNull(
firstNonNull(node.get("similarity"), firstNonNull(node.get("similarity_rate"), node.get("相似度"))), firstNonNull(node.get("similarity"), firstNonNull(node.get("similarity_rate"), node.get("相似度"))),
firstNonNull(itemNode.get("similarity"), firstNonNull(itemNode.get("similarity_rate"), itemNode.get("相似度"))))), firstNonNull(itemNode.get("similarity"), firstNonNull(itemNode.get("similarity_rate"), itemNode.get("相似度"))))),
text(firstNonNull(
firstNonNull(node.get("is_conform"), firstNonNull(node.get("isConform"), firstNonNull(node.get("conform"), node.get("是否符合类目")))),
firstNonNull(itemNode.get("is_conform"), firstNonNull(itemNode.get("isConform"), firstNonNull(itemNode.get("conform"), itemNode.get("是否符合类目")))))),
text(firstNonNull(
firstNonNull(node.get("reason"), firstNonNull(node.get("原因"), node.get("不符合理由"))),
firstNonNull(itemNode.get("reason"), firstNonNull(itemNode.get("原因"), itemNode.get("不符合理由"))))),
text(firstNonNull(
firstNonNull(node.get("category"), firstNonNull(node.get("类目"), node.get("产品类目"))),
firstNonNull(itemNode.get("category"), firstNonNull(itemNode.get("类目"), itemNode.get("产品类目"))))),
text(firstNonNull(node.get("appearance"), text(firstNonNull(node.get("appearance"),
firstNonNull(node.get("appearance_risk"), firstNonNull(node.get("appearance_risk"),
firstNonNull(itemNode.get("appearance"), itemNode.get("appearance_risk"))))), firstNonNull(itemNode.get("appearance"), itemNode.get("appearance_risk"))))),
@@ -360,6 +392,9 @@ public class SimilarAsinCozeClient {
text(firstNonNull(node.get("result"), text(firstNonNull(node.get("result"),
firstNonNull(node.get("conclusion"), firstNonNull(node.get("conclusion"),
firstNonNull(itemNode.get("result"), itemNode.get("conclusion"))))), firstNonNull(itemNode.get("result"), itemNode.get("conclusion"))))),
text(firstNonNull(
firstNonNull(node.get("status"), firstNonNull(node.get("row_status"), node.get("rowStatus"))),
firstNonNull(itemNode.get("status"), firstNonNull(itemNode.get("row_status"), itemNode.get("rowStatus"))))),
text(firstNonNull(node.get("title_reason"), text(firstNonNull(node.get("title_reason"),
firstNonNull(node.get("titleReason"), firstNonNull(node.get("titleReason"),
firstNonNull(itemNode.get("title_reason"), itemNode.get("titleReason"))))), firstNonNull(itemNode.get("title_reason"), itemNode.get("titleReason"))))),
@@ -391,15 +426,14 @@ public class SimilarAsinCozeClient {
} }
private List<SimilarAsinResultRowDto> mergeRows(List<SimilarAsinResultRowDto> rows, List<CozeResult> results) { private List<SimilarAsinResultRowDto> mergeRows(List<SimilarAsinResultRowDto> rows, List<CozeResult> results) {
Map<String, CozeResult> resultByGroupKey = new LinkedHashMap<>(); Map<String, CozeResult> resultByRowToken = new LinkedHashMap<>();
Map<String, CozeResult> resultByCompositeKey = new LinkedHashMap<>(); Map<String, CozeResult> resultByCompositeKey = new LinkedHashMap<>();
Map<String, CozeResult> resultByAsinCountry = new LinkedHashMap<>(); Map<String, CozeResult> resultByAsinCountry = new LinkedHashMap<>();
Map<String, CozeResult> resultByAsin = new LinkedHashMap<>();
Map<String, CozeResult> resultByRowId = new LinkedHashMap<>(); Map<String, CozeResult> resultByRowId = new LinkedHashMap<>();
for (CozeResult result : results) { for (CozeResult result : results) {
String groupKey = normalize(result.groupKey()); String rowToken = normalize(result.rowToken());
if (!groupKey.isBlank()) { if (!rowToken.isBlank()) {
resultByGroupKey.putIfAbsent(groupKey, result); resultByRowToken.putIfAbsent(rowToken, result);
} }
String compositeKey = rowKey(result.rowId(), result.asin(), result.country()); String compositeKey = rowKey(result.rowId(), result.asin(), result.country());
if (!compositeKey.isBlank()) { if (!compositeKey.isBlank()) {
@@ -409,10 +443,6 @@ public class SimilarAsinCozeClient {
if (!asinCountryKey.isBlank()) { if (!asinCountryKey.isBlank()) {
resultByAsinCountry.putIfAbsent(asinCountryKey, result); resultByAsinCountry.putIfAbsent(asinCountryKey, result);
} }
String asinKey = normalize(result.asin()).toUpperCase(Locale.ROOT);
if (!asinKey.isBlank()) {
resultByAsin.putIfAbsent(asinKey, result);
}
String rowIdKey = normalize(result.rowId()); String rowIdKey = normalize(result.rowId());
if (!rowIdKey.isBlank()) { if (!rowIdKey.isBlank()) {
resultByRowId.putIfAbsent(rowIdKey, result); resultByRowId.putIfAbsent(rowIdKey, result);
@@ -423,7 +453,7 @@ public class SimilarAsinCozeClient {
boolean allowIndexFallback = results.size() == rows.size() && results.stream().noneMatch(this::hasIdentity); boolean allowIndexFallback = results.size() == rows.size() && results.stream().noneMatch(this::hasIdentity);
for (int i = 0; i < rows.size(); i++) { for (int i = 0; i < rows.size(); i++) {
SimilarAsinResultRowDto row = copy(rows.get(i)); SimilarAsinResultRowDto row = copy(rows.get(i));
CozeResult result = resultByGroupKey.get(normalize(row.getGroupKey())); CozeResult result = resultByRowToken.get(normalize(row.getRowToken()));
if (result == null) { if (result == null) {
result = resultByCompositeKey.get(rowKey(row.getId(), row.getAsin(), row.getCountry())); result = resultByCompositeKey.get(rowKey(row.getId(), row.getAsin(), row.getCountry()));
} }
@@ -433,9 +463,6 @@ public class SimilarAsinCozeClient {
if (result == null) { if (result == null) {
result = resultByAsinCountry.get(asinCountryKey(row.getAsin(), row.getCountry())); result = resultByAsinCountry.get(asinCountryKey(row.getAsin(), row.getCountry()));
} }
if (result == null) {
result = resultByAsin.get(normalize(row.getAsin()).toUpperCase(Locale.ROOT));
}
if (result == null) { if (result == null) {
result = resultByRowId.get(normalize(row.getId())); result = resultByRowId.get(normalize(row.getId()));
} }
@@ -460,7 +487,7 @@ public class SimilarAsinCozeClient {
if (result == null) { if (result == null) {
return false; return false;
} }
return !normalize(result.groupKey()).isBlank() return !normalize(result.rowToken()).isBlank()
|| !normalize(result.rowId()).isBlank() || !normalize(result.rowId()).isBlank()
|| !normalize(result.asin()).isBlank(); || !normalize(result.asin()).isBlank();
} }
@@ -469,7 +496,7 @@ public class SimilarAsinCozeClient {
if (result == null || row == null) { if (result == null || row == null) {
return false; return false;
} }
if (!normalize(result.groupKey()).isBlank() || !normalize(result.rowId()).isBlank()) { if (!normalize(result.rowToken()).isBlank() || !normalize(result.rowId()).isBlank()) {
return false; return false;
} }
String resultAsin = normalize(result.asin()).toUpperCase(Locale.ROOT); String resultAsin = normalize(result.asin()).toUpperCase(Locale.ROOT);
@@ -481,12 +508,17 @@ public class SimilarAsinCozeClient {
return; return;
} }
row.setTitleRisk(result.title()); row.setTitleRisk(result.title());
row.setSku(nonBlank(result.sku(), row.getSku()));
row.setPrice(nonBlank(result.price(), row.getPrice())); row.setPrice(nonBlank(result.price(), row.getPrice()));
row.setIsStock(result.isStock()); row.setIsStock(result.isStock());
row.setSimilarity(result.similarity()); row.setSimilarity(result.similarity());
row.setIsConform(result.isConform());
row.setReason(result.reason());
row.setCategory(result.category());
row.setAppearanceRisk(result.appearance()); row.setAppearanceRisk(result.appearance());
row.setPatentRisk(result.patent()); row.setPatentRisk(result.patent());
row.setConclusion(result.result()); row.setConclusion(result.result());
row.setStatus(result.status());
row.setTitleReason(result.titleReason()); row.setTitleReason(result.titleReason());
row.setAppearanceReason(result.appearanceReason()); row.setAppearanceReason(result.appearanceReason());
row.setPatentReason(result.patentReason()); row.setPatentReason(result.patentReason());
@@ -508,11 +540,16 @@ public class SimilarAsinCozeClient {
row.setId(source.getId()); row.setId(source.getId());
row.setAsin(source.getAsin()); row.setAsin(source.getAsin());
row.setCountry(source.getCountry()); row.setCountry(source.getCountry());
row.setSku(source.getSku());
row.setPrice(source.getPrice()); row.setPrice(source.getPrice());
row.setUrls(source.getUrls()); row.setUrls(source.getUrls());
row.setTitle(source.getTitle()); row.setTitle(source.getTitle());
row.setError(source.getError()); row.setError(source.getError());
row.setDone(source.getDone()); row.setDone(source.getDone());
row.setStatus(source.getStatus());
row.setIsConform(source.getIsConform());
row.setReason(source.getReason());
row.setCategory(source.getCategory());
row.setTitleRisk(source.getTitleRisk()); row.setTitleRisk(source.getTitleRisk());
row.setAppearanceRisk(source.getAppearanceRisk()); row.setAppearanceRisk(source.getAppearanceRisk());
row.setPatentRisk(source.getPatentRisk()); row.setPatentRisk(source.getPatentRisk());
@@ -532,6 +569,12 @@ public class SimilarAsinCozeClient {
if (row.getError() == null || row.getError().isBlank()) { if (row.getError() == null || row.getError().isBlank()) {
row.setError(failureMessage); row.setError(failureMessage);
} }
if (row.getReason() == null || row.getReason().isBlank()) {
row.setReason(failureMessage);
}
if (row.getStatus() == null || row.getStatus().isBlank()) {
row.setStatus("FAILED");
}
if (row.getTitleRisk() == null || row.getTitleRisk().isBlank()) { if (row.getTitleRisk() == null || row.getTitleRisk().isBlank()) {
row.setTitleRisk(reviewMessage); row.setTitleRisk(reviewMessage);
} }
@@ -721,6 +764,17 @@ public class SimilarAsinCozeClient {
|| item.has("similarity") || item.has("similarity")
|| item.has("similarity_rate") || item.has("similarity_rate")
|| item.has("相似度") || item.has("相似度")
|| item.has("is_conform")
|| item.has("isConform")
|| item.has("conform")
|| item.has("是否符合类目")
|| item.has("reason")
|| item.has("原因")
|| item.has("不符合理由")
|| item.has("category")
|| item.has("类目")
|| item.has("产品类目")
|| item.has("status")
|| item.has("title_reason") || item.has("title_reason")
|| item.has("titleReason") || item.has("titleReason")
|| item.has("appearance_reason") || item.has("appearance_reason")
@@ -973,16 +1027,22 @@ public class SimilarAsinCozeClient {
private record CozeResult( private record CozeResult(
String groupKey, String groupKey,
String rowToken,
String rowId, String rowId,
String asin, String asin,
String country, String country,
String sku,
String price, String price,
String title, String title,
String isStock, String isStock,
String similarity, String similarity,
String isConform,
String reason,
String category,
String appearance, String appearance,
String patent, String patent,
String result, String result,
String status,
String titleReason, String titleReason,
String appearanceReason, String appearanceReason,
String patentReason String patentReason

View File

@@ -120,7 +120,7 @@ public class SimilarAsinController {
} }
@PostMapping("/tasks/{taskId}/result") @PostMapping("/tasks/{taskId}/result")
@Operation(summary = "提交 Python 回传结果", description = "Python 回传商品数据接口。items 可以是一条或多条Java 先原样保存回传数据,再内部攒够 10 条调用 Coze。done=true 表示 Python 已完成全部回传Java 会强制处理剩余不足 10 条的数据并生成最终 xlsx。") @Operation(summary = "提交 Python 回传结果", description = "Python 回传商品数据接口。请通过 groups[].items[] 回传分组结果Java 先原样保存回传数据,再内部攒调用 Coze。done=true 表示 Python 已完成全部回传Java 会强制处理剩余未满批的数据并生成最终 xlsx。")
public ApiResponse<Void> result( public ApiResponse<Void> result(
@Parameter(description = "相似ASIN检测任务 ID。任务必须处于 RUNNING 状态。", required = true, example = "3938") @Parameter(description = "相似ASIN检测任务 ID。任务必须处于 RUNNING 状态。", required = true, example = "3938")
@PathVariable Long taskId, @PathVariable Long taskId,

View File

@@ -39,6 +39,10 @@ public class SimilarAsinResultRowDto {
@Schema(description = "站点或国家。来自 Excel 的国家列,例如英国、德国、法国。", example = "英国") @Schema(description = "站点或国家。来自 Excel 的国家列,例如英国、德国、法国。", example = "英国")
private String country; private String country;
@JsonAlias({"sku", "SKU", "seller_sku", "sellerSku", "msku", "货号"})
@Schema(description = "商品 SKU。来自 Excel 解析或 Python 回传。", example = "SKU-001")
private String sku;
@JsonAlias({"price", "价格"}) @JsonAlias({"price", "价格"})
@Schema(description = "商品价格。来自 Excel 解析或 Python 回传。", example = "12.29") @Schema(description = "商品价格。来自 Excel 解析或 Python 回传。", example = "12.29")
private String price; private String price;
@@ -61,12 +65,28 @@ public class SimilarAsinResultRowDto {
@Schema(description = "Coze 返回的相似度结果。", example = "80%", accessMode = Schema.AccessMode.READ_ONLY) @Schema(description = "Coze 返回的相似度结果。", example = "80%", accessMode = Schema.AccessMode.READ_ONLY)
private String similarity; private String similarity;
@JsonAlias({"is_conform", "isConform", "conform", "是否符合类目"})
@Schema(description = "Coze 返回的是否符合类目结果。", example = "符合", accessMode = Schema.AccessMode.READ_ONLY)
private String isConform;
@JsonAlias({"reason", "原因", "不符合理由"})
@Schema(description = "Coze 返回的不符合理由。", example = "类目不匹配", accessMode = Schema.AccessMode.READ_ONLY)
private String reason;
@JsonAlias({"category", "类目", "产品类目"})
@Schema(description = "Coze 返回的产品类目。", example = "女装", accessMode = Schema.AccessMode.READ_ONLY)
private String category;
@Schema(description = "单行错误信息。通常用于记录 Python 单行处理异常Coze 失败时后端会尽量保留原始行,不强行写入风险结果。", example = "图片地址为空") @Schema(description = "单行错误信息。通常用于记录 Python 单行处理异常Coze 失败时后端会尽量保留原始行,不强行写入风险结果。", example = "图片地址为空")
private String error; private String error;
@Schema(description = "单行完成标记。当前主要使用请求体顶层 done 控制任务收尾,该字段仅作兼容。", example = "true") @Schema(description = "单行完成标记。当前主要使用请求体顶层 done 控制任务收尾,该字段仅作兼容。", example = "true")
private Boolean done; private Boolean done;
@JsonAlias({"row_status", "rowStatus", "Status"})
@Schema(description = "Coze row status", example = "success", accessMode = Schema.AccessMode.READ_ONLY)
private String status;
@Schema(description = "兼容旧版 Coze 返回中的标题维度结果字段;当前相似 ASIN 结果文件不再输出该列。", example = "标题未发现明显商标侵权风险。", accessMode = Schema.AccessMode.READ_ONLY) @Schema(description = "兼容旧版 Coze 返回中的标题维度结果字段;当前相似 ASIN 结果文件不再输出该列。", example = "标题未发现明显商标侵权风险。", accessMode = Schema.AccessMode.READ_ONLY)
private String titleRisk; private String titleRisk;

View File

@@ -27,6 +27,4 @@ public class SimilarAsinSubmitResultRequest {
@Schema(description = "本次回传的分组结果列表,推荐优先使用") @Schema(description = "本次回传的分组结果列表,推荐优先使用")
private List<SimilarAsinResultGroupDto> groups = new ArrayList<>(); private List<SimilarAsinResultGroupDto> groups = new ArrayList<>();
@Schema(description = "兼容旧链路的平铺结果列表")
private List<SimilarAsinResultRowDto> items = new ArrayList<>();
} }

View File

@@ -39,6 +39,9 @@ public class SimilarAsinParsedRowVo {
@Schema(description = "商品价格。", example = "12.29") @Schema(description = "商品价格。", example = "12.29")
private String price; private String price;
@Schema(description = "商品 SKU。", example = "SKU-001")
private String sku;
@Schema(description = "商品图片 URL 或商品 URL供 Coze 检测使用。", example = "https://webstatic.aiproxy.vip/output/demo.jpg") @Schema(description = "商品图片 URL 或商品 URL供 Coze 检测使用。", example = "https://webstatic.aiproxy.vip/output/demo.jpg")
private String url; private String url;

View File

@@ -114,11 +114,15 @@ public class SimilarAsinTaskService {
private static final int PARSE_RESPONSE_PREVIEW_LIMIT = 100; private static final int PARSE_RESPONSE_PREVIEW_LIMIT = 100;
private static final List<String> RESULT_HEADERS = List.of( private static final List<String> RESULT_HEADERS = List.of(
"id", "id",
"sku",
"asin", "asin",
"国家", "国家",
"价格",
"是否有货", "是否有货",
"相似度" "相似度",
"是否符合类目",
"不符合理由",
"产品类目",
"status"
); );
private final LocalFileStorageService localFileStorageService; private final LocalFileStorageService localFileStorageService;
@@ -316,8 +320,8 @@ public class SimilarAsinTaskService {
vo.setDroppedRows(droppedRows); vo.setDroppedRows(droppedRows);
vo.setGroupCount(groups.size()); vo.setGroupCount(groups.size());
vo.setAiPrompt(normalize(request.getAiPrompt())); vo.setAiPrompt(normalize(request.getAiPrompt()));
vo.setItems(buildResponsePreviewRows(allRows)); vo.setItems(new ArrayList<>(allRows));
vo.setGroups(List.of()); vo.setGroups(groups);
long finishedAt = System.nanoTime(); long finishedAt = System.nanoTime();
log.info("[similar-asin] parse timing taskId={} files={} rows={} groups={} totalMs={} parseMs={} groupMs={} taskInsertMs={} payloadJsonMs={} payloadStoreMs={} persistMs={} responseMs={}", log.info("[similar-asin] parse timing taskId={} files={} rows={} groups={} totalMs={} parseMs={} groupMs={} taskInsertMs={} payloadJsonMs={} payloadStoreMs={} persistMs={} responseMs={}",
task.getId(), task.getId(),
@@ -482,7 +486,7 @@ public class SimilarAsinTaskService {
completeSubmittedChunk(context); completeSubmittedChunk(context);
return null; return null;
}); });
submitCozeForSubmittedChunk(context); scheduleCozePipelineForSubmittedChunk(context);
return; return;
} }
FileTaskEntity task = fileTaskMapper.selectById(taskId); FileTaskEntity task = fileTaskMapper.selectById(taskId);
@@ -568,7 +572,7 @@ public class SimilarAsinTaskService {
task.setUpdatedAt(LocalDateTime.now()); task.setUpdatedAt(LocalDateTime.now());
fileTaskMapper.updateById(task); fileTaskMapper.updateById(task);
} }
submitCozeForSubmittedChunk(context); scheduleCozePipelineForSubmittedChunk(context);
} }
@Transactional @Transactional
@@ -620,19 +624,11 @@ public class SimilarAsinTaskService {
if (transactionManager != null) { if (transactionManager != null) {
LocalDateTime threshold = LocalDateTime.now().minusMinutes(Math.max(1, properties.getStaleTimeoutMinutes())); LocalDateTime threshold = LocalDateTime.now().minusMinutes(Math.max(1, properties.getStaleTimeoutMinutes()));
long thresholdMillis = threshold.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli(); long thresholdMillis = threshold.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli();
List<FileTaskEntity> tasks = fileTaskMapper.selectList(new LambdaQueryWrapper<FileTaskEntity>() List<FileTaskEntity> tasks = listStaleFinalizeCandidates(threshold);
.eq(FileTaskEntity::getModuleType, MODULE_TYPE)
.eq(FileTaskEntity::getStatus, STATUS_RUNNING)
.lt(FileTaskEntity::getUpdatedAt, threshold)
.last("limit 50"));
for (FileTaskEntity task : tasks) { for (FileTaskEntity task : tasks) {
if (!isOwnerCurrent(ownerFromTask(task))) { if (!isOwnerCurrent(ownerFromTask(task))) {
continue; continue;
} }
if (isJavaSideProcessing(task.getId())) {
touchJavaSideTaskActivity(task.getId());
continue;
}
long heartbeatMillis = taskCacheService.getTaskHeartbeatMillis(task.getId()); long heartbeatMillis = taskCacheService.getTaskHeartbeatMillis(task.getId());
if (heartbeatMillis > thresholdMillis) { if (heartbeatMillis > thresholdMillis) {
continue; continue;
@@ -652,19 +648,11 @@ public class SimilarAsinTaskService {
} }
LocalDateTime threshold = LocalDateTime.now().minusMinutes(Math.max(1, properties.getStaleTimeoutMinutes())); LocalDateTime threshold = LocalDateTime.now().minusMinutes(Math.max(1, properties.getStaleTimeoutMinutes()));
long thresholdMillis = threshold.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli(); long thresholdMillis = threshold.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli();
List<FileTaskEntity> tasks = fileTaskMapper.selectList(new LambdaQueryWrapper<FileTaskEntity>() List<FileTaskEntity> tasks = listStaleFinalizeCandidates(threshold);
.eq(FileTaskEntity::getModuleType, MODULE_TYPE)
.eq(FileTaskEntity::getStatus, STATUS_RUNNING)
.lt(FileTaskEntity::getUpdatedAt, threshold)
.last("limit 50"));
for (FileTaskEntity task : tasks) { for (FileTaskEntity task : tasks) {
if (!isOwnerCurrent(ownerFromTask(task))) { if (!isOwnerCurrent(ownerFromTask(task))) {
continue; continue;
} }
if (isJavaSideProcessing(task.getId())) {
touchJavaSideTaskActivity(task.getId());
continue;
}
long heartbeatMillis = taskCacheService.getTaskHeartbeatMillis(task.getId()); long heartbeatMillis = taskCacheService.getTaskHeartbeatMillis(task.getId());
if (heartbeatMillis > thresholdMillis) { if (heartbeatMillis > thresholdMillis) {
continue; continue;
@@ -674,11 +662,46 @@ public class SimilarAsinTaskService {
continue; continue;
} }
try (taskLockHandle) { try (taskLockHandle) {
finalizeTask(task, "Python interrupted before uploading final similar ASIN result", allRowCount(task), true); finalizeStaleTask(task.getId(), "Python interrupted before uploading final similar ASIN result");
} }
} }
} }
public void debugFinalizeStaleTask(Long taskId) {
if (taskId == null || taskId <= 0) {
return;
}
if (transactionManager != null) {
TaskDistributedLockService.LockHandle taskLockHandle = acquireTaskLock(taskId, 0L);
if (taskLockHandle == null) {
return;
}
try (taskLockHandle) {
inNewTransaction(() -> {
finalizeStaleTask(taskId, "Python interrupted before uploading final similar ASIN result");
return null;
});
}
return;
}
TaskDistributedLockService.LockHandle taskLockHandle = acquireTaskLock(taskId, 0L);
if (taskLockHandle == null) {
return;
}
try (taskLockHandle) {
finalizeStaleTask(taskId, "Python interrupted before uploading final similar ASIN result");
}
}
private List<FileTaskEntity> listStaleFinalizeCandidates(LocalDateTime threshold) {
return fileTaskMapper.selectList(new LambdaQueryWrapper<FileTaskEntity>()
.eq(FileTaskEntity::getModuleType, MODULE_TYPE)
.eq(FileTaskEntity::getStatus, STATUS_RUNNING)
.lt(FileTaskEntity::getCreatedAt, threshold)
.orderByAsc(FileTaskEntity::getCreatedAt)
.last("limit 200"));
}
private SubmitContext persistSubmittedChunk(Long taskId, SimilarAsinSubmitResultRequest request) { private SubmitContext persistSubmittedChunk(Long taskId, SimilarAsinSubmitResultRequest request) {
FileTaskEntity task = fileTaskMapper.selectById(taskId); FileTaskEntity task = fileTaskMapper.selectById(taskId);
if (task == null || !MODULE_TYPE.equals(task.getModuleType())) { if (task == null || !MODULE_TYPE.equals(task.getModuleType())) {
@@ -760,13 +783,26 @@ public class SimilarAsinTaskService {
if (task == null || !MODULE_TYPE.equals(task.getModuleType()) || STATUS_SUCCESS.equals(task.getStatus())) { if (task == null || !MODULE_TYPE.equals(task.getModuleType()) || STATUS_SUCCESS.equals(task.getStatus())) {
return; return;
} }
TaskChunkEntity chunk = taskChunkMapper.selectOne(new LambdaQueryWrapper<TaskChunkEntity>() List<TaskChunkEntity> chunks;
.eq(TaskChunkEntity::getTaskId, task.getId()) if (context.scopeHash() == null || context.scopeHash().isBlank() || context.chunkIndex() == null) {
.eq(TaskChunkEntity::getModuleType, MODULE_TYPE) chunks = taskChunkMapper.selectList(new LambdaQueryWrapper<TaskChunkEntity>()
.eq(TaskChunkEntity::getScopeHash, context.scopeHash()) .eq(TaskChunkEntity::getTaskId, task.getId())
.eq(TaskChunkEntity::getChunkIndex, context.chunkIndex()) .eq(TaskChunkEntity::getModuleType, MODULE_TYPE)
.last("limit 1")); .orderByAsc(TaskChunkEntity::getChunkIndex));
if (chunk == null || readChunkRows(chunk).isEmpty()) { } else {
TaskChunkEntity chunk = taskChunkMapper.selectOne(new LambdaQueryWrapper<TaskChunkEntity>()
.eq(TaskChunkEntity::getTaskId, task.getId())
.eq(TaskChunkEntity::getModuleType, MODULE_TYPE)
.eq(TaskChunkEntity::getScopeHash, context.scopeHash())
.eq(TaskChunkEntity::getChunkIndex, context.chunkIndex())
.last("limit 1"));
chunks = chunk == null ? List.of() : List.of(chunk);
}
chunks = chunks == null ? List.of() : chunks.stream()
.filter(Objects::nonNull)
.filter(chunk -> !readChunkRows(chunk).isEmpty())
.toList();
if (chunks.isEmpty()) {
return; return;
} }
FileResultEntity result = findOrCreateResultRecordForAssembly(task, allRowCount(task)); FileResultEntity result = findOrCreateResultRecordForAssembly(task, allRowCount(task));
@@ -779,24 +815,131 @@ public class SimilarAsinTaskService {
return; return;
} }
Map<String, List<SimilarAsinParsedRowVo>> allRowsByBaseId = loadAllRowsByBaseId(task); Map<String, List<SimilarAsinParsedRowVo>> allRowsByBaseId = loadAllRowsByBaseId(task);
boolean pendingCoze = submitCozeBatches(task, result, job, List.of(chunk), allRowsByBaseId); boolean pendingCoze = submitCozeBatches(task, result, job, chunks, allRowsByBaseId);
if (pendingCoze) { if (pendingCoze) {
taskFileJobService.touchRunning(job.getId()); taskFileJobService.touchRunning(job.getId());
touchJavaSideTaskActivity(task.getId()); touchJavaSideTaskActivity(task.getId());
} else if (isResultSubmissionComplete(task.getId())) { } else if (isResultSubmissionComplete(task.getId())) {
maybeFinalizeCozeJobLocked(task.getId(), new CozeBatchContext( maybeFinalizeCozeJobLocked(task.getId(), new CozeBatchContext(
job.getId(), result.getId(), chunk.getScopeHash(), chunk.getChunkIndex(), 1, 1, currentInstanceId(), 0)); job.getId(), result.getId(), context.scopeHash(), context.chunkIndex(), 1, 1, currentInstanceId(), 0));
} }
} }
private void scheduleCozePipelineForSubmittedChunk(SubmitContext context) {
if (context == null || context.task() == null || context.task().getId() == null) {
return;
}
FileTaskEntity task = fileTaskMapper.selectById(context.task().getId());
if (task == null || !MODULE_TYPE.equals(task.getModuleType()) || STATUS_SUCCESS.equals(task.getStatus())) {
return;
}
List<TaskChunkEntity> chunks = taskChunkMapper.selectList(new LambdaQueryWrapper<TaskChunkEntity>()
.eq(TaskChunkEntity::getTaskId, task.getId())
.eq(TaskChunkEntity::getModuleType, MODULE_TYPE)
.orderByAsc(TaskChunkEntity::getChunkIndex));
if (chunks.isEmpty()) {
return;
}
FileResultEntity result = findOrCreateResultRecordForAssembly(task, allRowCount(task));
if (result == null) {
return;
}
TaskFileJobEntity job = taskFileJobService.enqueueAssembleResult(
task.getId(), MODULE_TYPE, result.getId(), buildTaskOwnerScopeKey(task));
if (job == null || "SUCCESS".equals(job.getStatus())) {
return;
}
taskFileJobService.requeue(job.getId(), "Similar ASIN result uploaded, scheduling Coze/file assembly");
touchJavaSideTaskActivity(task.getId());
}
private void finalizeStaleTask(Long taskId, String error) { private void finalizeStaleTask(Long taskId, String error) {
FileTaskEntity task = fileTaskMapper.selectById(taskId); FileTaskEntity task = fileTaskMapper.selectById(taskId);
if (task == null || !MODULE_TYPE.equals(task.getModuleType()) || !STATUS_RUNNING.equals(task.getStatus())) { if (task == null || !MODULE_TYPE.equals(task.getModuleType()) || !STATUS_RUNNING.equals(task.getStatus())) {
return; return;
} }
if (tryRecoverTimedOutPythonTask(task)) {
return;
}
finalizeTask(task, error, allRowCount(task), true); finalizeTask(task, error, allRowCount(task), true);
} }
private boolean tryRecoverTimedOutPythonTask(FileTaskEntity task) {
if (task == null || task.getId() == null) {
return false;
}
Long taskId = task.getId();
boolean uploadComplete = isResultSubmissionComplete(taskId);
long pendingCozeStates = countPendingCozeStates(taskId);
long activeAssembleJobs = taskFileJobService.countActiveAssembleJobs(taskId, MODULE_TYPE);
if (uploadComplete && (pendingCozeStates > 0 || activeAssembleJobs > 0)) {
touchJavaSideTaskActivity(taskId);
return true;
}
if (!hasPersistedResultRows(taskId)) {
return false;
}
if (!uploadComplete) {
int forcedScopes = markSubmissionCompleteOnPythonTimeout(taskId);
if (forcedScopes <= 0) {
return false;
}
log.warn("[similar-asin] python heartbeat timed out, forcing finalize pipeline taskId={} pendingCozeStates={} activeAssembleJobs={} forcedScopes={}",
taskId, pendingCozeStates, activeAssembleJobs, forcedScopes);
} else {
log.warn("[similar-asin] stale running task resuming coze/file assembly after python timeout taskId={} pendingCozeStates={} activeAssembleJobs={}",
taskId, pendingCozeStates, activeAssembleJobs);
}
submitCozeForSubmittedChunk(new SubmitContext(task, null, null, null, true, null));
touchJavaSideTaskActivity(taskId);
return true;
}
private int markSubmissionCompleteOnPythonTimeout(Long taskId) {
if (taskId == null || taskId <= 0) {
return 0;
}
List<TaskScopeStateEntity> inputStates = taskScopeStateMapper.selectList(new LambdaQueryWrapper<TaskScopeStateEntity>()
.eq(TaskScopeStateEntity::getTaskId, taskId)
.eq(TaskScopeStateEntity::getModuleType, MODULE_TYPE)
.isNull(TaskScopeStateEntity::getCozeStatus)
.orderByDesc(TaskScopeStateEntity::getUpdatedAt));
if (inputStates == null || inputStates.isEmpty()) {
TaskChunkEntity latestChunk = taskChunkMapper.selectOne(new LambdaQueryWrapper<TaskChunkEntity>()
.eq(TaskChunkEntity::getTaskId, taskId)
.eq(TaskChunkEntity::getModuleType, MODULE_TYPE)
.orderByDesc(TaskChunkEntity::getUpdatedAt)
.last("limit 1"));
if (latestChunk == null || latestChunk.getScopeHash() == null || latestChunk.getScopeHash().isBlank()) {
return 0;
}
upsertScopeState(taskId,
firstNonBlank(latestChunk.getScopeKey(), "task:" + taskId),
latestChunk.getScopeHash(),
latestChunk.getChunkTotal(),
null,
true,
false);
return 1;
}
LocalDateTime now = LocalDateTime.now();
int updated = 0;
for (TaskScopeStateEntity state : inputStates) {
if (state == null || state.getId() == null) {
continue;
}
state.setCompleted(1);
if (state.getLastChunkAt() == null) {
state.setLastChunkAt(now);
}
state.setUpdatedAt(now);
state.setStateJson("{\"phase\":\"RECEIVED\",\"coze\":\"PENDING\"}");
taskScopeStateMapper.updateById(state);
updated++;
}
return updated;
}
private void upsertScopeState(Long taskId, private void upsertScopeState(Long taskId,
String scopeKey, String scopeKey,
String scopeHash, String scopeHash,
@@ -905,20 +1048,14 @@ public class SimilarAsinTaskService {
if (persistedRows.isEmpty()) { if (persistedRows.isEmpty()) {
continue; continue;
} }
List<SimilarAsinResultRowDto> unresolvedRows = pickGroupRepresentativesForCoze(persistedRows.values()); List<SimilarAsinResultRowDto> unresolvedRows = collectPendingCozeRows(persistedRows.values());
if (unresolvedRows.isEmpty()) { if (unresolvedRows.isEmpty()) {
continue; continue;
} }
List<SimilarAsinResultRowDto> cozeRows = applyCozeInBatches(unresolvedRows, task, progressHook); List<SimilarAsinResultRowDto> cozeRows = applyCozeInBatches(unresolvedRows, task, progressHook);
Map<String, SimilarAsinResultRowDto> mergedRows = new LinkedHashMap<>(); mergeChunkPayload(task.getId(), chunk.getScopeHash(), chunk.getChunkIndex(), cozeRows);
for (SimilarAsinResultRowDto resultRow : cozeRows) {
for (SimilarAsinResultRowDto expandedRow : expandRows(List.of(resultRow), allRowsByBaseId)) {
mergedRows.put(rowKey(expandedRow), expandedRow);
}
}
mergeChunkPayload(task.getId(), chunk.getScopeHash(), chunk.getChunkIndex(), new ArrayList<>(mergedRows.values()));
log.info("[similar-asin] async coze chunk merged taskId={} chunk={} unresolved={} merged={}", log.info("[similar-asin] async coze chunk merged taskId={} chunk={} unresolved={} merged={}",
task.getId(), chunk.getChunkIndex(), unresolvedRows.size(), mergedRows.size()); task.getId(), chunk.getChunkIndex(), unresolvedRows.size(), cozeRows.size());
} }
} }
@@ -951,44 +1088,22 @@ public class SimilarAsinTaskService {
transientPayloadStorageService.deleteReplacedPayloadIfNeeded(oldPayload, storedPayload); transientPayloadStorageService.deleteReplacedPayloadIfNeeded(oldPayload, storedPayload);
} }
private List<SimilarAsinResultRowDto> expandRows(List<SimilarAsinResultRowDto> representatives, private List<SimilarAsinResultRowDto> expandRows(List<SimilarAsinResultRowDto> rows,
Map<String, List<SimilarAsinParsedRowVo>> allRowsByBaseId) { Map<String, List<SimilarAsinParsedRowVo>> allRowsByBaseId) {
if (representatives == null || representatives.isEmpty()) { if (rows == null || rows.isEmpty()) {
return List.of(); return List.of();
} }
List<SimilarAsinResultRowDto> result = new ArrayList<>(); List<SimilarAsinResultRowDto> result = new ArrayList<>();
Set<String> emittedKeys = new LinkedHashSet<>(); Set<String> emittedKeys = new LinkedHashSet<>();
for (SimilarAsinResultRowDto representative : representatives) { for (SimilarAsinResultRowDto row : rows) {
String key = firstNonBlank(normalize(representative.getRowToken()), rowKey(representative)); String key = firstNonBlank(normalize(row.getRowToken()), rowKey(row));
if (emittedKeys.add(key)) { if (emittedKeys.add(key)) {
result.add(representative); result.add(row);
} }
} }
return result; return result;
} }
private List<SimilarAsinParsedRowVo> resolveSiblingRows(SimilarAsinResultRowDto representative,
Map<String, List<SimilarAsinParsedRowVo>> groupedRows) {
if (representative == null || groupedRows == null || groupedRows.isEmpty()) {
return List.of();
}
String representativeGroupKey = normalize(representative.getGroupKey());
if (!representativeGroupKey.isBlank()) {
return groupedRows.getOrDefault(representativeGroupKey, List.of());
}
String representativeKey = rowKey(representative);
String representativeLegacyKey = legacyRowKey(representative);
for (List<SimilarAsinParsedRowVo> rows : groupedRows.values()) {
for (SimilarAsinParsedRowVo row : rows) {
if (Objects.equals(representativeKey, rowKey(row))
|| Objects.equals(representativeLegacyKey, legacyRowKey(row))) {
return rows;
}
}
}
return List.of();
}
private Map<String, List<SimilarAsinParsedRowVo>> loadAllRowsByBaseId(FileTaskEntity task) { private Map<String, List<SimilarAsinParsedRowVo>> loadAllRowsByBaseId(FileTaskEntity task) {
try { try {
SimilarAsinParsedPayloadDto payload = readParsedPayload(task); SimilarAsinParsedPayloadDto payload = readParsedPayload(task);
@@ -1009,17 +1124,17 @@ public class SimilarAsinTaskService {
} }
} }
private List<SimilarAsinResultRowDto> pickGroupRepresentativesForCoze(java.util.Collection<SimilarAsinResultRowDto> rows) { private List<SimilarAsinResultRowDto> collectPendingCozeRows(java.util.Collection<SimilarAsinResultRowDto> rows) {
if (rows == null) { if (rows == null) {
return List.of(); return List.of();
} }
List<SimilarAsinResultRowDto> representatives = new ArrayList<>(); List<SimilarAsinResultRowDto> pendingRows = new ArrayList<>();
for (SimilarAsinResultRowDto row : rows) { for (SimilarAsinResultRowDto row : rows) {
if (row != null && !hasResolvedCozeFields(row) && shouldInspectRow(row)) { if (row != null && !hasResolvedCozeFields(row) && shouldInspectRow(row)) {
representatives.add(row); pendingRows.add(row);
} }
} }
return representatives; return pendingRows;
} }
private List<SimilarAsinParsedGroupVo> buildParsedGroups(List<SimilarAsinParsedRowVo> rows) { private List<SimilarAsinParsedGroupVo> buildParsedGroups(List<SimilarAsinParsedRowVo> rows) {
@@ -1065,6 +1180,7 @@ public class SimilarAsinTaskService {
vo.setGroupKey(row.getGroupKey()); vo.setGroupKey(row.getGroupKey());
vo.setAsin(row.getAsin()); vo.setAsin(row.getAsin());
vo.setCountry(row.getCountry()); vo.setCountry(row.getCountry());
vo.setSku(row.getSku());
vo.setPrice(row.getPrice()); vo.setPrice(row.getPrice());
vo.setUrl(row.getUrl()); vo.setUrl(row.getUrl());
vo.setTitle(row.getTitle()); vo.setTitle(row.getTitle());
@@ -1113,7 +1229,7 @@ public class SimilarAsinTaskService {
} }
return rows; return rows;
} }
return request.getItems() == null ? List.of() : request.getItems(); return List.of();
} }
private int allRowCount(FileTaskEntity task) { private int allRowCount(FileTaskEntity task) {
@@ -1139,36 +1255,6 @@ public class SimilarAsinTaskService {
} }
} }
private SimilarAsinResultRowDto copyResultToSibling(SimilarAsinResultRowDto representative, SimilarAsinParsedRowVo sibling) {
SimilarAsinResultRowDto row = new SimilarAsinResultRowDto();
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());
row.setPrice(firstNonBlank(representative.getPrice(), sibling.getPrice()));
if (representative.hasImageUrl()) {
row.setUrls(representative.getUrls());
} else {
row.setUrl(sibling.getUrl());
}
row.setTitle(firstNonBlank(representative.getTitle(), sibling.getTitle()));
row.setError(representative.getError());
row.setTitleRisk(representative.getTitleRisk());
row.setAppearanceRisk(representative.getAppearanceRisk());
row.setPatentRisk(representative.getPatentRisk());
row.setConclusion(representative.getConclusion());
row.setIsStock(representative.getIsStock());
row.setSimilarity(representative.getSimilarity());
row.setTitleReason(representative.getTitleReason());
row.setAppearanceReason(representative.getAppearanceReason());
row.setPatentReason(representative.getPatentReason());
row.setDone(representative.getDone());
return row;
}
private String readAiPrompt(FileTaskEntity task) { private String readAiPrompt(FileTaskEntity task) {
try { try {
return readParsedPayload(task).getAiPrompt(); return readParsedPayload(task).getAiPrompt();
@@ -1291,15 +1377,17 @@ public class SimilarAsinTaskService {
if (job == null || job.getTaskId() == null || job.getResultId() == null) { if (job == null || job.getTaskId() == null || job.getResultId() == null) {
throw new BusinessException("result file job arguments are incomplete"); throw new BusinessException("result file job arguments are incomplete");
} }
FileTaskEntity task = fileTaskMapper.selectById(job.getTaskId());
if (task == null || !MODULE_TYPE.equals(task.getModuleType())) {
throw new BusinessException("task not found");
}
ensureTaskOwnedByCurrentInstance(task, "assemble result file");
job.setScopeKey(buildTaskOwnerScopeKey(task));
if (!isJobOwnedByCurrentInstance(job)) { if (!isJobOwnedByCurrentInstance(job)) {
log.info("[similar-asin] skip result file job because owner is another instance jobId={} taskId={} owner={} current={}", log.info("[similar-asin] skip result file job because owner is another instance jobId={} taskId={} owner={} current={}",
job.getId(), job.getTaskId(), ownerFromScopeKey(job.getScopeKey()), currentInstanceId()); job.getId(), job.getTaskId(), ownerFromScopeKey(job.getScopeKey()), currentInstanceId());
return false; return false;
} }
FileTaskEntity task = fileTaskMapper.selectById(job.getTaskId());
if (task == null || !MODULE_TYPE.equals(task.getModuleType())) {
throw new BusinessException("task not found");
}
FileResultEntity result = fileResultMapper.selectById(job.getResultId()); FileResultEntity result = fileResultMapper.selectById(job.getResultId());
if (result == null || !MODULE_TYPE.equals(result.getModuleType())) { if (result == null || !MODULE_TYPE.equals(result.getModuleType())) {
throw new BusinessException("result record not found"); throw new BusinessException("result record not found");
@@ -1317,6 +1405,12 @@ public class SimilarAsinTaskService {
saveFileBuildProgress(task, job, totalProgressUnits, 1, "Coze submitted, waiting for result"); saveFileBuildProgress(task, job, totalProgressUnits, 1, "Coze submitted, waiting for result");
return false; 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");
return false;
}
saveFileBuildProgress(task, job, totalProgressUnits, 0, "Submitting Coze"); saveFileBuildProgress(task, job, totalProgressUnits, 0, "Submitting Coze");
boolean pendingCoze = submitCozeBatches(task, result, job, chunks, allRowsByBaseId); boolean pendingCoze = submitCozeBatches(task, result, job, chunks, allRowsByBaseId);
if (pendingCoze) { if (pendingCoze) {
@@ -1325,12 +1419,6 @@ public class SimilarAsinTaskService {
saveFileBuildProgress(task, job, totalProgressUnits, 1, "Coze submitted, waiting for result"); saveFileBuildProgress(task, job, totalProgressUnits, 1, "Coze submitted, waiting for result");
return false; 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");
return false;
}
completeCozeFileJob(task, result, job, totalProgressUnits, cozeWorkUnits); completeCozeFileJob(task, result, job, totalProgressUnits, cozeWorkUnits);
return true; return true;
} }
@@ -1384,7 +1472,7 @@ public class SimilarAsinTaskService {
if (persistedRows.isEmpty()) { if (persistedRows.isEmpty()) {
continue; continue;
} }
List<SimilarAsinResultRowDto> unresolvedRows = pickGroupRepresentativesForCoze(persistedRows.values()); List<SimilarAsinResultRowDto> unresolvedRows = collectPendingCozeRows(persistedRows.values());
if (unresolvedRows.isEmpty()) { if (unresolvedRows.isEmpty()) {
continue; continue;
} }
@@ -2232,7 +2320,7 @@ public class SimilarAsinTaskService {
if (persistedRows.isEmpty()) { if (persistedRows.isEmpty()) {
continue; continue;
} }
int unresolved = pickGroupRepresentativesForCoze(persistedRows.values()).size(); int unresolved = collectPendingCozeRows(persistedRows.values()).size();
if (unresolved > 0) { if (unresolved > 0) {
total += Math.max(1, (unresolved + batchSize - 1) / batchSize); total += Math.max(1, (unresolved + batchSize - 1) / batchSize);
} }
@@ -2280,7 +2368,7 @@ public class SimilarAsinTaskService {
SimilarAsinParsedPayloadDto parsed = readParsedPayload(task); SimilarAsinParsedPayloadDto parsed = readParsedPayload(task);
Map<String, SimilarAsinResultRowDto> resultMap = loadPersistedResultRowsWithRetry(task.getId(), parsed.getAllItems().size()); Map<String, SimilarAsinResultRowDto> resultMap = loadPersistedResultRowsWithRetry(task.getId(), parsed.getAllItems().size());
long resolvedRows = parsed.getAllItems().stream() long resolvedRows = parsed.getAllItems().stream()
.filter(row -> findResultRow(row, resultMap) != null || findResultRowByAsin(row.getAsin(), resultMap) != null) .filter(row -> findResultRow(row, resultMap) != null)
.count(); .count();
log.info("[similar-asin] assemble workbook taskId={} parsedRows={} resultRows={} resolvedRows={}", log.info("[similar-asin] assemble workbook taskId={} parsedRows={} resultRows={} resolvedRows={}",
task.getId(), parsed.getAllItems().size(), resultMap.size(), resolvedRows); task.getId(), parsed.getAllItems().size(), resultMap.size(), resolvedRows);
@@ -2381,19 +2469,18 @@ public class SimilarAsinTaskService {
int rowIndex = 1; int rowIndex = 1;
for (SimilarAsinParsedRowVo parsedRow : parsed.getAllItems()) { for (SimilarAsinParsedRowVo parsedRow : parsed.getAllItems()) {
SimilarAsinResultRowDto resultRow = findResultRow(parsedRow, resultMap); SimilarAsinResultRowDto resultRow = findResultRow(parsedRow, resultMap);
if (resultRow == null) {
resultRow = findResultRowByAsin(parsedRow.getAsin(), resultMap);
}
Row row = sheet.createRow(rowIndex++); Row row = sheet.createRow(rowIndex++);
int col = 0; int col = 0;
row.createCell(col++).setCellValue(firstNonBlank(parsedRow.getDisplayId(), parsedRow.getSourceId())); row.createCell(col++).setCellValue(firstNonBlank(parsedRow.getDisplayId(), parsedRow.getSourceId()));
row.createCell(col++).setCellValue(firstNonBlank(resultRow == null ? parsedRow.getSku() : firstNonBlank(resultRow.getSku(), parsedRow.getSku()), ""));
row.createCell(col++).setCellValue(firstNonBlank(parsedRow.getAsin(), "")); row.createCell(col++).setCellValue(firstNonBlank(parsedRow.getAsin(), ""));
row.createCell(col++).setCellValue(firstNonBlank(parsedRow.getCountry(), "")); row.createCell(col++).setCellValue(firstNonBlank(parsedRow.getCountry(), ""));
row.createCell(col++).setCellValue(resultRow == null
? firstNonBlank(parsedRow.getPrice(), readValueByHeader(parsedRow, "价格", "price"))
: firstNonBlank(resultRow.getPrice(), firstNonBlank(parsedRow.getPrice(), readValueByHeader(parsedRow, "价格", "price"))));
row.createCell(col++).setCellValue(resultRow == null ? "" : firstNonBlank(resultRow.getIsStock(), "")); row.createCell(col++).setCellValue(resultRow == null ? "" : firstNonBlank(resultRow.getIsStock(), ""));
row.createCell(col).setCellValue(resultRow == null ? "" : firstNonBlank(resultRow.getSimilarity(), "")); row.createCell(col++).setCellValue(resultRow == null ? "" : firstNonBlank(resultRow.getSimilarity(), ""));
row.createCell(col++).setCellValue(resultRow == null ? "" : firstNonBlank(resultRow.getIsConform(), ""));
row.createCell(col++).setCellValue(resultRow == null ? "" : firstNonBlank(resultRow.getReason(), ""));
row.createCell(col++).setCellValue(resultRow == null ? "" : firstNonBlank(resultRow.getCategory(), ""));
row.createCell(col).setCellValue(resultRow == null ? "" : firstNonBlank(resultRow.getStatus(), ""));
} }
workbook.write(fos); workbook.write(fos);
workbook.dispose(); workbook.dispose();
@@ -2402,26 +2489,6 @@ public class SimilarAsinTaskService {
} }
} }
private SimilarAsinResultRowDto findResultRowByAsin(String asin, Map<String, SimilarAsinResultRowDto> resultMap) {
String normalizedAsin = normalize(asin).toUpperCase(Locale.ROOT);
if (normalizedAsin.isBlank() || resultMap == null || resultMap.isEmpty()) {
return null;
}
SimilarAsinResultRowDto fallback = null;
for (SimilarAsinResultRowDto row : resultMap.values()) {
if (row == null || !normalizedAsin.equals(normalize(row.getAsin()).toUpperCase(Locale.ROOT))) {
continue;
}
if (hasResolvedCozeFields(row)) {
return row;
}
if (fallback == null) {
fallback = row;
}
}
return fallback;
}
private String imageUrlCellValue(SimilarAsinResultRowDto resultRow, String fallbackUrl) { private String imageUrlCellValue(SimilarAsinResultRowDto resultRow, String fallbackUrl) {
if (resultRow == null) { if (resultRow == null) {
return firstNonBlank(fallbackUrl, ""); return firstNonBlank(fallbackUrl, "");
@@ -2446,6 +2513,7 @@ public class SimilarAsinTaskService {
int idCol = findRequiredHeader(headerMap, "id"); int idCol = findRequiredHeader(headerMap, "id");
int asinCol = findRequiredHeader(headerMap, "asin"); int asinCol = findRequiredHeader(headerMap, "asin");
int countryCol = findRequiredHeader(headerMap, "国家", "country"); int countryCol = findRequiredHeader(headerMap, "国家", "country");
int skuCol = findOptionalHeaderExact(headerMap, "sku", "seller sku", "seller_sku", "msku", "货号");
int priceCol = findOptionalHeaderExact(headerMap, "价格", "price"); int priceCol = findOptionalHeaderExact(headerMap, "价格", "price");
int urlCol = findOptionalHeaderExact(headerMap, int urlCol = findOptionalHeaderExact(headerMap,
"url", "rul", "link", "image", "img", "pic", "picture", "url", "rul", "link", "image", "img", "pic", "picture",
@@ -2489,6 +2557,7 @@ public class SimilarAsinTaskService {
vo.setRowToken(buildRowToken(source.getFileKey(), vo.getRowIndex())); vo.setRowToken(buildRowToken(source.getFileKey(), vo.getRowIndex()));
vo.setAsin(asin); vo.setAsin(asin);
vo.setCountry(country); vo.setCountry(country);
vo.setSku(skuCol >= 0 ? cell(row, skuCol, formatter) : "");
vo.setPrice(priceCol >= 0 ? cell(row, priceCol, formatter) : ""); vo.setPrice(priceCol >= 0 ? cell(row, priceCol, formatter) : "");
vo.setUrl(urlCol >= 0 ? cell(row, urlCol, formatter) : ""); vo.setUrl(urlCol >= 0 ? cell(row, urlCol, formatter) : "");
vo.setTitle(titleCol >= 0 ? cell(row, titleCol, formatter) : ""); vo.setTitle(titleCol >= 0 ? cell(row, titleCol, formatter) : "");
@@ -2548,7 +2617,11 @@ public class SimilarAsinTaskService {
|| hasUsableCozeField(row.getPatentRisk()) || hasUsableCozeField(row.getPatentRisk())
|| hasUsableCozeField(row.getConclusion()) || hasUsableCozeField(row.getConclusion())
|| hasUsableCozeField(row.getIsStock()) || hasUsableCozeField(row.getIsStock())
|| hasUsableCozeField(row.getSimilarity()); || hasUsableCozeField(row.getSimilarity())
|| hasUsableCozeField(row.getIsConform())
|| hasUsableCozeField(row.getReason())
|| hasUsableCozeField(row.getCategory())
|| hasUsableCozeField(row.getStatus());
} }
private boolean hasUsableCozeField(String value) { private boolean hasUsableCozeField(String value) {
@@ -2833,9 +2906,9 @@ public class SimilarAsinTaskService {
payload.setApiKey(normalize(apiKey)); payload.setApiKey(normalize(apiKey));
payload.setSourceFiles(sourceFiles == null ? List.of() : sourceFiles); payload.setSourceFiles(sourceFiles == null ? List.of() : sourceFiles);
payload.setHeaders(headers == null ? List.of() : headers); payload.setHeaders(headers == null ? List.of() : headers);
payload.setItems(List.of()); payload.setItems(allRows == null ? List.of() : new ArrayList<>(allRows));
payload.setGroups(groups == null ? List.of() : groups); payload.setGroups(groups == null ? List.of() : groups);
payload.setAllItems(List.of()); payload.setAllItems(allRows == null ? List.of() : new ArrayList<>(allRows));
return writeJson(payload, "保存解析结果失败"); return writeJson(payload, "保存解析结果失败");
} }

View File

@@ -89,7 +89,7 @@ public class TaskResultFileJobWorker {
public void process(TaskFileJobEntity job) { public void process(TaskFileJobEntity job) {
if (isOwnerScopedCozeJob(job) && !isOwnedByCurrentInstance(job)) { if (isOwnerScopedCozeJob(job) && !isOwnedByCurrentInstance(job)) {
log.info("[task-file-job] skip owner-scoped job because owner is another instance jobId={} taskId={} moduleType={} owner={} current={}", log.debug("[task-file-job] skip owner-scoped job because owner is another instance jobId={} taskId={} moduleType={} owner={} current={}",
job.getId(), job.getTaskId(), job.getModuleType(), ownerFromScopeKey(job.getScopeKey()), currentInstanceId()); job.getId(), job.getTaskId(), job.getModuleType(), ownerFromScopeKey(job.getScopeKey()), currentInstanceId());
return; return;
} }

View File

@@ -1,6 +1,11 @@
# 这个文件只是给你对照看的“环境变量配置示例”,不是 Spring Boot 自动加载的配置文件。 # This file is only an environment-variable reference for local IDE startup.
# 你现在最简单的做法:把下面这些键值复制到 IDEA 的 Run/Debug Configuration -> Environment variables。 # Recommended local startup:
# 1. Set SPRING_PROFILES_ACTIVE=local
# 2. Copy the needed keys below into IDEA Run/Debug Configuration -> Environment variables
# 3. Set AIIMAGE_INSTANCE_ID to a stable local value such as local-121
SPRING_PROFILES_ACTIVE=local
AIIMAGE_INSTANCE_ID=local-121
AIIMAGE_SERVER_PORT=18080 AIIMAGE_SERVER_PORT=18080
AIIMAGE_DB_URL=jdbc:mysql://127.0.0.1:3306/aiimage?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai&useSSL=false AIIMAGE_DB_URL=jdbc:mysql://127.0.0.1:3306/aiimage?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai&useSSL=false

View File

@@ -0,0 +1,19 @@
# Common production override for all servers.
# Activate with: SPRING_PROFILES_ACTIVE=server
# Always pass AIIMAGE_INSTANCE_ID and Redis connection parameters from
# the startup command or panel environment variables.
spring:
data:
redis:
username: ${AIIMAGE_REDIS_USERNAME:}
host: ${AIIMAGE_REDIS_HOST}
port: ${AIIMAGE_REDIS_PORT}
password: ${AIIMAGE_REDIS_PASSWORD}
database: ${AIIMAGE_REDIS_DATABASE:0}
timeout: ${AIIMAGE_REDIS_TIMEOUT:5s}
aiimage:
instance-id: ${AIIMAGE_INSTANCE_ID:}
result-file-job:
mq-enabled: ${AIIMAGE_RESULT_FILE_JOB_MQ_ENABLED:true}

View File

@@ -4,8 +4,6 @@ server:
spring: spring:
application: application:
name: aiimage-backend name: aiimage-backend
config:
import: optional:classpath:application-local.yml
servlet: servlet:
multipart: multipart:
max-file-size: 200MB max-file-size: 200MB
@@ -14,9 +12,9 @@ spring:
time-zone: Asia/Shanghai time-zone: Asia/Shanghai
datasource: datasource:
driver-class-name: com.mysql.cj.jdbc.Driver driver-class-name: com.mysql.cj.jdbc.Driver
url: ${AIIMAGE_DB_URL:jdbc:mysql://127.0.0.1:3306/aiimage?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true} url: ${AIIMAGE_DB_URL:jdbc:mysql://47.110.241.161:3306/aiimage?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true}
username: ${AIIMAGE_DB_USERNAME:root} username: ${AIIMAGE_DB_USERNAME:root}
password: ${AIIMAGE_DB_PASSWORD:change-me} password: ${AIIMAGE_DB_PASSWORD:WTFrb5y6hNLz6hNy}
hikari: hikari:
maximum-pool-size: ${AIIMAGE_DB_POOL_MAX_SIZE:30} maximum-pool-size: ${AIIMAGE_DB_POOL_MAX_SIZE:30}
minimum-idle: ${AIIMAGE_DB_POOL_MIN_IDLE:5} minimum-idle: ${AIIMAGE_DB_POOL_MIN_IDLE:5}
@@ -28,9 +26,10 @@ spring:
leak-detection-threshold: ${AIIMAGE_DB_POOL_LEAK_DETECT_MS:0} leak-detection-threshold: ${AIIMAGE_DB_POOL_LEAK_DETECT_MS:0}
data: data:
redis: redis:
host: ${AIIMAGE_REDIS_HOST:127.0.0.1} username: ${AIIMAGE_REDIS_USERNAME:}
port: ${AIIMAGE_REDIS_PORT:6379} host: ${AIIMAGE_REDIS_HOST:47.111.163.154}
password: ${AIIMAGE_REDIS_PASSWORD:} port: ${AIIMAGE_REDIS_PORT:16379}
password: ${AIIMAGE_REDIS_PASSWORD:B6COTcY094TYe545}
database: ${AIIMAGE_REDIS_DATABASE:0} database: ${AIIMAGE_REDIS_DATABASE:0}
timeout: ${AIIMAGE_REDIS_TIMEOUT:5s} timeout: ${AIIMAGE_REDIS_TIMEOUT:5s}
@@ -72,19 +71,22 @@ knife4j:
language: zh_cn language: zh_cn
aiimage: aiimage:
instance-id: ${AIIMAGE_INSTANCE_ID:} # Set a stable server-level ID in 1Panel/Docker with AIIMAGE_INSTANCE_ID.
# For IDEA/local startup, set -Daiimage.instance-id=<stable-id> or configure the AIIMAGE_INSTANCE_ID env var.
# Avoid relying on container hostname/container ID, otherwise task owner continuity may break after restart/redeploy.
instance-id: ${AIIMAGE_INSTANCE_ID:local-dev}
oss: oss:
region: ${AIIMAGE_OSS_REGION:cn-hangzhou} region: ${AIIMAGE_OSS_REGION:cn-hangzhou}
endpoint: ${AIIMAGE_OSS_ENDPOINT:oss-cn-hangzhou.aliyuncs.com} endpoint: ${AIIMAGE_OSS_ENDPOINT:oss-cn-hangzhou.aliyuncs.com}
bucket: ${AIIMAGE_OSS_BUCKET:change-me} bucket: ${AIIMAGE_OSS_BUCKET:nanri-ai-images}
access-key-id: ${AIIMAGE_OSS_ACCESS_KEY_ID:change-me} access-key-id: ${AIIMAGE_OSS_ACCESS_KEY_ID:LTAI5tNpyvzMNz9f2dHarsm8}
access-key-secret: ${AIIMAGE_OSS_ACCESS_KEY_SECRET:change-me} access-key-secret: ${AIIMAGE_OSS_ACCESS_KEY_SECRET:bQSZnFH455i8tzyOgeahJmUzwmhynz}
transient-storage: transient-storage:
enabled: ${AIIMAGE_TRANSIENT_STORAGE_ENABLED:false} enabled: ${AIIMAGE_TRANSIENT_STORAGE_ENABLED:true}
endpoint: ${AIIMAGE_TRANSIENT_STORAGE_ENDPOINT:} endpoint: ${AIIMAGE_TRANSIENT_STORAGE_ENDPOINT:http://121.196.149.225:9000}
bucket: ${AIIMAGE_TRANSIENT_STORAGE_BUCKET:} bucket: ${AIIMAGE_TRANSIENT_STORAGE_BUCKET:json-server}
access-key-id: ${AIIMAGE_TRANSIENT_STORAGE_ACCESS_KEY_ID:} access-key-id: ${AIIMAGE_TRANSIENT_STORAGE_ACCESS_KEY_ID:YPkyFAymauf21pHMoK0V}
access-key-secret: ${AIIMAGE_TRANSIENT_STORAGE_ACCESS_KEY_SECRET:} access-key-secret: ${AIIMAGE_TRANSIENT_STORAGE_ACCESS_KEY_SECRET:XcPzJqT8jzAHDEQNCovhkPmlwWcphBwLdA36BTzL}
region: ${AIIMAGE_TRANSIENT_STORAGE_REGION:us-east-1} region: ${AIIMAGE_TRANSIENT_STORAGE_REGION:us-east-1}
connect-timeout-seconds: ${AIIMAGE_TRANSIENT_STORAGE_CONNECT_TIMEOUT_SECONDS:10} connect-timeout-seconds: ${AIIMAGE_TRANSIENT_STORAGE_CONNECT_TIMEOUT_SECONDS:10}
read-timeout-seconds: ${AIIMAGE_TRANSIENT_STORAGE_READ_TIMEOUT_SECONDS:60} read-timeout-seconds: ${AIIMAGE_TRANSIENT_STORAGE_READ_TIMEOUT_SECONDS:60}
@@ -131,7 +133,7 @@ aiimage:
scope-payload-recovery-max-files: ${AIIMAGE_TASK_SCOPE_PAYLOAD_RECOVERY_MAX_FILES:200} scope-payload-recovery-max-files: ${AIIMAGE_TASK_SCOPE_PAYLOAD_RECOVERY_MAX_FILES:200}
scope-payload-cleanup-cron: ${AIIMAGE_TASK_SCOPE_PAYLOAD_CLEANUP_CRON:15 */30 * * * *} scope-payload-cleanup-cron: ${AIIMAGE_TASK_SCOPE_PAYLOAD_CLEANUP_CRON:15 */30 * * * *}
result-file-job: result-file-job:
mq-enabled: ${AIIMAGE_RESULT_FILE_JOB_MQ_ENABLED:false} mq-enabled: ${AIIMAGE_RESULT_FILE_JOB_MQ_ENABLED:true}
topic: ${AIIMAGE_RESULT_FILE_JOB_TOPIC:aiimage-result-file-job} topic: ${AIIMAGE_RESULT_FILE_JOB_TOPIC:aiimage-result-file-job}
consumer-group: ${AIIMAGE_RESULT_FILE_JOB_CONSUMER_GROUP:aiimage-result-file-job-consumer} consumer-group: ${AIIMAGE_RESULT_FILE_JOB_CONSUMER_GROUP:aiimage-result-file-job-consumer}
local-dispatch-enabled: ${AIIMAGE_RESULT_FILE_JOB_LOCAL_DISPATCH_ENABLED:true} local-dispatch-enabled: ${AIIMAGE_RESULT_FILE_JOB_LOCAL_DISPATCH_ENABLED:true}

View File

@@ -8,7 +8,7 @@ import threading
import requests import requests
from requests.adapters import HTTPAdapter from requests.adapters import HTTPAdapter
from flask import Blueprint, request, jsonify, session, current_app, g from flask import Blueprint, request, jsonify, session, current_app, g, Response
import pymysql import pymysql
from werkzeug.security import generate_password_hash from werkzeug.security import generate_password_hash
@@ -149,14 +149,24 @@ def _proxy_backend_java(method, path, *, params=None, json_data=None, files=None
def _format_permission_item(item): def _format_permission_item(item):
created_at = item.get('createdAt')
if created_at in (None, ''):
created_at = item.get('created_at')
if hasattr(created_at, 'strftime'):
created_at_text = created_at.strftime('%Y-%m-%d %H:%M')
else:
created_at_text = (str(created_at).replace('T', ' ')[:16]) if created_at else ''
sort_order = item.get('sortOrder')
if sort_order is None:
sort_order = item.get('sort_order')
return { return {
'id': item.get('id'), 'id': item.get('id'),
'name': item.get('name') or '', 'name': item.get('name') or '',
'column_key': item.get('columnKey') or '', 'column_key': item.get('columnKey') or item.get('column_key') or '',
'menu_type': item.get('menuType') or 'app', 'menu_type': item.get('menuType') or item.get('menu_type') or 'app',
'route_path': item.get('routePath') or '', 'route_path': item.get('routePath') or item.get('route_path') or '',
'sort_order': item.get('sortOrder') if item.get('sortOrder') is not None else 0, 'sort_order': sort_order if sort_order is not None else 0,
'created_at': (item.get('createdAt') or '').replace('T', ' ')[:16], 'created_at': created_at_text,
} }
@@ -792,11 +802,9 @@ def create_user():
(username, pwd_hash, is_admin, want_role, want_created_by), (username, pwd_hash, is_admin, want_role, want_created_by),
) )
new_uid = cur.lastrowid new_uid = cur.lastrowid
_set_user_column_permissions(cur, new_uid, column_ids)
conn.commit() conn.commit()
conn.close() conn.close()
error_response, status = _sync_user_column_permissions(new_uid, column_ids)
if error_response is not None:
return error_response, status
return jsonify({'success': True, 'msg': '用户创建成功'}) return jsonify({'success': True, 'msg': '用户创建成功'})
except pymysql.IntegrityError: except pymysql.IntegrityError:
return jsonify({'success': False, 'error': '用户名已存在'}) return jsonify({'success': False, 'error': '用户名已存在'})
@@ -807,7 +815,7 @@ def create_user():
@admin_api.route('/user/<int:uid>', methods=['PUT']) @admin_api.route('/user/<int:uid>', methods=['PUT'])
@admin_required @admin_required
def update_user(uid): def update_user(uid):
"""更新用户(密码、角色)""" """更新用户(密码、角色、栏目权限"""
data = request.get_json() or {} data = request.get_json() or {}
_, _, denied = _ensure_admin_menu_access('users') _, _, denied = _ensure_admin_menu_access('users')
if denied: if denied:
@@ -817,7 +825,7 @@ def update_user(uid):
role, current_row = get_current_admin_role() role, current_row = get_current_admin_role()
if not role: if not role:
return jsonify({'success': False, 'error': '需要管理员权限'}), 403 return jsonify({'success': False, 'error': '需要管理员权限'}), 403
if want_role is None and not password: if want_role is None and not password and 'column_ids' not in data:
return jsonify({'success': False, 'error': '请提供要修改的内容'}) return jsonify({'success': False, 'error': '请提供要修改的内容'})
try: try:
conn = get_db() conn = get_db()
@@ -852,13 +860,10 @@ def update_user(uid):
"UPDATE users SET is_admin = %s, role = %s WHERE id = %s", "UPDATE users SET is_admin = %s, role = %s WHERE id = %s",
(is_admin, want_role, uid), (is_admin, want_role, uid),
) )
column_ids = data.get('column_ids') if 'column_ids' in data:
_set_user_column_permissions(cur, uid, data.get('column_ids'))
conn.commit() conn.commit()
conn.close() conn.close()
if column_ids is not None:
error_response, status = _sync_user_column_permissions(uid, column_ids)
if error_response is not None:
return error_response, status
return jsonify({'success': True, 'msg': '更新成功'}) return jsonify({'success': True, 'msg': '更新成功'})
except Exception as e: except Exception as e:
return jsonify({'success': False, 'error': str(e)}) return jsonify({'success': False, 'error': str(e)})
@@ -1139,15 +1144,7 @@ def get_user_columns(uid):
if denied: if denied:
return denied return denied
menu_type = (request.args.get('menu_type') or '').strip() menu_type = (request.args.get('menu_type') or '').strip()
result, error_response, status = _proxy_backend_java( return jsonify({'success': True, 'column_ids': _get_local_user_column_ids(uid, menu_type or None)})
'GET',
f'/api/admin/permission-users/{uid}/columns',
params={'menuType': menu_type} if menu_type else None,
)
if error_response is not None:
return error_response, status
payload = result.get('data') or {}
return jsonify({'success': True, 'column_ids': payload.get('columnIds') or []})
@admin_api.route('/user/<int:uid>/column-permissions') @admin_api.route('/user/<int:uid>/column-permissions')
@@ -1157,14 +1154,8 @@ def get_user_column_permissions(uid):
if denied: if denied:
return denied return denied
menu_type = (request.args.get('menu_type') or '').strip() menu_type = (request.args.get('menu_type') or '').strip()
result, error_response, status = _proxy_backend_java( items = _get_local_user_column_permission_items(uid, menu_type or None)
'GET', return jsonify({'success': True, 'items': [_format_permission_item(item) for item in items]})
f'/api/admin/permission-users/{uid}/column-permissions',
params={'menuType': menu_type} if menu_type else None,
)
if error_response is not None:
return error_response, status
return jsonify({'success': True, 'items': [_format_permission_item(item) for item in (result.get('data') or [])]})
def _set_user_column_permissions(cur, user_id, column_ids): def _set_user_column_permissions(cur, user_id, column_ids):
@@ -2006,18 +1997,18 @@ def list_skip_price_asins():
group_id_raw = (request.args.get('group_id') or '').strip() group_id_raw = (request.args.get('group_id') or '').strip()
shop_name = (request.args.get('shop_name') or '').strip() shop_name = (request.args.get('shop_name') or '').strip()
asin = (request.args.get('asin') or '').strip() asin = (request.args.get('asin') or '').strip()
params = {'page': page, 'pageSize': page_size} params = {'page': page, 'page_size': page_size, 'pageSize': page_size}
if current_row and current_row.get('id'): params.update(_skip_price_asin_auth_params(role, current_row))
params['operatorId'] = current_row.get('id')
params['superAdmin'] = 'true' if role == 'super_admin' else 'false'
if group_id_raw: if group_id_raw:
try: try:
group_id = int(group_id_raw) group_id = int(group_id_raw)
if group_id > 0: if group_id > 0:
params['group_id'] = group_id
params['groupId'] = group_id params['groupId'] = group_id
except (TypeError, ValueError): except (TypeError, ValueError):
pass pass
if shop_name: if shop_name:
params['shop_name'] = shop_name
params['shopName'] = shop_name params['shopName'] = shop_name
if asin: if asin:
params['asin'] = asin params['asin'] = asin
@@ -2059,6 +2050,64 @@ def list_skip_price_asins():
}) })
def _skip_price_asin_auth_params(role, current_row):
operator_id = current_row.get('id') if current_row else None
super_admin = 'true' if role == 'super_admin' else 'false'
return {
'operator_id': operator_id,
'operatorId': operator_id,
'super_admin': super_admin,
'superAdmin': super_admin,
}
@admin_api.route('/skip-price-asins/export')
@login_required
def export_skip_price_asins():
role, current_row, denied = _ensure_backend_menu_access('skip-price-asin')
if denied:
return denied
params = _skip_price_asin_auth_params(role, current_row)
group_id_raw = (request.args.get('group_id') or request.args.get('groupId') or '').strip()
shop_name = (request.args.get('shop_name') or request.args.get('shopName') or '').strip()
asin = (request.args.get('asin') or '').strip()
if group_id_raw:
params['group_id'] = group_id_raw
params['groupId'] = group_id_raw
if shop_name:
params['shop_name'] = shop_name
params['shopName'] = shop_name
if asin:
params['asin'] = asin
url = f"{backend_java_base_url}/api/admin/skip-price-asins/export"
try:
resp = _get_backend_java_session().get(url, params=params, timeout=60)
except requests.RequestException:
return jsonify({'success': False, 'error': 'backend-java 服务不可用'}), 502
if resp.status_code >= 400:
try:
data = resp.json()
error = data.get('message') or data.get('error') or '导出失败'
except ValueError:
error = '导出失败'
return jsonify({'success': False, 'error': error}), resp.status_code
headers = {}
disposition = resp.headers.get('Content-Disposition')
if disposition:
headers['Content-Disposition'] = disposition
return Response(
resp.content,
status=resp.status_code,
headers=headers,
content_type=resp.headers.get(
'Content-Type',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
),
)
@admin_api.route('/skip-price-asin', methods=['POST']) @admin_api.route('/skip-price-asin', methods=['POST'])
@login_required @login_required
def create_skip_price_asin(): def create_skip_price_asin():
@@ -2092,10 +2141,7 @@ def create_skip_price_asin():
result, error_response, status = _proxy_backend_java( result, error_response, status = _proxy_backend_java(
'POST', 'POST',
'/api/admin/skip-price-asins', '/api/admin/skip-price-asins',
params={ params=_skip_price_asin_auth_params(role, current_row),
'operatorId': current_row.get('id') if current_row else None,
'superAdmin': 'true' if role == 'super_admin' else 'false',
},
json_data=payload, json_data=payload,
) )
if error_response is not None: if error_response is not None:
@@ -2134,10 +2180,7 @@ def delete_skip_price_asin_country(item_id, country):
result, error_response, status = _proxy_backend_java( result, error_response, status = _proxy_backend_java(
'DELETE', 'DELETE',
f'/api/admin/skip-price-asins/{item_id}/countries/{country}', f'/api/admin/skip-price-asins/{item_id}/countries/{country}',
params={ params=_skip_price_asin_auth_params(role, current_row),
'operatorId': current_row.get('id') if current_row else None,
'superAdmin': 'true' if role == 'super_admin' else 'false',
},
) )
if error_response is not None: if error_response is not None:
return error_response, status return error_response, status
@@ -2177,10 +2220,7 @@ def update_skip_price_asin_country(item_id, country):
result, error_response, status = _proxy_backend_java( result, error_response, status = _proxy_backend_java(
'PUT', 'PUT',
f'/api/admin/skip-price-asins/{item_id}/countries/{country}', f'/api/admin/skip-price-asins/{item_id}/countries/{country}',
params={ params=_skip_price_asin_auth_params(role, current_row),
'operatorId': current_row.get('id') if current_row else None,
'superAdmin': 'true' if role == 'super_admin' else 'false',
},
json_data=payload, json_data=payload,
) )
if error_response is not None: if error_response is not None:
@@ -2240,11 +2280,9 @@ def import_skip_price_asins():
group_id_raw = (request.form.get('group_id') or '').strip() group_id_raw = (request.form.get('group_id') or '').strip()
if not group_id_raw: if not group_id_raw:
return jsonify({'success': False, 'error': '请先选择分组'}) return jsonify({'success': False, 'error': '请先选择分组'})
params = { params = _skip_price_asin_auth_params(role, current_row)
'groupId': group_id_raw, params['group_id'] = group_id_raw
'operatorId': current_row.get('id') if current_row else None, params['groupId'] = group_id_raw
'superAdmin': 'true' if role == 'super_admin' else 'false',
}
files = { files = {
'file': (file_storage.filename, file_storage.stream, file_storage.mimetype or 'application/octet-stream') 'file': (file_storage.filename, file_storage.stream, file_storage.mimetype or 'application/octet-stream')
} }
@@ -2295,11 +2333,9 @@ def delete_import_skip_price_asins():
group_id_raw = (request.form.get('group_id') or '').strip() group_id_raw = (request.form.get('group_id') or '').strip()
if not group_id_raw: if not group_id_raw:
return jsonify({'success': False, 'error': '请先选择分组'}) return jsonify({'success': False, 'error': '请先选择分组'})
params = { params = _skip_price_asin_auth_params(role, current_row)
'groupId': group_id_raw, params['group_id'] = group_id_raw
'operatorId': current_row.get('id') if current_row else None, params['groupId'] = group_id_raw
'superAdmin': 'true' if role == 'super_admin' else 'false',
}
files = { files = {
'file': (file_storage.filename, file_storage.stream, file_storage.mimetype or 'application/octet-stream') 'file': (file_storage.filename, file_storage.stream, file_storage.mimetype or 'application/octet-stream')
} }
@@ -2584,3 +2620,46 @@ def update_query_asin_country(item_id, country):
'msg': result.get('message') or '保存成功', 'msg': result.get('message') or '保存成功',
'item': _format_query_asin_item(result.get('data') or {}), 'item': _format_query_asin_item(result.get('data') or {}),
}) })
def _get_local_user_column_ids(user_id, menu_type=None):
conn = get_db()
try:
with conn.cursor() as cur:
sql = """
SELECT c.id
FROM user_column_permission ucp
INNER JOIN columns c ON c.id = ucp.column_id
WHERE ucp.user_id = %s
"""
params = [user_id]
if menu_type:
sql += " AND c.menu_type = %s"
params.append(menu_type)
sql += " ORDER BY c.sort_order ASC, c.id ASC"
cur.execute(sql, params)
rows = cur.fetchall()
return [int(row['id']) for row in rows if row.get('id') is not None]
finally:
conn.close()
def _get_local_user_column_permission_items(user_id, menu_type=None):
conn = get_db()
try:
with conn.cursor() as cur:
sql = """
SELECT c.id, c.name, c.column_key, c.menu_type, c.route_path, c.sort_order, c.created_at
FROM user_column_permission ucp
INNER JOIN columns c ON c.id = ucp.column_id
WHERE ucp.user_id = %s
"""
params = [user_id]
if menu_type:
sql += " AND c.menu_type = %s"
params.append(menu_type)
sql += " ORDER BY c.sort_order ASC, c.id ASC"
cur.execute(sql, params)
return cur.fetchall()
finally:
conn.close()

View File

@@ -1856,16 +1856,82 @@
minimumPriceMappings: minimumPriceMappings minimumPriceMappings: minimumPriceMappings
}; };
} }
function buildSkipPriceAsinQuery(page) { function buildSkipPriceAsinFilterQuery() {
var query = 'page=' + (page || 1) + '&page_size=' + skipPriceAsinPageSize; var query = '';
var groupId = (document.getElementById('skipPriceAsinFilterGroupId').value || '').trim(); var groupId = (document.getElementById('skipPriceAsinFilterGroupId').value || '').trim();
var shopName = (document.getElementById('skipPriceAsinFilterShopName').value || '').trim(); var shopName = (document.getElementById('skipPriceAsinFilterShopName').value || '').trim();
var asin = (document.getElementById('skipPriceAsinFilterAsin').value || '').trim(); var asin = (document.getElementById('skipPriceAsinFilterAsin').value || '').trim();
if (groupId) query += '&group_id=' + encodeURIComponent(groupId); if (groupId) query += (query ? '&' : '') + 'group_id=' + encodeURIComponent(groupId);
if (shopName) query += '&shop_name=' + encodeURIComponent(shopName); if (shopName) query += (query ? '&' : '') + 'shop_name=' + encodeURIComponent(shopName);
if (asin) query += '&asin=' + encodeURIComponent(asin); if (asin) query += (query ? '&' : '') + 'asin=' + encodeURIComponent(asin);
return query; return query;
} }
function buildSkipPriceAsinOperatorQuery() {
var query = '';
if (currentUserId) query += 'operator_id=' + encodeURIComponent(currentUserId);
if (currentUserRole === 'super_admin') query += (query ? '&' : '') + 'super_admin=true';
return query;
}
function buildSkipPriceAsinQuery(page) {
var query = 'page=' + (page || 1) + '&page_size=' + skipPriceAsinPageSize;
var filterQuery = buildSkipPriceAsinFilterQuery();
if (filterQuery) query += '&' + filterQuery;
var operatorQuery = buildSkipPriceAsinOperatorQuery();
if (operatorQuery) query += '&' + operatorQuery;
return query;
}
function extractDownloadFilename(disposition, fallbackName) {
var fallback = fallbackName || 'download.xlsx';
if (!disposition) return fallback;
var utf8Match = disposition.match(/filename\*=UTF-8''([^;]+)/i);
if (utf8Match && utf8Match[1]) {
try {
return decodeURIComponent(utf8Match[1]);
} catch (e) { }
}
var plainMatch = disposition.match(/filename=\"?([^\";]+)\"?/i);
if (plainMatch && plainMatch[1]) return plainMatch[1];
return fallback;
}
function triggerBrowserDownload(blob, filename) {
var url = URL.createObjectURL(blob);
var a = document.createElement('a');
a.href = url;
a.download = filename || 'download.xlsx';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(function () { URL.revokeObjectURL(url); }, 1000);
}
function exportSkipPriceAsin() {
var query = buildSkipPriceAsinFilterQuery();
var operatorQuery = buildSkipPriceAsinOperatorQuery();
if (operatorQuery) query += (query ? '&' : '') + operatorQuery;
var url = '/api/admin/skip-price-asins/export' + (query ? ('?' + query) : '');
fetch(url)
.then(function (response) {
var contentType = response.headers.get('content-type') || '';
if (!response.ok || contentType.indexOf('application/json') >= 0) {
return response.json().then(function (res) {
throw new Error((res && (res.error || res.msg)) || '导出失败');
}).catch(function (err) {
throw err instanceof Error ? err : new Error('导出失败');
});
}
return response.blob().then(function (blob) {
return {
blob: blob,
filename: extractDownloadFilename(response.headers.get('content-disposition'), 'skip-price-asin.xlsx')
};
});
})
.then(function (payload) {
triggerBrowserDownload(payload.blob, payload.filename);
})
.catch(function (err) {
alert((err && err.message) || '导出失败');
});
}
function renderSkipPriceAsinCell(item, country) { function renderSkipPriceAsinCell(item, country) {
var asinValue = item[country.field] || ''; var asinValue = item[country.field] || '';
var minimumPriceValue = formatSkipPriceMinimumPrice(item[country.minimumPriceField]); var minimumPriceValue = formatSkipPriceMinimumPrice(item[country.minimumPriceField]);
@@ -1917,7 +1983,8 @@
var shopName = (btn.dataset.shopName || '').replace(/&quot;/g, '"'); var shopName = (btn.dataset.shopName || '').replace(/&quot;/g, '"');
var countryCode = btn.dataset.country || ''; var countryCode = btn.dataset.country || '';
if (!confirm('确定删除店铺“' + shopName + '”在 ' + countryCode + ' 的 ASIN 吗?')) return; if (!confirm('确定删除店铺“' + shopName + '”在 ' + countryCode + ' 的 ASIN 吗?')) return;
fetch('/api/admin/skip-price-asin/' + btn.dataset.skipPriceAsinDelete + '/country/' + countryCode, { var operatorQuery = buildSkipPriceAsinOperatorQuery();
fetch('/api/admin/skip-price-asin/' + btn.dataset.skipPriceAsinDelete + '/country/' + countryCode + (operatorQuery ? ('?' + operatorQuery) : ''), {
method: 'DELETE' method: 'DELETE'
}) })
.then(function (r) { return r.json(); }) .then(function (r) { return r.json(); })
@@ -2184,6 +2251,8 @@
var formData = new FormData(); var formData = new FormData();
formData.append('file', file); formData.append('file', file);
formData.append('group_id', groupId); formData.append('group_id', groupId);
if (currentUserId) formData.append('operator_id', currentUserId);
if (currentUserRole === 'super_admin') formData.append('super_admin', 'true');
var xhr = new XMLHttpRequest(); var xhr = new XMLHttpRequest();
xhr.open('POST', deleteMode ? '/api/admin/skip-price-asins/delete-import' : '/api/admin/skip-price-asins/import', true); xhr.open('POST', deleteMode ? '/api/admin/skip-price-asins/delete-import' : '/api/admin/skip-price-asins/import', true);
xhr.upload.onprogress = function (event) { xhr.upload.onprogress = function (event) {
@@ -2267,7 +2336,8 @@
return; return;
} }
} }
fetch('/api/admin/skip-price-asin/' + itemId + '/country/' + countryCode, { var operatorQuery = buildSkipPriceAsinOperatorQuery();
fetch('/api/admin/skip-price-asin/' + itemId + '/country/' + countryCode + (operatorQuery ? ('?' + operatorQuery) : ''), {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
@@ -2294,6 +2364,9 @@
document.getElementById('btnSearchSkipPriceAsin').onclick = function () { document.getElementById('btnSearchSkipPriceAsin').onclick = function () {
loadSkipPriceAsin(1); loadSkipPriceAsin(1);
}; };
document.getElementById('btnExportSkipPriceAsin').onclick = function () {
exportSkipPriceAsin();
};
document.getElementById('skipPriceAsinFilterShopName').addEventListener('keydown', function (e) { document.getElementById('skipPriceAsinFilterShopName').addEventListener('keydown', function (e) {
if (e.key === 'Enter') { if (e.key === 'Enter') {
e.preventDefault(); e.preventDefault();
@@ -2334,7 +2407,8 @@
fallbackAsin = asinMappings[countryCode] || ''; fallbackAsin = asinMappings[countryCode] || '';
return !!fallbackAsin; return !!fallbackAsin;
}); });
fetch('/api/admin/skip-price-asin', { var operatorQuery = buildSkipPriceAsinOperatorQuery();
fetch('/api/admin/skip-price-asin' + (operatorQuery ? ('?' + operatorQuery) : ''), {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
@@ -3429,8 +3503,10 @@
} }
// 初始化 // 初始化
var adminCurrentUserPromise = null;
function loadAdminCurrentUser() { function loadAdminCurrentUser() {
fetch('/api/admin/current-user') if (adminCurrentUserPromise) return adminCurrentUserPromise;
adminCurrentUserPromise = fetch('/api/admin/current-user')
.then(function (r) { .then(function (r) {
if (r.status === 404) { if (r.status === 404) {
return fetch('/api/auth/check') return fetch('/api/auth/check')
@@ -3474,7 +3550,7 @@
updateShopManageGroupButtonsAccess(); updateShopManageGroupButtonsAccess();
}) })
.catch(function () { .catch(function () {
fetch('/api/auth/check') return fetch('/api/auth/check')
.then(function (r) { return r.json(); }) .then(function (r) { return r.json(); })
.then(function (res) { .then(function (res) {
document.getElementById('adminCurrentUsername').textContent = res && res.logged_in ? '当前用户' : '未登录'; document.getElementById('adminCurrentUsername').textContent = res && res.logged_in ? '当前用户' : '未登录';
@@ -3486,7 +3562,13 @@
document.getElementById('adminCurrentUserRole').style.display = 'none'; document.getElementById('adminCurrentUserRole').style.display = 'none';
updateShopManageGroupButtonsAccess(); updateShopManageGroupButtonsAccess();
}); });
})
.finally(function () {
if (!currentUserId && currentUserRole !== 'super_admin') {
adminCurrentUserPromise = null;
}
}); });
return adminCurrentUserPromise;
} }
document.getElementById('btnAdminLogout').onclick = function () { document.getElementById('btnAdminLogout').onclick = function () {
@@ -3514,10 +3596,11 @@
}); });
}; };
loadAdminCurrentUser(); loadAdminCurrentUser().finally(function () {
loadUserOptions(); loadUserOptions();
loadColumnsForPermission(); loadColumnsForPermission();
loadShopManageGroups(); loadShopManageGroups();
loadAdminMenus(); loadAdminMenus();
});
}) (); }) ();

View File

@@ -1202,6 +1202,7 @@
<input type="text" id="skipPriceAsinFilterAsin" placeholder="请输入 ASIN"> <input type="text" id="skipPriceAsinFilterAsin" placeholder="请输入 ASIN">
</div> </div>
<button class="btn" id="btnSearchSkipPriceAsin">查询</button> <button class="btn" id="btnSearchSkipPriceAsin">查询</button>
<button class="btn btn-secondary" id="btnExportSkipPriceAsin" type="button">导出 XLSX</button>
</div> </div>
<table> <table>
<thead> <thead>

View File

@@ -84,9 +84,6 @@
<div class="file-progress-track"> <div class="file-progress-track">
<div class="file-progress-bar" :style="{ width: `${fileProgressPercent(item)}%` }"></div> <div class="file-progress-bar" :style="{ width: `${fileProgressPercent(item)}%` }"></div>
</div> </div>
<div class="file-progress-count">
{{ item.fileProgressCurrent || 0 }}/{{ item.fileProgressTotal || 0 }}
</div>
</div> </div>
</div> </div>
<div class="task-right"> <div class="task-right">
@@ -119,9 +116,6 @@
<div class="file-progress-track"> <div class="file-progress-track">
<div class="file-progress-bar" :style="{ width: `${fileProgressPercent(item)}%` }"></div> <div class="file-progress-bar" :style="{ width: `${fileProgressPercent(item)}%` }"></div>
</div> </div>
<div class="file-progress-count">
{{ item.fileProgressCurrent || 0 }}/{{ item.fileProgressTotal || 0 }}
</div>
</div> </div>
<div v-if="item.error" class="files">错误{{ item.error }}</div> <div v-if="item.error" class="files">错误{{ item.error }}</div>
</div> </div>
@@ -702,8 +696,13 @@ function canDownload(item: AppearancePatentHistoryItem) {
function fileProgressPercent(item: AppearancePatentHistoryItem) { function fileProgressPercent(item: AppearancePatentHistoryItem) {
const percent = Number(item.fileProgressPercent || 0) const percent = Number(item.fileProgressPercent || 0)
if (!Number.isFinite(percent)) return 0 if (Number.isFinite(percent) && percent > 0) {
return Math.max(0, Math.min(100, Math.round(percent))) return Math.max(0, Math.min(100, Math.round(percent)))
}
if (shouldShowFallbackJobProgress(item)) {
return activeFileJobStatus(item) === 'PENDING' ? 6 : 10
}
return 0
} }
function displayFileProgressMessage(item: AppearancePatentHistoryItem, fallback: string) { function displayFileProgressMessage(item: AppearancePatentHistoryItem, fallback: string) {
@@ -736,7 +735,30 @@ function translateFileProgressMessage(message: string) {
function showFileProgress(item: AppearancePatentHistoryItem) { function showFileProgress(item: AppearancePatentHistoryItem) {
const status = normalizeTaskStatus(item) const status = normalizeTaskStatus(item)
return (status === 'RUNNING' || isResultPreparing(item)) && (item.fileProgressTotal || 0) > 0 return (status === 'RUNNING' || isResultPreparing(item)) && (hasExplicitFileProgress(item) || shouldShowFallbackJobProgress(item))
}
function hasExplicitFileProgress(item: AppearancePatentHistoryItem) {
return hasFileProgressCount(item)
|| Number(item.fileProgressPercent || 0) > 0
|| Boolean((item.fileProgressMessage || '').trim())
}
function hasFileProgressCount(item: AppearancePatentHistoryItem) {
return (item.fileProgressTotal || 0) > 0
}
function activeFileJobStatus(item: AppearancePatentHistoryItem) {
return (item.fileStatus || '').toUpperCase()
}
function hasActiveFileJob(item: AppearancePatentHistoryItem) {
const fileStatus = activeFileJobStatus(item)
return item.fileJobId != null || fileStatus === 'PENDING' || fileStatus === 'RUNNING'
}
function shouldShowFallbackJobProgress(item: AppearancePatentHistoryItem) {
return hasActiveFileJob(item) && !hasExplicitFileProgress(item)
} }
async function downloadResult(item: AppearancePatentHistoryItem) { async function downloadResult(item: AppearancePatentHistoryItem) {
@@ -842,7 +864,6 @@ onUnmounted(() => {
.file-progress-meta { display: flex; justify-content: space-between; gap: 12px; color: #d8c278; font-size: 12px; } .file-progress-meta { display: flex; justify-content: space-between; gap: 12px; color: #d8c278; font-size: 12px; }
.file-progress-track { margin-top: 5px; height: 8px; border-radius: 999px; overflow: hidden; background: #303030; border: 1px solid #3b3b3b; } .file-progress-track { margin-top: 5px; height: 8px; border-radius: 999px; overflow: hidden; background: #303030; border: 1px solid #3b3b3b; }
.file-progress-bar { height: 100%; border-radius: inherit; background: linear-gradient(90deg, #4aa3ff, #f0c75e); transition: width .25s ease; } .file-progress-bar { height: 100%; border-radius: inherit; background: linear-gradient(90deg, #4aa3ff, #f0c75e); transition: width .25s ease; }
.file-progress-count { margin-top: 4px; color: #858585; font-size: 11px; }
.download { padding: 6px 10px; color: #d6ecff; background: rgba(52, 152, 219, .18); } .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); } .btn-delete { padding: 6px 10px; color: #ff8f8f; background: rgba(231, 76, 60, .12); }
@media (max-width: 1100px) { @media (max-width: 1100px) {

View File

@@ -115,6 +115,7 @@
<div v-if="!parsedRows.length" class="empty-tasks">暂无解析结果</div> <div v-if="!parsedRows.length" class="empty-tasks">暂无解析结果</div>
<el-table v-else :data="previewRows" height="120" class="result-table"> <el-table v-else :data="previewRows" height="120" class="result-table">
<el-table-column prop="displayId" label="ID" width="90" /> <el-table-column prop="displayId" label="ID" width="90" />
<el-table-column prop="sku" label="SKU" width="130" />
<el-table-column prop="asin" label="ASIN" width="130" /> <el-table-column prop="asin" label="ASIN" width="130" />
<el-table-column prop="country" label="国家" width="100" /> <el-table-column prop="country" label="国家" width="100" />
<el-table-column prop="price" label="价格" width="90" /> <el-table-column prop="price" label="价格" width="90" />

View File

@@ -1721,6 +1721,7 @@ export interface SimilarAsinParsedRow {
groupKey?: string; groupKey?: string;
asin: string; asin: string;
country: string; country: string;
sku?: string;
price?: string; price?: string;
url?: string; url?: string;
title?: string; title?: string;