后端优化修复问题
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -12,5 +12,5 @@ public class ModuleCleanupProperties {
|
||||
private boolean enabled = true;
|
||||
private String cron = "0 0 0 * * *";
|
||||
private long retentionDays = 7;
|
||||
private List<String> 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<String> 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"));
|
||||
}
|
||||
|
||||
@@ -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<FileTaskEntity> runningTasks = fileTaskMapper.selectList(new LambdaQueryWrapper<FileTaskEntity>()
|
||||
.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<Long, Long> 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<FileTaskEntity>()
|
||||
.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) {
|
||||
|
||||
@@ -22,7 +22,8 @@ public class PermissionMenuSchemaInitializer {
|
||||
private static final List<DefaultAppMenu> 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<DefaultAdminMenu> DEFAULT_ADMIN_MENUS = List.of(
|
||||
|
||||
@@ -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<SkipPriceAsinCheckVo> 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<PriceTrackDashboardVo> dashboard(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<String, List<String>> skipAsinsByCountry = request.isAsinMode()
|
||||
? new LinkedHashMap<>()
|
||||
: skipPriceAsinService.listAllSkipAsinsByCountry();
|
||||
Map<String, List<Map<String, String>>> skipAsinDetailsByCountry = request.isAsinMode()
|
||||
? new LinkedHashMap<>()
|
||||
: skipPriceAsinService.listAllSkipAsinDetailsByCountry();
|
||||
Map<String, List<Map<String, String>>> asinRowsByCountry = request.isAsinMode()
|
||||
? parseAsinRowsByCountry(request.getAsinFiles(), request.getCountryCodes())
|
||||
: new LinkedHashMap<>();
|
||||
Map<String, Map<String, String>> minimumPriceByCountryAndAsin =
|
||||
buildMinimumPriceByCountryAndAsin(asinRowsByCountry);
|
||||
Map<String, List<String>> skipAsinsByCountry = new LinkedHashMap<>();
|
||||
Map<String, List<Map<String, String>>> skipAsinDetailsByCountry = new LinkedHashMap<>();
|
||||
Map<String, List<Map<String, String>>> asinRowsByCountry = new LinkedHashMap<>();
|
||||
Map<String, Map<String, String>> 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<String> 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<String> 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 数据
|
||||
*/
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -59,9 +59,11 @@ public class ProductRiskShopQueueItemVo {
|
||||
|
||||
@JsonAlias("skip_asins_by_country")
|
||||
@JsonProperty("skipAsinsByCountry")
|
||||
@Schema(description = "按国家分组的跳过 ASIN 列表;跟价任务使用,取款任务通常可忽略")
|
||||
private Map<String, List<String>> skipAsinsByCountry = new LinkedHashMap<>();
|
||||
|
||||
@JsonAlias("skip_asin_details_by_country")
|
||||
@JsonProperty("skipAsinDetailsByCountry")
|
||||
@Schema(description = "按国家分组的跳过 ASIN 明细;包含数据库中维护的跳过跟价 ASIN 信息,取款任务通常可忽略")
|
||||
private Map<String, List<SkipPriceAsinDetailDto>> skipAsinDetailsByCountry = new LinkedHashMap<>();
|
||||
}
|
||||
|
||||
@@ -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<String> countryCodes = new ArrayList<>();
|
||||
|
||||
@JsonProperty("scheduleStages")
|
||||
@Schema(description = "Stage schedule of shop match task")
|
||||
@Schema(description = "分阶段调度信息;用于需要按阶段执行的店铺任务")
|
||||
private List<ShopMatchTaskStageVo> 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;
|
||||
}
|
||||
|
||||
@@ -58,6 +58,7 @@ public class SkipPriceAsinService {
|
||||
private final ShopManageGroupService shopManageGroupService;
|
||||
private final Map<String, QueryAsinImportProgressVo> importProgressMap = new ConcurrentHashMap<>();
|
||||
private final Map<String, QueryAsinImportProgressVo> deleteImportProgressMap = new ConcurrentHashMap<>();
|
||||
private final Map<String, CachedSkipAsinLookup> 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<SkipPriceAsinEntity> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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 -> {
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<List<ProductRiskCandidateVo>> 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<ProductRiskCandidateVo> addCandidate(@Valid @RequestBody ProductRiskCandidateAddRequest request) {
|
||||
return ApiResponse.success(withdrawResolveService.addCandidate(request));
|
||||
}
|
||||
|
||||
@DeleteMapping("/candidates/{id}")
|
||||
@Operation(summary = "删除单个取款备选店铺", description = "按备选记录主键删除当前用户的一条取款备选店铺记录。")
|
||||
public ApiResponse<Void> 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<Void> 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<ProductRiskMatchShopsVo> matchShops(@Valid @RequestBody ProductRiskMatchShopsRequest request) {
|
||||
return ApiResponse.success(withdrawResolveService.matchShops(request));
|
||||
}
|
||||
|
||||
@GetMapping("/dashboard")
|
||||
@Operation(summary = "查询取款看板统计", description = "返回当前用户的备选店铺数量、已结束任务数、成功任务数、失败任务数。")
|
||||
public ApiResponse<ProductRiskDashboardVo> 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<WithdrawHistoryVo> 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<WithdrawTaskBatchVo> taskProgressBatch(@Valid @RequestBody WithdrawTaskBatchRequest request) {
|
||||
return ApiResponse.success(withdrawTaskService.getTaskProgressBatch(request.getTaskIds()));
|
||||
}
|
||||
|
||||
@PostMapping("/tasks")
|
||||
@Operation(summary = "创建取款任务", description = "为一批已匹配店铺创建一个取款任务,写入任务记录和店铺结果占位记录,并返回 taskId 及初始店铺快照。")
|
||||
public ApiResponse<WithdrawCreateTaskVo> createTask(@Valid @RequestBody WithdrawCreateTaskRequest request) {
|
||||
return ApiResponse.success(withdrawTaskService.createTask(request));
|
||||
}
|
||||
|
||||
@PostMapping("/tasks/{taskId}/result")
|
||||
@Operation(summary = "提交取款任务结果", description = "Python 回传取款处理结果。支持同一 taskId 多次增量提交;shopDone=true 表示该店铺已处理完成,可进入文件组装。")
|
||||
public ApiResponse<Void> 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<Void> 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<Void> 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);
|
||||
}
|
||||
}
|
||||
@@ -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<WithdrawShopCandidateEntity> {
|
||||
}
|
||||
@@ -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<String> shopNames = new ArrayList<>();
|
||||
}
|
||||
@@ -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<WithdrawTaskItemDto> items = new ArrayList<>();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<WithdrawRowDto> rows = new ArrayList<>();
|
||||
}
|
||||
@@ -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<WithdrawShopPayloadDto> shops = new ArrayList<>();
|
||||
}
|
||||
@@ -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<Long> taskIds = new ArrayList<>();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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<WithdrawResultItemVo> items = new ArrayList<>();
|
||||
}
|
||||
@@ -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<WithdrawResultItemVo> items = new ArrayList<>();
|
||||
}
|
||||
@@ -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<WithdrawRowDto> rows = new ArrayList<>();
|
||||
|
||||
@Schema(description = "任务级汇总项下的店铺结果列表;单店项通常为空")
|
||||
private List<WithdrawResultItemVo> shops = new ArrayList<>();
|
||||
}
|
||||
@@ -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<WithdrawResultItemVo> items = new ArrayList<>();
|
||||
|
||||
@Schema(description = "请求中不存在、已删除或不属于取款模块的任务 ID 列表")
|
||||
private List<Long> missingTaskIds = new ArrayList<>();
|
||||
}
|
||||
@@ -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<String, String> COUNTRY_NAMES = Map.of(
|
||||
"DE", "德国",
|
||||
"UK", "英国",
|
||||
"GB", "英国",
|
||||
"FR", "法国",
|
||||
"IT", "意大利",
|
||||
"ES", "西班牙"
|
||||
);
|
||||
|
||||
public void writeWorkbook(File outputXlsx, List<WithdrawResultItemVo> 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.<WithdrawResultItemVo>of() : items) {
|
||||
if (item == null || Boolean.FALSE.equals(item.getSuccess())) {
|
||||
continue;
|
||||
}
|
||||
List<WithdrawRowDto> 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<WithdrawResultItemVo> items) {
|
||||
int count = 0;
|
||||
for (WithdrawResultItemVo item : items == null ? List.<WithdrawResultItemVo>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<WithdrawResultItemVo> items) {
|
||||
BigDecimal total = BigDecimal.ZERO;
|
||||
for (WithdrawResultItemVo item : items == null ? List.<WithdrawResultItemVo>of() : items) {
|
||||
if (item == null || Boolean.FALSE.equals(item.getSuccess())) {
|
||||
continue;
|
||||
}
|
||||
total = total.add(shopSubtotal(item.getRows()));
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
private BigDecimal totalWithdrawAmount(List<WithdrawResultItemVo> items) {
|
||||
BigDecimal total = BigDecimal.ZERO;
|
||||
for (WithdrawResultItemVo item : items == null ? List.<WithdrawResultItemVo>of() : items) {
|
||||
if (item == null || Boolean.FALSE.equals(item.getSuccess())) {
|
||||
continue;
|
||||
}
|
||||
total = total.add(withdrawSubtotal(item.getRows()));
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
private BigDecimal shopSubtotal(List<WithdrawRowDto> rows) {
|
||||
BigDecimal total = BigDecimal.ZERO;
|
||||
for (WithdrawRowDto row : rows == null ? List.<WithdrawRowDto>of() : rows) {
|
||||
if (row != null && row.getShopAmount() != null) {
|
||||
total = total.add(row.getShopAmount());
|
||||
}
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
private BigDecimal withdrawSubtotal(List<WithdrawRowDto> rows) {
|
||||
BigDecimal total = BigDecimal.ZERO;
|
||||
for (WithdrawRowDto row : rows == null ? List.<WithdrawRowDto>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;
|
||||
}
|
||||
}
|
||||
@@ -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<ProductRiskCandidateVo> listCandidates(Long userId) {
|
||||
validateUserId(userId);
|
||||
List<WithdrawShopCandidateEntity> rows = candidateMapper.selectList(
|
||||
new LambdaQueryWrapper<WithdrawShopCandidateEntity>()
|
||||
.eq(WithdrawShopCandidateEntity::getUserId, userId)
|
||||
.orderByDesc(WithdrawShopCandidateEntity::getId));
|
||||
List<ProductRiskCandidateVo> 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<WithdrawShopCandidateEntity>()
|
||||
.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<String> shopNames) {
|
||||
validateUserId(userId);
|
||||
LinkedHashSet<String> normalizedNames = new LinkedHashSet<>();
|
||||
for (String raw : shopNames == null ? List.<String>of() : shopNames) {
|
||||
String normalized = ziniaoShopSwitchService.normalizeShopName(raw);
|
||||
if (!normalized.isBlank()) {
|
||||
normalizedNames.add(normalized);
|
||||
}
|
||||
}
|
||||
if (normalizedNames.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
candidateMapper.delete(new LambdaQueryWrapper<WithdrawShopCandidateEntity>()
|
||||
.eq(WithdrawShopCandidateEntity::getUserId, userId)
|
||||
.in(WithdrawShopCandidateEntity::getShopName, normalizedNames));
|
||||
}
|
||||
|
||||
public ProductRiskMatchShopsVo matchShops(ProductRiskMatchShopsRequest request) {
|
||||
validateUserId(request.getUserId());
|
||||
LinkedHashSet<String> 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<WithdrawShopCandidateEntity>()
|
||||
.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 不合法");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Long, LocalTaskEntityCacheEntry> 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<String, WithdrawShopPayloadDto> getAllShopMergedPayload(Long taskId) {
|
||||
return taskScopePayloadStorageService.getAllScopePayload(taskId, MODULE_TYPE, WithdrawShopPayloadDto.class);
|
||||
}
|
||||
|
||||
public boolean hasAnyShopMergedPayload(Long taskId) {
|
||||
return taskScopePayloadStorageService.hasAnyScopePayload(taskId, MODULE_TYPE);
|
||||
}
|
||||
|
||||
public Map<Long, Long> getTaskHeartbeatMillisBatch(List<Long> taskIds) {
|
||||
Map<Long, Long> result = new LinkedHashMap<>();
|
||||
if (taskIds == null || taskIds.isEmpty()) {
|
||||
return result;
|
||||
}
|
||||
List<Long> normalized = taskIds.stream().filter(id -> id != null && id > 0).distinct().toList();
|
||||
if (normalized.isEmpty()) {
|
||||
return result;
|
||||
}
|
||||
List<String> keys = normalized.stream().map(this::buildTaskHeartbeatKey).toList();
|
||||
List<String> values;
|
||||
try {
|
||||
values = stringRedisTemplate.opsForValue().multiGet(keys);
|
||||
} catch (Exception ex) {
|
||||
log.warn("[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<Long, FileTaskEntity> getTaskCacheBatch(List<Long> taskIds) {
|
||||
Map<Long, FileTaskEntity> result = new LinkedHashMap<>();
|
||||
if (taskIds == null || taskIds.isEmpty()) {
|
||||
return result;
|
||||
}
|
||||
List<Long> normalized = taskIds.stream().filter(id -> id != null && id > 0).distinct().toList();
|
||||
long now = System.currentTimeMillis();
|
||||
List<Long> 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<String> keys = missingIds.stream().map(this::buildTaskEntityKey).toList();
|
||||
List<String> 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) {}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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:
|
||||
|
||||
@@ -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='取款前台备选店铺';
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<number>()
|
||||
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 '' }
|
||||
|
||||
@@ -51,6 +51,7 @@ type ActiveNavKey =
|
||||
| 'pricing'
|
||||
| 'patrol-delete'
|
||||
| 'query-asin'
|
||||
| 'withdraw'
|
||||
| 'collect-data'
|
||||
| 'image-video'
|
||||
|
||||
@@ -111,7 +112,7 @@ const navGroups: ReadonlyArray<NavGroup> = [
|
||||
{ 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: '店铺状态查询' },
|
||||
],
|
||||
},
|
||||
|
||||
1360
frontend-vue/src/pages/brand/components/BrandWithdrawTab.vue
Normal file
1360
frontend-vue/src/pages/brand/components/BrandWithdrawTab.vue
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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<JavaApiResponse<WithdrawCandidateVo[]>>(`${JAVA_API_PREFIX}/withdraw/candidates`, {
|
||||
params: { user_id: getCurrentUserId() },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function addWithdrawCandidate(shopName: string) {
|
||||
return unwrapJavaResponse(
|
||||
post<JavaApiResponse<WithdrawCandidateVo>, { 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<JavaApiResponse<null>>(`${JAVA_API_PREFIX}/withdraw/candidates/${id}`, {
|
||||
params: { user_id: getCurrentUserId() },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function clearWithdrawCandidates(shopNames: string[]) {
|
||||
return unwrapJavaResponse(
|
||||
post<JavaApiResponse<null>, { 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<JavaApiResponse<ProductRiskMatchShopsVo>, { user_id: number; shop_names: string[] }>(
|
||||
`${JAVA_API_PREFIX}/withdraw/match-shops`,
|
||||
{ user_id: getCurrentUserId(), shop_names: shopNames },
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function getWithdrawDashboard() {
|
||||
return unwrapJavaResponse(
|
||||
get<JavaApiResponse<WithdrawDashboardVo>>(`${JAVA_API_PREFIX}/withdraw/dashboard`, {
|
||||
params: { user_id: getCurrentUserId() },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function getWithdrawHistory() {
|
||||
return unwrapJavaResponse(
|
||||
get<JavaApiResponse<WithdrawHistoryVo>>(`${JAVA_API_PREFIX}/withdraw/history`, {
|
||||
params: { user_id: getCurrentUserId() },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function getWithdrawTaskProgressBatch(taskIds: number[]) {
|
||||
return postTaskProgressBatch<WithdrawTaskBatchVo>(
|
||||
`${JAVA_API_PREFIX}/withdraw/tasks/progress/batch`,
|
||||
taskIds,
|
||||
);
|
||||
}
|
||||
|
||||
export function createWithdrawTask(items: WithdrawTaskItem[], reservedAmount: number | string | null) {
|
||||
return unwrapJavaResponse(
|
||||
post<JavaApiResponse<WithdrawCreateTaskVo>, { 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<JavaApiResponse<null>, 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<JavaApiResponse<null>>(`${JAVA_API_PREFIX}/withdraw/tasks/${taskId}`, {
|
||||
params: { user_id: getCurrentUserId() },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function deleteWithdrawHistory(resultId: number) {
|
||||
return unwrapJavaResponse(
|
||||
del<JavaApiResponse<null>>(`${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<JavaApiResponse<SkipPriceAsinCheckVo>>(
|
||||
`${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;
|
||||
|
||||
14
frontend-vue/src/withdraw-main.ts
Normal file
14
frontend-vue/src/withdraw-main.ts
Normal file
@@ -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')
|
||||
|
||||
|
||||
@@ -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'),
|
||||
},
|
||||
|
||||
14
frontend-vue/withdraw.html
Normal file
14
frontend-vue/withdraw.html
Normal file
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>取款 - 数富AI</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/withdraw-main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user