提交打包

This commit is contained in:
super
2026-05-07 16:21:35 +08:00
parent a7d8f8be6c
commit e5d0b2d9ab
64 changed files with 1368 additions and 875 deletions

View File

@@ -12,8 +12,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

@@ -224,6 +224,8 @@ def proxy(path):
print("target_url", target_url)
print("params", params)
print("data", request.get_data())
print("method", request.method)
print("timeout", proxy_timeout)
print("=============================")
except Exception as e:
print("打印失败", e)
@@ -258,11 +260,11 @@ def proxy(path):
return response
except requests.exceptions.Timeout:
print("后端服务超时")
print(f"后端服务超时target_url={target_url}, timeout={proxy_timeout}")
return Response("后端服务超时", status=504)
except requests.exceptions.ConnectionError:
print("无法连接到后端服务")
except requests.exceptions.ConnectionError as e:
print(f"无法连接到后端服务target_url={target_url}, error={e}")
return Response("无法连接到后端服务", status=502)
except Exception as e:
print(f"代理请求失败: {e}")
print(f"代理请求失败target_url={target_url}, error={e}")
return Response(f"代理错误: {str(e)}", status=500)

View File

@@ -6,9 +6,12 @@ STITCH_WORKFLOW_ID = "7608813873483300907"
import os
from dotenv import load_dotenv
from queue import Queue
load_dotenv()
_base_url = os.getenv("base_url","http://159.75.121.33:15124")
base_dir = os.path.dirname(os.path.abspath(__file__))
dotenv_path = os.path.join(base_dir, ".env")
load_dotenv(dotenv_path=dotenv_path, override=True)
_base_url = os.getenv("base_url","http://159.75.121.33:15124")
# MySQL 配置
mysql_host = os.getenv("mysql_host")

View File

@@ -3,6 +3,8 @@ package com.nanri.aiimage.modules.appearancepatent.controller;
import com.nanri.aiimage.common.api.ApiResponse;
import com.nanri.aiimage.common.util.DownloadHeaderUtil;
import com.nanri.aiimage.modules.appearancepatent.model.dto.AppearancePatentParseRequest;
import com.nanri.aiimage.modules.appearancepatent.model.dto.AppearancePatentParsedGroupPageDto;
import com.nanri.aiimage.modules.appearancepatent.model.dto.AppearancePatentParsedPayloadDto;
import com.nanri.aiimage.modules.appearancepatent.model.dto.AppearancePatentSubmitResultRequest;
import com.nanri.aiimage.modules.appearancepatent.model.dto.AppearancePatentTaskBatchRequest;
import com.nanri.aiimage.modules.appearancepatent.model.vo.AppearancePatentDashboardVo;
@@ -44,6 +46,34 @@ public class AppearancePatentController {
return ApiResponse.success(service.parseAndCreateTask(request));
}
@GetMapping("/tasks/{taskId}/parsed-payload")
@Operation(summary = "获取外观专利完整解析载荷", description = "供 Python 队列消费端使用。parse 接口仅返回轻量结果,完整行数据通过本接口按 taskId 拉取。")
public ApiResponse<AppearancePatentParsedPayloadDto> parsedPayload(
@Parameter(description = "外观专利任务 ID", required = true, example = "7004")
@PathVariable Long taskId,
@Parameter(description = "当前用户 ID", required = true, example = "1")
@RequestParam("user_id") Long userId) {
return ApiResponse.success(service.parsedPayload(taskId, userId));
}
@GetMapping("/tasks/{taskId}/queue-payload")
@Operation(summary = "获取外观专利 Python 队列载荷", description = "只返回 Python 当前需要的 groups避免前端搬运完整解析大数据。")
public ApiResponse<AppearancePatentParsedPayloadDto> queuePayload(
@PathVariable Long taskId,
@RequestParam("user_id") Long userId) {
return ApiResponse.success(service.queuePayload(taskId, userId));
}
@GetMapping("/tasks/{taskId}/parsed-groups")
@Operation(summary = "分页获取外观专利解析分组", description = "供后续优化使用,按页返回解析分组。")
public ApiResponse<AppearancePatentParsedGroupPageDto> parsedGroups(
@PathVariable Long taskId,
@RequestParam("user_id") Long userId,
@RequestParam(value = "page", required = false, defaultValue = "1") Integer page,
@RequestParam(value = "page_size", required = false, defaultValue = "50") Integer pageSize) {
return ApiResponse.success(service.parsedGroupPage(taskId, userId, page, pageSize));
}
@GetMapping("/dashboard")
@Operation(summary = "查询外观专利检测总览", description = "查询当前用户的运行中、成功、失败和已结束任务数量。页面进入时请求一次即可,不需要持续轮询。")
public ApiResponse<AppearancePatentDashboardVo> dashboard(

View File

@@ -0,0 +1,23 @@
package com.nanri.aiimage.modules.appearancepatent.model.dto;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
@Data
public class AppearancePatentParsedGroupManifestDto {
private String aiPrompt;
private String apiKey;
private Integer pageSize;
private Integer totalGroups;
private Integer totalRows;
private List<PageRef> pages = new ArrayList<>();
@Data
public static class PageRef {
private Integer page;
private Integer groupCount;
private String ref;
}
}

View File

@@ -0,0 +1,19 @@
package com.nanri.aiimage.modules.appearancepatent.model.dto;
import com.nanri.aiimage.modules.appearancepatent.model.vo.AppearancePatentParsedGroupVo;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
@Data
public class AppearancePatentParsedGroupPageDto {
private String aiPrompt;
private String apiKey;
private Integer page;
private Integer pageSize;
private Integer totalGroups;
private Integer totalRows;
private Boolean hasNext;
private List<AppearancePatentParsedGroupVo> groups = new ArrayList<>();
}

View File

@@ -3,9 +3,6 @@ package com.nanri.aiimage.modules.appearancepatent.model.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.util.LinkedHashMap;
import java.util.Map;
@Data
@Schema(description = "外观专利检测解析出的单行数据")
public class AppearancePatentParsedRowVo {
@@ -36,12 +33,12 @@ public class AppearancePatentParsedRowVo {
@Schema(description = "国家或站点。", example = "英国")
private String country;
@Schema(description = "价格。", example = "12.99")
private String price;
@Schema(description = "商品图片 URL 或商品 URL供 Coze 检测使用。", example = "https://webstatic.aiproxy.vip/output/demo.jpg")
private String url;
@Schema(description = "商品标题。", example = "Women Floral Dress Summer Casual")
private String title;
@Schema(description = "该 Excel 行的原始列值映射。最终生成 xlsx 时可以从这里读取价格等字段。", example = "{\"id\":\"2_1\",\"asin\":\"B0CJ8SNXXV\",\"国家\":\"英国\",\"价格\":\"12.99\"}")
private Map<String, String> values = new LinkedHashMap<>();
}

View File

@@ -13,6 +13,7 @@ import com.nanri.aiimage.config.InstanceMetadata;
import com.nanri.aiimage.config.StorageProperties;
import com.nanri.aiimage.modules.appearancepatent.client.AppearancePatentCozeClient;
import com.nanri.aiimage.modules.appearancepatent.model.dto.AppearancePatentParseRequest;
import com.nanri.aiimage.modules.appearancepatent.model.dto.AppearancePatentParsedGroupPageDto;
import com.nanri.aiimage.modules.appearancepatent.model.dto.AppearancePatentParsedPayloadDto;
import com.nanri.aiimage.modules.appearancepatent.model.dto.AppearancePatentResultGroupDto;
import com.nanri.aiimage.modules.appearancepatent.model.dto.AppearancePatentResultRowDto;
@@ -175,7 +176,7 @@ public class AppearancePatentTaskService {
throw new BusinessException("未解析到有效 ASIN 数据");
}
long parsedAt = System.nanoTime();
List<AppearancePatentParsedGroupVo> groups = buildParsedGroups(allRows);
int groupCount = countParsedGroups(allRows);
long groupedAt = System.nanoTime();
FileTaskEntity task = new FileTaskEntity();
@@ -201,7 +202,7 @@ public class AppearancePatentTaskService {
String aggregateScopeKey = buildAggregateScopeKey(sourceFiles);
String sourceScopeHash = DigestUtil.sha256Hex(aggregateScopeKey);
String parsedPayload = buildParsedPayloadJson(request.getAiPrompt(), request.getApiKey(), sourceFiles, mergedHeaders, groups, allRows);
String parsedPayload = buildParsedPayloadJson(request.getAiPrompt(), request.getApiKey(), sourceFiles, mergedHeaders, allRows);
long payloadBuiltAt = System.nanoTime();
String parsedPayloadPointer = storeParsedPayload(task.getId(), sourceScopeHash, parsedPayload);
long payloadStoredAt = System.nanoTime();
@@ -242,16 +243,16 @@ public class AppearancePatentTaskService {
vo.setTotalRows(totalRows);
vo.setAcceptedRows(allRows.size());
vo.setDroppedRows(droppedRows);
vo.setGroupCount(groups.size());
vo.setGroupCount(groupCount);
vo.setAiPrompt(normalize(request.getAiPrompt()));
vo.setItems(allRows);
vo.setGroups(groups);
vo.setItems(List.of());
vo.setGroups(List.of());
long finishedAt = System.nanoTime();
log.info("[appearance-patent] parse timing taskId={} files={} rows={} groups={} totalMs={} parseMs={} groupMs={} taskInsertMs={} payloadJsonMs={} payloadStoreMs={} persistMs={} responseMs={}",
task.getId(),
sourceFiles.size(),
allRows.size(),
groups.size(),
groupCount,
elapsedMs(startedAt, finishedAt),
elapsedMs(parseStartedAt, parsedAt),
elapsedMs(parsedAt, groupedAt),
@@ -322,15 +323,7 @@ public class AppearancePatentTaskService {
.map(FileResultEntity::getId)
.filter(Objects::nonNull)
.toList());
List<FileResultEntity> sortedRows = new ArrayList<>();
for (FileResultEntity row : rows) {
FileTaskEntity task = taskMap.get(row.getTaskId());
String taskStatus = task == null ? null : task.getStatus();
if (STATUS_PENDING.equals(taskStatus)) {
continue;
}
sortedRows.add(row);
}
List<FileResultEntity> sortedRows = new ArrayList<>(rows);
sortedRows.sort(Comparator
.comparingInt((FileResultEntity row) -> historyPriority(row, taskMap.get(row.getTaskId()), jobMap.get(row.getId())))
.thenComparing((FileResultEntity row) -> historyActivityTime(row, taskMap.get(row.getTaskId()), jobMap.get(row.getId())),
@@ -968,6 +961,10 @@ public class AppearancePatentTaskService {
return groups;
}
private int countParsedGroups(List<AppearancePatentParsedRowVo> rows) {
return groupRowsByBaseId(rows).size();
}
private Map<String, List<AppearancePatentParsedRowVo>> groupRowsByBaseId(List<AppearancePatentParsedRowVo> rows) {
Map<String, List<AppearancePatentParsedRowVo>> result = new LinkedHashMap<>();
if (rows == null) {
@@ -1997,7 +1994,7 @@ public class AppearancePatentTaskService {
row.createCell(col++).setCellValue(firstNonBlank(parsedRow.getDisplayId(), parsedRow.getSourceId()));
row.createCell(col++).setCellValue(firstNonBlank(parsedRow.getAsin(), ""));
row.createCell(col++).setCellValue(firstNonBlank(parsedRow.getCountry(), ""));
row.createCell(col++).setCellValue(readValueByHeader(parsedRow, "价格", "price"));
row.createCell(col++).setCellValue(firstNonBlank(parsedRow.getPrice(), ""));
row.createCell(col++).setCellValue(resultRow == null ? firstNonBlank(parsedRow.getTitle(), "") : firstNonBlank(resultRow.getTitle(), parsedRow.getTitle()));
row.createCell(col++).setCellValue(resultRow == null ? firstNonBlank(parsedRow.getUrl(), "") : firstNonBlank(resultRow.getUrl(), parsedRow.getUrl()));
row.createCell(col++).setCellValue(resultRow == null ? missingReason : userFacingCozeCellValue(resultRow, resultRow.getTitleRisk()));
@@ -2081,10 +2078,10 @@ public class AppearancePatentTaskService {
throw new BusinessException("Excel 表头为空");
}
Map<String, Integer> headerMap = buildHeaderMap(header, formatter);
List<String> headers = readHeaders(header, formatter);
int idCol = findRequiredHeader(headerMap, "id");
int asinCol = findRequiredHeader(headerMap, "asin");
int countryCol = findRequiredHeader(headerMap, "国家", "country");
int priceCol = findOptionalHeaderExact(headerMap, "价格", "price");
int urlCol = findOptionalHeaderExact(headerMap,
"url", "rul", "link", "image", "img", "pic", "picture",
"链接", "商品链接", "图片", "商品图片", "主图", "商品主图", "图片链接", "主图链接");
@@ -2127,16 +2124,16 @@ public class AppearancePatentTaskService {
vo.setRowToken(buildRowToken(source.getFileKey(), vo.getRowIndex()));
vo.setAsin(asin);
vo.setCountry(country);
vo.setPrice(priceCol >= 0 ? cell(row, priceCol, formatter) : "");
vo.setUrl(urlCol >= 0 ? cell(row, urlCol, formatter) : "");
vo.setTitle(titleCol >= 0 ? cell(row, titleCol, formatter) : "");
vo.setValues(readRowValues(row, headers, formatter));
allRows.add(vo);
}
hydratePromptFields(allRows);
if (allRows.isEmpty()) {
throw new BusinessException("no valid appearance patent rows");
}
return new ParsedWorkbook(total, dropped, headers, allRows);
return new ParsedWorkbook(total, dropped, List.of(), allRows);
} catch (BusinessException ex) {
throw ex;
} catch (Exception ex) {
@@ -2202,23 +2199,6 @@ public class AppearancePatentTaskService {
return map;
}
private List<String> readHeaders(Row header, DataFormatter formatter) {
List<String> headers = new ArrayList<>();
for (int i = 0; i < header.getLastCellNum(); i++) {
String val = normalize(formatter.formatCellValue(header.getCell(i)));
headers.add(val.isBlank() ? "" + (i + 1) : val);
}
return headers;
}
private Map<String, String> readRowValues(Row row, List<String> headers, DataFormatter formatter) {
Map<String, String> values = new LinkedHashMap<>();
for (int i = 0; i < headers.size(); i++) {
values.put(headers.get(i), cell(row, i, formatter));
}
return values;
}
private int findRequiredHeader(Map<String, Integer> map, String... names) {
int idx = findOptionalHeader(map, names);
if (idx < 0) {
@@ -2368,7 +2348,7 @@ public class AppearancePatentTaskService {
private int historyPriority(FileResultEntity row, FileTaskEntity task, TaskFileJobEntity job) {
String taskStatus = task == null ? null : task.getStatus();
if (STATUS_RUNNING.equals(taskStatus) || isHistoryFileBuilding(row, taskStatus, job)) {
if (STATUS_PENDING.equals(taskStatus) || STATUS_RUNNING.equals(taskStatus) || isHistoryFileBuilding(row, taskStatus, job)) {
return 0;
}
return 1;
@@ -2476,15 +2456,15 @@ public class AppearancePatentTaskService {
return val == null ? "" : val.replace(String.valueOf((char) 0xFEFF), "").replace((char) 0x3000, ' ').trim().replaceAll("\\s+", " ");
}
private String buildParsedPayloadJson(String aiPrompt, String apiKey, List<AppearancePatentSourceFileDto> sourceFiles, List<String> headers, List<AppearancePatentParsedGroupVo> groups, List<AppearancePatentParsedRowVo> allRows) {
private String buildParsedPayloadJson(String aiPrompt, String apiKey, List<AppearancePatentSourceFileDto> sourceFiles, List<String> headers, List<AppearancePatentParsedRowVo> allRows) {
AppearancePatentParsedPayloadDto payload = new AppearancePatentParsedPayloadDto();
payload.setAiPrompt(normalize(aiPrompt));
payload.setApiKey(normalize(apiKey));
payload.setSourceFiles(sourceFiles == null ? List.of() : sourceFiles);
payload.setHeaders(headers == null ? List.of() : headers);
payload.setItems(List.of());
payload.setGroups(groups == null ? List.of() : groups);
payload.setAllItems(List.of());
payload.setGroups(List.of());
payload.setAllItems(allRows == null ? List.of() : allRows);
return writeJson(payload, "保存解析结果失败");
}
@@ -2552,6 +2532,57 @@ public class AppearancePatentTaskService {
return hydrateParsedPayloadRows(new AppearancePatentParsedPayloadDto());
}
public AppearancePatentParsedPayloadDto parsedPayload(Long taskId, Long userId) {
FileTaskEntity task = fileTaskMapper.selectById(taskId);
if (task == null || !MODULE_TYPE.equals(task.getModuleType()) || userId != null && !Objects.equals(userId, task.getUserId())) {
throw new BusinessException("任务不存在");
}
return readParsedPayload(task);
}
public AppearancePatentParsedPayloadDto queuePayload(Long taskId, Long userId) {
FileTaskEntity task = fileTaskMapper.selectById(taskId);
if (task == null || !MODULE_TYPE.equals(task.getModuleType()) || userId != null && !Objects.equals(userId, task.getUserId())) {
throw new BusinessException("任务不存在");
}
AppearancePatentParsedPayloadDto payload = readParsedPayload(task);
AppearancePatentParsedPayloadDto queuePayload = new AppearancePatentParsedPayloadDto();
queuePayload.setAiPrompt(payload.getAiPrompt());
queuePayload.setApiKey(payload.getApiKey());
queuePayload.setGroups(payload.getGroups() == null ? List.of() : payload.getGroups());
queuePayload.setItems(List.of());
queuePayload.setAllItems(List.of());
queuePayload.setHeaders(List.of());
queuePayload.setSourceFiles(List.of());
return queuePayload;
}
public AppearancePatentParsedGroupPageDto parsedGroupPage(Long taskId, Long userId, Integer page, Integer pageSize) {
FileTaskEntity task = fileTaskMapper.selectById(taskId);
if (task == null || !MODULE_TYPE.equals(task.getModuleType()) || userId != null && !Objects.equals(userId, task.getUserId())) {
throw new BusinessException("任务不存在");
}
int safePage = Math.max(1, page == null ? 1 : page);
int safePageSize = Math.max(1, Math.min(pageSize == null ? 50 : pageSize, 200));
AppearancePatentParsedPayloadDto payload = readParsedPayload(task);
List<AppearancePatentParsedGroupVo> groups = payload.getGroups() == null ? List.of() : payload.getGroups();
int totalGroups = groups.size();
int totalRows = payload.getAllItems() == null ? 0 : payload.getAllItems().size();
int fromIndex = Math.min((safePage - 1) * safePageSize, totalGroups);
int toIndex = Math.min(fromIndex + safePageSize, totalGroups);
AppearancePatentParsedGroupPageDto vo = new AppearancePatentParsedGroupPageDto();
vo.setAiPrompt(payload.getAiPrompt());
vo.setApiKey(payload.getApiKey());
vo.setPage(safePage);
vo.setPageSize(safePageSize);
vo.setTotalGroups(totalGroups);
vo.setTotalRows(totalRows);
vo.setHasNext(toIndex < totalGroups);
vo.setGroups(fromIndex >= toIndex ? List.of() : groups.subList(fromIndex, toIndex));
return vo;
}
private AppearancePatentParsedPayloadDto hydrateParsedPayloadRows(AppearancePatentParsedPayloadDto payload) {
if (payload == null) {
return new AppearancePatentParsedPayloadDto();
@@ -2575,6 +2606,9 @@ public class AppearancePatentTaskService {
if (payload.getItems() == null || payload.getItems().isEmpty()) {
payload.setItems(rows);
}
if ((payload.getGroups() == null || payload.getGroups().isEmpty()) && !rows.isEmpty()) {
payload.setGroups(buildParsedGroups(rows));
}
return payload;
}
@@ -2656,22 +2690,6 @@ public class AppearancePatentTaskService {
return normalize(id) + "::" + normalize(asin).toUpperCase(Locale.ROOT) + "::" + normalize(country);
}
private String readValueByHeader(AppearancePatentParsedRowVo row, String... candidates) {
if (row == null || row.getValues() == null || row.getValues().isEmpty() || candidates == null) {
return "";
}
for (Map.Entry<String, String> entry : row.getValues().entrySet()) {
String header = normalize(entry.getKey()).toLowerCase(Locale.ROOT);
for (String candidate : candidates) {
String expected = normalize(candidate).toLowerCase(Locale.ROOT);
if (!expected.isBlank() && header.contains(expected)) {
return entry.getValue() == null ? "" : entry.getValue();
}
}
}
return "";
}
private String userFacingCozeCellValue(AppearancePatentResultRowDto row, String value) {
String normalizedValue = normalize(value);
if (!normalizedValue.isBlank() && !isTechnicalCozeFailure(normalizedValue)) {

View File

@@ -221,11 +221,15 @@ public class BrandTaskService {
}
public void submitCrawlResult(Long taskId, BrandCrawlResultRequest request) {
if (taskId == null || taskId <= 0) {
throw new BusinessException("taskId invalid");
}
try (TaskDistributedLockService.LockHandle ignored = acquireTaskLockOrThrow(taskId, TaskDistributedLockService.DEFAULT_WAIT_MILLIS)) {
long startedAt = System.currentTimeMillis();
BrandCrawlTaskEntity task = requireActiveTask(taskId);
List<BrandSourceFileDto> sourceFiles = parseSourceFiles(task.getFilePaths());
if (sourceFiles.isEmpty()) {
throw new BusinessException("任务没有源文件");
throw new BusinessException("task source files missing");
}
Map<String, BrandSourceFileDto> sourceByUrl = new LinkedHashMap<>();
@@ -237,7 +241,7 @@ public class BrandTaskService {
}
List<BrandParsedFileCacheDto> cachedFiles = brandTaskStorageService.getParsedPayload(taskId);
if (cachedFiles == null || cachedFiles.isEmpty()) {
throw new BusinessException("任务原始数据已过期,请重新创建任务");
throw new BusinessException("task parsed payload missing");
}
Map<String, BrandParsedFileCacheDto> cachedByUrl = new LinkedHashMap<>();
for (BrandParsedFileCacheDto cachedFile : cachedFiles) {
@@ -246,7 +250,7 @@ public class BrandTaskService {
List<BrandCrawlResultFileDto> resultFiles = request.getFiles() == null ? List.of() : request.getFiles();
if (resultFiles.isEmpty()) {
throw new BusinessException("files 不能为空");
throw new BusinessException("files is empty");
}
ensureNoDuplicateResultFiles(resultFiles);
@@ -264,21 +268,21 @@ public class BrandTaskService {
for (BrandCrawlResultFileDto resultFile : resultFiles) {
String fileUrl = blankToNull(resultFile.getFileUrl());
if (fileUrl == null) {
throw new BusinessException("fileUrl 不能为空");
throw new BusinessException("fileUrl is blank");
}
BrandSourceFileDto sourceFile = sourceByUrl.get(fileUrl);
if (sourceFile == null) {
throw new BusinessException("存在未知 fileUrl: " + fileUrl);
throw new BusinessException("unknown fileUrl: " + fileUrl);
}
BrandParsedFileCacheDto cachedFile = cachedByUrl.get(fileUrl);
if (cachedFile == null) {
throw new BusinessException("缺少原始缓存数据: " + fileUrl);
throw new BusinessException("parsed cache missing: " + fileUrl);
}
int totalLines = cachedFile.getRows() == null ? 0 : cachedFile.getRows().size();
if (resultFile.getTotalLines() == null || resultFile.getTotalLines() <= 0) {
resultFile.setTotalLines(totalLines);
} else if (totalLines > 0 && resultFile.getTotalLines() > totalLines) {
throw new BusinessException("totalLines 超过原始文件行数: " + fileUrl);
throw new BusinessException("totalLines exceeds source rows: " + fileUrl);
}
BrandTaskStorageService.ChunkStoreResult storeResult = brandTaskStorageService.storeChunk(taskId, resultFile);
BrandTaskStorageService.ChunkStoreResult refreshResult = brandTaskStorageService.refreshFileAggregate(taskId, fileUrl);
@@ -320,8 +324,10 @@ public class BrandTaskService {
System.currentTimeMillis() - startedAt,
Thread.currentThread().getName());
}
}
public void cancelTask(Long taskId) {
try (TaskDistributedLockService.LockHandle ignored = acquireTaskLockOrThrow(taskId, TaskDistributedLockService.DEFAULT_WAIT_MILLIS)) {
requireTask(taskId);
int updated = brandCrawlTaskMapper.update(null, new LambdaUpdateWrapper<BrandCrawlTaskEntity>()
.eq(BrandCrawlTaskEntity::getId, taskId)
@@ -333,8 +339,10 @@ public class BrandTaskService {
brandTaskStorageService.deleteTaskData(taskId);
brandTaskProgressCacheService.delete(taskId);
}
}
public void deleteTask(Long taskId) {
try (TaskDistributedLockService.LockHandle ignored = acquireTaskLockOrThrow(taskId, TaskDistributedLockService.DEFAULT_WAIT_MILLIS)) {
requireTask(taskId);
int deleted = brandCrawlTaskMapper.delete(new LambdaQueryWrapper<BrandCrawlTaskEntity>()
.eq(BrandCrawlTaskEntity::getId, taskId)
@@ -345,6 +353,7 @@ public class BrandTaskService {
brandTaskStorageService.deleteTaskData(taskId);
brandTaskProgressCacheService.delete(taskId);
}
}
public String resolveDownloadUrl(Long taskId) {
BrandCrawlTaskEntity task = requireTask(taskId);
@@ -1253,6 +1262,14 @@ public class BrandTaskService {
return lockHandle;
}
private TaskDistributedLockService.LockHandle acquireTaskLockOrThrow(Long taskId, long waitMillis) {
TaskDistributedLockService.LockHandle lockHandle = acquireTaskLock(taskId, waitMillis);
if (lockHandle == null) {
throw new BusinessException("task lock is busy");
}
return lockHandle;
}
private boolean tryRecoverCompletedRunningTask(BrandCrawlTaskEntity task) {
if (task == null || task.getId() == null) {
return false;

View File

@@ -35,6 +35,7 @@ import com.nanri.aiimage.modules.task.mapper.FileTaskMapper;
import com.nanri.aiimage.modules.task.model.entity.FileResultEntity;
import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity;
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
import com.nanri.aiimage.modules.task.service.TaskDistributedLockService;
import com.nanri.aiimage.modules.task.service.TaskFileJobService;
import com.nanri.aiimage.modules.ziniao.model.vo.ZiniaoShopMatchResultVo;
import com.nanri.aiimage.modules.ziniao.service.ZiniaoShopIndexService;
@@ -54,6 +55,7 @@ import org.springframework.transaction.annotation.Transactional;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Comparator;
@@ -71,6 +73,8 @@ public class DeleteBrandRunService {
private static final String MODULE_TYPE = "DELETE_BRAND";
private static final String CONTENT_TYPE_XLSX = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
private static final Duration TASK_LOCK_TTL = TaskDistributedLockService.DEFAULT_LOCK_TTL;
private static final long TASK_LOCK_WAIT_MILLIS = TaskDistributedLockService.DEFAULT_WAIT_MILLIS;
private final FileTaskMapper fileTaskMapper;
private final FileResultMapper fileResultMapper;
@@ -82,6 +86,7 @@ public class DeleteBrandRunService {
private final ObjectMapper objectMapper;
private final DeleteBrandProgressProperties deleteBrandProgressProperties;
private final TaskPressureProperties taskPressureProperties;
private final TaskDistributedLockService taskDistributedLockService;
private final TaskFileJobService taskFileJobService;
private boolean isUsableIndexedMatch(ZiniaoShopMatchResultVo matchResult) {
@@ -493,6 +498,7 @@ public class DeleteBrandRunService {
throw new BusinessException("记录不存在");
}
Long taskId = entity.getTaskId();
try (TaskDistributedLockService.LockHandle ignored = acquireTaskLockOrThrow(taskId)) {
fileResultMapper.deleteById(resultId);
if (taskId != null && taskId > 0) {
Long remaining = fileResultMapper.selectCount(new LambdaQueryWrapper<FileResultEntity>()
@@ -503,6 +509,7 @@ public class DeleteBrandRunService {
}
}
}
}
private ParsedDeleteBrandFile parseDeleteBrandFile(File inputFile) {
DataFormatter formatter = new DataFormatter();
@@ -1249,18 +1256,19 @@ public class DeleteBrandRunService {
@Transactional
public void submitResult(Long taskId, DeleteBrandSubmitResultRequest request) {
if (taskId == null || taskId <= 0) {
throw new BusinessException("taskId invalid");
}
try (TaskDistributedLockService.LockHandle ignored = acquireTaskLockOrThrow(taskId, TASK_LOCK_WAIT_MILLIS)) {
log.info("[DeleteBrand] Received submitResult for taskId: {}, files count: {}", taskId, request == null || request.getFiles() == null ? 0 : request.getFiles().size());
if (taskId == null || taskId <= 0) {
throw new BusinessException("taskId 不合法");
}
if (request == null || request.getFiles() == null || request.getFiles().isEmpty()) {
throw new BusinessException("files 不能为空");
throw new BusinessException("files is empty");
}
FileTaskEntity task = loadTaskForExecution(taskId);
if (task == null || !MODULE_TYPE.equals(task.getModuleType())) {
throw new BusinessException("任务不存在");
throw new BusinessException("task not found");
}
if ("SUCCESS".equals(task.getStatus()) || "FAILED".equals(task.getStatus())) {
log.warn("[DeleteBrand] submitResult rejected because task is terminal -> taskId: {}, status: {}, createdAt: {}, updatedAt: {}, finishedAt: {}, error: {}, sourceFiles: {}, successFiles: {}, failedFiles: {}",
@@ -1273,12 +1281,12 @@ public class DeleteBrandRunService {
task.getSourceFileCount(),
task.getSuccessFileCount(),
task.getFailedFileCount());
throw new BusinessException("任务已结束,拒绝继续提交结果");
throw new BusinessException("task already finished");
}
Map<String, DeleteBrandParsedFileCacheDto> parsedPayload = deleteBrandTaskStorageService.loadParsedPayload(taskId);
if (parsedPayload == null || parsedPayload.isEmpty()) {
throw new BusinessException("任务原始数据已过期,请重新执行解析");
throw new BusinessException("task parsed payload missing");
}
if (!"RUNNING".equals(task.getStatus())) {
@@ -1299,15 +1307,14 @@ public class DeleteBrandRunService {
String fileIdentity = resolveFileIdentity(fileDto);
if (fileIdentity.isBlank()) {
throw new BusinessException("fileKey/sourceFilename 不能为空");
throw new BusinessException("file identity is blank");
}
DeleteBrandParsedFileCacheDto parsedFile = parsedPayload.get(fileIdentity);
if (parsedFile == null) {
log.warn("[DeleteBrand] parsed payload not found for identity: {}", fileIdentity);
throw new BusinessException("文件标识不匹配: " + fileIdentity);
throw new BusinessException("parsed file not found: " + fileIdentity);
}
// 更早执行 duplicate fast-path尽量在较重校验和进度写入前直接短路
validateChunk(fileDto, parsedFile);
validateResultFileAgainstParsed(fileDto, parsedFile);
boolean stored = deleteBrandTaskStorageService.storeResultChunkIfChanged(taskId, fileIdentity, fileDto.getChunkIndex(), fileDto);
@@ -1329,21 +1336,18 @@ public class DeleteBrandRunService {
progress.put("last_heartbeat_at", String.valueOf(System.currentTimeMillis()));
deleteBrandTaskCacheService.saveProgress(taskId, progress, true);
// 如果该文件已完成,立即更新成品计数的 Redis 提示,让后续 tryFinalizeTask 更准确
if (isFileCompleted(fileDto)) {
log.info("[DeleteBrand] File chunk transmission fully reached via chunkTotal for taskId: {}, file: {}", taskId, fileDto.getSourceFilename());
enqueueCompletedFileAssembleJob(task, fileIdentity, parsedFile);
shouldTryFinalize = true;
// 这里暂不直接 increment而是标记需要在 tryFinalizeTask 中重新计算
// 为了保险,直接在 tryFinalizeTask 里重新遍历分片状态
}
}
if (shouldTryFinalize) {
tryFinalizeTask(taskId, false);
}
}
}
private void enqueueCompletedFileAssembleJob(FileTaskEntity task,
String fileIdentity,
@@ -1398,7 +1402,7 @@ public class DeleteBrandRunService {
if (taskId == null || taskId <= 0) {
return;
}
try (TaskDistributedLockService.LockHandle ignored = acquireTaskLockOrThrow(taskId, 0L)) {
FileTaskEntity task = loadTaskForExecution(taskId);
if (task == null || !MODULE_TYPE.equals(task.getModuleType())) {
return;
@@ -1412,15 +1416,10 @@ public class DeleteBrandRunService {
tryFinalizeTaskFromTerminalResults(task);
return;
}
// 移除 finishedFilesHint 的强依赖,因为 Redis 里的 finished_files 可能是旧值
// 既然进入 tryFinalizeTask就说明有新分片到达应该以 loadMergedChunks 为准
int finishedFiles = deleteBrandTaskStorageService.countCompletedScopes(taskId);
if (finishedFiles < expectedFiles) {
updateTaskFinalizeProgress(task, finishedFiles, fromCompensation);
log.info("[DeleteBrand] tryFinalizeTask skipping -> finishedFiles ({}) < expectedFiles ({})", finishedFiles, expectedFiles);
return;
}
@@ -1434,8 +1433,14 @@ public class DeleteBrandRunService {
Map<String, List<DeleteBrandResultFileDto>> mergedByFile = deleteBrandTaskStorageService.loadMergedChunks(taskId);
log.info("[DeleteBrand] tryFinalizeTask calling finalizeTask -> finishedFiles: {}, expectedFiles: {}", finishedFiles, expectedFiles);
finalizeTask(task, parsedPayload, mergedByFile);
} catch (BusinessException ex) {
if (ex.getMessage() != null && ex.getMessage().contains("task lock is busy")) {
log.info("[DeleteBrand] tryFinalizeTask skipped because task lock is busy taskId={} fromCompensation={}", taskId, fromCompensation);
return;
}
throw ex;
}
}
private void updateTaskFinalizeProgress(FileTaskEntity task, int finishedFiles, boolean fromCompensation) {
@@ -1974,6 +1979,27 @@ public class DeleteBrandRunService {
String normalized = blankToEmpty(value);
return normalized.isBlank() ? defaultValue : normalized;
}
private TaskDistributedLockService.LockHandle acquireTaskLockOrThrow(Long taskId) {
return acquireTaskLockOrThrow(taskId, TASK_LOCK_WAIT_MILLIS);
}
private TaskDistributedLockService.LockHandle acquireTaskLockOrThrow(Long taskId, long waitMillis) {
TaskDistributedLockService.LockHandle lockHandle = acquireTaskLock(taskId, waitMillis);
if (lockHandle == null) {
throw new BusinessException("task lock is busy");
}
return lockHandle;
}
private TaskDistributedLockService.LockHandle acquireTaskLock(Long taskId, long waitMillis) {
TaskDistributedLockService.LockHandle lockHandle =
taskDistributedLockService.acquire(MODULE_TYPE, taskId, TASK_LOCK_TTL, waitMillis);
if (lockHandle == null) {
log.info("[DeleteBrand] task lock busy taskId={} waitMillis={}", taskId, waitMillis);
}
return lockHandle;
}
/**
* 落库用 result_json去掉国家分组、预览行等大字段保留匹配摘要与计数。
* 索引命中后单次任务 JSON 体积会明显膨胀,容易触发 max_allowed_packet 或更新失败。

View File

@@ -5,6 +5,8 @@ import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.nanri.aiimage.common.service.DistributedJobLockService;
import com.nanri.aiimage.config.DeleteBrandProgressProperties;
import com.nanri.aiimage.modules.productrisk.service.ProductRiskTaskCacheService;
import com.nanri.aiimage.modules.queryasin.service.QueryAsinTaskCacheService;
import com.nanri.aiimage.modules.queryasin.service.QueryAsinTaskService;
import com.nanri.aiimage.modules.productrisk.service.ProductRiskTaskService;
import com.nanri.aiimage.modules.patroldelete.service.PatrolDeleteTaskCacheService;
import com.nanri.aiimage.modules.patroldelete.service.PatrolDeleteTaskService;
@@ -42,6 +44,7 @@ public class DeleteBrandStaleTaskService {
private static final String MODULE_TYPE_PRICE_TRACK = "PRICE_TRACK";
private static final String MODULE_TYPE_SHOP_MATCH = "SHOP_MATCH";
private static final String MODULE_TYPE_PATROL_DELETE = "PATROL_DELETE";
private static final String MODULE_TYPE_QUERY_ASIN = "QUERY_ASIN";
private static final Duration STALE_CHECK_LOCK_TTL = Duration.ofMinutes(10);
private static final Duration FINALIZE_CHECK_LOCK_TTL = Duration.ofMinutes(10);
private static final Duration TEMP_DIR_CLEANUP_LOCK_TTL = Duration.ofMinutes(10);
@@ -58,6 +61,8 @@ public class DeleteBrandStaleTaskService {
private final ShopMatchTaskCacheService shopMatchTaskCacheService;
private final PatrolDeleteTaskService patrolDeleteTaskService;
private final PatrolDeleteTaskCacheService patrolDeleteTaskCacheService;
private final QueryAsinTaskService queryAsinTaskService;
private final QueryAsinTaskCacheService queryAsinTaskCacheService;
private final DeleteBrandProgressProperties deleteBrandProgressProperties;
private final DistributedJobLockService distributedJobLockService;
private final TaskDistributedLockService taskDistributedLockService;
@@ -82,6 +87,7 @@ public class DeleteBrandStaleTaskService {
PriceTrackStaleCheckStats priceTrackStats = failStalePriceTrackTasks();
ShopMatchStaleCheckStats shopMatchStats = failStaleShopMatchTasks();
ShopMatchStaleCheckStats patrolDeleteStats = failStalePatrolDeleteTasks();
ShopMatchStaleCheckStats queryAsinStats = failStaleQueryAsinTasks();
log.info("[stale-check] product-risk summary scanned={} finalized={} failed={} skipped={} elapsedMs={} thread={}",
stats.scannedTaskCount,
stats.finalizedTaskCount,
@@ -110,6 +116,13 @@ public class DeleteBrandStaleTaskService {
patrolDeleteStats.skippedTaskCount,
System.currentTimeMillis() - startedAt,
Thread.currentThread().getName());
log.info("[stale-check] query-asin summary scanned={} finalized={} failed={} skipped={} elapsedMs={} thread={}",
queryAsinStats.scannedTaskCount,
queryAsinStats.finalizedTaskCount,
queryAsinStats.failedTaskCount,
queryAsinStats.skippedTaskCount,
System.currentTimeMillis() - startedAt,
Thread.currentThread().getName());
}
}
@@ -525,6 +538,85 @@ public class DeleteBrandStaleTaskService {
return stats;
}
private ShopMatchStaleCheckStats failStaleQueryAsinTasks() {
ShopMatchStaleCheckStats stats = new ShopMatchStaleCheckStats();
long minutes = Math.max(1L, deleteBrandProgressProperties.getPatrolDeleteStaleTimeoutMinutes());
long initialMinutes = Math.max(minutes, deleteBrandProgressProperties.getPatrolDeleteInitialTimeoutMinutes());
long staleTimeoutMillis = Duration.ofMinutes(minutes).toMillis();
long nowMillis = System.currentTimeMillis();
LocalDateTime now = LocalDateTime.now();
LocalDateTime threshold = now.minusMinutes(minutes);
LocalDateTime initialThreshold = now.minusMinutes(initialMinutes);
java.util.List<FileTaskEntity> runningTasks = fileTaskMapper.selectList(new LambdaQueryWrapper<FileTaskEntity>()
.eq(FileTaskEntity::getModuleType, MODULE_TYPE_QUERY_ASIN)
.eq(FileTaskEntity::getStatus, "RUNNING")
.lt(FileTaskEntity::getUpdatedAt, threshold)
.last("limit 200"));
stats.scannedTaskCount = runningTasks.size();
if (runningTasks.isEmpty()) {
return stats;
}
Map<Long, Long> heartbeatByTaskId = queryAsinTaskCacheService.getTaskHeartbeatMillisBatch(
runningTasks.stream().map(FileTaskEntity::getId).toList());
for (FileTaskEntity task : runningTasks) {
long lastHeartbeatMillis = heartbeatByTaskId.getOrDefault(task.getId(), 0L);
boolean hasFinishedRows = (task.getSuccessFileCount() != null && task.getSuccessFileCount() > 0)
|| (task.getFailedFileCount() != null && task.getFailedFileCount() > 0);
if (lastHeartbeatMillis > 0L && nowMillis - lastHeartbeatMillis < staleTimeoutMillis) {
stats.skippedTaskCount++;
continue;
}
boolean hasStartedProgress = hasFinishedRows;
if (!hasStartedProgress) {
hasStartedProgress = queryAsinTaskCacheService.hasAnyShopMergedPayload(task.getId());
}
if (hasPendingAssembleJobs(task.getId(), MODULE_TYPE_QUERY_ASIN)) {
stats.skippedTaskCount++;
log.info("[stale-check] query-asin skip pending-assemble-jobs taskId={} unfinishedJobs={} activeJobs={}",
task.getId(),
taskFileJobService.countUnfinishedAssembleJobs(task.getId(), MODULE_TYPE_QUERY_ASIN),
taskFileJobService.countActiveAssembleJobs(task.getId(), MODULE_TYPE_QUERY_ASIN));
continue;
}
if (!hasStartedProgress && isWithinInitialGrace(task, initialThreshold)) {
stats.skippedTaskCount++;
continue;
}
TaskDistributedLockService.LockHandle taskLockHandle = acquireTaskLock(MODULE_TYPE_QUERY_ASIN, task.getId());
if (taskLockHandle == null) {
stats.skippedTaskCount++;
continue;
}
try (taskLockHandle) {
try {
if (queryAsinTaskService.tryFinalizeTask(task.getId(), true)) {
stats.finalizedTaskCount++;
log.info("[stale-check] query-asin finalized taskId={}", task.getId());
continue;
}
} catch (Exception ex) {
log.warn("[stale-check] query-asin finalize threw taskId={} msg={}", task.getId(), ex.getMessage());
}
int updated = fileTaskMapper.update(null, new LambdaUpdateWrapper<FileTaskEntity>()
.eq(FileTaskEntity::getId, task.getId())
.eq(FileTaskEntity::getModuleType, MODULE_TYPE_QUERY_ASIN)
.eq(FileTaskEntity::getStatus, "RUNNING")
.set(FileTaskEntity::getStatus, "FAILED")
.set(FileTaskEntity::getErrorMessage, "闀挎椂闂存湭鏀跺埌 Python 缁撴灉鍥炰紶锛屼换鍔″凡鑷姩澶辫触")
.set(FileTaskEntity::getUpdatedAt, LocalDateTime.now())
.set(FileTaskEntity::getFinishedAt, LocalDateTime.now()));
if (updated > 0) {
stats.failedTaskCount++;
queryAsinTaskCacheService.deleteTaskCache(task.getId());
log.warn("[stale-check] query-asin failed taskId={} reason=timeout", task.getId());
} else {
stats.skippedTaskCount++;
}
}
}
return stats;
}
private boolean isWithinInitialGrace(FileTaskEntity task, LocalDateTime initialThreshold) {
if (task == null || initialThreshold == null) {
return false;
@@ -595,7 +687,8 @@ public class DeleteBrandStaleTaskService {
"patrol-delete-result",
"delete-brand-result",
"product-risk-result",
"price-track-result"
"price-track-result",
"query-asin-result"
);
for (String dirName : dirNames) {
cleanupTempRoot(dirName, cutoff);

View File

@@ -330,6 +330,7 @@ public class PatrolDeleteTaskService {
throw new BusinessException("shops 不能为空");
}
try (TaskDistributedLockService.LockHandle ignored = acquireTaskLockOrThrow(taskId)) {
FileTaskEntity task = loadTaskForExecution(taskId);
if (task == null || !MODULE_TYPE.equals(task.getModuleType())) {
log.info("[patrol-delete] ignore result callback for deleted task taskId={}", taskId);
@@ -374,11 +375,18 @@ public class PatrolDeleteTaskService {
taskCacheService.saveTaskCache(task);
tryFinalizeTask(taskId, false);
}
}
public boolean tryFinalizeTask(Long taskId, boolean fromCompensation) {
if (taskId == null || taskId <= 0) {
return false;
}
TaskDistributedLockService.LockHandle lockHandle = acquireTaskLock(taskId);
if (lockHandle == null) {
log.info("[patrol-delete] tryFinalizeTask skipped because task lock is busy taskId={} fromCompensation={}", taskId, fromCompensation);
return false;
}
try (lockHandle) {
FileTaskEntity task = loadTaskForExecution(taskId);
if (task == null || !MODULE_TYPE.equals(task.getModuleType())) {
return false;
@@ -450,6 +458,7 @@ public class PatrolDeleteTaskService {
taskCacheService.saveTaskCache(task);
return changed;
}
}
@Transactional
public void deleteTask(Long taskId, Long userId) {
@@ -459,6 +468,7 @@ public class PatrolDeleteTaskService {
if (task == null || !MODULE_TYPE.equals(task.getModuleType()) || !userId.equals(task.getUserId())) {
throw new BusinessException("任务不存在");
}
try (TaskDistributedLockService.LockHandle ignored = acquireTaskLockOrThrow(taskId)) {
long taskLoadedAt = System.nanoTime();
fileResultMapper.delete(new LambdaQueryWrapper<FileResultEntity>()
.eq(FileResultEntity::getTaskId, taskId)
@@ -480,6 +490,7 @@ public class PatrolDeleteTaskService {
elapsedMs(auxiliaryDeletedAt, taskDeletedAt),
elapsedMs(taskDeletedAt, finishedAt));
}
}
@Transactional
public void deleteHistory(Long resultId, Long userId) {

View File

@@ -258,6 +258,7 @@ public class PriceTrackTaskService {
@Transactional
public void deleteTask(Long taskId, Long userId) {
if (userId == null || userId <= 0) throw new BusinessException("user_id 不合法");
try (TaskDistributedLockService.LockHandle ignored = acquireTaskLockOrThrow(taskId)) {
FileTaskEntity task = loadTaskForExecution(taskId);
if (task == null || !MODULE_TYPE.equals(task.getModuleType()) || !userId.equals(task.getUserId())) {
throw new BusinessException("任务不存在");
@@ -269,6 +270,7 @@ public class PriceTrackTaskService {
priceTrackTaskCacheService.deleteTaskCache(taskId);
priceTrackLoopRunService.syncLoopRunAfterChildRemoved(taskId);
}
}
@Transactional
public PriceTrackPendingDeleteVo deletePendingShopResult(Long userId, String shopName) {
@@ -458,6 +460,7 @@ public class PriceTrackTaskService {
if (request == null || request.getShops() == null || request.getShops().isEmpty()) {
throw new BusinessException("shops 不能为空");
}
try (TaskDistributedLockService.LockHandle ignored = acquireTaskLockOrThrow(taskId)) {
FileTaskEntity task = loadTaskForExecution(taskId);
if (task == null || !MODULE_TYPE.equals(task.getModuleType())) {
throw new BusinessException("任务不存在");
@@ -526,9 +529,16 @@ public class PriceTrackTaskService {
.orderByAsc(FileResultEntity::getId));
updateTaskStatusFromLatestRows(task, latest);
}
}
public boolean tryFinalizeTask(Long taskId, boolean fromCompensation) {
if (taskId == null || taskId <= 0) return false;
TaskDistributedLockService.LockHandle lockHandle = acquireTaskLock(taskId);
if (lockHandle == null) {
log.info("[price-track] tryFinalizeTask skipped because task lock is busy taskId={} fromCompensation={}", taskId, fromCompensation);
return false;
}
try (lockHandle) {
FileTaskEntity task = loadTaskForExecution(taskId);
if (task == null || !MODULE_TYPE.equals(task.getModuleType())) return false;
if (!"RUNNING".equals(task.getStatus())) {
@@ -607,6 +617,7 @@ public class PriceTrackTaskService {
updateTaskStatusFromLatestRows(task, refreshed);
return true;
}
}
public String resolveResultDownloadUrl(Long resultId, Long userId) {
FileResultEntity entity = fileResultMapper.selectById(resultId);

View File

@@ -267,6 +267,7 @@ public class ProductRiskTaskService {
if (userId == null || userId <= 0) {
throw new BusinessException("user_id 不合法");
}
try (TaskDistributedLockService.LockHandle ignored = acquireTaskLockOrThrow(taskId)) {
FileTaskEntity task = loadTaskForExecution(taskId);
if (task == null || !MODULE_TYPE.equals(task.getModuleType()) || !userId.equals(task.getUserId())) {
throw new BusinessException("任务不存在");
@@ -277,6 +278,7 @@ public class ProductRiskTaskService {
fileTaskMapper.deleteById(taskId);
cleanupTaskCacheIfTerminal(taskId, "DELETE_EMPTY");
}
}
/**
* 从用户当前“运行中”的商品风险任务里,按规范化店铺名删除一条结果,用于前端移除匹配列表后同步后端。
@@ -550,7 +552,7 @@ public class ProductRiskTaskService {
if (request == null || request.getShops() == null || request.getShops().isEmpty()) {
throw new BusinessException("shops 不能为空");
}
try (TaskDistributedLockService.LockHandle ignored = acquireTaskLockOrThrow(taskId)) {
FileTaskEntity task = loadTaskForExecution(taskId);
if (task == null || !MODULE_TYPE.equals(task.getModuleType())) {
throw new BusinessException("任务不存在");
@@ -593,7 +595,6 @@ public class ProductRiskTaskService {
String shopKey = fr.getSourceFilename();
ProductRiskShopPayloadDto payload = shopKey == null ? null : payloadByShop.get(shopKey);
// 兼容单店任务:如果规范化店名偶发不一致导致未命中,且任务与回传都只有 1 个店时按唯一店铺回填。
if (payload == null && resultRows.size() == 1 && payloadByShop.size() == 1) {
payload = payloadByShop.values().iterator().next();
fallbackMatchedCount++;
@@ -603,7 +604,6 @@ public class ProductRiskTaskService {
if (payload == null) {
skippedUnmatchedCount++;
// 与删除品牌一致:支持队列逐条处理、分次回传,本次 payload 未包含的店铺保持待处理。
continue;
}
matchedShopCount++;
@@ -614,7 +614,6 @@ public class ProductRiskTaskService {
continue;
}
// 分次上报累积:合并到 Redis 缓存,再按“合并后是否完成”决定是否出包。
ProductRiskShopPayloadDto mergedPayload = mergeShopPayload(taskId, shopKey, payload);
boolean incomingShopDone = hasShopDoneSignal(payload);
int mergedRows = countPayloadRows(mergedPayload);
@@ -662,11 +661,18 @@ public class ProductRiskTaskService {
taskId, oldStatus, task.getStatus(), matchedShopCount, skippedUnmatchedCount,
waitingCount, assembledCount, fallbackMatchedCount, batchErrors);
}
}
public boolean tryFinalizeTask(Long taskId, boolean fromCompensation) {
if (taskId == null || taskId <= 0) {
return false;
}
TaskDistributedLockService.LockHandle lockHandle = acquireTaskLock(taskId);
if (lockHandle == null) {
log.info("[product-risk] tryFinalizeTask skipped because task lock is busy taskId={} fromCompensation={}", taskId, fromCompensation);
return false;
}
try (lockHandle) {
FileTaskEntity task = loadTaskForExecution(taskId);
if (task == null || !MODULE_TYPE.equals(task.getModuleType())) {
return false;
@@ -767,6 +773,7 @@ public class ProductRiskTaskService {
taskId, oldStatus, task.getStatus(), changed, cachedPayloadByShop.size(), fromCompensation, batchErrors);
return true;
}
}
private void markResultFailed(FileResultEntity fr, String message) {
fr.setSuccess(0);

View File

@@ -14,6 +14,7 @@ import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@@ -79,6 +80,41 @@ public class QueryAsinTaskCacheService {
}
}
public Map<Long, Long> getTaskHeartbeatMillisBatch(List<Long> taskIds) {
Map<Long, Long> result = new LinkedHashMap<>();
if (taskIds == null || taskIds.isEmpty()) {
return result;
}
List<Long> normalized = taskIds.stream()
.filter(id -> id != null && id > 0)
.distinct()
.toList();
if (normalized.isEmpty()) {
return result;
}
List<String> keys = normalized.stream().map(this::buildTaskHeartbeatKey).toList();
List<String> values;
try {
values = stringRedisTemplate.opsForValue().multiGet(keys);
} catch (Exception ex) {
log.warn("[query-asin-cache] batch get heartbeat degraded taskIds={} msg={}", normalized, ex.getMessage());
return result;
}
for (int i = 0; i < normalized.size(); i++) {
String raw = values != null && i < values.size() ? values.get(i) : null;
if (raw == null || raw.isBlank()) {
result.put(normalized.get(i), 0L);
continue;
}
try {
result.put(normalized.get(i), Long.parseLong(raw));
} catch (NumberFormatException ignored) {
result.put(normalized.get(i), 0L);
}
}
return result;
}
public void touchTaskHeartbeat(Long taskId) {
try {
stringRedisTemplate.opsForValue().set(

View File

@@ -328,6 +328,7 @@ public class QueryAsinTaskService {
throw new BusinessException("shops 不能为空");
}
try (TaskDistributedLockService.LockHandle ignored = acquireTaskLockOrThrow(taskId)) {
FileTaskEntity task = loadTaskForExecution(taskId);
if (task == null || !MODULE_TYPE.equals(task.getModuleType())) {
throw new BusinessException("任务不存在");
@@ -371,11 +372,18 @@ public class QueryAsinTaskService {
taskCacheService.saveTaskCache(task);
tryFinalizeTask(taskId, false);
}
}
public boolean tryFinalizeTask(Long taskId, boolean fromCompensation) {
if (taskId == null || taskId <= 0) {
return false;
}
TaskDistributedLockService.LockHandle lockHandle = acquireTaskLock(taskId);
if (lockHandle == null) {
log.info("[query-asin] tryFinalizeTask skipped because task lock is busy taskId={} fromCompensation={}", taskId, fromCompensation);
return false;
}
try (lockHandle) {
FileTaskEntity task = loadTaskForExecution(taskId);
if (task == null || !MODULE_TYPE.equals(task.getModuleType())) {
return false;
@@ -447,6 +455,7 @@ public class QueryAsinTaskService {
taskCacheService.saveTaskCache(task);
return changed;
}
}
@Transactional
public void deleteTask(Long taskId, Long userId) {
@@ -455,12 +464,14 @@ public class QueryAsinTaskService {
if (task == null || !MODULE_TYPE.equals(task.getModuleType()) || !userId.equals(task.getUserId())) {
throw new BusinessException("任务不存在");
}
try (TaskDistributedLockService.LockHandle ignored = acquireTaskLockOrThrow(taskId)) {
fileResultMapper.delete(new LambdaQueryWrapper<FileResultEntity>()
.eq(FileResultEntity::getTaskId, taskId)
.eq(FileResultEntity::getModuleType, MODULE_TYPE));
fileTaskMapper.deleteById(taskId);
taskCacheService.deleteTaskCache(taskId);
}
}
@Transactional
public void deleteHistory(Long resultId, Long userId) {

View File

@@ -275,12 +275,14 @@ public class ShopMatchTaskService {
if (task == null || !MODULE_TYPE.equals(task.getModuleType()) || !userId.equals(task.getUserId())) {
throw new BusinessException("任务不存在");
}
try (TaskDistributedLockService.LockHandle ignored = acquireTaskLockOrThrow(taskId)) {
fileResultMapper.delete(new LambdaQueryWrapper<FileResultEntity>()
.eq(FileResultEntity::getTaskId, taskId)
.eq(FileResultEntity::getModuleType, MODULE_TYPE));
fileTaskMapper.deleteById(taskId);
shopMatchTaskCacheService.deleteTaskCache(taskId);
}
}
private void reconcileTaskAfterResultRemoval(Long taskId) {
FileTaskEntity task = loadTaskForExecution(taskId);
@@ -452,6 +454,7 @@ public class ShopMatchTaskService {
if (stageIndex == null || stageIndex < 0) {
throw new BusinessException("stage_index 不合法");
}
try (TaskDistributedLockService.LockHandle ignored = acquireTaskLockOrThrow(taskId)) {
FileTaskEntity task = loadTaskForExecution(taskId);
if (task == null || !MODULE_TYPE.equals(task.getModuleType()) || !userId.equals(task.getUserId())) {
throw new BusinessException("任务不存在");
@@ -489,6 +492,7 @@ public class ShopMatchTaskService {
updateTaskAndRefreshCache(task);
shopMatchTaskCacheService.touchTaskHeartbeat(taskId);
}
}
@Transactional
public void completeStage(Long taskId, Long userId, ShopMatchStageCompleteRequest request) {
@@ -605,6 +609,12 @@ public class ShopMatchTaskService {
if (taskId == null || taskId <= 0) {
return false;
}
TaskDistributedLockService.LockHandle lockHandle = acquireTaskLock(taskId);
if (lockHandle == null) {
log.info("[shop-match] tryFinalizeTask skipped because task lock is busy taskId={} fromCompensation={}", taskId, fromCompensation);
return false;
}
try (lockHandle) {
FileTaskEntity task = loadTaskForExecution(taskId);
if (task == null || !MODULE_TYPE.equals(task.getModuleType())) {
return false;
@@ -700,6 +710,7 @@ public class ShopMatchTaskService {
updateTaskStatusFromLatestRows(task, latest, batchErrors);
return true;
}
}
private void assembleShopResult(FileResultEntity result, String shopKey, ShopMatchShopPayloadDto payload, File workRoot) {
Map<String, List<ShopMatchRowDto>> countries = excelAssemblyService.normalizeCountriesMap(payload.getCountries());

View File

@@ -316,6 +316,7 @@ public class SimilarAsinTaskService {
@Transactional
public void activateTask(Long taskId, Long userId) {
try (TaskDistributedLockService.LockHandle ignored = requireTaskLock(taskId, TASK_LOCK_WAIT_MILLIS)) {
FileTaskEntity task = fileTaskMapper.selectById(taskId);
if (task == null || !MODULE_TYPE.equals(task.getModuleType()) || userId != null && !Objects.equals(userId, task.getUserId())) {
throw new BusinessException("任务不存在");
@@ -328,6 +329,7 @@ public class SimilarAsinTaskService {
fileTaskMapper.updateById(task);
taskCacheService.touchTaskHeartbeat(taskId);
}
}
public SimilarAsinDashboardVo dashboard(Long userId) {
SimilarAsinDashboardVo vo = new SimilarAsinDashboardVo();
@@ -546,6 +548,7 @@ public class SimilarAsinTaskService {
@Transactional
public void deleteTask(Long taskId, Long userId) {
try (TaskDistributedLockService.LockHandle ignored = requireTaskLock(taskId, TASK_LOCK_WAIT_MILLIS)) {
FileTaskEntity task = fileTaskMapper.selectById(taskId);
if (task == null || !MODULE_TYPE.equals(task.getModuleType()) || !Objects.equals(userId, task.getUserId())) {
throw new BusinessException("任务不存在");
@@ -557,6 +560,7 @@ public class SimilarAsinTaskService {
taskCacheService.deleteTaskCache(taskId);
fileTaskMapper.deleteById(taskId);
}
}
public void deleteHistory(Long resultId, Long userId) {
FileResultEntity row = fileResultMapper.selectById(resultId);
@@ -1568,6 +1572,14 @@ public class SimilarAsinTaskService {
return lockHandle;
}
private TaskDistributedLockService.LockHandle requireTaskLock(Long taskId, long waitMillis) {
TaskDistributedLockService.LockHandle lockHandle = acquireTaskLock(taskId, waitMillis);
if (lockHandle == null) {
throw new BusinessException(40902, "Task is busy, please retry later");
}
return lockHandle;
}
private void completeCozeFileJob(FileTaskEntity task,
FileResultEntity result,
TaskFileJobEntity job,
@@ -2279,6 +2291,7 @@ public class SimilarAsinTaskService {
if (row == null || row.getTaskId() == null) {
return;
}
Long taskId = row.getTaskId();
if (Boolean.TRUE.equals(vo.getFileReady())) {
vo.setFileProgressCurrent(1);
vo.setFileProgressTotal(1);
@@ -2286,8 +2299,29 @@ public class SimilarAsinTaskService {
vo.setFileProgressMessage("结果文件已生成");
return;
}
TaskProgressSnapshotEntity snapshot = taskProgressSnapshotService.find(row.getTaskId(), MODULE_TYPE);
int cozeCompleted = countCompletedCozeStates(taskId);
int cozePending = countPendingCozeStates(taskId);
if (job != null && STATUS_RUNNING.equals(job.getStatus()) && cozePending > 0) {
int total = Math.max(1, cozeCompleted + cozePending);
int current = Math.max(0, Math.min(cozeCompleted, total));
vo.setFileProgressCurrent(current);
vo.setFileProgressTotal(total);
vo.setFileProgressPercent(Math.max(0, Math.min(100, (int) Math.floor(current * 100.0 / total))));
vo.setFileProgressMessage("Coze 已完成 " + current + "/" + total + ",等待回流");
return;
}
TaskProgressSnapshotEntity snapshot = taskProgressSnapshotService.find(taskId, MODULE_TYPE);
if (snapshot == null) {
if (job != null && STATUS_RUNNING.equals(job.getStatus()) && cozeCompleted + cozePending > 0) {
int total = Math.max(1, cozeCompleted + cozePending);
int current = Math.max(0, Math.min(cozeCompleted, total));
vo.setFileProgressCurrent(current);
vo.setFileProgressTotal(total);
vo.setFileProgressPercent(Math.max(0, Math.min(100, (int) Math.floor(current * 100.0 / total))));
vo.setFileProgressMessage(cozePending > 0
? "Coze 已完成 " + current + "/" + total + ",等待回流"
: "Coze 处理中");
}
return;
}
int total = snapshot.getTotalCount() == null ? 0 : snapshot.getTotalCount();
@@ -2296,22 +2330,12 @@ public class SimilarAsinTaskService {
return;
}
current = Math.max(0, Math.min(current, total));
int percent = Math.max(1, Math.min(99, (int) Math.floor(current * 100.0 / total)));
if (job != null && STATUS_RUNNING.equals(job.getStatus())) {
LocalDateTime baseTime = snapshot.getUpdatedAt() != null ? snapshot.getUpdatedAt() : job.getUpdatedAt();
long elapsedSeconds = baseTime == null ? 0 : Math.max(0, Duration.between(baseTime, LocalDateTime.now()).getSeconds());
if (current <= 0) {
percent = Math.max(percent, Math.min(35, 8 + (int) (elapsedSeconds / 6)));
} else if (current < total) {
percent = Math.max(percent, Math.min(92, percent + (int) (elapsedSeconds / 10)));
}
}
int percent = Math.max(0, Math.min(100, (int) Math.floor(current * 100.0 / total)));
vo.setFileProgressCurrent(current);
vo.setFileProgressTotal(total);
vo.setFileProgressPercent(percent);
vo.setFileProgressMessage(snapshot.getMessage());
}
private String fmt(LocalDateTime t) {
return t == null ? null : t.toString();
}

View File

@@ -13,8 +13,14 @@ import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.util.Base64;
import java.util.Locale;
import java.util.Objects;
import java.util.UUID;
import java.util.zip.GZIPInputStream;
import java.util.zip.GZIPOutputStream;
@Service
@RequiredArgsConstructor
@@ -57,6 +63,10 @@ public class TransientPayloadStorageService {
return store("task-parsed", moduleType, taskId, scopeHash, "latest", content, encodeAsJsonString, false);
}
public String storeParsedPayloadEntry(String moduleType, Long taskId, String scopeHash, String entryKey, String content, boolean encodeAsJsonString) {
return store("task-parsed", moduleType, taskId, scopeHash, entryKey, content, encodeAsJsonString, false);
}
public String storeResultPayload(String moduleType, Long taskId, String scopeHash, String submissionId, String content) {
return store("task-result-payload", moduleType, taskId, scopeHash, submissionId, content, true);
}
@@ -84,13 +94,13 @@ public class TransientPayloadStorageService {
}
try {
if (pointer.startsWith(LOCAL_POINTER_PREFIX)) {
return readLocalPayload(pointer.substring(LOCAL_POINTER_PREFIX.length()));
return decodeStoredPayload(readLocalPayload(pointer.substring(LOCAL_POINTER_PREFIX.length())));
}
if (pointer.startsWith(RUSTFS_POINTER_PREFIX)) {
return rustfsObjectStorageService.readObjectAsString(pointer.substring(RUSTFS_POINTER_PREFIX.length()));
return decodeStoredPayload(rustfsObjectStorageService.readObjectAsString(pointer.substring(RUSTFS_POINTER_PREFIX.length())));
}
if (pointer.startsWith(OSS_POINTER_PREFIX)) {
return ossStorageService.readObjectAsString(pointer.substring(OSS_POINTER_PREFIX.length()));
return decodeStoredPayload(ossStorageService.readObjectAsString(pointer.substring(OSS_POINTER_PREFIX.length())));
}
} catch (Exception ex) {
throw new IllegalStateException(errorMessage + ": " + pointer, ex);
@@ -175,10 +185,11 @@ public class TransientPayloadStorageService {
return content;
}
String objectKey = buildObjectKey(category, moduleType, taskId, scopeHash, entryKey);
String storedContent = encodeStoredPayload(content);
String pointer = null;
if (rustfsObjectStorageService.isConfigured()) {
try {
pointer = RUSTFS_POINTER_PREFIX + rustfsObjectStorageService.uploadText(objectKey, content, verifyAfterUpload);
pointer = RUSTFS_POINTER_PREFIX + rustfsObjectStorageService.uploadText(objectKey, storedContent, verifyAfterUpload);
} catch (Exception ex) {
log.warn("[transient-payload] rustfs upload failed objectKey={} err={}",
objectKey, ex.getMessage());
@@ -186,7 +197,7 @@ public class TransientPayloadStorageService {
}
}
if (pointer == null) {
pointer = storeLocal(objectKey, content);
pointer = storeLocal(objectKey, storedContent);
}
if (pointer == null) {
return content;
@@ -231,6 +242,36 @@ public class TransientPayloadStorageService {
}
}
private String encodeStoredPayload(String content) {
try {
byte[] raw = Objects.requireNonNullElse(content, "").getBytes(StandardCharsets.UTF_8);
ByteArrayOutputStream baos = new ByteArrayOutputStream();
try (GZIPOutputStream gzip = new GZIPOutputStream(baos)) {
gzip.write(raw);
}
return "gzip64:" + Base64.getEncoder().encodeToString(baos.toByteArray());
} catch (Exception ex) {
throw new IllegalStateException("failed to encode transient payload", ex);
}
}
private String decodeStoredPayload(String storedContent) {
if (storedContent == null || storedContent.isBlank()) {
return storedContent;
}
if (!storedContent.startsWith("gzip64:")) {
return storedContent;
}
try {
byte[] compressed = Base64.getDecoder().decode(storedContent.substring("gzip64:".length()));
try (GZIPInputStream gzip = new GZIPInputStream(new ByteArrayInputStream(compressed))) {
return new String(gzip.readAllBytes(), StandardCharsets.UTF_8);
}
} catch (Exception ex) {
throw new IllegalStateException("failed to decode transient payload", ex);
}
}
private void deleteLocalPayload(String objectKey) {
try {
Files.deleteIfExists(resolveLocalPayloadPath(objectKey));

View File

@@ -28,11 +28,11 @@
<button type="button" class="btn-run" :disabled="parsing || !uploadedFiles.length" @click="parseFiles">
{{ parsing ? '解析中...' : '解析并创建任务' }}
</button>
<button type="button" class="btn-run btn-queue" :disabled="pushing || !parsedRows.length" @click="pushToPythonQueue">
<button type="button" class="btn-run btn-queue" :disabled="pushing || !parseResult?.taskId" @click="pushToPythonQueue">
{{ pushing ? '推送中...' : '推送到 Python 队列' }}
</button>
</div>
<p class="loading-msg">Python 回传字段按 asin国家url标题提交后端接收分片后按 10 条一批调用 Coze</p>
<p class="loading-msg">Python 回传字段按 asin国家url标题提交后端接收分片后按 10 条一批调用 Coze结果区展示真实 Coze 批次进度</p>
<div v-if="visibleTaskSummary" class="parse-card">
<div>任务 ID{{ visibleTaskSummary.taskId }}</div>
@@ -64,21 +64,6 @@
</div>
<div class="subsection-title">匹配任务</div>
<div class="result-list-wrap preview-wrap">
<div class="result-list-header">
<span>解析结果</span>
<span v-if="parsedRows.length" class="muted">显示首组 {{ previewRows.length }} / {{ parseResult?.groupCount || parsedGroups.length }} {{ parsedRows.length }} </span>
</div>
<div v-if="!parsedRows.length" class="empty-tasks">暂无解析结果</div>
<el-table v-else :data="previewRows" height="120" class="result-table">
<el-table-column prop="displayId" label="ID" width="90" />
<el-table-column prop="asin" label="ASIN" width="130" />
<el-table-column prop="country" label="国家" width="100" />
<el-table-column prop="title" label="标题" min-width="160" show-overflow-tooltip />
<el-table-column prop="url" label="URL" min-width="160" show-overflow-tooltip />
</el-table>
</div>
<div class="result-list-wrap">
<div class="result-list-header">
<span>当前任务</span>
@@ -151,13 +136,13 @@ import {
deleteAppearancePatentTask,
getAppearancePatentDashboard,
getAppearancePatentHistory,
getAppearancePatentQueuePayload,
getAppearancePatentResultDownloadUrl,
getAppearancePatentTaskProgressBatch,
parseAppearancePatent,
type AppearancePatentParsedGroup,
type AppearancePatentParsedPayloadDto,
type AppearancePatentDashboardVo,
type AppearancePatentHistoryItem,
type AppearancePatentParsedRow,
type AppearancePatentParseVo,
type UploadedFileRef,
type UploadFileVo,
@@ -171,7 +156,6 @@ import { createCategorizedTimers } from '@/shared/utils/categorized-timers'
const selectedFileNames = ref<string[]>([])
const uploadedFiles = ref<UploadFileVo[]>([])
const parseResult = ref<AppearancePatentParseVo | null>(null)
const parsedGroups = ref<AppearancePatentParsedGroup[]>([])
type TaskSummary = Pick<AppearancePatentParseVo, 'taskId' | 'totalRows' | 'acceptedRows' | 'groupCount' | 'droppedRows'>
const queuedTaskSummary = ref<TaskSummary | null>(null)
const defaultAiPrompt = `请帮我排查以下亚马逊商品在英国及欧洲地区是否存在知识产权侵权风险。
@@ -202,12 +186,24 @@ const dashboard = ref<AppearancePatentDashboardVo>({
})
const historyItems = ref<AppearancePatentHistoryItem[]>([])
const parsedRows = computed<AppearancePatentParsedRow[]>(() =>
parsedGroups.value.flatMap((group) => group.items || []),
)
const previewRows = computed(() => parsedGroups.value[0]?.items || [])
const visibleTaskSummary = computed(() => parseResult.value || queuedTaskSummary.value)
const currentItems = computed(() => historyItems.value.filter((i) => i.taskId != null && pollingTaskIds.value.includes(i.taskId)))
const pendingParseItem = computed<AppearancePatentHistoryItem | null>(() => {
const result = parseResult.value
if (!result?.taskId) return null
const exists = historyItems.value.some((item) => item.taskId === result.taskId)
if (exists) return null
return {
taskId: result.taskId,
sourceFilename: result.sourceFilename,
rowCount: result.acceptedRows,
taskStatus: 'PENDING',
success: false,
}
})
const currentItems = computed(() => {
const runningItems = historyItems.value.filter((i) => i.taskId != null && pollingTaskIds.value.includes(i.taskId))
return pendingParseItem.value ? [pendingParseItem.value, ...runningItems] : runningItems
})
const historyOnlyItems = computed(() =>
historyItems.value.filter((i) => {
const status = normalizeTaskStatus(i)
@@ -309,7 +305,6 @@ async function selectFiles() {
uploadedFiles.value = files
selectedFileNames.value = files.map((f) => f.relativePath || f.originalFilename || f.fileKey)
parseResult.value = null
parsedGroups.value = []
queuedTaskSummary.value = null
queuePayloadText.value = ''
}
@@ -332,7 +327,6 @@ async function selectFolder() {
uploadedFiles.value = files
selectedFileNames.value = result.items.map((item) => item.relativePath || item.absolutePath)
parseResult.value = null
parsedGroups.value = []
queuedTaskSummary.value = null
queuePayloadText.value = ''
ElMessage.success(`已选择文件夹内 ${result.items.length} 个 Excel 文件`)
@@ -360,19 +354,6 @@ async function parseFiles() {
const res = await parseAppearancePatent(files, effectiveAiPrompt(), effectiveCozeApiKey())
parseResult.value = res
queuedTaskSummary.value = null
parsedGroups.value = res.groups?.length
? res.groups
: (res.items?.length
? [{
sourceFileKey: res.items[0]?.sourceFileKey,
sourceFilename: res.items[0]?.sourceFilename,
groupKey: res.items[0]?.groupKey,
baseId: '',
displayId: res.items[0]?.displayId,
itemCount: res.items.length,
items: res.items,
}]
: [])
queuePayloadText.value = ''
ElMessage.success(`解析完成,共 ${res.groupCount || 0} 组 / ${res.acceptedRows}`)
} catch (e) {
@@ -386,7 +367,7 @@ async function pushToPythonQueue() {
const api = getPywebviewApi()
const currentParseResult = parseResult.value
const taskId = currentParseResult?.taskId
if (!taskId || !parsedRows.value.length) {
if (!taskId) {
ElMessage.warning('请先解析文件')
return
}
@@ -400,14 +381,20 @@ async function pushToPythonQueue() {
}
pushing.value = true
try {
const parsedPayload: AppearancePatentParsedPayloadDto = await getAppearancePatentQueuePayload(taskId)
const groups = parsedPayload.groups || []
const payload = {
type: 'appearance-patent-run',
ts: Date.now(),
data: {
taskId,
prompt: currentParseResult.aiPrompt || effectiveAiPrompt(),
prompt: parsedPayload.aiPrompt || currentParseResult.aiPrompt || effectiveAiPrompt(),
api_key: effectiveCozeApiKey(),
groups: parsedGroups.value.map((group) => ({
sourceFileCount: currentParseResult.sourceFileCount || 0,
totalRows: currentParseResult.totalRows || 0,
acceptedRows: currentParseResult.acceptedRows || 0,
groupCount: currentParseResult.groupCount || 0,
groups: groups.map((group) => ({
sourceFileKey: group.sourceFileKey || '',
sourceFilename: group.sourceFilename || '',
groupKey: group.groupKey || '',
@@ -425,20 +412,9 @@ async function pushToPythonQueue() {
title: row.title || '',
})),
})),
rows: parsedRows.value.map((row) => ({
sourceFileKey: row.sourceFileKey || '',
sourceFilename: row.sourceFilename || '',
rowToken: row.rowToken || '',
groupKey: row.groupKey || '',
id: row.displayId,
asin: row.asin,
country: row.country,
url: row.url || '',
title: row.title || '',
})),
},
}
queuePayloadText.value = JSON.stringify(payloadForDisplay(payload), null, 2)
queuePayloadText.value = `任务 ${taskId} 已准备推送,共 ${groups.length}`
await activateAppearancePatentTask(taskId)
const result = await api.enqueue_json(payload)
if (!result?.success) {
@@ -458,7 +434,6 @@ async function pushToPythonQueue() {
function clearParsedTask() {
parseResult.value = null
parsedGroups.value = []
queuePayloadText.value = ''
}
@@ -681,10 +656,10 @@ function pendingResultHint(item: AppearancePatentHistoryItem) {
if (!isResultPreparing(item)) return ''
const fileStatus = (item.fileStatus || '').toUpperCase()
if (fileStatus === 'RUNNING') {
return '任务已完成,正在请求 Coze 组装结果文件,下载按钮稍后出现'
return '任务已完成,正在等待 Coze 回流并组装结果文件,下载按钮稍后出现'
}
if (fileStatus === 'PENDING') {
return '任务已完成,结果文件已入队,正在等待后端生成'
return '任务已完成,结果文件已入队,正在等待后端开始处理'
}
return '任务已完成,正在生成结果文件,下载按钮稍后出现'
}

View File

@@ -52,7 +52,7 @@
{{ pushing ? '推送中...' : '推送到 Python 队列' }}
</button>
</div>
<p class="loading-msg">Python 回传字段按 idasin国家价格标题图片链接数组提交后端接收分片后按 10 条一批送检</p>
<p class="loading-msg">Python 回传字段按 idasin国家价格标题图片链接数组提交后端接收分片后按 10 条一批送检结果区展示真实 Coze 批次进度</p>
<div v-if="visibleTaskSummary" class="parse-card">
<div>任务 ID{{ visibleTaskSummary.taskId }}</div>
@@ -186,6 +186,7 @@ import {
type SimilarAsinParseVo,
type UploadedFileRef,
type UploadFileVo,
uploadTempFileToJava,
} from '@/shared/api/java-modules'
import { expandBrandFolderRecursive, type BrandExpandFolderItem } from '@/shared/api/brand'
import { getPywebviewApi } from '@/shared/bridges/pywebview'
@@ -326,7 +327,7 @@ async function uploadAppearancePathsToJava(paths: Array<string | BrandExpandFold
async function selectFiles() {
const api = getPywebviewApi()
if (!api?.select_brand_xlsx_files || !api.upload_file_to_java) {
ElMessage.warning('当前环境不支持文件选择或上传')
ElMessage.warning('当前桌面端未提供文件选择或上传能力')
return
}
const paths = await api.select_brand_xlsx_files()
@@ -342,8 +343,8 @@ async function selectFiles() {
async function selectFolder() {
const api = getPywebviewApi()
if (!api?.select_brand_folder) {
ElMessage.warning('当前环境不支持文件夹选择,请在本地客户端中打开')
if (!api?.select_brand_folder || !api.upload_file_to_java) {
ElMessage.warning('当前桌面端未提供文件夹选择或上传能力')
return
}
try {
@@ -721,10 +722,10 @@ function pendingResultHint(item: SimilarAsinHistoryItem) {
if (!isResultPreparing(item)) return ''
const fileStatus = (item.fileStatus || '').toUpperCase()
if (fileStatus === 'RUNNING') {
return '任务已完成,正在请求 Coze 组装结果文件,下载按钮稍后出现'
return '任务已完成,正在等待 Coze 回流并组装结果文件,下载按钮稍后出现'
}
if (fileStatus === 'PENDING') {
return '任务已完成,结果文件已入队,正在等待后端生成'
return '任务已完成,结果文件已入队,正在等待后端开始处理'
}
return '任务已完成,正在生成结果文件,下载按钮稍后出现'
}

View File

@@ -102,6 +102,25 @@ export interface UploadFileVo {
relativePath?: string;
}
export async function uploadTempFileToJava(file: File, relativePath?: string) {
const formData = new FormData()
formData.append('file', file)
if (relativePath) {
formData.append('relativePath', relativePath)
}
const response = await http.post<JavaApiResponse<UploadFileVo>>(
`${JAVA_API_PREFIX}/files/upload`,
formData,
{
headers: {
'Content-Type': 'multipart/form-data',
},
timeout: 120000,
},
)
return response.data
}
export interface DedupeResultItem {
resultId?: number;
sourceFilename: string;
@@ -1452,6 +1471,7 @@ export interface AppearancePatentParsedRow {
groupKey?: string;
asin: string;
country: string;
price?: string;
url?: string;
title?: string;
}
@@ -1479,6 +1499,16 @@ export interface AppearancePatentParseVo {
groups?: AppearancePatentParsedGroup[];
}
export interface AppearancePatentParsedPayloadDto {
aiPrompt?: string;
apiKey?: string;
sourceFiles?: UploadedFileRef[];
headers?: string[];
items?: AppearancePatentParsedRow[];
groups?: AppearancePatentParsedGroup[];
allItems?: AppearancePatentParsedRow[];
}
export interface AppearancePatentDashboardVo {
pendingTaskCount: number;
processedTaskCount: number;
@@ -1545,6 +1575,24 @@ export function parseAppearancePatent(files: UploadedFileRef[], aiPrompt: string
);
}
export function getAppearancePatentParsedPayload(taskId: number) {
return unwrapJavaResponse(
get<JavaApiResponse<AppearancePatentParsedPayloadDto>>(
`${JAVA_API_PREFIX}/appearance-patent/tasks/${taskId}/parsed-payload`,
{ params: { user_id: getCurrentUserId() } },
),
);
}
export function getAppearancePatentQueuePayload(taskId: number) {
return unwrapJavaResponse(
get<JavaApiResponse<AppearancePatentParsedPayloadDto>>(
`${JAVA_API_PREFIX}/appearance-patent/tasks/${taskId}/queue-payload`,
{ params: { user_id: getCurrentUserId() } },
),
);
}
export function getAppearancePatentDashboard() {
return unwrapJavaResponse(
get<JavaApiResponse<AppearancePatentDashboardVo>>(

View File

@@ -5,12 +5,11 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>外观专利检测</title>
<script type="module" crossorigin src="/assets/appearance-patent.js"></script>
<link rel="modulepreload" crossorigin href="/assets/pywebview-RBTI4RuH.js">
<link rel="modulepreload" crossorigin href="/assets/brand-1lGM3Wzk.js">
<link rel="modulepreload" crossorigin href="/assets/pywebview-CcblbV5J.js">
<link rel="modulepreload" crossorigin href="/assets/brand-CEi7uRFN.js">
<link rel="modulepreload" crossorigin href="/assets/categorized-timers-JPA-olTr.js">
<link rel="stylesheet" crossorigin href="/assets/pywebview-C-qhaDJK.css">
<link rel="stylesheet" crossorigin href="/assets/appearance-patent-DaODE9AU.css">
<link rel="stylesheet" crossorigin href="/assets/el-table-column-Cy4YJvw0.css">
<link rel="stylesheet" crossorigin href="/assets/pywebview-DNJAt1gR.css">
<link rel="stylesheet" crossorigin href="/assets/appearance-patent-D7zGgGOj.css">
</head>
<body>
<div id="app"></div>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
import{bb as r}from"./pywebview-CcblbV5J.js";const n="";function s(e){return r(`${n}/api/brand/expand-folder-recursive`,{folder:e})}export{s as e};

View File

@@ -0,0 +1 @@
const r=new Map;function c(n){let t=r.get(n);return t||(t=new Map,r.set(n,t)),t}function u(n,t){const e=r.get(n);e&&(e.delete(t),e.size||r.delete(n))}function l(n,t,e){const i=window.setTimeout(()=>{u(n,i),t()},e);return c(n).set(i,{id:i,kind:"timeout",category:n}),i}function a(n,t,e){const i=window.setInterval(t,e);return c(n).set(i,{id:i,kind:"interval",category:n}),i}function f(n,t){if(t==null)return;const i=r.get(n)?.get(t);i?.kind==="interval"?window.clearInterval(t):window.clearTimeout(t),i?.cancel?.(),u(n,t)}function s(n){const t=r.get(n);if(t){for(const e of t.values())e.kind==="interval"?window.clearInterval(e.id):window.clearTimeout(e.id),e.cancel?.();r.delete(n)}}function d(n,t){return new Promise(e=>{const i=window.setTimeout(()=>{u(n,i),e()},t);c(n).set(i,{id:i,kind:"timeout",category:n,cancel:e})})}function m(n){const t=`${n}:`;for(const e of Array.from(r.keys()))(e===n||e.startsWith(t))&&s(e)}function w(n){const t=e=>`${n}:${e}`;return{setTimeout(e,i,o){return l(t(e),i,o)},setInterval(e,i,o){return a(t(e),i,o)},clearTimer(e,i){f(t(e),i)},clearCategory(e){s(t(e))},clearScope(){m(n)},sleep(e,i){return d(t(e),i)}}}export{w as c};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
const e=[{value:"SearchSuppressed",label:"在搜索结果中禁止显示"},{value:"ApprovalRequired",label:"需要批准"},{value:"Active",label:"在售"},{value:"DetailPageRemoved",label:"详情页面已删除"}];export{e as L};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -5,10 +5,10 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>格式转换 - 数富AI</title>
<script type="module" crossorigin src="/assets/convert.js"></script>
<link rel="modulepreload" crossorigin href="/assets/pywebview-BWNiqXug.js">
<link rel="modulepreload" crossorigin href="/assets/brand-CABpTGPu.js">
<link rel="stylesheet" crossorigin href="/assets/pywebview-BFZO8_3q.css">
<link rel="stylesheet" crossorigin href="/assets/convert-r7MSXq7I.css">
<link rel="modulepreload" crossorigin href="/assets/pywebview-CcblbV5J.js">
<link rel="modulepreload" crossorigin href="/assets/brand-CEi7uRFN.js">
<link rel="stylesheet" crossorigin href="/assets/pywebview-DNJAt1gR.css">
<link rel="stylesheet" crossorigin href="/assets/convert-BKSNvX8i.css">
</head>
<body>
<div id="app"></div>

View File

@@ -5,10 +5,10 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>数据去重 - 数富AI</title>
<script type="module" crossorigin src="/assets/dedupe.js"></script>
<link rel="modulepreload" crossorigin href="/assets/pywebview-BWNiqXug.js">
<link rel="modulepreload" crossorigin href="/assets/brand-CABpTGPu.js">
<link rel="stylesheet" crossorigin href="/assets/pywebview-BFZO8_3q.css">
<link rel="stylesheet" crossorigin href="/assets/dedupe-DMeaPUMZ.css">
<link rel="modulepreload" crossorigin href="/assets/pywebview-CcblbV5J.js">
<link rel="modulepreload" crossorigin href="/assets/brand-CEi7uRFN.js">
<link rel="stylesheet" crossorigin href="/assets/pywebview-DNJAt1gR.css">
<link rel="stylesheet" crossorigin href="/assets/dedupe-DNlVfFj-.css">
</head>
<body>
<div id="app"></div>

View File

@@ -5,11 +5,11 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>删除品牌 - 数富AI</title>
<script type="module" crossorigin src="/assets/delete-brand.js"></script>
<link rel="modulepreload" crossorigin href="/assets/pywebview-BWNiqXug.js">
<link rel="modulepreload" crossorigin href="/assets/brand-CABpTGPu.js">
<link rel="modulepreload" crossorigin href="/assets/pywebview-CcblbV5J.js">
<link rel="modulepreload" crossorigin href="/assets/brand-CEi7uRFN.js">
<link rel="modulepreload" crossorigin href="/assets/categorized-timers-JPA-olTr.js">
<link rel="stylesheet" crossorigin href="/assets/pywebview-BFZO8_3q.css">
<link rel="stylesheet" crossorigin href="/assets/delete-brand-B5ChOXZY.css">
<link rel="stylesheet" crossorigin href="/assets/pywebview-DNJAt1gR.css">
<link rel="stylesheet" crossorigin href="/assets/delete-brand-BJod9K65.css">
</head>
<body>
<div id="app"></div>

View File

@@ -5,12 +5,12 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>巡店删除 - 数富AI</title>
<script type="module" crossorigin src="/assets/patrol-delete.js"></script>
<link rel="modulepreload" crossorigin href="/assets/pywebview-BWNiqXug.js">
<link rel="modulepreload" crossorigin href="/assets/zh-cn-BbuLS91u.js">
<link rel="modulepreload" crossorigin href="/assets/pywebview-CcblbV5J.js">
<link rel="modulepreload" crossorigin href="/assets/zh-cn-DCaAv7Wt.js">
<link rel="modulepreload" crossorigin href="/assets/categorized-timers-JPA-olTr.js">
<link rel="stylesheet" crossorigin href="/assets/pywebview-BFZO8_3q.css">
<link rel="stylesheet" crossorigin href="/assets/patrol-delete-DyumfGaZ.css">
<link rel="stylesheet" crossorigin href="/assets/el-table-column-Cy4YJvw0.css">
<link rel="stylesheet" crossorigin href="/assets/pywebview-DNJAt1gR.css">
<link rel="stylesheet" crossorigin href="/assets/patrol-delete-CBBdonF4.css">
<link rel="stylesheet" crossorigin href="/assets/el-table-column-BoPoyBFZ.css">
<link rel="stylesheet" crossorigin href="/assets/el-input-xaztPnzw.css">
</head>
<body>

View File

@@ -5,12 +5,12 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>跟价 - 数富AI</title>
<script type="module" crossorigin src="/assets/price-track.js"></script>
<link rel="modulepreload" crossorigin href="/assets/pywebview-BWNiqXug.js">
<link rel="modulepreload" crossorigin href="/assets/brand-CABpTGPu.js">
<link rel="modulepreload" crossorigin href="/assets/pywebview-CcblbV5J.js">
<link rel="modulepreload" crossorigin href="/assets/brand-CEi7uRFN.js">
<link rel="modulepreload" crossorigin href="/assets/categorized-timers-JPA-olTr.js">
<link rel="stylesheet" crossorigin href="/assets/pywebview-BFZO8_3q.css">
<link rel="stylesheet" crossorigin href="/assets/price-track-DMhhVz4u.css">
<link rel="stylesheet" crossorigin href="/assets/el-table-column-Cy4YJvw0.css">
<link rel="stylesheet" crossorigin href="/assets/pywebview-DNJAt1gR.css">
<link rel="stylesheet" crossorigin href="/assets/price-track-Hozgt_em.css">
<link rel="stylesheet" crossorigin href="/assets/el-table-column-BoPoyBFZ.css">
<link rel="stylesheet" crossorigin href="/assets/el-input-xaztPnzw.css">
</head>
<body>

View File

@@ -5,13 +5,13 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>商品风险解决 - 数富AI</title>
<script type="module" crossorigin src="/assets/product-risk.js"></script>
<link rel="modulepreload" crossorigin href="/assets/pywebview-BWNiqXug.js">
<link rel="modulepreload" crossorigin href="/assets/pywebview-CcblbV5J.js">
<link rel="modulepreload" crossorigin href="/assets/listingFilters-BpGOU_pJ.js">
<link rel="modulepreload" crossorigin href="/assets/categorized-timers-JPA-olTr.js">
<link rel="stylesheet" crossorigin href="/assets/pywebview-BFZO8_3q.css">
<link rel="stylesheet" crossorigin href="/assets/pywebview-DNJAt1gR.css">
<link rel="stylesheet" crossorigin href="/assets/listingFilters-CK58rX4v.css">
<link rel="stylesheet" crossorigin href="/assets/product-risk-CI6qY5U2.css">
<link rel="stylesheet" crossorigin href="/assets/el-table-column-Cy4YJvw0.css">
<link rel="stylesheet" crossorigin href="/assets/product-risk-DV5AzjVc.css">
<link rel="stylesheet" crossorigin href="/assets/el-table-column-BoPoyBFZ.css">
<link rel="stylesheet" crossorigin href="/assets/el-input-xaztPnzw.css">
</head>
<body>

View File

@@ -5,12 +5,12 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>巡店删除 - 数富AI</title>
<script type="module" crossorigin src="/assets/query-asin.js"></script>
<link rel="modulepreload" crossorigin href="/assets/pywebview-BWNiqXug.js">
<link rel="modulepreload" crossorigin href="/assets/zh-cn-BbuLS91u.js">
<link rel="modulepreload" crossorigin href="/assets/pywebview-CcblbV5J.js">
<link rel="modulepreload" crossorigin href="/assets/zh-cn-DCaAv7Wt.js">
<link rel="modulepreload" crossorigin href="/assets/categorized-timers-JPA-olTr.js">
<link rel="stylesheet" crossorigin href="/assets/pywebview-BFZO8_3q.css">
<link rel="stylesheet" crossorigin href="/assets/query-asin-Bw6BQsRN.css">
<link rel="stylesheet" crossorigin href="/assets/el-table-column-Cy4YJvw0.css">
<link rel="stylesheet" crossorigin href="/assets/pywebview-DNJAt1gR.css">
<link rel="stylesheet" crossorigin href="/assets/query-asin-B4RsOiza.css">
<link rel="stylesheet" crossorigin href="/assets/el-table-column-BoPoyBFZ.css">
<link rel="stylesheet" crossorigin href="/assets/el-input-xaztPnzw.css">
</head>
<body>

View File

@@ -5,14 +5,14 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>匹配店铺 - 数富AI</title>
<script type="module" crossorigin src="/assets/shop-match.js"></script>
<link rel="modulepreload" crossorigin href="/assets/pywebview-BWNiqXug.js">
<link rel="modulepreload" crossorigin href="/assets/zh-cn-BbuLS91u.js">
<link rel="modulepreload" crossorigin href="/assets/pywebview-CcblbV5J.js">
<link rel="modulepreload" crossorigin href="/assets/zh-cn-DCaAv7Wt.js">
<link rel="modulepreload" crossorigin href="/assets/listingFilters-BpGOU_pJ.js">
<link rel="modulepreload" crossorigin href="/assets/categorized-timers-JPA-olTr.js">
<link rel="stylesheet" crossorigin href="/assets/pywebview-BFZO8_3q.css">
<link rel="stylesheet" crossorigin href="/assets/pywebview-DNJAt1gR.css">
<link rel="stylesheet" crossorigin href="/assets/listingFilters-CK58rX4v.css">
<link rel="stylesheet" crossorigin href="/assets/shop-match-CxCKUsqS.css">
<link rel="stylesheet" crossorigin href="/assets/el-table-column-Cy4YJvw0.css">
<link rel="stylesheet" crossorigin href="/assets/shop-match-B2HWAgBY.css">
<link rel="stylesheet" crossorigin href="/assets/el-table-column-BoPoyBFZ.css">
<link rel="stylesheet" crossorigin href="/assets/el-input-xaztPnzw.css">
</head>
<body>

View File

@@ -3,14 +3,14 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>相似ASIN检测</title>
<title>货源查询</title>
<script type="module" crossorigin src="/assets/similar-asin.js"></script>
<link rel="modulepreload" crossorigin href="/assets/pywebview-RBTI4RuH.js">
<link rel="modulepreload" crossorigin href="/assets/brand-1lGM3Wzk.js">
<link rel="modulepreload" crossorigin href="/assets/pywebview-CcblbV5J.js">
<link rel="modulepreload" crossorigin href="/assets/brand-CEi7uRFN.js">
<link rel="modulepreload" crossorigin href="/assets/categorized-timers-JPA-olTr.js">
<link rel="stylesheet" crossorigin href="/assets/pywebview-C-qhaDJK.css">
<link rel="stylesheet" crossorigin href="/assets/similar-asin-B2xrJRis.css">
<link rel="stylesheet" crossorigin href="/assets/el-table-column-Cy4YJvw0.css">
<link rel="stylesheet" crossorigin href="/assets/pywebview-DNJAt1gR.css">
<link rel="stylesheet" crossorigin href="/assets/similar-asin-CVTb4S1E.css">
<link rel="stylesheet" crossorigin href="/assets/el-table-column-BoPoyBFZ.css">
</head>
<body>
<div id="app"></div>

View File

@@ -5,10 +5,10 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>数据拆分 - 数富AI</title>
<script type="module" crossorigin src="/assets/split.js"></script>
<link rel="modulepreload" crossorigin href="/assets/pywebview-BWNiqXug.js">
<link rel="modulepreload" crossorigin href="/assets/brand-CABpTGPu.js">
<link rel="stylesheet" crossorigin href="/assets/pywebview-BFZO8_3q.css">
<link rel="stylesheet" crossorigin href="/assets/split-Cw6sXhAg.css">
<link rel="modulepreload" crossorigin href="/assets/pywebview-CcblbV5J.js">
<link rel="modulepreload" crossorigin href="/assets/brand-CEi7uRFN.js">
<link rel="stylesheet" crossorigin href="/assets/pywebview-DNJAt1gR.css">
<link rel="stylesheet" crossorigin href="/assets/split-BoBVrLdC.css">
</head>
<body>
<div id="app"></div>