后台管理跳过asin、后台相关数据做数据隔离
This commit is contained in:
4
app/.env
4
app/.env
@@ -13,7 +13,7 @@ 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://8.136.19.173:18080
|
||||
java_api_base=http://127.0.0.1:18080
|
||||
# java_api_base=http://8.136.19.173:18080
|
||||
|
||||
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -21,6 +21,12 @@ public class DeleteBrandProgressProperties {
|
||||
private long productRiskStaleTimeoutMinutes = 10;
|
||||
private long productRiskInitialTimeoutMinutes = 10;
|
||||
|
||||
/**
|
||||
* Price track RUNNING timeout auto-finalize/fail threshold in minutes.
|
||||
*/
|
||||
private long priceTrackStaleTimeoutMinutes = 10;
|
||||
private long priceTrackInitialTimeoutMinutes = 10;
|
||||
|
||||
/**
|
||||
* Shop match RUNNING timeout auto-finalize/fail threshold in minutes.
|
||||
*/
|
||||
|
||||
@@ -11,5 +11,5 @@ import java.util.List;
|
||||
public class ModuleCleanupProperties {
|
||||
private boolean enabled = true;
|
||||
private String cron = "0 0 0 * * *";
|
||||
private List<String> moduleTypes = new ArrayList<>(List.of("DEDUPE", "SPLIT", "CONVERT", "DELETE_BRAND", "PRODUCT_RISK_RESOLVE"));
|
||||
private List<String> moduleTypes = new ArrayList<>(List.of("DEDUPE", "SPLIT", "CONVERT", "DELETE_BRAND", "PRODUCT_RISK_RESOLVE", "PRICE_TRACK"));
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ 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.productrisk.service.ProductRiskTaskService;
|
||||
import com.nanri.aiimage.modules.pricetrack.service.PriceTrackTaskCacheService;
|
||||
import com.nanri.aiimage.modules.pricetrack.service.PriceTrackTaskService;
|
||||
import com.nanri.aiimage.modules.shopmatch.service.ShopMatchTaskCacheService;
|
||||
import com.nanri.aiimage.modules.shopmatch.service.ShopMatchTaskService;
|
||||
import com.nanri.aiimage.modules.task.mapper.FileTaskMapper;
|
||||
@@ -28,6 +30,7 @@ public class DeleteBrandStaleTaskService {
|
||||
|
||||
private static final String MODULE_TYPE_DELETE_BRAND = "DELETE_BRAND";
|
||||
private static final String MODULE_TYPE_PRODUCT_RISK = "PRODUCT_RISK_RESOLVE";
|
||||
private static final String MODULE_TYPE_PRICE_TRACK = "PRICE_TRACK";
|
||||
private static final String MODULE_TYPE_SHOP_MATCH = "SHOP_MATCH";
|
||||
private static final Duration STALE_CHECK_LOCK_TTL = Duration.ofMinutes(10);
|
||||
private static final Duration FINALIZE_CHECK_LOCK_TTL = Duration.ofMinutes(10);
|
||||
@@ -37,6 +40,8 @@ public class DeleteBrandStaleTaskService {
|
||||
private final DeleteBrandRunService deleteBrandRunService;
|
||||
private final ProductRiskTaskService productRiskTaskService;
|
||||
private final ProductRiskTaskCacheService productRiskTaskCacheService;
|
||||
private final PriceTrackTaskService priceTrackTaskService;
|
||||
private final PriceTrackTaskCacheService priceTrackTaskCacheService;
|
||||
private final ShopMatchTaskService shopMatchTaskService;
|
||||
private final ShopMatchTaskCacheService shopMatchTaskCacheService;
|
||||
private final DeleteBrandProgressProperties deleteBrandProgressProperties;
|
||||
@@ -55,6 +60,7 @@ public class DeleteBrandStaleTaskService {
|
||||
log.info("[stale-check] scan started thread={}", Thread.currentThread().getName());
|
||||
failStaleDeleteBrandTasks();
|
||||
ProductRiskStaleCheckStats stats = failStaleProductRiskResolveTasks();
|
||||
PriceTrackStaleCheckStats priceTrackStats = failStalePriceTrackTasks();
|
||||
ShopMatchStaleCheckStats shopMatchStats = failStaleShopMatchTasks();
|
||||
log.info("[stale-check] product-risk summary scanned={} finalized={} failed={} skipped={} elapsedMs={} thread={}",
|
||||
stats.scannedTaskCount,
|
||||
@@ -63,6 +69,13 @@ public class DeleteBrandStaleTaskService {
|
||||
stats.skippedTaskCount,
|
||||
System.currentTimeMillis() - startedAt,
|
||||
Thread.currentThread().getName());
|
||||
log.info("[stale-check] price-track summary scanned={} finalized={} failed={} skipped={} elapsedMs={} thread={}",
|
||||
priceTrackStats.scannedTaskCount,
|
||||
priceTrackStats.finalizedTaskCount,
|
||||
priceTrackStats.failedTaskCount,
|
||||
priceTrackStats.skippedTaskCount,
|
||||
System.currentTimeMillis() - startedAt,
|
||||
Thread.currentThread().getName());
|
||||
log.info("[stale-check] shop-match summary scanned={} finalized={} failed={} skipped={} elapsedMs={} thread={}",
|
||||
shopMatchStats.scannedTaskCount,
|
||||
shopMatchStats.finalizedTaskCount,
|
||||
@@ -196,6 +209,67 @@ public class DeleteBrandStaleTaskService {
|
||||
return stats;
|
||||
}
|
||||
|
||||
private PriceTrackStaleCheckStats failStalePriceTrackTasks() {
|
||||
PriceTrackStaleCheckStats stats = new PriceTrackStaleCheckStats();
|
||||
long minutes = Math.max(1L, deleteBrandProgressProperties.getPriceTrackStaleTimeoutMinutes());
|
||||
long initialMinutes = Math.max(minutes, deleteBrandProgressProperties.getPriceTrackInitialTimeoutMinutes());
|
||||
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_PRICE_TRACK)
|
||||
.eq(FileTaskEntity::getStatus, "RUNNING")
|
||||
.lt(FileTaskEntity::getUpdatedAt, threshold)
|
||||
.last("limit 200"));
|
||||
stats.scannedTaskCount = runningTasks.size();
|
||||
if (runningTasks.isEmpty()) {
|
||||
return stats;
|
||||
}
|
||||
log.info("[stale-check] price-track candidates={} threshold={} timeoutMinutes={} initialThreshold={} initialTimeoutMinutes={}",
|
||||
runningTasks.size(), threshold, minutes, initialThreshold, initialMinutes);
|
||||
for (FileTaskEntity task : runningTasks) {
|
||||
long lastHeartbeatMillis = priceTrackTaskCacheService.getTaskHeartbeatMillis(task.getId());
|
||||
boolean hasFinishedRows = (task.getSuccessFileCount() != null && task.getSuccessFileCount() > 0)
|
||||
|| (task.getFailedFileCount() != null && task.getFailedFileCount() > 0);
|
||||
boolean hasStartedProgress = lastHeartbeatMillis > 0L || hasFinishedRows;
|
||||
if (lastHeartbeatMillis > 0L && nowMillis - lastHeartbeatMillis < staleTimeoutMillis) {
|
||||
stats.skippedTaskCount++;
|
||||
continue;
|
||||
}
|
||||
if (!hasStartedProgress && task.getCreatedAt() != null && task.getCreatedAt().isAfter(initialThreshold)) {
|
||||
stats.skippedTaskCount++;
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
if (priceTrackTaskService.tryFinalizeTask(task.getId(), true)) {
|
||||
stats.finalizedTaskCount++;
|
||||
log.info("[stale-check] price-track finalized taskId={}", task.getId());
|
||||
continue;
|
||||
}
|
||||
} catch (Exception ex) {
|
||||
log.warn("[stale-check] price-track 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_PRICE_TRACK)
|
||||
.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++;
|
||||
priceTrackTaskCacheService.deleteTaskCache(task.getId());
|
||||
log.warn("[stale-check] price-track failed taskId={} reason=timeout", task.getId());
|
||||
} else {
|
||||
stats.skippedTaskCount++;
|
||||
}
|
||||
}
|
||||
return stats;
|
||||
}
|
||||
|
||||
private ShopMatchStaleCheckStats failStaleShopMatchTasks() {
|
||||
ShopMatchStaleCheckStats stats = new ShopMatchStaleCheckStats();
|
||||
long minutes = Math.max(1L, deleteBrandProgressProperties.getShopMatchStaleTimeoutMinutes());
|
||||
@@ -291,4 +365,11 @@ public class DeleteBrandStaleTaskService {
|
||||
private int failedTaskCount;
|
||||
private int skippedTaskCount;
|
||||
}
|
||||
|
||||
private static final class PriceTrackStaleCheckStats {
|
||||
private int scannedTaskCount;
|
||||
private int finalizedTaskCount;
|
||||
private int failedTaskCount;
|
||||
private int skippedTaskCount;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,16 +2,19 @@ package com.nanri.aiimage.modules.pricetrack.controller;
|
||||
|
||||
import com.nanri.aiimage.common.api.ApiResponse;
|
||||
import com.nanri.aiimage.modules.pricetrack.model.dto.PriceTrackCandidateAddRequest;
|
||||
import com.nanri.aiimage.modules.pricetrack.model.dto.PriceTrackCountryPreferenceSaveRequest;
|
||||
import com.nanri.aiimage.modules.pricetrack.model.dto.PriceTrackCreateTaskRequest;
|
||||
import com.nanri.aiimage.modules.pricetrack.model.dto.PriceTrackMatchShopsRequest;
|
||||
import com.nanri.aiimage.modules.pricetrack.model.dto.PriceTrackSubmitResultRequest;
|
||||
import com.nanri.aiimage.modules.pricetrack.model.dto.PriceTrackCountryPreferenceSaveRequest;
|
||||
import com.nanri.aiimage.modules.pricetrack.model.dto.PriceTrackTaskBatchRequest;
|
||||
import com.nanri.aiimage.modules.pricetrack.model.vo.PriceTrackCandidateVo;
|
||||
import com.nanri.aiimage.modules.pricetrack.model.vo.PriceTrackCountryPreferenceVo;
|
||||
import com.nanri.aiimage.modules.pricetrack.model.vo.PriceTrackCreateTaskVo;
|
||||
import com.nanri.aiimage.modules.pricetrack.model.vo.PriceTrackDashboardVo;
|
||||
import com.nanri.aiimage.modules.pricetrack.model.vo.PriceTrackHistoryVo;
|
||||
import com.nanri.aiimage.modules.pricetrack.model.vo.PriceTrackMatchShopsVo;
|
||||
import com.nanri.aiimage.modules.pricetrack.model.vo.PriceTrackPendingDeleteVo;
|
||||
import com.nanri.aiimage.modules.pricetrack.model.vo.PriceTrackTaskBatchVo;
|
||||
import com.nanri.aiimage.modules.pricetrack.service.PriceTrackService;
|
||||
import com.nanri.aiimage.modules.pricetrack.service.PriceTrackTaskService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
@@ -20,127 +23,164 @@ import io.swagger.v3.oas.annotations.enums.ParameterIn;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.net.URI;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.net.URLEncoder;
|
||||
import java.io.InputStream;
|
||||
import java.net.URI;
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@RequestMapping("/api/price-track")
|
||||
@Tag(name = "跟价", description = "亚马逊跟价处理:备选店铺、紫鸟匹配、创建任务、Python队列与回传。")
|
||||
@Tag(
|
||||
name = "跟价",
|
||||
description = "亚马逊跟价处理模块:包含备选店铺、紫鸟匹配、任务创建、Python 回传、任务轮询和结果下载接口。查询类接口需要携带 Query 参数 user_id。")
|
||||
public class PriceTrackController {
|
||||
|
||||
private final PriceTrackService priceTrackService;
|
||||
private final PriceTrackTaskService priceTrackTaskService;
|
||||
|
||||
@GetMapping("/candidates")
|
||||
@Operation(summary = "查询备选店铺列表")
|
||||
public ApiResponse<java.util.List<PriceTrackCandidateVo>> listCandidates(
|
||||
@Parameter(name = "user_id", description = "当前用户 ID", required = true, in = ParameterIn.QUERY)
|
||||
@Operation(summary = "查询备选店铺列表", description = "返回当前用户在跟价模块中保存的备选店铺。")
|
||||
public ApiResponse<List<PriceTrackCandidateVo>> listCandidates(
|
||||
@Parameter(name = "user_id", description = "当前用户 ID", required = true, in = ParameterIn.QUERY, example = "1")
|
||||
@RequestParam("user_id") Long userId) {
|
||||
return ApiResponse.success(priceTrackService.listCandidates(userId));
|
||||
}
|
||||
|
||||
@PostMapping("/candidates")
|
||||
@Operation(summary = "新增备选店铺")
|
||||
@Operation(summary = "新增备选店铺", description = "把店铺名称写入当前用户的备选店铺列表,供后续匹配和创建任务使用。")
|
||||
public ApiResponse<PriceTrackCandidateVo> addCandidate(@Valid @RequestBody PriceTrackCandidateAddRequest request) {
|
||||
return ApiResponse.success(priceTrackService.addCandidate(request));
|
||||
}
|
||||
|
||||
@DeleteMapping("/candidates/{id}")
|
||||
@Operation(summary = "删除备选店铺")
|
||||
@Operation(summary = "删除备选店铺", description = "删除一条备选店铺记录,id 必须属于当前 user_id。")
|
||||
public ApiResponse<Void> deleteCandidate(
|
||||
@PathVariable Long id,
|
||||
@Parameter(name = "user_id", required = true, in = ParameterIn.QUERY) @RequestParam("user_id") Long userId) {
|
||||
@Parameter(description = "备选店铺记录主键", example = "10") @PathVariable Long id,
|
||||
@Parameter(name = "user_id", description = "当前用户 ID", required = true, in = ParameterIn.QUERY, example = "1")
|
||||
@RequestParam("user_id") Long userId) {
|
||||
priceTrackService.deleteCandidate(userId, id);
|
||||
return ApiResponse.success(null);
|
||||
}
|
||||
|
||||
@GetMapping("/country-preference")
|
||||
@Operation(summary = "查询国家处理顺序")
|
||||
@Operation(summary = "查询国家处理顺序", description = "返回当前用户保存的国家代码列表,顺序即跟价任务处理顺序。")
|
||||
public ApiResponse<PriceTrackCountryPreferenceVo> getCountryPreference(
|
||||
@Parameter(name = "user_id", required = true, in = ParameterIn.QUERY) @RequestParam("user_id") Long userId) {
|
||||
@Parameter(name = "user_id", description = "当前用户 ID", required = true, in = ParameterIn.QUERY, example = "1")
|
||||
@RequestParam("user_id") Long userId) {
|
||||
return ApiResponse.success(priceTrackService.getCountryPreference(userId));
|
||||
}
|
||||
|
||||
@PutMapping("/country-preference")
|
||||
@Operation(summary = "保存国家处理顺序")
|
||||
@Operation(summary = "保存国家处理顺序", description = "保存当前用户在跟价模块中的国家勾选和处理顺序。")
|
||||
public ApiResponse<PriceTrackCountryPreferenceVo> saveCountryPreference(
|
||||
@Valid @RequestBody PriceTrackCountryPreferenceSaveRequest request) {
|
||||
return ApiResponse.success(priceTrackService.saveCountryPreference(request));
|
||||
}
|
||||
|
||||
@PostMapping("/match-shops")
|
||||
@Operation(summary = "批量匹配店铺")
|
||||
@Operation(summary = "批量匹配店铺", description = "按店铺名查询紫鸟索引,返回 shopId、platform、companyName、matchStatus 等信息。")
|
||||
public ApiResponse<PriceTrackMatchShopsVo> matchShops(@Valid @RequestBody PriceTrackMatchShopsRequest request) {
|
||||
return ApiResponse.success(priceTrackService.matchShops(request));
|
||||
}
|
||||
|
||||
@GetMapping("/dashboard")
|
||||
@Operation(summary = "统计看板")
|
||||
@Operation(summary = "统计看板", description = "返回备选店铺数、已结束任务数、成功任务数和失败任务数。")
|
||||
public ApiResponse<PriceTrackDashboardVo> dashboard(
|
||||
@Parameter(name = "user_id", required = true, in = ParameterIn.QUERY) @RequestParam("user_id") Long userId) {
|
||||
@Parameter(name = "user_id", description = "当前用户 ID", required = true, in = ParameterIn.QUERY, example = "1")
|
||||
@RequestParam("user_id") Long userId) {
|
||||
return ApiResponse.success(priceTrackTaskService.dashboard(userId));
|
||||
}
|
||||
|
||||
@GetMapping("/history")
|
||||
@Operation(summary = "处理记录列表")
|
||||
@Operation(summary = "处理记录列表", description = "返回当前用户跟价模块下各店铺结果行,包含运行中和历史记录。")
|
||||
public ApiResponse<PriceTrackHistoryVo> history(
|
||||
@Parameter(name = "user_id", required = true, in = ParameterIn.QUERY) @RequestParam("user_id") Long userId) {
|
||||
@Parameter(name = "user_id", description = "当前用户 ID", required = true, in = ParameterIn.QUERY, example = "1")
|
||||
@RequestParam("user_id") Long userId) {
|
||||
return ApiResponse.success(priceTrackTaskService.listHistory(userId));
|
||||
}
|
||||
|
||||
@DeleteMapping("/history/{resultId}")
|
||||
@Operation(summary = "删除单条结果记录")
|
||||
@Operation(summary = "删除单条结果记录", description = "删除一条 biz_file_result,并同步重算父任务状态。")
|
||||
public ApiResponse<Void> deleteHistory(
|
||||
@PathVariable Long resultId,
|
||||
@Parameter(name = "user_id", required = true, in = ParameterIn.QUERY) @RequestParam("user_id") Long userId) {
|
||||
@Parameter(description = "结果行主键 biz_file_result.id", example = "1001") @PathVariable Long resultId,
|
||||
@Parameter(name = "user_id", description = "当前用户 ID", required = true, in = ParameterIn.QUERY, example = "1")
|
||||
@RequestParam("user_id") Long userId) {
|
||||
priceTrackTaskService.deleteHistory(resultId, userId);
|
||||
return ApiResponse.success(null);
|
||||
}
|
||||
|
||||
@PostMapping("/tasks")
|
||||
@Operation(summary = "创建跟价处理任务")
|
||||
@Operation(summary = "创建跟价任务", description = "落库 RUNNING 任务和各店铺占位结果,返回 taskId 和初始 items 快照。")
|
||||
public ApiResponse<PriceTrackCreateTaskVo> createTask(@Valid @RequestBody PriceTrackCreateTaskRequest request) {
|
||||
return ApiResponse.success(priceTrackTaskService.createTask(request));
|
||||
}
|
||||
|
||||
@DeleteMapping("/tasks/{taskId}")
|
||||
@Operation(summary = "删除整条任务")
|
||||
@Operation(summary = "删除整条任务", description = "删除该任务以及其下属全部结果行,RUNNING、SUCCESS、FAILED 均可。")
|
||||
public ApiResponse<Void> deleteTask(
|
||||
@PathVariable Long taskId,
|
||||
@Parameter(name = "user_id", required = true, in = ParameterIn.QUERY) @RequestParam("user_id") Long userId) {
|
||||
@Parameter(description = "任务主键 biz_file_task.id", example = "200") @PathVariable Long taskId,
|
||||
@Parameter(name = "user_id", description = "当前用户 ID", required = true, in = ParameterIn.QUERY, example = "1")
|
||||
@RequestParam("user_id") Long userId) {
|
||||
priceTrackTaskService.deleteTask(taskId, userId);
|
||||
return ApiResponse.success(null);
|
||||
}
|
||||
|
||||
@DeleteMapping("/pending-shop-result")
|
||||
@Operation(summary = "按店铺名删除运行中结果", description = "仅在 RUNNING 任务下按规范化店名删除一条 biz_file_result,并同步重算任务状态。")
|
||||
public ApiResponse<PriceTrackPendingDeleteVo> deletePendingShopResult(
|
||||
@Parameter(name = "user_id", description = "当前用户 ID", required = true, in = ParameterIn.QUERY, example = "1")
|
||||
@RequestParam("user_id") Long userId,
|
||||
@Parameter(name = "shop_name", description = "店铺名称", required = true, in = ParameterIn.QUERY, example = "某某店铺")
|
||||
@RequestParam("shop_name") String shopName) {
|
||||
return ApiResponse.success(priceTrackTaskService.deletePendingShopResult(userId, shopName));
|
||||
}
|
||||
|
||||
@PostMapping("/tasks/batch")
|
||||
@Operation(summary = "批量查询任务详情", description = "按 taskIds 返回任务概要和各店铺结果,供前端轮询使用。")
|
||||
public ApiResponse<PriceTrackTaskBatchVo> tasksBatch(@Valid @RequestBody PriceTrackTaskBatchRequest request) {
|
||||
return ApiResponse.success(priceTrackTaskService.getTaskDetailsBatch(request.getTaskIds()));
|
||||
}
|
||||
|
||||
@PostMapping("/tasks/{taskId}/result")
|
||||
@Operation(summary = "Python回传处理结果")
|
||||
public ApiResponse<Void> submitResult(@PathVariable Long taskId,
|
||||
@Operation(summary = "Python 回传处理结果", description = "同一 taskId 可多次回传。成功时按 countries 生成结果文件,所有店铺进入终态后任务会变为 SUCCESS 或 FAILED。")
|
||||
public ApiResponse<Void> submitResult(
|
||||
@Parameter(description = "任务主键,必须处于 RUNNING 状态", example = "200") @PathVariable Long taskId,
|
||||
@Valid @RequestBody PriceTrackSubmitResultRequest request) {
|
||||
priceTrackTaskService.submitResult(taskId, request);
|
||||
return ApiResponse.success(null);
|
||||
}
|
||||
|
||||
@GetMapping("/results/{resultId}/download")
|
||||
@Operation(summary = "下载结果文件")
|
||||
@Operation(summary = "下载结果文件", description = "按结果记录下载后端生成的跟价 Excel 文件。")
|
||||
public void downloadResult(
|
||||
@PathVariable Long resultId,
|
||||
@Parameter(name = "user_id", required = true, in = ParameterIn.QUERY) @RequestParam("user_id") Long userId,
|
||||
@Parameter(description = "结果记录主键 biz_file_result.id", example = "1001") @PathVariable Long resultId,
|
||||
@Parameter(name = "user_id", description = "当前用户 ID", required = true, in = ParameterIn.QUERY, example = "1")
|
||||
@RequestParam("user_id") Long userId,
|
||||
jakarta.servlet.http.HttpServletResponse response) {
|
||||
String url = priceTrackTaskService.resolveResultDownloadUrl(resultId, userId);
|
||||
String filename = priceTrackTaskService.resolveResultDownloadFilename(resultId, userId);
|
||||
if (url == null || url.isBlank()) {
|
||||
throw new ResponseStatusException(org.springframework.http.HttpStatus.NOT_FOUND, "暂无可下载结果");
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "暂无可下载结果");
|
||||
}
|
||||
try {
|
||||
String encodedFilename = URLEncoder.encode(filename, StandardCharsets.UTF_8).replace("+", "%20");
|
||||
response.setContentType("application/octet-stream");
|
||||
response.setHeader(org.springframework.http.HttpHeaders.CONTENT_DISPOSITION,
|
||||
response.setHeader(HttpHeaders.CONTENT_DISPOSITION,
|
||||
"attachment; filename=\"" + filename + "\"; filename*=UTF-8''" + encodedFilename);
|
||||
try (InputStream in = URI.create(url).toURL().openStream()) {
|
||||
byte[] buffer = new byte[65536];
|
||||
@@ -151,7 +191,7 @@ public class PriceTrackController {
|
||||
response.getOutputStream().flush();
|
||||
}
|
||||
} catch (Exception ex) {
|
||||
throw new ResponseStatusException(org.springframework.http.HttpStatus.INTERNAL_SERVER_ERROR, "下载失败");
|
||||
throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "下载失败");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
package com.nanri.aiimage.modules.pricetrack.model.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@Schema(description = "新增备选店铺请求体:写入用户维度的待处理店铺列表,供前端勾选与匹配")
|
||||
public class PriceTrackCandidateAddRequest {
|
||||
@NotNull(message = "user_id不能为空")
|
||||
@Schema(description = "当前用户 ID,数据按用户隔离", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
|
||||
private Long userId;
|
||||
|
||||
@NotBlank(message = "店铺名不能为空")
|
||||
@NotBlank(message = "店铺名称不能为空")
|
||||
@Schema(description = "店铺名称,入库前会先做规范化和去重", requiredMode = Schema.RequiredMode.REQUIRED, example = "测试店铺A")
|
||||
private String shopName;
|
||||
}
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
package com.nanri.aiimage.modules.pricetrack.model.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@Schema(description = "保存用户五国处理顺序:代码仅支持 DE、UK、FR、IT、ES,且不能重复")
|
||||
public class PriceTrackCountryPreferenceSaveRequest {
|
||||
@NotNull(message = "user_id不能为空")
|
||||
@Schema(description = "当前用户 ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
|
||||
private Long userId;
|
||||
|
||||
@NotEmpty(message = "国家列表不能为空")
|
||||
@Schema(description = "国家代码列表,顺序即任务处理顺序", requiredMode = Schema.RequiredMode.REQUIRED, example = "[\"DE\",\"UK\",\"FR\"]")
|
||||
private List<String> countryCodes;
|
||||
}
|
||||
|
||||
@@ -1,35 +1,36 @@
|
||||
package com.nanri.aiimage.modules.pricetrack.model.dto;
|
||||
|
||||
import com.nanri.aiimage.modules.pricetrack.model.vo.PriceTrackMatchShopsVo;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Data
|
||||
@Schema(description = "创建跟价任务请求体:在推送 Python 队列前调用,用于落库任务和店铺结果占位记录")
|
||||
public class PriceTrackCreateTaskRequest {
|
||||
@NotNull(message = "user_id不能为空")
|
||||
@Schema(description = "任务所属用户 ID,会写入 biz_file_task.user_id", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
|
||||
private Long userId;
|
||||
|
||||
/** 跟价模式:status_mode=按商品状态(全量), asin_mode=按指定ASIN(自定义) */
|
||||
@Schema(description = "是否启用按商品状态处理模式")
|
||||
private boolean statusMode;
|
||||
|
||||
@Schema(description = "是否启用按指定 ASIN 文件处理模式")
|
||||
private boolean asinMode;
|
||||
|
||||
/** 匹配好的店铺队列项 */
|
||||
@NotEmpty(message = "店铺列表不能为空")
|
||||
private List<Map<String, Object>> items;
|
||||
@Valid
|
||||
@Schema(description = "已匹配的店铺列表,每项都应为 matched=true;服务端会按规范化店名去重", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private List<PriceTrackMatchShopsVo.PriceTrackShopQueueItem> items;
|
||||
|
||||
/** ASIN文档路径(asinMode时使用) */
|
||||
@Schema(description = "ASIN 文件路径列表;asinMode=true 时由前端上传或选择后传入", example = "[\"/tmp/a.xlsx\",\"/tmp/b.csv\"]")
|
||||
private List<String> asinFiles;
|
||||
|
||||
/** 国家代码列表 */
|
||||
@NotEmpty(message = "国家列表不能为空")
|
||||
@Schema(description = "国家代码列表,顺序即跟价处理顺序", requiredMode = Schema.RequiredMode.REQUIRED, example = "[\"DE\",\"UK\",\"FR\"]")
|
||||
private List<String> countryCodes;
|
||||
|
||||
/** 价格区间 */
|
||||
private Double minPrice;
|
||||
private Double maxPrice;
|
||||
|
||||
/** 价差比例(%) */
|
||||
private Double priceDiffPercent;
|
||||
}
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
package com.nanri.aiimage.modules.pricetrack.model.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@Schema(description = "批量匹配店铺请求体:按店铺名查询紫鸟索引,返回是否命中及店铺元数据")
|
||||
public class PriceTrackMatchShopsRequest {
|
||||
@NotNull(message = "user_id不能为空")
|
||||
@Schema(description = "当前用户 ID,用于审计和数据归属", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
|
||||
private Long userId;
|
||||
|
||||
@NotEmpty(message = "店铺列表不能为空")
|
||||
@Schema(description = "待匹配的店铺名称列表,至少 1 个;返回体 items 与列表顺序对应", requiredMode = Schema.RequiredMode.REQUIRED, example = "[\"店铺甲\",\"店铺乙\"]")
|
||||
private List<String> shopNames;
|
||||
}
|
||||
|
||||
@@ -1,27 +1,49 @@
|
||||
package com.nanri.aiimage.modules.pricetrack.model.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Data
|
||||
@Schema(description = "Python 回传跟价结果请求体:按店铺提交各国家 ASIN 跟价结果或错误信息")
|
||||
public class PriceTrackSubmitResultRequest {
|
||||
@Schema(description = "本次回传涉及的店铺列表,至少 1 个,店名需要能和任务结果行匹配", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private List<ShopResult> shops;
|
||||
|
||||
@Data
|
||||
@Schema(description = "单个店铺回传结果:成功时提供 countries,失败时可只提供 error")
|
||||
public static class ShopResult {
|
||||
@Schema(description = "店铺名称,用于和任务内结果行匹配", example = "测试店铺A")
|
||||
private String shopName;
|
||||
|
||||
@Schema(description = "按国家分组的结果行,key 为国家代码,例如 DE、UK、FR、IT、ES")
|
||||
private Map<String, List<AsinResult>> countries;
|
||||
|
||||
@Schema(description = "失败时的错误信息;非空则该店铺记为失败")
|
||||
private String error;
|
||||
|
||||
@Schema(description = "是否成功;false 按失败处理,true 或 null 通常表示结合 countries 判定")
|
||||
private Boolean success;
|
||||
}
|
||||
|
||||
@Data
|
||||
@Schema(description = "单条 ASIN 跟价结果")
|
||||
public static class AsinResult {
|
||||
@Schema(description = "ASIN", example = "B0ABCDE123")
|
||||
private String asin;
|
||||
|
||||
@Schema(description = "当前店铺价格", example = "19.99")
|
||||
private String currentPrice;
|
||||
|
||||
@Schema(description = "竞争对手价格", example = "18.50")
|
||||
private String competitorPrice;
|
||||
|
||||
@Schema(description = "建议动作或跟价动作", example = "LOWER_PRICE")
|
||||
private String action;
|
||||
|
||||
@Schema(description = "该行是否已处理完成")
|
||||
private Boolean done;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.nanri.aiimage.modules.pricetrack.model.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@Schema(description = "批量查询任务详情请求体:一次拉取多个 taskId 的概要和下属店铺结果,供轮询使用")
|
||||
public class PriceTrackTaskBatchRequest {
|
||||
@NotEmpty(message = "taskIds不能为空")
|
||||
@Schema(description = "待查询的任务主键列表,无效或非本模块 taskId 会出现在 missingTaskIds", requiredMode = Schema.RequiredMode.REQUIRED, example = "[200,201]")
|
||||
private List<Long> taskIds = new ArrayList<>();
|
||||
}
|
||||
@@ -1,11 +1,16 @@
|
||||
package com.nanri.aiimage.modules.pricetrack.model.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
import lombok.experimental.Accessors;
|
||||
|
||||
@Data
|
||||
@Accessors(chain = true)
|
||||
@Schema(description = "备选店铺列表单项")
|
||||
public class PriceTrackCandidateVo {
|
||||
@Schema(description = "记录主键,删除备选店铺时作为路径参数", example = "10")
|
||||
private Long id;
|
||||
|
||||
@Schema(description = "店铺名称", example = "测试店铺A")
|
||||
private String shopName;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
package com.nanri.aiimage.modules.pricetrack.model.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@Schema(description = "用户五国处理偏好:按顺序返回国家代码列表,例如 DE、UK、FR、IT、ES")
|
||||
public class PriceTrackCountryPreferenceVo {
|
||||
@Schema(description = "当前用户 ID", example = "1")
|
||||
private Long userId;
|
||||
|
||||
@Schema(description = "已选国家代码列表;若用户未保存,则服务端返回默认顺序 DE、UK、FR、IT、ES")
|
||||
private List<String> countryCodes;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
package com.nanri.aiimage.modules.pricetrack.model.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Data
|
||||
@Schema(description = "创建任务响应:包含任务 id 和各店铺的初始结果快照")
|
||||
public class PriceTrackCreateTaskVo {
|
||||
@Schema(description = "新建任务主键 biz_file_task.id,前端推送队列时需要回传该值", example = "200")
|
||||
private Long taskId;
|
||||
private List<Map<String, Object>> items;
|
||||
|
||||
@Schema(description = "各店铺一条初始结果快照,包含 resultId、taskId、店铺信息和初始任务状态")
|
||||
private List<PriceTrackResultItemVo> items;
|
||||
}
|
||||
|
||||
@@ -1,13 +1,22 @@
|
||||
package com.nanri.aiimage.modules.pricetrack.model.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
import lombok.experimental.Accessors;
|
||||
|
||||
@Data
|
||||
@Accessors(chain = true)
|
||||
@Schema(description = "跟价模块统计看板,按当前用户维度汇总")
|
||||
public class PriceTrackDashboardVo {
|
||||
private int candidateCount;
|
||||
private int processedTaskCount;
|
||||
private int successTaskCount;
|
||||
private int failedTaskCount;
|
||||
@Schema(description = "备选店铺数量")
|
||||
private Long candidateCount;
|
||||
|
||||
@Schema(description = "已结束任务数,状态为 SUCCESS 或 FAILED")
|
||||
private Long processedTaskCount;
|
||||
|
||||
@Schema(description = "成功结束的任务数")
|
||||
private Long successTaskCount;
|
||||
|
||||
@Schema(description = "失败结束的任务数")
|
||||
private Long failedTaskCount;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
package com.nanri.aiimage.modules.pricetrack.model.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@Schema(description = "跟价结果列表响应:items 中每项对应一个店铺结果行")
|
||||
public class PriceTrackHistoryVo {
|
||||
@Schema(description = "结果列表,包含运行中和历史记录")
|
||||
private List<PriceTrackResultItemVo> items;
|
||||
}
|
||||
|
||||
@@ -1,20 +1,39 @@
|
||||
package com.nanri.aiimage.modules.pricetrack.model.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@Schema(description = "批量匹配店铺响应")
|
||||
public class PriceTrackMatchShopsVo {
|
||||
private List<PriceTrackShopQueueItem> items;
|
||||
@Schema(description = "逐店返回的匹配结果,顺序与请求 shopNames 对应")
|
||||
private List<PriceTrackShopQueueItem> items = new ArrayList<>();
|
||||
|
||||
@Data
|
||||
@Schema(description = "单店匹配结果,同时也是创建任务时 items 的结构")
|
||||
public static class PriceTrackShopQueueItem {
|
||||
@Schema(description = "店铺名称", example = "某某店铺")
|
||||
private String shopName;
|
||||
private Long shopId;
|
||||
|
||||
@Schema(description = "紫鸟店铺 ID,未命中时可能为空")
|
||||
private Object shopId;
|
||||
|
||||
@Schema(description = "平台,例如亚马逊", example = "亚马逊")
|
||||
private String platform;
|
||||
|
||||
@Schema(description = "公司名称或主体标识")
|
||||
private String companyName;
|
||||
|
||||
@Schema(description = "是否命中紫鸟索引;false 时不可用于创建任务")
|
||||
private Boolean matched;
|
||||
|
||||
@Schema(description = "匹配状态码,例如 MATCHED、PENDING、CONFLICT、INDEX_STALE")
|
||||
private String matchStatus;
|
||||
|
||||
@Schema(description = "人类可读说明,供前端展示")
|
||||
private String matchMessage;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.nanri.aiimage.modules.pricetrack.model.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@Schema(description = "按店铺名删除运行中结果的响应")
|
||||
public class PriceTrackPendingDeleteVo {
|
||||
@Schema(description = "是否实际删除到一条运行中的结果记录")
|
||||
private boolean removed;
|
||||
}
|
||||
@@ -1,23 +1,52 @@
|
||||
package com.nanri.aiimage.modules.pricetrack.model.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
import lombok.experimental.Accessors;
|
||||
|
||||
@Data
|
||||
@Accessors(chain = true)
|
||||
@Schema(description = "单店结果项:列表接口与任务详情共用,对应 biz_file_result 一行以及请求衍生字段")
|
||||
public class PriceTrackResultItemVo {
|
||||
@Schema(description = "结果行主键 biz_file_result.id", example = "1001")
|
||||
private Long resultId;
|
||||
|
||||
@Schema(description = "所属任务主键 biz_file_task.id", example = "200")
|
||||
private Long taskId;
|
||||
|
||||
@Schema(description = "店铺名称,详情页会尽量恢复创建任务时的展示名")
|
||||
private String shopName;
|
||||
|
||||
@Schema(description = "店铺 ID(紫鸟)")
|
||||
private Object shopId;
|
||||
|
||||
@Schema(description = "平台")
|
||||
private String platform;
|
||||
|
||||
@Schema(description = "公司名称")
|
||||
private String companyName;
|
||||
|
||||
@Schema(description = "创建任务时是否已匹配")
|
||||
private Boolean matched;
|
||||
|
||||
@Schema(description = "匹配状态码,含义同 PriceTrackShopQueueItem.matchStatus")
|
||||
private String matchStatus;
|
||||
|
||||
@Schema(description = "匹配说明")
|
||||
private String matchMessage;
|
||||
|
||||
@Schema(description = "所属任务当前状态:RUNNING、SUCCESS、FAILED")
|
||||
private String taskStatus;
|
||||
|
||||
@Schema(description = "该店铺是否成功生成结果文件;null 或 false 可视为未完成或失败")
|
||||
private Boolean success;
|
||||
|
||||
@Schema(description = "失败时的错误信息")
|
||||
private String error;
|
||||
|
||||
@Schema(description = "生成的 xlsx 文件名")
|
||||
private String outputFilename;
|
||||
|
||||
@Schema(description = "预签名下载 URL,列表可能带回,也可通过下载接口获取")
|
||||
private String downloadUrl;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.nanri.aiimage.modules.pricetrack.model.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@Schema(description = "批量任务查询响应:每个 taskId 返回一条详情;无效 id 放到 missingTaskIds")
|
||||
public class PriceTrackTaskBatchVo {
|
||||
@Schema(description = "存在且属于跟价模块的任务详情列表")
|
||||
private List<PriceTrackTaskDetailVo> items = new ArrayList<>();
|
||||
|
||||
@Schema(description = "请求中不存在或不属于跟价模块的 taskId 列表")
|
||||
private List<Long> missingTaskIds = new ArrayList<>();
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.nanri.aiimage.modules.pricetrack.model.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@Schema(description = "单个任务详情:任务级概要和下属店铺结果列表")
|
||||
public class PriceTrackTaskDetailVo {
|
||||
@Schema(description = "任务级字段,例如状态、错误信息、时间等")
|
||||
private PriceTrackTaskItemVo task = new PriceTrackTaskItemVo();
|
||||
|
||||
@Schema(description = "该任务下每个店铺对应一条结果记录")
|
||||
private List<PriceTrackResultItemVo> items = new ArrayList<>();
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.nanri.aiimage.modules.pricetrack.model.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@Schema(description = "跟价任务概要")
|
||||
public class PriceTrackTaskItemVo {
|
||||
@Schema(description = "任务主键", example = "200")
|
||||
private Long id;
|
||||
|
||||
@Schema(description = "任务编号")
|
||||
private String taskNo;
|
||||
|
||||
@Schema(description = "任务状态:RUNNING、SUCCESS、FAILED")
|
||||
private String status;
|
||||
|
||||
@Schema(description = "任务错误信息;部分成功或失败时可能有值")
|
||||
private String errorMessage;
|
||||
|
||||
@Schema(description = "创建时间")
|
||||
private String createdAt;
|
||||
|
||||
@Schema(description = "更新时间")
|
||||
private String updatedAt;
|
||||
|
||||
@Schema(description = "完成时间;未结束时为空")
|
||||
private String finishedAt;
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package com.nanri.aiimage.modules.pricetrack.service;
|
||||
|
||||
import com.nanri.aiimage.common.exception.BusinessException;
|
||||
import com.nanri.aiimage.modules.pricetrack.model.dto.PriceTrackSubmitResultRequest;
|
||||
import com.nanri.aiimage.modules.productrisk.model.enums.ProductRiskCountryCode;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.poi.ss.usermodel.Row;
|
||||
import org.apache.poi.ss.usermodel.Sheet;
|
||||
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileOutputStream;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Service
|
||||
@Slf4j
|
||||
public class PriceTrackExcelAssemblyService {
|
||||
|
||||
private static final String[] HEADER = {"ASIN", "当前店铺价格", "竞争对手价格", "建议动作", "是否完成"};
|
||||
|
||||
public void writeWorkbook(File outputXlsx, Map<String, List<PriceTrackSubmitResultRequest.AsinResult>> countries) {
|
||||
Map<String, List<PriceTrackSubmitResultRequest.AsinResult>> safe = countries == null ? Map.of() : countries;
|
||||
try (XSSFWorkbook wb = new XSSFWorkbook(); FileOutputStream fos = new FileOutputStream(outputXlsx)) {
|
||||
for (ProductRiskCountryCode code : ProductRiskCountryCode.values()) {
|
||||
String sheetName = code.name();
|
||||
Sheet sheet = wb.createSheet(sheetName);
|
||||
Row headerRow = sheet.createRow(0);
|
||||
for (int i = 0; i < HEADER.length; i++) {
|
||||
headerRow.createCell(i).setCellValue(HEADER[i]);
|
||||
}
|
||||
List<PriceTrackSubmitResultRequest.AsinResult> rows = safe.getOrDefault(sheetName, List.of());
|
||||
int rowIdx = 1;
|
||||
for (PriceTrackSubmitResultRequest.AsinResult item : rows) {
|
||||
if (item == null) {
|
||||
continue;
|
||||
}
|
||||
Row row = sheet.createRow(rowIdx++);
|
||||
row.createCell(0).setCellValue(item.getAsin() == null ? "" : item.getAsin());
|
||||
row.createCell(1).setCellValue(item.getCurrentPrice() == null ? "" : item.getCurrentPrice());
|
||||
row.createCell(2).setCellValue(item.getCompetitorPrice() == null ? "" : item.getCompetitorPrice());
|
||||
row.createCell(3).setCellValue(item.getAction() == null ? "" : item.getAction());
|
||||
row.createCell(4).setCellValue(item.getDone() == null ? "" : String.valueOf(item.getDone()));
|
||||
}
|
||||
for (int i = 0; i < HEADER.length; i++) {
|
||||
sheet.autoSizeColumn(i);
|
||||
}
|
||||
}
|
||||
wb.write(fos);
|
||||
} catch (Exception ex) {
|
||||
log.warn("[price-track] write workbook failed: {}", ex.getMessage());
|
||||
throw new BusinessException("生成 Excel 失败: " + ex.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public int countRows(Map<String, List<PriceTrackSubmitResultRequest.AsinResult>> countries) {
|
||||
if (countries == null || countries.isEmpty()) {
|
||||
return 0;
|
||||
}
|
||||
int count = 0;
|
||||
for (List<PriceTrackSubmitResultRequest.AsinResult> rows : countries.values()) {
|
||||
if (rows != null) {
|
||||
count += rows.size();
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
public Map<String, List<PriceTrackSubmitResultRequest.AsinResult>> normalizeCountriesMap(
|
||||
Map<String, List<PriceTrackSubmitResultRequest.AsinResult>> raw) {
|
||||
if (raw == null || raw.isEmpty()) {
|
||||
return new LinkedHashMap<>();
|
||||
}
|
||||
Map<String, List<PriceTrackSubmitResultRequest.AsinResult>> out = new LinkedHashMap<>();
|
||||
for (ProductRiskCountryCode code : ProductRiskCountryCode.values()) {
|
||||
List<PriceTrackSubmitResultRequest.AsinResult> rows = raw.get(code.name());
|
||||
if (rows != null) {
|
||||
out.put(code.name(), rows);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,6 @@ import com.nanri.aiimage.modules.pricetrack.model.entity.PriceTrackShopCandidate
|
||||
import com.nanri.aiimage.modules.pricetrack.model.vo.PriceTrackCandidateVo;
|
||||
import com.nanri.aiimage.modules.pricetrack.model.vo.PriceTrackCountryPreferenceVo;
|
||||
import com.nanri.aiimage.modules.pricetrack.model.vo.PriceTrackMatchShopsVo;
|
||||
import com.nanri.aiimage.modules.pricetrack.model.vo.PriceTrackShopQueueItem;
|
||||
import com.nanri.aiimage.modules.shopkey.service.SkipPriceAsinService;
|
||||
import com.nanri.aiimage.modules.ziniao.model.vo.ZiniaoShopMatchResultVo;
|
||||
import com.nanri.aiimage.modules.ziniao.service.ZiniaoShopIndexService;
|
||||
@@ -52,7 +51,9 @@ public class PriceTrackService {
|
||||
.orderByDesc(PriceTrackShopCandidateEntity::getId));
|
||||
List<PriceTrackCandidateVo> list = new ArrayList<>();
|
||||
for (PriceTrackShopCandidateEntity row : rows) {
|
||||
if (row == null) continue;
|
||||
if (row == null) {
|
||||
continue;
|
||||
}
|
||||
PriceTrackCandidateVo vo = new PriceTrackCandidateVo();
|
||||
vo.setId(row.getId());
|
||||
vo.setShopName(row.getShopName());
|
||||
@@ -68,20 +69,22 @@ public class PriceTrackService {
|
||||
}
|
||||
String normalized = ziniaoShopSwitchService.normalizeShopName(request.getShopName());
|
||||
if (normalized.isBlank()) {
|
||||
throw new BusinessException("店铺名不能为空");
|
||||
throw new BusinessException("店铺名称不能为空");
|
||||
}
|
||||
// 店铺需在紫鸟索引中命中才能加入备选
|
||||
|
||||
ZiniaoShopMatchResultVo indexHit = ziniaoShopSwitchService.findIndexedStoreByName(normalized, false);
|
||||
if (indexHit == null || !indexHit.isMatched()) {
|
||||
String hint = (indexHit != null && indexHit.getMatchMessage() != null)
|
||||
? indexHit.getMatchMessage() : "店铺索引未命中,无法加入备选";
|
||||
String hint = indexHit != null && indexHit.getMatchMessage() != null
|
||||
? indexHit.getMatchMessage()
|
||||
: "店铺未命中紫鸟索引,无法加入备选列表";
|
||||
throw new BusinessException(hint);
|
||||
}
|
||||
if (ZiniaoShopIndexService.MATCH_STATUS_CONFLICT.equals(indexHit.getMatchStatus())) {
|
||||
throw new BusinessException(indexHit.getMatchMessage() != null
|
||||
? indexHit.getMatchMessage() : "存在多个同名店铺,请人工确认");
|
||||
? indexHit.getMatchMessage()
|
||||
: "存在多个同名店铺,请先人工确认");
|
||||
}
|
||||
// 查重
|
||||
|
||||
PriceTrackShopCandidateEntity existing = candidateMapper.selectOne(
|
||||
new LambdaQueryWrapper<PriceTrackShopCandidateEntity>()
|
||||
.eq(PriceTrackShopCandidateEntity::getUserId, request.getUserId())
|
||||
@@ -93,11 +96,13 @@ public class PriceTrackService {
|
||||
vo.setShopName(existing.getShopName());
|
||||
return vo;
|
||||
}
|
||||
|
||||
PriceTrackShopCandidateEntity entity = new PriceTrackShopCandidateEntity();
|
||||
entity.setUserId(request.getUserId());
|
||||
entity.setShopName(normalized);
|
||||
entity.setCreatedAt(LocalDateTime.now());
|
||||
candidateMapper.insert(entity);
|
||||
|
||||
PriceTrackCandidateVo vo = new PriceTrackCandidateVo();
|
||||
vo.setId(entity.getId());
|
||||
vo.setShopName(entity.getShopName());
|
||||
@@ -106,8 +111,12 @@ public class PriceTrackService {
|
||||
|
||||
@Transactional
|
||||
public void deleteCandidate(Long userId, Long id) {
|
||||
if (userId == null || userId <= 0) throw new BusinessException("user_id 不合法");
|
||||
if (id == null || id <= 0) throw new BusinessException("id 不合法");
|
||||
if (userId == null || userId <= 0) {
|
||||
throw new BusinessException("user_id 不合法");
|
||||
}
|
||||
if (id == null || id <= 0) {
|
||||
throw new BusinessException("id 不合法");
|
||||
}
|
||||
PriceTrackShopCandidateEntity row = candidateMapper.selectById(id);
|
||||
if (row == null || !userId.equals(row.getUserId())) {
|
||||
throw new BusinessException("记录不存在");
|
||||
@@ -121,12 +130,15 @@ public class PriceTrackService {
|
||||
}
|
||||
LinkedHashSet<String> ordered = new LinkedHashSet<>();
|
||||
for (String raw : request.getShopNames()) {
|
||||
String n = ziniaoShopSwitchService.normalizeShopName(raw);
|
||||
if (!n.isBlank()) ordered.add(n);
|
||||
String normalized = ziniaoShopSwitchService.normalizeShopName(raw);
|
||||
if (!normalized.isBlank()) {
|
||||
ordered.add(normalized);
|
||||
}
|
||||
}
|
||||
if (ordered.isEmpty()) {
|
||||
throw new BusinessException("shop_names 无有效店铺名");
|
||||
throw new BusinessException("shop_names 没有有效店铺名");
|
||||
}
|
||||
|
||||
PriceTrackMatchShopsVo vo = new PriceTrackMatchShopsVo();
|
||||
for (String shopName : ordered) {
|
||||
vo.getItems().add(matchOneShop(shopName));
|
||||
@@ -135,7 +147,9 @@ public class PriceTrackService {
|
||||
}
|
||||
|
||||
public PriceTrackCountryPreferenceVo getCountryPreference(Long userId) {
|
||||
if (userId == null || userId <= 0) throw new BusinessException("user_id 不合法");
|
||||
if (userId == null || userId <= 0) {
|
||||
throw new BusinessException("user_id 不合法");
|
||||
}
|
||||
PriceTrackCountryPrefEntity row = countryPrefMapper.selectById(userId);
|
||||
PriceTrackCountryPreferenceVo vo = new PriceTrackCountryPreferenceVo();
|
||||
if (row == null || row.getCountryCodesJson() == null || row.getCountryCodesJson().isBlank()) {
|
||||
@@ -156,14 +170,17 @@ public class PriceTrackService {
|
||||
|
||||
@Transactional
|
||||
public PriceTrackCountryPreferenceVo saveCountryPreference(PriceTrackCountryPreferenceSaveRequest request) {
|
||||
if (request.getUserId() == null || request.getUserId() <= 0) throw new BusinessException("user_id 不合法");
|
||||
if (request.getUserId() == null || request.getUserId() <= 0) {
|
||||
throw new BusinessException("user_id 不合法");
|
||||
}
|
||||
List<String> normalized = validateCountryCodesForSave(request.getCountryCodes());
|
||||
String json;
|
||||
try {
|
||||
json = objectMapper.writeValueAsString(normalized);
|
||||
} catch (Exception ex) {
|
||||
throw new BusinessException("保存偏好失败");
|
||||
throw new BusinessException("保存国家偏好失败");
|
||||
}
|
||||
|
||||
PriceTrackCountryPrefEntity row = countryPrefMapper.selectById(request.getUserId());
|
||||
if (row == null) {
|
||||
row = new PriceTrackCountryPrefEntity();
|
||||
@@ -174,29 +191,30 @@ public class PriceTrackService {
|
||||
row.setCountryCodesJson(json);
|
||||
countryPrefMapper.updateById(row);
|
||||
}
|
||||
|
||||
PriceTrackCountryPreferenceVo vo = new PriceTrackCountryPreferenceVo();
|
||||
vo.setUserId(request.getUserId());
|
||||
vo.setCountryCodes(normalized);
|
||||
return vo;
|
||||
}
|
||||
|
||||
private PriceTrackShopQueueItem matchOneShop(String shopName) {
|
||||
PriceTrackShopQueueItem item = new PriceTrackShopQueueItem();
|
||||
private PriceTrackMatchShopsVo.PriceTrackShopQueueItem matchOneShop(String shopName) {
|
||||
PriceTrackMatchShopsVo.PriceTrackShopQueueItem item = new PriceTrackMatchShopsVo.PriceTrackShopQueueItem();
|
||||
item.setShopName(shopName);
|
||||
try {
|
||||
ZiniaoShopMatchResultVo m = ziniaoShopSwitchService.findIndexedStoreByName(shopName, false);
|
||||
if (m == null) {
|
||||
ZiniaoShopMatchResultVo match = ziniaoShopSwitchService.findIndexedStoreByName(shopName, false);
|
||||
if (match == null) {
|
||||
item.setMatched(false);
|
||||
item.setMatchStatus("PENDING");
|
||||
item.setMatchMessage("匹配结果为空");
|
||||
item.setMatchMessage("未查询到匹配结果");
|
||||
return item;
|
||||
}
|
||||
item.setMatched(m.isMatched());
|
||||
item.setShopId(m.getShopId());
|
||||
item.setPlatform(m.getPlatform());
|
||||
item.setCompanyName(m.getCompanyName());
|
||||
item.setMatchStatus(m.getMatchStatus());
|
||||
item.setMatchMessage(m.getMatchMessage());
|
||||
item.setMatched(match.isMatched());
|
||||
item.setShopId(match.getShopId());
|
||||
item.setPlatform(match.getPlatform());
|
||||
item.setCompanyName(match.getCompanyName());
|
||||
item.setMatchStatus(match.getMatchStatus());
|
||||
item.setMatchMessage(match.getMatchMessage());
|
||||
} catch (BusinessException ex) {
|
||||
item.setMatched(false);
|
||||
item.setMatchStatus("PENDING");
|
||||
@@ -211,34 +229,54 @@ public class PriceTrackService {
|
||||
|
||||
private static List<String> sanitizeStoredCodes(List<String> raw) {
|
||||
List<String> parsed = parseValidCountryCodes(raw);
|
||||
if (parsed.isEmpty()) return new ArrayList<>(DEFAULT_COUNTRY_PREFERENCE_ORDER);
|
||||
if (parsed.isEmpty()) {
|
||||
return new ArrayList<>(DEFAULT_COUNTRY_PREFERENCE_ORDER);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
private static List<String> validateCountryCodesForSave(List<String> raw) {
|
||||
if (raw == null || raw.isEmpty()) throw new BusinessException("country_codes 至少选择 1 个国家");
|
||||
if (raw == null || raw.isEmpty()) {
|
||||
throw new BusinessException("country_codes 至少选择 1 个国家");
|
||||
}
|
||||
LinkedHashSet<String> seen = new LinkedHashSet<>();
|
||||
List<String> out = new ArrayList<>();
|
||||
for (String s : raw) {
|
||||
if (s == null || s.isBlank()) throw new BusinessException("country_codes 含空项");
|
||||
String u = s.trim().toUpperCase();
|
||||
if (!SUPPORTED_COUNTRIES.contains(u)) throw new BusinessException("非法国家代码: " + s);
|
||||
if (!seen.add(u)) throw new BusinessException("country_codes 存在重复: " + u);
|
||||
out.add(u);
|
||||
for (String code : raw) {
|
||||
if (code == null || code.isBlank()) {
|
||||
throw new BusinessException("country_codes 不能包含空值");
|
||||
}
|
||||
String normalized = code.trim().toUpperCase();
|
||||
if (!SUPPORTED_COUNTRIES.contains(normalized)) {
|
||||
throw new BusinessException("非法国家代码: " + code);
|
||||
}
|
||||
if (!seen.add(normalized)) {
|
||||
throw new BusinessException("country_codes 存在重复值: " + normalized);
|
||||
}
|
||||
out.add(normalized);
|
||||
}
|
||||
if (out.isEmpty()) {
|
||||
throw new BusinessException("country_codes 至少选择 1 个国家");
|
||||
}
|
||||
if (out.isEmpty()) throw new BusinessException("country_codes 至少选择 1 个国家");
|
||||
return out;
|
||||
}
|
||||
|
||||
private static List<String> parseValidCountryCodes(List<String> raw) {
|
||||
if (raw == null || raw.isEmpty()) return new ArrayList<>();
|
||||
if (raw == null || raw.isEmpty()) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
LinkedHashSet<String> seen = new LinkedHashSet<>();
|
||||
List<String> out = new ArrayList<>();
|
||||
for (String s : raw) {
|
||||
if (s == null || s.isBlank()) continue;
|
||||
String u = s.trim().toUpperCase();
|
||||
if (!SUPPORTED_COUNTRIES.contains(u)) continue;
|
||||
if (seen.add(u)) out.add(u);
|
||||
for (String code : raw) {
|
||||
if (code == null || code.isBlank()) {
|
||||
continue;
|
||||
}
|
||||
String normalized = code.trim().toUpperCase();
|
||||
if (!SUPPORTED_COUNTRIES.contains(normalized)) {
|
||||
continue;
|
||||
}
|
||||
if (seen.add(normalized)) {
|
||||
out.add(normalized);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
package com.nanri.aiimage.modules.pricetrack.service;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class PriceTrackTaskCacheService {
|
||||
|
||||
private static final long HEARTBEAT_TTL_HOURS = 24;
|
||||
|
||||
private final StringRedisTemplate stringRedisTemplate;
|
||||
|
||||
public void touchTaskHeartbeat(Long taskId) {
|
||||
if (taskId == null || taskId <= 0) {
|
||||
return;
|
||||
}
|
||||
stringRedisTemplate.opsForValue().set(
|
||||
buildTaskHeartbeatKey(taskId),
|
||||
String.valueOf(Instant.now().toEpochMilli()),
|
||||
Duration.ofHours(HEARTBEAT_TTL_HOURS));
|
||||
}
|
||||
|
||||
public long getTaskHeartbeatMillis(Long taskId) {
|
||||
if (taskId == null || taskId <= 0) {
|
||||
return 0L;
|
||||
}
|
||||
String raw = stringRedisTemplate.opsForValue().get(buildTaskHeartbeatKey(taskId));
|
||||
if (raw == null || raw.isBlank()) {
|
||||
return 0L;
|
||||
}
|
||||
try {
|
||||
return Long.parseLong(raw);
|
||||
} catch (NumberFormatException ignored) {
|
||||
return 0L;
|
||||
}
|
||||
}
|
||||
|
||||
public void deleteTaskCache(Long taskId) {
|
||||
if (taskId == null || taskId <= 0) {
|
||||
return;
|
||||
}
|
||||
stringRedisTemplate.delete(buildTaskHeartbeatKey(taskId));
|
||||
}
|
||||
|
||||
private String buildTaskHeartbeatKey(Long taskId) {
|
||||
return "price-track:task:heartbeat:" + taskId;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.nanri.aiimage.modules.pricetrack.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.nanri.aiimage.common.exception.BusinessException;
|
||||
import com.nanri.aiimage.modules.file.service.oss.OssStorageService;
|
||||
@@ -11,28 +12,28 @@ import com.nanri.aiimage.modules.pricetrack.model.entity.PriceTrackShopCandidate
|
||||
import com.nanri.aiimage.modules.pricetrack.model.vo.PriceTrackCreateTaskVo;
|
||||
import com.nanri.aiimage.modules.pricetrack.model.vo.PriceTrackDashboardVo;
|
||||
import com.nanri.aiimage.modules.pricetrack.model.vo.PriceTrackHistoryVo;
|
||||
import com.nanri.aiimage.modules.pricetrack.model.vo.PriceTrackMatchShopsVo;
|
||||
import com.nanri.aiimage.modules.pricetrack.model.vo.PriceTrackPendingDeleteVo;
|
||||
import com.nanri.aiimage.modules.pricetrack.model.vo.PriceTrackResultItemVo;
|
||||
import com.nanri.aiimage.modules.pricetrack.model.vo.PriceTrackShopQueueItem;
|
||||
import com.nanri.aiimage.modules.pricetrack.model.vo.PriceTrackTaskBatchVo;
|
||||
import com.nanri.aiimage.modules.pricetrack.model.vo.PriceTrackTaskDetailVo;
|
||||
import com.nanri.aiimage.modules.pricetrack.model.vo.PriceTrackTaskItemVo;
|
||||
import com.nanri.aiimage.modules.shopkey.service.SkipPriceAsinService;
|
||||
import com.nanri.aiimage.modules.task.mapper.FileResultMapper;
|
||||
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.ziniao.service.ZiniaoShopSwitchService;
|
||||
import cn.hutool.core.io.FileUtil;
|
||||
import cn.hutool.core.util.IdUtil;
|
||||
import cn.hutool.core.util.ZipUtil;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.io.File;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
@@ -42,7 +43,7 @@ import java.util.Objects;
|
||||
public class PriceTrackTaskService {
|
||||
|
||||
private static final String MODULE_TYPE = "PRICE_TRACK";
|
||||
private static final String CONTENT_TYPE_ZIP = "application/zip";
|
||||
private static final String CONTENT_TYPE_XLSX = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
|
||||
|
||||
private final FileTaskMapper fileTaskMapper;
|
||||
private final FileResultMapper fileResultMapper;
|
||||
@@ -51,6 +52,8 @@ public class PriceTrackTaskService {
|
||||
private final OssStorageService ossStorageService;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final SkipPriceAsinService skipPriceAsinService;
|
||||
private final PriceTrackExcelAssemblyService excelAssemblyService;
|
||||
private final PriceTrackTaskCacheService priceTrackTaskCacheService;
|
||||
|
||||
public PriceTrackDashboardVo dashboard(Long userId) {
|
||||
if (userId == null || userId <= 0) throw new BusinessException("user_id 不合法");
|
||||
@@ -129,6 +132,51 @@ public class PriceTrackTaskService {
|
||||
.eq(FileResultEntity::getTaskId, taskId)
|
||||
.eq(FileResultEntity::getModuleType, MODULE_TYPE));
|
||||
fileTaskMapper.deleteById(taskId);
|
||||
priceTrackTaskCacheService.deleteTaskCache(taskId);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public PriceTrackPendingDeleteVo deletePendingShopResult(Long userId, String shopName) {
|
||||
if (userId == null || userId <= 0) throw new BusinessException("user_id 不合法");
|
||||
if (shopName == null || shopName.isBlank()) throw new BusinessException("shop_name 涓嶈兘涓虹┖");
|
||||
String norm = ziniaoShopSwitchService.normalizeShopName(shopName);
|
||||
if (norm.isBlank()) throw new BusinessException("店铺名称无效");
|
||||
|
||||
List<FileResultEntity> candidates = fileResultMapper.selectList(new LambdaQueryWrapper<FileResultEntity>()
|
||||
.eq(FileResultEntity::getUserId, userId)
|
||||
.eq(FileResultEntity::getModuleType, MODULE_TYPE)
|
||||
.eq(FileResultEntity::getSourceFilename, norm)
|
||||
.orderByDesc(FileResultEntity::getId)
|
||||
.last("limit 30"));
|
||||
|
||||
PriceTrackPendingDeleteVo vo = new PriceTrackPendingDeleteVo();
|
||||
vo.setRemoved(false);
|
||||
for (FileResultEntity fr : candidates) {
|
||||
if (fr.getTaskId() == null) continue;
|
||||
FileTaskEntity task = fileTaskMapper.selectById(fr.getTaskId());
|
||||
if (task == null || !MODULE_TYPE.equals(task.getModuleType()) || !userId.equals(task.getUserId())) continue;
|
||||
if (!"RUNNING".equals(task.getStatus())) continue;
|
||||
fileResultMapper.deleteById(fr.getId());
|
||||
reconcileTaskAfterResultRemoval(fr.getTaskId());
|
||||
vo.setRemoved(true);
|
||||
return vo;
|
||||
}
|
||||
return vo;
|
||||
}
|
||||
|
||||
public PriceTrackTaskBatchVo getTaskDetailsBatch(List<Long> taskIds) {
|
||||
PriceTrackTaskBatchVo batch = new PriceTrackTaskBatchVo();
|
||||
if (taskIds == null || taskIds.isEmpty()) return batch;
|
||||
for (Long taskId : taskIds) {
|
||||
if (taskId == null || taskId <= 0) continue;
|
||||
FileTaskEntity task = fileTaskMapper.selectById(taskId);
|
||||
if (task == null || !MODULE_TYPE.equals(task.getModuleType())) {
|
||||
batch.getMissingTaskIds().add(taskId);
|
||||
continue;
|
||||
}
|
||||
batch.getItems().add(buildTaskDetail(task));
|
||||
}
|
||||
return batch;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@@ -137,18 +185,18 @@ public class PriceTrackTaskService {
|
||||
if (request.getItems() == null || request.getItems().isEmpty()) throw new BusinessException("items 不能为空");
|
||||
|
||||
// 去重并校验匹配状态
|
||||
List<PriceTrackShopQueueItem> uniqueItems = dedupeQueueItems(request.getItems());
|
||||
List<PriceTrackMatchShopsVo.PriceTrackShopQueueItem> uniqueItems = dedupeQueueItems(request.getItems());
|
||||
if (uniqueItems.isEmpty()) throw new BusinessException("items 不能为空");
|
||||
for (PriceTrackShopQueueItem item : uniqueItems) {
|
||||
for (PriceTrackMatchShopsVo.PriceTrackShopQueueItem item : uniqueItems) {
|
||||
if (item == null || !Boolean.TRUE.equals(item.getMatched())) {
|
||||
String name = item == null ? "" : Objects.toString(item.getShopName(), "");
|
||||
throw new BusinessException("存在未匹配店铺,无法创建任务: " + name);
|
||||
}
|
||||
}
|
||||
|
||||
// 收集店铺名,查询跳过ASIN
|
||||
// 汇总店铺名并查询需要跳过的 ASIN
|
||||
List<String> shopNames = uniqueItems.stream()
|
||||
.map(PriceTrackShopQueueItem::getShopName)
|
||||
.map(PriceTrackMatchShopsVo.PriceTrackShopQueueItem::getShopName)
|
||||
.filter(s -> s != null && !s.isBlank())
|
||||
.distinct()
|
||||
.toList();
|
||||
@@ -169,11 +217,12 @@ public class PriceTrackTaskService {
|
||||
task.setCreatedAt(LocalDateTime.now());
|
||||
task.setUpdatedAt(LocalDateTime.now());
|
||||
fileTaskMapper.insert(task);
|
||||
priceTrackTaskCacheService.touchTaskHeartbeat(task.getId());
|
||||
|
||||
List<PriceTrackResultItemVo> snapshot = new ArrayList<>();
|
||||
for (PriceTrackShopQueueItem item : uniqueItems) {
|
||||
for (PriceTrackMatchShopsVo.PriceTrackShopQueueItem item : uniqueItems) {
|
||||
String norm = ziniaoShopSwitchService.normalizeShopName(item.getShopName());
|
||||
if (norm.isBlank()) throw new BusinessException("店铺名无效");
|
||||
if (norm.isBlank()) throw new BusinessException("店铺名称无效");
|
||||
FileResultEntity fr = new FileResultEntity();
|
||||
fr.setTaskId(task.getId());
|
||||
fr.setModuleType(MODULE_TYPE);
|
||||
@@ -186,16 +235,13 @@ public class PriceTrackTaskService {
|
||||
snapshot.add(toSnapshotVo(fr, item, task.getStatus()));
|
||||
}
|
||||
|
||||
// 保存请求上下文(含skipASIN)用于Python后续查询
|
||||
// 保存请求上下文快照,供 Python 消费和前端轮询展示使用
|
||||
try {
|
||||
Map<String, Object> ctx = new LinkedHashMap<>();
|
||||
ctx.put("userId", request.getUserId());
|
||||
ctx.put("statusMode", request.isStatusMode());
|
||||
ctx.put("asinMode", request.isAsinMode());
|
||||
ctx.put("countryCodes", request.getCountryCodes());
|
||||
ctx.put("minPrice", request.getMinPrice());
|
||||
ctx.put("maxPrice", request.getMaxPrice());
|
||||
ctx.put("priceDiffPercent", request.getPriceDiffPercent());
|
||||
ctx.put("skipAsinsByShop", skipAsinsByShop);
|
||||
ctx.put("items", uniqueItems);
|
||||
task.setRequestJson(objectMapper.writeValueAsString(ctx));
|
||||
@@ -205,8 +251,8 @@ public class PriceTrackTaskService {
|
||||
}
|
||||
fileTaskMapper.updateById(task);
|
||||
|
||||
// TODO: Python侧定时轮询 RUNNING 状态任务, pickup 后开始处理
|
||||
// Java侧通过 submitResult 接口接收Python回传结果
|
||||
// TODO: Python 侧轮询 RUNNING 任务,pickup 后开始处理
|
||||
// Java 侧通过 submitResult 接口接收 Python 回传结果
|
||||
|
||||
PriceTrackCreateTaskVo vo = new PriceTrackCreateTaskVo();
|
||||
vo.setTaskId(task.getId());
|
||||
@@ -229,6 +275,7 @@ public class PriceTrackTaskService {
|
||||
throw new BusinessException("任务已结束,拒绝重复提交");
|
||||
}
|
||||
|
||||
priceTrackTaskCacheService.touchTaskHeartbeat(taskId);
|
||||
List<FileResultEntity> resultRows = fileResultMapper.selectList(
|
||||
new LambdaQueryWrapper<FileResultEntity>()
|
||||
.eq(FileResultEntity::getTaskId, taskId)
|
||||
@@ -242,38 +289,26 @@ public class PriceTrackTaskService {
|
||||
if (!key.isBlank()) payloadByShop.put(key, sr);
|
||||
}
|
||||
|
||||
int ok = 0, fail = 0;
|
||||
List<String> allErrors = new ArrayList<>();
|
||||
boolean allDone = true;
|
||||
|
||||
for (FileResultEntity fr : resultRows) {
|
||||
String shopKey = fr.getSourceFilename();
|
||||
PriceTrackSubmitResultRequest.ShopResult payload = shopKey == null ? null : payloadByShop.get(shopKey);
|
||||
if (payload == null) {
|
||||
// 未匹配到payload的店铺保持待处理
|
||||
allDone = false;
|
||||
// 当前批次没有该店铺的 payload,保持待处理
|
||||
continue;
|
||||
}
|
||||
if (payload.getError() != null && !payload.getError().isBlank()) {
|
||||
markResultFailed(fr, payload.getError());
|
||||
fail++;
|
||||
allErrors.add(shopKey + ": " + payload.getError());
|
||||
continue;
|
||||
}
|
||||
if (Boolean.FALSE.equals(payload.getSuccess())) {
|
||||
markResultFailed(fr, "处理失败");
|
||||
fail++;
|
||||
allErrors.add(shopKey + ": 处理失败");
|
||||
markResultFailed(fr, "店铺处理失败");
|
||||
continue;
|
||||
}
|
||||
// 成功
|
||||
fr.setSuccess(1);
|
||||
fr.setErrorMessage(null);
|
||||
fr.setResultFilename(shopKey + "-price-track.xlsx");
|
||||
fr.setResultFileSize(0L);
|
||||
fr.setRowCount(countAsinResults(payload));
|
||||
fileResultMapper.updateById(fr);
|
||||
ok++;
|
||||
try {
|
||||
assembleShopResult(fr, shopKey, payload);
|
||||
} catch (Exception ex) {
|
||||
markResultFailed(fr, ex.getMessage() == null ? "组装结果失败" : ex.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
List<FileResultEntity> latest = fileResultMapper.selectList(
|
||||
@@ -282,33 +317,57 @@ public class PriceTrackTaskService {
|
||||
.eq(FileResultEntity::getModuleType, MODULE_TYPE)
|
||||
.orderByAsc(FileResultEntity::getId));
|
||||
|
||||
allDone = latest.stream().noneMatch(fr ->
|
||||
fr.getSuccess() == null || fr.getSuccess() == 0);
|
||||
updateTaskStatusFromLatestRows(task, latest);
|
||||
}
|
||||
|
||||
task.setSuccessFileCount(ok);
|
||||
task.setFailedFileCount(fail);
|
||||
task.setUpdatedAt(LocalDateTime.now());
|
||||
@Transactional
|
||||
public boolean tryFinalizeTask(Long taskId, boolean fromCompensation) {
|
||||
if (taskId == null || taskId <= 0) return false;
|
||||
FileTaskEntity task = fileTaskMapper.selectById(taskId);
|
||||
if (task == null || !MODULE_TYPE.equals(task.getModuleType())) return false;
|
||||
if (!"RUNNING".equals(task.getStatus())) {
|
||||
log.warn("[price-track] stale finalize skipped taskId={} status={} fromCompensation={}",
|
||||
taskId, task.getStatus(), fromCompensation);
|
||||
cleanupTaskCacheIfTerminal(taskId, task.getStatus());
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!allDone) {
|
||||
task.setStatus("RUNNING");
|
||||
} else if (ok > 0 && fail == 0) {
|
||||
task.setStatus("SUCCESS");
|
||||
task.setFinishedAt(LocalDateTime.now());
|
||||
} else if (ok > 0) {
|
||||
task.setStatus("SUCCESS");
|
||||
task.setErrorMessage(String.join(";", allErrors));
|
||||
task.setFinishedAt(LocalDateTime.now());
|
||||
} else {
|
||||
List<FileResultEntity> latest = fileResultMapper.selectList(
|
||||
new LambdaQueryWrapper<FileResultEntity>()
|
||||
.eq(FileResultEntity::getTaskId, taskId)
|
||||
.eq(FileResultEntity::getModuleType, MODULE_TYPE)
|
||||
.orderByAsc(FileResultEntity::getId));
|
||||
if (latest.isEmpty()) {
|
||||
task.setStatus("FAILED");
|
||||
task.setErrorMessage(String.join(";", allErrors));
|
||||
task.setErrorMessage("任务明细缺失,无法自动收尾");
|
||||
task.setUpdatedAt(LocalDateTime.now());
|
||||
task.setFinishedAt(LocalDateTime.now());
|
||||
fileTaskMapper.updateById(task);
|
||||
cleanupTaskCacheIfTerminal(taskId, task.getStatus());
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
task.setResultJson(objectMapper.writeValueAsString(buildSnapshotFromDb(taskId, task.getStatus())));
|
||||
} catch (Exception ex) {
|
||||
log.warn("[price-track] compact result json failed: {}", ex.getMessage());
|
||||
|
||||
boolean changed = false;
|
||||
for (FileResultEntity fr : latest) {
|
||||
boolean success = fr.getSuccess() != null && fr.getSuccess() == 1;
|
||||
boolean failed = fr.getErrorMessage() != null && !fr.getErrorMessage().isBlank();
|
||||
if (success || failed) continue;
|
||||
markResultFailed(fr, "Python interrupted before this shop finished uploading results");
|
||||
changed = true;
|
||||
log.warn("[price-track] stale finalize failed taskId={} shop={} fromCompensation={}",
|
||||
taskId, fr.getSourceFilename(), fromCompensation);
|
||||
}
|
||||
fileTaskMapper.updateById(task);
|
||||
if (!changed) {
|
||||
return false;
|
||||
}
|
||||
|
||||
List<FileResultEntity> refreshed = fileResultMapper.selectList(
|
||||
new LambdaQueryWrapper<FileResultEntity>()
|
||||
.eq(FileResultEntity::getTaskId, taskId)
|
||||
.eq(FileResultEntity::getModuleType, MODULE_TYPE)
|
||||
.orderByAsc(FileResultEntity::getId));
|
||||
updateTaskStatusFromLatestRows(task, refreshed);
|
||||
return true;
|
||||
}
|
||||
|
||||
public String resolveResultDownloadUrl(Long resultId, Long userId) {
|
||||
@@ -345,21 +404,10 @@ public class PriceTrackTaskService {
|
||||
.orderByAsc(FileResultEntity::getId));
|
||||
if (latest.isEmpty()) {
|
||||
fileTaskMapper.deleteById(taskId);
|
||||
cleanupTaskCacheIfTerminal(taskId, "DELETE_EMPTY");
|
||||
return;
|
||||
}
|
||||
int ok = 0, fail = 0;
|
||||
for (FileResultEntity fr : latest) {
|
||||
if (Boolean.TRUE.equals(fr.getSuccess())) ok++;
|
||||
else if (fr.getErrorMessage() != null && !fr.getErrorMessage().isBlank()) fail++;
|
||||
}
|
||||
task.setSuccessFileCount(ok);
|
||||
task.setFailedFileCount(fail);
|
||||
task.setUpdatedAt(LocalDateTime.now());
|
||||
boolean allDone = latest.stream().noneMatch(fr -> fr.getSuccess() == null || fr.getSuccess() == 0);
|
||||
if (!allDone) return;
|
||||
task.setStatus(ok > 0 ? "SUCCESS" : "FAILED");
|
||||
task.setFinishedAt(LocalDateTime.now());
|
||||
fileTaskMapper.updateById(task);
|
||||
updateTaskStatusFromLatestRows(task, latest);
|
||||
}
|
||||
|
||||
private void markResultFailed(FileResultEntity fr, String message) {
|
||||
@@ -369,12 +417,44 @@ public class PriceTrackTaskService {
|
||||
fr.setResultFileUrl(null);
|
||||
fr.setResultFileSize(0L);
|
||||
fr.setRowCount(0);
|
||||
fr.setResultContentType(null);
|
||||
fileResultMapper.updateById(fr);
|
||||
}
|
||||
|
||||
private List<PriceTrackShopQueueItem> dedupeQueueItems(List<PriceTrackShopQueueItem> raw) {
|
||||
Map<String, PriceTrackShopQueueItem> byNorm = new LinkedHashMap<>();
|
||||
for (PriceTrackShopQueueItem item : raw) {
|
||||
private void assembleShopResult(FileResultEntity result,
|
||||
String shopKey,
|
||||
PriceTrackSubmitResultRequest.ShopResult payload) {
|
||||
Map<String, List<PriceTrackSubmitResultRequest.AsinResult>> countries =
|
||||
excelAssemblyService.normalizeCountriesMap(payload.getCountries());
|
||||
if (excelAssemblyService.countRows(countries) <= 0) {
|
||||
throw new BusinessException("未收到可用结果数据");
|
||||
}
|
||||
String displayName = payload.getShopName() != null && !payload.getShopName().isBlank()
|
||||
? payload.getShopName().trim()
|
||||
: shopKey;
|
||||
String stem = safeFileStem(displayName);
|
||||
java.io.File workRoot = cn.hutool.core.io.FileUtil.mkdir(
|
||||
cn.hutool.core.io.FileUtil.file(System.getProperty("java.io.tmpdir"), "price-track-result", String.valueOf(result.getTaskId())));
|
||||
java.io.File xlsx = cn.hutool.core.io.FileUtil.file(workRoot, stem + ".xlsx");
|
||||
try {
|
||||
excelAssemblyService.writeWorkbook(xlsx, countries);
|
||||
String objectKey = ossStorageService.uploadResultFile(xlsx, MODULE_TYPE);
|
||||
result.setSuccess(1);
|
||||
result.setErrorMessage(null);
|
||||
result.setResultFilename(stem + ".xlsx");
|
||||
result.setResultFileUrl(objectKey);
|
||||
result.setResultFileSize(xlsx.length());
|
||||
result.setResultContentType(CONTENT_TYPE_XLSX);
|
||||
result.setRowCount(excelAssemblyService.countRows(countries));
|
||||
fileResultMapper.updateById(result);
|
||||
} finally {
|
||||
cn.hutool.core.io.FileUtil.del(xlsx);
|
||||
}
|
||||
}
|
||||
|
||||
private List<PriceTrackMatchShopsVo.PriceTrackShopQueueItem> dedupeQueueItems(List<PriceTrackMatchShopsVo.PriceTrackShopQueueItem> raw) {
|
||||
Map<String, PriceTrackMatchShopsVo.PriceTrackShopQueueItem> byNorm = new LinkedHashMap<>();
|
||||
for (PriceTrackMatchShopsVo.PriceTrackShopQueueItem item : raw) {
|
||||
if (item == null) continue;
|
||||
String k = ziniaoShopSwitchService.normalizeShopName(item.getShopName());
|
||||
if (k.isBlank()) continue;
|
||||
@@ -383,7 +463,7 @@ public class PriceTrackTaskService {
|
||||
return new ArrayList<>(byNorm.values());
|
||||
}
|
||||
|
||||
private PriceTrackResultItemVo toSnapshotVo(FileResultEntity fr, PriceTrackShopQueueItem item, String taskStatus) {
|
||||
private PriceTrackResultItemVo toSnapshotVo(FileResultEntity fr, PriceTrackMatchShopsVo.PriceTrackShopQueueItem item, String taskStatus) {
|
||||
PriceTrackResultItemVo vo = new PriceTrackResultItemVo();
|
||||
vo.setResultId(fr.getId());
|
||||
vo.setTaskId(fr.getTaskId());
|
||||
@@ -416,9 +496,65 @@ public class PriceTrackTaskService {
|
||||
vo.setDownloadUrl(entity.getResultFileUrl() == null || entity.getResultFileUrl().isBlank()
|
||||
? null : ossStorageService.generateFreshDownloadUrl(entity.getResultFileUrl()));
|
||||
vo.setMatched(true);
|
||||
if (entity.getTaskId() != null) {
|
||||
mergeQueueFieldsFromRequest(vo, entity.getTaskId());
|
||||
}
|
||||
return vo;
|
||||
}
|
||||
|
||||
private PriceTrackTaskDetailVo buildTaskDetail(FileTaskEntity task) {
|
||||
PriceTrackTaskDetailVo detail = new PriceTrackTaskDetailVo();
|
||||
detail.setTask(toTaskItemVo(task));
|
||||
List<FileResultEntity> rows = fileResultMapper.selectList(
|
||||
new LambdaQueryWrapper<FileResultEntity>()
|
||||
.eq(FileResultEntity::getTaskId, task.getId())
|
||||
.eq(FileResultEntity::getModuleType, MODULE_TYPE)
|
||||
.orderByAsc(FileResultEntity::getId));
|
||||
for (FileResultEntity fr : rows) {
|
||||
detail.getItems().add(toHistoryItemVo(fr, task.getStatus()));
|
||||
}
|
||||
return detail;
|
||||
}
|
||||
|
||||
private PriceTrackTaskItemVo toTaskItemVo(FileTaskEntity task) {
|
||||
PriceTrackTaskItemVo vo = new PriceTrackTaskItemVo();
|
||||
vo.setId(task.getId());
|
||||
vo.setTaskNo(task.getTaskNo());
|
||||
vo.setStatus(task.getStatus());
|
||||
vo.setErrorMessage(task.getErrorMessage());
|
||||
vo.setCreatedAt(fmt(task.getCreatedAt()));
|
||||
vo.setUpdatedAt(fmt(task.getUpdatedAt()));
|
||||
vo.setFinishedAt(fmt(task.getFinishedAt()));
|
||||
return vo;
|
||||
}
|
||||
|
||||
private void mergeQueueFieldsFromRequest(PriceTrackResultItemVo vo, Long taskId) {
|
||||
FileTaskEntity task = fileTaskMapper.selectById(taskId);
|
||||
if (task == null || task.getRequestJson() == null || task.getRequestJson().isBlank()) return;
|
||||
try {
|
||||
Map<String, Object> payload = objectMapper.readValue(task.getRequestJson(), new TypeReference<Map<String, Object>>() {});
|
||||
Object rawItems = payload.get("items");
|
||||
if (rawItems == null) return;
|
||||
List<PriceTrackMatchShopsVo.PriceTrackShopQueueItem> items = objectMapper.convertValue(
|
||||
rawItems,
|
||||
new TypeReference<List<PriceTrackMatchShopsVo.PriceTrackShopQueueItem>>() {});
|
||||
String key = ziniaoShopSwitchService.normalizeShopName(vo.getShopName());
|
||||
for (PriceTrackMatchShopsVo.PriceTrackShopQueueItem item : items) {
|
||||
if (item == null) continue;
|
||||
if (key.equals(ziniaoShopSwitchService.normalizeShopName(item.getShopName()))) {
|
||||
vo.setShopName(item.getShopName());
|
||||
vo.setShopId(item.getShopId());
|
||||
vo.setPlatform(item.getPlatform());
|
||||
vo.setCompanyName(item.getCompanyName());
|
||||
vo.setMatchStatus(item.getMatchStatus());
|
||||
vo.setMatchMessage(item.getMatchMessage());
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
private List<PriceTrackResultItemVo> buildSnapshotFromDb(Long taskId, String taskStatus) {
|
||||
List<FileResultEntity> rows = fileResultMapper.selectList(
|
||||
new LambdaQueryWrapper<FileResultEntity>()
|
||||
@@ -430,17 +566,70 @@ public class PriceTrackTaskService {
|
||||
return list;
|
||||
}
|
||||
|
||||
private int countAsinResults(PriceTrackSubmitResultRequest.ShopResult payload) {
|
||||
if (payload == null || payload.getCountries() == null) return 0;
|
||||
return payload.getCountries().values().stream()
|
||||
.filter(l -> l != null)
|
||||
.mapToInt(List::size)
|
||||
.sum();
|
||||
}
|
||||
|
||||
private static String safeFileStem(String shopName) {
|
||||
String s = shopName == null ? "shop" : shopName.trim();
|
||||
if (s.isBlank()) s = "shop";
|
||||
return s.replaceAll("[\\\\/:*?\"<>|]", "_");
|
||||
}
|
||||
|
||||
private static String fmt(LocalDateTime t) {
|
||||
return t == null ? null : t.toString();
|
||||
}
|
||||
|
||||
private void updateTaskStatusFromLatestRows(FileTaskEntity task, List<FileResultEntity> latest) {
|
||||
String oldStatus = task.getStatus();
|
||||
int ok = 0;
|
||||
int fail = 0;
|
||||
List<String> allErrors = new ArrayList<>();
|
||||
boolean allDone = true;
|
||||
for (FileResultEntity fr : latest) {
|
||||
boolean success = fr.getSuccess() != null && fr.getSuccess() == 1;
|
||||
boolean failed = fr.getErrorMessage() != null && !fr.getErrorMessage().isBlank();
|
||||
if (success) {
|
||||
ok++;
|
||||
} else if (failed) {
|
||||
fail++;
|
||||
allErrors.add(fr.getSourceFilename() + ": " + fr.getErrorMessage());
|
||||
} else {
|
||||
allDone = false;
|
||||
}
|
||||
}
|
||||
task.setSourceFileCount(latest.size());
|
||||
task.setSuccessFileCount(ok);
|
||||
task.setFailedFileCount(fail);
|
||||
task.setUpdatedAt(LocalDateTime.now());
|
||||
if (!allDone) {
|
||||
task.setStatus("RUNNING");
|
||||
task.setErrorMessage(null);
|
||||
task.setFinishedAt(null);
|
||||
} else if (ok > 0 && fail == 0) {
|
||||
task.setStatus("SUCCESS");
|
||||
task.setErrorMessage(null);
|
||||
task.setFinishedAt(LocalDateTime.now());
|
||||
} else if (ok > 0) {
|
||||
task.setStatus("SUCCESS");
|
||||
task.setErrorMessage(String.join("; ", allErrors));
|
||||
task.setFinishedAt(LocalDateTime.now());
|
||||
} else {
|
||||
task.setStatus("FAILED");
|
||||
task.setErrorMessage(allErrors.isEmpty() ? "全部店铺处理失败" : String.join("; ", allErrors));
|
||||
task.setFinishedAt(LocalDateTime.now());
|
||||
}
|
||||
try {
|
||||
task.setResultJson(objectMapper.writeValueAsString(buildSnapshotFromDb(task.getId(), task.getStatus())));
|
||||
} catch (Exception ex) {
|
||||
log.warn("[price-track] compact result json failed: {}", ex.getMessage());
|
||||
}
|
||||
fileTaskMapper.updateById(task);
|
||||
log.warn("[price-track] status updated from latest rows taskId={} oldStatus={} newStatus={} ok={} fail={} allDone={} errorMessage={}",
|
||||
task.getId(), oldStatus, task.getStatus(), ok, fail, allDone, task.getErrorMessage());
|
||||
cleanupTaskCacheIfTerminal(task.getId(), task.getStatus());
|
||||
}
|
||||
|
||||
private void cleanupTaskCacheIfTerminal(Long taskId, String taskStatus) {
|
||||
if (!"SUCCESS".equals(taskStatus) && !"FAILED".equals(taskStatus) && !"DELETE_EMPTY".equals(taskStatus)) {
|
||||
return;
|
||||
}
|
||||
priceTrackTaskCacheService.deleteTaskCache(taskId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,15 +2,15 @@ package com.nanri.aiimage.modules.shopkey.controller;
|
||||
|
||||
import com.nanri.aiimage.common.api.ApiResponse;
|
||||
import com.nanri.aiimage.modules.shopkey.model.dto.ShopManageCreateRequest;
|
||||
import com.nanri.aiimage.modules.shopkey.model.dto.ShopManageUpdateRequest;
|
||||
import com.nanri.aiimage.modules.shopkey.model.dto.ShopManageGroupCreateRequest;
|
||||
import com.nanri.aiimage.modules.shopkey.model.dto.ShopManageGroupUpdateRequest;
|
||||
import com.nanri.aiimage.modules.shopkey.model.dto.ShopManageUpdateRequest;
|
||||
import com.nanri.aiimage.modules.shopkey.model.vo.ShopManageCredentialVo;
|
||||
import com.nanri.aiimage.modules.shopkey.model.vo.ShopManageGroupItemVo;
|
||||
import com.nanri.aiimage.modules.shopkey.model.vo.ShopManageItemVo;
|
||||
import com.nanri.aiimage.modules.shopkey.model.vo.ShopManagePageVo;
|
||||
import com.nanri.aiimage.modules.shopkey.model.vo.ShopManageGroupItemVo;
|
||||
import com.nanri.aiimage.modules.shopkey.model.vo.ShopManageCredentialVo;
|
||||
import com.nanri.aiimage.modules.shopkey.service.ShopManageService;
|
||||
import com.nanri.aiimage.modules.shopkey.service.ShopManageGroupService;
|
||||
import com.nanri.aiimage.modules.shopkey.service.ShopManageService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.media.Content;
|
||||
@@ -34,7 +34,7 @@ import org.springframework.web.bind.annotation.RestController;
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@RequestMapping("/api/admin/shop-manages")
|
||||
@Tag(name = "店铺管理", description = "维护店铺信息,支持增删改查。")
|
||||
@Tag(name = "店铺管理", description = "维护店铺信息,支持查询、新建、修改、删除")
|
||||
public class ShopManageController {
|
||||
|
||||
@Value("${aiimage.security.internal-token:}")
|
||||
@@ -44,68 +44,89 @@ public class ShopManageController {
|
||||
private final ShopManageGroupService shopManageGroupService;
|
||||
|
||||
@GetMapping
|
||||
@Operation(summary = "分页查询店铺", description = "分页查询店铺列表。")
|
||||
@Operation(summary = "分页查询店铺", description = "支持按分组、店铺名称、创建人进行筛选")
|
||||
@ApiResponses({
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "查询成功", content = @Content(schema = @Schema(implementation = ShopManagePageVo.class)))
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "查询成功",
|
||||
content = @Content(schema = @Schema(implementation = ShopManagePageVo.class)))
|
||||
})
|
||||
public ApiResponse<ShopManagePageVo> 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) {
|
||||
return ApiResponse.success(shopManageService.page(page, pageSize, groupId, shopName));
|
||||
@Parameter(description = "分组 ID") @RequestParam(required = false) Long groupId,
|
||||
@Parameter(description = "店铺名称") @RequestParam(required = false) String shopName,
|
||||
@Parameter(description = "创建人用户 ID,普通管理员只传自己") @RequestParam(required = false) Long createdById) {
|
||||
return ApiResponse.success(shopManageService.page(page, pageSize, groupId, shopName, createdById));
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
@Operation(summary = "新增店铺", description = "新增一条店铺记录。")
|
||||
public ApiResponse<ShopManageItemVo> create(@Valid @RequestBody ShopManageCreateRequest request) {
|
||||
return ApiResponse.success("创建成功", shopManageService.create(request));
|
||||
@Operation(summary = "新增店铺", description = "创建店铺记录,并写入创建人")
|
||||
public ApiResponse<ShopManageItemVo> create(
|
||||
@Parameter(description = "当前操作人用户 ID") @RequestParam Long operatorId,
|
||||
@Parameter(description = "是否超级管理员") @RequestParam(defaultValue = "false") Boolean superAdmin,
|
||||
@Valid @RequestBody ShopManageCreateRequest request) {
|
||||
return ApiResponse.success("创建成功",
|
||||
shopManageService.create(request, operatorId, Boolean.TRUE.equals(superAdmin)));
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
@Operation(summary = "更新店铺", description = "按 ID 更新一条店铺记录。")
|
||||
@Operation(summary = "更新店铺", description = "按 ID 更新店铺,普通管理员只能修改自己创建的数据")
|
||||
public ApiResponse<ShopManageItemVo> update(
|
||||
@Parameter(description = "主键ID", required = true) @PathVariable Long id,
|
||||
@Parameter(description = "主键 ID", required = true) @PathVariable Long id,
|
||||
@Parameter(description = "当前操作人用户 ID") @RequestParam Long operatorId,
|
||||
@Parameter(description = "是否超级管理员") @RequestParam(defaultValue = "false") Boolean superAdmin,
|
||||
@Valid @RequestBody ShopManageUpdateRequest request) {
|
||||
return ApiResponse.success("更新成功", shopManageService.update(id, request));
|
||||
return ApiResponse.success("更新成功",
|
||||
shopManageService.update(id, operatorId, Boolean.TRUE.equals(superAdmin), request));
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
@Operation(summary = "删除店铺", description = "按 ID 删除一条店铺记录。")
|
||||
public ApiResponse<Void> delete(@Parameter(description = "主键ID", required = true) @PathVariable Long id) {
|
||||
shopManageService.delete(id);
|
||||
@Operation(summary = "删除店铺", description = "按 ID 删除店铺,普通管理员只能删除自己创建的数据")
|
||||
public ApiResponse<Void> delete(
|
||||
@Parameter(description = "主键 ID", required = true) @PathVariable Long id,
|
||||
@Parameter(description = "当前操作人用户 ID") @RequestParam Long operatorId,
|
||||
@Parameter(description = "是否超级管理员") @RequestParam(defaultValue = "false") Boolean superAdmin) {
|
||||
shopManageService.delete(id, operatorId, Boolean.TRUE.equals(superAdmin));
|
||||
return ApiResponse.success("删除成功", null);
|
||||
}
|
||||
|
||||
@GetMapping("/groups")
|
||||
@Operation(summary = "查询分组列表", description = "用于店铺管理下拉选择。")
|
||||
public ApiResponse<java.util.List<ShopManageGroupItemVo>> listGroups() {
|
||||
return ApiResponse.success(shopManageGroupService.list());
|
||||
@Operation(summary = "查询分组列表", description = "普通管理员只返回自己创建的分组,超级管理员返回全部")
|
||||
public ApiResponse<java.util.List<ShopManageGroupItemVo>> listGroups(
|
||||
@Parameter(description = "创建人用户 ID,普通管理员只传自己") @RequestParam(required = false) Long createdById) {
|
||||
return ApiResponse.success(shopManageGroupService.list(createdById));
|
||||
}
|
||||
|
||||
@PostMapping("/groups")
|
||||
@Operation(summary = "新增分组", description = "新增一条店铺分组。")
|
||||
@Operation(summary = "新增分组", description = "创建分组并写入创建人")
|
||||
public ApiResponse<ShopManageGroupItemVo> createGroup(@Valid @RequestBody ShopManageGroupCreateRequest request) {
|
||||
return ApiResponse.success("创建成功", shopManageGroupService.create(request));
|
||||
}
|
||||
|
||||
@PutMapping("/groups/{id}")
|
||||
@Operation(summary = "更新分组", description = "按 ID 更新分组名称。")
|
||||
@Operation(summary = "更新分组", description = "普通管理员只能修改自己创建的分组")
|
||||
public ApiResponse<ShopManageGroupItemVo> updateGroup(
|
||||
@Parameter(description = "主键ID", required = true) @PathVariable Long id,
|
||||
@Parameter(description = "主键 ID", required = true) @PathVariable Long id,
|
||||
@Parameter(description = "当前操作人用户 ID") @RequestParam Long operatorId,
|
||||
@Parameter(description = "是否超级管理员") @RequestParam(defaultValue = "false") Boolean superAdmin,
|
||||
@Valid @RequestBody ShopManageGroupUpdateRequest request) {
|
||||
return ApiResponse.success("更新成功", shopManageGroupService.update(id, request));
|
||||
return ApiResponse.success("更新成功",
|
||||
shopManageGroupService.update(id, operatorId, Boolean.TRUE.equals(superAdmin), request));
|
||||
}
|
||||
|
||||
@DeleteMapping("/groups/{id}")
|
||||
@Operation(summary = "删除分组", description = "删除分组(分组下有店铺时不允许删除)。")
|
||||
public ApiResponse<Void> deleteGroup(@Parameter(description = "主键ID", required = true) @PathVariable Long id) {
|
||||
shopManageGroupService.delete(id);
|
||||
@Operation(summary = "删除分组", description = "普通管理员只能删除自己创建的分组")
|
||||
public ApiResponse<Void> deleteGroup(
|
||||
@Parameter(description = "主键 ID", required = true) @PathVariable Long id,
|
||||
@Parameter(description = "当前操作人用户 ID") @RequestParam Long operatorId,
|
||||
@Parameter(description = "是否超级管理员") @RequestParam(defaultValue = "false") Boolean superAdmin) {
|
||||
shopManageGroupService.delete(id, operatorId, Boolean.TRUE.equals(superAdmin));
|
||||
return ApiResponse.success("删除成功", null);
|
||||
}
|
||||
|
||||
@GetMapping("/credential")
|
||||
@Operation(summary = "按店铺名获取明文凭据(内部)", description = "仅供 Python 自动化内部调用,需传 X-Internal-Token。")
|
||||
@Operation(summary = "按店铺名获取明文凭据(内部)", description = "仅供 Python 自动化内部调用,需要传 X-Internal-Token")
|
||||
public ApiResponse<ShopManageCredentialVo> getCredentialByShopName(
|
||||
@RequestParam("shopName") String shopName,
|
||||
@RequestHeader(value = "X-Internal-Token", required = false) String token) {
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.nanri.aiimage.modules.shopkey.controller;
|
||||
|
||||
import com.nanri.aiimage.common.api.ApiResponse;
|
||||
import com.nanri.aiimage.modules.shopkey.model.dto.SkipPriceAsinCreateRequest;
|
||||
import com.nanri.aiimage.modules.shopkey.model.dto.SkipPriceAsinCountryUpdateRequest;
|
||||
import com.nanri.aiimage.modules.shopkey.model.vo.SkipPriceAsinItemVo;
|
||||
import com.nanri.aiimage.modules.shopkey.model.vo.SkipPriceAsinPageVo;
|
||||
import com.nanri.aiimage.modules.shopkey.service.SkipPriceAsinService;
|
||||
@@ -14,6 +15,7 @@ import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
@@ -44,6 +46,15 @@ public class SkipPriceAsinController {
|
||||
return ApiResponse.success("保存成功", skipPriceAsinService.createOrUpdate(request));
|
||||
}
|
||||
|
||||
@PutMapping("/{id}/countries/{country}")
|
||||
@Operation(summary = "编辑指定国家的跳过跟价 ASIN")
|
||||
public ApiResponse<SkipPriceAsinItemVo> updateCountry(
|
||||
@Parameter(description = "主键ID", required = true) @PathVariable Long id,
|
||||
@Parameter(description = "国家编码", required = true) @PathVariable String country,
|
||||
@Valid @RequestBody SkipPriceAsinCountryUpdateRequest request) {
|
||||
return ApiResponse.success("保存成功", skipPriceAsinService.updateCountry(id, country, request.getAsin()));
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}/countries/{country}")
|
||||
@Operation(summary = "删除指定国家的跳过跟价 ASIN")
|
||||
public ApiResponse<Void> deleteCountry(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.nanri.aiimage.modules.shopkey.model.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import lombok.Data;
|
||||
@@ -7,18 +8,27 @@ import lombok.Data;
|
||||
@Data
|
||||
public class ShopManageCreateRequest {
|
||||
|
||||
@Schema(description = "分组 ID", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@NotNull(message = "分组不能为空")
|
||||
private Long groupId;
|
||||
|
||||
@NotBlank(message = "店铺名不能为空")
|
||||
@Schema(description = "店铺名称", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@NotBlank(message = "店铺名称不能为空")
|
||||
private String shopName;
|
||||
|
||||
@NotBlank(message = "店铺商城名不能为空")
|
||||
@Schema(description = "商城名称", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@NotBlank(message = "商城名称不能为空")
|
||||
private String mallName;
|
||||
|
||||
@Schema(description = "登录账号", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@NotBlank(message = "账号不能为空")
|
||||
private String account;
|
||||
|
||||
@Schema(description = "登录密码", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@NotBlank(message = "密码不能为空")
|
||||
private String password;
|
||||
|
||||
@Schema(description = "创建人用户 ID,由后台代理透传", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@NotNull(message = "创建人不能为空")
|
||||
private Long createdById;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
package com.nanri.aiimage.modules.shopkey.model.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class ShopManageGroupCreateRequest {
|
||||
|
||||
@Schema(description = "分组名称", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@NotBlank(message = "分组名称不能为空")
|
||||
private String groupName;
|
||||
|
||||
@Schema(description = "创建人用户 ID,由后台代理透传", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@NotNull(message = "创建人不能为空")
|
||||
private Long createdById;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
package com.nanri.aiimage.modules.shopkey.model.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class ShopManageGroupUpdateRequest {
|
||||
|
||||
@Schema(description = "分组名称", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@NotBlank(message = "分组名称不能为空")
|
||||
private String groupName;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.nanri.aiimage.modules.shopkey.model.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import lombok.Data;
|
||||
@@ -7,18 +8,23 @@ import lombok.Data;
|
||||
@Data
|
||||
public class ShopManageUpdateRequest {
|
||||
|
||||
@Schema(description = "分组 ID", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@NotNull(message = "分组不能为空")
|
||||
private Long groupId;
|
||||
|
||||
@NotBlank(message = "店铺名不能为空")
|
||||
@Schema(description = "店铺名称", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@NotBlank(message = "店铺名称不能为空")
|
||||
private String shopName;
|
||||
|
||||
@NotBlank(message = "店铺商城名不能为空")
|
||||
@Schema(description = "商城名称", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@NotBlank(message = "商城名称不能为空")
|
||||
private String mallName;
|
||||
|
||||
@Schema(description = "登录账号", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@NotBlank(message = "账号不能为空")
|
||||
private String account;
|
||||
|
||||
@Schema(description = "登录密码", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@NotBlank(message = "密码不能为空")
|
||||
private String password;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.nanri.aiimage.modules.shopkey.model.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class SkipPriceAsinCountryUpdateRequest {
|
||||
|
||||
@Schema(description = "ASIN", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@NotBlank(message = "ASIN 不能为空")
|
||||
private String asin;
|
||||
}
|
||||
@@ -17,6 +17,8 @@ public class ShopManageEntity {
|
||||
private Long groupId;
|
||||
private String groupName;
|
||||
private String shopName;
|
||||
@TableField("created_by_id")
|
||||
private Long createdById;
|
||||
@TableField("mall_name")
|
||||
private String mallName;
|
||||
private String account;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.nanri.aiimage.modules.shopkey.model.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
@@ -14,6 +15,8 @@ public class ShopManageGroupEntity {
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
private String groupName;
|
||||
@TableField("created_by_id")
|
||||
private Long createdById;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
|
||||
@@ -6,8 +6,8 @@ import com.nanri.aiimage.modules.shopkey.mapper.ShopManageGroupMapper;
|
||||
import com.nanri.aiimage.modules.shopkey.mapper.ShopManageMapper;
|
||||
import com.nanri.aiimage.modules.shopkey.model.dto.ShopManageGroupCreateRequest;
|
||||
import com.nanri.aiimage.modules.shopkey.model.dto.ShopManageGroupUpdateRequest;
|
||||
import com.nanri.aiimage.modules.shopkey.model.entity.ShopManageGroupEntity;
|
||||
import com.nanri.aiimage.modules.shopkey.model.entity.ShopManageEntity;
|
||||
import com.nanri.aiimage.modules.shopkey.model.entity.ShopManageGroupEntity;
|
||||
import com.nanri.aiimage.modules.shopkey.model.vo.ShopManageGroupItemVo;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -23,7 +23,13 @@ public class ShopManageGroupService {
|
||||
private final ShopManageMapper shopManageMapper;
|
||||
|
||||
public List<ShopManageGroupItemVo> list() {
|
||||
return groupMapper.selectList(new LambdaQueryWrapper<ShopManageGroupEntity>().orderByAsc(ShopManageGroupEntity::getId))
|
||||
return list(null);
|
||||
}
|
||||
|
||||
public List<ShopManageGroupItemVo> list(Long createdById) {
|
||||
return groupMapper.selectList(new LambdaQueryWrapper<ShopManageGroupEntity>()
|
||||
.eq(createdById != null && createdById > 0, ShopManageGroupEntity::getCreatedById, createdById)
|
||||
.orderByAsc(ShopManageGroupEntity::getId))
|
||||
.stream()
|
||||
.map(this::toVo)
|
||||
.toList();
|
||||
@@ -31,34 +37,45 @@ public class ShopManageGroupService {
|
||||
|
||||
@Transactional
|
||||
public ShopManageGroupItemVo create(ShopManageGroupCreateRequest request) {
|
||||
Long createdById = normalizePositiveId(request.getCreatedById(), "创建人不能为空");
|
||||
String groupName = normalizeRequired(request.getGroupName(), "分组名称不能为空");
|
||||
ensureGroupNameUnique(groupName, null);
|
||||
ensureGroupNameUnique(groupName, null, createdById, false);
|
||||
ShopManageGroupEntity entity = new ShopManageGroupEntity();
|
||||
entity.setGroupName(groupName);
|
||||
entity.setCreatedById(createdById);
|
||||
groupMapper.insert(entity);
|
||||
return toVo(getById(entity.getId()));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public ShopManageGroupItemVo update(Long id, ShopManageGroupUpdateRequest request) {
|
||||
public ShopManageGroupItemVo update(Long id, Long operatorId, boolean superAdmin, ShopManageGroupUpdateRequest request) {
|
||||
ShopManageGroupEntity entity = getById(id);
|
||||
validateOwnership(entity, operatorId, superAdmin);
|
||||
String groupName = normalizeRequired(request.getGroupName(), "分组名称不能为空");
|
||||
ensureGroupNameUnique(groupName, id);
|
||||
ensureGroupNameUnique(groupName, id, operatorId, superAdmin);
|
||||
entity.setGroupName(groupName);
|
||||
groupMapper.updateById(entity);
|
||||
return toVo(getById(id));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void delete(Long id) {
|
||||
public void delete(Long id, Long operatorId, boolean superAdmin) {
|
||||
ShopManageGroupEntity entity = getById(id);
|
||||
Long useCount = shopManageMapper.selectCount(new LambdaQueryWrapper<ShopManageEntity>().eq(ShopManageEntity::getGroupId, entity.getId()));
|
||||
validateOwnership(entity, operatorId, superAdmin);
|
||||
Long useCount = shopManageMapper.selectCount(new LambdaQueryWrapper<ShopManageEntity>()
|
||||
.eq(ShopManageEntity::getGroupId, entity.getId()));
|
||||
if (useCount != null && useCount > 0) {
|
||||
throw new BusinessException("该分组下存在店铺,无法删除");
|
||||
}
|
||||
groupMapper.deleteById(entity.getId());
|
||||
}
|
||||
|
||||
public ShopManageGroupEntity getAccessibleById(Long id, Long operatorId, boolean superAdmin) {
|
||||
ShopManageGroupEntity entity = getById(id);
|
||||
validateOwnership(entity, operatorId, superAdmin);
|
||||
return entity;
|
||||
}
|
||||
|
||||
public ShopManageGroupEntity getById(Long id) {
|
||||
ShopManageGroupEntity entity = groupMapper.selectById(id);
|
||||
if (entity == null) {
|
||||
@@ -67,9 +84,12 @@ public class ShopManageGroupService {
|
||||
return entity;
|
||||
}
|
||||
|
||||
private void ensureGroupNameUnique(String groupName, Long excludeId) {
|
||||
private void ensureGroupNameUnique(String groupName, Long excludeId, Long operatorId, boolean superAdmin) {
|
||||
LambdaQueryWrapper<ShopManageGroupEntity> query = new LambdaQueryWrapper<ShopManageGroupEntity>()
|
||||
.eq(ShopManageGroupEntity::getGroupName, groupName);
|
||||
if (!superAdmin) {
|
||||
query.eq(ShopManageGroupEntity::getCreatedById, normalizePositiveId(operatorId, "操作人不能为空"));
|
||||
}
|
||||
if (excludeId != null) {
|
||||
query.ne(ShopManageGroupEntity::getId, excludeId);
|
||||
}
|
||||
@@ -79,6 +99,16 @@ public class ShopManageGroupService {
|
||||
}
|
||||
}
|
||||
|
||||
private void validateOwnership(ShopManageGroupEntity entity, Long operatorId, boolean superAdmin) {
|
||||
if (superAdmin) {
|
||||
return;
|
||||
}
|
||||
Long normalizedOperatorId = normalizePositiveId(operatorId, "操作人不能为空");
|
||||
if (entity.getCreatedById() == null || !entity.getCreatedById().equals(normalizedOperatorId)) {
|
||||
throw new BusinessException("只能操作自己创建的分组数据");
|
||||
}
|
||||
}
|
||||
|
||||
private String normalizeRequired(String value, String message) {
|
||||
String normalized = value == null ? "" : value.trim();
|
||||
if (normalized.isEmpty()) {
|
||||
@@ -87,6 +117,13 @@ public class ShopManageGroupService {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private Long normalizePositiveId(Long value, String message) {
|
||||
if (value == null || value <= 0) {
|
||||
throw new BusinessException(message);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private ShopManageGroupItemVo toVo(ShopManageGroupEntity entity) {
|
||||
ShopManageGroupItemVo vo = new ShopManageGroupItemVo();
|
||||
vo.setId(entity.getId());
|
||||
|
||||
@@ -8,9 +8,9 @@ import com.nanri.aiimage.modules.shopkey.model.dto.ShopManageCreateRequest;
|
||||
import com.nanri.aiimage.modules.shopkey.model.dto.ShopManageUpdateRequest;
|
||||
import com.nanri.aiimage.modules.shopkey.model.entity.ShopManageEntity;
|
||||
import com.nanri.aiimage.modules.shopkey.model.entity.ShopManageGroupEntity;
|
||||
import com.nanri.aiimage.modules.shopkey.model.vo.ShopManageCredentialVo;
|
||||
import com.nanri.aiimage.modules.shopkey.model.vo.ShopManageItemVo;
|
||||
import com.nanri.aiimage.modules.shopkey.model.vo.ShopManagePageVo;
|
||||
import com.nanri.aiimage.modules.shopkey.model.vo.ShopManageCredentialVo;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
@@ -28,16 +28,21 @@ public class ShopManageService {
|
||||
private final ShopCredentialCryptoService shopCredentialCryptoService;
|
||||
|
||||
public ShopManagePageVo page(long page, long pageSize) {
|
||||
return page(page, pageSize, null, null);
|
||||
return page(page, pageSize, null, null, null);
|
||||
}
|
||||
|
||||
public ShopManagePageVo page(long page, long pageSize, Long groupId, String shopName) {
|
||||
return page(page, pageSize, groupId, shopName, null);
|
||||
}
|
||||
|
||||
public ShopManagePageVo page(long page, long pageSize, Long groupId, String shopName, Long createdById) {
|
||||
long safePage = Math.max(page, 1);
|
||||
long safePageSize = Math.min(Math.max(pageSize, 1), 100);
|
||||
String safeShopName = shopName == null ? "" : shopName.trim();
|
||||
|
||||
LambdaQueryWrapper<ShopManageEntity> query = new LambdaQueryWrapper<ShopManageEntity>()
|
||||
.eq(groupId != null && groupId > 0, ShopManageEntity::getGroupId, groupId)
|
||||
.eq(createdById != null && createdById > 0, ShopManageEntity::getCreatedById, createdById)
|
||||
.like(!safeShopName.isEmpty(), ShopManageEntity::getShopName, safeShopName)
|
||||
.orderByDesc(ShopManageEntity::getId);
|
||||
Long total = shopManageMapper.selectCount(query);
|
||||
@@ -62,16 +67,18 @@ public class ShopManageService {
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public ShopManageItemVo create(ShopManageCreateRequest request) {
|
||||
ShopManageGroupEntity group = shopManageGroupService.getById(request.getGroupId());
|
||||
String shopName = normalizeRequired(request.getShopName(), "店铺名不能为空");
|
||||
String mallName = normalizeRequired(request.getMallName(), "店铺商城名不能为空");
|
||||
public ShopManageItemVo create(ShopManageCreateRequest request, Long operatorId, boolean superAdmin) {
|
||||
Long createdById = normalizePositiveId(request.getCreatedById(), "创建人不能为空");
|
||||
ShopManageGroupEntity group = shopManageGroupService.getAccessibleById(request.getGroupId(), operatorId, superAdmin);
|
||||
String shopName = normalizeRequired(request.getShopName(), "店铺名称不能为空");
|
||||
String mallName = normalizeRequired(request.getMallName(), "商城名称不能为空");
|
||||
ensureShopNameUnique(shopName, null);
|
||||
|
||||
ShopManageEntity entity = new ShopManageEntity();
|
||||
entity.setGroupId(group.getId());
|
||||
entity.setGroupName(group.getGroupName());
|
||||
entity.setShopName(shopName);
|
||||
entity.setCreatedById(createdById);
|
||||
entity.setMallName(mallName);
|
||||
entity.setAccount(normalizeRequired(request.getAccount(), "账号不能为空"));
|
||||
entity.setPassword(shopCredentialCryptoService.encrypt(normalizeRequired(request.getPassword(), "密码不能为空")));
|
||||
@@ -81,11 +88,12 @@ public class ShopManageService {
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public ShopManageItemVo update(Long id, ShopManageUpdateRequest request) {
|
||||
public ShopManageItemVo update(Long id, Long operatorId, boolean superAdmin, ShopManageUpdateRequest request) {
|
||||
ShopManageEntity entity = getById(id);
|
||||
ShopManageGroupEntity group = shopManageGroupService.getById(request.getGroupId());
|
||||
String shopName = normalizeRequired(request.getShopName(), "店铺名不能为空");
|
||||
String mallName = normalizeRequired(request.getMallName(), "店铺商城名不能为空");
|
||||
validateOwnership(entity, operatorId, superAdmin);
|
||||
ShopManageGroupEntity group = shopManageGroupService.getAccessibleById(request.getGroupId(), operatorId, superAdmin);
|
||||
String shopName = normalizeRequired(request.getShopName(), "店铺名称不能为空");
|
||||
String mallName = normalizeRequired(request.getMallName(), "商城名称不能为空");
|
||||
ensureShopNameUnique(shopName, id);
|
||||
|
||||
entity.setGroupId(group.getId());
|
||||
@@ -100,13 +108,14 @@ public class ShopManageService {
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void delete(Long id) {
|
||||
public void delete(Long id, Long operatorId, boolean superAdmin) {
|
||||
ShopManageEntity entity = getById(id);
|
||||
validateOwnership(entity, operatorId, superAdmin);
|
||||
shopManageMapper.deleteById(entity.getId());
|
||||
}
|
||||
|
||||
public ShopManageCredentialVo getCredentialByShopName(String shopName) {
|
||||
String normalizedShopName = normalizeRequired(shopName, "店铺名不能为空");
|
||||
String normalizedShopName = normalizeRequired(shopName, "店铺名称不能为空");
|
||||
ShopManageEntity entity = shopManageMapper.selectOne(new LambdaQueryWrapper<ShopManageEntity>()
|
||||
.eq(ShopManageEntity::getShopName, normalizedShopName)
|
||||
.last("limit 1"));
|
||||
@@ -137,6 +146,16 @@ public class ShopManageService {
|
||||
return entity;
|
||||
}
|
||||
|
||||
private void validateOwnership(ShopManageEntity entity, Long operatorId, boolean superAdmin) {
|
||||
if (superAdmin) {
|
||||
return;
|
||||
}
|
||||
Long normalizedOperatorId = normalizePositiveId(operatorId, "操作人不能为空");
|
||||
if (entity.getCreatedById() == null || !entity.getCreatedById().equals(normalizedOperatorId)) {
|
||||
throw new BusinessException("只能操作自己创建的店铺数据");
|
||||
}
|
||||
}
|
||||
|
||||
private String normalizeRequired(String value, String message) {
|
||||
String normalized = value == null ? "" : value.trim();
|
||||
if (normalized.isEmpty()) {
|
||||
@@ -145,6 +164,13 @@ public class ShopManageService {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private Long normalizePositiveId(Long value, String message) {
|
||||
if (value == null || value <= 0) {
|
||||
throw new BusinessException(message);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private void ensureShopNameUnique(String shopName, Long excludeId) {
|
||||
LambdaQueryWrapper<ShopManageEntity> query = new LambdaQueryWrapper<ShopManageEntity>()
|
||||
.eq(ShopManageEntity::getShopName, shopName);
|
||||
@@ -153,7 +179,7 @@ public class ShopManageService {
|
||||
}
|
||||
Long count = shopManageMapper.selectCount(query);
|
||||
if (count != null && count > 0) {
|
||||
throw new BusinessException("店铺名已存在");
|
||||
throw new BusinessException("店铺名称已存在");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,8 +190,8 @@ public class ShopManageService {
|
||||
LinkedHashMap<Long, String> map = new LinkedHashMap<>();
|
||||
for (Long groupId : groupIds) {
|
||||
try {
|
||||
ShopManageGroupEntity g = shopManageGroupService.getById(groupId);
|
||||
map.put(groupId, g.getGroupName());
|
||||
ShopManageGroupEntity group = shopManageGroupService.getById(groupId);
|
||||
map.put(groupId, group.getGroupName());
|
||||
} catch (Exception ignored) {
|
||||
map.put(groupId, "");
|
||||
}
|
||||
|
||||
@@ -105,6 +105,24 @@ public class SkipPriceAsinService {
|
||||
skipPriceAsinMapper.updateById(entity);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public SkipPriceAsinItemVo updateCountry(Long id, String country, String asin) {
|
||||
SkipPriceAsinEntity entity = getById(id);
|
||||
String normalizedCountry = normalizeCountry(country);
|
||||
String normalizedAsin = normalizeAsin(asin);
|
||||
setCountryAsin(entity, normalizedCountry, normalizedAsin);
|
||||
skipPriceAsinMapper.updateById(entity);
|
||||
SkipPriceAsinEntity saved = getById(entity.getId());
|
||||
String groupName = "";
|
||||
try {
|
||||
ShopManageGroupEntity group = shopManageGroupService.getById(saved.getGroupId());
|
||||
groupName = group.getGroupName();
|
||||
} catch (Exception ignored) {
|
||||
groupName = "";
|
||||
}
|
||||
return toItemVo(saved, groupName);
|
||||
}
|
||||
|
||||
private SkipPriceAsinEntity getById(Long id) {
|
||||
SkipPriceAsinEntity entity = skipPriceAsinMapper.selectById(id);
|
||||
if (entity == null) {
|
||||
|
||||
@@ -84,12 +84,14 @@ aiimage:
|
||||
finalize-check-cron: ${AIIMAGE_DELETE_BRAND_FINALIZE_CHECK_CRON:30 */2 * * * *}
|
||||
product-risk-stale-timeout-minutes: ${AIIMAGE_PRODUCT_RISK_STALE_TIMEOUT_MINUTES:10}
|
||||
product-risk-initial-timeout-minutes: ${AIIMAGE_PRODUCT_RISK_INITIAL_TIMEOUT_MINUTES:10}
|
||||
price-track-stale-timeout-minutes: ${AIIMAGE_PRICE_TRACK_STALE_TIMEOUT_MINUTES:10}
|
||||
price-track-initial-timeout-minutes: ${AIIMAGE_PRICE_TRACK_INITIAL_TIMEOUT_MINUTES:10}
|
||||
shop-match-stale-timeout-minutes: ${AIIMAGE_SHOP_MATCH_STALE_TIMEOUT_MINUTES:10}
|
||||
shop-match-initial-timeout-minutes: ${AIIMAGE_SHOP_MATCH_INITIAL_TIMEOUT_MINUTES:10}
|
||||
module-cleanup:
|
||||
enabled: ${AIIMAGE_MODULE_CLEANUP_ENABLED:true}
|
||||
cron: ${AIIMAGE_MODULE_CLEANUP_CRON:0 0 0 * * *}
|
||||
module-types: ${AIIMAGE_MODULE_CLEANUP_MODULE_TYPES:DEDUPE,SPLIT,CONVERT,DELETE_BRAND,PRODUCT_RISK_RESOLVE,SHOP_MATCH}
|
||||
module-types: ${AIIMAGE_MODULE_CLEANUP_MODULE_TYPES:DEDUPE,SPLIT,CONVERT,DELETE_BRAND,PRODUCT_RISK_RESOLVE,PRICE_TRACK,SHOP_MATCH}
|
||||
security:
|
||||
shop-credential-key: ${AIIMAGE_SHOP_CREDENTIAL_KEY:change-me-shop-credential-key}
|
||||
internal-token: ${AIIMAGE_INTERNAL_TOKEN:}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
SET @db_name = DATABASE();
|
||||
|
||||
SET @created_by_id_col_exists := (
|
||||
SELECT COUNT(*)
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = @db_name
|
||||
AND TABLE_NAME = 'biz_shop_manage'
|
||||
AND COLUMN_NAME = 'created_by_id'
|
||||
);
|
||||
|
||||
SET @sql_add_created_by_id_col := IF(@created_by_id_col_exists = 0,
|
||||
'ALTER TABLE biz_shop_manage ADD COLUMN created_by_id BIGINT NULL COMMENT ''创建人用户ID'' AFTER shop_name',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE stmt_add_created_by_id_col FROM @sql_add_created_by_id_col;
|
||||
EXECUTE stmt_add_created_by_id_col;
|
||||
DEALLOCATE PREPARE stmt_add_created_by_id_col;
|
||||
|
||||
SET @created_by_id_idx_exists := (
|
||||
SELECT COUNT(*)
|
||||
FROM information_schema.STATISTICS
|
||||
WHERE TABLE_SCHEMA = @db_name
|
||||
AND TABLE_NAME = 'biz_shop_manage'
|
||||
AND INDEX_NAME = 'idx_created_by_id'
|
||||
);
|
||||
|
||||
SET @sql_add_created_by_id_idx := IF(@created_by_id_idx_exists = 0,
|
||||
'ALTER TABLE biz_shop_manage ADD INDEX idx_created_by_id (created_by_id)',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE stmt_add_created_by_id_idx FROM @sql_add_created_by_id_idx;
|
||||
EXECUTE stmt_add_created_by_id_idx;
|
||||
DEALLOCATE PREPARE stmt_add_created_by_id_idx;
|
||||
@@ -0,0 +1,33 @@
|
||||
SET @db_name = DATABASE();
|
||||
|
||||
SET @created_by_id_col_exists := (
|
||||
SELECT COUNT(*)
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = @db_name
|
||||
AND TABLE_NAME = 'biz_shop_manage_group'
|
||||
AND COLUMN_NAME = 'created_by_id'
|
||||
);
|
||||
|
||||
SET @sql_add_created_by_id_col := IF(@created_by_id_col_exists = 0,
|
||||
'ALTER TABLE biz_shop_manage_group ADD COLUMN created_by_id BIGINT NULL COMMENT ''创建人用户ID'' AFTER group_name',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE stmt_add_created_by_id_col FROM @sql_add_created_by_id_col;
|
||||
EXECUTE stmt_add_created_by_id_col;
|
||||
DEALLOCATE PREPARE stmt_add_created_by_id_col;
|
||||
|
||||
SET @created_by_id_idx_exists := (
|
||||
SELECT COUNT(*)
|
||||
FROM information_schema.STATISTICS
|
||||
WHERE TABLE_SCHEMA = @db_name
|
||||
AND TABLE_NAME = 'biz_shop_manage_group'
|
||||
AND INDEX_NAME = 'idx_created_by_id'
|
||||
);
|
||||
|
||||
SET @sql_add_created_by_id_idx := IF(@created_by_id_idx_exists = 0,
|
||||
'ALTER TABLE biz_shop_manage_group ADD INDEX idx_created_by_id (created_by_id)',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE stmt_add_created_by_id_idx FROM @sql_add_created_by_id_idx;
|
||||
EXECUTE stmt_add_created_by_id_idx;
|
||||
DEALLOCATE PREPARE stmt_add_created_by_id_idx;
|
||||
Binary file not shown.
Binary file not shown.
@@ -32,7 +32,7 @@ def _ensure_dedupe_total_data_access():
|
||||
return _ensure_admin_menu_access('dedupe-total-data')
|
||||
role, current_row = get_current_admin_role()
|
||||
if not _can_access_dedupe_total_data(role, current_row):
|
||||
return None, None, (jsonify({'success': False, 'error': '无权限访问数据去重总数据'}), 403)
|
||||
return None, None, (jsonify({'success': False, 'error': '无权访问数据去重汇总数据'}), 403)
|
||||
return role, current_row, None
|
||||
from ali_oss import upload_file as oss_upload_file
|
||||
|
||||
@@ -48,23 +48,23 @@ ADMIN_MENU_ACCESS_CONFIG = {
|
||||
'dedupe-total-data': {
|
||||
'column_key': 'admin_dedupe_total_data',
|
||||
'route_path': 'dedupe-total-data',
|
||||
'error': '鏃犳潈闄愯闂暟鎹幓閲嶆€绘暟鎹?',
|
||||
'error': '无权访问数据去重汇总数据',
|
||||
},
|
||||
'shop-manage': {
|
||||
'column_key': 'admin_shop_manage',
|
||||
'route_path': 'shop-manage',
|
||||
'error': '鏃犳潈闄愯闂簵閾虹鐞嗘ā鍧?',
|
||||
'error': '无权访问店铺管理模块',
|
||||
},
|
||||
'skip-price-asin': {
|
||||
'column_key': 'admin_skip_price_asin',
|
||||
'route_path': 'skip-price-asin',
|
||||
'error': '鏃犳潈闄愯闂烦杩囪窡浠稟SIN 妯″潡',
|
||||
'error': '无权访问跳过跟价 ASIN 模块',
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _safe_version_key(version):
|
||||
"""将版本号转为安全的 OSS 对象名部分"""
|
||||
"""将版本号转换为安全的 OSS 对象名片段"""
|
||||
s = (version or '').strip()
|
||||
s = re.sub(r'[^\w.\-]', '_', s)
|
||||
return s or 'unknown'
|
||||
@@ -138,7 +138,7 @@ def _ensure_admin_menu_access(*menu_names):
|
||||
if role == 'super_admin':
|
||||
return role, current_row, None
|
||||
if role != 'admin' or not current_row:
|
||||
return role, current_row, (jsonify({'success': False, 'error': '闇€瑕佺鐞嗗憳鏉冮檺'}), 403)
|
||||
return role, current_row, (jsonify({'success': False, 'error': '需要管理员权限'}), 403)
|
||||
key_set, route_set = _get_current_admin_permission_sets()
|
||||
for menu_name in menu_names:
|
||||
config = ADMIN_MENU_ACCESS_CONFIG.get(menu_name) or {}
|
||||
@@ -148,7 +148,7 @@ def _ensure_admin_menu_access(*menu_names):
|
||||
return role, current_row, None
|
||||
fallback_menu = menu_names[0] if menu_names else ''
|
||||
fallback_config = ADMIN_MENU_ACCESS_CONFIG.get(fallback_menu) or {}
|
||||
error_message = fallback_config.get('error') or '鏃犳潈闄愯闂綋鍓嶆ā鍧?'
|
||||
error_message = fallback_config.get('error') or '无权访问当前模块'
|
||||
return role, current_row, (jsonify({'success': False, 'error': error_message}), 403)
|
||||
|
||||
|
||||
@@ -157,7 +157,7 @@ def _ensure_admin_menu_access(*menu_names):
|
||||
@admin_api.route('/users')
|
||||
@admin_required
|
||||
def list_users():
|
||||
"""分页获取用户列表;支持用户名模糊搜索、指定管理员所属普通用户筛选"""
|
||||
"""分页获取用户列表,支持用户名模糊搜索和按管理员归属筛选普通用户"""
|
||||
role, current_row = get_current_admin_role()
|
||||
if not role:
|
||||
return jsonify({'success': False, 'error': '需要管理员权限'}), 403
|
||||
@@ -998,7 +998,7 @@ def delete_dedupe_total_data(item_id):
|
||||
@admin_api.route('/shop-manages')
|
||||
@admin_required
|
||||
def list_shop_manages():
|
||||
_, _, denied = _ensure_admin_menu_access('shop-manage')
|
||||
role, current_row, denied = _ensure_admin_menu_access('shop-manage')
|
||||
if denied:
|
||||
return denied
|
||||
page = max(1, int(request.args.get('page', 1)))
|
||||
@@ -1007,6 +1007,8 @@ def list_shop_manages():
|
||||
shop_name = (request.args.get('shop_name') or '').strip()
|
||||
|
||||
params = {'page': page, 'pageSize': page_size}
|
||||
if role != 'super_admin' and current_row and current_row.get('id'):
|
||||
params['createdById'] = current_row.get('id')
|
||||
if group_id_raw:
|
||||
try:
|
||||
group_id = int(group_id_raw)
|
||||
@@ -1051,7 +1053,7 @@ def list_shop_manages():
|
||||
@admin_api.route('/shop-manage', methods=['POST'])
|
||||
@admin_required
|
||||
def create_shop_manage():
|
||||
_, _, denied = _ensure_admin_menu_access('shop-manage')
|
||||
role, current_row, denied = _ensure_admin_menu_access('shop-manage')
|
||||
if denied:
|
||||
return denied
|
||||
data = request.get_json() or {}
|
||||
@@ -1061,10 +1063,15 @@ def create_shop_manage():
|
||||
'mallName': (data.get('mall_name') or '').strip(),
|
||||
'account': (data.get('account') or '').strip(),
|
||||
'password': (data.get('password') or '').strip(),
|
||||
'createdById': current_row.get('id') if current_row else None,
|
||||
}
|
||||
result, error_response, status = _proxy_backend_java(
|
||||
'POST',
|
||||
'/api/admin/shop-manages',
|
||||
params={
|
||||
'operatorId': current_row.get('id') if current_row else None,
|
||||
'superAdmin': 'true' if role == 'super_admin' else 'false',
|
||||
},
|
||||
json_data=payload,
|
||||
)
|
||||
if error_response is not None:
|
||||
@@ -1090,7 +1097,7 @@ def create_shop_manage():
|
||||
@admin_api.route('/shop-manage/<int:item_id>', methods=['PUT'])
|
||||
@admin_required
|
||||
def update_shop_manage(item_id):
|
||||
_, _, denied = _ensure_admin_menu_access('shop-manage')
|
||||
role, current_row, denied = _ensure_admin_menu_access('shop-manage')
|
||||
if denied:
|
||||
return denied
|
||||
data = request.get_json() or {}
|
||||
@@ -1104,6 +1111,10 @@ def update_shop_manage(item_id):
|
||||
result, error_response, status = _proxy_backend_java(
|
||||
'PUT',
|
||||
f'/api/admin/shop-manages/{item_id}',
|
||||
params={
|
||||
'operatorId': current_row.get('id') if current_row else None,
|
||||
'superAdmin': 'true' if role == 'super_admin' else 'false',
|
||||
},
|
||||
json_data=payload,
|
||||
)
|
||||
if error_response is not None:
|
||||
@@ -1128,12 +1139,16 @@ def update_shop_manage(item_id):
|
||||
@admin_api.route('/shop-manage/<int:item_id>', methods=['DELETE'])
|
||||
@admin_required
|
||||
def delete_shop_manage(item_id):
|
||||
_, _, denied = _ensure_admin_menu_access('shop-manage')
|
||||
role, current_row, denied = _ensure_admin_menu_access('shop-manage')
|
||||
if denied:
|
||||
return denied
|
||||
result, error_response, status = _proxy_backend_java(
|
||||
'DELETE',
|
||||
f'/api/admin/shop-manages/{item_id}',
|
||||
params={
|
||||
'operatorId': current_row.get('id') if current_row else None,
|
||||
'superAdmin': 'true' if role == 'super_admin' else 'false',
|
||||
},
|
||||
)
|
||||
if error_response is not None:
|
||||
return error_response, status
|
||||
@@ -1146,12 +1161,13 @@ def delete_shop_manage(item_id):
|
||||
@admin_api.route('/shop-manage-groups')
|
||||
@admin_required
|
||||
def list_shop_manage_groups():
|
||||
_, _, denied = _ensure_admin_menu_access('shop-manage', 'skip-price-asin')
|
||||
role, current_row, denied = _ensure_admin_menu_access('shop-manage', 'skip-price-asin')
|
||||
if denied:
|
||||
return denied
|
||||
result, error_response, status = _proxy_backend_java(
|
||||
'GET',
|
||||
'/api/admin/shop-manages/groups',
|
||||
params={'createdById': current_row.get('id')} if role != 'super_admin' and current_row and current_row.get('id') else None,
|
||||
)
|
||||
if error_response is not None:
|
||||
return error_response, status
|
||||
@@ -1170,11 +1186,14 @@ def list_shop_manage_groups():
|
||||
@admin_api.route('/shop-manage-group', methods=['POST'])
|
||||
@admin_required
|
||||
def create_shop_manage_group():
|
||||
_, _, denied = _ensure_admin_menu_access('shop-manage', 'skip-price-asin')
|
||||
_, current_row, denied = _ensure_admin_menu_access('shop-manage', 'skip-price-asin')
|
||||
if denied:
|
||||
return denied
|
||||
data = request.get_json() or {}
|
||||
payload = {'groupName': (data.get('group_name') or '').strip()}
|
||||
payload = {
|
||||
'groupName': (data.get('group_name') or '').strip(),
|
||||
'createdById': current_row.get('id') if current_row else None,
|
||||
}
|
||||
result, error_response, status = _proxy_backend_java(
|
||||
'POST',
|
||||
'/api/admin/shop-manages/groups',
|
||||
@@ -1198,7 +1217,7 @@ def create_shop_manage_group():
|
||||
@admin_api.route('/shop-manage-group/<int:item_id>', methods=['PUT'])
|
||||
@admin_required
|
||||
def update_shop_manage_group(item_id):
|
||||
_, _, denied = _ensure_admin_menu_access('shop-manage', 'skip-price-asin')
|
||||
role, current_row, denied = _ensure_admin_menu_access('shop-manage', 'skip-price-asin')
|
||||
if denied:
|
||||
return denied
|
||||
data = request.get_json() or {}
|
||||
@@ -1206,6 +1225,10 @@ def update_shop_manage_group(item_id):
|
||||
result, error_response, status = _proxy_backend_java(
|
||||
'PUT',
|
||||
f'/api/admin/shop-manages/groups/{item_id}',
|
||||
params={
|
||||
'operatorId': current_row.get('id') if current_row else None,
|
||||
'superAdmin': 'true' if role == 'super_admin' else 'false',
|
||||
},
|
||||
json_data=payload,
|
||||
)
|
||||
if error_response is not None:
|
||||
@@ -1302,7 +1325,7 @@ def create_skip_price_asin():
|
||||
item = result.get('data') or {}
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'msg': result.get('message') or '淇濆瓨鎴愬姛',
|
||||
'msg': result.get('message') or '保存成功',
|
||||
'item': {
|
||||
'id': item.get('id'),
|
||||
'group_id': item.get('groupId'),
|
||||
@@ -1331,19 +1354,58 @@ def delete_skip_price_asin_country(item_id, country):
|
||||
)
|
||||
if error_response is not None:
|
||||
return error_response, status
|
||||
return jsonify({'success': True, 'msg': result.get('message') or '鍒犻櫎鎴愬姛'})
|
||||
return jsonify({'success': True, 'msg': result.get('message') or '删除成功'})
|
||||
|
||||
|
||||
@admin_api.route('/shop-manage-group/<int:item_id>', methods=['DELETE'])
|
||||
@admin_required
|
||||
def delete_shop_manage_group(item_id):
|
||||
_, _, denied = _ensure_admin_menu_access('shop-manage', 'skip-price-asin')
|
||||
role, current_row, denied = _ensure_admin_menu_access('shop-manage', 'skip-price-asin')
|
||||
if denied:
|
||||
return denied
|
||||
result, error_response, status = _proxy_backend_java(
|
||||
'DELETE',
|
||||
f'/api/admin/shop-manages/groups/{item_id}',
|
||||
params={
|
||||
'operatorId': current_row.get('id') if current_row else None,
|
||||
'superAdmin': 'true' if role == 'super_admin' else 'false',
|
||||
},
|
||||
)
|
||||
if error_response is not None:
|
||||
return error_response, status
|
||||
return jsonify({'success': True, 'msg': result.get('message') or '删除成功'})
|
||||
|
||||
|
||||
@admin_api.route('/skip-price-asin/<int:item_id>/country/<country>', methods=['PUT'])
|
||||
@admin_required
|
||||
def update_skip_price_asin_country(item_id, country):
|
||||
_, _, denied = _ensure_admin_menu_access('skip-price-asin')
|
||||
if denied:
|
||||
return denied
|
||||
data = request.get_json() or {}
|
||||
payload = {'asin': (data.get('asin') or '').strip()}
|
||||
result, error_response, status = _proxy_backend_java(
|
||||
'PUT',
|
||||
f'/api/admin/skip-price-asins/{item_id}/countries/{country}',
|
||||
json_data=payload,
|
||||
)
|
||||
if error_response is not None:
|
||||
return error_response, status
|
||||
item = result.get('data') or {}
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'msg': result.get('message') or '保存成功',
|
||||
'item': {
|
||||
'id': item.get('id'),
|
||||
'group_id': item.get('groupId'),
|
||||
'group_name': item.get('groupName') or '',
|
||||
'shop_name': item.get('shopName') or '',
|
||||
'asin_de': item.get('asinDe') or '',
|
||||
'asin_uk': item.get('asinUk') or '',
|
||||
'asin_fr': item.get('asinFr') or '',
|
||||
'asin_it': item.get('asinIt') or '',
|
||||
'asin_es': item.get('asinEs') or '',
|
||||
'created_at': (item.get('createdAt') or '').replace('T', ' ')[:16],
|
||||
'updated_at': (item.get('updatedAt') or '').replace('T', ' ')[:16],
|
||||
},
|
||||
})
|
||||
|
||||
@@ -23,8 +23,8 @@ bucket_path = "nanri-image/"
|
||||
file_url_pre = f"https://{bucket}.oss-cn-hangzhou.aliyuncs.com/"
|
||||
|
||||
import os
|
||||
# backend_java_base_url = os.environ.get('BACKEND_JAVA_BASE_URL', 'http://127.0.0.1:18080').rstrip('/')
|
||||
backend_java_base_url = os.environ.get('BACKEND_JAVA_BASE_URL', 'http://8.136.19.173:18080').rstrip('/')
|
||||
backend_java_base_url = os.environ.get('BACKEND_JAVA_BASE_URL', 'http://127.0.0.1:18080').rstrip('/')
|
||||
# backend_java_base_url = os.environ.get('BACKEND_JAVA_BASE_URL', 'http://8.136.19.173:18080').rstrip('/')
|
||||
os.environ['OSS_ACCESS_KEY_ID'] = accessKeyId
|
||||
os.environ['OSS_ACCESS_KEY_SECRET'] = accessKeySecret
|
||||
os.environ['SECRET_KEY'] = "ddffc7c1d02121d9554d7b080b2511b6"
|
||||
|
||||
@@ -1995,10 +1995,35 @@
|
||||
if (!value) return '<span style="color:#999;">-</span>';
|
||||
return '<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;">' +
|
||||
'<span>' + value + '</span>' +
|
||||
'<button class="btn btn-sm" data-skip-price-asin-edit="' + item.id + '" data-country="' + country.code + '" data-asin="' + value.replace(/"/g, '"') + '" data-shop-name="' + (item.shop_name || '').replace(/"/g, '"') + '">编辑</button>' +
|
||||
'<button class="btn btn-sm btn-danger" data-skip-price-asin-delete="' + item.id + '" data-country="' + country.code + '" data-shop-name="' + (item.shop_name || '').replace(/"/g, '"') + '">删除</button>' +
|
||||
'</div>';
|
||||
}
|
||||
function bindSkipPriceAsinActions() {
|
||||
document.querySelectorAll('[data-skip-price-asin-edit]').forEach(function(btn) {
|
||||
btn.onclick = function() {
|
||||
var shopName = (btn.dataset.shopName || '').replace(/"/g, '"');
|
||||
var countryCode = btn.dataset.country || '';
|
||||
var currentAsin = (btn.dataset.asin || '').replace(/"/g, '"');
|
||||
var nextAsin = prompt('请输入店铺“' + shopName + '”在 ' + countryCode + ' 的 ASIN', currentAsin);
|
||||
if (nextAsin === null) return;
|
||||
nextAsin = (nextAsin || '').trim();
|
||||
if (!nextAsin) {
|
||||
alert('ASIN 不能为空');
|
||||
return;
|
||||
}
|
||||
fetch('/api/admin/skip-price-asin/' + btn.dataset.skipPriceAsinEdit + '/country/' + countryCode, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ asin: nextAsin })
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(res) {
|
||||
if (res.success) loadSkipPriceAsin(skipPriceAsinPage);
|
||||
else alert(res.error || '保存失败');
|
||||
});
|
||||
};
|
||||
});
|
||||
document.querySelectorAll('[data-skip-price-asin-delete]').forEach(function(btn) {
|
||||
btn.onclick = function() {
|
||||
var shopName = (btn.dataset.shopName || '').replace(/"/g, '"');
|
||||
|
||||
@@ -90,26 +90,6 @@
|
||||
</div>
|
||||
<div v-if="countryPrefSaving" class="country-pref-status">保存中…</div>
|
||||
|
||||
<!-- 价格监控设置 -->
|
||||
<div class="section-title">价格监控设置</div>
|
||||
<div class="price-filter-zone">
|
||||
<div class="price-input-row">
|
||||
<span class="price-label">最低价</span>
|
||||
<el-input v-model.number="minPrice" type="number" clearable placeholder="可不填" class="price-input" />
|
||||
<span class="price-unit">EUR</span>
|
||||
</div>
|
||||
<div class="price-input-row">
|
||||
<span class="price-label">最高价</span>
|
||||
<el-input v-model.number="maxPrice" type="number" clearable placeholder="可不填" class="price-input" />
|
||||
<span class="price-unit">EUR</span>
|
||||
</div>
|
||||
<div class="price-input-row">
|
||||
<span class="price-label">价差比例</span>
|
||||
<el-input v-model.number="priceDiffPercent" type="number" clearable placeholder="如 5 表示 5%" class="price-input" />
|
||||
<span class="price-unit">%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 状态指示 -->
|
||||
<div v-if="statusModeEnabled" class="mode-status-bar">
|
||||
<span class="mode-status-dot active"></span>
|
||||
@@ -257,6 +237,7 @@ import {
|
||||
getPriceTrackDashboard,
|
||||
getPriceTrackHistory,
|
||||
getPriceTrackResultDownloadUrl,
|
||||
getPriceTrackTasksBatch,
|
||||
listPriceTrackCandidates,
|
||||
matchPriceTrackShops,
|
||||
putPriceTrackCountryPreference,
|
||||
@@ -264,6 +245,7 @@ import {
|
||||
type PriceTrackDashboardVo,
|
||||
type PriceTrackHistoryItem,
|
||||
type PriceTrackShopQueueItem,
|
||||
type PriceTrackTaskDetailVo,
|
||||
} from '@/shared/api/java-modules'
|
||||
|
||||
// ========== 类型定义 ==========
|
||||
@@ -293,11 +275,6 @@ const asinModeEnabled = ref(false) // 按指定ASIN文档(自定义)
|
||||
// 上传文件
|
||||
const asinFiles = ref<string[]>([]) // ASIN文档
|
||||
|
||||
// 价格监控
|
||||
const minPrice = ref<number | undefined>(undefined)
|
||||
const maxPrice = ref<number | undefined>(undefined)
|
||||
const priceDiffPercent = ref<number | undefined>(undefined)
|
||||
|
||||
// 是否有有效模式
|
||||
const hasValidMode = computed(() => statusModeEnabled.value || asinModeEnabled.value)
|
||||
|
||||
@@ -317,10 +294,113 @@ const dashboard = ref<PriceTrackDashboardVo>({
|
||||
const historyItems = ref<PriceTrackHistoryItem[]>([])
|
||||
const pollingTaskIds = ref<number[]>([])
|
||||
const taskDetails = ref<Record<number, string>>({})
|
||||
const taskSnapshots = ref<Record<number, any>>({})
|
||||
const taskSnapshots = ref<Record<number, PriceTrackTaskDetailVo>>({})
|
||||
const pollTimer = ref<number | null>(null)
|
||||
const pollingInFlight = ref(false)
|
||||
|
||||
function uidForStorage() {
|
||||
return typeof window !== 'undefined' ? window.localStorage.getItem('uid') || '0' : '0'
|
||||
}
|
||||
|
||||
function sessionKey() {
|
||||
return `price-track:tasks:${uidForStorage()}`
|
||||
}
|
||||
|
||||
function taskStatusStorageKey() {
|
||||
return `price-track:task-status:${uidForStorage()}`
|
||||
}
|
||||
|
||||
function taskSnapshotsStorageKey() {
|
||||
return `price-track:tasks-batch-snapshot:${uidForStorage()}`
|
||||
}
|
||||
|
||||
function matchedItemsStorageKey() {
|
||||
return `price-track:matched-items:${uidForStorage()}`
|
||||
}
|
||||
|
||||
function setStorageJson(key: string, value: unknown, shouldRemove: boolean) {
|
||||
if (typeof window === 'undefined') return
|
||||
try {
|
||||
if (shouldRemove) window.localStorage.removeItem(key)
|
||||
else window.localStorage.setItem(key, JSON.stringify(value))
|
||||
} catch {
|
||||
/* quota */
|
||||
}
|
||||
}
|
||||
|
||||
function loadPollingIdsFromStorage() {
|
||||
try {
|
||||
const raw = typeof window !== 'undefined' ? window.localStorage.getItem(sessionKey()) : null
|
||||
if (!raw) {
|
||||
pollingTaskIds.value = []
|
||||
return
|
||||
}
|
||||
const parsed = JSON.parse(raw) as unknown
|
||||
pollingTaskIds.value = Array.isArray(parsed) ? parsed.filter((n): n is number => typeof n === 'number' && n > 0) : []
|
||||
} catch {
|
||||
pollingTaskIds.value = []
|
||||
}
|
||||
}
|
||||
|
||||
function savePollingIds() {
|
||||
setStorageJson(sessionKey(), pollingTaskIds.value, pollingTaskIds.value.length === 0)
|
||||
}
|
||||
|
||||
function loadTaskDetailsFromStorage() {
|
||||
try {
|
||||
const raw = typeof window !== 'undefined' ? window.localStorage.getItem(taskStatusStorageKey()) : null
|
||||
if (!raw) return
|
||||
const parsed = JSON.parse(raw) as Record<string, unknown>
|
||||
const out: Record<number, string> = {}
|
||||
for (const [k, v] of Object.entries(parsed)) {
|
||||
const id = Number(k)
|
||||
if (id > 0 && typeof v === 'string') out[id] = v
|
||||
}
|
||||
taskDetails.value = out
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
function saveTaskDetailsToStorage() {
|
||||
setStorageJson(taskStatusStorageKey(), taskDetails.value, Object.keys(taskDetails.value).length === 0)
|
||||
}
|
||||
|
||||
function loadTaskSnapshotsFromStorage() {
|
||||
try {
|
||||
const raw = typeof window !== 'undefined' ? window.localStorage.getItem(taskSnapshotsStorageKey()) : null
|
||||
if (!raw) return
|
||||
const parsed = JSON.parse(raw) as Record<string, unknown>
|
||||
const out: Record<number, PriceTrackTaskDetailVo> = {}
|
||||
for (const [k, v] of Object.entries(parsed)) {
|
||||
const id = Number(k)
|
||||
if (id > 0 && v && typeof v === 'object') out[id] = v as PriceTrackTaskDetailVo
|
||||
}
|
||||
taskSnapshots.value = out
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
function saveTaskSnapshotsToStorage() {
|
||||
setStorageJson(taskSnapshotsStorageKey(), taskSnapshots.value, Object.keys(taskSnapshots.value).length === 0)
|
||||
}
|
||||
|
||||
function loadMatchedItemsFromStorage() {
|
||||
try {
|
||||
const raw = typeof window !== 'undefined' ? window.localStorage.getItem(matchedItemsStorageKey()) : null
|
||||
if (!raw) return
|
||||
const parsed = JSON.parse(raw) as unknown
|
||||
if (Array.isArray(parsed)) matchedItems.value = parsed as PriceTrackShopQueueItem[]
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
function saveMatchedItemsToStorage() {
|
||||
setStorageJson(matchedItemsStorageKey(), matchedItems.value, matchedItems.value.length === 0)
|
||||
}
|
||||
|
||||
// ========== 文件上传 ==========
|
||||
async function selectAsinFile() {
|
||||
const api = getPywebviewApi()
|
||||
@@ -463,6 +543,15 @@ async function loadHistory() {
|
||||
}
|
||||
}
|
||||
|
||||
function syncPollingIdsWithHistory() {
|
||||
for (const taskId of [...pollingTaskIds.value]) {
|
||||
const any = historyItems.value.some((r) => r.taskId === taskId)
|
||||
if (!any && !taskSnapshots.value[taskId]) {
|
||||
removePollingTask(taskId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onSelectionChange(rows: PriceTrackCandidateVo[]) {
|
||||
selectedCandidates.value = rows || []
|
||||
}
|
||||
@@ -491,6 +580,7 @@ async function removeCandidate(row: PriceTrackCandidateVo) {
|
||||
await deletePriceTrackCandidate(row.id)
|
||||
await loadCandidates()
|
||||
matchedItems.value = []
|
||||
saveMatchedItemsToStorage()
|
||||
ElMessage.success('已删除')
|
||||
} catch (e) {
|
||||
ElMessage.error(e instanceof Error ? e.message : '删除失败')
|
||||
@@ -519,6 +609,7 @@ async function runMatch() {
|
||||
const res = await matchPriceTrackShops(names)
|
||||
const batch = res.items || []
|
||||
matchedItems.value = [...matchedItems.value, ...batch.filter((n) => !matchedItems.value.some((e) => e.shopName === n.shopName))]
|
||||
saveMatchedItemsToStorage()
|
||||
ElMessage.success(`匹配 ${batch.length} 个店铺`)
|
||||
} catch (e) {
|
||||
ElMessage.error(e instanceof Error ? e.message : '匹配失败')
|
||||
@@ -549,11 +640,32 @@ function removeMatchedRowsLocally(rows: Array<{ shopName?: string; shopId?: numb
|
||||
if (!rows.length) return
|
||||
const keys = new Set(rows.map((row) => rowKeyForMatch(row)))
|
||||
matchedItems.value = matchedItems.value.filter((row) => !keys.has(rowKeyForMatch(row)))
|
||||
saveMatchedItemsToStorage()
|
||||
}
|
||||
|
||||
async function removeMatchedRow(row: PriceTrackShopQueueItem) {
|
||||
removeMatchedRowsLocally([row])
|
||||
ElMessage.success('已从匹配结果中移除')
|
||||
const key = rowKeyForMatch(row)
|
||||
const backup = [...matchedItems.value]
|
||||
matchedItems.value = matchedItems.value.filter((r) => rowKeyForMatch(r) !== key)
|
||||
saveMatchedItemsToStorage()
|
||||
const name = (row.shopName || '').trim()
|
||||
if (!name) {
|
||||
ElMessage.success('已从匹配结果中移除')
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res = await deletePendingPriceTrackShopResult(name)
|
||||
if (res?.removed) {
|
||||
await loadHistory()
|
||||
await loadDashboard()
|
||||
syncPollingIdsWithHistory()
|
||||
}
|
||||
ElMessage.success('已从匹配结果中移除')
|
||||
} catch (e) {
|
||||
matchedItems.value = backup
|
||||
saveMatchedItemsToStorage()
|
||||
ElMessage.error(e instanceof Error ? e.message : '同步后端失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function pushToPythonQueue() {
|
||||
@@ -582,11 +694,21 @@ async function pushToPythonQueue() {
|
||||
items: matchedRows as unknown as Record<string, unknown>[],
|
||||
asinFiles: asinFiles.value,
|
||||
countryCodes: [...orderedCountryCodes.value],
|
||||
minPrice: minPrice.value,
|
||||
maxPrice: maxPrice.value,
|
||||
priceDiffPercent: priceDiffPercent.value,
|
||||
}
|
||||
const taskVo = await createPriceTrackTask(taskReq)
|
||||
taskSnapshots.value = {
|
||||
...taskSnapshots.value,
|
||||
[taskVo.taskId]: {
|
||||
task: { id: taskVo.taskId, status: 'RUNNING' },
|
||||
items: taskVo.items,
|
||||
},
|
||||
}
|
||||
taskDetails.value = {
|
||||
...taskDetails.value,
|
||||
[taskVo.taskId]: 'RUNNING',
|
||||
}
|
||||
saveTaskSnapshotsToStorage()
|
||||
saveTaskDetailsToStorage()
|
||||
|
||||
// 2. 推送 taskId 给 Python,Python 会从 Java 拉取完整任务上下文
|
||||
const queuePayload = {
|
||||
@@ -605,6 +727,7 @@ async function pushToPythonQueue() {
|
||||
if (pushResult?.success) {
|
||||
queuePushResult.value = `任务 ${taskVo.taskId} 已入队,等待执行完成...`
|
||||
addPollingTask(taskVo.taskId)
|
||||
scheduleNextPoll(true)
|
||||
ElMessage.success('已推送到 Python 队列')
|
||||
} else {
|
||||
queuePushResult.value = `推送失败:${pushResult?.error || '未知错误'}`
|
||||
@@ -627,24 +750,37 @@ async function refreshTaskBatch() {
|
||||
const ids = pollingTaskIds.value.filter((id) => id > 0)
|
||||
if (!ids.length) return
|
||||
try {
|
||||
await loadHistory()
|
||||
// 检查是否有任务结束,停止轮询
|
||||
const terminalIds = ids.filter((id) => isTaskTerminal(id))
|
||||
for (const id of terminalIds) {
|
||||
removePollingTask(id)
|
||||
const batch = await getPriceTrackTasksBatch(ids)
|
||||
let changed = false
|
||||
const nextSnapshots = { ...taskSnapshots.value }
|
||||
for (const missingId of batch.missingTaskIds || []) {
|
||||
removePollingTask(missingId)
|
||||
delete nextSnapshots[missingId]
|
||||
}
|
||||
for (const detail of batch.items || []) {
|
||||
const taskId = detail.task?.id
|
||||
const status = detail.task?.status
|
||||
if (typeof taskId !== 'number' || taskId <= 0) continue
|
||||
nextSnapshots[taskId] = detail
|
||||
if (status) {
|
||||
taskDetails.value[taskId] = status
|
||||
if (status === 'SUCCESS' || status === 'FAILED') {
|
||||
removePollingTask(taskId)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
taskSnapshots.value = nextSnapshots
|
||||
saveTaskSnapshotsToStorage()
|
||||
saveTaskDetailsToStorage()
|
||||
await loadHistory()
|
||||
syncPollingIdsWithHistory()
|
||||
if (changed) await loadDashboard()
|
||||
} catch {
|
||||
/* polling noise */
|
||||
}
|
||||
}
|
||||
|
||||
function isTaskTerminal(taskId: number) {
|
||||
const rows = historyItems.value.filter((r) => r.taskId === taskId)
|
||||
if (!rows.length) return false
|
||||
// 全部行都有终态才停止
|
||||
return rows.every((r) => r.taskStatus === 'SUCCESS' || r.taskStatus === 'FAILED')
|
||||
}
|
||||
|
||||
function scheduleNextPoll(immediate = false) {
|
||||
if (pollTimer.value) {
|
||||
if (!immediate) return
|
||||
@@ -670,17 +806,33 @@ function stopPolling() {
|
||||
function addPollingTask(taskId: number) {
|
||||
if (!pollingTaskIds.value.includes(taskId)) {
|
||||
pollingTaskIds.value = [...pollingTaskIds.value, taskId]
|
||||
savePollingIds()
|
||||
}
|
||||
}
|
||||
|
||||
function removePollingTask(taskId: number) {
|
||||
pollingTaskIds.value = pollingTaskIds.value.filter((id) => id !== taskId)
|
||||
savePollingIds()
|
||||
delete taskDetails.value[taskId]
|
||||
saveTaskDetailsToStorage()
|
||||
if (taskSnapshots.value[taskId]) {
|
||||
const next = { ...taskSnapshots.value }
|
||||
delete next[taskId]
|
||||
taskSnapshots.value = next
|
||||
saveTaskSnapshotsToStorage()
|
||||
}
|
||||
}
|
||||
|
||||
const currentSectionItems = computed(() =>
|
||||
historyItems.value.filter((row) => row.taskId && pollingTaskIds.value.includes(row.taskId) && !isTaskTerminalById(row.taskId))
|
||||
)
|
||||
const currentSectionItems = computed(() => {
|
||||
const out = historyItems.value.filter((row) => row.taskId && pollingTaskIds.value.includes(row.taskId) && !isTaskTerminalById(row.taskId))
|
||||
const existingTaskIds = new Set(out.map((row) => row.taskId).filter((id): id is number => typeof id === 'number'))
|
||||
for (const taskId of pollingTaskIds.value) {
|
||||
if (existingTaskIds.has(taskId)) continue
|
||||
const first = taskSnapshots.value[taskId]?.items?.[0]
|
||||
if (first) out.push(first)
|
||||
}
|
||||
return out
|
||||
})
|
||||
|
||||
const historySectionItems = computed(() =>
|
||||
historyItems.value.filter((row) => !row.taskId || !pollingTaskIds.value.includes(row.taskId) || isTaskTerminalById(row.taskId))
|
||||
@@ -690,7 +842,7 @@ const hasVisibleList = computed(() => currentSectionItems.value.length > 0 || hi
|
||||
|
||||
function taskStatusOf(taskId?: number) {
|
||||
if (!taskId) return ''
|
||||
return taskDetails.value[taskId] || ''
|
||||
return taskDetails.value[taskId] || taskSnapshots.value[taskId]?.task?.status || ''
|
||||
}
|
||||
|
||||
function isTaskTerminalById(taskId?: number) {
|
||||
@@ -729,7 +881,7 @@ function canDownload(item: PriceTrackHistoryItem) {
|
||||
async function downloadResult(item: PriceTrackHistoryItem) {
|
||||
if (!item.resultId) return
|
||||
const api = getPywebviewApi()
|
||||
const filename = item.outputFilename || `${item.shopName || 'result'}.zip`
|
||||
const filename = item.outputFilename || `${item.shopName || 'result'}.xlsx`
|
||||
if (!api?.save_file_from_url_new) {
|
||||
ElMessage.error('当前客户端未提供 save_file_from_url_new,无法下载文件')
|
||||
return
|
||||
@@ -747,10 +899,14 @@ async function deleteTaskRecord(item: PriceTrackHistoryItem) {
|
||||
try {
|
||||
if (item.resultId != null) {
|
||||
await deletePriceTrackHistory(item.resultId)
|
||||
if (item.taskId != null) removePollingTask(item.taskId)
|
||||
} else if (item.taskId != null) {
|
||||
await deletePriceTrackTask(item.taskId)
|
||||
}
|
||||
if (item.taskId != null) removePollingTask(item.taskId)
|
||||
removeMatchedRowsLocally([item])
|
||||
await loadHistory()
|
||||
await loadDashboard()
|
||||
syncPollingIdsWithHistory()
|
||||
ElMessage.success('已删除')
|
||||
} catch (e) {
|
||||
ElMessage.error(e instanceof Error ? e.message : '删除失败')
|
||||
@@ -759,6 +915,10 @@ async function deleteTaskRecord(item: PriceTrackHistoryItem) {
|
||||
|
||||
// ========== 生命周期 ==========
|
||||
onMounted(() => {
|
||||
loadPollingIdsFromStorage()
|
||||
loadTaskDetailsFromStorage()
|
||||
loadTaskSnapshotsFromStorage()
|
||||
loadMatchedItemsFromStorage()
|
||||
loadCandidates().catch(() => undefined)
|
||||
loadCountryPreference().catch(() => undefined)
|
||||
loadHistory().catch(() => undefined)
|
||||
@@ -1085,49 +1245,12 @@ onUnmounted(() => {
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.price-filter-zone {
|
||||
border: 1px dashed #3a3a3a;
|
||||
border-radius: 10px;
|
||||
padding: 14px;
|
||||
background: #252525;
|
||||
margin-bottom: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.price-input-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.price-label {
|
||||
font-size: 12px;
|
||||
color: #aaa;
|
||||
width: 60px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.price-input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.price-unit {
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
width: 30px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.price-input-row :deep(.el-input__wrapper) {
|
||||
background-color: #2a2a2a;
|
||||
box-shadow: 0 0 0 1px #3a3a3a inset;
|
||||
}
|
||||
|
||||
.price-input-row :deep(.el-input__inner) {
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.empty-candidates {
|
||||
color: #666;
|
||||
|
||||
@@ -215,6 +215,7 @@ async function loadCandidates() { candidates.value = await listShopMatchCandidat
|
||||
async function loadDashboard() { dashboard.value = await getShopMatchDashboard() }
|
||||
async function loadHistory() { const data = await getShopMatchHistory(); historyItems.value = data.items || [] }
|
||||
async function loadCountryPreference() { try { const data = await getShopMatchCountryPreference(); if (countryPrefUserTouched.value) return; const codes = (data.country_codes || []).filter((item) => COUNTRY_OPTIONS.some((option) => option.code === item)); if (codes.length) orderedCountryCodes.value = codes } catch {} }
|
||||
async function refreshTaskViewsBestEffort() { await Promise.allSettled([loadHistory(), loadDashboard()]) }
|
||||
function scheduleSaveCountryPreference() { if (countryPrefSaveTimer) clearTimeout(countryPrefSaveTimer); countryPrefSaveTimer = setTimeout(() => { countryPrefSaveTimer = null; void saveCountryPreference() }, 450) }
|
||||
async function saveCountryPreference() { if (!orderedCountryCodes.value.length) return; countryPrefSaving.value = true; try { await putShopMatchCountryPreference(orderedCountryCodes.value) } catch (error) { ElMessage.error(error instanceof Error ? error.message : '保存国家配置失败') } finally { countryPrefSaving.value = false } }
|
||||
function onSelectionChange(rows: ShopMatchCandidateVo[]) { selectedCandidates.value = rows }
|
||||
@@ -252,7 +253,7 @@ function getPollIntervalMs() { return document.visibilityState === 'visible' ? 4
|
||||
function scheduleNextPoll(immediate = false) { if (pollTimer.value) { if (!immediate) return; window.clearTimeout(pollTimer.value); pollTimer.value = null } const run = async () => { pollTimer.value = null; if (pollingInFlight.value || !pollingTaskIds.value.length) { if (pollingTaskIds.value.length) scheduleNextPoll(); return } pollingInFlight.value = true; try { const { settledTaskIds } = await refreshTaskBatch(); if (settledTaskIds.length) await Promise.allSettled([loadHistory(), loadDashboard()]) } finally { pollingInFlight.value = false } if (pollingTaskIds.value.length) pollTimer.value = window.setTimeout(run, getPollIntervalMs()) }; if (immediate) void run(); else pollTimer.value = window.setTimeout(run, getPollIntervalMs()) }
|
||||
function ensurePolling(immediate = false) { scheduleNextPoll(immediate) }
|
||||
function stopPolling() { if (pollTimer.value) { window.clearTimeout(pollTimer.value); pollTimer.value = null } }
|
||||
async function waitForTaskTerminal(taskId: number) { while (true) { const batch = await getShopMatchTasksBatch([taskId]); if ((batch.missingTaskIds || []).includes(taskId)) { removeTaskLocally(taskId); await loadHistory(); await loadDashboard(); restoreScheduledDispatches(); return 'FAILED' } const detail = (batch.items || []).find((item) => item.task?.id === taskId); const status = detail?.task?.status || ''; if (detail) { taskSnapshots.value = { ...taskSnapshots.value, [taskId]: detail }; saveTaskSnapshotsToStorage() } if (status) { taskDetails.value[taskId] = status; saveTaskDetailsToStorage() } if (isTaskTerminalStatus(status)) { removePollingTask(taskId); await loadHistory(); await loadDashboard(); restoreScheduledDispatches(); return status } await new Promise<void>((resolve) => { window.setTimeout(() => resolve(), getPollIntervalMs()) }) } }
|
||||
async function waitForTaskTerminal(taskId: number) { while (true) { const batch = await getShopMatchTasksBatch([taskId]); if ((batch.missingTaskIds || []).includes(taskId)) { removeTaskLocally(taskId); await refreshTaskViewsBestEffort(); restoreScheduledDispatches(); return 'FAILED' } const detail = (batch.items || []).find((item) => item.task?.id === taskId); const status = detail?.task?.status || ''; if (detail) { taskSnapshots.value = { ...taskSnapshots.value, [taskId]: detail }; saveTaskSnapshotsToStorage() } if (status) { taskDetails.value[taskId] = status; saveTaskDetailsToStorage() } if (isTaskTerminalStatus(status)) { removePollingTask(taskId); await refreshTaskViewsBestEffort(); restoreScheduledDispatches(); return status } await new Promise<void>((resolve) => { window.setTimeout(() => resolve(), getPollIntervalMs()) }) } }
|
||||
function resolvedTaskStatus(item: ShopMatchHistoryItem) { const taskId = normalizeTaskId(item.taskId); if (!taskId) return item.taskStatus || ''; return taskDetails.value[taskId] || taskSnapshots.value[taskId]?.task?.status || item.taskStatus || '' }
|
||||
function taskSnapshotOf(item: ShopMatchHistoryItem) { const taskId = normalizeTaskId(item.taskId); return taskId ? taskSnapshots.value[taskId] : undefined }
|
||||
function currentTaskStageText(item: ShopMatchHistoryItem) { const snapshot = taskSnapshotOf(item); const task = snapshot?.task; const stages = task?.scheduleStages || []; if (!stages.length) return ''; const total = stages.length; const activeIndex = typeof task?.activeStageIndex === 'number' ? task.activeStageIndex : undefined; const currentIndex = typeof task?.currentStageIndex === 'number' ? task.currentStageIndex : undefined; if (typeof activeIndex === 'number') return `执行进度: 第 ${activeIndex + 1}/${total} 次执行中`; if (typeof currentIndex === 'number') return `执行进度: 等待第 ${currentIndex + 1}/${total} 次`; return `执行进度: 共 ${total} 次`}
|
||||
|
||||
@@ -2,6 +2,7 @@ export const LISTING_FILTER_OPTIONS = [
|
||||
{ value: 'SearchSuppressed', label: '在搜索结果中禁止显示' },
|
||||
{ value: 'ApprovalRequired', label: '需要批准' },
|
||||
{ value: 'Active', label: '在售' },
|
||||
{ value: 'DetailPageRemoved', label: '详情页面已删除' },
|
||||
] as const
|
||||
|
||||
export type ListingFilterValue = (typeof LISTING_FILTER_OPTIONS)[number]['value']
|
||||
|
||||
@@ -972,9 +972,38 @@ export interface PriceTrackHistoryVo {
|
||||
|
||||
export interface PriceTrackCreateTaskVo {
|
||||
taskId: number;
|
||||
items: PriceTrackShopQueueItem[];
|
||||
items: PriceTrackHistoryItem[];
|
||||
}
|
||||
|
||||
export interface PriceTrackTaskSummary {
|
||||
id?: number;
|
||||
taskNo?: string;
|
||||
status?: string;
|
||||
errorMessage?: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
finishedAt?: string;
|
||||
}
|
||||
|
||||
export interface PriceTrackTaskDetailVo {
|
||||
task?: PriceTrackTaskSummary;
|
||||
items?: PriceTrackHistoryItem[];
|
||||
}
|
||||
|
||||
export interface PriceTrackTaskBatchVo {
|
||||
items: PriceTrackTaskDetailVo[];
|
||||
missingTaskIds?: number[];
|
||||
}
|
||||
|
||||
export interface PriceTrackPendingDeleteVo {
|
||||
removed: boolean;
|
||||
}
|
||||
|
||||
export type PriceTrackCreateTaskPayload = Omit<
|
||||
PriceTrackCreateTaskRequest,
|
||||
"userId"
|
||||
>;
|
||||
|
||||
export function listPriceTrackCandidates() {
|
||||
return unwrapJavaResponse(
|
||||
get<JavaApiResponse<PriceTrackCandidateVo[]>>(
|
||||
@@ -985,9 +1014,9 @@ export function listPriceTrackCandidates() {
|
||||
|
||||
export function addPriceTrackCandidate(shopName: string) {
|
||||
return unwrapJavaResponse(
|
||||
post<JavaApiResponse<PriceTrackCandidateVo>, { user_id: number; shopName: string }>(
|
||||
post<JavaApiResponse<PriceTrackCandidateVo>, { userId: number; shopName: string }>(
|
||||
`${JAVA_API_PREFIX}/price-track/candidates`,
|
||||
{ user_id: getCurrentUserId(), shopName },
|
||||
{ userId: getCurrentUserId(), shopName },
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -1010,18 +1039,18 @@ export function getPriceTrackCountryPreference() {
|
||||
|
||||
export function putPriceTrackCountryPreference(countryCodes: string[]) {
|
||||
return unwrapJavaResponse(
|
||||
put<JavaApiResponse<PriceTrackCountryPreferenceVo>, { user_id: number; countryCodes: string[] }>(
|
||||
put<JavaApiResponse<PriceTrackCountryPreferenceVo>, { userId: number; countryCodes: string[] }>(
|
||||
`${JAVA_API_PREFIX}/price-track/country-preference`,
|
||||
{ user_id: getCurrentUserId(), countryCodes },
|
||||
{ userId: getCurrentUserId(), countryCodes },
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function matchPriceTrackShops(shopNames: string[]) {
|
||||
return unwrapJavaResponse(
|
||||
post<JavaApiResponse<PriceTrackMatchShopsVo>, { user_id: number; shopNames: string[] }>(
|
||||
post<JavaApiResponse<PriceTrackMatchShopsVo>, { userId: number; shopNames: string[] }>(
|
||||
`${JAVA_API_PREFIX}/price-track/match-shops`,
|
||||
{ user_id: getCurrentUserId(), shopNames },
|
||||
{ userId: getCurrentUserId(), shopNames },
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -1057,16 +1086,15 @@ export interface PriceTrackCreateTaskRequest {
|
||||
items: PriceTrackShopQueueItem[];
|
||||
asinFiles: string[];
|
||||
countryCodes: string[];
|
||||
minPrice?: number;
|
||||
maxPrice?: number;
|
||||
priceDiffPercent?: number;
|
||||
}
|
||||
|
||||
export function createPriceTrackTask(request: PriceTrackCreateTaskRequest) {
|
||||
export function createPriceTrackTask(
|
||||
request: PriceTrackCreateTaskPayload | PriceTrackCreateTaskRequest,
|
||||
) {
|
||||
return unwrapJavaResponse(
|
||||
post<JavaApiResponse<PriceTrackCreateTaskVo>, PriceTrackCreateTaskRequest>(
|
||||
`${JAVA_API_PREFIX}/price-track/tasks`,
|
||||
{ ...request, user_id: getCurrentUserId() },
|
||||
{ ...request, userId: getCurrentUserId() },
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -1079,13 +1107,22 @@ export function deletePriceTrackTask(taskId: number) {
|
||||
);
|
||||
}
|
||||
|
||||
export function getPriceTrackTasksBatch(taskIds: number[]) {
|
||||
return unwrapJavaResponse(
|
||||
post<JavaApiResponse<PriceTrackTaskBatchVo>, { taskIds: number[] }>(
|
||||
`${JAVA_API_PREFIX}/price-track/tasks/batch`,
|
||||
{ taskIds },
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function getPriceTrackResultDownloadUrl(resultId: number) {
|
||||
return getJavaDownloadUrl(`/price-track/results/${resultId}/download`);
|
||||
}
|
||||
|
||||
export function deletePendingPriceTrackShopResult(shopName: string) {
|
||||
return unwrapJavaResponse(
|
||||
del<JavaApiResponse<null>>(
|
||||
del<JavaApiResponse<PriceTrackPendingDeleteVo>>(
|
||||
`${JAVA_API_PREFIX}/price-track/pending-shop-result?user_id=${encodeURIComponent(String(getCurrentUserId()))}&shop_name=${encodeURIComponent(shopName)}`,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -11,6 +11,15 @@ export interface PywebviewApi {
|
||||
minimize?: () => Promise<void>;
|
||||
maximize?: () => Promise<void>;
|
||||
toggle_maximize?: () => Promise<void>;
|
||||
select_files?: (options?: {
|
||||
filters?: Array<{
|
||||
name?: string;
|
||||
extensions?: string;
|
||||
}>;
|
||||
multiple?: boolean;
|
||||
}) => Promise<{
|
||||
paths?: string[];
|
||||
} | null>;
|
||||
save_image?: (
|
||||
urlOrData: string,
|
||||
filename?: string,
|
||||
|
||||
16
new_web_source/convert.html
Normal file
16
new_web_source/convert.html
Normal file
@@ -0,0 +1,16 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<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-DiP0HdY6.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/brand-BATjYfEL.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/pywebview-D-mMH8F6.css">
|
||||
<link rel="stylesheet" crossorigin href="/assets/convert-7wWJ02Tw.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
</body>
|
||||
</html>
|
||||
16
new_web_source/dedupe.html
Normal file
16
new_web_source/dedupe.html
Normal file
@@ -0,0 +1,16 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<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-DiP0HdY6.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/brand-BATjYfEL.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/pywebview-D-mMH8F6.css">
|
||||
<link rel="stylesheet" crossorigin href="/assets/dedupe-BpNHwt51.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
</body>
|
||||
</html>
|
||||
16
new_web_source/delete-brand.html
Normal file
16
new_web_source/delete-brand.html
Normal file
@@ -0,0 +1,16 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<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-DiP0HdY6.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/brand-BATjYfEL.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/pywebview-D-mMH8F6.css">
|
||||
<link rel="stylesheet" crossorigin href="/assets/delete-brand-DfRLfrtm.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
</body>
|
||||
</html>
|
||||
16
new_web_source/price-track.html
Normal file
16
new_web_source/price-track.html
Normal file
@@ -0,0 +1,16 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<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-DiP0HdY6.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/pywebview-D-mMH8F6.css">
|
||||
<link rel="stylesheet" crossorigin href="/assets/price-track-BO5PtL0W.css">
|
||||
<link rel="stylesheet" crossorigin href="/assets/el-input-CezJelDw.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
</body>
|
||||
18
new_web_source/product-risk.html
Normal file
18
new_web_source/product-risk.html
Normal file
@@ -0,0 +1,18 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<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-DiP0HdY6.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/listingFilters-BpGOU_pJ.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/pywebview-D-mMH8F6.css">
|
||||
<link rel="stylesheet" crossorigin href="/assets/listingFilters-CK58rX4v.css">
|
||||
<link rel="stylesheet" crossorigin href="/assets/product-risk-CG-1Iq4P.css">
|
||||
<link rel="stylesheet" crossorigin href="/assets/el-input-CezJelDw.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
</body>
|
||||
18
new_web_source/shop-match.html
Normal file
18
new_web_source/shop-match.html
Normal file
@@ -0,0 +1,18 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<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-DiP0HdY6.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/listingFilters-BpGOU_pJ.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/pywebview-D-mMH8F6.css">
|
||||
<link rel="stylesheet" crossorigin href="/assets/listingFilters-CK58rX4v.css">
|
||||
<link rel="stylesheet" crossorigin href="/assets/shop-match-MsxXMG_G.css">
|
||||
<link rel="stylesheet" crossorigin href="/assets/el-input-CezJelDw.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
</body>
|
||||
</html>
|
||||
16
new_web_source/split.html
Normal file
16
new_web_source/split.html
Normal file
@@ -0,0 +1,16 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<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-DiP0HdY6.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/brand-BATjYfEL.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/pywebview-D-mMH8F6.css">
|
||||
<link rel="stylesheet" crossorigin href="/assets/split-CRUIYKS6.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user