后端优化修复问题

This commit is contained in:
super
2026-06-18 18:53:16 +08:00
parent 021b0c618b
commit 0ecf5cd45a
44 changed files with 4275 additions and 81 deletions

View File

@@ -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;
}

View File

@@ -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"));
}

View File

@@ -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) {

View File

@@ -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(

View File

@@ -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(

View File

@@ -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;
}

View File

@@ -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 数据
*/

View File

@@ -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")

View File

@@ -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<>();
}

View File

@@ -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;
}

View File

@@ -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;
}
}
}

View File

@@ -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();
}

View File

@@ -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 -> {
}

View File

@@ -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;

View File

@@ -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);
}
}

View File

@@ -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> {
}

View File

@@ -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<>();
}

View File

@@ -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<>();
}

View File

@@ -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;
}

View File

@@ -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<>();
}

View File

@@ -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<>();
}

View File

@@ -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<>();
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
};
}
}

View File

@@ -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<>();
}

View File

@@ -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<>();
}

View File

@@ -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<>();
}

View File

@@ -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<>();
}

View File

@@ -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;
}
}

View File

@@ -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 不合法");
}
}
}

View File

@@ -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) {}
}

View File

@@ -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:

View File

@@ -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='取款前台备选店铺';

View File

@@ -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;

View File

@@ -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,
},
}
}

View File

@@ -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 '' }

View File

@@ -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: '店铺状态查询' },
],
},

File diff suppressed because it is too large Load Diff

View File

@@ -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;

View 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')

View File

@@ -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'),
},

View 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>