From 0ecf5cd45a3e6fa54591ae2011d257da0aa08122 Mon Sep 17 00:00:00 2001 From: super <2903208875@qq.com> Date: Thu, 18 Jun 2026 18:53:16 +0800 Subject: [PATCH] =?UTF-8?q?=E5=90=8E=E7=AB=AF=E4=BC=98=E5=8C=96=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../config/DeleteBrandProgressProperties.java | 5 + .../config/ModuleCleanupProperties.java | 2 +- .../service/DeleteBrandStaleTaskService.java | 69 + .../PermissionMenuSchemaInitializer.java | 3 +- .../controller/PriceTrackController.java | 16 + .../model/vo/SkipPriceAsinCheckVo.java | 21 + .../service/PriceTrackTaskService.java | 101 +- .../model/vo/ProductRiskDashboardVo.java | 6 +- .../model/vo/ProductRiskShopQueueItemVo.java | 2 + .../model/vo/ProductRiskTaskItemVo.java | 26 +- .../shopkey/service/SkipPriceAsinService.java | 75 + .../service/SimilarAsinTaskService.java | 51 +- .../task/service/TaskHeartbeatService.java | 7 + .../task/service/TaskResultFileJobWorker.java | 7 + .../controller/WithdrawTaskController.java | 179 +++ .../mapper/WithdrawShopCandidateMapper.java | 9 + .../dto/WithdrawCandidateClearRequest.java | 25 + .../model/dto/WithdrawCreateTaskRequest.java | 31 + .../withdraw/model/dto/WithdrawRowDto.java | 34 + .../model/dto/WithdrawShopPayloadDto.java | 37 + .../dto/WithdrawSubmitResultRequest.java | 19 + .../model/dto/WithdrawTaskBatchRequest.java | 14 + .../model/dto/WithdrawTaskItemDto.java | 36 + .../entity/WithdrawShopCandidateEntity.java | 19 + .../withdraw/model/enums/WithdrawStatus.java | 54 + .../model/vo/WithdrawCreateTaskVo.java | 17 + .../withdraw/model/vo/WithdrawHistoryVo.java | 14 + .../model/vo/WithdrawResultItemVo.java | 91 ++ .../model/vo/WithdrawTaskBatchVo.java | 17 + .../service/WithdrawExcelAssemblyService.java | 200 +++ .../service/WithdrawResolveService.java | 177 +++ .../service/WithdrawTaskCacheService.java | 182 +++ .../withdraw/service/WithdrawTaskService.java | 1071 +++++++++++++ .../src/main/resources/application.yml | 3 +- .../db/V61__withdraw_shop_candidate.sql | 8 + ...__skip_price_asin_country_asin_indexes.sql | 76 + .../brand/components/BrandPriceTrackTab.vue | 39 +- .../brand/components/BrandShopMatchTab.vue | 26 +- .../pages/brand/components/BrandTopBar.vue | 3 +- .../brand/components/BrandWithdrawTab.vue | 1360 +++++++++++++++++ frontend-vue/src/shared/api/java-modules.ts | 195 +++ frontend-vue/src/withdraw-main.ts | 14 + frontend-vue/vite.config.ts | 1 + frontend-vue/withdraw.html | 14 + 44 files changed, 4275 insertions(+), 81 deletions(-) create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/model/vo/SkipPriceAsinCheckVo.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/withdraw/controller/WithdrawTaskController.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/withdraw/mapper/WithdrawShopCandidateMapper.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/withdraw/model/dto/WithdrawCandidateClearRequest.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/withdraw/model/dto/WithdrawCreateTaskRequest.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/withdraw/model/dto/WithdrawRowDto.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/withdraw/model/dto/WithdrawShopPayloadDto.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/withdraw/model/dto/WithdrawSubmitResultRequest.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/withdraw/model/dto/WithdrawTaskBatchRequest.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/withdraw/model/dto/WithdrawTaskItemDto.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/withdraw/model/entity/WithdrawShopCandidateEntity.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/withdraw/model/enums/WithdrawStatus.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/withdraw/model/vo/WithdrawCreateTaskVo.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/withdraw/model/vo/WithdrawHistoryVo.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/withdraw/model/vo/WithdrawResultItemVo.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/withdraw/model/vo/WithdrawTaskBatchVo.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/withdraw/service/WithdrawExcelAssemblyService.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/withdraw/service/WithdrawResolveService.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/withdraw/service/WithdrawTaskCacheService.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/withdraw/service/WithdrawTaskService.java create mode 100644 backend-java/src/main/resources/db/V61__withdraw_shop_candidate.sql create mode 100644 backend-java/src/main/resources/db/V62__skip_price_asin_country_asin_indexes.sql create mode 100644 frontend-vue/src/pages/brand/components/BrandWithdrawTab.vue create mode 100644 frontend-vue/src/withdraw-main.ts create mode 100644 frontend-vue/withdraw.html diff --git a/backend-java/src/main/java/com/nanri/aiimage/config/DeleteBrandProgressProperties.java b/backend-java/src/main/java/com/nanri/aiimage/config/DeleteBrandProgressProperties.java index 4081166..6ec284d 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/config/DeleteBrandProgressProperties.java +++ b/backend-java/src/main/java/com/nanri/aiimage/config/DeleteBrandProgressProperties.java @@ -39,4 +39,9 @@ public class DeleteBrandProgressProperties { * Query ASIN RUNNING timeout auto-finalize/fail threshold in minutes. */ private long queryAsinStaleTimeoutMinutes = 30; + + /** + * Withdraw RUNNING timeout auto-finalize/fail threshold in minutes. + */ + private long withdrawStaleTimeoutMinutes = 30; } diff --git a/backend-java/src/main/java/com/nanri/aiimage/config/ModuleCleanupProperties.java b/backend-java/src/main/java/com/nanri/aiimage/config/ModuleCleanupProperties.java index 91907ee..127b514 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/config/ModuleCleanupProperties.java +++ b/backend-java/src/main/java/com/nanri/aiimage/config/ModuleCleanupProperties.java @@ -12,5 +12,5 @@ public class ModuleCleanupProperties { private boolean enabled = true; private String cron = "0 0 0 * * *"; private long retentionDays = 7; - private List moduleTypes = new ArrayList<>(List.of("DEDUPE", "SPLIT", "CONVERT", "DELETE_BRAND", "PRODUCT_RISK_RESOLVE", "PRICE_TRACK", "SHOP_MATCH", "PATROL_DELETE", "QUERY_ASIN", "APPEARANCE_PATENT", "SIMILAR_ASIN", "COLLECT_DATA")); + private List moduleTypes = new ArrayList<>(List.of("DEDUPE", "SPLIT", "CONVERT", "DELETE_BRAND", "PRODUCT_RISK_RESOLVE", "PRICE_TRACK", "SHOP_MATCH", "PATROL_DELETE", "QUERY_ASIN", "WITHDRAW", "APPEARANCE_PATENT", "SIMILAR_ASIN", "COLLECT_DATA")); } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/service/DeleteBrandStaleTaskService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/service/DeleteBrandStaleTaskService.java index 84146e3..044d51a 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/service/DeleteBrandStaleTaskService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/service/DeleteBrandStaleTaskService.java @@ -17,6 +17,8 @@ import com.nanri.aiimage.modules.pricetrack.service.PriceTrackTaskService; import com.nanri.aiimage.modules.similarasin.service.SimilarAsinTaskService; import com.nanri.aiimage.modules.shopmatch.service.ShopMatchTaskCacheService; import com.nanri.aiimage.modules.shopmatch.service.ShopMatchTaskService; +import com.nanri.aiimage.modules.withdraw.service.WithdrawTaskCacheService; +import com.nanri.aiimage.modules.withdraw.service.WithdrawTaskService; import com.nanri.aiimage.modules.task.mapper.FileTaskMapper; import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity; import com.nanri.aiimage.modules.task.service.TaskDistributedLockService; @@ -48,6 +50,7 @@ public class DeleteBrandStaleTaskService { private static final String MODULE_TYPE_SHOP_MATCH = "SHOP_MATCH"; private static final String MODULE_TYPE_PATROL_DELETE = "PATROL_DELETE"; private static final String MODULE_TYPE_QUERY_ASIN = "QUERY_ASIN"; + private static final String MODULE_TYPE_WITHDRAW = "WITHDRAW"; private static final Duration STALE_CHECK_LOCK_TTL = Duration.ofMinutes(10); private static final Duration FINALIZE_CHECK_LOCK_TTL = Duration.ofMinutes(10); private static final Duration TEMP_DIR_CLEANUP_LOCK_TTL = Duration.ofMinutes(10); @@ -66,6 +69,8 @@ public class DeleteBrandStaleTaskService { private final PatrolDeleteTaskCacheService patrolDeleteTaskCacheService; private final QueryAsinTaskService queryAsinTaskService; private final QueryAsinTaskCacheService queryAsinTaskCacheService; + private final WithdrawTaskService withdrawTaskService; + private final WithdrawTaskCacheService withdrawTaskCacheService; private final BrandTaskService brandTaskService; private final AppearancePatentTaskService appearancePatentTaskService; private final SimilarAsinTaskService similarAsinTaskService; @@ -94,6 +99,7 @@ public class DeleteBrandStaleTaskService { ShopMatchStaleCheckStats shopMatchStats = failStaleShopMatchTasks(); ShopMatchStaleCheckStats patrolDeleteStats = failStalePatrolDeleteTasks(); ShopMatchStaleCheckStats queryAsinStats = failStaleQueryAsinTasks(); + ShopMatchStaleCheckStats withdrawStats = failStaleWithdrawTasks(); runModuleStaleCheck("brand", brandTaskService::failStaleRunningTasks); runModuleStaleCheck("appearance-patent", appearancePatentTaskService::finalizeStaleTasks); runModuleStaleCheck("similar-asin", similarAsinTaskService::finalizeStaleTasks); @@ -132,6 +138,13 @@ public class DeleteBrandStaleTaskService { queryAsinStats.skippedTaskCount, System.currentTimeMillis() - startedAt, Thread.currentThread().getName()); + log.info("[stale-check] withdraw summary scanned={} finalized={} failed={} skipped={} elapsedMs={} thread={}", + withdrawStats.scannedTaskCount, + withdrawStats.finalizedTaskCount, + withdrawStats.failedTaskCount, + withdrawStats.skippedTaskCount, + System.currentTimeMillis() - startedAt, + Thread.currentThread().getName()); } } @@ -602,6 +615,62 @@ public class DeleteBrandStaleTaskService { return stats; } + private ShopMatchStaleCheckStats failStaleWithdrawTasks() { + ShopMatchStaleCheckStats stats = new ShopMatchStaleCheckStats(); + long minutes = Math.max(1L, deleteBrandProgressProperties.getWithdrawStaleTimeoutMinutes()); + long staleTimeoutMillis = Duration.ofMinutes(minutes).toMillis(); + long nowMillis = System.currentTimeMillis(); + LocalDateTime threshold = LocalDateTime.now().minusMinutes(minutes); + java.util.List runningTasks = fileTaskMapper.selectList(new LambdaQueryWrapper() + .eq(FileTaskEntity::getModuleType, MODULE_TYPE_WITHDRAW) + .eq(FileTaskEntity::getStatus, "RUNNING") + .lt(FileTaskEntity::getUpdatedAt, threshold) + .last("limit 200")); + stats.scannedTaskCount = runningTasks.size(); + if (runningTasks.isEmpty()) { + return stats; + } + Map heartbeatByTaskId = withdrawTaskCacheService.getTaskHeartbeatMillisBatch( + runningTasks.stream().map(FileTaskEntity::getId).toList()); + for (FileTaskEntity task : runningTasks) { + long lastHeartbeatMillis = heartbeatByTaskId.getOrDefault(task.getId(), 0L); + if (lastHeartbeatMillis > 0L && nowMillis - lastHeartbeatMillis < staleTimeoutMillis) { + stats.skippedTaskCount++; + continue; + } + TaskDistributedLockService.LockHandle taskLockHandle = acquireTaskLock(MODULE_TYPE_WITHDRAW, task.getId()); + if (taskLockHandle == null) { + stats.skippedTaskCount++; + continue; + } + try (taskLockHandle) { + try { + if (withdrawTaskService.tryFinalizeTask(task.getId(), true)) { + stats.finalizedTaskCount++; + continue; + } + } catch (Exception ex) { + log.warn("[stale-check] withdraw finalize threw taskId={} msg={}", task.getId(), ex.getMessage()); + } + int updated = fileTaskMapper.update(null, new LambdaUpdateWrapper() + .eq(FileTaskEntity::getId, task.getId()) + .eq(FileTaskEntity::getModuleType, MODULE_TYPE_WITHDRAW) + .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++; + withdrawTaskCacheService.deleteTaskCache(task.getId()); + } else { + stats.skippedTaskCount++; + } + } + } + return stats; + } + private TaskDistributedLockService.LockHandle acquireTaskLock(String moduleType, Long taskId) { TaskDistributedLockService.LockHandle lockHandle = taskDistributedLockService.acquire(moduleType, taskId, 0L); if (lockHandle == null) { diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/permission/service/PermissionMenuSchemaInitializer.java b/backend-java/src/main/java/com/nanri/aiimage/modules/permission/service/PermissionMenuSchemaInitializer.java index bec699d..a84ac7f 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/permission/service/PermissionMenuSchemaInitializer.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/permission/service/PermissionMenuSchemaInitializer.java @@ -22,7 +22,8 @@ public class PermissionMenuSchemaInitializer { private static final List DEFAULT_APP_MENUS = List.of( new DefaultAppMenu("前端工具", "brand_front_tools", "brand-front-tools", 110), new DefaultAppMenu("运营工具", "brand_operation_tools", "brand-operation-tools", 120), - new DefaultAppMenu("后勤工具", "brand_logistics_tools", "brand-logistics-tools", 130) + new DefaultAppMenu("后勤工具", "brand_logistics_tools", "brand-logistics-tools", 130), + new DefaultAppMenu("取款", "withdraw", "withdraw", 140) ); private static final List DEFAULT_ADMIN_MENUS = List.of( diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/controller/PriceTrackController.java b/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/controller/PriceTrackController.java index 02ca381..46f3001 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/controller/PriceTrackController.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/controller/PriceTrackController.java @@ -20,6 +20,7 @@ import com.nanri.aiimage.modules.pricetrack.model.vo.PriceTrackLoopRunVo; 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.model.vo.SkipPriceAsinCheckVo; import com.nanri.aiimage.modules.pricetrack.model.vo.SkipPriceAsinPageVo; import com.nanri.aiimage.modules.pricetrack.service.PriceTrackLoopRunService; import com.nanri.aiimage.modules.pricetrack.service.PriceTrackService; @@ -125,6 +126,21 @@ public class PriceTrackController { return ApiResponse.success(priceTrackTaskService.getTaskSkipAsinsPaginated(taskId, page, pageSize)); } + @GetMapping("/tasks/{taskId}/skip-asin/check") + @Operation( + summary = "检查单个跳过跟价 ASIN", + description = "Python 按国家和单个 ASIN 查询是否命中跳过跟价库,避免分页加载全量跳过 ASIN。" + ) + public ApiResponse checkTaskSkipAsin( + @Parameter(description = "任务 ID", required = true, example = "200") + @PathVariable Long taskId, + @Parameter(description = "国家代码", required = true, example = "DE") + @RequestParam("country") String country, + @Parameter(description = "ASIN", required = true, example = "B0XXXX") + @RequestParam("asin") String asin) { + return ApiResponse.success(priceTrackTaskService.checkTaskSkipAsin(taskId, country, asin)); + } + @GetMapping("/dashboard") @Operation(summary = "统计看板", description = "返回备选店铺数、已结束任务数、成功任务数和失败任务数。") public ApiResponse dashboard( diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/model/vo/SkipPriceAsinCheckVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/model/vo/SkipPriceAsinCheckVo.java new file mode 100644 index 0000000..5326940 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/model/vo/SkipPriceAsinCheckVo.java @@ -0,0 +1,21 @@ +package com.nanri.aiimage.modules.pricetrack.model.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +@Schema(description = "single skip price asin lookup response") +public class SkipPriceAsinCheckVo { + + @Schema(description = "country code", example = "DE") + private String country; + + @Schema(description = "normalized asin", example = "B0XXXX") + private String asin; + + @Schema(description = "whether the asin exists in skip price asin table") + private boolean exists; + + @Schema(description = "configured minimum price, empty when not configured or not found") + private String minimumPrice; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/service/PriceTrackTaskService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/service/PriceTrackTaskService.java index 544ff7b..8426394 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/service/PriceTrackTaskService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/service/PriceTrackTaskService.java @@ -21,6 +21,8 @@ import com.nanri.aiimage.modules.pricetrack.model.vo.PriceTrackResultItemVo; 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.pricetrack.model.vo.SkipPriceAsinCheckVo; +import com.nanri.aiimage.modules.shopkey.model.dto.SkipPriceAsinDetailDto; import com.nanri.aiimage.modules.shopkey.service.SkipPriceAsinService; import com.nanri.aiimage.modules.task.mapper.FileResultMapper; import com.nanri.aiimage.modules.task.mapper.FileTaskMapper; @@ -367,18 +369,10 @@ public class PriceTrackTaskService { } } - // 汇总店铺名并查询需要跳过的 ASIN - Map> skipAsinsByCountry = request.isAsinMode() - ? new LinkedHashMap<>() - : skipPriceAsinService.listAllSkipAsinsByCountry(); - Map>> skipAsinDetailsByCountry = request.isAsinMode() - ? new LinkedHashMap<>() - : skipPriceAsinService.listAllSkipAsinDetailsByCountry(); - Map>> asinRowsByCountry = request.isAsinMode() - ? parseAsinRowsByCountry(request.getAsinFiles(), request.getCountryCodes()) - : new LinkedHashMap<>(); - Map> minimumPriceByCountryAndAsin = - buildMinimumPriceByCountryAndAsin(asinRowsByCountry); + Map> skipAsinsByCountry = new LinkedHashMap<>(); + Map>> skipAsinDetailsByCountry = new LinkedHashMap<>(); + Map>> asinRowsByCountry = new LinkedHashMap<>(); + Map> minimumPriceByCountryAndAsin = new LinkedHashMap<>(); log.info("[price-track] createTask skipAsins countries={} asinMode={}", skipAsinsByCountry.keySet(), request.isAsinMode()); @@ -788,16 +782,7 @@ public class PriceTrackTaskService { throw new BusinessException("任务不存在"); } - // 解析任务请求参数 - PriceTrackCreateTaskRequest request; - try { - if (task.getRequestJson() == null || task.getRequestJson().isBlank()) { - throw new BusinessException("任务请求参数为空"); - } - request = objectMapper.readValue(task.getRequestJson(), PriceTrackCreateTaskRequest.class); - } catch (Exception e) { - throw new BusinessException("任务请求参数解析失败: " + e.getMessage()); - } + PriceTrackCreateTaskRequest request = parseTaskRequest(task); // 从任务中获取国家代码 List countryCodes = request.getCountryCodes(); @@ -814,6 +799,78 @@ public class PriceTrackTaskService { } } + public SkipPriceAsinCheckVo checkTaskSkipAsin(Long taskId, String country, String asin) { + if (taskId == null || taskId <= 0) { + throw new BusinessException("taskId 不合法"); + } + String normalizedCountry = normalizeLookupCountry(country); + String normalizedAsin = normalizeLookupAsin(asin); + + FileTaskEntity task = loadTaskForExecution(taskId); + if (task == null || !MODULE_TYPE.equals(task.getModuleType())) { + throw new BusinessException("任务不存在"); + } + PriceTrackCreateTaskRequest request = parseTaskRequest(task); + + SkipPriceAsinCheckVo vo = new SkipPriceAsinCheckVo(); + vo.setCountry(normalizedCountry); + vo.setAsin(normalizedAsin); + vo.setExists(false); + vo.setMinimumPrice(""); + + if (request.isAsinMode() || !isTaskCountryEnabled(request.getCountryCodes(), normalizedCountry)) { + return vo; + } + SkipPriceAsinDetailDto detail = skipPriceAsinService.findSkipAsinByCountryAndAsin(normalizedCountry, normalizedAsin); + if (detail != null) { + vo.setExists(true); + vo.setMinimumPrice(detail.getMinimumPrice() == null ? "" : detail.getMinimumPrice()); + } + return vo; + } + + private PriceTrackCreateTaskRequest parseTaskRequest(FileTaskEntity task) { + try { + if (task.getRequestJson() == null || task.getRequestJson().isBlank()) { + throw new BusinessException("任务请求参数为空"); + } + return objectMapper.readValue(task.getRequestJson(), PriceTrackCreateTaskRequest.class); + } catch (BusinessException ex) { + throw ex; + } catch (Exception ex) { + throw new BusinessException("任务请求参数解析失败: " + ex.getMessage()); + } + } + + private boolean isTaskCountryEnabled(List countryCodes, String country) { + if (countryCodes == null || countryCodes.isEmpty()) { + return true; + } + return countryCodes.stream() + .filter(Objects::nonNull) + .map(code -> code.trim().toUpperCase(Locale.ROOT)) + .anyMatch(country::equals); + } + + private String normalizeLookupCountry(String country) { + String normalized = country == null ? "" : country.trim().toUpperCase(Locale.ROOT); + if (!List.of("DE", "UK", "FR", "IT", "ES").contains(normalized)) { + throw new BusinessException("国家参数不支持: " + country); + } + return normalized; + } + + private String normalizeLookupAsin(String asin) { + String normalized = asin == null ? "" : asin.trim().toUpperCase(Locale.ROOT); + if (normalized.isEmpty()) { + throw new BusinessException("ASIN 不能为空"); + } + if (normalized.length() > 64) { + throw new BusinessException("ASIN 长度不能超过 64"); + } + return normalized; + } + /** * 文件模式:从任务上传的文件中分页返回 ASIN 数据 */ diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/productrisk/model/vo/ProductRiskDashboardVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/productrisk/model/vo/ProductRiskDashboardVo.java index 316b6e7..1126855 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/productrisk/model/vo/ProductRiskDashboardVo.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/productrisk/model/vo/ProductRiskDashboardVo.java @@ -5,15 +5,15 @@ import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; @Data -@Schema(description = "商品风险模块统计看板(当前用户维度)") +@Schema(description = "店铺任务统计看板(当前用户维度);商品风险和取款等复用模块按各自模块类型统计") public class ProductRiskDashboardVo { @JsonProperty("candidateCount") - @Schema(description = "备选店铺条数(biz_product_risk_shop_candidate)") + @Schema(description = "备选店铺条数;按当前接口所属模块和当前用户统计") private long candidateCount; @JsonProperty("processedTaskCount") - @Schema(description = "已结束任务数:状态为 SUCCESS 或 FAILED 的商品风险任务总数") + @Schema(description = "已结束任务数:状态为 SUCCESS 或 FAILED 的任务总数") private long processedTaskCount; @JsonProperty("successTaskCount") diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/productrisk/model/vo/ProductRiskShopQueueItemVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/productrisk/model/vo/ProductRiskShopQueueItemVo.java index f45a519..089ad37 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/productrisk/model/vo/ProductRiskShopQueueItemVo.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/productrisk/model/vo/ProductRiskShopQueueItemVo.java @@ -59,9 +59,11 @@ public class ProductRiskShopQueueItemVo { @JsonAlias("skip_asins_by_country") @JsonProperty("skipAsinsByCountry") + @Schema(description = "按国家分组的跳过 ASIN 列表;跟价任务使用,取款任务通常可忽略") private Map> skipAsinsByCountry = new LinkedHashMap<>(); @JsonAlias("skip_asin_details_by_country") @JsonProperty("skipAsinDetailsByCountry") + @Schema(description = "按国家分组的跳过 ASIN 明细;包含数据库中维护的跳过跟价 ASIN 信息,取款任务通常可忽略") private Map> skipAsinDetailsByCountry = new LinkedHashMap<>(); } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/productrisk/model/vo/ProductRiskTaskItemVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/productrisk/model/vo/ProductRiskTaskItemVo.java index d63cce6..3e5d5de 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/productrisk/model/vo/ProductRiskTaskItemVo.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/productrisk/model/vo/ProductRiskTaskItemVo.java @@ -9,52 +9,52 @@ import java.util.ArrayList; import java.util.List; @Data -@Schema(description = "Task summary") +@Schema(description = "任务摘要:任务主表的基础状态、时间和调度信息") public class ProductRiskTaskItemVo { - @Schema(description = "Task primary key", example = "200") + @Schema(description = "任务主键 biz_file_task.id", example = "200") private Long id; @JsonProperty("taskNo") - @Schema(description = "Task number") + @Schema(description = "任务编号") private String taskNo; - @Schema(description = "Task status") + @Schema(description = "任务状态:RUNNING=执行中,SUCCESS=成功结束,FAILED=失败结束") private String status; @JsonProperty("errorMessage") - @Schema(description = "Task error message") + @Schema(description = "任务失败原因或异常信息") private String errorMessage; @JsonProperty("createdAt") - @Schema(description = "Created at") + @Schema(description = "任务创建时间") private String createdAt; @JsonProperty("updatedAt") - @Schema(description = "Updated at") + @Schema(description = "任务最后更新时间") private String updatedAt; @JsonProperty("finishedAt") - @Schema(description = "Finished at") + @Schema(description = "任务完成时间") private String finishedAt; @JsonProperty("scheduledAt") - @Schema(description = "Next scheduled execution time") + @Schema(description = "下次计划执行时间;未设置定时时为空") private String scheduledAt; @JsonProperty("countryCodes") - @Schema(description = "Country codes used by shop match task") + @Schema(description = "任务使用的国家/站点代码列表") private List countryCodes = new ArrayList<>(); @JsonProperty("scheduleStages") - @Schema(description = "Stage schedule of shop match task") + @Schema(description = "分阶段调度信息;用于需要按阶段执行的店铺任务") private List scheduleStages = new ArrayList<>(); @JsonProperty("currentStageIndex") - @Schema(description = "Next pending stage index") + @Schema(description = "下一个待执行阶段索引") private Integer currentStageIndex; @JsonProperty("activeStageIndex") - @Schema(description = "Current running stage index") + @Schema(description = "当前正在执行的阶段索引") private Integer activeStageIndex; } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/service/SkipPriceAsinService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/service/SkipPriceAsinService.java index 83162f3..b191667 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/service/SkipPriceAsinService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/service/SkipPriceAsinService.java @@ -58,6 +58,7 @@ public class SkipPriceAsinService { private final ShopManageGroupService shopManageGroupService; private final Map importProgressMap = new ConcurrentHashMap<>(); private final Map deleteImportProgressMap = new ConcurrentHashMap<>(); + private final Map skipAsinLookupCache = new ConcurrentHashMap<>(); public SkipPriceAsinPageVo page(long page, long pageSize, Long groupId, String shopName, String asin, Long operatorId, boolean superAdmin) { @@ -1057,6 +1058,62 @@ public class SkipPriceAsinService { return grouped; } + public SkipPriceAsinDetailDto findSkipAsinByCountryAndAsin(String country, String asin) { + String normalizedCountry = normalizeCountry(country); + String normalizedAsin = normalizeAsin(asin); + String cacheKey = normalizedCountry + ":" + normalizedAsin; + long now = System.currentTimeMillis(); + CachedSkipAsinLookup cached = skipAsinLookupCache.get(cacheKey); + if (cached != null && cached.isFresh(now)) { + return cached.toDto(normalizedAsin); + } + + SkipPriceAsinEntity row = selectCountryAsin(normalizedCountry, normalizedAsin); + String minimumPrice = ""; + if (row != null) { + BigDecimal value = getCountryMinimumPrice(row, normalizedCountry); + minimumPrice = value == null ? "" : value.toPlainString(); + } + cacheSkipAsinLookup(cacheKey, new CachedSkipAsinLookup(row != null, minimumPrice, now)); + if (row == null) { + return null; + } + SkipPriceAsinDetailDto dto = new SkipPriceAsinDetailDto(); + dto.setAsin(normalizedAsin); + dto.setMinimumPrice(minimumPrice); + return dto; + } + + private SkipPriceAsinEntity selectCountryAsin(String country, String asin) { + LambdaQueryWrapper query = new LambdaQueryWrapper<>(); + switch (country) { + case "DE" -> query.select(SkipPriceAsinEntity::getId, SkipPriceAsinEntity::getAsinDe, + SkipPriceAsinEntity::getMinimumPriceDe) + .eq(SkipPriceAsinEntity::getAsinDe, asin); + case "UK" -> query.select(SkipPriceAsinEntity::getId, SkipPriceAsinEntity::getAsinUk, + SkipPriceAsinEntity::getMinimumPriceUk) + .eq(SkipPriceAsinEntity::getAsinUk, asin); + case "FR" -> query.select(SkipPriceAsinEntity::getId, SkipPriceAsinEntity::getAsinFr, + SkipPriceAsinEntity::getMinimumPriceFr) + .eq(SkipPriceAsinEntity::getAsinFr, asin); + case "IT" -> query.select(SkipPriceAsinEntity::getId, SkipPriceAsinEntity::getAsinIt, + SkipPriceAsinEntity::getMinimumPriceIt) + .eq(SkipPriceAsinEntity::getAsinIt, asin); + case "ES" -> query.select(SkipPriceAsinEntity::getId, SkipPriceAsinEntity::getAsinEs, + SkipPriceAsinEntity::getMinimumPriceEs) + .eq(SkipPriceAsinEntity::getAsinEs, asin); + default -> throw new BusinessException("unsupported country: " + country); + } + return skipPriceAsinMapper.selectOne(query.last("LIMIT 1")); + } + + private void cacheSkipAsinLookup(String cacheKey, CachedSkipAsinLookup value) { + if (skipAsinLookupCache.size() > 20000) { + skipAsinLookupCache.clear(); + } + skipAsinLookupCache.put(cacheKey, value); + } + /** * 分页查询跳过 ASIN 数据(按商品状态全量模式使用) * 按 ASIN 个数精确分页(而非表记录数) @@ -1208,4 +1265,22 @@ public class SkipPriceAsinService { detail.put("minimumPrice", minimumPrice == null ? "" : minimumPrice.toPlainString()); bucket.add(detail); } + + private record CachedSkipAsinLookup(boolean exists, String minimumPrice, long cachedAtMillis) { + private static final long TTL_MILLIS = 30_000L; + + boolean isFresh(long now) { + return now - cachedAtMillis <= TTL_MILLIS; + } + + SkipPriceAsinDetailDto toDto(String asin) { + if (!exists) { + return null; + } + SkipPriceAsinDetailDto dto = new SkipPriceAsinDetailDto(); + dto.setAsin(asin); + dto.setMinimumPrice(minimumPrice == null ? "" : minimumPrice); + return dto; + } + } } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskService.java index 93be949..fa21a7f 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskService.java @@ -5066,6 +5066,7 @@ public class SimilarAsinTaskService { int cozeCompleted = countCompletedCozeStates(taskId); int cozePending = countPendingCozeStates(taskId); boolean uploadComplete = isResultSubmissionComplete(taskId); + TaskProgressSnapshotEntity snapshot = taskProgressSnapshotService.find(taskId, MODULE_TYPE); if (!uploadComplete) { if (canTreatTaskAsCompleted(task, row, job)) { vo.setFileProgressCurrent(1); @@ -5077,21 +5078,15 @@ public class SimilarAsinTaskService { if (task != null && !STATUS_RUNNING.equals(task.getStatus())) { return; } + if (cozeCompleted + cozePending > 0) { + attachCozeProgress(vo, job, snapshot, cozeCompleted, cozePending, false); + return; + } attachPythonUploadProgress(vo, taskId); return; } - TaskProgressSnapshotEntity snapshot = taskProgressSnapshotService.find(taskId, MODULE_TYPE); if (cozeCompleted + cozePending > 0) { - int total = calculateCozeDisplayTotal(snapshot, cozeCompleted + cozePending); - int current = Math.max(0, Math.min(cozeCompleted, total)); - vo.setFileProgressCurrent(current); - vo.setFileProgressTotal(total); - int percent = calculateDisplayProgressPercent(current, total, job, snapshot == null ? null : snapshot.getUpdatedAt()); - percent = Math.max(percent, calculateSnapshotDisplayPercent(snapshot, job)); - vo.setFileProgressPercent(Math.min(99, percent)); - vo.setFileProgressMessage(cozePending > 0 - ? buildCozeProgressMessage(current, total, cozePending) - : "Coze 已回流 " + current + "/" + total + " 批次,正在生成结果文件"); + attachCozeProgress(vo, job, snapshot, cozeCompleted, cozePending, true); return; } if (snapshot == null) { @@ -5112,6 +5107,30 @@ public class SimilarAsinTaskService { vo.setFileProgressMessage(snapshot.getMessage()); } + private void attachCozeProgress(SimilarAsinHistoryItemVo vo, + TaskFileJobEntity job, + TaskProgressSnapshotEntity snapshot, + int cozeCompleted, + int cozePending, + boolean uploadComplete) { + int observed = Math.max(0, cozeCompleted) + Math.max(0, cozePending); + int total = calculateCozeDisplayTotal(snapshot, observed); + if (!uploadComplete) { + total = Math.max(total, observed + 1); + } + int current = Math.max(0, Math.min(cozeCompleted, total)); + vo.setFileProgressCurrent(current); + vo.setFileProgressTotal(total); + int percent = calculateDisplayProgressPercent(current, total, job, snapshot == null ? null : snapshot.getUpdatedAt()); + percent = Math.max(percent, calculateSnapshotDisplayPercent(snapshot, job)); + vo.setFileProgressPercent(Math.min(uploadComplete ? 99 : 98, percent)); + vo.setFileProgressMessage(uploadComplete + ? (cozePending > 0 + ? buildCozeProgressMessage(current, total, cozePending) + : "Coze 已回流 " + current + "/" + total + " 批次,正在生成结果文件") + : buildUploadingCozeProgressMessage(current, total, cozePending)); + } + private void attachPythonUploadProgress(SimilarAsinHistoryItemVo vo, Long taskId) { PythonUploadProgress progress = resolvePythonUploadProgress(taskId); int total = progress.total() > 0 ? progress.total() : Math.max(1, progress.current() + 1); @@ -5287,6 +5306,16 @@ public class SimilarAsinTaskService { return "Coze 已回流 " + safeCompleted + "/" + safeTotal + " 批次,等待结果文件"; } + private String buildUploadingCozeProgressMessage(int completed, int total, int pending) { + int safeTotal = Math.max(1, total); + int safeCompleted = Math.max(0, Math.min(completed, safeTotal)); + int submitted = Math.max(safeCompleted, Math.min(safeTotal, safeCompleted + Math.max(0, pending))); + if (pending > 0) { + return "Python 仍在回传,Coze 已提交 " + submitted + "/" + safeTotal + " 批次"; + } + return "Python 仍在回传,Coze 已回流 " + safeCompleted + "/" + safeTotal + " 批次"; + } + private String fmt(LocalDateTime t) { return t == null ? null : t.toString(); } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TaskHeartbeatService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TaskHeartbeatService.java index ebe815f..85d98e0 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TaskHeartbeatService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TaskHeartbeatService.java @@ -17,6 +17,7 @@ import com.nanri.aiimage.modules.task.mapper.FileTaskMapper; import com.nanri.aiimage.modules.task.model.dto.TaskHeartbeatRequest; import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity; import com.nanri.aiimage.modules.task.model.vo.TaskHeartbeatVo; +import com.nanri.aiimage.modules.withdraw.service.WithdrawTaskCacheService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -39,6 +40,7 @@ public class TaskHeartbeatService { private static final String MODULE_SHOP_MATCH = "SHOP_MATCH"; private static final String MODULE_PATROL_DELETE = "PATROL_DELETE"; private static final String MODULE_QUERY_ASIN = "QUERY_ASIN"; + private static final String MODULE_WITHDRAW = "WITHDRAW"; private static final String MODULE_APPEARANCE_PATENT = "APPEARANCE_PATENT"; private static final String MODULE_SIMILAR_ASIN = "SIMILAR_ASIN"; private static final String MODULE_BRAND = "BRAND"; @@ -50,6 +52,7 @@ public class TaskHeartbeatService { private final ShopMatchTaskCacheService shopMatchTaskCacheService; private final PatrolDeleteTaskCacheService patrolDeleteTaskCacheService; private final QueryAsinTaskCacheService queryAsinTaskCacheService; + private final WithdrawTaskCacheService withdrawTaskCacheService; private final AppearancePatentTaskCacheService appearancePatentTaskCacheService; private final SimilarAsinTaskCacheService similarAsinTaskCacheService; private final DeleteBrandTaskCacheService deleteBrandTaskCacheService; @@ -168,6 +171,9 @@ public class TaskHeartbeatService { case MODULE_QUERY_ASIN -> { queryAsinTaskCacheService.touchTaskHeartbeat(taskId); } + case MODULE_WITHDRAW -> { + withdrawTaskCacheService.touchTaskHeartbeat(taskId); + } case MODULE_APPEARANCE_PATENT -> { appearancePatentTaskCacheService.touchTaskHeartbeat(taskId); } @@ -202,6 +208,7 @@ public class TaskHeartbeatService { case MODULE_SHOP_MATCH -> shopMatchTaskCacheService.saveTaskCache(task); case MODULE_PATROL_DELETE -> patrolDeleteTaskCacheService.saveTaskCache(task); case MODULE_QUERY_ASIN -> queryAsinTaskCacheService.saveTaskCache(task); + case MODULE_WITHDRAW -> withdrawTaskCacheService.saveTaskCache(task); case MODULE_DELETE_BRAND -> deleteBrandTaskCacheService.saveTaskCache(task); default -> { } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TaskResultFileJobWorker.java b/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TaskResultFileJobWorker.java index 9905d40..f9a4787 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TaskResultFileJobWorker.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TaskResultFileJobWorker.java @@ -15,6 +15,7 @@ import com.nanri.aiimage.modules.similarasin.service.SimilarAsinTaskService; import com.nanri.aiimage.modules.task.mapper.FileResultMapper; import com.nanri.aiimage.modules.task.model.entity.FileResultEntity; import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity; +import com.nanri.aiimage.modules.withdraw.service.WithdrawTaskService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; @@ -41,6 +42,7 @@ public class TaskResultFileJobWorker { private final PriceTrackTaskService priceTrackTaskService; private final ProductRiskTaskService productRiskTaskService; private final QueryAsinTaskService queryAsinTaskService; + private final WithdrawTaskService withdrawTaskService; private final PatrolDeleteTaskService patrolDeleteTaskService; private final AppearancePatentTaskService appearancePatentTaskService; private final SimilarAsinTaskService similarAsinTaskService; @@ -258,6 +260,10 @@ public class TaskResultFileJobWorker { queryAsinTaskService.processResultFileJob(job); return true; } + if ("WITHDRAW".equals(moduleType)) { + withdrawTaskService.processResultFileJob(job); + return true; + } if ("PATROL_DELETE".equals(moduleType)) { patrolDeleteTaskService.processResultFileJob(job); return true; @@ -289,6 +295,7 @@ public class TaskResultFileJobWorker { || "PRICE_TRACK".equals(moduleType) || "PRODUCT_RISK_RESOLVE".equals(moduleType) || "QUERY_ASIN".equals(moduleType) + || "WITHDRAW".equals(moduleType) || "PATROL_DELETE".equals(moduleType)) { taskResultPayloadService.deleteLatest(job.getTaskId(), moduleType, job.getScopeKey()); return; diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/withdraw/controller/WithdrawTaskController.java b/backend-java/src/main/java/com/nanri/aiimage/modules/withdraw/controller/WithdrawTaskController.java new file mode 100644 index 0000000..3f229e3 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/withdraw/controller/WithdrawTaskController.java @@ -0,0 +1,179 @@ +package com.nanri.aiimage.modules.withdraw.controller; + +import com.nanri.aiimage.common.api.ApiResponse; +import com.nanri.aiimage.common.util.DownloadHeaderUtil; +import com.nanri.aiimage.modules.productrisk.model.dto.ProductRiskCandidateAddRequest; +import com.nanri.aiimage.modules.productrisk.model.dto.ProductRiskMatchShopsRequest; +import com.nanri.aiimage.modules.productrisk.model.vo.ProductRiskCandidateVo; +import com.nanri.aiimage.modules.productrisk.model.vo.ProductRiskDashboardVo; +import com.nanri.aiimage.modules.productrisk.model.vo.ProductRiskMatchShopsVo; +import com.nanri.aiimage.modules.withdraw.model.dto.WithdrawCandidateClearRequest; +import com.nanri.aiimage.modules.withdraw.model.dto.WithdrawCreateTaskRequest; +import com.nanri.aiimage.modules.withdraw.model.dto.WithdrawSubmitResultRequest; +import com.nanri.aiimage.modules.withdraw.model.dto.WithdrawTaskBatchRequest; +import com.nanri.aiimage.modules.withdraw.model.vo.WithdrawCreateTaskVo; +import com.nanri.aiimage.modules.withdraw.model.vo.WithdrawHistoryVo; +import com.nanri.aiimage.modules.withdraw.model.vo.WithdrawTaskBatchVo; +import com.nanri.aiimage.modules.withdraw.service.WithdrawResolveService; +import com.nanri.aiimage.modules.withdraw.service.WithdrawTaskService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +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.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.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.io.InputStream; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.util.List; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/withdraw") +@Tag(name = "取款任务", description = "店铺取款任务模块:维护待取款店铺、匹配紫鸟店铺、创建任务、接收 Python 回传、查询进度和下载结果文件。") +public class WithdrawTaskController { + + private final WithdrawResolveService withdrawResolveService; + private final WithdrawTaskService withdrawTaskService; + + @GetMapping("/candidates") + @Operation(summary = "查询取款备选店铺", description = "按当前用户查询已保存的取款备选店铺列表,用于前端勾选后进行匹配和创建取款任务。") + public ApiResponse> listCandidates( + @Parameter(name = "user_id", description = "当前登录用户 ID,数据按用户隔离。", required = true, in = ParameterIn.QUERY, example = "1") + @RequestParam("user_id") Long userId) { + return ApiResponse.success(withdrawResolveService.listCandidates(userId)); + } + + @PostMapping("/candidates") + @Operation(summary = "新增取款备选店铺", description = "新增一个待处理店铺名到当前用户的取款备选列表;同一用户下店铺名会规范化去重。") + public ApiResponse addCandidate(@Valid @RequestBody ProductRiskCandidateAddRequest request) { + return ApiResponse.success(withdrawResolveService.addCandidate(request)); + } + + @DeleteMapping("/candidates/{id}") + @Operation(summary = "删除单个取款备选店铺", description = "按备选记录主键删除当前用户的一条取款备选店铺记录。") + public ApiResponse deleteCandidate( + @Parameter(description = "备选店铺记录主键,即 candidates 接口返回的 id。", required = true, example = "100") + @PathVariable Long id, + @Parameter(name = "user_id", description = "当前登录用户 ID。", required = true, in = ParameterIn.QUERY, example = "1") + @RequestParam("user_id") Long userId) { + withdrawResolveService.deleteCandidate(userId, id); + return ApiResponse.success(null); + } + + @PostMapping("/candidates/clear") + @Operation(summary = "批量清空取款备选店铺", description = "按店铺名批量删除当前用户的备选记录;通常在店铺成功推送到 Python 队列后清理已匹配数据。") + public ApiResponse clearCandidates(@Valid @RequestBody WithdrawCandidateClearRequest request) { + withdrawResolveService.deleteCandidatesByShopNames(request.getUserId(), request.getShopNames()); + return ApiResponse.success(null); + } + + @PostMapping("/match-shops") + @Operation(summary = "匹配取款店铺", description = "根据店铺名称批量查询紫鸟索引,返回店铺 ID、平台、公司名、匹配状态等信息;只有 matched=true 的店铺可创建任务。") + public ApiResponse matchShops(@Valid @RequestBody ProductRiskMatchShopsRequest request) { + return ApiResponse.success(withdrawResolveService.matchShops(request)); + } + + @GetMapping("/dashboard") + @Operation(summary = "查询取款看板统计", description = "返回当前用户的备选店铺数量、已结束任务数、成功任务数、失败任务数。") + public ApiResponse dashboard( + @Parameter(name = "user_id", description = "当前登录用户 ID。", required = true, in = ParameterIn.QUERY, example = "1") + @RequestParam("user_id") Long userId) { + return ApiResponse.success(withdrawTaskService.dashboard(userId)); + } + + @GetMapping("/history") + @Operation(summary = "查询取款任务记录", description = "查询当前用户取款模块的运行中和历史结果记录,包含任务状态、下载地址、店铺结果和明细行。") + public ApiResponse history( + @Parameter(name = "user_id", description = "当前登录用户 ID。", required = true, in = ParameterIn.QUERY, example = "1") + @RequestParam("user_id") Long userId) { + return ApiResponse.success(withdrawTaskService.listHistory(userId)); + } + + @PostMapping("/tasks/progress/batch") + @Operation(summary = "批量查询取款任务进度", description = "前端轮询使用。按任务 ID 批量查询轻量进度和结果快照;不存在的任务 ID 会返回在 missingTaskIds 中。") + public ApiResponse taskProgressBatch(@Valid @RequestBody WithdrawTaskBatchRequest request) { + return ApiResponse.success(withdrawTaskService.getTaskProgressBatch(request.getTaskIds())); + } + + @PostMapping("/tasks") + @Operation(summary = "创建取款任务", description = "为一批已匹配店铺创建一个取款任务,写入任务记录和店铺结果占位记录,并返回 taskId 及初始店铺快照。") + public ApiResponse createTask(@Valid @RequestBody WithdrawCreateTaskRequest request) { + return ApiResponse.success(withdrawTaskService.createTask(request)); + } + + @PostMapping("/tasks/{taskId}/result") + @Operation(summary = "提交取款任务结果", description = "Python 回传取款处理结果。支持同一 taskId 多次增量提交;shopDone=true 表示该店铺已处理完成,可进入文件组装。") + public ApiResponse submitResult( + @Parameter(description = "取款任务 ID,即 createTask 返回的 taskId。", required = true, example = "200") + @PathVariable Long taskId, + @Valid @RequestBody WithdrawSubmitResultRequest request, + jakarta.servlet.http.HttpServletResponse response) { + response.setCharacterEncoding(StandardCharsets.UTF_8.name()); + response.setContentType("application/json;charset=UTF-8"); + withdrawTaskService.submitResult(taskId, request); + return ApiResponse.success(null); + } + + @GetMapping("/results/{resultId}/download") + @Operation(summary = "下载取款结果文件", description = "按结果记录 ID 下载后端组装好的取款 Excel 文件。只有文件组装完成后才有可下载地址。") + public void downloadResult( + @Parameter(description = "结果记录 ID,即 history/items 中的 resultId。", required = true, 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 = withdrawTaskService.resolveResultDownloadUrl(resultId, userId); + String filename = withdrawTaskService.resolveResultDownloadFilename(resultId, userId); + if (url == null || url.isBlank()) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "暂无可下载结果"); + } + try { + response.setContentType("application/octet-stream"); + DownloadHeaderUtil.setAttachment(response, filename); + try (InputStream in = URI.create(url).toURL().openStream()) { + byte[] buffer = new byte[65536]; + int read; + while ((read = in.read(buffer)) != -1) { + response.getOutputStream().write(buffer, 0, read); + } + response.getOutputStream().flush(); + } + } catch (Exception ex) { + throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "下载失败"); + } + } + + @DeleteMapping("/tasks/{taskId}") + @Operation(summary = "删除取款任务", description = "删除一整个取款任务及其下所有结果记录;运行中、成功、失败任务均可删除。") + public ApiResponse deleteTask( + @Parameter(description = "取款任务 ID。", required = true, example = "200") + @PathVariable Long taskId, + @Parameter(name = "user_id", description = "当前登录用户 ID。", required = true, in = ParameterIn.QUERY, example = "1") + @RequestParam("user_id") Long userId) { + withdrawTaskService.deleteTask(taskId, userId); + return ApiResponse.success(null); + } + + @DeleteMapping("/history/{resultId}") + @Operation(summary = "删除单条取款历史记录", description = "删除一条取款结果记录,并同步重算父任务状态。") + public ApiResponse deleteHistory( + @Parameter(description = "结果记录 ID,即 history/items 中的 resultId。", required = true, example = "1001") + @PathVariable Long resultId, + @Parameter(name = "user_id", description = "当前登录用户 ID。", required = true, in = ParameterIn.QUERY, example = "1") + @RequestParam("user_id") Long userId) { + withdrawTaskService.deleteHistory(resultId, userId); + return ApiResponse.success(null); + } +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/withdraw/mapper/WithdrawShopCandidateMapper.java b/backend-java/src/main/java/com/nanri/aiimage/modules/withdraw/mapper/WithdrawShopCandidateMapper.java new file mode 100644 index 0000000..8ef4304 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/withdraw/mapper/WithdrawShopCandidateMapper.java @@ -0,0 +1,9 @@ +package com.nanri.aiimage.modules.withdraw.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.nanri.aiimage.modules.withdraw.model.entity.WithdrawShopCandidateEntity; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface WithdrawShopCandidateMapper extends BaseMapper { +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/withdraw/model/dto/WithdrawCandidateClearRequest.java b/backend-java/src/main/java/com/nanri/aiimage/modules/withdraw/model/dto/WithdrawCandidateClearRequest.java new file mode 100644 index 0000000..2574e85 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/withdraw/model/dto/WithdrawCandidateClearRequest.java @@ -0,0 +1,25 @@ +package com.nanri.aiimage.modules.withdraw.model.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +import java.util.ArrayList; +import java.util.List; + +@Data +@Schema(description = "批量清空取款备选店铺请求体:按店铺名删除当前用户的备选记录") +public class WithdrawCandidateClearRequest { + + @NotNull + @JsonProperty("user_id") + @Schema(description = "当前用户 ID,数据按用户隔离", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Long userId; + + @NotEmpty + @JsonProperty("shop_names") + @Schema(description = "需要从备选列表移除的店铺名称列表;通常为已成功创建取款任务的店铺", requiredMode = Schema.RequiredMode.REQUIRED, example = "[\"店铺甲\",\"店铺乙\"]") + private List shopNames = new ArrayList<>(); +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/withdraw/model/dto/WithdrawCreateTaskRequest.java b/backend-java/src/main/java/com/nanri/aiimage/modules/withdraw/model/dto/WithdrawCreateTaskRequest.java new file mode 100644 index 0000000..a55218f --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/withdraw/model/dto/WithdrawCreateTaskRequest.java @@ -0,0 +1,31 @@ +package com.nanri.aiimage.modules.withdraw.model.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +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.math.BigDecimal; +import java.util.ArrayList; +import java.util.List; + +@Data +@Schema(description = "创建取款任务请求体:前端将已匹配的多个店铺合并为一个任务提交,后端创建任务和各店铺结果占位记录") +public class WithdrawCreateTaskRequest { + + @NotNull + @JsonProperty("user_id") + @Schema(description = "当前用户 ID,任务归属和数据隔离使用", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Long userId; + + @JsonProperty("reserved_amount") + @Schema(description = "保留金额筛选条件;创建任务时随任务快照保存,并下发给 Python 侧用于计算取款金额", example = "100.00") + private BigDecimal reservedAmount; + + @Valid + @NotEmpty + @Schema(description = "本次任务包含的店铺列表;多个店铺会合并为同一个取款任务并串行处理", requiredMode = Schema.RequiredMode.REQUIRED) + private List items = new ArrayList<>(); +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/withdraw/model/dto/WithdrawRowDto.java b/backend-java/src/main/java/com/nanri/aiimage/modules/withdraw/model/dto/WithdrawRowDto.java new file mode 100644 index 0000000..84b3ff2 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/withdraw/model/dto/WithdrawRowDto.java @@ -0,0 +1,34 @@ +package com.nanri.aiimage.modules.withdraw.model.dto; + +import com.fasterxml.jackson.annotation.JsonAlias; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.nanri.aiimage.modules.withdraw.model.enums.WithdrawStatus; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.math.BigDecimal; + +@Data +@Schema(description = "取款结果明细行:对应 Excel 中一个店铺下某个国家的一行数据") +public class WithdrawRowDto { + + @JsonProperty("country") + @JsonAlias({"countryCode", "country_code"}) + @Schema(description = "国家或站点代码;常用值 DE、UK/GB、FR、IT、ES,生成 Excel 时会显示为中文国家名", example = "DE") + private String country; + + @JsonProperty("shopAmount") + @JsonAlias({"shop_amount", "amount", "availableAmount", "available_amount"}) + @Schema(description = "店铺可用资金金额;参与“所有店铺金额总数”和店铺小计累加", example = "1234.56") + private BigDecimal shopAmount; + + @JsonProperty("withdrawAmount") + @JsonAlias({"withdraw_amount", "withdrawalAmount", "withdrawal_amount"}) + @Schema(description = "本次取款金额;为负数时 Excel 的“取款金额”和“状态”留空,且不计入取款金额总数", example = "1000.00") + private BigDecimal withdrawAmount; + + @Schema( + description = "取款状态:ZERO_AVAILABLE=可用资金为0不可取款;SUCCESS=成功;BALANCE_FORBIDDEN=有余额禁止取出;NEGATIVE_WITHDRAW_BLANK=取款金额为负数则留空", + example = "SUCCESS") + private WithdrawStatus status; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/withdraw/model/dto/WithdrawShopPayloadDto.java b/backend-java/src/main/java/com/nanri/aiimage/modules/withdraw/model/dto/WithdrawShopPayloadDto.java new file mode 100644 index 0000000..b828e62 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/withdraw/model/dto/WithdrawShopPayloadDto.java @@ -0,0 +1,37 @@ +package com.nanri.aiimage.modules.withdraw.model.dto; + +import com.fasterxml.jackson.annotation.JsonAlias; +import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.ArrayList; +import java.util.List; + +@Data +@Schema(description = "Python 回传的单个店铺取款结果:成功时带 rows,失败时带 error") +public class WithdrawShopPayloadDto { + + @JsonProperty("shopName") + @JsonAlias({"shop_name"}) + @Schema(description = "店铺名称,用于匹配任务内的店铺结果记录;建议使用创建任务时的 shopName", requiredMode = Schema.RequiredMode.REQUIRED, example = "测试店铺A") + private String shopName; + + @Schema(description = "错误信息;非空表示该店铺处理失败,后端会写入失败原因并不参与 Excel 成功数据组装") + private String error; + + @JsonProperty("shopDone") + @JsonAlias({"shop_done"}) + @Schema(description = "该店铺是否已处理完成;true 表示该店进入终态,可参与任务完成判断和文件组装", example = "true") + private Boolean shopDone; + + @JsonProperty("submissionId") + @JsonAlias({"submission_id"}) + @Schema(description = "Python 侧提交批次或流水号,可用于排查重复回传和日志关联") + private String submissionId; + + @JsonProperty("rows") + @JsonAlias({"items", "countryResults", "country_results"}) + @Schema(description = "该店铺各国家取款明细行;生成 Excel 时按店铺分组写入,店铺之间空一行") + private List rows = new ArrayList<>(); +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/withdraw/model/dto/WithdrawSubmitResultRequest.java b/backend-java/src/main/java/com/nanri/aiimage/modules/withdraw/model/dto/WithdrawSubmitResultRequest.java new file mode 100644 index 0000000..121b2f4 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/withdraw/model/dto/WithdrawSubmitResultRequest.java @@ -0,0 +1,19 @@ +package com.nanri.aiimage.modules.withdraw.model.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotEmpty; +import lombok.Data; + +import java.util.ArrayList; +import java.util.List; + +@Data +@Schema(description = "Python 回传取款任务结果请求体:按店铺提交取款明细、错误信息和完成标记") +public class WithdrawSubmitResultRequest { + + @Valid + @NotEmpty + @Schema(description = "本次回传涉及的店铺结果列表;可增量回传,店名用于匹配任务内的店铺结果记录", requiredMode = Schema.RequiredMode.REQUIRED) + private List shops = new ArrayList<>(); +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/withdraw/model/dto/WithdrawTaskBatchRequest.java b/backend-java/src/main/java/com/nanri/aiimage/modules/withdraw/model/dto/WithdrawTaskBatchRequest.java new file mode 100644 index 0000000..c57a482 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/withdraw/model/dto/WithdrawTaskBatchRequest.java @@ -0,0 +1,14 @@ +package com.nanri.aiimage.modules.withdraw.model.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.ArrayList; +import java.util.List; + +@Data +@Schema(description = "批量查询取款任务进度请求体:前端轮询多个任务的轻量结果快照") +public class WithdrawTaskBatchRequest { + @Schema(description = "待查询的取款任务 ID 列表;不存在或非取款模块任务会返回到 missingTaskIds", requiredMode = Schema.RequiredMode.REQUIRED, example = "[200,201]") + private List taskIds = new ArrayList<>(); +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/withdraw/model/dto/WithdrawTaskItemDto.java b/backend-java/src/main/java/com/nanri/aiimage/modules/withdraw/model/dto/WithdrawTaskItemDto.java new file mode 100644 index 0000000..aaf3e40 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/withdraw/model/dto/WithdrawTaskItemDto.java @@ -0,0 +1,36 @@ +package com.nanri.aiimage.modules.withdraw.model.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +@Schema(description = "创建取款任务的单个店铺项:来自匹配店铺接口返回的 items 元素") +public class WithdrawTaskItemDto { + + @JsonProperty("shopName") + @Schema(description = "店铺名称,用于展示、结果匹配和生成 Excel 店铺分组", requiredMode = Schema.RequiredMode.REQUIRED, example = "测试店铺A") + private String shopName; + + @Schema(description = "是否已匹配到紫鸟店铺;只有 true 的店铺才应创建取款任务", example = "true") + private boolean matched; + + @JsonProperty("shopId") + @Schema(description = "紫鸟侧店铺 ID,推送给 Python 打开对应店铺时使用", example = "15206") + private String shopId; + + @Schema(description = "店铺平台,例如亚马逊", example = "亚马逊") + private String platform; + + @JsonProperty("companyName") + @Schema(description = "公司名称或店铺主体名称") + private String companyName; + + @JsonProperty("matchStatus") + @Schema(description = "匹配状态码,例如 MATCHED、PENDING、CONFLICT、INDEX_STALE;具体值以后端匹配服务为准", example = "MATCHED") + private String matchStatus; + + @JsonProperty("matchMessage") + @Schema(description = "匹配状态说明,供前端展示和排查未匹配原因") + private String matchMessage; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/withdraw/model/entity/WithdrawShopCandidateEntity.java b/backend-java/src/main/java/com/nanri/aiimage/modules/withdraw/model/entity/WithdrawShopCandidateEntity.java new file mode 100644 index 0000000..e3bc1c6 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/withdraw/model/entity/WithdrawShopCandidateEntity.java @@ -0,0 +1,19 @@ +package com.nanri.aiimage.modules.withdraw.model.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +@TableName("biz_withdraw_shop_candidate") +public class WithdrawShopCandidateEntity { + + @TableId(type = IdType.AUTO) + private Long id; + private Long userId; + private String shopName; + private LocalDateTime createdAt; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/withdraw/model/enums/WithdrawStatus.java b/backend-java/src/main/java/com/nanri/aiimage/modules/withdraw/model/enums/WithdrawStatus.java new file mode 100644 index 0000000..b30a34a --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/withdraw/model/enums/WithdrawStatus.java @@ -0,0 +1,54 @@ +package com.nanri.aiimage.modules.withdraw.model.enums; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema( + description = "取款明细状态枚举:ZERO_AVAILABLE=可用资金为0不可取款;SUCCESS=成功;BALANCE_FORBIDDEN=有余额禁止取出;NEGATIVE_WITHDRAW_BLANK=取款金额为负数则留空") +public enum WithdrawStatus { + ZERO_AVAILABLE("ZERO_AVAILABLE", "可用资金为0不可取款"), + SUCCESS("SUCCESS", "成功"), + BALANCE_FORBIDDEN("BALANCE_FORBIDDEN", "有余额禁止取出"), + NEGATIVE_WITHDRAW_BLANK("NEGATIVE_WITHDRAW_BLANK", "取款金额为负数则留空"); + + private final String code; + private final String displayName; + + WithdrawStatus(String code, String displayName) { + this.code = code; + this.displayName = displayName; + } + + @JsonValue + public String getCode() { + return code; + } + + public String getDisplayName() { + return displayName; + } + + @JsonCreator + public static WithdrawStatus from(Object raw) { + if (raw == null) { + return SUCCESS; + } + String value = String.valueOf(raw).trim(); + if (value.isEmpty()) { + return NEGATIVE_WITHDRAW_BLANK; + } + for (WithdrawStatus status : values()) { + if (status.code.equalsIgnoreCase(value) || status.displayName.equals(value)) { + return status; + } + } + return switch (value) { + case "可用资金为0不可取款" -> ZERO_AVAILABLE; + case "成功" -> SUCCESS; + case "有余额禁止取出" -> BALANCE_FORBIDDEN; + case "取款金额为负数则留空", "取款金额为负数", "留空" -> NEGATIVE_WITHDRAW_BLANK; + default -> SUCCESS; + }; + } +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/withdraw/model/vo/WithdrawCreateTaskVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/withdraw/model/vo/WithdrawCreateTaskVo.java new file mode 100644 index 0000000..21d6e94 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/withdraw/model/vo/WithdrawCreateTaskVo.java @@ -0,0 +1,17 @@ +package com.nanri.aiimage.modules.withdraw.model.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.ArrayList; +import java.util.List; + +@Data +@Schema(description = "创建取款任务响应体:返回新任务 ID 和各店铺初始结果快照") +public class WithdrawCreateTaskVo { + @Schema(description = "新建取款任务 ID,后续推送 Python 队列和回传结果都使用该值", example = "200") + private Long taskId; + + @Schema(description = "任务内店铺结果快照;每个店铺一项,包含 resultId、店铺信息和初始任务状态") + private List items = new ArrayList<>(); +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/withdraw/model/vo/WithdrawHistoryVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/withdraw/model/vo/WithdrawHistoryVo.java new file mode 100644 index 0000000..be9ea5e --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/withdraw/model/vo/WithdrawHistoryVo.java @@ -0,0 +1,14 @@ +package com.nanri.aiimage.modules.withdraw.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 WithdrawHistoryVo { + @Schema(description = "取款结果项列表;一个任务可能包含多个店铺,任务级汇总项的 shops 字段会包含店铺明细") + private List items = new ArrayList<>(); +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/withdraw/model/vo/WithdrawResultItemVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/withdraw/model/vo/WithdrawResultItemVo.java new file mode 100644 index 0000000..ef76dc5 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/withdraw/model/vo/WithdrawResultItemVo.java @@ -0,0 +1,91 @@ +package com.nanri.aiimage.modules.withdraw.model.vo; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.nanri.aiimage.modules.withdraw.model.dto.WithdrawRowDto; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +@Data +@Schema(description = "取款结果项:可表示任务级汇总,也可表示任务下的单个店铺结果") +public class WithdrawResultItemVo { + @Schema(description = "结果记录 ID;下载结果、删除单条历史记录时使用。任务级汇总项可能取任务下首个结果 ID", example = "1001") + private Long resultId; + + @Schema(description = "所属取款任务 ID;一个任务可包含多个店铺", example = "200") + private Long taskId; + + @JsonProperty("shopName") + @Schema(description = "店铺名称;任务级汇总项可能为多个店铺名拼接,单店项为具体店铺名", example = "测试店铺A") + private String shopName; + + @JsonProperty("shopId") + @Schema(description = "紫鸟侧店铺 ID") + private String shopId; + + @Schema(description = "平台,例如亚马逊") + private String platform; + + @JsonProperty("companyName") + @Schema(description = "公司名称或店铺主体名称") + private String companyName; + + @Schema(description = "创建任务时该店铺是否匹配成功") + private boolean matched; + + @JsonProperty("matchStatus") + @Schema(description = "匹配状态码,例如 MATCHED、PENDING、CONFLICT、INDEX_STALE") + private String matchStatus; + + @JsonProperty("matchMessage") + @Schema(description = "匹配状态说明") + private String matchMessage; + + @JsonProperty("taskStatus") + @Schema(description = "任务状态:RUNNING=执行中,SUCCESS=成功结束,FAILED=失败结束", example = "RUNNING") + private String taskStatus; + + @JsonProperty("reservedAmount") + @Schema(description = "创建任务时传入的保留金额筛选条件", example = "100.00") + private BigDecimal reservedAmount; + + @Schema(description = "该结果项是否成功;true=成功,false=失败,null=尚未完成") + private Boolean success; + + @Schema(description = "失败原因;success=false 时通常有值") + private String error; + + @Schema(description = "任务或结果记录创建时间") + private LocalDateTime createdAt; + + @Schema(description = "任务或结果记录完成时间") + private LocalDateTime finishedAt; + + @Schema(description = "生成的取款 Excel 文件名;文件组装完成后有值") + private String outputFilename; + + @Schema(description = "结果文件下载地址;也可通过 GET /api/withdraw/results/{resultId}/download 下载") + private String downloadUrl; + + @Schema(description = "后台文件组装任务 ID;用于排查 Excel 生成任务") + private Long fileJobId; + + @Schema(description = "文件组装状态:PENDING/RUNNING/SUCCESS/FAILED 等,以文件任务表状态为准") + private String fileStatus; + + @Schema(description = "文件组装失败原因;fileStatus=FAILED 时通常有值") + private String fileError; + + @Schema(description = "结果文件是否已可下载;true 表示 downloadUrl 或下载接口可用") + private Boolean fileReady; + + @Schema(description = "该店铺的取款明细行;每行对应一个国家/站点") + private List rows = new ArrayList<>(); + + @Schema(description = "任务级汇总项下的店铺结果列表;单店项通常为空") + private List shops = new ArrayList<>(); +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/withdraw/model/vo/WithdrawTaskBatchVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/withdraw/model/vo/WithdrawTaskBatchVo.java new file mode 100644 index 0000000..881eb5c --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/withdraw/model/vo/WithdrawTaskBatchVo.java @@ -0,0 +1,17 @@ +package com.nanri.aiimage.modules.withdraw.model.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.ArrayList; +import java.util.List; + +@Data +@Schema(description = "批量查询取款任务进度响应体:返回任务快照和未命中的任务 ID") +public class WithdrawTaskBatchVo { + @Schema(description = "已查询到的任务或店铺结果快照;包含任务状态、文件状态、明细行和下载地址") + private List items = new ArrayList<>(); + + @Schema(description = "请求中不存在、已删除或不属于取款模块的任务 ID 列表") + private List missingTaskIds = new ArrayList<>(); +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/withdraw/service/WithdrawExcelAssemblyService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/withdraw/service/WithdrawExcelAssemblyService.java new file mode 100644 index 0000000..22a1be9 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/withdraw/service/WithdrawExcelAssemblyService.java @@ -0,0 +1,200 @@ +package com.nanri.aiimage.modules.withdraw.service; + +import com.nanri.aiimage.common.exception.BusinessException; +import com.nanri.aiimage.modules.withdraw.model.dto.WithdrawRowDto; +import com.nanri.aiimage.modules.withdraw.model.enums.WithdrawStatus; +import com.nanri.aiimage.modules.withdraw.model.vo.WithdrawResultItemVo; +import lombok.extern.slf4j.Slf4j; +import org.apache.poi.ss.usermodel.Cell; +import org.apache.poi.ss.usermodel.Row; +import org.apache.poi.ss.usermodel.Sheet; +import org.apache.poi.xssf.streaming.SXSSFWorkbook; +import org.springframework.stereotype.Service; + +import java.io.File; +import java.io.FileOutputStream; +import java.math.BigDecimal; +import java.util.List; +import java.util.Map; + +@Service +@Slf4j +public class WithdrawExcelAssemblyService { + + private static final String[] HEADER = { + "店铺名", "国家", "店铺金额", "取款金额", "状态", "", + "所有店铺金额总数", "所有店铺取款金额总数" + }; + private static final Map COUNTRY_NAMES = Map.of( + "DE", "德国", + "UK", "英国", + "GB", "英国", + "FR", "法国", + "IT", "意大利", + "ES", "西班牙" + ); + + public void writeWorkbook(File outputXlsx, List items) { + SXSSFWorkbook workbook = new SXSSFWorkbook(200); + workbook.setCompressTempFiles(true); + try (FileOutputStream outputStream = new FileOutputStream(outputXlsx)) { + Sheet sheet = workbook.createSheet("Sheet1"); + writeHeader(sheet); + BigDecimal totalShopAmount = totalShopAmount(items); + BigDecimal totalWithdrawAmount = totalWithdrawAmount(items); + + int rowIndex = 1; + boolean totalsWritten = false; + for (WithdrawResultItemVo item : items == null ? List.of() : items) { + if (item == null || Boolean.FALSE.equals(item.getSuccess())) { + continue; + } + List rows = item.getRows() == null ? List.of() : item.getRows(); + if (rows.isEmpty()) { + Row row = sheet.createRow(rowIndex++); + row.createCell(0).setCellValue(safe(item.getShopName())); + if (!totalsWritten) { + writeTotals(row, totalShopAmount, totalWithdrawAmount); + totalsWritten = true; + } + } else { + for (int i = 0; i < rows.size(); i++) { + WithdrawRowDto data = rows.get(i); + Row row = sheet.createRow(rowIndex++); + row.createCell(0).setCellValue(i == 0 ? safe(item.getShopName()) : ""); + row.createCell(1).setCellValue(formatCountry(data == null ? null : data.getCountry())); + writeMoney(row.createCell(2), data == null ? null : data.getShopAmount()); + if (!isNegativeWithdraw(data)) { + writeMoney(row.createCell(3), data == null ? null : data.getWithdrawAmount()); + } else { + row.createCell(3).setCellValue(""); + } + row.createCell(4).setCellValue(formatStatus(data)); + if (!totalsWritten) { + writeTotals(row, totalShopAmount, totalWithdrawAmount); + totalsWritten = true; + } + } + } + + Row subtotal = sheet.createRow(rowIndex++); + writeMoney(subtotal.createCell(2), shopSubtotal(rows)); + writeMoney(subtotal.createCell(3), withdrawSubtotal(rows)); + rowIndex++; + } + workbook.write(outputStream); + } catch (Exception ex) { + log.warn("[withdraw] write workbook failed: {}", ex.getMessage()); + throw new BusinessException("生成取款 Excel 失败: " + ex.getMessage()); + } finally { + try { + workbook.close(); + } catch (Exception ignored) { + } + workbook.dispose(); + } + } + + public int countRows(List items) { + int count = 0; + for (WithdrawResultItemVo item : items == null ? List.of() : items) { + if (item == null || Boolean.FALSE.equals(item.getSuccess())) { + continue; + } + int dataRows = Math.max(1, item.getRows() == null ? 0 : item.getRows().size()); + count += dataRows + 2; + } + return count; + } + + private void writeHeader(Sheet sheet) { + Row headerRow = sheet.createRow(0); + for (int columnIndex = 0; columnIndex < HEADER.length; columnIndex++) { + headerRow.createCell(columnIndex).setCellValue(HEADER[columnIndex]); + sheet.setColumnWidth(columnIndex, (columnIndex == 0 ? 24 : 18) * 256); + } + } + + private void writeTotals(Row row, BigDecimal totalShopAmount, BigDecimal totalWithdrawAmount) { + writeMoney(row.createCell(6), totalShopAmount); + writeMoney(row.createCell(7), totalWithdrawAmount); + } + + private BigDecimal totalShopAmount(List items) { + BigDecimal total = BigDecimal.ZERO; + for (WithdrawResultItemVo item : items == null ? List.of() : items) { + if (item == null || Boolean.FALSE.equals(item.getSuccess())) { + continue; + } + total = total.add(shopSubtotal(item.getRows())); + } + return total; + } + + private BigDecimal totalWithdrawAmount(List items) { + BigDecimal total = BigDecimal.ZERO; + for (WithdrawResultItemVo item : items == null ? List.of() : items) { + if (item == null || Boolean.FALSE.equals(item.getSuccess())) { + continue; + } + total = total.add(withdrawSubtotal(item.getRows())); + } + return total; + } + + private BigDecimal shopSubtotal(List rows) { + BigDecimal total = BigDecimal.ZERO; + for (WithdrawRowDto row : rows == null ? List.of() : rows) { + if (row != null && row.getShopAmount() != null) { + total = total.add(row.getShopAmount()); + } + } + return total; + } + + private BigDecimal withdrawSubtotal(List rows) { + BigDecimal total = BigDecimal.ZERO; + for (WithdrawRowDto row : rows == null ? List.of() : rows) { + if (row != null && row.getWithdrawAmount() != null && !isNegativeWithdraw(row)) { + total = total.add(row.getWithdrawAmount()); + } + } + return total; + } + + private boolean isNegativeWithdraw(WithdrawRowDto row) { + if (row == null) { + return false; + } + return WithdrawStatus.NEGATIVE_WITHDRAW_BLANK.equals(row.getStatus()) + || (row.getWithdrawAmount() != null && row.getWithdrawAmount().compareTo(BigDecimal.ZERO) < 0); + } + + private String formatStatus(WithdrawRowDto row) { + if (isNegativeWithdraw(row)) { + return ""; + } + WithdrawStatus status = row == null ? null : row.getStatus(); + return status == null ? "" : status.getDisplayName(); + } + + private String formatCountry(String country) { + if (country == null || country.isBlank()) { + return ""; + } + String value = country.trim(); + return COUNTRY_NAMES.getOrDefault(value.toUpperCase(), value); + } + + private void writeMoney(Cell cell, BigDecimal value) { + if (value == null) { + cell.setCellValue(""); + return; + } + cell.setCellValue(value.doubleValue()); + } + + private String safe(String value) { + return value == null ? "" : value; + } +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/withdraw/service/WithdrawResolveService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/withdraw/service/WithdrawResolveService.java new file mode 100644 index 0000000..a40fb20 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/withdraw/service/WithdrawResolveService.java @@ -0,0 +1,177 @@ +package com.nanri.aiimage.modules.withdraw.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.nanri.aiimage.common.exception.BusinessException; +import com.nanri.aiimage.modules.productrisk.model.dto.ProductRiskCandidateAddRequest; +import com.nanri.aiimage.modules.productrisk.model.dto.ProductRiskMatchShopsRequest; +import com.nanri.aiimage.modules.productrisk.model.vo.ProductRiskCandidateVo; +import com.nanri.aiimage.modules.productrisk.model.vo.ProductRiskMatchShopsVo; +import com.nanri.aiimage.modules.productrisk.model.vo.ProductRiskShopQueueItemVo; +import com.nanri.aiimage.modules.withdraw.mapper.WithdrawShopCandidateMapper; +import com.nanri.aiimage.modules.withdraw.model.entity.WithdrawShopCandidateEntity; +import com.nanri.aiimage.modules.ziniao.model.vo.ZiniaoShopMatchResultVo; +import com.nanri.aiimage.modules.ziniao.service.ZiniaoShopIndexService; +import com.nanri.aiimage.modules.ziniao.service.ZiniaoShopSwitchService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Objects; + +@Service +@RequiredArgsConstructor +public class WithdrawResolveService { + + private final WithdrawShopCandidateMapper candidateMapper; + private final ZiniaoShopSwitchService ziniaoShopSwitchService; + + public List listCandidates(Long userId) { + validateUserId(userId); + List rows = candidateMapper.selectList( + new LambdaQueryWrapper() + .eq(WithdrawShopCandidateEntity::getUserId, userId) + .orderByDesc(WithdrawShopCandidateEntity::getId)); + List out = new ArrayList<>(); + for (WithdrawShopCandidateEntity row : rows) { + ProductRiskCandidateVo vo = new ProductRiskCandidateVo(); + vo.setId(row.getId()); + vo.setShopName(row.getShopName()); + vo.setCreatedAt(row.getCreatedAt()); + out.add(vo); + } + return out; + } + + @Transactional + public ProductRiskCandidateVo addCandidate(ProductRiskCandidateAddRequest request) { + validateUserId(request.getUserId()); + String normalized = ziniaoShopSwitchService.normalizeShopName(request.getShopName()); + if (normalized.isBlank()) { + throw new BusinessException("店铺名不能为空"); + } + ZiniaoShopMatchResultVo indexHit = ziniaoShopSwitchService.findIndexedStoreByName(normalized, false); + if (indexHit == null || !indexHit.isMatched()) { + String hint = indexHit != null && indexHit.getMatchMessage() != null && !indexHit.getMatchMessage().isBlank() + ? indexHit.getMatchMessage() + : "店铺索引未命中,无法加入备选区"; + throw new BusinessException(hint); + } + if (ZiniaoShopIndexService.MATCH_STATUS_CONFLICT.equals(indexHit.getMatchStatus())) { + throw new BusinessException(indexHit.getMatchMessage() != null ? indexHit.getMatchMessage() : "存在多个同名店铺,请人工确认"); + } + WithdrawShopCandidateEntity existing = candidateMapper.selectOne(new LambdaQueryWrapper() + .eq(WithdrawShopCandidateEntity::getUserId, request.getUserId()) + .eq(WithdrawShopCandidateEntity::getShopName, normalized) + .last("limit 1")); + if (existing == null) { + existing = new WithdrawShopCandidateEntity(); + existing.setUserId(request.getUserId()); + existing.setShopName(normalized); + existing.setCreatedAt(LocalDateTime.now()); + candidateMapper.insert(existing); + } + ProductRiskCandidateVo vo = new ProductRiskCandidateVo(); + vo.setId(existing.getId()); + vo.setShopName(existing.getShopName()); + vo.setCreatedAt(existing.getCreatedAt()); + return vo; + } + + @Transactional + public void deleteCandidate(Long userId, Long id) { + validateUserId(userId); + if (id == null || id <= 0) { + throw new BusinessException("id 不合法"); + } + WithdrawShopCandidateEntity row = candidateMapper.selectById(id); + if (row == null || !userId.equals(row.getUserId())) { + throw new BusinessException("记录不存在"); + } + candidateMapper.deleteById(id); + } + + @Transactional + public void deleteCandidatesByShopNames(Long userId, List shopNames) { + validateUserId(userId); + LinkedHashSet normalizedNames = new LinkedHashSet<>(); + for (String raw : shopNames == null ? List.of() : shopNames) { + String normalized = ziniaoShopSwitchService.normalizeShopName(raw); + if (!normalized.isBlank()) { + normalizedNames.add(normalized); + } + } + if (normalizedNames.isEmpty()) { + return; + } + candidateMapper.delete(new LambdaQueryWrapper() + .eq(WithdrawShopCandidateEntity::getUserId, userId) + .in(WithdrawShopCandidateEntity::getShopName, normalizedNames)); + } + + public ProductRiskMatchShopsVo matchShops(ProductRiskMatchShopsRequest request) { + validateUserId(request.getUserId()); + LinkedHashSet ordered = new LinkedHashSet<>(); + for (String raw : request.getShopNames()) { + String normalized = ziniaoShopSwitchService.normalizeShopName(raw); + if (!normalized.isBlank()) { + ordered.add(normalized); + } + } + if (ordered.isEmpty()) { + throw new BusinessException("shop_names 无有效店铺名"); + } + ProductRiskMatchShopsVo vo = new ProductRiskMatchShopsVo(); + for (String shopName : ordered) { + vo.getItems().add(matchOneShop(shopName)); + } + return vo; + } + + public long countCandidates(Long userId) { + validateUserId(userId); + Long count = candidateMapper.selectCount(new LambdaQueryWrapper() + .eq(WithdrawShopCandidateEntity::getUserId, userId)); + return count == null ? 0L : count; + } + + private ProductRiskShopQueueItemVo matchOneShop(String shopName) { + ProductRiskShopQueueItemVo item = new ProductRiskShopQueueItemVo(); + item.setShopName(shopName); + try { + ZiniaoShopMatchResultVo matched = ziniaoShopSwitchService.findIndexedStoreByName(shopName, false); + if (matched == null) { + item.setMatched(false); + item.setMatchStatus("PENDING"); + item.setMatchMessage("匹配结果为空"); + return item; + } + item.setMatched(matched.isMatched()); + item.setShopId(matched.getShopId()); + item.setPlatform(matched.getPlatform()); + item.setCompanyName(matched.getCompanyName()); + item.setMatchedUserId(matched.getMatchedUserId()); + item.setMatchStatus(matched.getMatchStatus()); + item.setMatchMessage(matched.getMatchMessage()); + item.setOpenStoreUrl(matched.getOpenStoreUrl()); + } catch (BusinessException ex) { + item.setMatched(false); + item.setMatchStatus("PENDING"); + item.setMatchMessage(ex.getMessage()); + } catch (Exception ex) { + item.setMatched(false); + item.setMatchStatus("PENDING"); + item.setMatchMessage(Objects.toString(ex.getMessage(), "匹配异常")); + } + return item; + } + + private void validateUserId(Long userId) { + if (userId == null || userId <= 0) { + throw new BusinessException("user_id 不合法"); + } + } +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/withdraw/service/WithdrawTaskCacheService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/withdraw/service/WithdrawTaskCacheService.java new file mode 100644 index 0000000..8329aae --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/withdraw/service/WithdrawTaskCacheService.java @@ -0,0 +1,182 @@ +package com.nanri.aiimage.modules.withdraw.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.nanri.aiimage.config.TaskPressureProperties; +import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity; +import com.nanri.aiimage.modules.task.service.TaskScopePayloadStorageService; +import com.nanri.aiimage.modules.withdraw.model.dto.WithdrawShopPayloadDto; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Service; + +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +@Service +@RequiredArgsConstructor +@Slf4j +public class WithdrawTaskCacheService { + + private static final String MODULE_TYPE = "WITHDRAW"; + private static final long PAYLOAD_TTL_HOURS = 24; + + private final StringRedisTemplate stringRedisTemplate; + private final ObjectMapper objectMapper; + private final TaskPressureProperties taskPressureProperties; + private final TaskScopePayloadStorageService taskScopePayloadStorageService; + private final ConcurrentHashMap taskEntityLocalCache = new ConcurrentHashMap<>(); + + public WithdrawShopPayloadDto getShopMergedPayload(Long taskId, String shopKey) { + return taskScopePayloadStorageService.getScopePayload(taskId, MODULE_TYPE, shopKey, WithdrawShopPayloadDto.class); + } + + public void saveShopMergedPayload(Long taskId, String shopKey, WithdrawShopPayloadDto payload) { + if (taskId == null || taskId <= 0 || shopKey == null || shopKey.isBlank() || payload == null) { + return; + } + taskScopePayloadStorageService.saveScopePayload(taskId, MODULE_TYPE, shopKey, payload); + touchTaskHeartbeat(taskId); + } + + public void removeShopMergedPayload(Long taskId, String shopKey) { + taskScopePayloadStorageService.removeScopePayload(taskId, MODULE_TYPE, shopKey); + } + + public Map getAllShopMergedPayload(Long taskId) { + return taskScopePayloadStorageService.getAllScopePayload(taskId, MODULE_TYPE, WithdrawShopPayloadDto.class); + } + + public boolean hasAnyShopMergedPayload(Long taskId) { + return taskScopePayloadStorageService.hasAnyScopePayload(taskId, MODULE_TYPE); + } + + public Map getTaskHeartbeatMillisBatch(List taskIds) { + Map result = new LinkedHashMap<>(); + if (taskIds == null || taskIds.isEmpty()) { + return result; + } + List normalized = taskIds.stream().filter(id -> id != null && id > 0).distinct().toList(); + if (normalized.isEmpty()) { + return result; + } + List keys = normalized.stream().map(this::buildTaskHeartbeatKey).toList(); + List values; + try { + values = stringRedisTemplate.opsForValue().multiGet(keys); + } catch (Exception ex) { + log.warn("[withdraw-cache] batch get heartbeat degraded taskIds={} msg={}", normalized, ex.getMessage()); + return result; + } + for (int i = 0; i < normalized.size(); i++) { + String raw = values != null && i < values.size() ? values.get(i) : null; + try { + result.put(normalized.get(i), raw == null || raw.isBlank() ? 0L : Long.parseLong(raw)); + } catch (NumberFormatException ignored) { + result.put(normalized.get(i), 0L); + } + } + return result; + } + + public void touchTaskHeartbeat(Long taskId) { + if (taskId == null || taskId <= 0) { + return; + } + try { + stringRedisTemplate.opsForValue().set( + buildTaskHeartbeatKey(taskId), + String.valueOf(Instant.now().toEpochMilli()), + Duration.ofHours(PAYLOAD_TTL_HOURS)); + } catch (Exception ex) { + log.warn("[withdraw-cache] touch heartbeat degraded taskId={} msg={}", taskId, ex.getMessage()); + } + } + + public void deleteTaskCache(Long taskId) { + if (taskId == null || taskId <= 0) { + return; + } + taskEntityLocalCache.remove(taskId); + try { + stringRedisTemplate.delete(buildTaskHeartbeatKey(taskId)); + stringRedisTemplate.delete(buildTaskEntityKey(taskId)); + } catch (Exception ex) { + log.warn("[withdraw-cache] delete cache degraded taskId={} msg={}", taskId, ex.getMessage()); + } + taskScopePayloadStorageService.deleteTaskScopePayloads(taskId, MODULE_TYPE); + } + + public void saveTaskCache(FileTaskEntity task) { + if (task == null || task.getId() == null) { + return; + } + long now = System.currentTimeMillis(); + taskEntityLocalCache.put(task.getId(), new LocalTaskEntityCacheEntry(now, objectMapper.convertValue(task, FileTaskEntity.class))); + try { + stringRedisTemplate.opsForValue().set( + buildTaskEntityKey(task.getId()), + objectMapper.writeValueAsString(task), + Duration.ofHours(PAYLOAD_TTL_HOURS)); + } catch (Exception ignored) { + } + } + + public Map getTaskCacheBatch(List taskIds) { + Map result = new LinkedHashMap<>(); + if (taskIds == null || taskIds.isEmpty()) { + return result; + } + List normalized = taskIds.stream().filter(id -> id != null && id > 0).distinct().toList(); + long now = System.currentTimeMillis(); + List missingIds = new ArrayList<>(); + for (Long taskId : normalized) { + LocalTaskEntityCacheEntry cached = taskEntityLocalCache.get(taskId); + if (cached != null && now - cached.cachedAtMillis() <= Math.max(0L, taskPressureProperties.getLocalTaskEntityCacheMillis())) { + result.put(taskId, objectMapper.convertValue(cached.task(), FileTaskEntity.class)); + } else { + missingIds.add(taskId); + } + } + if (missingIds.isEmpty()) { + return result; + } + List keys = missingIds.stream().map(this::buildTaskEntityKey).toList(); + List values; + try { + values = stringRedisTemplate.opsForValue().multiGet(keys); + } catch (Exception ex) { + log.warn("[withdraw-cache] batch get task cache degraded taskIds={} msg={}", missingIds, ex.getMessage()); + return result; + } + for (int i = 0; i < missingIds.size(); i++) { + Long taskId = missingIds.get(i); + String raw = values != null && i < values.size() ? values.get(i) : null; + if (raw == null || raw.isBlank()) { + continue; + } + try { + FileTaskEntity task = objectMapper.readValue(raw, FileTaskEntity.class); + result.put(taskId, task); + taskEntityLocalCache.put(taskId, new LocalTaskEntityCacheEntry(now, task)); + } catch (Exception ignored) { + } + } + return result; + } + + private String buildTaskHeartbeatKey(Long taskId) { + return "withdraw:task:heartbeat:" + taskId; + } + + private String buildTaskEntityKey(Long taskId) { + return "withdraw:task:entity:" + taskId; + } + + private record LocalTaskEntityCacheEntry(long cachedAtMillis, FileTaskEntity task) {} +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/withdraw/service/WithdrawTaskService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/withdraw/service/WithdrawTaskService.java new file mode 100644 index 0000000..3218ed6 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/withdraw/service/WithdrawTaskService.java @@ -0,0 +1,1071 @@ +package com.nanri.aiimage.modules.withdraw.service; + +import cn.hutool.core.io.FileUtil; +import cn.hutool.core.util.IdUtil; +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.config.TaskPressureProperties; +import com.nanri.aiimage.modules.file.service.oss.OssStorageService; +import com.nanri.aiimage.modules.productrisk.model.vo.ProductRiskDashboardVo; +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.task.model.entity.TaskFileJobEntity; +import com.nanri.aiimage.modules.task.service.TaskDistributedLockService; +import com.nanri.aiimage.modules.task.service.TaskFileJobService; +import com.nanri.aiimage.modules.task.service.TaskProgressSnapshotService; +import com.nanri.aiimage.modules.task.service.TaskResultItemService; +import com.nanri.aiimage.modules.withdraw.model.dto.WithdrawCreateTaskRequest; +import com.nanri.aiimage.modules.withdraw.model.dto.WithdrawRowDto; +import com.nanri.aiimage.modules.withdraw.model.dto.WithdrawShopPayloadDto; +import com.nanri.aiimage.modules.withdraw.model.dto.WithdrawSubmitResultRequest; +import com.nanri.aiimage.modules.withdraw.model.dto.WithdrawTaskItemDto; +import com.nanri.aiimage.modules.withdraw.model.enums.WithdrawStatus; +import com.nanri.aiimage.modules.withdraw.model.vo.WithdrawCreateTaskVo; +import com.nanri.aiimage.modules.withdraw.model.vo.WithdrawHistoryVo; +import com.nanri.aiimage.modules.withdraw.model.vo.WithdrawResultItemVo; +import com.nanri.aiimage.modules.withdraw.model.vo.WithdrawTaskBatchVo; +import com.nanri.aiimage.modules.ziniao.service.ZiniaoShopSwitchService; +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.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +@Service +@RequiredArgsConstructor +@Slf4j +public class WithdrawTaskService { + + private static final String MODULE_TYPE = "WITHDRAW"; + private static final String CONTENT_TYPE_XLSX = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; + private static final int RESULT_PENDING = -1; + private static final int RESULT_FAILED = 0; + private static final int RESULT_SUCCESS = 1; + private static final String INTERRUPTED_MESSAGE = "Python 在该店铺结果提交完成前中断"; + + private final FileTaskMapper fileTaskMapper; + private final FileResultMapper fileResultMapper; + private final WithdrawResolveService withdrawResolveService; + private final WithdrawExcelAssemblyService excelAssemblyService; + private final WithdrawTaskCacheService taskCacheService; + private final OssStorageService ossStorageService; + private final ZiniaoShopSwitchService ziniaoShopSwitchService; + private final ObjectMapper objectMapper; + private final TaskPressureProperties taskPressureProperties; + private final TaskFileJobService taskFileJobService; + private final TaskResultItemService taskResultItemService; + private final TaskProgressSnapshotService taskProgressSnapshotService; + private final TaskDistributedLockService taskDistributedLockService; + + public ProductRiskDashboardVo dashboard(Long userId) { + validateUserId(userId); + ProductRiskDashboardVo vo = new ProductRiskDashboardVo(); + vo.setCandidateCount(withdrawResolveService.countCandidates(userId)); + vo.setProcessedTaskCount(countTasks(userId, List.of("SUCCESS", "FAILED"))); + vo.setSuccessTaskCount(countTasks(userId, List.of("SUCCESS"))); + vo.setFailedTaskCount(countTasks(userId, List.of("FAILED"))); + return vo; + } + + public WithdrawHistoryVo listHistory(Long userId) { + validateUserId(userId); + WithdrawHistoryVo vo = new WithdrawHistoryVo(); + List entities = fileResultMapper.selectList(new LambdaQueryWrapper() + .eq(FileResultEntity::getModuleType, MODULE_TYPE) + .eq(FileResultEntity::getUserId, userId) + .orderByDesc(FileResultEntity::getCreatedAt) + .last("limit 100")); + if (entities.isEmpty()) { + vo.setItems(List.of()); + return vo; + } + Map taskMap = loadHistoryTaskMap(entities); + Map> snapshotMap = buildSnapshotMap(taskMap); + Map jobMap = taskFileJobService.findAssembleJobsByResultIds(MODULE_TYPE, entities.stream() + .map(FileResultEntity::getId) + .filter(id -> id != null && id > 0) + .distinct() + .toList()); + List items = new ArrayList<>(); + for (FileResultEntity entity : entities) { + FileTaskEntity task = taskMap.get(entity.getTaskId()); + WithdrawResultItemVo snapshot = snapshotMap.getOrDefault(entity.getTaskId(), Map.of()).get(entity.getId()); + items.add(toHistoryItem(entity, task, snapshot, jobMap.get(entity.getId()))); + } + vo.setItems(items); + return vo; + } + + public WithdrawTaskBatchVo getTaskProgressBatch(List taskIds) { + WithdrawTaskBatchVo batch = new WithdrawTaskBatchVo(); + List normalizedTaskIds = normalizeTaskIds(taskIds); + if (normalizedTaskIds.isEmpty()) { + return batch; + } + Map taskMap = loadTaskMapByIds(normalizedTaskIds); + List rows = fileResultMapper.selectList(new LambdaQueryWrapper() + .eq(FileResultEntity::getModuleType, MODULE_TYPE) + .in(FileResultEntity::getTaskId, normalizedTaskIds) + .orderByAsc(FileResultEntity::getId)); + Map> rowsByTaskId = new LinkedHashMap<>(); + for (FileResultEntity row : rows) { + rowsByTaskId.computeIfAbsent(row.getTaskId(), ignored -> new ArrayList<>()).add(row); + } + for (Long taskId : normalizedTaskIds) { + FileTaskEntity task = taskMap.get(taskId); + List taskRows = rowsByTaskId.get(taskId); + if (task == null || taskRows == null || taskRows.isEmpty()) { + batch.getMissingTaskIds().add(taskId); + continue; + } + batch.getItems().addAll(buildSnapshotFromDb(task, taskRows)); + } + return batch; + } + + public String resolveResultDownloadUrl(Long resultId, Long userId) { + FileResultEntity entity = fileResultMapper.selectById(resultId); + if (entity == null || !MODULE_TYPE.equals(entity.getModuleType()) || !userId.equals(entity.getUserId())) { + throw new BusinessException("记录不存在"); + } + if (blank(entity.getResultFileUrl())) { + throw new BusinessException("暂无可下载文件"); + } + return ossStorageService.generateFreshDownloadUrl(entity.getResultFileUrl()); + } + + public String resolveResultDownloadFilename(Long resultId, Long userId) { + FileResultEntity entity = fileResultMapper.selectById(resultId); + if (entity == null || !MODULE_TYPE.equals(entity.getModuleType()) || !userId.equals(entity.getUserId())) { + throw new BusinessException("记录不存在"); + } + return !blank(entity.getResultFilename()) ? entity.getResultFilename() : safeFileStem("取款-" + resultId) + ".xlsx"; + } + + @Transactional + public WithdrawCreateTaskVo createTask(WithdrawCreateTaskRequest request) { + validateUserId(request.getUserId()); + if (request.getItems() == null || request.getItems().isEmpty()) { + throw new BusinessException("items 不能为空"); + } + List uniqueItems = dedupeItems(request.getItems()); + if (uniqueItems.isEmpty()) { + throw new BusinessException("items 不能为空"); + } + for (WithdrawTaskItemDto item : uniqueItems) { + if (item == null || !item.isMatched()) { + throw new BusinessException("存在未匹配店铺,无法创建任务"); + } + } + + LocalDateTime now = LocalDateTime.now(); + FileTaskEntity task = new FileTaskEntity(); + task.setTaskNo(MODULE_TYPE + "-" + IdUtil.getSnowflakeNextIdStr()); + task.setModuleType(MODULE_TYPE); + task.setTaskMode("PYTHON_QUEUE"); + task.setStatus("RUNNING"); + task.setSourceFileCount(uniqueItems.size()); + task.setSuccessFileCount(0); + task.setFailedFileCount(0); + task.setCreatedBy("user:" + request.getUserId()); + task.setUserId(request.getUserId()); + task.setCreatedAt(now); + task.setUpdatedAt(now); + fileTaskMapper.insert(task); + taskCacheService.saveTaskCache(task); + taskCacheService.touchTaskHeartbeat(task.getId()); + + String summaryShopName = buildTaskShopSummary(uniqueItems); + FileResultEntity result = new FileResultEntity(); + result.setTaskId(task.getId()); + result.setModuleType(MODULE_TYPE); + result.setSourceFilename(summaryShopName); + result.setSourceFileUrl(task.getTaskNo()); + result.setUserId(request.getUserId()); + result.setSuccess(RESULT_PENDING); + result.setCreatedAt(now); + fileResultMapper.insert(result); + + WithdrawResultItemVo snapshot = toTaskSnapshotVo(result, uniqueItems, task.getStatus(), null, request.getReservedAmount()); + List snapshots = List.of(snapshot); + Map requestPayload = new LinkedHashMap<>(); + requestPayload.put("reservedAmount", request.getReservedAmount()); + requestPayload.put("items", uniqueItems); + persistTaskJson(task, requestPayload, snapshots); + taskCacheService.saveTaskCache(task); + + WithdrawCreateTaskVo vo = new WithdrawCreateTaskVo(); + vo.setTaskId(task.getId()); + vo.setItems(snapshots); + return vo; + } + + public void submitResult(Long taskId, WithdrawSubmitResultRequest request) { + if (taskId == null || taskId <= 0) { + throw new BusinessException("taskId 不合法"); + } + if (request == null || request.getShops() == null || request.getShops().isEmpty()) { + throw new BusinessException("shops 不能为空"); + } + try (TaskDistributedLockService.LockHandle ignored = acquireTaskLockOrThrow(taskId)) { + FileTaskEntity task = loadTaskForExecution(taskId); + if (task == null || !MODULE_TYPE.equals(task.getModuleType())) { + throw new BusinessException("任务不存在"); + } + if ("SUCCESS".equals(task.getStatus()) || "FAILED".equals(task.getStatus())) { + throw new BusinessException("任务已结束,拒绝重复提交"); + } + + taskCacheService.touchTaskHeartbeat(taskId); + List resultRows = listTaskRows(taskId); + if (resultRows.isEmpty()) { + throw new BusinessException("任务不存在"); + } + FileResultEntity resultRow = resultRows.get(0); + List snapshots = buildSnapshotFromDb(task, resultRows); + WithdrawResultItemVo snapshot = snapshots.isEmpty() ? new WithdrawResultItemVo() : snapshots.get(0); + Map payloadByShop = normalizePayloadByShop(request.getShops()); + List mergedShops = mergeShopSnapshots(taskId, snapshot, payloadByShop); + snapshot.setShops(mergedShops); + snapshot.setRows(flattenShopRows(mergedShops)); + snapshot.setShopName(buildTaskShopSummary(mergedShops)); + + boolean hasError = mergedShops.stream().anyMatch(item -> !blank(item.getError())); + boolean allFinished = !mergedShops.isEmpty() && mergedShops.stream().noneMatch(item -> item.getSuccess() == null); + if (hasError) { + String error = mergedShops.stream().map(WithdrawResultItemVo::getError).filter(value -> !blank(value)).findFirst().orElse("取款任务执行失败"); + markResultFailed(resultRow, error); + applyFailureToSnapshot(snapshot, error); + } else if (allFinished) { + markResultSuccess(resultRow); + applySuccessToSnapshot(snapshot); + } + + persistSnapshotJson(task, List.of(snapshot)); + fileTaskMapper.updateById(task); + taskCacheService.saveTaskCache(task); + tryFinalizeTask(taskId, false); + } + } + + public boolean tryFinalizeTask(Long taskId, boolean fromCompensation) { + if (taskId == null || taskId <= 0) { + return false; + } + TaskDistributedLockService.LockHandle lockHandle = acquireTaskLock(taskId); + if (lockHandle == null) { + log.info("[withdraw] tryFinalizeTask skipped because task lock is busy taskId={} fromCompensation={}", taskId, fromCompensation); + return false; + } + try (lockHandle) { + FileTaskEntity task = loadTaskForExecution(taskId); + if (task == null || !MODULE_TYPE.equals(task.getModuleType())) { + return false; + } + if ("SUCCESS".equals(task.getStatus()) || "FAILED".equals(task.getStatus())) { + return true; + } + List rows = listTaskRows(taskId); + if (rows.isEmpty()) { + return false; + } + FileResultEntity resultRow = rows.get(0); + + Map cachedPayloads = taskCacheService.getAllShopMergedPayload(taskId); + List snapshots = buildSnapshotFromDb(task, rows); + WithdrawResultItemVo snapshot = snapshots.isEmpty() ? new WithdrawResultItemVo() : snapshots.get(0); + boolean changed = false; + for (WithdrawResultItemVo shop : snapshot.getShops() == null ? List.of() : snapshot.getShops()) { + if (Boolean.TRUE.equals(shop.getSuccess()) || Boolean.FALSE.equals(shop.getSuccess())) { + continue; + } + String shopKey = ziniaoShopSwitchService.normalizeShopName(shop.getShopName()); + WithdrawShopPayloadDto cached = cachedPayloads.get(shopKey); + if (cached == null) { + if (fromCompensation) { + applyFailureToSnapshot(shop, INTERRUPTED_MESSAGE); + changed = true; + } + continue; + } + mergePayloadIntoSnapshot(shop, cached); + if (!blank(cached.getError())) { + applyFailureToSnapshot(shop, cached.getError()); + taskCacheService.removeShopMergedPayload(taskId, shopKey); + changed = true; + } else if (Boolean.TRUE.equals(cached.getShopDone())) { + applySuccessToSnapshot(shop); + taskCacheService.removeShopMergedPayload(taskId, shopKey); + changed = true; + } else if (fromCompensation) { + applyFailureToSnapshot(shop, INTERRUPTED_MESSAGE); + taskCacheService.removeShopMergedPayload(taskId, shopKey); + changed = true; + } + } + + snapshot.setRows(flattenShopRows(snapshot.getShops())); + snapshot.setShopName(buildTaskShopSummary(snapshot.getShops())); + boolean hasError = snapshot.getShops() != null && snapshot.getShops().stream().anyMatch(item -> !blank(item.getError())); + boolean allFinished = snapshot.getShops() != null && !snapshot.getShops().isEmpty() + && snapshot.getShops().stream().noneMatch(item -> item.getSuccess() == null); + if (hasError) { + String error = snapshot.getShops().stream().map(WithdrawResultItemVo::getError).filter(value -> !blank(value)).findFirst().orElse("取款任务执行失败"); + markResultFailed(resultRow, error); + applyFailureToSnapshot(snapshot, error); + } else if (allFinished) { + markResultSuccess(resultRow); + applySuccessToSnapshot(snapshot); + } + rows = listTaskRows(taskId); + updateTaskStatusFromRows(task, rows); + if ("SUCCESS".equals(task.getStatus()) || "FAILED".equals(task.getStatus())) { + finalizeTaskWorkbook(task, rows, List.of(snapshot)); + return true; + } + persistSnapshotJson(task, List.of(snapshot)); + fileTaskMapper.updateById(task); + taskCacheService.saveTaskCache(task); + return changed; + } + } + + @Transactional + public void deleteTask(Long taskId, Long userId) { + validateUserId(userId); + FileTaskEntity task = loadTaskForExecution(taskId); + if (task == null || !MODULE_TYPE.equals(task.getModuleType()) || !userId.equals(task.getUserId())) { + throw new BusinessException("任务不存在"); + } + try (TaskDistributedLockService.LockHandle ignored = acquireTaskLockOrThrow(taskId)) { + fileResultMapper.delete(new LambdaQueryWrapper() + .eq(FileResultEntity::getTaskId, taskId) + .eq(FileResultEntity::getModuleType, MODULE_TYPE)); + fileTaskMapper.deleteById(taskId); + taskFileJobService.deleteTaskJobs(taskId, MODULE_TYPE); + taskResultItemService.deleteTaskItems(taskId, MODULE_TYPE); + taskProgressSnapshotService.delete(taskId, MODULE_TYPE); + taskCacheService.deleteTaskCache(taskId); + } + } + + @Transactional + public void deleteHistory(Long resultId, Long userId) { + validateUserId(userId); + FileResultEntity entity = fileResultMapper.selectById(resultId); + if (entity == null || !MODULE_TYPE.equals(entity.getModuleType()) || !userId.equals(entity.getUserId())) { + throw new BusinessException("记录不存在"); + } + Long taskId = entity.getTaskId(); + if (taskId == null || taskId <= 0) { + fileResultMapper.deleteById(resultId); + return; + } + try (TaskDistributedLockService.LockHandle ignored = acquireTaskLockOrThrow(taskId)) { + fileResultMapper.deleteById(resultId); + taskFileJobService.deleteResultJobs(taskId, MODULE_TYPE, resultId); + taskResultItemService.deleteResultItem(taskId, MODULE_TYPE, resultId); + reconcileTaskAfterResultRemoval(taskId); + } + } + + public void processResultFileJob(TaskFileJobEntity job) { + if (job == null || job.getTaskId() == null) { + throw new BusinessException("结果文件任务参数不完整"); + } + FileTaskEntity task = fileTaskMapper.selectById(job.getTaskId()); + if (task == null || !MODULE_TYPE.equals(task.getModuleType())) { + throw new BusinessException("任务不存在"); + } + List rows = listTaskRows(job.getTaskId()); + List snapshots = taskResultItemService.listResultSnapshots(job.getTaskId(), MODULE_TYPE, WithdrawResultItemVo.class); + if (snapshots.isEmpty()) { + snapshots = buildSnapshotFromDb(task, rows); + } + List successItems = snapshots.stream() + .filter(item -> Boolean.TRUE.equals(item.getSuccess())) + .toList(); + if (successItems.isEmpty()) { + throw new BusinessException("没有可生成的取款结果"); + } + File workRoot = FileUtil.mkdir(FileUtil.file(System.getProperty("java.io.tmpdir"), "withdraw-result", String.valueOf(task.getId()))); + String filename = buildTaskWorkbookFilename(task); + File xlsx = FileUtil.file(workRoot, filename); + try { + excelAssemblyService.writeWorkbook(xlsx, successItems); + String objectKey = ossStorageService.uploadResultFile(xlsx, MODULE_TYPE); + long fileSize = xlsx.length(); + int rowCount = excelAssemblyService.countRows(successItems); + for (FileResultEntity row : rows) { + if (Integer.valueOf(RESULT_SUCCESS).equals(row.getSuccess())) { + row.setResultFilename(filename); + row.setResultFileUrl(objectKey); + row.setResultFileSize(fileSize); + row.setResultContentType(CONTENT_TYPE_XLSX); + row.setRowCount(rowCount); + fileResultMapper.updateById(row); + } + } + updateTaskStatusFromRows(task, rows); + persistSnapshotJson(task, buildSnapshotFromDb(task, rows)); + fileTaskMapper.updateById(task); + } finally { + FileUtil.del(xlsx); + } + } + + private void finalizeTaskWorkbook(FileTaskEntity task, List rows, List snapshots) { + List successItems = snapshots.stream() + .filter(item -> Boolean.TRUE.equals(item.getSuccess())) + .toList(); + if (!successItems.isEmpty()) { + String filename = buildTaskWorkbookFilename(task); + int rowCount = excelAssemblyService.countRows(successItems); + FileResultEntity firstSuccessRow = null; + for (FileResultEntity row : rows) { + if (Integer.valueOf(RESULT_SUCCESS).equals(row.getSuccess())) { + row.setResultFilename(filename); + row.setResultFileUrl(null); + row.setResultFileSize(0L); + row.setResultContentType(CONTENT_TYPE_XLSX); + row.setRowCount(rowCount); + fileResultMapper.updateById(row); + if (firstSuccessRow == null) { + firstSuccessRow = row; + } + } + } + if (firstSuccessRow != null) { + taskFileJobService.enqueueAssembleResult(task.getId(), MODULE_TYPE, firstSuccessRow.getId(), "task:" + task.getId()); + } + } + persistSnapshotJson(task, snapshots); + fileTaskMapper.updateById(task); + taskCacheService.deleteTaskCache(task.getId()); + } + + private void reconcileTaskAfterResultRemoval(Long taskId) { + FileTaskEntity task = loadTaskForExecution(taskId); + if (task == null || !MODULE_TYPE.equals(task.getModuleType())) { + return; + } + List rows = listTaskRows(taskId); + if (rows.isEmpty()) { + fileTaskMapper.deleteById(taskId); + taskFileJobService.deleteTaskJobs(taskId, MODULE_TYPE); + taskResultItemService.deleteTaskItems(taskId, MODULE_TYPE); + taskProgressSnapshotService.delete(taskId, MODULE_TYPE); + taskCacheService.deleteTaskCache(taskId); + return; + } + updateTaskStatusFromRows(task, rows); + persistSnapshotJson(task, buildSnapshotFromDb(task, rows)); + fileTaskMapper.updateById(task); + taskCacheService.saveTaskCache(task); + } + + private FileTaskEntity loadTaskForExecution(Long taskId) { + Map cachedTasks = taskCacheService.getTaskCacheBatch(List.of(taskId)); + FileTaskEntity cached = cachedTasks.get(taskId); + if (cached != null) { + return cached; + } + FileTaskEntity dbTask = fileTaskMapper.selectById(taskId); + if (dbTask != null && MODULE_TYPE.equals(dbTask.getModuleType())) { + taskCacheService.saveTaskCache(dbTask); + } + return dbTask; + } + + private Map loadTaskMapByIds(List taskIds) { + Map result = new LinkedHashMap<>(); + List normalizedTaskIds = normalizeTaskIds(taskIds); + if (normalizedTaskIds.isEmpty()) { + return result; + } + Map cachedTasks = taskCacheService.getTaskCacheBatch(normalizedTaskIds); + result.putAll(cachedTasks); + List missingTaskIds = normalizedTaskIds.stream().filter(taskId -> !cachedTasks.containsKey(taskId)).toList(); + for (FileTaskEntity dbTask : selectTasksByIdsInBatches(missingTaskIds)) { + if (dbTask != null && MODULE_TYPE.equals(dbTask.getModuleType())) { + result.put(dbTask.getId(), dbTask); + if ("RUNNING".equals(dbTask.getStatus())) { + taskCacheService.saveTaskCache(dbTask); + } + } + } + return result; + } + + private List selectTasksByIdsInBatches(List taskIds) { + List tasks = new ArrayList<>(); + if (taskIds == null || taskIds.isEmpty()) { + return tasks; + } + int batchSize = Math.max(1, taskPressureProperties.getDbSelectBatchSize()); + for (int start = 0; start < taskIds.size(); start += batchSize) { + int end = Math.min(start + batchSize, taskIds.size()); + tasks.addAll(fileTaskMapper.selectBatchIds(taskIds.subList(start, end))); + } + return tasks; + } + + private Map loadHistoryTaskMap(List entities) { + List taskIds = entities.stream() + .map(FileResultEntity::getTaskId) + .filter(id -> id != null && id > 0) + .distinct() + .toList(); + return loadTaskMapByIds(taskIds); + } + + private Map> buildSnapshotMap(Map taskMap) { + Map> out = new LinkedHashMap<>(); + for (Map.Entry entry : taskMap.entrySet()) { + List snapshots = parseTaskSnapshots(entry.getValue().getResultJson()); + if (snapshots.isEmpty()) { + snapshots = taskResultItemService.listResultSnapshots(entry.getKey(), MODULE_TYPE, WithdrawResultItemVo.class); + } + out.put(entry.getKey(), indexSnapshotByResultId(snapshots)); + } + return out; + } + + private List listTaskRows(Long taskId) { + return fileResultMapper.selectList(new LambdaQueryWrapper() + .eq(FileResultEntity::getTaskId, taskId) + .eq(FileResultEntity::getModuleType, MODULE_TYPE) + .orderByAsc(FileResultEntity::getId)); + } + + private Map normalizePayloadByShop(List shops) { + Map map = new LinkedHashMap<>(); + for (WithdrawShopPayloadDto shop : shops == null ? List.of() : shops) { + String shopName = ziniaoShopSwitchService.normalizeShopName(shop == null ? null : shop.getShopName()); + if (blank(shopName)) { + continue; + } + shop.setShopName(shopName); + shop.setRows(copyRows(shop.getRows())); + map.put(shopName, shop); + } + return map; + } + + private List dedupeItems(List items) { + LinkedHashMap map = new LinkedHashMap<>(); + for (WithdrawTaskItemDto item : items == null ? List.of() : items) { + String normalizedShopName = ziniaoShopSwitchService.normalizeShopName(item == null ? null : item.getShopName()); + if (blank(normalizedShopName)) { + continue; + } + item.setShopName(normalizedShopName); + map.put(normalizedShopName + "::" + Objects.toString(item.getShopId(), ""), item); + } + return new ArrayList<>(map.values()); + } + + private List mergeShopSnapshots(Long taskId, WithdrawResultItemVo taskSnapshot, Map payloadByShop) { + List shops = taskSnapshot.getShops() == null ? new ArrayList<>() : taskSnapshot.getShops(); + for (WithdrawResultItemVo shop : shops) { + String shopKey = ziniaoShopSwitchService.normalizeShopName(shop.getShopName()); + WithdrawShopPayloadDto incoming = payloadByShop.get(shopKey); + if (incoming == null) { + continue; + } + WithdrawShopPayloadDto mergedPayload = mergeShopPayload(taskId, shopKey, incoming); + mergePayloadIntoSnapshot(shop, mergedPayload); + if (!blank(mergedPayload.getError())) { + shop.setSuccess(Boolean.FALSE); + shop.setError(mergedPayload.getError()); + } else if (Boolean.TRUE.equals(mergedPayload.getShopDone())) { + shop.setSuccess(Boolean.TRUE); + shop.setError(null); + } + } + return shops; + } + + private List flattenShopRows(List shops) { + List rows = new ArrayList<>(); + for (WithdrawResultItemVo shop : shops == null ? List.of() : shops) { + rows.addAll(copyRows(shop.getRows())); + } + return rows; + } + + private String buildTaskShopSummary(List items) { + List names = new ArrayList<>(); + for (Object item : items == null ? List.of() : items) { + if (item instanceof WithdrawResultItemVo vo) { + if (!blank(vo.getShopName())) { + names.add(vo.getShopName()); + } + } else if (item instanceof WithdrawTaskItemDto dto) { + String shopName = ziniaoShopSwitchService.normalizeShopName(dto.getShopName()); + if (!blank(shopName)) { + names.add(shopName); + } + } + } + return names.isEmpty() ? "" : String.join("、", names); + } + + private WithdrawResultItemVo toSnapshotVo(FileResultEntity result, WithdrawTaskItemDto item, String taskStatus, LocalDateTime finishedAt, BigDecimal reservedAmount) { + WithdrawResultItemVo vo = new WithdrawResultItemVo(); + vo.setResultId(result.getId()); + vo.setTaskId(result.getTaskId()); + vo.setShopName(item.getShopName()); + vo.setShopId(item.getShopId()); + vo.setPlatform(item.getPlatform()); + vo.setCompanyName(item.getCompanyName()); + vo.setMatched(item.isMatched()); + vo.setMatchStatus(item.getMatchStatus()); + vo.setMatchMessage(item.getMatchMessage()); + vo.setTaskStatus(taskStatus); + vo.setReservedAmount(reservedAmount); + vo.setSuccess(toSuccessFlag(result.getSuccess(), null)); + vo.setError(result.getErrorMessage()); + vo.setCreatedAt(result.getCreatedAt()); + vo.setFinishedAt(finishedAt); + vo.setOutputFilename(result.getResultFilename()); + vo.setDownloadUrl(null); + vo.setRows(new ArrayList<>()); + return vo; + } + + private WithdrawResultItemVo toHistoryItem(FileResultEntity entity, FileTaskEntity task, WithdrawResultItemVo snapshot, TaskFileJobEntity job) { + WithdrawResultItemVo item = snapshot != null ? snapshot : new WithdrawResultItemVo(); + item.setResultId(entity.getId()); + item.setTaskId(entity.getTaskId()); + item.setShopName(firstNonBlank(item.getShopName(), entity.getSourceFilename())); + item.setShopId(firstNonBlank(item.getShopId(), entity.getSourceFileUrl())); + item.setTaskStatus(task != null ? task.getStatus() : item.getTaskStatus()); + item.setSuccess(toSuccessFlag(entity.getSuccess(), item.getSuccess())); + item.setError(!blank(entity.getErrorMessage()) ? entity.getErrorMessage() : item.getError()); + item.setCreatedAt(entity.getCreatedAt()); + item.setFinishedAt(task != null ? task.getFinishedAt() : item.getFinishedAt()); + item.setOutputFilename(firstNonBlank(item.getOutputFilename(), entity.getResultFilename())); + item.setDownloadUrl(null); + attachFileJobState(item, entity, job); + if (item.getRows() == null) { + item.setRows(new ArrayList<>()); + } + return item; + } + + private void attachFileJobState(WithdrawResultItemVo item, FileResultEntity entity, TaskFileJobEntity job) { + item.setFileReady(!blank(entity.getResultFileUrl())); + if (job == null) { + item.setFileStatus(Boolean.TRUE.equals(item.getFileReady()) ? "SUCCESS" : null); + return; + } + item.setFileJobId(job.getId()); + item.setFileStatus(job.getStatus()); + item.setFileError(job.getErrorMessage()); + } + + private void persistTaskJson(FileTaskEntity task, Object requestPayload, List snapshots) { + try { + task.setRequestJson(objectMapper.writeValueAsString(requestPayload)); + task.setResultJson(objectMapper.writeValueAsString(snapshots == null ? List.of() : snapshots)); + syncSnapshotTables(task, snapshots); + fileTaskMapper.updateById(task); + } catch (Exception ex) { + throw new BusinessException("取款任务快照保存失败"); + } + } + + private void persistSnapshotJson(FileTaskEntity task, List snapshots) { + try { + task.setResultJson(objectMapper.writeValueAsString(snapshots == null ? List.of() : snapshots)); + syncSnapshotTables(task, snapshots); + } catch (Exception ex) { + throw new BusinessException("取款任务快照保存失败"); + } + } + + private void syncSnapshotTables(FileTaskEntity task, List snapshots) { + List safe = snapshots == null ? List.of() : snapshots; + taskResultItemService.replaceTaskSnapshots(task.getId(), MODULE_TYPE, safe, new TaskResultItemService.SnapshotKeyResolver() { + @Override + public Long resultId(Object snapshot) { + return ((WithdrawResultItemVo) snapshot).getResultId(); + } + + @Override + public String scopeKey(Object snapshot) { + WithdrawResultItemVo item = (WithdrawResultItemVo) snapshot; + return firstNonBlank(item.getShopName(), "result:" + item.getResultId()); + } + }); + int successCount = 0; + int failedCount = 0; + for (WithdrawResultItemVo item : safe) { + if (Boolean.TRUE.equals(item.getSuccess())) { + successCount++; + } else if (Boolean.FALSE.equals(item.getSuccess())) { + failedCount++; + } + } + taskProgressSnapshotService.save(task.getId(), MODULE_TYPE, firstNonBlank(task.getStatus(), "RUNNING"), + safe.size(), successCount, failedCount, null, task.getErrorMessage(), null); + } + + private List buildSnapshotFromDb(FileTaskEntity task, List rows) { + List existing = parseTaskSnapshots(task.getResultJson()); + if (existing.isEmpty()) { + existing = taskResultItemService.listResultSnapshots(task.getId(), MODULE_TYPE, WithdrawResultItemVo.class); + } + List list = new ArrayList<>(); + if (existing.isEmpty()) { + return list; + } + WithdrawResultItemVo snapshot = existing.get(0); + List shops = snapshot.getShops() == null ? new ArrayList<>() : snapshot.getShops(); + if (shops.isEmpty()) { + shops = new ArrayList<>(); + for (FileResultEntity row : rows) { + shops.add(toShopSnapshot(row, task, taskFileJobService.findAssembleJob(row.getTaskId(), MODULE_TYPE, row.getId()))); + } + snapshot.setShops(shops); + } + snapshot.setRows(flattenShopRows(shops)); + snapshot.setShopName(buildTaskShopSummary(shops)); + if (snapshot.getResultId() == null && !rows.isEmpty()) { + snapshot.setResultId(rows.get(0).getId()); + } + list.add(snapshot); + return list; + } + + private Map indexSnapshotByResultId(List snapshots) { + Map map = new LinkedHashMap<>(); + for (WithdrawResultItemVo snapshot : snapshots == null ? List.of() : snapshots) { + if (snapshot != null && snapshot.getResultId() != null) { + map.put(snapshot.getResultId(), snapshot); + } + } + return map; + } + + private void updateTaskStatusFromRows(FileTaskEntity task, List rows) { + long successCount = rows.stream().filter(row -> Integer.valueOf(RESULT_SUCCESS).equals(row.getSuccess())).count(); + long failedCount = rows.stream().filter(row -> Integer.valueOf(RESULT_FAILED).equals(row.getSuccess())).count(); + long pendingCount = rows.stream().filter(row -> !isResultFinished(row)).count(); + if (pendingCount == 0 && successCount > 0 && isTaskWorkbookPending(task, rows)) { + pendingCount = 1; + } + task.setSuccessFileCount((int) successCount); + task.setFailedFileCount((int) failedCount); + task.setUpdatedAt(LocalDateTime.now()); + if (pendingCount > 0) { + task.setStatus("RUNNING"); + task.setFinishedAt(null); + task.setErrorMessage(null); + return; + } + task.setStatus(failedCount > 0 ? "FAILED" : "SUCCESS"); + task.setFinishedAt(LocalDateTime.now()); + if (failedCount > 0) { + List errors = new ArrayList<>(); + for (FileResultEntity row : rows) { + if (!blank(row.getErrorMessage())) { + errors.add(row.getSourceFilename() + ": " + row.getErrorMessage()); + } + } + task.setErrorMessage(String.join("; ", errors)); + } else { + task.setErrorMessage(null); + } + } + + private WithdrawResultItemVo toTaskSnapshotVo(FileResultEntity result, + List items, + String taskStatus, + LocalDateTime finishedAt, + BigDecimal reservedAmount) { + WithdrawResultItemVo vo = new WithdrawResultItemVo(); + vo.setResultId(result.getId()); + vo.setTaskId(result.getTaskId()); + vo.setShopName(buildTaskShopSummary(items)); + vo.setTaskStatus(taskStatus); + vo.setReservedAmount(reservedAmount); + vo.setSuccess(null); + vo.setError(null); + vo.setCreatedAt(result.getCreatedAt()); + vo.setFinishedAt(finishedAt); + vo.setOutputFilename(result.getResultFilename()); + vo.setDownloadUrl(null); + vo.setRows(new ArrayList<>()); + vo.setShops(new ArrayList<>()); + for (WithdrawTaskItemDto item : items == null ? List.of() : items) { + WithdrawResultItemVo shop = new WithdrawResultItemVo(); + shop.setResultId(result.getId()); + shop.setTaskId(result.getTaskId()); + shop.setShopName(ziniaoShopSwitchService.normalizeShopName(item == null ? null : item.getShopName())); + shop.setShopId(item == null ? null : item.getShopId()); + shop.setPlatform(item == null ? null : item.getPlatform()); + shop.setCompanyName(item == null ? null : item.getCompanyName()); + shop.setMatched(item != null && item.isMatched()); + shop.setMatchStatus(item == null ? null : item.getMatchStatus()); + shop.setMatchMessage(item == null ? null : item.getMatchMessage()); + shop.setTaskStatus(taskStatus); + shop.setReservedAmount(reservedAmount); + shop.setSuccess(null); + shop.setError(null); + shop.setCreatedAt(result.getCreatedAt()); + shop.setFinishedAt(finishedAt); + shop.setOutputFilename(result.getResultFilename()); + shop.setRows(new ArrayList<>()); + shop.setShops(new ArrayList<>()); + vo.getShops().add(shop); + } + return vo; + } + + private WithdrawResultItemVo toShopSnapshot(FileResultEntity row, FileTaskEntity task, TaskFileJobEntity job) { + WithdrawResultItemVo item = new WithdrawResultItemVo(); + item.setResultId(row.getId()); + item.setTaskId(row.getTaskId()); + item.setShopName(row.getSourceFilename()); + item.setShopId(row.getSourceFileUrl()); + item.setTaskStatus(task != null ? task.getStatus() : null); + item.setSuccess(toSuccessFlag(row.getSuccess(), null)); + item.setError(row.getErrorMessage()); + item.setCreatedAt(row.getCreatedAt()); + item.setFinishedAt(task != null ? task.getFinishedAt() : null); + item.setOutputFilename(row.getResultFilename()); + item.setDownloadUrl(null); + item.setRows(new ArrayList<>()); + item.setShops(new ArrayList<>()); + attachFileJobState(item, row, job); + return item; + } + + private WithdrawShopPayloadDto mergeShopPayload(Long taskId, String shopKey, WithdrawShopPayloadDto incoming) { + WithdrawShopPayloadDto merged = taskCacheService.getShopMergedPayload(taskId, shopKey); + if (merged == null) { + merged = new WithdrawShopPayloadDto(); + merged.setShopName(shopKey); + merged.setRows(new ArrayList<>()); + } + merged.setShopName(firstNonBlank(incoming.getShopName(), merged.getShopName())); + merged.setSubmissionId(firstNonBlank(incoming.getSubmissionId(), merged.getSubmissionId())); + if (!blank(incoming.getError())) { + merged.setError(incoming.getError().trim()); + merged.setShopDone(Boolean.TRUE); + taskCacheService.saveShopMergedPayload(taskId, shopKey, merged); + return merged; + } + merged.setRows(mergeRows(merged.getRows(), incoming.getRows())); + if (incoming.getShopDone() == null || Boolean.TRUE.equals(incoming.getShopDone())) { + merged.setShopDone(Boolean.TRUE); + } + taskCacheService.saveShopMergedPayload(taskId, shopKey, merged); + return merged; + } + + private void mergePayloadIntoSnapshot(WithdrawResultItemVo snapshot, WithdrawShopPayloadDto payload) { + if (snapshot == null || payload == null) { + return; + } + snapshot.setShopName(firstNonBlank(payload.getShopName(), snapshot.getShopName())); + snapshot.setRows(copyRows(payload.getRows())); + if (!blank(payload.getError())) { + snapshot.setError(payload.getError().trim()); + } + } + + private List mergeRows(List base, List incoming) { + Map map = new LinkedHashMap<>(); + for (WithdrawRowDto row : copyRows(base)) { + map.put(firstNonBlank(normalizeCountry(row.getCountry()), "row:" + map.size()), row); + } + for (WithdrawRowDto row : copyRows(incoming)) { + map.put(firstNonBlank(normalizeCountry(row.getCountry()), "row:" + map.size()), row); + } + return new ArrayList<>(map.values()); + } + + private List copyRows(List rows) { + List copy = new ArrayList<>(); + for (WithdrawRowDto source : rows == null ? List.of() : rows) { + if (source == null) { + continue; + } + WithdrawRowDto row = new WithdrawRowDto(); + row.setCountry(normalizeCountry(source.getCountry())); + row.setShopAmount(source.getShopAmount()); + row.setWithdrawAmount(source.getWithdrawAmount()); + row.setStatus(resolveStatus(source)); + copy.add(row); + } + return copy; + } + + private WithdrawStatus resolveStatus(WithdrawRowDto row) { + if (row == null) { + return WithdrawStatus.SUCCESS; + } + if (row.getWithdrawAmount() != null && row.getWithdrawAmount().compareTo(BigDecimal.ZERO) < 0) { + return WithdrawStatus.NEGATIVE_WITHDRAW_BLANK; + } + return row.getStatus() == null ? WithdrawStatus.SUCCESS : row.getStatus(); + } + + private boolean isTaskWorkbookPending(FileTaskEntity task, List rows) { + FileResultEntity firstSuccess = rows.stream() + .filter(row -> Integer.valueOf(RESULT_SUCCESS).equals(row.getSuccess())) + .findFirst() + .orElse(null); + if (firstSuccess == null || !blank(firstSuccess.getResultFileUrl())) { + return false; + } + TaskFileJobEntity job = taskFileJobService.findAssembleJob(task.getId(), MODULE_TYPE, firstSuccess.getId()); + return job != null && !"SUCCESS".equals(job.getStatus()); + } + + private void applySuccessToSnapshot(WithdrawResultItemVo snapshot) { + if (snapshot == null) { + return; + } + snapshot.setSuccess(Boolean.TRUE); + snapshot.setError(null); + } + + private void applyFailureToSnapshot(WithdrawResultItemVo snapshot, String error) { + if (snapshot == null) { + return; + } + snapshot.setSuccess(Boolean.FALSE); + snapshot.setError(blankToNull(error)); + } + + private void markResultSuccess(FileResultEntity row) { + row.setSuccess(RESULT_SUCCESS); + row.setErrorMessage(null); + fileResultMapper.updateById(row); + } + + private void markResultFailed(FileResultEntity row, String message) { + row.setSuccess(RESULT_FAILED); + row.setErrorMessage(blankToNull(message)); + row.setResultFilename(null); + row.setResultFileUrl(null); + row.setResultFileSize(0L); + row.setResultContentType(null); + row.setRowCount(0); + fileResultMapper.updateById(row); + } + + private boolean isResultFinished(FileResultEntity row) { + return Integer.valueOf(RESULT_SUCCESS).equals(row.getSuccess()) || Integer.valueOf(RESULT_FAILED).equals(row.getSuccess()); + } + + private Boolean toSuccessFlag(Integer dbValue, Boolean fallback) { + if (dbValue == null || Integer.valueOf(RESULT_PENDING).equals(dbValue)) { + return fallback; + } + return Integer.valueOf(RESULT_SUCCESS).equals(dbValue); + } + + private List parseTaskSnapshots(String json) { + if (blank(json)) { + return new ArrayList<>(); + } + try { + return objectMapper.readValue(json, new TypeReference>() {}); + } catch (Exception ex) { + log.warn("[withdraw] parse task snapshot failed: {}", ex.getMessage()); + return new ArrayList<>(); + } + } + + private long countTasks(Long userId, List statuses) { + Long count = fileTaskMapper.selectCount(new LambdaQueryWrapper() + .eq(FileTaskEntity::getModuleType, MODULE_TYPE) + .eq(FileTaskEntity::getUserId, userId) + .in(FileTaskEntity::getStatus, statuses)); + return count == null ? 0L : count; + } + + private TaskDistributedLockService.LockHandle acquireTaskLockOrThrow(Long taskId) { + TaskDistributedLockService.LockHandle lockHandle = acquireTaskLock(taskId); + if (lockHandle == null) { + throw new BusinessException(40901, "任务正在处理中,请稍后再试"); + } + return lockHandle; + } + + private TaskDistributedLockService.LockHandle acquireTaskLock(Long taskId) { + if (taskId == null || taskId <= 0) { + return null; + } + return taskDistributedLockService.acquire(MODULE_TYPE, taskId); + } + + private List normalizeTaskIds(List taskIds) { + return taskIds == null ? List.of() : taskIds.stream() + .filter(taskId -> taskId != null && taskId > 0) + .distinct() + .limit(50) + .toList(); + } + + private String normalizeCountry(String country) { + if (country == null) { + return ""; + } + String value = country.trim().toUpperCase(); + return switch (value) { + case "德国" -> "DE"; + case "英国" -> "UK"; + case "法国" -> "FR"; + case "意大利" -> "IT"; + case "西班牙" -> "ES"; + default -> value; + }; + } + + private String buildTaskWorkbookFilename(FileTaskEntity task) { + Long taskId = task == null ? null : task.getId(); + return safeFileStem("取款-" + (taskId == null ? "result" : taskId)) + ".xlsx"; + } + + private String safeFileStem(String value) { + String raw = value == null ? "result" : value.trim(); + String safe = raw.replaceAll("[\\\\/:*?\"<>|]+", "_"); + return safe.isBlank() ? "result" : safe; + } + + private String firstNonBlank(String first, String second) { + return !blank(first) ? first : second; + } + + private String blankToNull(String value) { + return blank(value) ? null : value.trim(); + } + + private boolean blank(String value) { + return value == null || value.isBlank(); + } + + private void validateUserId(Long userId) { + if (userId == null || userId <= 0) { + throw new BusinessException("user_id 不合法"); + } + } +} diff --git a/backend-java/src/main/resources/application.yml b/backend-java/src/main/resources/application.yml index 133ba93..cd08937 100644 --- a/backend-java/src/main/resources/application.yml +++ b/backend-java/src/main/resources/application.yml @@ -145,11 +145,12 @@ aiimage: shop-match-stale-timeout-minutes: ${AIIMAGE_SHOP_MATCH_STALE_TIMEOUT_MINUTES:30} patrol-delete-stale-timeout-minutes: ${AIIMAGE_PATROL_DELETE_STALE_TIMEOUT_MINUTES:30} query-asin-stale-timeout-minutes: ${AIIMAGE_QUERY_ASIN_STALE_TIMEOUT_MINUTES:30} + withdraw-stale-timeout-minutes: ${AIIMAGE_WITHDRAW_STALE_TIMEOUT_MINUTES:30} module-cleanup: enabled: ${AIIMAGE_MODULE_CLEANUP_ENABLED:true} cron: ${AIIMAGE_MODULE_CLEANUP_CRON:0 0 0 * * *} retention-days: ${AIIMAGE_MODULE_CLEANUP_RETENTION_DAYS:7} - module-types: ${AIIMAGE_MODULE_CLEANUP_MODULE_TYPES:DEDUPE,SPLIT,CONVERT,DELETE_BRAND,PRODUCT_RISK_RESOLVE,PRICE_TRACK,SHOP_MATCH,PATROL_DELETE,QUERY_ASIN,APPEARANCE_PATENT,SIMILAR_ASIN,COLLECT_DATA} + module-types: ${AIIMAGE_MODULE_CLEANUP_MODULE_TYPES:DEDUPE,SPLIT,CONVERT,DELETE_BRAND,PRODUCT_RISK_RESOLVE,PRICE_TRACK,SHOP_MATCH,PATROL_DELETE,QUERY_ASIN,WITHDRAW,APPEARANCE_PATENT,SIMILAR_ASIN,COLLECT_DATA} permission-schema-init: enabled: ${AIIMAGE_PERMISSION_SCHEMA_INIT_ENABLED:false} task-pressure: diff --git a/backend-java/src/main/resources/db/V61__withdraw_shop_candidate.sql b/backend-java/src/main/resources/db/V61__withdraw_shop_candidate.sql new file mode 100644 index 0000000..e48a682 --- /dev/null +++ b/backend-java/src/main/resources/db/V61__withdraw_shop_candidate.sql @@ -0,0 +1,8 @@ +CREATE TABLE IF NOT EXISTS biz_withdraw_shop_candidate ( + id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键', + user_id BIGINT NOT NULL COMMENT '用户ID', + shop_name VARCHAR(128) NOT NULL COMMENT '店铺名', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + UNIQUE KEY uk_user_shop_name (user_id, shop_name), + KEY idx_user_id (user_id) +) COMMENT='取款前台备选店铺'; diff --git a/backend-java/src/main/resources/db/V62__skip_price_asin_country_asin_indexes.sql b/backend-java/src/main/resources/db/V62__skip_price_asin_country_asin_indexes.sql new file mode 100644 index 0000000..8c7260b --- /dev/null +++ b/backend-java/src/main/resources/db/V62__skip_price_asin_country_asin_indexes.sql @@ -0,0 +1,76 @@ +SET @db_name = DATABASE(); + +SET @idx_exists := ( + SELECT COUNT(*) + FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = @db_name + AND TABLE_NAME = 'biz_skip_price_asin' + AND INDEX_NAME = 'idx_skip_price_asin_de' +); +SET @sql := IF(@idx_exists = 0, + 'ALTER TABLE biz_skip_price_asin ADD INDEX idx_skip_price_asin_de (asin_de)', + 'SELECT 1' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @idx_exists := ( + SELECT COUNT(*) + FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = @db_name + AND TABLE_NAME = 'biz_skip_price_asin' + AND INDEX_NAME = 'idx_skip_price_asin_uk' +); +SET @sql := IF(@idx_exists = 0, + 'ALTER TABLE biz_skip_price_asin ADD INDEX idx_skip_price_asin_uk (asin_uk)', + 'SELECT 1' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @idx_exists := ( + SELECT COUNT(*) + FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = @db_name + AND TABLE_NAME = 'biz_skip_price_asin' + AND INDEX_NAME = 'idx_skip_price_asin_fr' +); +SET @sql := IF(@idx_exists = 0, + 'ALTER TABLE biz_skip_price_asin ADD INDEX idx_skip_price_asin_fr (asin_fr)', + 'SELECT 1' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @idx_exists := ( + SELECT COUNT(*) + FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = @db_name + AND TABLE_NAME = 'biz_skip_price_asin' + AND INDEX_NAME = 'idx_skip_price_asin_it' +); +SET @sql := IF(@idx_exists = 0, + 'ALTER TABLE biz_skip_price_asin ADD INDEX idx_skip_price_asin_it (asin_it)', + 'SELECT 1' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @idx_exists := ( + SELECT COUNT(*) + FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = @db_name + AND TABLE_NAME = 'biz_skip_price_asin' + AND INDEX_NAME = 'idx_skip_price_asin_es' +); +SET @sql := IF(@idx_exists = 0, + 'ALTER TABLE biz_skip_price_asin ADD INDEX idx_skip_price_asin_es (asin_es)', + 'SELECT 1' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; diff --git a/frontend-vue/src/pages/brand/components/BrandPriceTrackTab.vue b/frontend-vue/src/pages/brand/components/BrandPriceTrackTab.vue index 8a6d5b2..ec44c22 100644 --- a/frontend-vue/src/pages/brand/components/BrandPriceTrackTab.vue +++ b/frontend-vue/src/pages/brand/components/BrandPriceTrackTab.vue @@ -849,9 +849,9 @@ function buildSkipAsinDeletePolicy() { return { enabled, mode: enabled ? 'DELETE_WHEN_PRICE_BELOW_MINIMUM' : 'NONE', - deleteWhenPriceBelowMinimum: enabled, - compareField: 'price', - thresholdField: 'minimumPrice', + delete_when_price_below_minimum: enabled, + compare_field: 'price', + threshold_field: 'minimum_price', target: 'skip_asin', source: 'frontend-price-track', } @@ -959,12 +959,14 @@ async function pushToPythonQueueLegacy() { ts: Date.now(), data: { task_id: taskVo.taskId, - // 店铺简要信息(用于展示) shop_names: matchedRows.map((i) => i.shopName).filter(Boolean), country_codes: resolveCountryCodesForRequest(), + use_skip_asin_check: statusModeEnabled.value && !asinModeEnabled.value, + skip_asin_check_url: `/api/price-track/tasks/${taskVo.taskId}/skip-asin/check`, + use_paginated_skip_asins: false, + skip_asin_page_size: 0, skip_asin_delete_policy: buildSkipAsinDeletePolicy(), delete_skip_asin_when_price_below_minimum: statusModeEnabled.value && !asinModeEnabled.value, - deleteSkipAsinWhenPriceBelowMinimum: statusModeEnabled.value && !asinModeEnabled.value, }, } queuePayloadText.value = JSON.stringify(queuePayload, null, 2) @@ -992,6 +994,7 @@ function buildQueuePayload(taskVo: PriceTrackCreateTaskVo, row: PriceTrackShopQu const taskItem = taskVo.items?.[0] const shopName = (row.shopName || '').trim() const skipAsinDeletePolicy = buildSkipAsinDeletePolicy() + const skipAsinCheckPath = `/api/price-track/tasks/${taskVo.taskId}/skip-asin/check` const resultId = taskItem?.resultId ?? null const loopRunId = taskItem?.loopRunId ?? null const roundIndex = taskItem?.roundIndex ?? null @@ -1024,28 +1027,12 @@ function buildQueuePayload(taskVo: PriceTrackCreateTaskVo, row: PriceTrackShopQu round_index: roundIndex, country_codes: resolveCountryCodesForRequest(), mode: statusModeEnabled.value ? 'status' : 'asin', - // 分页拉取配置(Python 端统一处理) - use_paginated_skip_asins: true, - skip_asin_page_size: 1000, - // 移除:不再传递任何 ASIN 数据,Python 端统一分页拉取 + use_skip_asin_check: statusModeEnabled.value && !asinModeEnabled.value, + skip_asin_check_url: skipAsinCheckPath, + use_paginated_skip_asins: false, + skip_asin_page_size: 0, skip_asin_delete_policy: skipAsinDeletePolicy, - delete_skip_asin_when_price_below_minimum: skipAsinDeletePolicy.deleteWhenPriceBelowMinimum, - deleteSkipAsinWhenPriceBelowMinimum: skipAsinDeletePolicy.deleteWhenPriceBelowMinimum, - // 兼容字段 - taskId: taskVo.taskId, - resultId: resultId, - shopName, - shopId: row.shopId ?? null, - platformName: row.platform || '', - companyName: row.companyName || '', - shopMallName: row.shopMallName || '', - matchStatus: row.matchStatus || '', - matchMessage: row.matchMessage || null, - outputFilename: outputFilename, - downloadUrl: downloadUrl, - taskStatus, - loopRunId: loopRunId, - roundIndex: roundIndex, + delete_skip_asin_when_price_below_minimum: skipAsinDeletePolicy.delete_when_price_below_minimum, }, } } diff --git a/frontend-vue/src/pages/brand/components/BrandShopMatchTab.vue b/frontend-vue/src/pages/brand/components/BrandShopMatchTab.vue index d2355ed..c819e96 100644 --- a/frontend-vue/src/pages/brand/components/BrandShopMatchTab.vue +++ b/frontend-vue/src/pages/brand/components/BrandShopMatchTab.vue @@ -227,7 +227,18 @@ function isTaskTerminalItem(item: ShopMatchHistoryItem) { } async function loadCandidates() { candidates.value = await listShopMatchCandidates() } async function loadDashboard() { dashboard.value = await getShopMatchDashboard() } -async function loadHistory() { const data = await getShopMatchHistory(); historyItems.value = data.items || [] } +async function loadHistory() { const data = await getShopMatchHistory(); const items = data.items || []; historyItems.value = items; reconcileTerminalTasksFromHistory(items) } +function reconcileTerminalTasksFromHistory(items: ShopMatchHistoryItem[]) { + const terminalTaskIds = new Set() + for (const item of items) { + const taskId = normalizeTaskId(item.taskId) + if (!taskId) continue + if (isTaskTerminalStatus(item.taskStatus) || item.success === true || (item.fileStatus === 'SUCCESS' && item.fileReady)) { + terminalTaskIds.add(taskId) + } + } + for (const taskId of terminalTaskIds) removePollingTask(taskId) +} 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) timers.clearTimer('preference-save', countryPrefSaveTimer); countryPrefSaveTimer = timers.setTimeout('preference-save', () => { countryPrefSaveTimer = null; void saveCountryPreference() }, 450) } @@ -394,7 +405,18 @@ async function waitForScheduledTaskStageExit(taskId: number) { } } } -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 resolvedTaskStatus(item: ShopMatchHistoryItem) { + const itemStatus = item.taskStatus || '' + if (isTaskTerminalStatus(itemStatus)) return itemStatus + if (item.success === true || (item.fileStatus === 'SUCCESS' && item.fileReady)) return 'SUCCESS' + const taskId = normalizeTaskId(item.taskId) + if (!taskId) return itemStatus + const cachedStatus = taskDetails.value[taskId] || '' + const snapshotStatus = taskSnapshots.value[taskId]?.task?.status || '' + if (isTaskTerminalStatus(cachedStatus)) return cachedStatus + if (isTaskTerminalStatus(snapshotStatus)) return snapshotStatus + return cachedStatus || snapshotStatus || itemStatus +} 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} 次`} function nextScheduledDisplay(item: ShopMatchHistoryItem) { const snapshot = taskSnapshotOf(item); const task = snapshot?.task; const stages = task?.scheduleStages || []; if (typeof task?.currentStageIndex === 'number') { const stage = stages.find((entry) => entry.stageIndex === task.currentStageIndex); if (stage?.scheduledAt) return formatMonthDayTime(stage.scheduledAt) } if (item.scheduledAt) return formatMonthDayTime(item.scheduledAt); return '' } diff --git a/frontend-vue/src/pages/brand/components/BrandTopBar.vue b/frontend-vue/src/pages/brand/components/BrandTopBar.vue index 2b8d5d6..f0bcd63 100644 --- a/frontend-vue/src/pages/brand/components/BrandTopBar.vue +++ b/frontend-vue/src/pages/brand/components/BrandTopBar.vue @@ -51,6 +51,7 @@ type ActiveNavKey = | 'pricing' | 'patrol-delete' | 'query-asin' + | 'withdraw' | 'collect-data' | 'image-video' @@ -111,7 +112,7 @@ const navGroups: ReadonlyArray = [ { key: 'pricing', label: '跟价', href: '/new_web_source/price-track.html', aliases: ['price-track'] }, { key: 'patrol-delete', label: '巡店删除', href: '/new_web_source/patrol-delete.html' }, { key: 'query-asin', label: '查询ASIN', href: '/new_web_source/query-asin.html' }, - { key: 'withdraw', label: '取款' }, + { key: 'withdraw', label: '取款', href: '/new_web_source/withdraw.html' }, { key: 'shop-status', label: '店铺状态查询' }, ], }, diff --git a/frontend-vue/src/pages/brand/components/BrandWithdrawTab.vue b/frontend-vue/src/pages/brand/components/BrandWithdrawTab.vue new file mode 100644 index 0000000..802a35e --- /dev/null +++ b/frontend-vue/src/pages/brand/components/BrandWithdrawTab.vue @@ -0,0 +1,1360 @@ + + + + + + + diff --git a/frontend-vue/src/shared/api/java-modules.ts b/frontend-vue/src/shared/api/java-modules.ts index 9702195..aa8f663 100644 --- a/frontend-vue/src/shared/api/java-modules.ts +++ b/frontend-vue/src/shared/api/java-modules.ts @@ -1485,6 +1485,180 @@ export function deleteQueryAsinHistory(resultId: number) { ); } +// ========== 取款 ========== + +export type WithdrawCandidateVo = QueryAsinCandidateVo; +export type WithdrawDashboardVo = QueryAsinDashboardVo; +export type WithdrawShopQueueItem = ProductRiskShopQueueItem; + +export type WithdrawStatusCode = + | "ZERO_AVAILABLE" + | "SUCCESS" + | "BALANCE_FORBIDDEN" + | "NEGATIVE_WITHDRAW_BLANK"; + +export interface WithdrawRow { + country?: string; + shopAmount?: number | string | null; + withdrawAmount?: number | string | null; + status?: WithdrawStatusCode | string; +} + +export interface WithdrawTaskItem { + shopName: string; + matched: boolean; + shopId?: string; + platform?: string; + companyName?: string; + matchStatus?: string; + matchMessage?: string; +} + +export interface WithdrawHistoryItem extends WithdrawTaskItem { + resultId?: number; + taskId?: number; + taskStatus?: string; + reservedAmount?: number | string | null; + success?: boolean; + error?: string; + outputFilename?: string; + downloadUrl?: string; + fileJobId?: number; + fileStatus?: string; + fileError?: string; + fileReady?: boolean; + createdAt?: string; + finishedAt?: string; + rows?: WithdrawRow[]; + shops?: WithdrawHistoryItem[]; +} + +export interface WithdrawHistoryVo { + items: WithdrawHistoryItem[]; +} + +export interface WithdrawTaskBatchVo { + items: WithdrawHistoryItem[]; + missingTaskIds: number[]; +} + +export interface WithdrawCreateTaskVo { + taskId: number; + items: WithdrawHistoryItem[]; +} + +export function listWithdrawCandidates() { + return unwrapJavaResponse( + get>(`${JAVA_API_PREFIX}/withdraw/candidates`, { + params: { user_id: getCurrentUserId() }, + }), + ); +} + +export function addWithdrawCandidate(shopName: string) { + return unwrapJavaResponse( + post, { user_id: number; shop_name: string }>( + `${JAVA_API_PREFIX}/withdraw/candidates`, + { user_id: getCurrentUserId(), shop_name: shopName }, + ), + ); +} + +export function deleteWithdrawCandidate(id: number) { + return unwrapJavaResponse( + del>(`${JAVA_API_PREFIX}/withdraw/candidates/${id}`, { + params: { user_id: getCurrentUserId() }, + }), + ); +} + +export function clearWithdrawCandidates(shopNames: string[]) { + return unwrapJavaResponse( + post, { user_id: number; shop_names: string[] }>( + `${JAVA_API_PREFIX}/withdraw/candidates/clear`, + { user_id: getCurrentUserId(), shop_names: shopNames }, + ), + ); +} + +export function matchWithdrawShops(shopNames: string[]) { + return unwrapJavaResponse( + post, { user_id: number; shop_names: string[] }>( + `${JAVA_API_PREFIX}/withdraw/match-shops`, + { user_id: getCurrentUserId(), shop_names: shopNames }, + ), + ); +} + +export function getWithdrawDashboard() { + return unwrapJavaResponse( + get>(`${JAVA_API_PREFIX}/withdraw/dashboard`, { + params: { user_id: getCurrentUserId() }, + }), + ); +} + +export function getWithdrawHistory() { + return unwrapJavaResponse( + get>(`${JAVA_API_PREFIX}/withdraw/history`, { + params: { user_id: getCurrentUserId() }, + }), + ); +} + +export function getWithdrawTaskProgressBatch(taskIds: number[]) { + return postTaskProgressBatch( + `${JAVA_API_PREFIX}/withdraw/tasks/progress/batch`, + taskIds, + ); +} + +export function createWithdrawTask(items: WithdrawTaskItem[], reservedAmount: number | string | null) { + return unwrapJavaResponse( + post, { user_id: number; reserved_amount: number | string | null; items: WithdrawTaskItem[] }>( + `${JAVA_API_PREFIX}/withdraw/tasks`, + { user_id: getCurrentUserId(), reserved_amount: reservedAmount, items }, + ), + ); +} + +export function submitWithdrawTaskResult( + taskId: number, + payload: { + shops: Array<{ + shopName: string; + error?: string; + rows?: WithdrawRow[]; + shopDone?: boolean; + submissionId?: string; + }>; + }, +) { + return unwrapJavaResponse( + post, typeof payload>(`${JAVA_API_PREFIX}/withdraw/tasks/${taskId}/result`, payload), + ); +} + +export function getWithdrawResultDownloadUrl(resultId: number) { + return getJavaDownloadUrl(`/withdraw/results/${resultId}/download`); +} + +export function deleteWithdrawTask(taskId: number) { + return unwrapJavaResponse( + del>(`${JAVA_API_PREFIX}/withdraw/tasks/${taskId}`, { + params: { user_id: getCurrentUserId() }, + }), + ); +} + +export function deleteWithdrawHistory(resultId: number) { + return unwrapJavaResponse( + del>(`${JAVA_API_PREFIX}/withdraw/history/${resultId}`, { + params: { user_id: getCurrentUserId() }, + }), + ); +} + // ========== 外观专利检测 ========== export interface AppearancePatentParsedRow { @@ -2235,6 +2409,27 @@ export function getTaskSkipPriceAsinsPaginated( ); } +export function checkTaskSkipPriceAsin(taskId: number, country: string, asin: string) { + return unwrapJavaResponse( + get>( + `${JAVA_API_PREFIX}/price-track/tasks/${taskId}/skip-asin/check`, + { + params: { + country, + asin, + }, + }, + ), + ); +} + +export interface SkipPriceAsinCheckVo { + country: string; + asin: string; + exists: boolean; + minimumPrice?: string; +} + export interface SkipPriceAsinPageVo { page: number; pageSize: number; diff --git a/frontend-vue/src/withdraw-main.ts b/frontend-vue/src/withdraw-main.ts new file mode 100644 index 0000000..647a567 --- /dev/null +++ b/frontend-vue/src/withdraw-main.ts @@ -0,0 +1,14 @@ +import { createApp } from 'vue' +import ElementPlus from 'element-plus' +import zhCn from 'element-plus/es/locale/lang/zh-cn' +import dayjs from 'dayjs' +import 'dayjs/locale/zh-cn' +import 'element-plus/dist/index.css' +import '@/styles/main.css' +import BrandWithdrawTab from '@/pages/brand/components/BrandWithdrawTab.vue' + +dayjs.locale('zh-cn') + +createApp(BrandWithdrawTab).use(ElementPlus, { locale: zhCn }).mount('#app') + + diff --git a/frontend-vue/vite.config.ts b/frontend-vue/vite.config.ts index 7bc7b1b..6559995 100644 --- a/frontend-vue/vite.config.ts +++ b/frontend-vue/vite.config.ts @@ -53,6 +53,7 @@ export default defineConfig({ 'price-track': resolve(__dirname, 'price-track.html'), 'patrol-delete': resolve(__dirname, 'patrol-delete.html'), 'query-asin': resolve(__dirname, 'query-asin.html'), + withdraw: resolve(__dirname, 'withdraw.html'), 'collect-data': resolve(__dirname, 'collect-data.html'), 'image-video': resolve(__dirname, 'image-video.html'), }, diff --git a/frontend-vue/withdraw.html b/frontend-vue/withdraw.html new file mode 100644 index 0000000..75b6c27 --- /dev/null +++ b/frontend-vue/withdraw.html @@ -0,0 +1,14 @@ + + + + + + 取款 - 数富AI + + +
+ + + + +