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

This commit is contained in:
super
2026-05-12 21:03:42 +08:00
parent 5c990e651e
commit 0d78d63437
30 changed files with 3444 additions and 394 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://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://121.196.149.225:18080
java_api_base=http://121.196.149.225:18080

View File

@@ -594,6 +594,165 @@ class SimilarAsinTask(TaskBase):
runing_task[task_id]["status"] = "failed"
runing_task[task_id]["error"] = str(e)
def process_task(self, task_data: dict):
"""Process similar ASIN task rows one by one."""
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):

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;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.net.InetAddress;
@Slf4j
@Component
public class InstanceMetadata {
private final String instanceId;
private final String hostname;
private final String source;
private final boolean stable;
public InstanceMetadata(@Value("${aiimage.instance-id:}") String configuredInstanceId) {
this.hostname = resolveHostname();
this.instanceId = configuredInstanceId == null || configuredInstanceId.isBlank()
? this.hostname
: configuredInstanceId.trim();
String normalizedConfiguredInstanceId = configuredInstanceId == null ? "" : configuredInstanceId.trim();
if (!normalizedConfiguredInstanceId.isBlank()) {
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() {
@@ -26,6 +43,14 @@ public class InstanceMetadata {
return hostname;
}
public String getSource() {
return source;
}
public boolean isStable() {
return stable;
}
private static String resolveHostname() {
try {
return InetAddress.getLocalHost().getHostName();
@@ -34,4 +59,12 @@ public class InstanceMetadata {
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-Host", instanceMetadata.getHostname());
response.setHeader("X-AIIMAGE-Instance-Source", instanceMetadata.getSource());
response.setHeader("X-AIIMAGE-Instance-Stable", String.valueOf(instanceMetadata.isStable()));
try {
filterChain.doFilter(request, response);
} finally {
long costMs = System.currentTimeMillis() - start;
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.getSource(),
instanceMetadata.isStable(),
instanceMetadata.getHostname(),
request.getMethod(),
request.getRequestURI(),

View File

@@ -3,6 +3,8 @@ package com.nanri.aiimage.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import java.nio.file.Path;
@Data
@ConfigurationProperties(prefix = "aiimage.storage")
public class StorageProperties {
@@ -12,4 +14,25 @@ public class StorageProperties {
private long sourceRetentionHours = 24;
private long resultRetentionHours = 24;
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"),
firstNonNull(node.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"),
firstNonNull(node.get("titleReason"),
firstNonNull(itemNode.get("title_reason"), itemNode.get("titleReason"))))),
@@ -465,6 +468,7 @@ public class AppearancePatentCozeClient {
row.setAppearanceRisk(result.appearance());
row.setPatentRisk(result.patent());
row.setConclusion(result.result());
row.setStatus(result.status());
row.setTitleReason(result.titleReason());
row.setAppearanceReason(result.appearanceReason());
row.setPatentReason(result.patentReason());
@@ -490,6 +494,7 @@ public class AppearancePatentCozeClient {
row.setTitle(source.getTitle());
row.setError(source.getError());
row.setDone(source.getDone());
row.setStatus(source.getStatus());
row.setTitleRisk(source.getTitleRisk());
row.setAppearanceRisk(source.getAppearanceRisk());
row.setPatentRisk(source.getPatentRisk());
@@ -942,6 +947,7 @@ public class AppearancePatentCozeClient {
String appearance,
String patent,
String result,
String status,
String titleReason,
String appearanceReason,
String patentReason

View File

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

View File

@@ -118,7 +118,8 @@ public class AppearancePatentTaskService {
"标题维度(商标)",
"外观维度(外观设计专利)",
"专利维度(发明/实用新型专利)",
"结论"
"结论",
"status"
);
private final LocalFileStorageService localFileStorageService;
@@ -424,7 +425,7 @@ public class AppearancePatentTaskService {
completeSubmittedChunk(context);
return null;
});
submitCozeForSubmittedChunk(context);
scheduleCozePipelineForSubmittedChunk(context);
return;
}
FileTaskEntity task = fileTaskMapper.selectById(taskId);
@@ -510,7 +511,7 @@ public class AppearancePatentTaskService {
task.setUpdatedAt(LocalDateTime.now());
fileTaskMapper.updateById(task);
}
submitCozeForSubmittedChunk(context);
scheduleCozePipelineForSubmittedChunk(context);
}
@Transactional
@@ -560,14 +561,9 @@ public class AppearancePatentTaskService {
if (transactionManager != null) {
LocalDateTime threshold = LocalDateTime.now().minusMinutes(Math.max(1, properties.getStaleTimeoutMinutes()));
long thresholdMillis = threshold.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli();
List<FileTaskEntity> tasks = fileTaskMapper.selectList(new LambdaQueryWrapper<FileTaskEntity>()
.eq(FileTaskEntity::getModuleType, MODULE_TYPE)
.eq(FileTaskEntity::getStatus, STATUS_RUNNING)
.lt(FileTaskEntity::getUpdatedAt, threshold)
.last("limit 50"));
List<FileTaskEntity> tasks = listStaleFinalizeCandidates(threshold);
for (FileTaskEntity task : tasks) {
if (isJavaSideProcessing(task.getId())) {
touchJavaSideTaskActivity(task.getId());
if (!isOwnerCurrent(ownerFromTask(task))) {
continue;
}
long heartbeatMillis = taskCacheService.getTaskHeartbeatMillis(task.getId());
@@ -589,14 +585,9 @@ public class AppearancePatentTaskService {
}
LocalDateTime threshold = LocalDateTime.now().minusMinutes(Math.max(1, properties.getStaleTimeoutMinutes()));
long thresholdMillis = threshold.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli();
List<FileTaskEntity> tasks = fileTaskMapper.selectList(new LambdaQueryWrapper<FileTaskEntity>()
.eq(FileTaskEntity::getModuleType, MODULE_TYPE)
.eq(FileTaskEntity::getStatus, STATUS_RUNNING)
.lt(FileTaskEntity::getUpdatedAt, threshold)
.last("limit 50"));
List<FileTaskEntity> tasks = listStaleFinalizeCandidates(threshold);
for (FileTaskEntity task : tasks) {
if (isJavaSideProcessing(task.getId())) {
touchJavaSideTaskActivity(task.getId());
if (!isOwnerCurrent(ownerFromTask(task))) {
continue;
}
long heartbeatMillis = taskCacheService.getTaskHeartbeatMillis(task.getId());
@@ -608,14 +599,46 @@ public class AppearancePatentTaskService {
continue;
}
try (lockHandle) {
FileTaskEntity latestTask = fileTaskMapper.selectById(task.getId());
if (latestTask != null) {
finalizeTask(latestTask, "Python interrupted before uploading final appearance patent result", allRowCount(latestTask), true);
}
finalizeStaleTask(task.getId(), "Python interrupted before uploading final appearance patent result");
}
}
}
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) {
FileTaskEntity task = fileTaskMapper.selectById(taskId);
if (task == null || !MODULE_TYPE.equals(task.getModuleType())) {
@@ -699,17 +722,24 @@ public class AppearancePatentTaskService {
}
List<TaskChunkEntity> chunks = loadSubmittedChunks(task.getId());
if (chunks.isEmpty()) {
log.info("[appearance-patent] skip stale recovery coze submission because no submitted chunks remain taskId={}",
task.getId());
return;
}
FileResultEntity result = findOrCreateResultRecordForAssembly(task, allRowCount(task));
if (result == null) {
log.warn("[appearance-patent] stale recovery could not create result record taskId={}", task.getId());
return;
}
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())) {
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;
}
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);
boolean pendingCoze = submitCozeBatches(task, result, job, chunks, allRowsByBaseId);
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) {
FileTaskEntity task = fileTaskMapper.selectById(taskId);
if (task == null || !MODULE_TYPE.equals(task.getModuleType()) || !STATUS_RUNNING.equals(task.getStatus())) {
return;
}
if (tryRecoverTimedOutPythonTask(task)) {
return;
}
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,
String scopeKey,
String scopeHash,
@@ -765,6 +908,8 @@ public class AppearancePatentTaskService {
if (scope.getId() == null) {
try {
taskScopeStateMapper.insert(scope);
log.info("[appearance-patent] scope state inserted taskId={} scope={} scopeHash={} completed={} cozeDone={}",
taskId, scopeKey, scopeHash, completed, cozeDone);
return;
} catch (DuplicateKeyException ex) {
log.info("[appearance-patent] duplicate scope state inserted concurrently taskId={} scope={}", taskId, scopeKey);
@@ -792,6 +937,8 @@ public class AppearancePatentTaskService {
}
}
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) {
@@ -1085,6 +1232,7 @@ public class AppearancePatentTaskService {
row.setUrl(firstNonBlank(representative.getUrl(), sibling.getUrl()));
row.setTitle(firstNonBlank(representative.getTitle(), sibling.getTitle()));
row.setError(representative.getError());
row.setStatus(representative.getStatus());
row.setTitleRisk(representative.getTitleRisk());
row.setAppearanceRisk(representative.getAppearanceRisk());
row.setPatentRisk(representative.getPatentRisk());
@@ -1185,7 +1333,7 @@ public class AppearancePatentTaskService {
return null;
}
TaskFileJobEntity job = taskFileJobService.enqueueAssembleResult(
task.getId(), MODULE_TYPE, result.getId(), buildTaskOwnerScopeKey(task.getId()));
task.getId(), MODULE_TYPE, result.getId(), buildTaskOwnerScopeKey(task));
if (dispatchWhenIdle
&& job != null
&& "RUNNING".equals(job.getStatus())
@@ -1220,11 +1368,6 @@ public class AppearancePatentTaskService {
if (job == null || job.getTaskId() == null || job.getResultId() == null) {
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);
if (lockHandle == null) {
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())) {
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());
if (result == null || !MODULE_TYPE.equals(result.getModuleType())) {
throw new BusinessException("result record not found");
@@ -1250,7 +1400,8 @@ public class AppearancePatentTaskService {
.eq(TaskChunkEntity::getModuleType, MODULE_TYPE)
.orderByAsc(TaskChunkEntity::getChunkIndex));
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) {
taskFileJobService.touchRunning(job.getId());
touchJavaSideTaskActivity(task.getId());
@@ -1269,10 +1420,10 @@ public class AppearancePatentTaskService {
if (STATUS_RUNNING.equals(task.getStatus()) && !isResultSubmissionComplete(task.getId())) {
taskFileJobService.touchRunning(job.getId());
touchJavaSideTaskActivity(task.getId());
saveFileBuildProgress(task, job, totalProgressUnits, Math.max(1, cozeWorkUnits), "等待 Python 继续回传数据");
saveFileBuildProgress(task, job, totalProgressUnits, Math.max(1, plannedCozeUnits), "等待 Python 继续回传数据");
return false;
}
completeCozeFileJob(task, result, job, totalProgressUnits, cozeWorkUnits);
completeCozeFileJob(task, result, job, totalProgressUnits);
return true;
}
@@ -1555,10 +1706,10 @@ public class AppearancePatentTaskService {
markCozeStateTerminal(state, COZE_STATUS_FAILED, "Coze batch context missing");
return;
}
if (!isOwnerCurrent(context.ownerInstanceId())) {
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());
return;
if (!isOwnerCurrent(context.ownerInstanceId())) {
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());
return;
}
taskFileJobService.touchRunning(context.jobId());
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) {
return;
}
if (!isOwnerCurrent(context.ownerInstanceId())) {
return;
}
TaskDistributedLockService.LockHandle taskLockHandle = acquireTaskLock(taskId, 0L);
if (taskLockHandle == null) {
return;
@@ -1974,12 +2128,11 @@ public class AppearancePatentTaskService {
private void completeCozeFileJob(FileTaskEntity task,
FileResultEntity result,
TaskFileJobEntity job,
int totalProgressUnits,
int cozeWorkUnits) {
int totalProgressUnits) {
if (countPendingCozeStates(task.getId()) > 0) {
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");
assembleResultWorkbook(task, result);
saveFileBuildProgress(task, job, totalProgressUnits, totalProgressUnits - 1, "正在上传结果文件");
@@ -2216,8 +2369,9 @@ public class AppearancePatentTaskService {
return "coze:task:" + taskId + ":rows:" + DigestUtil.sha256Hex(rowKeys.toString());
}
private String buildTaskOwnerScopeKey(Long taskId) {
return "task:" + taskId + ":owner:" + currentInstanceId();
private String buildTaskOwnerScopeKey(FileTaskEntity task) {
Long taskId = task == null ? null : task.getId();
return "task:" + taskId + ":owner:" + firstNonBlank(ownerFromTask(task), 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.getAppearanceRisk()));
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);
workbook.write(fos);
@@ -2962,7 +3117,7 @@ public class AppearancePatentTaskService {
}
vo.setFileJobId(job.getId());
vo.setFileStatus(job.getStatus());
vo.setFileError(job.getErrorMessage());
vo.setFileError(STATUS_FAILED.equals(job.getStatus()) ? firstNonBlank(job.getErrorMessage(), null) : null);
attachFileProgress(vo, row, job);
}

View File

@@ -34,6 +34,8 @@ public class DebugRequestInfoController {
) {
Map<String, Object> result = new LinkedHashMap<>();
result.put("instanceId", instanceMetadata.getInstanceId());
result.put("instanceSource", instanceMetadata.getSource());
result.put("instanceStable", instanceMetadata.isStable());
result.put("hostname", instanceMetadata.getHostname());
result.put("serverName", request.getServerName());
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;
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.SkipPriceAsinCreateRequest;
import com.nanri.aiimage.modules.shopkey.model.vo.QueryAsinImportProgressVo;
@@ -13,6 +14,9 @@ import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
@@ -24,24 +28,36 @@ import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/admin/skip-price-asins")
@Tag(name = "跳过跟价 ASIN", description = "维护店铺按国家跳过跟价的 ASIN")
@RequestMapping({
"/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 {
private static final DateTimeFormatter EXPORT_FILENAME_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss");
private final SkipPriceAsinService skipPriceAsinService;
@GetMapping
@Operation(summary = "分页查询跳过跟价 ASIN")
@Operation(summary = "Page query skip price asins")
public ApiResponse<SkipPriceAsinPageVo> page(
@Parameter(description = "页码") @RequestParam(defaultValue = "1") Long page,
@Parameter(description = "每页数量") @RequestParam(defaultValue = "15") Long pageSize,
@Parameter(description = "分组ID") @RequestParam(required = false) Long groupId,
@Parameter(description = "店铺名") @RequestParam(required = false) String shopName,
@Parameter(description = "ASIN") @RequestParam(required = false) String asin,
@Parameter(description = "当前操作人用户ID") @RequestParam(required = false) Long operatorId,
@Parameter(description = "是否超级管理员") @RequestParam(defaultValue = "false") Boolean superAdmin) {
@Parameter(description = "page") @RequestParam(name = "page", defaultValue = "1") Long page,
@Parameter(description = "page size") @RequestParam(name = "page_size", defaultValue = "15") Long pageSize,
@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) {
return ApiResponse.success(skipPriceAsinService.page(
page,
pageSize,
@@ -52,59 +68,76 @@ public class SkipPriceAsinController {
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
@Operation(summary = "新增或覆盖跳过跟价 ASIN")
@Operation(summary = "Create or update skip price asin")
public ApiResponse<SkipPriceAsinItemVo> create(
@Parameter(description = "当前操作人用户ID") @RequestParam Long operatorId,
@Parameter(description = "是否超级管理员") @RequestParam(defaultValue = "false") Boolean superAdmin,
@Parameter(description = "operator user id") @RequestParam(name = "operator_id") Long operatorId,
@Parameter(description = "is super admin") @RequestParam(name = "super_admin", defaultValue = "false") Boolean superAdmin,
@Valid @RequestBody SkipPriceAsinCreateRequest request) {
return ApiResponse.success("保存成功",
return ApiResponse.success("\u4fdd\u5b58\u6210\u529f",
skipPriceAsinService.createOrUpdate(request, operatorId, Boolean.TRUE.equals(superAdmin)));
}
@PostMapping("/import")
@Operation(summary = "导入新增跳过跟价 ASIN")
@Operation(summary = "Import skip price asin")
public ApiResponse<QueryAsinImportStartVo> importExcel(
@Parameter(description = "xlsx/xls 文件,文件名必须等于店铺名", required = true) @RequestParam("file") MultipartFile file,
@Parameter(description = "页面选择的分组ID", required = true) @RequestParam Long groupId,
@Parameter(description = "当前操作人用户ID") @RequestParam Long operatorId,
@Parameter(description = "是否超级管理员") @RequestParam(defaultValue = "false") Boolean superAdmin) {
return ApiResponse.success("开始导入",
@Parameter(description = "xlsx or xls file", required = true) @RequestParam("file") MultipartFile file,
@Parameter(description = "group id", required = true) @RequestParam(name = "group_id") Long groupId,
@Parameter(description = "operator user id") @RequestParam(name = "operator_id") Long operatorId,
@Parameter(description = "is super admin") @RequestParam(name = "super_admin", defaultValue = "false") Boolean superAdmin) {
return ApiResponse.success("\u5f00\u59cb\u5bfc\u5165",
skipPriceAsinService.startImport(file, groupId, operatorId, Boolean.TRUE.equals(superAdmin)));
}
@GetMapping("/import/{importId}")
@Operation(summary = "查询导入新增进度")
@Operation(summary = "Query import progress")
public ApiResponse<QueryAsinImportProgressVo> importProgress(@PathVariable String importId) {
return ApiResponse.success(skipPriceAsinService.getImportProgress(importId));
}
@PostMapping("/delete-import")
@Operation(summary = "导入删除跳过跟价 ASIN")
@Operation(summary = "Import delete skip price asin")
public ApiResponse<QueryAsinImportStartVo> deleteImportExcel(
@Parameter(description = "xlsx/xls 文件,文件名必须等于店铺名", required = true) @RequestParam("file") MultipartFile file,
@Parameter(description = "页面选择的分组ID", required = true) @RequestParam Long groupId,
@Parameter(description = "当前操作人用户ID") @RequestParam Long operatorId,
@Parameter(description = "是否超级管理员") @RequestParam(defaultValue = "false") Boolean superAdmin) {
return ApiResponse.success("开始删除",
@Parameter(description = "xlsx or xls file", required = true) @RequestParam("file") MultipartFile file,
@Parameter(description = "group id", required = true) @RequestParam(name = "group_id") Long groupId,
@Parameter(description = "operator user id") @RequestParam(name = "operator_id") Long operatorId,
@Parameter(description = "is super admin") @RequestParam(name = "super_admin", defaultValue = "false") Boolean superAdmin) {
return ApiResponse.success("\u5f00\u59cb\u5220\u9664",
skipPriceAsinService.startDeleteImport(file, groupId, operatorId, Boolean.TRUE.equals(superAdmin)));
}
@GetMapping("/delete-import/{importId}")
@Operation(summary = "查询导入删除进度")
@Operation(summary = "Query delete import progress")
public ApiResponse<QueryAsinImportProgressVo> deleteImportProgress(@PathVariable String importId) {
return ApiResponse.success(skipPriceAsinService.getDeleteImportProgress(importId));
}
@PutMapping("/{id}/countries/{country}")
@Operation(summary = "编辑指定国家的跳过跟价 ASIN")
@PutMapping({"/{id}/countries/{country}", "/{id}/country/{country}"})
@Operation(summary = "Update one country skip price asin")
public ApiResponse<SkipPriceAsinItemVo> updateCountry(
@Parameter(description = "主键ID", required = true) @PathVariable Long id,
@Parameter(description = "国家编码", required = true) @PathVariable String country,
@Parameter(description = "当前操作人用户ID") @RequestParam Long operatorId,
@Parameter(description = "是否超级管理员") @RequestParam(defaultValue = "false") Boolean superAdmin,
@Parameter(description = "id", required = true) @PathVariable Long id,
@Parameter(description = "country code", required = true) @PathVariable String country,
@Parameter(description = "operator user id") @RequestParam(name = "operator_id") Long operatorId,
@Parameter(description = "is super admin") @RequestParam(name = "super_admin", defaultValue = "false") Boolean superAdmin,
@Valid @RequestBody SkipPriceAsinCountryUpdateRequest request) {
return ApiResponse.success("保存成功", skipPriceAsinService.updateCountry(
return ApiResponse.success("\u4fdd\u5b58\u6210\u529f", skipPriceAsinService.updateCountry(
id,
country,
request.getAsin(),
@@ -113,14 +146,14 @@ public class SkipPriceAsinController {
Boolean.TRUE.equals(superAdmin)));
}
@DeleteMapping("/{id}/countries/{country}")
@Operation(summary = "删除指定国家的跳过跟价 ASIN")
@DeleteMapping({"/{id}/countries/{country}", "/{id}/country/{country}"})
@Operation(summary = "Delete one country skip price asin")
public ApiResponse<Void> deleteCountry(
@Parameter(description = "主键ID", required = true) @PathVariable Long id,
@Parameter(description = "国家编码", required = true) @PathVariable String country,
@Parameter(description = "当前操作人用户ID") @RequestParam Long operatorId,
@Parameter(description = "是否超级管理员") @RequestParam(defaultValue = "false") Boolean superAdmin) {
@Parameter(description = "id", required = true) @PathVariable Long id,
@Parameter(description = "country code", required = true) @PathVariable String country,
@Parameter(description = "operator user id") @RequestParam(name = "operator_id") Long operatorId,
@Parameter(description = "is super admin") @RequestParam(name = "super_admin", defaultValue = "false") Boolean 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.Workbook;
import org.apache.poi.ss.usermodel.WorkbookFactory;
import org.apache.poi.xssf.streaming.SXSSFWorkbook;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.nio.file.Files;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
@@ -46,6 +49,7 @@ import java.util.concurrent.ConcurrentHashMap;
public class SkipPriceAsinService {
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 ShopManageMapper shopManageMapper;
@@ -57,32 +61,15 @@ public class SkipPriceAsinService {
Long operatorId, boolean superAdmin) {
long safePage = Math.max(page, 1);
long safePageSize = Math.min(Math.max(pageSize, 1), 100);
String safeShopName = normalizeBlank(shopName);
String safeAsin = normalizeBlank(asin);
Set<Long> accessibleGroupIds = superAdmin ? Set.of() : shopManageGroupService.listAccessibleGroupIds(operatorId, false);
LambdaQueryWrapper<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);
}
}
Long total = skipPriceAsinMapper.selectCount(query);
List<SkipPriceAsinEntity> rows = skipPriceAsinMapper
.selectList(query.last("LIMIT " + ((safePage - 1) * safePageSize) + ", " + safePageSize));
Long total = countFilteredRows(groupId, shopName, asin, operatorId, superAdmin);
List<SkipPriceAsinEntity> rows = listFilteredRows(
groupId,
shopName,
asin,
operatorId,
superAdmin,
(safePage - 1) * safePageSize,
safePageSize);
Map<Long, String> groupNameById = shopManageGroupService.buildGroupNameMap(rows.stream()
.map(SkipPriceAsinEntity::getGroupId)
@@ -98,6 +85,127 @@ public class SkipPriceAsinService {
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
public SkipPriceAsinItemVo createOrUpdate(SkipPriceAsinCreateRequest request, Long operatorId, boolean 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) {
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> 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<List<String>> urlLists = rows.stream().map(SimilarAsinResultRowDto::getUrls).toList();
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);
if (apiKey != null && !apiKey.isBlank()) {
parameters.put("api_key", apiKey.trim());
@@ -302,14 +308,25 @@ public class SimilarAsinCozeClient {
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> countries,
List<String> skus,
List<String> titles,
List<String> urls,
List<List<String>> urlLists) {
List<Map<String, Object>> items = new ArrayList<>(rows.size());
for (int i = 0; i < rows.size(); i++) {
List<Map<String, Object>> items = new ArrayList<>(asins.size());
for (int i = 0; i < asins.size(); i++) {
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("country", countries.get(i));
item.put("sku", skus.get(i));
item.put("title", titles.get(i));
item.put("url", urls.get(i));
item.put("target_urls", urlLists.get(i));
items.add(item);
@@ -334,6 +351,9 @@ public class SimilarAsinCozeClient {
text(firstNonNull(
firstNonNull(node.get("group_key"), node.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(
firstNonNull(node.get("row_id"), firstNonNull(node.get("rowId"), node.get("id"))),
firstNonNull(itemNode.get("row_id"), firstNonNull(itemNode.get("rowId"), itemNode.get("id"))))),
@@ -341,6 +361,9 @@ public class SimilarAsinCozeClient {
text(firstNonNull(
firstNonNull(node.get("country"), node.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(
firstNonNull(node.get("price"), node.get("价格")),
firstNonNull(itemNode.get("price"), itemNode.get("价格")))),
@@ -351,6 +374,15 @@ public class SimilarAsinCozeClient {
text(firstNonNull(
firstNonNull(node.get("similarity"), firstNonNull(node.get("similarity_rate"), node.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"),
firstNonNull(node.get("appearance_risk"),
firstNonNull(itemNode.get("appearance"), itemNode.get("appearance_risk"))))),
@@ -360,6 +392,9 @@ public class SimilarAsinCozeClient {
text(firstNonNull(node.get("result"),
firstNonNull(node.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"),
firstNonNull(node.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) {
Map<String, CozeResult> resultByGroupKey = new LinkedHashMap<>();
Map<String, CozeResult> resultByRowToken = new LinkedHashMap<>();
Map<String, CozeResult> resultByCompositeKey = new LinkedHashMap<>();
Map<String, CozeResult> resultByAsinCountry = new LinkedHashMap<>();
Map<String, CozeResult> resultByAsin = new LinkedHashMap<>();
Map<String, CozeResult> resultByRowId = new LinkedHashMap<>();
for (CozeResult result : results) {
String groupKey = normalize(result.groupKey());
if (!groupKey.isBlank()) {
resultByGroupKey.putIfAbsent(groupKey, result);
String rowToken = normalize(result.rowToken());
if (!rowToken.isBlank()) {
resultByRowToken.putIfAbsent(rowToken, result);
}
String compositeKey = rowKey(result.rowId(), result.asin(), result.country());
if (!compositeKey.isBlank()) {
@@ -409,10 +443,6 @@ public class SimilarAsinCozeClient {
if (!asinCountryKey.isBlank()) {
resultByAsinCountry.putIfAbsent(asinCountryKey, result);
}
String asinKey = normalize(result.asin()).toUpperCase(Locale.ROOT);
if (!asinKey.isBlank()) {
resultByAsin.putIfAbsent(asinKey, result);
}
String rowIdKey = normalize(result.rowId());
if (!rowIdKey.isBlank()) {
resultByRowId.putIfAbsent(rowIdKey, result);
@@ -423,7 +453,7 @@ public class SimilarAsinCozeClient {
boolean allowIndexFallback = results.size() == rows.size() && results.stream().noneMatch(this::hasIdentity);
for (int i = 0; i < rows.size(); i++) {
SimilarAsinResultRowDto row = copy(rows.get(i));
CozeResult result = resultByGroupKey.get(normalize(row.getGroupKey()));
CozeResult result = resultByRowToken.get(normalize(row.getRowToken()));
if (result == null) {
result = resultByCompositeKey.get(rowKey(row.getId(), row.getAsin(), row.getCountry()));
}
@@ -433,9 +463,6 @@ public class SimilarAsinCozeClient {
if (result == null) {
result = resultByAsinCountry.get(asinCountryKey(row.getAsin(), row.getCountry()));
}
if (result == null) {
result = resultByAsin.get(normalize(row.getAsin()).toUpperCase(Locale.ROOT));
}
if (result == null) {
result = resultByRowId.get(normalize(row.getId()));
}
@@ -460,7 +487,7 @@ public class SimilarAsinCozeClient {
if (result == null) {
return false;
}
return !normalize(result.groupKey()).isBlank()
return !normalize(result.rowToken()).isBlank()
|| !normalize(result.rowId()).isBlank()
|| !normalize(result.asin()).isBlank();
}
@@ -469,7 +496,7 @@ public class SimilarAsinCozeClient {
if (result == null || row == null) {
return false;
}
if (!normalize(result.groupKey()).isBlank() || !normalize(result.rowId()).isBlank()) {
if (!normalize(result.rowToken()).isBlank() || !normalize(result.rowId()).isBlank()) {
return false;
}
String resultAsin = normalize(result.asin()).toUpperCase(Locale.ROOT);
@@ -481,12 +508,17 @@ public class SimilarAsinCozeClient {
return;
}
row.setTitleRisk(result.title());
row.setSku(nonBlank(result.sku(), row.getSku()));
row.setPrice(nonBlank(result.price(), row.getPrice()));
row.setIsStock(result.isStock());
row.setSimilarity(result.similarity());
row.setIsConform(result.isConform());
row.setReason(result.reason());
row.setCategory(result.category());
row.setAppearanceRisk(result.appearance());
row.setPatentRisk(result.patent());
row.setConclusion(result.result());
row.setStatus(result.status());
row.setTitleReason(result.titleReason());
row.setAppearanceReason(result.appearanceReason());
row.setPatentReason(result.patentReason());
@@ -508,11 +540,16 @@ public class SimilarAsinCozeClient {
row.setId(source.getId());
row.setAsin(source.getAsin());
row.setCountry(source.getCountry());
row.setSku(source.getSku());
row.setPrice(source.getPrice());
row.setUrls(source.getUrls());
row.setTitle(source.getTitle());
row.setError(source.getError());
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.setAppearanceRisk(source.getAppearanceRisk());
row.setPatentRisk(source.getPatentRisk());
@@ -532,6 +569,12 @@ public class SimilarAsinCozeClient {
if (row.getError() == null || row.getError().isBlank()) {
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()) {
row.setTitleRisk(reviewMessage);
}
@@ -721,6 +764,17 @@ public class SimilarAsinCozeClient {
|| item.has("similarity")
|| item.has("similarity_rate")
|| 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("titleReason")
|| item.has("appearance_reason")
@@ -973,16 +1027,22 @@ public class SimilarAsinCozeClient {
private record CozeResult(
String groupKey,
String rowToken,
String rowId,
String asin,
String country,
String sku,
String price,
String title,
String isStock,
String similarity,
String isConform,
String reason,
String category,
String appearance,
String patent,
String result,
String status,
String titleReason,
String appearanceReason,
String patentReason

View File

@@ -120,7 +120,7 @@ public class SimilarAsinController {
}
@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(
@Parameter(description = "相似ASIN检测任务 ID。任务必须处于 RUNNING 状态。", required = true, example = "3938")
@PathVariable Long taskId,

View File

@@ -39,6 +39,10 @@ public class SimilarAsinResultRowDto {
@Schema(description = "站点或国家。来自 Excel 的国家列,例如英国、德国、法国。", example = "英国")
private String country;
@JsonAlias({"sku", "SKU", "seller_sku", "sellerSku", "msku", "货号"})
@Schema(description = "商品 SKU。来自 Excel 解析或 Python 回传。", example = "SKU-001")
private String sku;
@JsonAlias({"price", "价格"})
@Schema(description = "商品价格。来自 Excel 解析或 Python 回传。", example = "12.29")
private String price;
@@ -61,12 +65,28 @@ public class SimilarAsinResultRowDto {
@Schema(description = "Coze 返回的相似度结果。", example = "80%", accessMode = Schema.AccessMode.READ_ONLY)
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 = "图片地址为空")
private String error;
@Schema(description = "单行完成标记。当前主要使用请求体顶层 done 控制任务收尾,该字段仅作兼容。", example = "true")
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)
private String titleRisk;

View File

@@ -27,6 +27,4 @@ public class SimilarAsinSubmitResultRequest {
@Schema(description = "本次回传的分组结果列表,推荐优先使用")
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")
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")
private String url;

View File

@@ -114,11 +114,15 @@ public class SimilarAsinTaskService {
private static final int PARSE_RESPONSE_PREVIEW_LIMIT = 100;
private static final List<String> RESULT_HEADERS = List.of(
"id",
"sku",
"asin",
"国家",
"价格",
"是否有货",
"相似度"
"相似度",
"是否符合类目",
"不符合理由",
"产品类目",
"status"
);
private final LocalFileStorageService localFileStorageService;
@@ -316,8 +320,8 @@ public class SimilarAsinTaskService {
vo.setDroppedRows(droppedRows);
vo.setGroupCount(groups.size());
vo.setAiPrompt(normalize(request.getAiPrompt()));
vo.setItems(buildResponsePreviewRows(allRows));
vo.setGroups(List.of());
vo.setItems(new ArrayList<>(allRows));
vo.setGroups(groups);
long finishedAt = System.nanoTime();
log.info("[similar-asin] parse timing taskId={} files={} rows={} groups={} totalMs={} parseMs={} groupMs={} taskInsertMs={} payloadJsonMs={} payloadStoreMs={} persistMs={} responseMs={}",
task.getId(),
@@ -482,7 +486,7 @@ public class SimilarAsinTaskService {
completeSubmittedChunk(context);
return null;
});
submitCozeForSubmittedChunk(context);
scheduleCozePipelineForSubmittedChunk(context);
return;
}
FileTaskEntity task = fileTaskMapper.selectById(taskId);
@@ -568,7 +572,7 @@ public class SimilarAsinTaskService {
task.setUpdatedAt(LocalDateTime.now());
fileTaskMapper.updateById(task);
}
submitCozeForSubmittedChunk(context);
scheduleCozePipelineForSubmittedChunk(context);
}
@Transactional
@@ -620,19 +624,11 @@ public class SimilarAsinTaskService {
if (transactionManager != null) {
LocalDateTime threshold = LocalDateTime.now().minusMinutes(Math.max(1, properties.getStaleTimeoutMinutes()));
long thresholdMillis = threshold.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli();
List<FileTaskEntity> tasks = fileTaskMapper.selectList(new LambdaQueryWrapper<FileTaskEntity>()
.eq(FileTaskEntity::getModuleType, MODULE_TYPE)
.eq(FileTaskEntity::getStatus, STATUS_RUNNING)
.lt(FileTaskEntity::getUpdatedAt, threshold)
.last("limit 50"));
List<FileTaskEntity> tasks = listStaleFinalizeCandidates(threshold);
for (FileTaskEntity task : tasks) {
if (!isOwnerCurrent(ownerFromTask(task))) {
continue;
}
if (isJavaSideProcessing(task.getId())) {
touchJavaSideTaskActivity(task.getId());
continue;
}
long heartbeatMillis = taskCacheService.getTaskHeartbeatMillis(task.getId());
if (heartbeatMillis > thresholdMillis) {
continue;
@@ -652,19 +648,11 @@ public class SimilarAsinTaskService {
}
LocalDateTime threshold = LocalDateTime.now().minusMinutes(Math.max(1, properties.getStaleTimeoutMinutes()));
long thresholdMillis = threshold.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli();
List<FileTaskEntity> tasks = fileTaskMapper.selectList(new LambdaQueryWrapper<FileTaskEntity>()
.eq(FileTaskEntity::getModuleType, MODULE_TYPE)
.eq(FileTaskEntity::getStatus, STATUS_RUNNING)
.lt(FileTaskEntity::getUpdatedAt, threshold)
.last("limit 50"));
List<FileTaskEntity> tasks = listStaleFinalizeCandidates(threshold);
for (FileTaskEntity task : tasks) {
if (!isOwnerCurrent(ownerFromTask(task))) {
continue;
}
if (isJavaSideProcessing(task.getId())) {
touchJavaSideTaskActivity(task.getId());
continue;
}
long heartbeatMillis = taskCacheService.getTaskHeartbeatMillis(task.getId());
if (heartbeatMillis > thresholdMillis) {
continue;
@@ -674,11 +662,46 @@ public class SimilarAsinTaskService {
continue;
}
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) {
FileTaskEntity task = fileTaskMapper.selectById(taskId);
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())) {
return;
}
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"));
if (chunk == null || readChunkRows(chunk).isEmpty()) {
List<TaskChunkEntity> chunks;
if (context.scopeHash() == null || context.scopeHash().isBlank() || context.chunkIndex() == null) {
chunks = taskChunkMapper.selectList(new LambdaQueryWrapper<TaskChunkEntity>()
.eq(TaskChunkEntity::getTaskId, task.getId())
.eq(TaskChunkEntity::getModuleType, MODULE_TYPE)
.orderByAsc(TaskChunkEntity::getChunkIndex));
} 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;
}
FileResultEntity result = findOrCreateResultRecordForAssembly(task, allRowCount(task));
@@ -779,24 +815,131 @@ public class SimilarAsinTaskService {
return;
}
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) {
taskFileJobService.touchRunning(job.getId());
touchJavaSideTaskActivity(task.getId());
} else if (isResultSubmissionComplete(task.getId())) {
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) {
FileTaskEntity task = fileTaskMapper.selectById(taskId);
if (task == null || !MODULE_TYPE.equals(task.getModuleType()) || !STATUS_RUNNING.equals(task.getStatus())) {
return;
}
if (tryRecoverTimedOutPythonTask(task)) {
return;
}
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,
String scopeKey,
String scopeHash,
@@ -905,20 +1048,14 @@ public class SimilarAsinTaskService {
if (persistedRows.isEmpty()) {
continue;
}
List<SimilarAsinResultRowDto> unresolvedRows = pickGroupRepresentativesForCoze(persistedRows.values());
List<SimilarAsinResultRowDto> unresolvedRows = collectPendingCozeRows(persistedRows.values());
if (unresolvedRows.isEmpty()) {
continue;
}
List<SimilarAsinResultRowDto> cozeRows = applyCozeInBatches(unresolvedRows, task, progressHook);
Map<String, SimilarAsinResultRowDto> mergedRows = new LinkedHashMap<>();
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()));
mergeChunkPayload(task.getId(), chunk.getScopeHash(), chunk.getChunkIndex(), cozeRows);
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);
}
private List<SimilarAsinResultRowDto> expandRows(List<SimilarAsinResultRowDto> representatives,
Map<String, List<SimilarAsinParsedRowVo>> allRowsByBaseId) {
if (representatives == null || representatives.isEmpty()) {
private List<SimilarAsinResultRowDto> expandRows(List<SimilarAsinResultRowDto> rows,
Map<String, List<SimilarAsinParsedRowVo>> allRowsByBaseId) {
if (rows == null || rows.isEmpty()) {
return List.of();
}
List<SimilarAsinResultRowDto> result = new ArrayList<>();
Set<String> emittedKeys = new LinkedHashSet<>();
for (SimilarAsinResultRowDto representative : representatives) {
String key = firstNonBlank(normalize(representative.getRowToken()), rowKey(representative));
for (SimilarAsinResultRowDto row : rows) {
String key = firstNonBlank(normalize(row.getRowToken()), rowKey(row));
if (emittedKeys.add(key)) {
result.add(representative);
result.add(row);
}
}
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) {
try {
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) {
return List.of();
}
List<SimilarAsinResultRowDto> representatives = new ArrayList<>();
List<SimilarAsinResultRowDto> pendingRows = new ArrayList<>();
for (SimilarAsinResultRowDto row : rows) {
if (row != null && !hasResolvedCozeFields(row) && shouldInspectRow(row)) {
representatives.add(row);
pendingRows.add(row);
}
}
return representatives;
return pendingRows;
}
private List<SimilarAsinParsedGroupVo> buildParsedGroups(List<SimilarAsinParsedRowVo> rows) {
@@ -1065,6 +1180,7 @@ public class SimilarAsinTaskService {
vo.setGroupKey(row.getGroupKey());
vo.setAsin(row.getAsin());
vo.setCountry(row.getCountry());
vo.setSku(row.getSku());
vo.setPrice(row.getPrice());
vo.setUrl(row.getUrl());
vo.setTitle(row.getTitle());
@@ -1113,7 +1229,7 @@ public class SimilarAsinTaskService {
}
return rows;
}
return request.getItems() == null ? List.of() : request.getItems();
return List.of();
}
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) {
try {
return readParsedPayload(task).getAiPrompt();
@@ -1291,15 +1377,17 @@ public class SimilarAsinTaskService {
if (job == null || job.getTaskId() == null || job.getResultId() == null) {
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)) {
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());
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());
if (result == null || !MODULE_TYPE.equals(result.getModuleType())) {
throw new BusinessException("result record not found");
@@ -1317,6 +1405,12 @@ public class SimilarAsinTaskService {
saveFileBuildProgress(task, job, totalProgressUnits, 1, "Coze submitted, waiting for result");
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");
boolean pendingCoze = submitCozeBatches(task, result, job, chunks, allRowsByBaseId);
if (pendingCoze) {
@@ -1325,12 +1419,6 @@ public class SimilarAsinTaskService {
saveFileBuildProgress(task, job, totalProgressUnits, 1, "Coze submitted, waiting for result");
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);
return true;
}
@@ -1384,7 +1472,7 @@ public class SimilarAsinTaskService {
if (persistedRows.isEmpty()) {
continue;
}
List<SimilarAsinResultRowDto> unresolvedRows = pickGroupRepresentativesForCoze(persistedRows.values());
List<SimilarAsinResultRowDto> unresolvedRows = collectPendingCozeRows(persistedRows.values());
if (unresolvedRows.isEmpty()) {
continue;
}
@@ -2232,7 +2320,7 @@ public class SimilarAsinTaskService {
if (persistedRows.isEmpty()) {
continue;
}
int unresolved = pickGroupRepresentativesForCoze(persistedRows.values()).size();
int unresolved = collectPendingCozeRows(persistedRows.values()).size();
if (unresolved > 0) {
total += Math.max(1, (unresolved + batchSize - 1) / batchSize);
}
@@ -2280,7 +2368,7 @@ public class SimilarAsinTaskService {
SimilarAsinParsedPayloadDto parsed = readParsedPayload(task);
Map<String, SimilarAsinResultRowDto> resultMap = loadPersistedResultRowsWithRetry(task.getId(), parsed.getAllItems().size());
long resolvedRows = parsed.getAllItems().stream()
.filter(row -> findResultRow(row, resultMap) != null || findResultRowByAsin(row.getAsin(), resultMap) != null)
.filter(row -> findResultRow(row, resultMap) != null)
.count();
log.info("[similar-asin] assemble workbook taskId={} parsedRows={} resultRows={} resolvedRows={}",
task.getId(), parsed.getAllItems().size(), resultMap.size(), resolvedRows);
@@ -2381,19 +2469,18 @@ public class SimilarAsinTaskService {
int rowIndex = 1;
for (SimilarAsinParsedRowVo parsedRow : parsed.getAllItems()) {
SimilarAsinResultRowDto resultRow = findResultRow(parsedRow, resultMap);
if (resultRow == null) {
resultRow = findResultRowByAsin(parsedRow.getAsin(), resultMap);
}
Row row = sheet.createRow(rowIndex++);
int col = 0;
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.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.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.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) {
if (resultRow == null) {
return firstNonBlank(fallbackUrl, "");
@@ -2446,6 +2513,7 @@ public class SimilarAsinTaskService {
int idCol = findRequiredHeader(headerMap, "id");
int asinCol = findRequiredHeader(headerMap, "asin");
int countryCol = findRequiredHeader(headerMap, "国家", "country");
int skuCol = findOptionalHeaderExact(headerMap, "sku", "seller sku", "seller_sku", "msku", "货号");
int priceCol = findOptionalHeaderExact(headerMap, "价格", "price");
int urlCol = findOptionalHeaderExact(headerMap,
"url", "rul", "link", "image", "img", "pic", "picture",
@@ -2489,6 +2557,7 @@ public class SimilarAsinTaskService {
vo.setRowToken(buildRowToken(source.getFileKey(), vo.getRowIndex()));
vo.setAsin(asin);
vo.setCountry(country);
vo.setSku(skuCol >= 0 ? cell(row, skuCol, formatter) : "");
vo.setPrice(priceCol >= 0 ? cell(row, priceCol, formatter) : "");
vo.setUrl(urlCol >= 0 ? cell(row, urlCol, formatter) : "");
vo.setTitle(titleCol >= 0 ? cell(row, titleCol, formatter) : "");
@@ -2548,7 +2617,11 @@ public class SimilarAsinTaskService {
|| hasUsableCozeField(row.getPatentRisk())
|| hasUsableCozeField(row.getConclusion())
|| 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) {
@@ -2833,9 +2906,9 @@ public class SimilarAsinTaskService {
payload.setApiKey(normalize(apiKey));
payload.setSourceFiles(sourceFiles == null ? List.of() : sourceFiles);
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.setAllItems(List.of());
payload.setAllItems(allRows == null ? List.of() : new ArrayList<>(allRows));
return writeJson(payload, "保存解析结果失败");
}

View File

@@ -89,7 +89,7 @@ public class TaskResultFileJobWorker {
public void process(TaskFileJobEntity 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());
return;
}

View File

@@ -1,6 +1,11 @@
# 这个文件只是给你对照看的“环境变量配置示例”,不是 Spring Boot 自动加载的配置文件。
# 你现在最简单的做法:把下面这些键值复制到 IDEA 的 Run/Debug Configuration -> Environment variables。
# This file is only an environment-variable reference for local IDE startup.
# 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_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:
application:
name: aiimage-backend
config:
import: optional:classpath:application-local.yml
servlet:
multipart:
max-file-size: 200MB
@@ -14,9 +12,9 @@ spring:
time-zone: Asia/Shanghai
datasource:
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}
password: ${AIIMAGE_DB_PASSWORD:change-me}
password: ${AIIMAGE_DB_PASSWORD:WTFrb5y6hNLz6hNy}
hikari:
maximum-pool-size: ${AIIMAGE_DB_POOL_MAX_SIZE:30}
minimum-idle: ${AIIMAGE_DB_POOL_MIN_IDLE:5}
@@ -28,9 +26,10 @@ spring:
leak-detection-threshold: ${AIIMAGE_DB_POOL_LEAK_DETECT_MS:0}
data:
redis:
host: ${AIIMAGE_REDIS_HOST:127.0.0.1}
port: ${AIIMAGE_REDIS_PORT:6379}
password: ${AIIMAGE_REDIS_PASSWORD:}
username: ${AIIMAGE_REDIS_USERNAME:}
host: ${AIIMAGE_REDIS_HOST:47.111.163.154}
port: ${AIIMAGE_REDIS_PORT:16379}
password: ${AIIMAGE_REDIS_PASSWORD:B6COTcY094TYe545}
database: ${AIIMAGE_REDIS_DATABASE:0}
timeout: ${AIIMAGE_REDIS_TIMEOUT:5s}
@@ -72,19 +71,22 @@ knife4j:
language: zh_cn
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:
region: ${AIIMAGE_OSS_REGION:cn-hangzhou}
endpoint: ${AIIMAGE_OSS_ENDPOINT:oss-cn-hangzhou.aliyuncs.com}
bucket: ${AIIMAGE_OSS_BUCKET:change-me}
access-key-id: ${AIIMAGE_OSS_ACCESS_KEY_ID:change-me}
access-key-secret: ${AIIMAGE_OSS_ACCESS_KEY_SECRET:change-me}
bucket: ${AIIMAGE_OSS_BUCKET:nanri-ai-images}
access-key-id: ${AIIMAGE_OSS_ACCESS_KEY_ID:LTAI5tNpyvzMNz9f2dHarsm8}
access-key-secret: ${AIIMAGE_OSS_ACCESS_KEY_SECRET:bQSZnFH455i8tzyOgeahJmUzwmhynz}
transient-storage:
enabled: ${AIIMAGE_TRANSIENT_STORAGE_ENABLED:false}
endpoint: ${AIIMAGE_TRANSIENT_STORAGE_ENDPOINT:}
bucket: ${AIIMAGE_TRANSIENT_STORAGE_BUCKET:}
access-key-id: ${AIIMAGE_TRANSIENT_STORAGE_ACCESS_KEY_ID:}
access-key-secret: ${AIIMAGE_TRANSIENT_STORAGE_ACCESS_KEY_SECRET:}
enabled: ${AIIMAGE_TRANSIENT_STORAGE_ENABLED:true}
endpoint: ${AIIMAGE_TRANSIENT_STORAGE_ENDPOINT:http://121.196.149.225:9000}
bucket: ${AIIMAGE_TRANSIENT_STORAGE_BUCKET:json-server}
access-key-id: ${AIIMAGE_TRANSIENT_STORAGE_ACCESS_KEY_ID:YPkyFAymauf21pHMoK0V}
access-key-secret: ${AIIMAGE_TRANSIENT_STORAGE_ACCESS_KEY_SECRET:XcPzJqT8jzAHDEQNCovhkPmlwWcphBwLdA36BTzL}
region: ${AIIMAGE_TRANSIENT_STORAGE_REGION:us-east-1}
connect-timeout-seconds: ${AIIMAGE_TRANSIENT_STORAGE_CONNECT_TIMEOUT_SECONDS:10}
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-cleanup-cron: ${AIIMAGE_TASK_SCOPE_PAYLOAD_CLEANUP_CRON:15 */30 * * * *}
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}
consumer-group: ${AIIMAGE_RESULT_FILE_JOB_CONSUMER_GROUP:aiimage-result-file-job-consumer}
local-dispatch-enabled: ${AIIMAGE_RESULT_FILE_JOB_LOCAL_DISPATCH_ENABLED:true}

View File

@@ -8,7 +8,7 @@ import threading
import requests
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
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):
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 {
'id': item.get('id'),
'name': item.get('name') or '',
'column_key': item.get('columnKey') or '',
'menu_type': item.get('menuType') or 'app',
'route_path': item.get('routePath') or '',
'sort_order': item.get('sortOrder') if item.get('sortOrder') is not None else 0,
'created_at': (item.get('createdAt') or '').replace('T', ' ')[:16],
'column_key': item.get('columnKey') or item.get('column_key') or '',
'menu_type': item.get('menuType') or item.get('menu_type') or 'app',
'route_path': item.get('routePath') or item.get('route_path') or '',
'sort_order': sort_order if sort_order is not None else 0,
'created_at': created_at_text,
}
@@ -792,11 +802,9 @@ def create_user():
(username, pwd_hash, is_admin, want_role, want_created_by),
)
new_uid = cur.lastrowid
_set_user_column_permissions(cur, new_uid, column_ids)
conn.commit()
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': '用户创建成功'})
except pymysql.IntegrityError:
return jsonify({'success': False, 'error': '用户名已存在'})
@@ -807,7 +815,7 @@ def create_user():
@admin_api.route('/user/<int:uid>', methods=['PUT'])
@admin_required
def update_user(uid):
"""更新用户(密码、角色)"""
"""更新用户(密码、角色、栏目权限"""
data = request.get_json() or {}
_, _, denied = _ensure_admin_menu_access('users')
if denied:
@@ -817,7 +825,7 @@ def update_user(uid):
role, current_row = get_current_admin_role()
if not role:
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': '请提供要修改的内容'})
try:
conn = get_db()
@@ -852,13 +860,10 @@ def update_user(uid):
"UPDATE users SET is_admin = %s, role = %s WHERE id = %s",
(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.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': '更新成功'})
except Exception as e:
return jsonify({'success': False, 'error': str(e)})
@@ -1139,15 +1144,7 @@ def get_user_columns(uid):
if denied:
return denied
menu_type = (request.args.get('menu_type') or '').strip()
result, error_response, status = _proxy_backend_java(
'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 []})
return jsonify({'success': True, 'column_ids': _get_local_user_column_ids(uid, menu_type or None)})
@admin_api.route('/user/<int:uid>/column-permissions')
@@ -1157,14 +1154,8 @@ def get_user_column_permissions(uid):
if denied:
return denied
menu_type = (request.args.get('menu_type') or '').strip()
result, error_response, status = _proxy_backend_java(
'GET',
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 [])]})
items = _get_local_user_column_permission_items(uid, menu_type or None)
return jsonify({'success': True, 'items': [_format_permission_item(item) for item in items]})
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()
shop_name = (request.args.get('shop_name') or '').strip()
asin = (request.args.get('asin') or '').strip()
params = {'page': page, 'pageSize': page_size}
if current_row and current_row.get('id'):
params['operatorId'] = current_row.get('id')
params['superAdmin'] = 'true' if role == 'super_admin' else 'false'
params = {'page': page, 'page_size': page_size, 'pageSize': page_size}
params.update(_skip_price_asin_auth_params(role, current_row))
if group_id_raw:
try:
group_id = int(group_id_raw)
if group_id > 0:
params['group_id'] = group_id
params['groupId'] = group_id
except (TypeError, ValueError):
pass
if shop_name:
params['shop_name'] = shop_name
params['shopName'] = shop_name
if 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'])
@login_required
def create_skip_price_asin():
@@ -2092,10 +2141,7 @@ def create_skip_price_asin():
result, error_response, status = _proxy_backend_java(
'POST',
'/api/admin/skip-price-asins',
params={
'operatorId': current_row.get('id') if current_row else None,
'superAdmin': 'true' if role == 'super_admin' else 'false',
},
params=_skip_price_asin_auth_params(role, current_row),
json_data=payload,
)
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(
'DELETE',
f'/api/admin/skip-price-asins/{item_id}/countries/{country}',
params={
'operatorId': current_row.get('id') if current_row else None,
'superAdmin': 'true' if role == 'super_admin' else 'false',
},
params=_skip_price_asin_auth_params(role, current_row),
)
if error_response is not None:
return error_response, status
@@ -2177,10 +2220,7 @@ def update_skip_price_asin_country(item_id, country):
result, error_response, status = _proxy_backend_java(
'PUT',
f'/api/admin/skip-price-asins/{item_id}/countries/{country}',
params={
'operatorId': current_row.get('id') if current_row else None,
'superAdmin': 'true' if role == 'super_admin' else 'false',
},
params=_skip_price_asin_auth_params(role, current_row),
json_data=payload,
)
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()
if not group_id_raw:
return jsonify({'success': False, 'error': '请先选择分组'})
params = {
'groupId': group_id_raw,
'operatorId': current_row.get('id') if current_row else None,
'superAdmin': 'true' if role == 'super_admin' else 'false',
}
params = _skip_price_asin_auth_params(role, current_row)
params['group_id'] = group_id_raw
params['groupId'] = group_id_raw
files = {
'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()
if not group_id_raw:
return jsonify({'success': False, 'error': '请先选择分组'})
params = {
'groupId': group_id_raw,
'operatorId': current_row.get('id') if current_row else None,
'superAdmin': 'true' if role == 'super_admin' else 'false',
}
params = _skip_price_asin_auth_params(role, current_row)
params['group_id'] = group_id_raw
params['groupId'] = group_id_raw
files = {
'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 '保存成功',
'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
};
}
function buildSkipPriceAsinQuery(page) {
var query = 'page=' + (page || 1) + '&page_size=' + skipPriceAsinPageSize;
function buildSkipPriceAsinFilterQuery() {
var query = '';
var groupId = (document.getElementById('skipPriceAsinFilterGroupId').value || '').trim();
var shopName = (document.getElementById('skipPriceAsinFilterShopName').value || '').trim();
var asin = (document.getElementById('skipPriceAsinFilterAsin').value || '').trim();
if (groupId) query += '&group_id=' + encodeURIComponent(groupId);
if (shopName) query += '&shop_name=' + encodeURIComponent(shopName);
if (asin) query += '&asin=' + encodeURIComponent(asin);
if (groupId) query += (query ? '&' : '') + 'group_id=' + encodeURIComponent(groupId);
if (shopName) query += (query ? '&' : '') + 'shop_name=' + encodeURIComponent(shopName);
if (asin) query += (query ? '&' : '') + 'asin=' + encodeURIComponent(asin);
return query;
}
function 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) {
var asinValue = item[country.field] || '';
var minimumPriceValue = formatSkipPriceMinimumPrice(item[country.minimumPriceField]);
@@ -1917,7 +1983,8 @@
var shopName = (btn.dataset.shopName || '').replace(/&quot;/g, '"');
var countryCode = btn.dataset.country || '';
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'
})
.then(function (r) { return r.json(); })
@@ -2184,6 +2251,8 @@
var formData = new FormData();
formData.append('file', file);
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();
xhr.open('POST', deleteMode ? '/api/admin/skip-price-asins/delete-import' : '/api/admin/skip-price-asins/import', true);
xhr.upload.onprogress = function (event) {
@@ -2267,7 +2336,8 @@
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',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@@ -2294,6 +2364,9 @@
document.getElementById('btnSearchSkipPriceAsin').onclick = function () {
loadSkipPriceAsin(1);
};
document.getElementById('btnExportSkipPriceAsin').onclick = function () {
exportSkipPriceAsin();
};
document.getElementById('skipPriceAsinFilterShopName').addEventListener('keydown', function (e) {
if (e.key === 'Enter') {
e.preventDefault();
@@ -2334,7 +2407,8 @@
fallbackAsin = asinMappings[countryCode] || '';
return !!fallbackAsin;
});
fetch('/api/admin/skip-price-asin', {
var operatorQuery = buildSkipPriceAsinOperatorQuery();
fetch('/api/admin/skip-price-asin' + (operatorQuery ? ('?' + operatorQuery) : ''), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@@ -3429,8 +3503,10 @@
}
// 初始化
var adminCurrentUserPromise = null;
function loadAdminCurrentUser() {
fetch('/api/admin/current-user')
if (adminCurrentUserPromise) return adminCurrentUserPromise;
adminCurrentUserPromise = fetch('/api/admin/current-user')
.then(function (r) {
if (r.status === 404) {
return fetch('/api/auth/check')
@@ -3474,7 +3550,7 @@
updateShopManageGroupButtonsAccess();
})
.catch(function () {
fetch('/api/auth/check')
return fetch('/api/auth/check')
.then(function (r) { return r.json(); })
.then(function (res) {
document.getElementById('adminCurrentUsername').textContent = res && res.logged_in ? '当前用户' : '未登录';
@@ -3486,7 +3562,13 @@
document.getElementById('adminCurrentUserRole').style.display = 'none';
updateShopManageGroupButtonsAccess();
});
})
.finally(function () {
if (!currentUserId && currentUserRole !== 'super_admin') {
adminCurrentUserPromise = null;
}
});
return adminCurrentUserPromise;
}
document.getElementById('btnAdminLogout').onclick = function () {
@@ -3514,10 +3596,11 @@
});
};
loadAdminCurrentUser();
loadUserOptions();
loadColumnsForPermission();
loadShopManageGroups();
loadAdminMenus();
loadAdminCurrentUser().finally(function () {
loadUserOptions();
loadColumnsForPermission();
loadShopManageGroups();
loadAdminMenus();
});
}) ();

View File

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

View File

@@ -84,9 +84,6 @@
<div class="file-progress-track">
<div class="file-progress-bar" :style="{ width: `${fileProgressPercent(item)}%` }"></div>
</div>
<div class="file-progress-count">
{{ item.fileProgressCurrent || 0 }}/{{ item.fileProgressTotal || 0 }}
</div>
</div>
</div>
<div class="task-right">
@@ -119,9 +116,6 @@
<div class="file-progress-track">
<div class="file-progress-bar" :style="{ width: `${fileProgressPercent(item)}%` }"></div>
</div>
<div class="file-progress-count">
{{ item.fileProgressCurrent || 0 }}/{{ item.fileProgressTotal || 0 }}
</div>
</div>
<div v-if="item.error" class="files">错误{{ item.error }}</div>
</div>
@@ -702,8 +696,13 @@ function canDownload(item: AppearancePatentHistoryItem) {
function fileProgressPercent(item: AppearancePatentHistoryItem) {
const percent = Number(item.fileProgressPercent || 0)
if (!Number.isFinite(percent)) return 0
return Math.max(0, Math.min(100, Math.round(percent)))
if (Number.isFinite(percent) && percent > 0) {
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) {
@@ -736,7 +735,30 @@ function translateFileProgressMessage(message: string) {
function showFileProgress(item: AppearancePatentHistoryItem) {
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) {
@@ -842,7 +864,6 @@ onUnmounted(() => {
.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-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); }
.btn-delete { padding: 6px 10px; color: #ff8f8f; background: rgba(231, 76, 60, .12); }
@media (max-width: 1100px) {

View File

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

View File

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