Compare commits

...

4 Commits

Author SHA1 Message Date
huangzd1997 3634ea1d62 feat(撞款): 采集明细落库后自动触发重扫,重复检查无需等次日 00:00
- shopdatacrawl 新增 DuplicateCheckRefreshPort 端口,TaskService 在明细落库成功后请求重扫
- shopduplicatecheck 新增 DuplicateCheckRefreshScheduler:10s 合并窗口去抖、单飞、
  扫描锁忙自动重试(最多 6 次),失败不影响采集归档
- 复用现有 SCAN_LOCK,与定时扫描、手动「重新分析」互斥;新增 5 个调度器单测
2026-09-17 23:08:15 +08:00
huangzd1997 3ce0569c59 fix(提示语): 失败提示优先展示后端真实原因,修正 10+ 处误导文案
上架/删除品牌/去重/转换/拆分/撤回/查ASIN/产品风险/跟价/巡店/店铺匹配/视频工作台:
- 解析失败不再只报通用文案,逐条展示后端文件级 errorMessage(上架新增「店铺未找到」专属弹窗)
- 匹配失败不再一律「请检查店铺名」,按 matchStatus 区分 CONFLICT/PENDING
- 去重/转换/拆分全失败弹窗优先列各文件真实原因,取不到才回退猜测文案
- dispatch-guard 新增 fileErrors 选项与 collectDistinctErrors 导出
2026-09-17 23:02:15 +08:00
huangzd1997 d2d95f0b71 feat(上架): 激活时按店铺互斥,同一店铺不允许两个任务同时跑
2026-09-17 事故(28519/28520/28521 相继提交同一店铺「林洪武」):同一店铺被多个
任务并发打开,客户端 startBrowser 全部返回 -10000,三个任务一起失败。

- PublishTaskService.activateFile:激活前查是否已有其他任务在同一店铺 RUNNING,
  有则拒绝并带出占用中的任务号(激活是任务真正开跑的唯一入口,能最早拦住);
  校验与更新之间仍有极小竞态窗口,真正串行由客户端店铺锁保证,这层负责尽早提示;
- 前端 BrandPublishTab:文件派发失败的原因回显给用户。原来错误只写进日志、提示
  固定为"已记录并继续",用户会误以为是文件问题而反复重传,看不到真正原因;
- 新增 3 个后端单测:同店铺被占用则拒绝、无占用则放行、店铺名为空跳过校验。
2026-09-17 15:48:51 +08:00
huangzd1997 2a51006888 feat(前端防连点): 工具页提交按钮加全局冷却,并精简 4.0.28 更新日志
各工具页的「开始上架 / 启动任务 / 匹配店铺」按钮在提交逻辑跑完后立刻恢复可点,
手快连点会重复发起(2026-09-17 上架事故:同一店铺被接连提交三次,服务端并存多个
同店铺任务,客户端并发打开同一店铺时全部失败)。

- 新增 shared/utils/submit-guard.ts:冷却按按钮元素各自计算(WeakMap,点 A 不影响
  B),在捕获阶段拦截、抢在 Vue 的 @click 之前;冷却自首次有效点击起算,被拦的
  点击不会把冷却越拖越长;
- main.ts 全局安装 installSubmitGuard(),覆盖所有 .btn-run 主按钮,页面零改动;
- 13 个单测覆盖边界:不同按钮互不影响、被拦不延长冷却、非法时长回落默认等;
- client-changelog 每条精简到 40 字以内(超出面板显示不下,已有守护测试)。
2026-09-17 15:41:41 +08:00
37 changed files with 894 additions and 21 deletions
@@ -198,6 +198,27 @@ public class PublishTaskService {
if (runningFile != null) {
throw new BusinessException("同一任务已有文件正在执行: " + runningFile.getSourceFilename());
}
// 店铺级互斥:同一店铺同一时刻只允许一个上架任务在跑。
// 2026-09-17 事故(28519/28520/28521 相继提交同一店铺「林洪武」):同一店铺被多个
// 任务并发打开,客户端 startBrowser 全部返回 -10000,三个任务一起失败。激活是任务
// 真正开跑的唯一入口,在这里挡掉并带出占用中的任务号,用户才知道要等谁。
//
// 注意:本校验与随后的状态更新之间仍有极小竞态窗口(两个请求恰好同时通过校验);
// 真正的串行由客户端店铺锁保证,这一层的目的是尽早给出明确提示,避免白传文件与重复执行。
String shopName = file.getShopName();
if (shopName != null && !shopName.isBlank()) {
PublishFileEntity shopRunning = publishFileMapper.selectOne(
new LambdaQueryWrapper<PublishFileEntity>()
.eq(PublishFileEntity::getShopName, shopName)
.eq(PublishFileEntity::getStatus, STATUS_RUNNING)
.ne(PublishFileEntity::getTaskId, taskId)
.orderByAsc(PublishFileEntity::getId)
.last("limit 1"));
if (shopRunning != null) {
throw new BusinessException("店铺「" + shopName + "」已有上架任务正在执行(任务 "
+ shopRunning.getTaskId() + "),请等它完成后再提交");
}
}
int updated = publishFileMapper.update(null, new LambdaUpdateWrapper<PublishFileEntity>()
.eq(PublishFileEntity::getId, fileId)
.eq(PublishFileEntity::getTaskId, taskId)
@@ -27,6 +27,7 @@ import com.nanri.aiimage.modules.shopdatacrawl.model.vo.ShopDataCrawlTaskBatchVo
import com.nanri.aiimage.modules.shopdatacrawl.model.vo.ShopDataCrawlCreateTaskVo;
import com.nanri.aiimage.modules.shopdatacrawl.model.vo.ShopDataCrawlHistoryVo;
import com.nanri.aiimage.modules.shopdatacrawl.model.vo.ShopDataCrawlResultItemVo;
import com.nanri.aiimage.modules.shopdatacrawl.spi.DuplicateCheckRefreshPort;
import com.nanri.aiimage.common.model.vo.ProductRiskDashboardVo;
import com.nanri.aiimage.modules.task.mapper.FileResultMapper;
import com.nanri.aiimage.modules.task.mapper.FileTaskMapper;
@@ -121,6 +122,7 @@ public class ShopDataCrawlTaskService {
private final InstanceMetadata instanceMetadata;
private final ShopDataCrawlDailyFileService dailyFileService;
private final ShopDataCrawlItemStoreService shopDataCrawlItemStoreService;
private final DuplicateCheckRefreshPort duplicateCheckRefreshPort;
private final PlatformTransactionManager transactionManager;
private final TaskProgressLightAssembler taskProgressLightAssembler;
@@ -2116,6 +2118,13 @@ public class ShopDataCrawlTaskService {
shopDataCrawlItemStoreService.saveShopBatchFromSnapshot(
snapshot.getShopName(), itemBatchDate, accumulatedItems,
snapshot.getResultId(), task.getId(), baseDailyFile == null ? null : baseDailyFile.getId());
// 明细已落库:请求撞款重扫(异步合并执行,不阻塞归档;端口契约保证不抛错)
try {
duplicateCheckRefreshPort.requestRefresh("shop-data-crawl:" + snapshot.getShopName());
} catch (RuntimeException ex) {
log.warn("[shop-data-crawl] 请求撞款重扫失败(忽略,不影响归档) shop={} msg={}",
snapshot.getShopName(), ex.getMessage());
}
int rowCount = excelAssemblyService.writeWorkbook(outputXlsx, accumulatedItems);
String objectKey = ossStorageService.uploadResultFile(outputXlsx, MODULE_TYPE);
if (blank(objectKey)) {
@@ -0,0 +1,17 @@
package com.nanri.aiimage.modules.shopdatacrawl.spi;
/**
* 采集明细就绪后的撞款重扫触发端口(2026-09:店铺数据采集落库后即时刷新重复检查)。
*
* <p>实现方在 shopduplicatecheck 模块({@code ShopDataDuplicateCheckScanService})。
* 契约:实现必须异步执行、去抖合并,不得阻塞调用方、不得向外抛出异常。
*/
public interface DuplicateCheckRefreshPort {
/**
* 请求一次撞款重扫(异步;合并窗口内的多次触发聚合为一次扫描)。
*
* @param reason 触发来源,仅用于日志排查
*/
void requestRefresh(String reason);
}
@@ -9,6 +9,7 @@ import com.nanri.aiimage.modules.shopdatacrawl.model.dto.ShopDataCrawlCountryRes
import com.nanri.aiimage.modules.shopdatacrawl.model.dto.ShopDataCrawlRowDto;
import com.nanri.aiimage.modules.shopdatacrawl.model.vo.ShopDataCrawlResultItemVo;
import com.nanri.aiimage.modules.shopdatacrawl.service.ShopDataCrawlItemStoreService;
import com.nanri.aiimage.modules.shopdatacrawl.spi.DuplicateCheckRefreshPort;
import com.nanri.aiimage.modules.shopduplicatecheck.mapper.ShopDataDuplicateScanMapper;
import com.nanri.aiimage.modules.shopduplicatecheck.mapper.ShopDuplicateCheckItemMapper;
import com.nanri.aiimage.modules.shopduplicatecheck.mapper.ShopDuplicateCheckSourceMapper;
@@ -21,6 +22,7 @@ import com.nanri.aiimage.modules.shopduplicatecheck.model.entity.ShopDataDuplica
import com.nanri.aiimage.modules.shopduplicatecheck.model.payload.DuplicateScanPayload;
import com.nanri.aiimage.modules.shopduplicatecheck.model.payload.DuplicateScanSummary;
import com.nanri.aiimage.modules.shopduplicatecheck.service.support.DuplicateCheckAggregator;
import com.nanri.aiimage.modules.shopduplicatecheck.service.support.DuplicateCheckRefreshScheduler;
import com.nanri.aiimage.modules.shopduplicatecheck.service.support.DuplicateCheckWorkbookParser;
import com.nanri.aiimage.modules.shopduplicatecheck.service.support.RawRow;
import com.nanri.aiimage.modules.shopduplicatecheck.service.support.ShopParsed;
@@ -43,14 +45,14 @@ import java.util.Set;
import java.util.concurrent.atomic.AtomicLong;
/**
* 撞款扫描:每日 00:00 定时 + force 同步全量重扫。
* 撞款扫描:每日 00:00 定时 + force 同步全量重扫 + 采集落库触发的异步合并重扫
* 数据源 = 采集明细表(biz_shop_data_crawl_item,采集先落库再更新文件),
* 直接查库聚合 shops/items/summary 落库 shop_data_duplicate_scan(输出契约不变)。
* 双实例通过 Redis 分布式锁防重;扫描失败落 FAILED 行并上抛,force 场景由端点转 409/500。
*/
@Service
@Slf4j
public class ShopDataDuplicateCheckScanService {
public class ShopDataDuplicateCheckScanService implements DuplicateCheckRefreshPort {
public static final String SCAN_LOCK = "shop-data-duplicate-check:scan";
static final DateTimeFormatter SCANNED_AT_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
@@ -71,6 +73,9 @@ public class ShopDataDuplicateCheckScanService {
private final AtomicLong cachedRowId = new AtomicLong(-1L);
private volatile CachedScan cachedScan;
/** 采集落库触发的异步合并重扫调度器(单飞 + 去抖 + 锁忙重试)。 */
private final DuplicateCheckRefreshScheduler refreshScheduler;
@Autowired
public ShopDataDuplicateCheckScanService(ShopDataDuplicateScanMapper scanMapper,
ShopDuplicateCheckSourceMapper sourceMapper,
@@ -86,6 +91,7 @@ public class ShopDataDuplicateCheckScanService {
this.objectMapper = objectMapper;
this.itemMapper = itemMapper;
this.itemStoreService = itemStoreService;
this.refreshScheduler = new DuplicateCheckRefreshScheduler(this::runRefreshOnce);
}
/** 读侧视图:scanned_at 为最新 SUCCESS 行 created_atyyyy-MM-dd HH:mm:ss)。 */
@@ -122,6 +128,30 @@ public class ShopDataDuplicateCheckScanService {
}
}
/** 采集明细落库后的重扫请求(端口实现):异步合并执行,不阻塞、不抛错。 */
@Override
public void requestRefresh(String reason) {
refreshScheduler.request(reason);
}
/** 调度器单次扫描动作:锁被占返回 LOCK_BUSY 供其重试;失败只记日志(FAILED 行已落库)。 */
private DuplicateCheckRefreshScheduler.Outcome runRefreshOnce() {
try {
scanNow();
return DuplicateCheckRefreshScheduler.Outcome.DONE;
} catch (BusinessException ex) {
if (ex.getCode() != null && ex.getCode() == 409) {
log.info("[shop-duplicate-check] 自动重扫未执行:其它扫描进行中 msg={}", ex.getMessage());
return DuplicateCheckRefreshScheduler.Outcome.LOCK_BUSY;
}
log.warn("[shop-duplicate-check] 自动重扫失败 code={} msg={}", ex.getCode(), ex.getMessage());
return DuplicateCheckRefreshScheduler.Outcome.FAILED;
} catch (Exception ex) {
log.error("[shop-duplicate-check] 自动重扫异常", ex);
return DuplicateCheckRefreshScheduler.Outcome.FAILED;
}
}
/** 最新 SUCCESS 扫描视图;无扫描结果返回 null。 */
public DuplicateScanView loadLatest() {
ScanLightRowDto light = scanMapper.selectLatestLightRow();
@@ -0,0 +1,138 @@
package com.nanri.aiimage.modules.shopduplicatecheck.service.support;
import com.nanri.aiimage.common.util.ThreadPools;
import lombok.extern.slf4j.Slf4j;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Supplier;
/**
* 采集落库触发的撞款重扫调度器:合并窗口去抖 + 单飞 + 锁忙重试。
*
* <p>语义:{@link #request} 只置位并异步执行,永不阻塞调用方、永不向外抛错;
* 合并窗口内的多次触发聚合为一次扫描;扫描动作执行期间到达的触发在下一轮执行;
* 扫描因分布式锁被占用未执行({@link Outcome#LOCK_BUSY})时按固定间隔重试有限次。
*/
@Slf4j
public class DuplicateCheckRefreshScheduler {
/** 单次扫描动作的终态:完成 / 锁被占(可重试)/ 失败(不重试,等下次触发或定时扫描)。 */
public enum Outcome {
DONE, LOCK_BUSY, FAILED
}
private static final long DEFAULT_DEBOUNCE_MILLIS = 10_000L;
private static final long DEFAULT_LOCK_RETRY_MILLIS = 20_000L;
private static final int DEFAULT_MAX_LOCK_RETRIES = 6;
private final Supplier<Outcome> scanAction;
private final long debounceMillis;
private final long lockRetryMillis;
private final int maxLockRetries;
private final ExecutorService executor;
private final AtomicBoolean pending = new AtomicBoolean(false);
private final AtomicBoolean running = new AtomicBoolean(false);
public DuplicateCheckRefreshScheduler(Supplier<Outcome> scanAction) {
this(scanAction, DEFAULT_DEBOUNCE_MILLIS, DEFAULT_LOCK_RETRY_MILLIS, DEFAULT_MAX_LOCK_RETRIES);
}
/** 测试用:注入更短的窗口与重试参数。 */
DuplicateCheckRefreshScheduler(Supplier<Outcome> scanAction, long debounceMillis,
long lockRetryMillis, int maxLockRetries) {
this.scanAction = scanAction;
this.debounceMillis = Math.max(0L, debounceMillis);
this.lockRetryMillis = Math.max(0L, lockRetryMillis);
this.maxLockRetries = Math.max(0, maxLockRetries);
this.executor = ThreadPools.boundedFixed("shop-dup-refresh", 1, 8);
}
/** 请求一次重扫(异步、去抖合并)。调用方不被阻塞,也不会收到异常。 */
public void request(String reason) {
pending.set(true);
if (running.compareAndSet(false, true)) {
submit(reason);
}
}
private void submit(String reason) {
try {
log.info("[shop-duplicate-check] 触发撞款重扫(异步合并执行,窗口={}ms) reason={}", debounceMillis, reason);
executor.execute(this::drain);
} catch (Exception ex) {
// 提交失败(如线程池拒绝)时复位单飞标记,避免后续触发被永久吞掉
running.set(false);
log.warn("[shop-duplicate-check] 撞款重扫任务提交失败 reason={} msg={}", reason, ex.getMessage());
}
}
private void drain() {
try {
while (true) {
// 合并窗口:窗口内到达的多次触发聚合为同一轮扫描
if (!sleepQuietly(debounceMillis)) {
return;
}
if (!pending.compareAndSet(true, false)) {
return;
}
int lockRetries = 0;
while (true) {
Outcome outcome = runOnceSafely();
if (outcome != Outcome.LOCK_BUSY) {
break;
}
if (lockRetries >= maxLockRetries) {
log.warn("[shop-duplicate-check] 撞款重扫连续 {} 次未取得扫描锁,放弃本轮(等待下次触发或定时扫描)",
lockRetries + 1);
break;
}
lockRetries++;
log.info("[shop-duplicate-check] 撞款重扫未取得扫描锁,{}ms 后重试(第 {}/{} 次)",
lockRetryMillis, lockRetries, maxLockRetries);
if (!sleepQuietly(lockRetryMillis)) {
return;
}
}
}
} finally {
running.set(false);
// 竞态兜底:running 复位前到达的触发可能没能提交,补一次
if (pending.get() && running.compareAndSet(false, true)) {
submit("race-guard");
}
}
}
/** 执行一次扫描动作;动作自身异常也被吸收(调度器对外零抛出)。 */
private Outcome runOnceSafely() {
long startedAt = System.currentTimeMillis();
try {
Outcome outcome = scanAction.get();
long elapsed = System.currentTimeMillis() - startedAt;
if (outcome == Outcome.DONE) {
log.info("[shop-duplicate-check] 采集后自动重扫完成 耗时={}ms", elapsed);
} else if (outcome == Outcome.FAILED) {
log.warn("[shop-duplicate-check] 采集后自动重扫失败 耗时={}ms", elapsed);
}
return outcome == null ? Outcome.FAILED : outcome;
} catch (Exception ex) {
log.error("[shop-duplicate-check] 采集后自动重扫异常", ex);
return Outcome.FAILED;
}
}
private static boolean sleepQuietly(long millis) {
if (millis <= 0) {
return true;
}
try {
Thread.sleep(millis);
return true;
} catch (InterruptedException ex) {
Thread.currentThread().interrupt();
return false;
}
}
}
@@ -751,6 +751,73 @@ class PublishTaskServiceTest {
verify(lock).close();
}
@Test
void activateFileRejectsShopAlreadyRunningInAnotherTask() {
long taskId = 106L;
long fileId = 206L;
FileTaskEntity task = task(taskId, 7L, "RUNNING");
PublishFileEntity target = file(taskId, fileId, "PENDING", "林洪武.xlsx");
target.setShopName("林洪武");
// 另一个任务(28520)正跑同一店铺 —— 2026-09-17 事故形态
PublishFileEntity otherTaskRunning = file(105L, 205L, "RUNNING", "林洪武.xlsx");
otherTaskRunning.setShopName("林洪武");
TaskDistributedLockService.LockHandle lock = mock(TaskDistributedLockService.LockHandle.class);
when(taskDistributedLockService.acquire(PublishTaskService.MODULE_TYPE, taskId)).thenReturn(lock);
when(fileTaskMapper.selectById(taskId)).thenReturn(task);
when(publishFileMapper.selectById(fileId)).thenReturn(target);
// 第一次 selectOne:同任务其它 RUNNING 文件(无);第二次:同店铺跨任务(有)
when(publishFileMapper.selectOne(any())).thenReturn(null, otherTaskRunning);
BusinessException error = assertThrows(BusinessException.class,
() -> service.activateFile(taskId, fileId, 7L));
assertTrue(error.getMessage().contains("林洪武"), "提示要带店铺名: " + error.getMessage());
assertTrue(error.getMessage().contains("105"), "提示要带占用中的任务号: " + error.getMessage());
verify(publishFileMapper, never()).update(isNull(), any());
verify(lock).close();
}
@Test
void activateFileAllowsWhenShopHasNoOtherRunningTask() {
long taskId = 107L;
long fileId = 207L;
FileTaskEntity task = task(taskId, 7L, "RUNNING");
PublishFileEntity target = file(taskId, fileId, "PENDING", "林洪武.xlsx");
target.setShopName("林洪武");
TaskDistributedLockService.LockHandle lock = mock(TaskDistributedLockService.LockHandle.class);
when(taskDistributedLockService.acquire(PublishTaskService.MODULE_TYPE, taskId)).thenReturn(lock);
when(fileTaskMapper.selectById(taskId)).thenReturn(task);
when(publishFileMapper.selectById(fileId)).thenReturn(target);
when(publishFileMapper.selectOne(any())).thenReturn(null);
when(publishFileMapper.update(isNull(), any())).thenReturn(1);
service.activateFile(taskId, fileId, 7L);
verify(publishFileMapper).update(isNull(), any());
}
@Test
void activateFileSkipsShopCheckWhenShopNameBlank() {
long taskId = 108L;
long fileId = 208L;
FileTaskEntity task = task(taskId, 7L, "RUNNING");
// 未匹配到店铺的文件:没有店铺标识就不做店铺维度校验(不能因此阻断激活)
PublishFileEntity target = file(taskId, fileId, "PENDING", "未匹配.xlsx");
TaskDistributedLockService.LockHandle lock = mock(TaskDistributedLockService.LockHandle.class);
when(taskDistributedLockService.acquire(PublishTaskService.MODULE_TYPE, taskId)).thenReturn(lock);
when(fileTaskMapper.selectById(taskId)).thenReturn(task);
when(publishFileMapper.selectById(fileId)).thenReturn(target);
when(publishFileMapper.selectOne(any())).thenReturn(null);
when(publishFileMapper.update(isNull(), any())).thenReturn(1);
service.activateFile(taskId, fileId, 7L);
verify(publishFileMapper, times(1)).selectOne(any());
}
@Test
void activateFileIsIdempotentForTheSameRunningFile() {
long taskId = 105L;
@@ -11,6 +11,7 @@ import com.nanri.aiimage.modules.shopdatacrawl.model.dto.ShopDataCrawlCountryRes
import com.nanri.aiimage.modules.shopdatacrawl.model.dto.ShopDataCrawlRowDto;
import com.nanri.aiimage.modules.shopdatacrawl.model.dto.ShopDataCrawlShopPayloadDto;
import com.nanri.aiimage.modules.shopdatacrawl.model.dto.ShopDataCrawlSubmitResultRequest;
import com.nanri.aiimage.modules.shopdatacrawl.spi.DuplicateCheckRefreshPort;
import com.nanri.aiimage.modules.task.mapper.FileResultMapper;
import com.nanri.aiimage.modules.task.mapper.FileTaskMapper;
import com.nanri.aiimage.modules.task.mapper.TaskChunkMapper;
@@ -137,6 +138,7 @@ class ShopDataCrawlChunkUpsertTest {
instanceMetadata,
dailyFileService,
mock(ShopDataCrawlItemStoreService.class),
mock(DuplicateCheckRefreshPort.class),
null,
mock(TaskProgressLightAssembler.class));
@@ -16,6 +16,7 @@ import com.nanri.aiimage.modules.shopdatacrawl.model.dto.ShopDataCrawlSubmitResu
import com.nanri.aiimage.modules.shopdatacrawl.model.entity.ShopDataCrawlDailyFileEntity;
import com.nanri.aiimage.modules.shopdatacrawl.model.entity.ShopDataCrawlDailyMemberEntity;
import com.nanri.aiimage.modules.shopdatacrawl.model.vo.ShopDataCrawlResultItemVo;
import com.nanri.aiimage.modules.shopdatacrawl.spi.DuplicateCheckRefreshPort;
import com.nanri.aiimage.modules.task.mapper.FileResultMapper;
import com.nanri.aiimage.modules.task.mapper.FileTaskMapper;
import com.nanri.aiimage.modules.task.mapper.TaskChunkMapper;
@@ -174,6 +175,7 @@ class ShopDataCrawlCleanupTest {
instanceMetadata,
dailyFileService,
mock(ShopDataCrawlItemStoreService.class),
mock(DuplicateCheckRefreshPort.class),
null,
mock(TaskProgressLightAssembler.class));
ReflectionTestUtils.setField(service, "staleTimeoutMinutes", 30L);
@@ -11,6 +11,7 @@ import com.nanri.aiimage.modules.file.service.oss.OssStorageService;
import com.nanri.aiimage.modules.shopdatacrawl.model.entity.ShopDataCrawlDailyFileEntity;
import com.nanri.aiimage.modules.shopdatacrawl.model.entity.ShopDataCrawlDailyMemberEntity;
import com.nanri.aiimage.modules.shopdatacrawl.model.vo.ShopDataCrawlResultItemVo;
import com.nanri.aiimage.modules.shopdatacrawl.spi.DuplicateCheckRefreshPort;
import com.nanri.aiimage.modules.task.mapper.FileResultMapper;
import com.nanri.aiimage.modules.task.mapper.FileTaskMapper;
import com.nanri.aiimage.modules.task.mapper.TaskChunkMapper;
@@ -146,6 +147,7 @@ class ShopDataCrawlDailyFileIncrementalTest {
instanceMetadata,
dailyFileService,
mock(ShopDataCrawlItemStoreService.class),
mock(DuplicateCheckRefreshPort.class),
null,
mock(TaskProgressLightAssembler.class));
@@ -11,6 +11,7 @@ import com.nanri.aiimage.modules.file.service.oss.OssStorageService;
import com.nanri.aiimage.modules.shopdatacrawl.model.entity.ShopDataCrawlDailyFileEntity;
import com.nanri.aiimage.modules.shopdatacrawl.model.entity.ShopDataCrawlDailyMemberEntity;
import com.nanri.aiimage.modules.shopdatacrawl.model.vo.ShopDataCrawlResultItemVo;
import com.nanri.aiimage.modules.shopdatacrawl.spi.DuplicateCheckRefreshPort;
import com.nanri.aiimage.modules.task.mapper.FileResultMapper;
import com.nanri.aiimage.modules.task.mapper.FileTaskMapper;
import com.nanri.aiimage.modules.task.mapper.TaskChunkMapper;
@@ -152,6 +153,7 @@ class ShopDataCrawlDailyFileJobSplitTest {
instanceMetadata,
dailyFileService,
mock(ShopDataCrawlItemStoreService.class),
mock(DuplicateCheckRefreshPort.class),
null,
mock(TaskProgressLightAssembler.class));
@@ -11,6 +11,7 @@ import com.nanri.aiimage.modules.file.service.oss.OssStorageService;
import com.nanri.aiimage.modules.shopdatacrawl.model.entity.ShopDataCrawlDailyFileEntity;
import com.nanri.aiimage.modules.shopdatacrawl.model.entity.ShopDataCrawlDailyMemberEntity;
import com.nanri.aiimage.modules.shopdatacrawl.model.vo.ShopDataCrawlResultItemVo;
import com.nanri.aiimage.modules.shopdatacrawl.spi.DuplicateCheckRefreshPort;
import com.nanri.aiimage.modules.task.mapper.FileResultMapper;
import com.nanri.aiimage.modules.task.mapper.FileTaskMapper;
import com.nanri.aiimage.modules.task.mapper.TaskChunkMapper;
@@ -152,6 +153,7 @@ class ShopDataCrawlDailyFileLockTest {
instanceMetadata,
dailyFileService,
mock(ShopDataCrawlItemStoreService.class),
mock(DuplicateCheckRefreshPort.class),
null,
mock(TaskProgressLightAssembler.class));
@@ -12,6 +12,7 @@ import com.nanri.aiimage.modules.shopdatacrawl.model.dto.ShopDataCrawlRowDto;
import com.nanri.aiimage.modules.shopdatacrawl.model.dto.ShopDataCrawlShopPayloadDto;
import com.nanri.aiimage.modules.shopdatacrawl.model.dto.ShopDataCrawlSubmitResultRequest;
import com.nanri.aiimage.modules.shopdatacrawl.model.vo.ShopDataCrawlResultItemVo;
import com.nanri.aiimage.modules.shopdatacrawl.spi.DuplicateCheckRefreshPort;
import com.nanri.aiimage.modules.task.mapper.FileResultMapper;
import com.nanri.aiimage.modules.task.mapper.FileTaskMapper;
import com.nanri.aiimage.modules.task.mapper.TaskChunkMapper;
@@ -134,6 +135,7 @@ class ShopDataCrawlLightweightProgressTest {
instanceMetadata,
dailyFileService,
mock(ShopDataCrawlItemStoreService.class),
mock(DuplicateCheckRefreshPort.class),
null,
mock(TaskProgressLightAssembler.class));
@@ -12,6 +12,7 @@ import com.nanri.aiimage.modules.file.service.oss.OssStorageService;
import com.nanri.aiimage.modules.shopdatacrawl.model.dto.ShopDataCrawlCreateTaskRequest;
import com.nanri.aiimage.modules.shopdatacrawl.model.dto.ShopDataCrawlTaskItemDto;
import com.nanri.aiimage.modules.shopdatacrawl.model.vo.ShopDataCrawlCreateTaskVo;
import com.nanri.aiimage.modules.shopdatacrawl.spi.DuplicateCheckRefreshPort;
import com.nanri.aiimage.modules.task.mapper.FileResultMapper;
import com.nanri.aiimage.modules.task.mapper.FileTaskMapper;
import com.nanri.aiimage.modules.task.mapper.TaskChunkMapper;
@@ -139,6 +140,7 @@ class ShopDataCrawlOwnerColumnTest {
instanceMetadata,
dailyFileService,
mock(ShopDataCrawlItemStoreService.class),
mock(DuplicateCheckRefreshPort.class),
null,
mock(TaskProgressLightAssembler.class));
@@ -11,6 +11,7 @@ import com.nanri.aiimage.modules.file.service.oss.OssStorageService;
import com.nanri.aiimage.modules.shopdatacrawl.model.vo.ShopDataCrawlHistoryVo;
import com.nanri.aiimage.modules.shopdatacrawl.model.vo.ShopDataCrawlResultItemVo;
import com.nanri.aiimage.modules.shopdatacrawl.model.vo.ShopDataCrawlTaskBatchVo;
import com.nanri.aiimage.modules.shopdatacrawl.spi.DuplicateCheckRefreshPort;
import com.nanri.aiimage.modules.task.mapper.FileResultMapper;
import com.nanri.aiimage.modules.task.mapper.FileTaskMapper;
import com.nanri.aiimage.modules.task.mapper.TaskChunkMapper;
@@ -146,6 +147,7 @@ class ShopDataCrawlProgressQueryTest {
instanceMetadata,
dailyFileService,
mock(ShopDataCrawlItemStoreService.class),
mock(DuplicateCheckRefreshPort.class),
null,
mock(TaskProgressLightAssembler.class));
@@ -12,6 +12,7 @@ import com.nanri.aiimage.modules.shopdatacrawl.model.dto.ShopDataCrawlRowDto;
import com.nanri.aiimage.modules.shopdatacrawl.model.dto.ShopDataCrawlShopPayloadDto;
import com.nanri.aiimage.modules.shopdatacrawl.model.dto.ShopDataCrawlSubmitResultRequest;
import com.nanri.aiimage.modules.shopdatacrawl.model.vo.ShopDataCrawlResultItemVo;
import com.nanri.aiimage.modules.shopdatacrawl.spi.DuplicateCheckRefreshPort;
import com.nanri.aiimage.modules.task.mapper.FileResultMapper;
import com.nanri.aiimage.modules.task.mapper.FileTaskMapper;
import com.nanri.aiimage.modules.task.mapper.TaskChunkMapper;
@@ -133,6 +134,7 @@ class ShopDataCrawlRowDedupKeyTest {
instanceMetadata,
dailyFileService,
mock(ShopDataCrawlItemStoreService.class),
mock(DuplicateCheckRefreshPort.class),
null,
mock(TaskProgressLightAssembler.class));
@@ -11,6 +11,7 @@ import com.nanri.aiimage.modules.shopdatacrawl.model.dto.ShopDataCrawlCountryRes
import com.nanri.aiimage.modules.shopdatacrawl.model.dto.ShopDataCrawlRowDto;
import com.nanri.aiimage.modules.shopdatacrawl.model.dto.ShopDataCrawlShopPayloadDto;
import com.nanri.aiimage.modules.shopdatacrawl.model.dto.ShopDataCrawlSubmitResultRequest;
import com.nanri.aiimage.modules.shopdatacrawl.spi.DuplicateCheckRefreshPort;
import com.nanri.aiimage.modules.task.mapper.FileResultMapper;
import com.nanri.aiimage.modules.task.mapper.FileTaskMapper;
import com.nanri.aiimage.modules.task.mapper.TaskChunkMapper;
@@ -134,6 +135,7 @@ class ShopDataCrawlScopeCounterTest {
instanceMetadata,
dailyFileService,
mock(ShopDataCrawlItemStoreService.class),
mock(DuplicateCheckRefreshPort.class),
null,
mock(TaskProgressLightAssembler.class));
@@ -11,6 +11,7 @@ import com.nanri.aiimage.modules.shopdatacrawl.model.dto.ShopDataCrawlCountryRes
import com.nanri.aiimage.modules.shopdatacrawl.model.dto.ShopDataCrawlRowDto;
import com.nanri.aiimage.modules.shopdatacrawl.model.dto.ShopDataCrawlShopPayloadDto;
import com.nanri.aiimage.modules.shopdatacrawl.model.dto.ShopDataCrawlSubmitResultRequest;
import com.nanri.aiimage.modules.shopdatacrawl.spi.DuplicateCheckRefreshPort;
import com.nanri.aiimage.modules.task.mapper.FileResultMapper;
import com.nanri.aiimage.modules.task.mapper.FileTaskMapper;
import com.nanri.aiimage.modules.task.mapper.TaskChunkMapper;
@@ -135,6 +136,7 @@ class ShopDataCrawlScopeMergeTest {
instanceMetadata,
dailyFileService,
mock(ShopDataCrawlItemStoreService.class),
mock(DuplicateCheckRefreshPort.class),
null,
mock(TaskProgressLightAssembler.class));
@@ -11,6 +11,7 @@ import com.nanri.aiimage.modules.shopdatacrawl.model.dto.ShopDataCrawlCountryRes
import com.nanri.aiimage.modules.shopdatacrawl.model.dto.ShopDataCrawlRowDto;
import com.nanri.aiimage.modules.shopdatacrawl.model.dto.ShopDataCrawlShopPayloadDto;
import com.nanri.aiimage.modules.shopdatacrawl.model.dto.ShopDataCrawlSubmitResultRequest;
import com.nanri.aiimage.modules.shopdatacrawl.spi.DuplicateCheckRefreshPort;
import com.nanri.aiimage.modules.task.mapper.FileResultMapper;
import com.nanri.aiimage.modules.task.mapper.FileTaskMapper;
import com.nanri.aiimage.modules.task.mapper.TaskChunkMapper;
@@ -129,6 +130,7 @@ class ShopDataCrawlTaskServiceChunkTest {
instanceMetadata,
dailyFileService,
mock(ShopDataCrawlItemStoreService.class),
mock(DuplicateCheckRefreshPort.class),
null,
mock(TaskProgressLightAssembler.class));
@@ -0,0 +1,123 @@
package com.nanri.aiimage.modules.shopduplicatecheck.service.support;
import org.junit.jupiter.api.Test;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.BooleanSupplier;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.fail;
/**
* 撞款重扫调度器:合并窗口去抖(突发触发聚为一次扫描)、单飞、
* 锁忙有限重试、动作异常吸收(对外零抛出)。
*/
class DuplicateCheckRefreshSchedulerTest {
private static final long AWAIT_TIMEOUT_MILLIS = 5_000L;
@Test
void request_mergesBurstIntoSingleScan() throws Exception {
AtomicInteger scans = new AtomicInteger();
CountDownLatch firstScan = new CountDownLatch(1);
DuplicateCheckRefreshScheduler scheduler = new DuplicateCheckRefreshScheduler(() -> {
scans.incrementAndGet();
firstScan.countDown();
return DuplicateCheckRefreshScheduler.Outcome.DONE;
}, 150L, 30L, 3);
scheduler.request("burst-1");
Thread.sleep(10L);
scheduler.request("burst-2");
Thread.sleep(10L);
scheduler.request("burst-3");
assertTrue(firstScan.await(AWAIT_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS), "合并窗口内的触发应执行扫描");
Thread.sleep(300L);
assertEquals(1, scans.get(), "合并窗口内多次触发只执行一次扫描");
}
@Test
void request_retriesWhileLockBusyThenSucceeds() throws Exception {
AtomicInteger attempts = new AtomicInteger();
CountDownLatch succeeded = new CountDownLatch(1);
DuplicateCheckRefreshScheduler scheduler = new DuplicateCheckRefreshScheduler(() -> {
if (attempts.incrementAndGet() <= 2) {
return DuplicateCheckRefreshScheduler.Outcome.LOCK_BUSY;
}
succeeded.countDown();
return DuplicateCheckRefreshScheduler.Outcome.DONE;
}, 10L, 30L, 5);
scheduler.request("retry");
assertTrue(succeeded.await(AWAIT_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS), "锁忙重试后应成功执行");
Thread.sleep(100L);
assertEquals(3, attempts.get(), "2 次锁忙 + 1 次成功");
}
@Test
void request_givesUpAfterMaxLockRetries() throws Exception {
AtomicInteger attempts = new AtomicInteger();
CountDownLatch firstAttempt = new CountDownLatch(1);
DuplicateCheckRefreshScheduler scheduler = new DuplicateCheckRefreshScheduler(() -> {
attempts.incrementAndGet();
firstAttempt.countDown();
return DuplicateCheckRefreshScheduler.Outcome.LOCK_BUSY;
}, 10L, 20L, 2);
scheduler.request("always-busy");
assertTrue(firstAttempt.await(AWAIT_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS));
awaitUntil(() -> attempts.get() >= 3, "应完成初试 + 2 次重试");
Thread.sleep(200L);
assertEquals(3, attempts.get(), "超过重试上限后放弃,不再执行");
}
@Test
void request_absorbsActionFailure() throws Exception {
AtomicInteger attempts = new AtomicInteger();
DuplicateCheckRefreshScheduler scheduler = new DuplicateCheckRefreshScheduler(() -> {
attempts.incrementAndGet();
throw new IllegalStateException("模拟扫描动作异常");
}, 10L, 20L, 1);
assertDoesNotThrow(() -> scheduler.request("boom"), "request 不得向调用方抛错");
awaitUntil(() -> attempts.get() >= 1, "动作应被执行");
Thread.sleep(100L);
assertEquals(1, attempts.get(), "动作异常视为失败,不做锁忙重试");
}
@Test
void request_afterPreviousCycleAllowsNewScan() throws Exception {
AtomicInteger scans = new AtomicInteger();
CountDownLatch twoScans = new CountDownLatch(2);
DuplicateCheckRefreshScheduler scheduler = new DuplicateCheckRefreshScheduler(() -> {
scans.incrementAndGet();
twoScans.countDown();
return DuplicateCheckRefreshScheduler.Outcome.DONE;
}, 20L, 20L, 2);
scheduler.request("first");
awaitUntil(() -> scans.get() >= 1, "首轮扫描应执行");
scheduler.request("second");
assertTrue(twoScans.await(AWAIT_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS), "新触发应再执行一次扫描");
assertEquals(2, scans.get());
}
private static void awaitUntil(BooleanSupplier condition, String message) throws InterruptedException {
long deadline = System.currentTimeMillis() + AWAIT_TIMEOUT_MILLIS;
while (System.currentTimeMillis() < deadline) {
if (condition.getAsBoolean()) {
return;
}
Thread.sleep(10L);
}
fail(message);
}
}
+4
View File
@@ -11,10 +11,14 @@ import router from '@/router'
import { ensureAuth } from '@/shared/auth/ensure-auth'
import { ensureApiSecretsLoaded, loadApiSecrets } from '@/shared/utils/api-secret-store'
import { installUserTokenSync } from '@/shared/auth/user-token-sync.ts'
import { installSubmitGuard } from '@/shared/utils/submit-guard'
// 登录态令牌同步给桌面端 Python(A1/A3):无桥环境静默跳过
installUserTokenSync()
// 提交按钮防连点:捕获阶段拦下同一按钮的连点(各工具页 .btn-run 通用)
installSubmitGuard()
/**
* 数富AI 前端统一入口(SPAURL 无 .html 后缀)
*
@@ -166,7 +166,7 @@ import {
} from '@/shared/api/java-modules'
import { getPywebviewApi, type UploadedJavaFile } from '@/shared/bridges/pywebview'
import { saveUrlWithProgress } from '@/shared/utils/download-progress'
import { EXCEL_EXTENSIONS, checkSelectedFiles, guardBlocked } from '@/shared/dispatch-guard'
import { EXCEL_EXTENSIONS, checkSelectedFiles, collectDistinctErrors, guardBlocked } from '@/shared/dispatch-guard'
import { passGuard } from '@/shared/dispatch-guard-ui'
import { formatDateTime } from '@/shared/utils/datetime'
import { uploadPathsToJava } from '@/shared/utils/upload-to-java'
@@ -349,11 +349,15 @@ async function submitConvertRun() {
convertResultItems.value = result.items || []
await loadConvertHistory()
if (result.total > 0 && result.successCount === 0) {
// 逐文件原因就在 items[].error 里,优先直接列出,不再让用户对着「常见原因」猜
const details = collectDistinctErrors((result.items || []).map((item) => item.error)).slice(0, 5)
await passGuard(
guardBlocked(
'格式转换未成功',
`本次提交的 ${result.total} 个文件全部转换失败。\n` +
'常见原因是源文件表头与所选模板不匹配,或文件内没有数据行。\n' +
(details.length
? `失败原因:\n${details.map((message) => `· ${message}`).join('\n')}\n`
: '常见原因是源文件表头与所选模板不匹配,或文件内没有数据行。\n') +
'请在右侧结果列表查看每个文件的失败原因,或换一个模板后重试。',
'convert.all-failed',
),
@@ -137,7 +137,7 @@ import { expandBrandFolderRecursive } from '@/shared/api/brand'
import { deleteDedupeHistory, getDedupeHistory, getDedupeResultDownloadUrl, getDedupeRunProgress, getExcelInfo, runDedupe, type DedupeResultItem, type DedupeRunProgressVo, type DedupeRunVo } from '@/shared/api/java-modules'
import { getPywebviewApi, type UploadedJavaFile } from '@/shared/bridges/pywebview'
import { saveUrlWithProgress } from '@/shared/utils/download-progress'
import { EXCEL_EXTENSIONS, checkSelectedFiles, guardBlocked } from '@/shared/dispatch-guard'
import { EXCEL_EXTENSIONS, checkSelectedFiles, collectDistinctErrors, guardBlocked } from '@/shared/dispatch-guard'
import { passGuard } from '@/shared/dispatch-guard-ui'
import { formatDateTime } from '@/shared/utils/datetime'
import { uploadPathsToJava } from '@/shared/utils/upload-to-java'
@@ -237,7 +237,8 @@ function clearAllCleanColumns() {
async function loadCleanHeaders(fileKey: string) {
const result = await getExcelInfo(fileKey)
if (!result.headers?.length) {
ElMessage.error('读取 Excel 表头失败')
// 接口本身成功、只是没解析出表头,说明文件内容有问题而非「读取失败」,别让用户反复重选文件
ElMessage.error('未读到表头行:请确认文件不是空表、且首行是表头')
cleanAvailableColumns.value = []
cleanSelectedColumns.value = []
return
@@ -359,11 +360,15 @@ async function submitCleanRun() {
await loadCleanHistory()
// 全部文件都失败时不该报「完成」,让用户看清是哪一批没处理成功
if (result.total > 0 && result.successCount === 0) {
// 逐文件原因就在 items[].error 里,优先直接列出,不再让用户对着「常见原因」猜
const details = collectDistinctErrors((result.items || []).map((item) => item.error)).slice(0, 5)
await passGuard(
guardBlocked(
'去重未成功',
`本次提交的 ${result.total} 个文件全部处理失败。\n` +
'常见原因是表头列名与所选保留列对不上,或文件内没有数据行。\n' +
(details.length
? `失败原因:\n${details.map((message) => `· ${message}`).join('\n')}\n`
: '常见原因是表头列名与所选保留列对不上,或文件内没有数据行。\n') +
'请在右侧结果列表查看每个文件的失败原因后重试。',
'dedupe.all-failed',
),
@@ -1105,7 +1105,13 @@ async function submitRun() {
syncResultState()
if (hasBlockedItems) {
ElMessage.warning('部分文件尚未匹配到店铺,已保留状态信息,请稍后重试。')
// 具体原因(店铺未录入 / 索引未命中 / 表头错误等)在各文件项里,直接带出来;
// 只报「请稍后重试」会把「需去后台添加店铺」误导成「等一等就好」
const firstBlocked = normalizedItems.find((item) => !isUsableMatchedItem(item))
const detail = firstBlocked ? getDisplayError(firstBlocked) : ''
ElMessage.warning(detail
? `部分文件不可启动:${detail}`
: '部分文件尚未匹配到店铺,已保留状态信息,请稍后重试。')
} else if (hasStaleMatchedItems) {
ElMessage.warning('部分文件匹配到的店铺信息已过期,仍可启动任务;后台会自动重新匹配。')
} else if (hasRunnableItems) {
@@ -511,6 +511,12 @@ function formatMatchRemark(row: PatrolDeleteShopQueueItem) {
if (row.matched) {
return "已匹配成功,请查看状态确认";
}
if (row.matchStatus === "CONFLICT") {
return "存在多个同名店铺,请人工确认";
}
if (row.matchStatus === "PENDING") {
return "店铺尚未匹配完成,请稍后查看";
}
return "未匹配成功,请检查店铺名";
}
@@ -848,6 +848,8 @@ function formatMatchRemark(row: PriceTrackShopQueueItem) {
if (msg) return msg
if (row.matched && row.matchStatus === 'MATCHED') return '已匹配成功,可启动任务'
if (row.matched) return '已关联店铺,请查看状态确认'
if (row.matchStatus === 'CONFLICT') return '存在多个同名店铺,请人工确认'
if (row.matchStatus === 'PENDING') return '店铺尚未匹配完成,请稍后查看'
return '未匹配成功,请检查店铺名'
}
@@ -1056,6 +1056,12 @@ function formatMatchRemark(row: ProductRiskShopQueueItem) {
if (row.matched) {
return '已关联店铺,请查看状态确认'
}
if (row.matchStatus === 'CONFLICT') {
return '存在多个同名店铺,请人工确认'
}
if (row.matchStatus === 'PENDING') {
return '店铺尚未匹配完成,请稍后查看'
}
return '未匹配成功,请检查店铺名'
}
@@ -216,6 +216,8 @@ import {
checkParseResult,
checkQueuePayload,
checkSelectedFiles,
collectDistinctErrors,
guardBlocked,
} from '@/shared/dispatch-guard'
import { passGuard } from '@/shared/dispatch-guard-ui'
import { useZiniaoVersion, type ZiniaoVersion } from '@/shared/utils/ziniao-version'
@@ -563,10 +565,25 @@ async function submitRun() {
sync_countries: syncCountries.value.filter((country) => country !== publishCountry.value),
})
if (!parsed.taskId) throw new Error('后端未返回有效任务标识')
// publish Vo files acceptedRows taskId
const fileErrors = collectDistinctErrors(
(parsed.files || []).map((file) => file.errorMessage || file.error),
)
// 2026-09-17 7
//
const missingShopErrors = fileErrors.filter((message) => message.includes('未找到店铺'))
if (!parsed.totalRows && missingShopErrors.length) {
await passGuard(guardBlocked('店铺未找到', missingShopErrors.join('\n'), 'publish.shop-missing'))
return
}
// publish Vo files acceptedRows taskId
// Java ExceltotalRows 0
// guard
const guard = checkParseResult(
{ taskId: parsed.taskId, totalRows: parsed.totalRows, acceptedRows: parsed.totalRows },
{ requiredColumnsHint: '店铺名 / 商品行' },
{
requiredColumnsHint: '店铺名 / 商品行',
fileErrors,
},
)
if (!(await passGuard(guard))) return
@@ -604,7 +621,9 @@ async function submitRun() {
await Promise.all([loadDashboard(), loadHistory()])
if (!batch.pendingFileIds.length) {
queueMessage.value = '解析完成,当前没有匹配成功且可上架的文件。'
queueMessage.value = fileErrors.length
? `解析完成,当前没有匹配成功且可上架的文件:${fileErrors[0]}`
: '解析完成,当前没有匹配成功且可上架的文件。'
ElMessage.warning(queueMessage.value)
return
}
@@ -917,7 +936,10 @@ async function processQueue() {
saveQueueState()
throw new Error(queueMessage.value)
}
queueMessage.value = `文件 ${file.sourceFilename || nextFileId} 启动失败,已记录并继续下一个文件。`
//
// ""
queueMessage.value = `文件 ${file.sourceFilename || nextFileId} 启动失败:${reason}`
ElMessage.warning(queueMessage.value)
activeFileId.value = null
saveQueueState()
}
@@ -439,6 +439,12 @@ function formatMatchRemark(row: QueryAsinShopQueueItem) {
if (row.matched) {
return "已匹配成功,请查看状态确认";
}
if (row.matchStatus === "CONFLICT") {
return "存在多个同名店铺,请人工确认";
}
if (row.matchStatus === "PENDING") {
return "店铺尚未匹配完成,请稍后查看";
}
return "未匹配成功,请检查店铺名";
}
@@ -565,7 +565,7 @@ async function deleteTaskRecord(item: ShopMatchHistoryItem) {
}
}
function formatMatchStatus(status?: string) { const value = (status || '').trim(); return { MATCHED: '已匹配', PENDING: '待匹配', CONFLICT: '需人工确认', INDEX_STALE: '匹配已过期' }[value] || value || '—' }
function formatMatchRemark(row: ShopMatchShopQueueItem) { const message = (row.matchMessage || '').trim(); if (message) return message; if (row.matched && row.matchStatus === 'MATCHED') return '已匹配成功,可启动任务'; if (row.matched) return '已关联店铺,请查看状态确认'; return '未匹配成功,请检查店铺名' }
function formatMatchRemark(row: ShopMatchShopQueueItem) { const message = (row.matchMessage || '').trim(); if (message) return message; if (row.matched && row.matchStatus === 'MATCHED') return '已匹配成功,可启动任务'; if (row.matched) return '已关联店铺,请查看状态确认'; if (row.matchStatus === 'CONFLICT') return '存在多个同名店铺,请人工确认'; if (row.matchStatus === 'PENDING') return '店铺尚未匹配完成,请稍后查看'; return '未匹配成功,请检查店铺名' }
async function pushToPythonQueue() { autoQueueEnabled.value = true; await processMatchedQueue() }
function nextMatchedQueueItem() { return matchedItems.value.find((item) => item.matched) }
async function processMatchedQueue() { if (disposed || queueWorkerRunning.value) return; const api = getPywebviewApi(); if (!api?.enqueue_json) { ElMessage.error('当前客户端未提供 enqueue_json'); return } const matched = matchedItems.value.filter((item) => item.matched); if (!matched.length) { ElMessage.warning('请先匹配可用店铺'); return } if (!orderedCountryCodes.value.length) { ElMessage.warning('请至少勾选一个国家'); return } let scheduleValues: string[] | undefined; try { scheduleValues = parseScheduleValues() } catch (error) { ElMessage.error(error instanceof Error ? error.message : '定时配置无效'); return } queueWorkerRunning.value = true; pushing.value = true; queuePayloadText.value = ''; let successCount = 0; let failedCount = 0; let index = 0; try { while (!disposed && autoQueueEnabled.value) { const item = nextMatchedQueueItem(); if (!item) break; index += 1; try { const created = await withTransientRetry(() => createShopMatchTask([buildTaskCreateItem(item)], orderedCountryCodes.value, scheduleValues), (attempt, maxAttempts) => { queuePushResult.value = `服务暂时不可用,正在重试创建任务(${attempt}/${maxAttempts}...` }); const taskId = created.taskId; const initialStatus = scheduleValues?.length ? 'SCHEDULED' : 'RUNNING'; const snapshot: ShopMatchTaskDetailVo = { task: { id: taskId, status: initialStatus, scheduledAt: scheduleValues?.[0], countryCodes: [...orderedCountryCodes.value], currentStageIndex: scheduleValues?.length ? 0 : undefined, activeStageIndex: undefined, scheduleStages: (scheduleValues || []).map((value, stageIndex) => ({ stageIndex, scheduledAt: value, status: stageIndex === 0 ? 'SCHEDULED' : 'PENDING' })) }, items: created.items }; taskSnapshots.value = { ...taskSnapshots.value, [taskId]: snapshot }; taskDetails.value = { ...taskDetails.value, [taskId]: initialStatus }; saveTaskSnapshotsToStorage(); saveTaskDetailsToStorage(); const createdItem = created.items?.[0]; if (!createdItem) { removeMatchedRowsLocally([item]); continue } if (scheduleValues?.length) { successCount += 1; restoreScheduledDispatches(); queuePushResult.value = `任务 ${taskId} 已创建,共 ${scheduleValues.length} 个执行时间点`; removeMatchedRowsLocally([item]); continue } const payload = buildQueuePayload(taskId, item, orderedCountryCodes.value); queuePayloadText.value = JSON.stringify(payload, null, 2); const result = await api.enqueue_json(payload); if (!result?.success) { failedCount += 1; queuePushResult.value = `任务 ${taskId} 启动失败,已自动继续下一条:${result?.error || '未知错误'}`; ElMessage.error(queuePushResult.value); removeMatchedRowsLocally([item]); continue } addPollingTask(taskId); ensurePolling(true); removeMatchedRowsLocally([item]); queuePushResult.value = `任务 ${taskId} 已提交,等待执行完成`; const finalStatus = await waitForTaskTerminal(taskId); if (finalStatus !== 'SUCCESS') { failedCount += 1; queuePushResult.value = `任务 ${taskId} 执行失败,已自动继续下一条`; ElMessage.error(queuePushResult.value); continue } successCount += 1; queuePushResult.value = `任务 ${taskId} 已完成` } catch (error) { if (disposed) break; failedCount += 1; queuePushResult.value = `${index} 条任务处理失败,已自动继续下一条:${error instanceof Error ? error.message : '未知错误'}`; ElMessage.error(queuePushResult.value); removeMatchedRowsLocally([item]); continue } } restoreScheduledDispatches(); if (!scheduleValues?.length) ensurePolling(true); if (!disposed && (successCount > 0 || failedCount > 0)) ElMessage.success(scheduleValues?.length ? `定时任务已创建:成功 ${successCount} 条,失败 ${failedCount}` : `店铺启动已完成:成功 ${successCount} 条,失败 ${failedCount}`) } catch (error) { if (disposed) return; const message = error instanceof Error ? error.message : '启动失败'; queuePushResult.value = message; ElMessage.error(message) } finally { queueWorkerRunning.value = false; pushing.value = false } }
@@ -145,7 +145,7 @@ import { expandBrandFolderRecursive } from '@/shared/api/brand'
import { deleteSplitHistory, getExcelInfo, getSplitHistory, getSplitResultDownloadUrl, runSplit, type SplitResultItem } from '@/shared/api/java-modules'
import { getPywebviewApi, type UploadedJavaFile } from '@/shared/bridges/pywebview'
import { saveUrlWithProgress } from '@/shared/utils/download-progress'
import { EXCEL_EXTENSIONS, checkSelectedFiles, guardBlocked } from '@/shared/dispatch-guard'
import { EXCEL_EXTENSIONS, checkSelectedFiles, collectDistinctErrors, guardBlocked } from '@/shared/dispatch-guard'
import { passGuard } from '@/shared/dispatch-guard-ui'
import { formatDateTime } from '@/shared/utils/datetime'
import { uploadPathsToJava } from '@/shared/utils/upload-to-java'
@@ -327,11 +327,15 @@ async function submitSplitRun() {
splitResultItems.value = result.items || []
await loadSplitHistory()
if (result.total > 0 && result.successCount === 0) {
// items[].error
const details = collectDistinctErrors((result.items || []).map((item) => item.error)).slice(0, 5)
await passGuard(
guardBlocked(
'拆分未成功',
`本次提交的 ${result.total} 个文件全部处理失败。\n` +
'常见原因是表头列名与所选保留列对不上,或文件内没有数据行。\n' +
(details.length
? `失败原因:\n${details.map((message) => `· ${message}`).join('\n')}\n`
: '常见原因是表头列名与所选保留列对不上,或文件内没有数据行。\n') +
'请在右侧结果列表查看每个文件的失败原因后重试。',
'split.all-failed',
),
@@ -570,6 +570,12 @@ function formatMatchRemark(row: WithdrawShopQueueItem) {
if (row.matched) {
return "已匹配成功,请查看状态确认";
}
if (row.matchStatus === "CONFLICT") {
return "存在多个同名店铺,请人工确认";
}
if (row.matchStatus === "PENDING") {
return "店铺尚未匹配完成,请稍后查看";
}
return "未匹配成功,请检查店铺名";
}
@@ -1015,12 +1015,12 @@ async function waitForImageVideoTask(ticket: ImageVideoAsyncTaskVo): Promise<unk
}
if (task.status === 'SUCCESS') return task.result
if (isTerminalImageVideoTask(task)) {
throw new Error(task.errorMessage || 'Coze task failed')
throw new Error(task.errorMessage || 'Coze 任务执行失败')
}
await sleep(IMAGE_VIDEO_TASK_POLL_DELAY_MS)
task = await getImageVideoAsyncTask(task.taskId)
}
throw new Error('Coze task polling timed out')
throw new Error('Coze 任务查询超时,请稍后重试')
}
async function rewriteScriptFromSource() {
@@ -1296,7 +1296,8 @@ async function pollAssemblyResult(tab: WorkspaceTab, taskId: number) {
if (isTerminalImageVideoTask(task)) {
assembly.polling = false
if (task.status === 'FAILED') {
ElMessage.error('Coze 工作流执行失败')
// errorMessage //
ElMessage.error(task.errorMessage ? `Coze 工作流执行失败:${task.errorMessage}` : 'Coze 工作流执行失败')
} else {
ElMessage.success(videoUrl || assembly.videoUrl ? '视频生成完成' : 'Coze 工作流执行完成,未解析到视频地址')
}
+3 -2
View File
@@ -28,8 +28,9 @@ export const CLIENT_CHANGELOG: ClientChangelogEntry[] = [
version: '4.0.28',
date: '2026-09-17',
items: [
'修复上架、跟价等任务「多个任务同时操作同一店铺导致打开店铺失败」的问题,同一店铺改为排队执行',
'紫鸟更新内核期间不再直接报「打开店铺失败」,会等待更新完成并显示等待进度',
'修复多个任务同时操作同一店铺导致打开店铺失败」的问题',
'同一店铺的任务改为排队执行,不会再互相打断',
'紫鸟更新内核期间不再直接报「打开店铺失败」,而是等待完成',
],
},
{
+36 -1
View File
@@ -267,6 +267,13 @@ export interface ParseResultOptions {
requireRows?: boolean
/** 必要字段名,用于拼「缺什么」的提示,如 'ASIN / 国家' */
requiredColumnsHint?: string
/**
* files[].errorMessage0
* totalRows
* 02026-09-17
* Excel 7
*/
fileErrors?: readonly string[]
title?: string
}
@@ -333,7 +340,21 @@ export function checkParseResult(
: ''
if (requireRows && acceptedRows === 0) {
if (!totalRows) {
const fileErrors = collectDistinctErrors(options.fileErrors)
if (fileErrors.length) {
const shown = fileErrors.slice(0, 8)
issues.push({
code: 'parse.file-failed',
severity: 'block',
message:
'以下文件解析未通过,没有可执行的数据行:\n' +
shown.map((item) => `· ${item}`).join('\n') +
(fileErrors.length > shown.length
? `\n· 另有 ${fileErrors.length - shown.length} 条不同原因`
: '') +
'\n请按上述原因处理对应文件后重新上传解析。',
})
} else if (!totalRows) {
issues.push({
code: 'parse.empty-file',
severity: 'block',
@@ -387,6 +408,20 @@ function normalizeCount(value: unknown): number | null {
return null
}
/** 汇总一组错误原因:去空、去重(同一原因多条只留一条),保持出现顺序。 */
export function collectDistinctErrors(values: readonly unknown[] | undefined): string[] {
if (!values || !values.length) return []
const seen = new Set<string>()
const result: string[] = []
for (const value of values) {
const trimmed = typeof value === 'string' ? value.trim() : ''
if (!trimmed || seen.has(trimmed)) continue
seen.add(trimmed)
result.push(trimmed)
}
return result
}
export interface QueuePayloadOptions {
/** data 下必须存在且非空的字段名 */
requiredDataKeys?: readonly string[]
@@ -0,0 +1,88 @@
/**
* + main.ts
*
* / /
* 2026-09-17
*
*
*
* - ****WeakMap A B
* - "首次有效点击"
* - **** Vue @click
*/
/** 提交按钮的统一类名(17 个工具页的主按钮都用它) */
export const SUBMIT_BUTTON_SELECTOR = '.btn-run'
/** 默认冷却时长:够挡住手快连点,又不至于让正常操作感到卡顿 */
export const DEFAULT_SUBMIT_COOLDOWN_MS = 1500
export interface ClickGate {
/** 本次点击是否应被拦下(被拦时不会刷新冷却) */
shouldBlock(target: object): boolean
/** 解除某个目标的冷却(例如提交失败要允许用户立刻重试) */
reset(target: object): void
}
export function createClickGate(
cooldownMs: number = DEFAULT_SUBMIT_COOLDOWN_MS,
now: () => number = () => Date.now(),
): ClickGate {
// 非法时长(NaN/0/负数)回落默认值:宁可多挡一下,也不能等同"不设防"
const cooldown = Number.isFinite(cooldownMs) && cooldownMs > 0
? cooldownMs
: DEFAULT_SUBMIT_COOLDOWN_MS
const lastClickAt = new WeakMap<object, number>()
return {
shouldBlock(target: object): boolean {
const current = now()
const last = lastClickAt.get(target)
// 用 undefined(而非 0)表示"从未点击过":哨兵值参与减法会跟时钟起点耦合,
// 在 now() 起点较小(测试假时钟/单调时钟)时会误判首次点击为连点
if (last !== undefined && current - last < cooldown) {
return true
}
lastClickAt.set(target, current)
return false
},
reset(target: object): void {
lastClickAt.delete(target)
},
}
}
export interface SubmitGuardOptions {
/** 生效的按钮选择器,默认 SUBMIT_BUTTON_SELECTOR */
selector?: string
cooldownMs?: number
now?: () => number
}
/**
* main.ts
*
* @returns
*/
export function installSubmitGuard(options: SubmitGuardOptions = {}): () => void {
const selector = options.selector ?? SUBMIT_BUTTON_SELECTOR
const gate = createClickGate(options.cooldownMs, options.now)
const handler = (event: Event): void => {
const target = event.target as Element | null
const button = target && typeof target.closest === 'function'
? target.closest(selector)
: null
if (!button) {
return
}
if (gate.shouldBlock(button)) {
event.stopImmediatePropagation()
event.preventDefault()
}
}
document.addEventListener('click', handler, true)
return () => document.removeEventListener('click', handler, true)
}
+50
View File
@@ -7,6 +7,7 @@ import {
checkParseResult,
checkQueuePayload,
checkSelectedFiles,
collectDistinctErrors,
extensionOf,
findUnsafeJsonPaths,
guardPassed,
@@ -165,6 +166,55 @@ test('test_task_101_dispatch_guard_boundary_parse_result_zero_rows_blocked', ()
assert.match(emptyFile.message, /没有读到任何数据行/)
})
test('test_task_101_dispatch_guard_parse_result_file_errors_shown_instead_of_empty_file', () => {
// 2026-09-17:店铺未录入后台导致文件级失败,用户被「空文件」文案误导反复重传
const result = checkParseResult(parseVo({ totalRows: 0, acceptedRows: 0 }), {
fileErrors: ['后台店铺管理中未找到店铺:林清斌,请先添加店铺信息'],
})
assert.equal(result.ok, false)
assert.deepEqual(codes(result), ['parse.file-failed'])
assert.match(result.message, /林清斌/)
assert.ok(!result.message.includes('没有读到任何数据行'), '有具体原因时不展示通用空文件文案')
})
test('test_task_101_dispatch_guard_parse_result_file_errors_dedupe_blank_and_limit', () => {
const deduped = checkParseResult(parseVo({ totalRows: 0, acceptedRows: 0 }), {
fileErrors: ['店铺未匹配', ' ', '店铺未匹配', '', '第二条原因'],
})
assert.deepEqual(codes(deduped), ['parse.file-failed'])
assert.equal(
deduped.message.split('\n').filter((line) => line.startsWith('· ')).length,
2,
'空串与重复原因不应重复展示',
)
const many = checkParseResult(parseVo({ totalRows: 0, acceptedRows: 0 }), {
fileErrors: Array.from({ length: 10 }, (_, index) => `原因 ${index}`),
})
assert.match(many.message, /另有 2 条不同原因/)
})
test('test_task_101_dispatch_guard_parse_result_blank_file_errors_fallback_to_empty_file', () => {
const result = checkParseResult(parseVo({ totalRows: 0, acceptedRows: 0 }), {
fileErrors: [' ', ''],
})
assert.deepEqual(codes(result), ['parse.empty-file'])
})
test('test_task_101_dispatch_guard_collect_distinct_errors', () => {
assert.deepEqual(collectDistinctErrors(['a', ' ', 'a', '', 'b']), ['a', 'b'], '去空去重且保持顺序')
assert.deepEqual(collectDistinctErrors([' 店铺未找到 ']), ['店铺未找到'], '首尾空白裁剪')
assert.deepEqual(collectDistinctErrors([]), [])
assert.deepEqual(collectDistinctErrors(undefined), [])
assert.deepEqual(collectDistinctErrors([null, 7, undefined]), [], '非字符串项忽略')
})
test('test_task_101_dispatch_guard_parse_result_file_errors_ignored_when_rows_present', () => {
const result = checkParseResult(parseVo(), { fileErrors: ['某文件失败'] })
assert.equal(result.ok, true)
assert.equal(result.needsConfirm, false)
})
test('test_task_101_dispatch_guard_parse_result_zero_rows_allowed_when_not_required', () => {
const result = checkParseResult(parseVo({ totalRows: 0, acceptedRows: 0 }), { requireRows: false })
assert.equal(result.ok, true)
+198
View File
@@ -0,0 +1,198 @@
/**
* shared/utils/submit-guard
*
* 2026-09-17 /
*
* 便
*/
import { test } from 'node:test'
import assert from 'node:assert/strict'
import {
createClickGate,
installSubmitGuard,
DEFAULT_SUBMIT_COOLDOWN_MS,
SUBMIT_BUTTON_SELECTOR,
} from '../src/shared/utils/submit-guard.ts'
function fakeClock(start = 1_000) {
let current = start
return {
now: () => current,
advance: (ms: number) => {
current += ms
},
}
}
test('默认冷却时长是正数秒级', () => {
assert.ok(DEFAULT_SUBMIT_COOLDOWN_MS >= 1000, '冷却至少 1 秒,否则挡不住连点')
})
test('默认选择器覆盖各工具页的主按钮', () => {
assert.equal(SUBMIT_BUTTON_SELECTOR, '.btn-run')
})
test('首次点击放行', () => {
const clock = fakeClock()
const gate = createClickGate(1500, clock.now)
assert.equal(gate.shouldBlock({}), false)
})
test('冷却期内重复点击被拦截', () => {
const clock = fakeClock()
const gate = createClickGate(1500, clock.now)
const button = {}
assert.equal(gate.shouldBlock(button), false)
clock.advance(100)
assert.equal(gate.shouldBlock(button), true)
clock.advance(1399)
assert.equal(gate.shouldBlock(button), true)
})
test('冷却结束时(边界)放行', () => {
const clock = fakeClock()
const gate = createClickGate(1500, clock.now)
const button = {}
assert.equal(gate.shouldBlock(button), false)
clock.advance(1500)
assert.equal(gate.shouldBlock(button), false, '恰好到达冷却终点应放行')
})
test('不同按钮互不影响', () => {
const clock = fakeClock()
const gate = createClickGate(1500, clock.now)
const startButton = {}
const matchButton = {}
assert.equal(gate.shouldBlock(startButton), false)
assert.equal(gate.shouldBlock(matchButton), false, '另一个按钮不该被前一个的冷却波及')
clock.advance(100)
assert.equal(gate.shouldBlock(startButton), true)
})
test('自定义冷却时长生效', () => {
const clock = fakeClock()
const gate = createClickGate(300, clock.now)
const button = {}
assert.equal(gate.shouldBlock(button), false)
clock.advance(299)
assert.equal(gate.shouldBlock(button), true)
clock.advance(2)
assert.equal(gate.shouldBlock(button), false)
})
test('被拦截的点击不会延长冷却', () => {
const clock = fakeClock()
const gate = createClickGate(1000, clock.now)
const button = {}
assert.equal(gate.shouldBlock(button), false)
for (let i = 0; i < 5; i += 1) {
clock.advance(100)
assert.equal(gate.shouldBlock(button), true)
}
// 从首次点击起算 1000ms 后就该放行,而不是被连点拖长
clock.advance(500)
assert.equal(gate.shouldBlock(button), false)
})
test('同一时刻的两次点击只有第一次放行', () => {
const clock = fakeClock()
const gate = createClickGate(1500, clock.now)
const button = {}
assert.equal(gate.shouldBlock(button), false)
assert.equal(gate.shouldBlock(button), true, '同一 tick 的第二次点击必须被拦')
})
test('reset 后立即放行', () => {
const clock = fakeClock()
const gate = createClickGate(1500, clock.now)
const button = {}
assert.equal(gate.shouldBlock(button), false)
assert.equal(gate.shouldBlock(button), true)
gate.reset(button)
assert.equal(gate.shouldBlock(button), false)
})
test('非法冷却时长回落到默认值', () => {
const clock = fakeClock()
const gate = createClickGate(Number.NaN, clock.now)
const button = {}
assert.equal(gate.shouldBlock(button), false)
clock.advance(DEFAULT_SUBMIT_COOLDOWN_MS - 1)
assert.equal(gate.shouldBlock(button), true, 'NaN 应回落为默认冷却而不是立刻放行')
})
test('全局安装:捕获阶段注册且可卸载', () => {
const registered: Array<{ type: string; handler: unknown; capture: boolean }> = []
const removed: string[] = []
const originalDocument = (globalThis as Record<string, unknown>).document
;(globalThis as Record<string, unknown>).document = {
addEventListener: (type: string, handler: unknown, capture: boolean) => {
registered.push({ type, handler, capture })
},
removeEventListener: (type: string) => {
removed.push(type)
},
}
try {
const uninstall = installSubmitGuard({ cooldownMs: 1000, now: () => 0 })
assert.equal(registered.length, 1)
assert.equal(registered[0].type, 'click')
assert.equal(registered[0].capture, true, '必须捕获阶段,否则 Vue 的 @click 已先执行')
uninstall()
assert.deepEqual(removed, ['click'])
} finally {
;(globalThis as Record<string, unknown>).document = originalDocument
}
})
test('全局安装:只拦 .btn-run 的连点,且第二次点击阻断冒泡', () => {
const registered: Array<{ handler: (event: unknown) => void }> = []
const originalDocument = (globalThis as Record<string, unknown>).document
;(globalThis as Record<string, unknown>).document = {
addEventListener: (_type: string, handler: (event: unknown) => void) => {
registered.push({ handler })
},
removeEventListener: () => undefined,
}
try {
installSubmitGuard({ cooldownMs: 1000, now: () => 5000 })
const handler = registered[0].handler
const button = { closest: (selector: string) => (selector === '.btn-run' ? button : null) }
const elsewhere = { closest: () => null }
const makeEvent = (target: unknown) => {
const calls: string[] = []
return {
calls,
target,
stopImmediatePropagation: () => calls.push('stop'),
preventDefault: () => calls.push('prevent'),
}
}
const first = makeEvent(button)
handler(first)
assert.deepEqual(first.calls, [], '首次点击不应被拦')
const second = makeEvent(button)
handler(second)
assert.deepEqual(second.calls, ['stop', 'prevent'], '连点必须被拦下')
const other = makeEvent(elsewhere)
handler(other)
assert.deepEqual(other.calls, [], '非提交按钮的点击不受影响')
} finally {
;(globalThis as Record<string, unknown>).document = originalDocument
}
})