Compare commits
69 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6b76333159 | |||
| 9a8fab2ce4 | |||
| 36bed2bdc6 | |||
| a9a72c7bdf | |||
| 95e314cef4 | |||
| 6f6d7be344 | |||
| 93ccb27b40 | |||
| f2546fc152 | |||
| 2f89f795f6 | |||
| add8b057d5 | |||
| 11e19ef4f1 | |||
| 790267560a | |||
| 0e4a1c1c35 | |||
| 76d9558851 | |||
| 2cd5e07248 | |||
| 5f7457a07b | |||
| cb4e10c87f | |||
| 76a51ac290 | |||
| 199345fd11 | |||
| 6e3b43d591 | |||
| ec28216984 | |||
| 24ea02f150 | |||
| f1db31f813 | |||
| 7f49ef596e | |||
| 23eddde770 | |||
| 0092092dbd | |||
| 8ea4d2f502 | |||
| 6594e1f5d7 | |||
| 4e4af7911a | |||
| f816fed4f5 | |||
| 7eccbc016a | |||
| a9cbd18246 | |||
| 70d724382d | |||
| 4a11fd612e | |||
| 6d488515ca | |||
| 064e13c132 | |||
| 81da7a182e | |||
| 3d5fd42bfa | |||
| 29b6353d7e | |||
| 9b5873bb84 | |||
| 77ae8b2823 | |||
| 07e99a5fa8 | |||
| 92858acb4d | |||
| e1217c9c85 | |||
| 795dd1084c | |||
| 423666ef99 | |||
| 0e3dbe450f | |||
| f517b7585f | |||
| dc69b002db | |||
| fb10077557 | |||
| d1ea8feb6f | |||
| 4030897d49 | |||
| e201b13301 | |||
| cbe0ec8bb2 | |||
| 4d91146256 | |||
| 59ee3c8154 | |||
| 3d085c1605 | |||
| d64ba3a827 | |||
| 1ccf4f74ef | |||
| 17a3292c78 | |||
| 99c0b7dd75 | |||
| 3b051bbafe | |||
| 68b7d9a4a4 | |||
| db6e8f0ce8 | |||
| 16bec1c0fa | |||
| 026aa4e0c3 | |||
| 9f8bdecc9e | |||
| 8c84ff3394 | |||
| 2039acdfe6 |
@@ -183,7 +183,7 @@
|
|||||||
<groupId>org.apache.maven.plugins</groupId>
|
<groupId>org.apache.maven.plugins</groupId>
|
||||||
<artifactId>maven-surefire-plugin</artifactId>
|
<artifactId>maven-surefire-plugin</artifactId>
|
||||||
<configuration>
|
<configuration>
|
||||||
<argLine>-XX:+EnableDynamicAgentLoading -Xshare:off</argLine>
|
<argLine>-XX:+EnableDynamicAgentLoading -Xshare:off -Xmx1536m</argLine>
|
||||||
</configuration>
|
</configuration>
|
||||||
</plugin>
|
</plugin>
|
||||||
</plugins>
|
</plugins>
|
||||||
|
|||||||
@@ -132,6 +132,67 @@ public class SimilarAsinProperties {
|
|||||||
*/
|
*/
|
||||||
private long cozeSubmitLockWaitMillis = 10000L;
|
private long cozeSubmitLockWaitMillis = 10000L;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析接口返回的预览行/预览组数量上限。
|
||||||
|
* 响应体只携带预览行(默认 100),全量行仅写入后端任务载荷。
|
||||||
|
* 有效范围 [1, 1000];0/负值回退默认,超上限 clamp 到 1000。
|
||||||
|
*/
|
||||||
|
private int parseResponsePreviewLimit = 100;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Task 7:单个源文件大小上限(字节)。超过则拒绝解析,防止无界文件增长。
|
||||||
|
*/
|
||||||
|
private long maxSourceFileBytes = 50L * 1024L * 1024L;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Task 7:单次解析最大有效行数。超过则拒绝解析,防止任务无界增长。
|
||||||
|
*/
|
||||||
|
private int maxParseRows = 50000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Task 7:单字段最大长度(字符)。超过的字段值截断到该上限,防止内存无界增长。
|
||||||
|
*/
|
||||||
|
private int maxFieldLength = 2000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Task 8:xlsx(zip) 最大条目数。受控读取在 WorkBookFactory 打开前探测,
|
||||||
|
* 超过则拒绝,防止 zip bomb / 超大工作簿拖垮内存。
|
||||||
|
*/
|
||||||
|
private int maxWorkbookZipEntries = 20000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Task 8:xlsx(zip) 解压后总字节数上限。同样在打开前探测,超过则拒绝。
|
||||||
|
*/
|
||||||
|
private long maxWorkbookUncompressedBytes = 512L * 1024L * 1024L;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Task 13:单次 chunk 合并后的最大行数。mergeChunkPayload 合并后总行数超过该上限时,
|
||||||
|
* 从最旧行开始降级到 orphan 兜底(assemble 阶段 putIfAbsent 合并回结果),chunk 不无界增长。
|
||||||
|
* 默认与 maxParseRows 一致(50000)。
|
||||||
|
*/
|
||||||
|
private int chunkMergeMaxRows = 50000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Task 13:单次 chunk 合并后 payload 的字节上限。合并后序列化字节超过该上限时,
|
||||||
|
* 从最旧行开始降级到 orphan 兜底;单行本身超过该上限时抛异常拒绝合并。
|
||||||
|
* 默认 16MB:50000 行 × 平均 300B/行 ≈ 15MB,留余量。
|
||||||
|
*/
|
||||||
|
private long chunkMergePayloadMaxBytes = 16L * 1024L * 1024L;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Task 15:图片缓存 last_used_at 异步批量刷新的缓冲阈值。
|
||||||
|
* lookup 命中先入内存缓冲(按 url_hash 去重),达到该阈值时立即批量 touch;
|
||||||
|
* 其余由定时 flush 兜底,把逐图 UPDATE 合并为批量 UPDATE。
|
||||||
|
*/
|
||||||
|
private int imageCacheTouchFlushThreshold = 1000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Task 16:图片预取短预算(秒)。assemble 阶段预取在预算内 best-effort
|
||||||
|
* 尽力完成,预算耗尽即取消在途任务并回退 URL,避免整批预取拖垮结果组装。
|
||||||
|
* 该值仅作用于 assemble 阶段预取;后台预热仍使用 imagePrefetchTimeoutSeconds。
|
||||||
|
*/
|
||||||
|
private int imagePrefetchBudgetSeconds = 60;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* P0-4:抢 Coze 提交锁失败后下次重试间隔(毫秒)。
|
* P0-4:抢 Coze 提交锁失败后下次重试间隔(毫秒)。
|
||||||
* 原硬编码 500ms,会在指数退避算法中作为基础值(500/1000/2000/4000ms 上限 4000)。
|
* 原硬编码 500ms,会在指数退避算法中作为基础值(500/1000/2000/4000ms 上限 4000)。
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import org.springframework.boot.context.properties.ConfigurationProperties;
|
|||||||
@ConfigurationProperties(prefix = "aiimage.task-pressure")
|
@ConfigurationProperties(prefix = "aiimage.task-pressure")
|
||||||
public class TaskPressureProperties {
|
public class TaskPressureProperties {
|
||||||
private long localTaskEntityCacheMillis = 3000;
|
private long localTaskEntityCacheMillis = 3000;
|
||||||
|
/** task entity 本地缓存容量上限,超限时按时间戳 LRU 淘汰最旧条目。 */
|
||||||
|
private int localTaskEntityCacheCapacity = 512;
|
||||||
// 本地文件缓存有效时长,超过该时长视为过期、强制回查 DB,避免陈旧 RUNNING 被复活
|
// 本地文件缓存有效时长,超过该时长视为过期、强制回查 DB,避免陈旧 RUNNING 被复活
|
||||||
private long localTaskEntityFileCacheMillis = 60000;
|
private long localTaskEntityFileCacheMillis = 60000;
|
||||||
private int dbSelectBatchSize = 200;
|
private int dbSelectBatchSize = 200;
|
||||||
|
|||||||
+15
-2
@@ -96,13 +96,26 @@ public class LocalFileStorageService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 店铺源文件 key → 确定路径解析:saveTempFile 始终把源文件平铺写入
|
||||||
|
* localTempDir/<fileKey>[.<ext>],因此这里只列举临时目录根层(非递归),
|
||||||
|
* 匹配 name == fileKey 或 fileKey.<ext> 的直接子文件,
|
||||||
|
* 取代原 FileUtil.loopFiles 对整棵临时目录树的递归前缀扫描。
|
||||||
|
*/
|
||||||
public File findLocalSourceFile(String fileKey) {
|
public File findLocalSourceFile(String fileKey) {
|
||||||
|
if (fileKey == null || fileKey.isBlank()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
File baseDir = FileUtil.file(storageProperties.getLocalTempDir());
|
File baseDir = FileUtil.file(storageProperties.getLocalTempDir());
|
||||||
if (!baseDir.exists()) {
|
if (!baseDir.exists()) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
List<File> matchedFiles = FileUtil.loopFiles(baseDir, pathname -> pathname.isFile() && pathname.getName().startsWith(fileKey));
|
File[] matchedFiles = baseDir.listFiles(pathname -> pathname.isFile()
|
||||||
return matchedFiles.isEmpty() ? null : matchedFiles.getFirst();
|
&& (pathname.getName().equals(fileKey) || pathname.getName().startsWith(fileKey + ".")));
|
||||||
|
if (matchedFiles == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return matchedFiles.length == 0 ? null : matchedFiles[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
private String normalizeCellText(String value) {
|
private String normalizeCellText(String value) {
|
||||||
|
|||||||
+8
-4
@@ -127,14 +127,18 @@ public class ShopDataCrawlTaskController {
|
|||||||
|
|
||||||
@GetMapping("/history")
|
@GetMapping("/history")
|
||||||
@Operation(
|
@Operation(
|
||||||
summary = "查询抓取记录",
|
summary = "分页查询抓取记录",
|
||||||
description = "返回当前用户最近 100 条店铺抓取结果,按创建时间倒序排列,包含执行状态和异步结果文件状态。",
|
description = "返回当前用户的店铺抓取结果,按创建时间倒序分页返回(limit 收敛到 [1,100]),包含执行状态和异步结果文件状态。",
|
||||||
responses = @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "查询成功"))
|
responses = @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "查询成功"))
|
||||||
public ApiResponse<ShopDataCrawlHistoryVo> history(
|
public ApiResponse<ShopDataCrawlHistoryVo> history(
|
||||||
@Parameter(name = "user_id", description = "当前用户 ID", required = true,
|
@Parameter(name = "user_id", description = "当前用户 ID", required = true,
|
||||||
in = ParameterIn.QUERY, example = "1")
|
in = ParameterIn.QUERY, example = "1")
|
||||||
@RequestParam("user_id") Long userId) {
|
@RequestParam("user_id") Long userId,
|
||||||
return ApiResponse.success(taskService.listHistory(userId));
|
@Parameter(name = "page", description = "页号,从 1 开始", in = ParameterIn.QUERY, example = "1")
|
||||||
|
@RequestParam(value = "page", defaultValue = "1") int page,
|
||||||
|
@Parameter(name = "limit", description = "每页条数,最大 100;默认 100 与旧版返回最近 100 条的行为一致", in = ParameterIn.QUERY, example = "20")
|
||||||
|
@RequestParam(value = "limit", defaultValue = "100") int limit) {
|
||||||
|
return ApiResponse.success(taskService.listHistory(userId, page, limit));
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/tasks/progress/batch")
|
@PostMapping("/tasks/progress/batch")
|
||||||
|
|||||||
+2
@@ -16,5 +16,7 @@ public class ShopDataCrawlDailyMemberEntity {
|
|||||||
private Long dailyFileId;
|
private Long dailyFileId;
|
||||||
private Long taskId;
|
private Long taskId;
|
||||||
private Long resultId;
|
private Long resultId;
|
||||||
|
/** 结果快照 JSON(数据层增量模型:整表重建时按成员行累积,不再读回旧累计对象)。 */
|
||||||
|
private String rowPayload;
|
||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-2
@@ -7,10 +7,19 @@ import java.util.ArrayList;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
@Data
|
@Data
|
||||||
@Schema(description = "当前用户的店铺数据抓取历史记录")
|
@Schema(description = "当前用户的店铺数据抓取历史记录(分页)")
|
||||||
public class ShopDataCrawlHistoryVo {
|
public class ShopDataCrawlHistoryVo {
|
||||||
|
|
||||||
@Schema(description = "历史记录项,按创建时间倒序返回,最多返回最近 100 条")
|
@Schema(description = "历史记录项,按创建时间倒序返回,本页最多返回 limit 条")
|
||||||
private List<ShopDataCrawlResultItemVo> items = new ArrayList<>();
|
private List<ShopDataCrawlResultItemVo> items = new ArrayList<>();
|
||||||
|
|
||||||
|
@Schema(description = "当前页号,从 1 开始;空数据时返回 0", example = "1")
|
||||||
|
private long page;
|
||||||
|
|
||||||
|
@Schema(description = "本页实际返回条数上限,收敛到 [1,100]", example = "20")
|
||||||
|
private int limit;
|
||||||
|
|
||||||
|
@Schema(description = "当前用户历史记录总数", example = "128")
|
||||||
|
private long total;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
package com.nanri.aiimage.modules.shopdatacrawl.service;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Task 36:每日累计文件归档提交阶段的版本 CAS 冲突信号。
|
||||||
|
* 由提交阶段捕获并触发整次归档重试(释放店铺级锁后按最新状态重新组装),
|
||||||
|
* 区别于需要调用方直接失败的运行时异常。
|
||||||
|
*/
|
||||||
|
class DailyStateConflictException extends RuntimeException {
|
||||||
|
|
||||||
|
DailyStateConflictException() {
|
||||||
|
super("daily file state conflict");
|
||||||
|
}
|
||||||
|
}
|
||||||
+17
-1
@@ -138,7 +138,7 @@ public class ShopDataCrawlDailyFileService {
|
|||||||
.eq(ShopDataCrawlDailyMemberEntity::getResultId, resultId)) > 0;
|
.eq(ShopDataCrawlDailyMemberEntity::getResultId, resultId)) > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
public boolean addMember(Long dailyFileId, Long taskId, Long resultId) {
|
public boolean addMemberWithPayload(Long dailyFileId, Long taskId, Long resultId, String rowPayload) {
|
||||||
if (dailyFileId == null || dailyFileId <= 0 || taskId == null || taskId <= 0
|
if (dailyFileId == null || dailyFileId <= 0 || taskId == null || taskId <= 0
|
||||||
|| resultId == null || resultId <= 0) {
|
|| resultId == null || resultId <= 0) {
|
||||||
return false;
|
return false;
|
||||||
@@ -147,6 +147,7 @@ public class ShopDataCrawlDailyFileService {
|
|||||||
member.setDailyFileId(dailyFileId);
|
member.setDailyFileId(dailyFileId);
|
||||||
member.setTaskId(taskId);
|
member.setTaskId(taskId);
|
||||||
member.setResultId(resultId);
|
member.setResultId(resultId);
|
||||||
|
member.setRowPayload(rowPayload);
|
||||||
member.setCreatedAt(currentBusinessDateTime());
|
member.setCreatedAt(currentBusinessDateTime());
|
||||||
try {
|
try {
|
||||||
dailyMemberMapper.insert(member);
|
dailyMemberMapper.insert(member);
|
||||||
@@ -156,6 +157,10 @@ public class ShopDataCrawlDailyFileService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public boolean addMember(Long dailyFileId, Long taskId, Long resultId) {
|
||||||
|
return addMemberWithPayload(dailyFileId, taskId, resultId, null);
|
||||||
|
}
|
||||||
|
|
||||||
public List<ShopDataCrawlDailyMemberEntity> listMembers(Long dailyFileId) {
|
public List<ShopDataCrawlDailyMemberEntity> listMembers(Long dailyFileId) {
|
||||||
if (dailyFileId == null || dailyFileId <= 0) {
|
if (dailyFileId == null || dailyFileId <= 0) {
|
||||||
return List.of();
|
return List.of();
|
||||||
@@ -183,6 +188,17 @@ public class ShopDataCrawlDailyFileService {
|
|||||||
.in(ShopDataCrawlDailyMemberEntity::getResultId, resultIds));
|
.in(ShopDataCrawlDailyMemberEntity::getResultId, resultIds));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 累计文件跨天滚动时把旧文件成员行(含 row_payload)迁移到新文件,保留跨天携带的行。 */
|
||||||
|
public void reassignMembers(Long fromDailyFileId, Long toDailyFileId) {
|
||||||
|
if (fromDailyFileId == null || fromDailyFileId <= 0 || toDailyFileId == null || toDailyFileId <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ShopDataCrawlDailyMemberEntity update = new ShopDataCrawlDailyMemberEntity();
|
||||||
|
update.setDailyFileId(toDailyFileId);
|
||||||
|
dailyMemberMapper.update(update, new LambdaQueryWrapper<ShopDataCrawlDailyMemberEntity>()
|
||||||
|
.eq(ShopDataCrawlDailyMemberEntity::getDailyFileId, fromDailyFileId));
|
||||||
|
}
|
||||||
|
|
||||||
public long countObjectReferences(String objectKey) {
|
public long countObjectReferences(String objectKey) {
|
||||||
if (objectKey == null || objectKey.isBlank()) {
|
if (objectKey == null || objectKey.isBlank()) {
|
||||||
return 0L;
|
return 0L;
|
||||||
|
|||||||
+109
-12
@@ -4,6 +4,8 @@ import com.nanri.aiimage.common.exception.BusinessException;
|
|||||||
import com.nanri.aiimage.modules.shopdatacrawl.model.dto.ShopDataCrawlCountryResultDto;
|
import com.nanri.aiimage.modules.shopdatacrawl.model.dto.ShopDataCrawlCountryResultDto;
|
||||||
import com.nanri.aiimage.modules.shopdatacrawl.model.dto.ShopDataCrawlRowDto;
|
import com.nanri.aiimage.modules.shopdatacrawl.model.dto.ShopDataCrawlRowDto;
|
||||||
import com.nanri.aiimage.modules.shopdatacrawl.model.vo.ShopDataCrawlResultItemVo;
|
import com.nanri.aiimage.modules.shopdatacrawl.model.vo.ShopDataCrawlResultItemVo;
|
||||||
|
import com.nanri.aiimage.modules.shopdatacrawl.util.BoundedImageCache;
|
||||||
|
import com.nanri.aiimage.modules.shopdatacrawl.util.ShopDataCrawlPrefetchBudget;
|
||||||
import com.nanri.aiimage.modules.similarasin.util.SimilarAsinImageEmbedder;
|
import com.nanri.aiimage.modules.similarasin.util.SimilarAsinImageEmbedder;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
@@ -16,6 +18,7 @@ import org.apache.poi.ss.usermodel.Sheet;
|
|||||||
import org.apache.poi.ss.usermodel.Workbook;
|
import org.apache.poi.ss.usermodel.Workbook;
|
||||||
import org.apache.poi.xssf.usermodel.XSSFDrawing;
|
import org.apache.poi.xssf.usermodel.XSSFDrawing;
|
||||||
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
|
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
|
||||||
|
import org.apache.poi.xssf.streaming.SXSSFWorkbook;
|
||||||
import org.openxmlformats.schemas.drawingml.x2006.spreadsheetDrawing.CTTwoCellAnchor;
|
import org.openxmlformats.schemas.drawingml.x2006.spreadsheetDrawing.CTTwoCellAnchor;
|
||||||
import org.springframework.core.io.ClassPathResource;
|
import org.springframework.core.io.ClassPathResource;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
@@ -28,7 +31,6 @@ import java.util.ArrayList;
|
|||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@@ -44,22 +46,57 @@ public class ShopDataCrawlExcelAssemblyService {
|
|||||||
private static final int BRAND_COLUMN = HEADERS.size() - 1;
|
private static final int BRAND_COLUMN = HEADERS.size() - 1;
|
||||||
private static final int IMAGE_COLUMN_WIDTH = 18 * 256;
|
private static final int IMAGE_COLUMN_WIDTH = 18 * 256;
|
||||||
private static final float IMAGE_ROW_HEIGHT_POINTS = 80f;
|
private static final float IMAGE_ROW_HEIGHT_POINTS = 80f;
|
||||||
|
/** 图片缓存默认上限:64MB 字节预算 / 2000 条目,超过按 FIFO 淘汰,保证组装期内存有界。 */
|
||||||
|
private static final long DEFAULT_IMAGE_CACHE_MAX_BYTES = 64L * 1024 * 1024;
|
||||||
|
private static final int DEFAULT_IMAGE_CACHE_MAX_ENTRIES = 2000;
|
||||||
|
/** 预取默认上限:单任务最多预取 2000 个唯一 URL;其余 URL 由 embed 阶段兜底直接下载。 */
|
||||||
|
private static final int DEFAULT_PREFETCH_MAX_URLS = 2000;
|
||||||
|
/** 预取超时默认上限(秒),按 200ms/URL 估算的 deadline 被钳制到该值。 */
|
||||||
|
private static final long DEFAULT_PREFETCH_TIMEOUT_SECONDS = 120L;
|
||||||
|
|
||||||
private final SimilarAsinImageEmbedder imageEmbedder;
|
private final SimilarAsinImageEmbedder imageEmbedder;
|
||||||
|
private long imageCacheMaxBytes = DEFAULT_IMAGE_CACHE_MAX_BYTES;
|
||||||
|
private int imageCacheMaxEntries = DEFAULT_IMAGE_CACHE_MAX_ENTRIES;
|
||||||
|
private int prefetchMaxUrls = DEFAULT_PREFETCH_MAX_URLS;
|
||||||
|
|
||||||
public void writeWorkbook(File outputXlsx, List<ShopDataCrawlResultItemVo> items) {
|
public ShopDataCrawlExcelAssemblyService(SimilarAsinImageEmbedder imageEmbedder, long imageCacheMaxBytes) {
|
||||||
|
this.imageEmbedder = imageEmbedder;
|
||||||
|
this.imageCacheMaxBytes = imageCacheMaxBytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ShopDataCrawlExcelAssemblyService(SimilarAsinImageEmbedder imageEmbedder, long imageCacheMaxBytes,
|
||||||
|
int prefetchMaxUrls) {
|
||||||
|
this.imageEmbedder = imageEmbedder;
|
||||||
|
this.imageCacheMaxBytes = imageCacheMaxBytes;
|
||||||
|
this.prefetchMaxUrls = prefetchMaxUrls;
|
||||||
|
}
|
||||||
|
|
||||||
|
private BoundedImageCache newImageCache() {
|
||||||
|
return new BoundedImageCache(imageCacheMaxBytes, imageCacheMaxEntries);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ShopDataCrawlPrefetchBudget prefetchBudget() {
|
||||||
|
return ShopDataCrawlPrefetchBudget.of(prefetchMaxUrls, imageCacheMaxBytes, DEFAULT_PREFETCH_TIMEOUT_SECONDS);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void prefetchImages(Map<String, List<ShopDataCrawlRowDto>> rowsByCountry, BoundedImageCache imageCache) {
|
||||||
|
imageEmbedder.prefetch(prefetchBudget().boundedUrls(imageUrls(rowsByCountry)), imageCache);
|
||||||
|
}
|
||||||
|
|
||||||
|
public int writeWorkbook(File outputXlsx, List<ShopDataCrawlResultItemVo> items) {
|
||||||
try (InputStream input = new ClassPathResource(TEMPLATE).getInputStream();
|
try (InputStream input = new ClassPathResource(TEMPLATE).getInputStream();
|
||||||
XSSFWorkbook workbook = new XSSFWorkbook(input);
|
XSSFWorkbook workbook = new XSSFWorkbook(input);
|
||||||
FileOutputStream output = new FileOutputStream(outputXlsx)) {
|
FileOutputStream output = new FileOutputStream(outputXlsx)) {
|
||||||
validateTemplate(workbook);
|
validateTemplate(workbook);
|
||||||
Map<String, List<ShopDataCrawlRowDto>> rowsByCountry = rowsByCountry(items);
|
Map<String, List<ShopDataCrawlRowDto>> rowsByCountry = rowsByCountry(items);
|
||||||
Map<String, SimilarAsinImageEmbedder.ResizedImage> imageCache = new ConcurrentHashMap<>();
|
BoundedImageCache imageCache = newImageCache();
|
||||||
imageEmbedder.prefetch(imageUrls(rowsByCountry), imageCache);
|
prefetchImages(rowsByCountry, imageCache);
|
||||||
Map<String, Integer> pictureIndexes = new LinkedHashMap<>();
|
Map<String, Integer> pictureIndexes = new LinkedHashMap<>();
|
||||||
for (int i = 0; i < COUNTRIES.size(); i++) {
|
for (int i = 0; i < COUNTRIES.size(); i++) {
|
||||||
writeSheet(workbook, workbook.getSheetAt(i), rowsByCountry.get(COUNTRIES.get(i)), imageCache, pictureIndexes);
|
writeSheet(workbook, workbook.getSheetAt(i), rowsByCountry.get(COUNTRIES.get(i)), imageCache, pictureIndexes);
|
||||||
}
|
}
|
||||||
workbook.write(output);
|
workbook.write(output);
|
||||||
|
return rowsByCountry.values().stream().mapToInt(List::size).sum();
|
||||||
} catch (BusinessException ex) {
|
} catch (BusinessException ex) {
|
||||||
throw ex;
|
throw ex;
|
||||||
} catch (Exception ex) {
|
} catch (Exception ex) {
|
||||||
@@ -82,8 +119,8 @@ public class ShopDataCrawlExcelAssemblyService {
|
|||||||
FileOutputStream output = new FileOutputStream(outputXlsx)) {
|
FileOutputStream output = new FileOutputStream(outputXlsx)) {
|
||||||
validateTemplate(workbook);
|
validateTemplate(workbook);
|
||||||
Map<String, List<ShopDataCrawlRowDto>> rowsByCountry = rowsByCountry(items);
|
Map<String, List<ShopDataCrawlRowDto>> rowsByCountry = rowsByCountry(items);
|
||||||
Map<String, SimilarAsinImageEmbedder.ResizedImage> imageCache = new ConcurrentHashMap<>();
|
BoundedImageCache imageCache = newImageCache();
|
||||||
imageEmbedder.prefetch(imageUrls(rowsByCountry), imageCache);
|
prefetchImages(rowsByCountry, imageCache);
|
||||||
Map<String, Integer> pictureIndexes = new LinkedHashMap<>();
|
Map<String, Integer> pictureIndexes = new LinkedHashMap<>();
|
||||||
for (int i = 0; i < COUNTRIES.size(); i++) {
|
for (int i = 0; i < COUNTRIES.size(); i++) {
|
||||||
List<ShopDataCrawlRowDto> rows = rowsByCountry.get(COUNTRIES.get(i));
|
List<ShopDataCrawlRowDto> rows = rowsByCountry.get(COUNTRIES.get(i));
|
||||||
@@ -106,6 +143,60 @@ public class ShopDataCrawlExcelAssemblyService {
|
|||||||
return rowsByCountry(items).values().stream().mapToInt(List::size).sum();
|
return rowsByCountry(items).values().stream().mapToInt(List::size).sum();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Task 25:SXSSF 流式写入路径。空构造 SXSSFWorkbook + 自建 5 个国家工作表与表头,
|
||||||
|
* 数据行按 rowAccessWindow 数量 spill 到磁盘临时文件,写入后 dispose() 释放 spill 文件。
|
||||||
|
* 模板路径(validateTemplate/样式/累计替换)保留 XSSFWorkbook 不变。
|
||||||
|
* 返回实际写入的数据行数;图片下载失败的行兜底为 URL 文本,不中断整表。
|
||||||
|
*/
|
||||||
|
public int writeWorkbookStreaming(File outputXlsx, List<ShopDataCrawlResultItemVo> items, int rowAccessWindow) {
|
||||||
|
if (outputXlsx == null) {
|
||||||
|
throw new BusinessException("输出文件路径不能为空");
|
||||||
|
}
|
||||||
|
if (rowAccessWindow <= 0) {
|
||||||
|
throw new IllegalArgumentException("rowAccessWindow 必须为正数,实际 " + rowAccessWindow);
|
||||||
|
}
|
||||||
|
if (items == null) {
|
||||||
|
throw new IllegalArgumentException("items 不能为 null");
|
||||||
|
}
|
||||||
|
SXSSFWorkbook workbook = new SXSSFWorkbook(rowAccessWindow);
|
||||||
|
try (FileOutputStream output = new FileOutputStream(outputXlsx)) {
|
||||||
|
Map<String, List<ShopDataCrawlRowDto>> rowsByCountry = rowsByCountry(items);
|
||||||
|
BoundedImageCache imageCache = newImageCache();
|
||||||
|
prefetchImages(rowsByCountry, imageCache);
|
||||||
|
Map<String, Integer> pictureIndexes = new LinkedHashMap<>();
|
||||||
|
for (int i = 0; i < COUNTRIES.size(); i++) {
|
||||||
|
writeStreamingSheet(workbook, i, rowsByCountry.get(COUNTRIES.get(i)), imageCache, pictureIndexes);
|
||||||
|
}
|
||||||
|
workbook.write(output);
|
||||||
|
return rowsByCountry.values().stream().mapToInt(List::size).sum();
|
||||||
|
} catch (BusinessException ex) {
|
||||||
|
throw ex;
|
||||||
|
} catch (Exception ex) {
|
||||||
|
throw new BusinessException("生成店铺数据抓取流式 Excel 失败: " + ex.getMessage());
|
||||||
|
} finally {
|
||||||
|
workbook.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void writeStreamingSheet(Workbook workbook,
|
||||||
|
int index,
|
||||||
|
List<ShopDataCrawlRowDto> rows,
|
||||||
|
BoundedImageCache imageCache,
|
||||||
|
Map<String, Integer> pictureIndexes) {
|
||||||
|
Sheet sheet = workbook.createSheet(SHEETS.get(index));
|
||||||
|
Row header = sheet.createRow(0);
|
||||||
|
for (int column = 0; column < HEADERS.size(); column++) {
|
||||||
|
header.createCell(column).setCellValue(HEADERS.get(column));
|
||||||
|
}
|
||||||
|
sheet.setColumnWidth(IMAGE_COLUMN, IMAGE_COLUMN_WIDTH);
|
||||||
|
int rowIndex = 1;
|
||||||
|
for (ShopDataCrawlRowDto value : rows == null ? List.<ShopDataCrawlRowDto>of() : rows) {
|
||||||
|
Row row = sheet.createRow(rowIndex++);
|
||||||
|
writeDataRow(workbook, sheet, row, value, new CellStyle[HEADERS.size()], imageCache, pictureIndexes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void validateTemplate(XSSFWorkbook workbook) {
|
void validateTemplate(XSSFWorkbook workbook) {
|
||||||
if (workbook.getNumberOfSheets() != SHEETS.size()) {
|
if (workbook.getNumberOfSheets() != SHEETS.size()) {
|
||||||
throw new BusinessException("店铺数据抓取模板工作表数量不正确");
|
throw new BusinessException("店铺数据抓取模板工作表数量不正确");
|
||||||
@@ -133,7 +224,7 @@ public class ShopDataCrawlExcelAssemblyService {
|
|||||||
private void writeSheet(XSSFWorkbook workbook,
|
private void writeSheet(XSSFWorkbook workbook,
|
||||||
Sheet sheet,
|
Sheet sheet,
|
||||||
List<ShopDataCrawlRowDto> rows,
|
List<ShopDataCrawlRowDto> rows,
|
||||||
Map<String, SimilarAsinImageEmbedder.ResizedImage> imageCache,
|
BoundedImageCache imageCache,
|
||||||
Map<String, Integer> pictureIndexes) {
|
Map<String, Integer> pictureIndexes) {
|
||||||
Row header = sheet.getRow(0);
|
Row header = sheet.getRow(0);
|
||||||
Row styleRow = sheet.getRow(1);
|
Row styleRow = sheet.getRow(1);
|
||||||
@@ -170,12 +261,12 @@ public class ShopDataCrawlExcelAssemblyService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void writeDataRow(XSSFWorkbook workbook,
|
private void writeDataRow(Workbook workbook,
|
||||||
Sheet sheet,
|
Sheet sheet,
|
||||||
Row row,
|
Row row,
|
||||||
ShopDataCrawlRowDto value,
|
ShopDataCrawlRowDto value,
|
||||||
CellStyle[] styles,
|
CellStyle[] styles,
|
||||||
Map<String, SimilarAsinImageEmbedder.ResizedImage> imageCache,
|
BoundedImageCache imageCache,
|
||||||
Map<String, Integer> pictureIndexes) {
|
Map<String, Integer> pictureIndexes) {
|
||||||
String[] values = {value.getDate(), value.getAsin(), "", value.getInventorySales(), value.getSalesRank(),
|
String[] values = {value.getDate(), value.getAsin(), "", value.getInventorySales(), value.getSalesRank(),
|
||||||
value.getPageViews(), value.getUnitsSold(), value.getPrice(), value.getRecommendedOffer(), value.getBrand()};
|
value.getPageViews(), value.getUnitsSold(), value.getPrice(), value.getRecommendedOffer(), value.getBrand()};
|
||||||
@@ -214,11 +305,11 @@ public class ShopDataCrawlExcelAssemblyService {
|
|||||||
return currentTemplate || outputColumn < IMAGE_COLUMN ? outputColumn : outputColumn - 1;
|
return currentTemplate || outputColumn < IMAGE_COLUMN ? outputColumn : outputColumn - 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void embedImage(XSSFWorkbook workbook,
|
private void embedImage(Workbook workbook,
|
||||||
Sheet sheet,
|
Sheet sheet,
|
||||||
Row row,
|
Row row,
|
||||||
String imageUrl,
|
String imageUrl,
|
||||||
Map<String, SimilarAsinImageEmbedder.ResizedImage> imageCache,
|
BoundedImageCache imageCache,
|
||||||
Map<String, Integer> pictureIndexes) {
|
Map<String, Integer> pictureIndexes) {
|
||||||
String normalizedUrl = imageUrl.trim();
|
String normalizedUrl = imageUrl.trim();
|
||||||
try {
|
try {
|
||||||
@@ -232,6 +323,8 @@ public class ShopDataCrawlExcelAssemblyService {
|
|||||||
if (pictureIndex == null) {
|
if (pictureIndex == null) {
|
||||||
pictureIndex = workbook.addPicture(image.bytes(), Workbook.PICTURE_TYPE_JPEG);
|
pictureIndex = workbook.addPicture(image.bytes(), Workbook.PICTURE_TYPE_JPEG);
|
||||||
pictureIndexes.put(normalizedUrl, pictureIndex);
|
pictureIndexes.put(normalizedUrl, pictureIndex);
|
||||||
|
// 嵌入成功后立即释放缩略图字节副本,byte[] 可被 GC 回收;后续行复用 pictureIndex。
|
||||||
|
imageCache.release(normalizedUrl);
|
||||||
}
|
}
|
||||||
Drawing<?> drawing = sheet.createDrawingPatriarch();
|
Drawing<?> drawing = sheet.createDrawingPatriarch();
|
||||||
ClientAnchor anchor = workbook.getCreationHelper().createClientAnchor();
|
ClientAnchor anchor = workbook.getCreationHelper().createClientAnchor();
|
||||||
@@ -275,7 +368,11 @@ public class ShopDataCrawlExcelAssemblyService {
|
|||||||
if (item == null || Boolean.FALSE.equals(item.getSuccess()) || item.getCountryResults() == null) continue;
|
if (item == null || Boolean.FALSE.equals(item.getSuccess()) || item.getCountryResults() == null) continue;
|
||||||
for (ShopDataCrawlCountryResultDto countryResult : item.getCountryResults()) {
|
for (ShopDataCrawlCountryResultDto countryResult : item.getCountryResults()) {
|
||||||
String country = countryResult == null || countryResult.getCountry() == null ? "" : countryResult.getCountry().trim().toUpperCase();
|
String country = countryResult == null || countryResult.getCountry() == null ? "" : countryResult.getCountry().trim().toUpperCase();
|
||||||
if (result.containsKey(country) && countryResult.getItems() != null) result.get(country).addAll(countryResult.getItems());
|
// 同国覆盖:按成员顺序累积,后面的结果覆盖前面的同名国家行(最新任务胜出),
|
||||||
|
// 本次未提交的国家由调用方从旧累计对象/模板保留,不在这里清空。
|
||||||
|
if (result.containsKey(country) && countryResult.getItems() != null) {
|
||||||
|
result.put(country, new ArrayList<>(countryResult.getItems()));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
|
|||||||
+28
-1
@@ -13,6 +13,7 @@ import org.springframework.stereotype.Service;
|
|||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
|
import java.util.Comparator;
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
@@ -145,7 +146,7 @@ public class ShopDataCrawlTaskCacheService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
long now = System.currentTimeMillis();
|
long now = System.currentTimeMillis();
|
||||||
taskEntityLocalCache.put(task.getId(), new LocalTaskEntityCacheEntry(
|
putLocalCache(task.getId(), new LocalTaskEntityCacheEntry(
|
||||||
now,
|
now,
|
||||||
objectMapper.convertValue(task, FileTaskEntity.class)
|
objectMapper.convertValue(task, FileTaskEntity.class)
|
||||||
));
|
));
|
||||||
@@ -159,6 +160,23 @@ public class ShopDataCrawlTaskCacheService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 有界本地缓存写入:容量达到上限时按 cachedAtMillis LRU 淘汰最旧条目,
|
||||||
|
* 保证本地缓存内存有界。
|
||||||
|
*/
|
||||||
|
private void putLocalCache(Long taskId, LocalTaskEntityCacheEntry entry) {
|
||||||
|
taskEntityLocalCache.put(taskId, entry);
|
||||||
|
int capacity = Math.max(1, taskPressureProperties.getLocalTaskEntityCacheCapacity());
|
||||||
|
if (taskEntityLocalCache.size() > capacity) {
|
||||||
|
taskEntityLocalCache.entrySet().stream()
|
||||||
|
.sorted(Map.Entry.comparingByValue(
|
||||||
|
Comparator.comparingLong(LocalTaskEntityCacheEntry::cachedAtMillis)
|
||||||
|
.thenComparingLong(e -> e.task() == null ? 0L : e.task().getId() == null ? 0L : e.task().getId())))
|
||||||
|
.limit(taskEntityLocalCache.size() - capacity)
|
||||||
|
.forEach(entryToEvict -> taskEntityLocalCache.remove(entryToEvict.getKey()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public Map<Long, FileTaskEntity> getTaskCacheBatch(java.util.List<Long> taskIds) {
|
public Map<Long, FileTaskEntity> getTaskCacheBatch(java.util.List<Long> taskIds) {
|
||||||
Map<Long, FileTaskEntity> result = new LinkedHashMap<>();
|
Map<Long, FileTaskEntity> result = new LinkedHashMap<>();
|
||||||
if (taskIds == null || taskIds.isEmpty()) {
|
if (taskIds == null || taskIds.isEmpty()) {
|
||||||
@@ -178,6 +196,10 @@ public class ShopDataCrawlTaskCacheService {
|
|||||||
if (isLocalCacheFresh(cached, now)) {
|
if (isLocalCacheFresh(cached, now)) {
|
||||||
result.put(taskId, objectMapper.convertValue(cached.task(), FileTaskEntity.class));
|
result.put(taskId, objectMapper.convertValue(cached.task(), FileTaskEntity.class));
|
||||||
} else {
|
} else {
|
||||||
|
// 过期条目即时回收,避免本地缓存无限累积。
|
||||||
|
if (cached != null) {
|
||||||
|
taskEntityLocalCache.remove(taskId);
|
||||||
|
}
|
||||||
missingIds.add(taskId);
|
missingIds.add(taskId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -221,6 +243,11 @@ public class ShopDataCrawlTaskCacheService {
|
|||||||
&& now - cached.cachedAtMillis() <= Math.max(0L, taskPressureProperties.getLocalTaskEntityCacheMillis());
|
&& now - cached.cachedAtMillis() <= Math.max(0L, taskPressureProperties.getLocalTaskEntityCacheMillis());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 本地缓存当前条目数(测试与监控用)。 */
|
||||||
|
int localCacheSize() {
|
||||||
|
return taskEntityLocalCache.size();
|
||||||
|
}
|
||||||
|
|
||||||
private record LocalTaskEntityCacheEntry(long cachedAtMillis, FileTaskEntity task) {}
|
private record LocalTaskEntityCacheEntry(long cachedAtMillis, FileTaskEntity task) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+430
-95
@@ -83,9 +83,15 @@ public class ShopDataCrawlTaskService {
|
|||||||
private static final int RESULT_SUCCESS = 1;
|
private static final int RESULT_SUCCESS = 1;
|
||||||
/** 批量 IN 查询单批上限。 */
|
/** 批量 IN 查询单批上限。 */
|
||||||
private static final int ID_BATCH_SIZE = 500;
|
private static final int ID_BATCH_SIZE = 500;
|
||||||
|
/** Task 36:每日累计归档版本 CAS 冲突的最大重试次数(每次重试都释放店铺级锁)。 */
|
||||||
|
private static final int MAX_DAILY_AGGREGATION_ATTEMPTS = 3;
|
||||||
|
/** 历史列表单页条数上限。 */
|
||||||
|
private static final int HISTORY_MAX_PAGE_SIZE = 100;
|
||||||
private static final String INTERRUPTED_MESSAGE = "Python 在该店铺结果提交完成前中断";
|
private static final String INTERRUPTED_MESSAGE = "Python 在该店铺结果提交完成前中断";
|
||||||
private static final String PARTIAL_RESULT_MESSAGE = "Python 中断,已保留已回传的部分数据";
|
private static final String PARTIAL_RESULT_MESSAGE = "Python 中断,已保留已回传的部分数据";
|
||||||
private static final String RESULT_CHUNK_SCOPE_PREFIX = "result-chunks:";
|
private static final String RESULT_CHUNK_SCOPE_PREFIX = "result-chunks:";
|
||||||
|
/** 国家结果行去重键字段分隔符(控制字符,字段值 trim 后不可能包含)。 */
|
||||||
|
private static final String ROW_KEY_SEPARATOR = "";
|
||||||
|
|
||||||
private final FileTaskMapper fileTaskMapper;
|
private final FileTaskMapper fileTaskMapper;
|
||||||
private final FileResultMapper fileResultMapper;
|
private final FileResultMapper fileResultMapper;
|
||||||
@@ -114,12 +120,18 @@ public class ShopDataCrawlTaskService {
|
|||||||
public void finalizeOwnedStaleTasks() {
|
public void finalizeOwnedStaleTasks() {
|
||||||
long minutes = Math.max(1L, staleTimeoutMinutes);
|
long minutes = Math.max(1L, staleTimeoutMinutes);
|
||||||
long nowMillis = System.currentTimeMillis();
|
long nowMillis = System.currentTimeMillis();
|
||||||
List<FileTaskEntity> tasks = fileTaskMapper.selectList(new LambdaQueryWrapper<FileTaskEntity>()
|
List<FileTaskEntity> tasks;
|
||||||
.eq(FileTaskEntity::getModuleType, MODULE_TYPE)
|
try {
|
||||||
.eq(FileTaskEntity::getStatus, "RUNNING")
|
tasks = fileTaskMapper.selectList(new LambdaQueryWrapper<FileTaskEntity>()
|
||||||
.apply("JSON_UNQUOTE(JSON_EXTRACT(request_json, '$.ownerInstanceId')) = {0}", currentInstanceId())
|
.eq(FileTaskEntity::getModuleType, MODULE_TYPE)
|
||||||
.lt(FileTaskEntity::getUpdatedAt, LocalDateTime.now().minusMinutes(minutes))
|
.eq(FileTaskEntity::getStatus, "RUNNING")
|
||||||
.last("limit 200"));
|
.eq(FileTaskEntity::getOwnerInstanceId, currentInstanceId())
|
||||||
|
.lt(FileTaskEntity::getUpdatedAt, LocalDateTime.now().minusMinutes(minutes))
|
||||||
|
.last("limit 200"));
|
||||||
|
} catch (Exception ex) {
|
||||||
|
log.warn("[shop-data-crawl] stale task scan failed msg={}", ex.getMessage());
|
||||||
|
return;
|
||||||
|
}
|
||||||
Map<Long, Long> heartbeats = taskCacheService.getTaskHeartbeatMillisBatch(
|
Map<Long, Long> heartbeats = taskCacheService.getTaskHeartbeatMillisBatch(
|
||||||
tasks.stream().map(FileTaskEntity::getId).toList());
|
tasks.stream().map(FileTaskEntity::getId).toList());
|
||||||
for (FileTaskEntity task : tasks) {
|
for (FileTaskEntity task : tasks) {
|
||||||
@@ -213,9 +225,35 @@ public class ShopDataCrawlTaskService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public ShopDataCrawlHistoryVo listHistory(Long userId) {
|
public ShopDataCrawlHistoryVo listHistory(Long userId) {
|
||||||
|
return listHistory(userId, 1, HISTORY_MAX_PAGE_SIZE);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分页查询当前用户历史记录:结果行按创建时间倒序,字段裁剪,任务与文件作业状态批量加载。
|
||||||
|
* page 必须 ≥ 1;limit 收敛到 [1, {@value #HISTORY_MAX_PAGE_SIZE}];越界页返回空页。
|
||||||
|
*/
|
||||||
|
public ShopDataCrawlHistoryVo listHistory(Long userId, int page, int limit) {
|
||||||
long startedAt = System.nanoTime();
|
long startedAt = System.nanoTime();
|
||||||
validateUserId(userId);
|
validateUserId(userId);
|
||||||
|
if (page < 1) {
|
||||||
|
throw new BusinessException("page 必须大于等于 1");
|
||||||
|
}
|
||||||
|
if (limit < 1) {
|
||||||
|
throw new BusinessException("limit 必须大于等于 1");
|
||||||
|
}
|
||||||
|
int effectiveLimit = Math.min(limit, HISTORY_MAX_PAGE_SIZE);
|
||||||
ShopDataCrawlHistoryVo vo = new ShopDataCrawlHistoryVo();
|
ShopDataCrawlHistoryVo vo = new ShopDataCrawlHistoryVo();
|
||||||
|
vo.setPage(page);
|
||||||
|
vo.setLimit(effectiveLimit);
|
||||||
|
long total = countHistoryRows(userId);
|
||||||
|
vo.setTotal(total);
|
||||||
|
if (total == 0L || (long) (page - 1) * effectiveLimit >= total) {
|
||||||
|
vo.setItems(List.of());
|
||||||
|
vo.setPage(total == 0L ? 0L : page);
|
||||||
|
log.info("[shop-data-crawl] history timing userId={} rows=0 total={} page={} limit={} totalMs={}",
|
||||||
|
userId, total, page, effectiveLimit, elapsedMs(startedAt, System.nanoTime()));
|
||||||
|
return vo;
|
||||||
|
}
|
||||||
List<FileResultEntity> entities = fileResultMapper.selectList(new LambdaQueryWrapper<FileResultEntity>()
|
List<FileResultEntity> entities = fileResultMapper.selectList(new LambdaQueryWrapper<FileResultEntity>()
|
||||||
.select(FileResultEntity::getId,
|
.select(FileResultEntity::getId,
|
||||||
FileResultEntity::getTaskId,
|
FileResultEntity::getTaskId,
|
||||||
@@ -231,12 +269,12 @@ public class ShopDataCrawlTaskService {
|
|||||||
.eq(FileResultEntity::getModuleType, MODULE_TYPE)
|
.eq(FileResultEntity::getModuleType, MODULE_TYPE)
|
||||||
.eq(FileResultEntity::getUserId, userId)
|
.eq(FileResultEntity::getUserId, userId)
|
||||||
.orderByDesc(FileResultEntity::getCreatedAt)
|
.orderByDesc(FileResultEntity::getCreatedAt)
|
||||||
.last("limit 100"));
|
.last("LIMIT " + (long) (page - 1) * effectiveLimit + ", " + effectiveLimit));
|
||||||
long resultRowsLoadedAt = System.nanoTime();
|
long resultRowsLoadedAt = System.nanoTime();
|
||||||
if (entities.isEmpty()) {
|
if (entities.isEmpty()) {
|
||||||
vo.setItems(List.of());
|
vo.setItems(List.of());
|
||||||
log.info("[shop-data-crawl] history timing userId={} rows=0 totalMs={} resultQueryMs={} taskQueryMs=0 jobQueryMs=0 buildMs=0",
|
log.info("[shop-data-crawl] history timing userId={} rows=0 total={} page={} limit={} totalMs={}",
|
||||||
userId, elapsedMs(startedAt, resultRowsLoadedAt), elapsedMs(startedAt, resultRowsLoadedAt));
|
userId, total, page, effectiveLimit, elapsedMs(startedAt, resultRowsLoadedAt));
|
||||||
return vo;
|
return vo;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -255,11 +293,14 @@ public class ShopDataCrawlTaskService {
|
|||||||
}
|
}
|
||||||
vo.setItems(items);
|
vo.setItems(items);
|
||||||
long finishedAt = System.nanoTime();
|
long finishedAt = System.nanoTime();
|
||||||
log.info("[shop-data-crawl] history timing userId={} rows={} tasks={} jobs={} totalMs={} resultQueryMs={} taskQueryMs={} jobQueryMs={} buildMs={}",
|
log.info("[shop-data-crawl] history timing userId={} rows={} tasks={} jobs={} total={} page={} limit={} totalMs={} resultQueryMs={} taskQueryMs={} jobQueryMs={} buildMs={}",
|
||||||
userId,
|
userId,
|
||||||
entities.size(),
|
entities.size(),
|
||||||
taskMap.size(),
|
taskMap.size(),
|
||||||
jobMap.size(),
|
jobMap.size(),
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
effectiveLimit,
|
||||||
elapsedMs(startedAt, finishedAt),
|
elapsedMs(startedAt, finishedAt),
|
||||||
elapsedMs(startedAt, resultRowsLoadedAt),
|
elapsedMs(startedAt, resultRowsLoadedAt),
|
||||||
elapsedMs(resultRowsLoadedAt, tasksLoadedAt),
|
elapsedMs(resultRowsLoadedAt, tasksLoadedAt),
|
||||||
@@ -268,6 +309,13 @@ public class ShopDataCrawlTaskService {
|
|||||||
return vo;
|
return vo;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private long countHistoryRows(Long userId) {
|
||||||
|
Long count = fileResultMapper.selectCount(new LambdaQueryWrapper<FileResultEntity>()
|
||||||
|
.eq(FileResultEntity::getModuleType, MODULE_TYPE)
|
||||||
|
.eq(FileResultEntity::getUserId, userId));
|
||||||
|
return count == null ? 0L : count;
|
||||||
|
}
|
||||||
|
|
||||||
public ShopDataCrawlTaskBatchVo getTaskProgressBatch(List<Long> taskIds) {
|
public ShopDataCrawlTaskBatchVo getTaskProgressBatch(List<Long> taskIds) {
|
||||||
ShopDataCrawlTaskBatchVo batch = new ShopDataCrawlTaskBatchVo();
|
ShopDataCrawlTaskBatchVo batch = new ShopDataCrawlTaskBatchVo();
|
||||||
if (taskIds == null || taskIds.isEmpty()) {
|
if (taskIds == null || taskIds.isEmpty()) {
|
||||||
@@ -284,10 +332,18 @@ public class ShopDataCrawlTaskService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Map<Long, FileTaskEntity> taskMap = loadTaskMapByIds(normalizedTaskIds);
|
Map<Long, FileTaskEntity> taskMap = loadTaskMapByIds(normalizedTaskIds);
|
||||||
List<FileResultEntity> rows = fileResultMapper.selectList(new LambdaQueryWrapper<FileResultEntity>()
|
List<FileResultEntity> rows;
|
||||||
.eq(FileResultEntity::getModuleType, MODULE_TYPE)
|
try {
|
||||||
.in(FileResultEntity::getTaskId, normalizedTaskIds)
|
rows = fileResultMapper.selectList(new LambdaQueryWrapper<FileResultEntity>()
|
||||||
.orderByAsc(FileResultEntity::getId));
|
.eq(FileResultEntity::getModuleType, MODULE_TYPE)
|
||||||
|
.in(FileResultEntity::getTaskId, normalizedTaskIds)
|
||||||
|
.orderByAsc(FileResultEntity::getId));
|
||||||
|
} catch (Exception ex) {
|
||||||
|
throw new BusinessException("任务进度查询失败", ex);
|
||||||
|
}
|
||||||
|
if (rows == null) {
|
||||||
|
throw new BusinessException("任务进度查询失败");
|
||||||
|
}
|
||||||
Map<Long, List<FileResultEntity>> rowsByTaskId = new LinkedHashMap<>();
|
Map<Long, List<FileResultEntity>> rowsByTaskId = new LinkedHashMap<>();
|
||||||
for (FileResultEntity row : rows) {
|
for (FileResultEntity row : rows) {
|
||||||
rowsByTaskId.computeIfAbsent(row.getTaskId(), ignored -> new ArrayList<>()).add(row);
|
rowsByTaskId.computeIfAbsent(row.getTaskId(), ignored -> new ArrayList<>()).add(row);
|
||||||
@@ -492,8 +548,7 @@ public class ShopDataCrawlTaskService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
persistSnapshotJson(task, snapshots);
|
persistProgressOrSnapshot(task, snapshots, resultRows.stream().allMatch(this::isResultFinished));
|
||||||
fileTaskMapper.updateById(task);
|
|
||||||
taskCacheService.saveTaskCache(task);
|
taskCacheService.saveTaskCache(task);
|
||||||
tryFinalizeTask(taskId, false);
|
tryFinalizeTask(taskId, false);
|
||||||
}
|
}
|
||||||
@@ -597,13 +652,14 @@ public class ShopDataCrawlTaskService {
|
|||||||
List<FileResultEntity> latestRows = listTaskRows(taskId);
|
List<FileResultEntity> latestRows = listTaskRows(taskId);
|
||||||
updateTaskStatusFromRows(task, latestRows);
|
updateTaskStatusFromRows(task, latestRows);
|
||||||
if (latestRows.stream().allMatch(this::isResultFinished)) {
|
if (latestRows.stream().allMatch(this::isResultFinished)) {
|
||||||
persistSnapshotJson(task, snapshots);
|
if (changed || !snapshotJsonHasCountryRows(task)) {
|
||||||
|
persistSnapshotJson(task, snapshots);
|
||||||
|
}
|
||||||
fileTaskMapper.updateById(task);
|
fileTaskMapper.updateById(task);
|
||||||
finalizeTaskWorkbook(task, latestRows, snapshots);
|
finalizeTaskWorkbook(task, latestRows, snapshots);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
persistSnapshotJson(task, snapshots);
|
|
||||||
fileTaskMapper.updateById(task);
|
fileTaskMapper.updateById(task);
|
||||||
taskCacheService.saveTaskCache(task);
|
taskCacheService.saveTaskCache(task);
|
||||||
return changed;
|
return changed;
|
||||||
@@ -1135,6 +1191,7 @@ public class ShopDataCrawlTaskService {
|
|||||||
requestSnapshot.put("items", requestItems);
|
requestSnapshot.put("items", requestItems);
|
||||||
requestSnapshot.put("countryCodes", countryCodes);
|
requestSnapshot.put("countryCodes", countryCodes);
|
||||||
task.setRequestJson(objectMapper.writeValueAsString(requestSnapshot));
|
task.setRequestJson(objectMapper.writeValueAsString(requestSnapshot));
|
||||||
|
task.setOwnerInstanceId(currentInstanceId());
|
||||||
task.setResultJson(objectMapper.writeValueAsString(snapshots));
|
task.setResultJson(objectMapper.writeValueAsString(snapshots));
|
||||||
syncSnapshotTables(task, snapshots);
|
syncSnapshotTables(task, snapshots);
|
||||||
fileTaskMapper.updateById(task);
|
fileTaskMapper.updateById(task);
|
||||||
@@ -1251,22 +1308,18 @@ public class ShopDataCrawlTaskService {
|
|||||||
int chunkTotal = incoming.getChunkTotal();
|
int chunkTotal = incoming.getChunkTotal();
|
||||||
validateChunkMetadata(chunkIndex, chunkTotal);
|
validateChunkMetadata(chunkIndex, chunkTotal);
|
||||||
|
|
||||||
|
List<ShopDataCrawlCountryResultDto> countryResults = copyCountryResults(incoming.getCountryResults());
|
||||||
|
if (!hasProcessableChunkData(incoming.getCountryResults())) {
|
||||||
|
throw new BusinessException("店铺数据抓取结果分片内容为空,拒绝接收");
|
||||||
|
}
|
||||||
|
String payloadJson = writeJson(countryResults, "序列化店铺数据抓取结果分片失败");
|
||||||
|
String payloadHash = DigestUtil.sha256Hex(payloadJson);
|
||||||
|
|
||||||
String scopeKey = resultChunkScopeKey(shopKey);
|
String scopeKey = resultChunkScopeKey(shopKey);
|
||||||
String scopeHash = DigestUtil.sha256Hex(scopeKey);
|
String scopeHash = DigestUtil.sha256Hex(scopeKey);
|
||||||
TaskScopeStateEntity scope = findResultScope(taskId, scopeHash);
|
TaskScopeStateEntity scope = findResultScope(taskId, scopeHash);
|
||||||
validateChunkTotal(scope == null ? null : scope.getChunkTotal(), chunkTotal);
|
validateChunkTotal(scope == null ? null : scope.getChunkTotal(), chunkTotal);
|
||||||
|
|
||||||
List<ShopDataCrawlCountryResultDto> countryResults = copyCountryResults(incoming.getCountryResults());
|
|
||||||
String payloadJson = writeJson(countryResults, "序列化店铺数据抓取结果分片失败");
|
|
||||||
String payloadHash = DigestUtil.sha256Hex(payloadJson);
|
|
||||||
TaskChunkEntity existing = findResultChunk(taskId, scopeHash, chunkIndex);
|
|
||||||
if (existing != null) {
|
|
||||||
validateExistingChunk(existing, chunkTotal, payloadHash);
|
|
||||||
int receivedChunkCount = countResultChunks(taskId, scopeHash);
|
|
||||||
persistResultScope(taskId, scopeKey, scopeHash, chunkTotal, receivedChunkCount);
|
|
||||||
return new ResultChunkReceipt(scopeHash, chunkTotal, receivedChunkCount >= chunkTotal);
|
|
||||||
}
|
|
||||||
|
|
||||||
ensureRustfsPayloadStorageEnabled();
|
ensureRustfsPayloadStorageEnabled();
|
||||||
String storedPayload = transientPayloadStorageService.storeChunkPayloadVersioned(
|
String storedPayload = transientPayloadStorageService.storeChunkPayloadVersioned(
|
||||||
MODULE_TYPE, taskId, scopeHash, chunkIndex, payloadJson);
|
MODULE_TYPE, taskId, scopeHash, chunkIndex, payloadJson);
|
||||||
@@ -1284,8 +1337,12 @@ public class ShopDataCrawlTaskService {
|
|||||||
chunk.setPayloadHash(payloadHash);
|
chunk.setPayloadHash(payloadHash);
|
||||||
chunk.setCreatedAt(now);
|
chunk.setCreatedAt(now);
|
||||||
chunk.setUpdatedAt(now);
|
chunk.setUpdatedAt(now);
|
||||||
|
int receivedChunkCount;
|
||||||
|
boolean insertedThisCall = false;
|
||||||
try {
|
try {
|
||||||
taskChunkMapper.insert(chunk);
|
taskChunkMapper.insert(chunk);
|
||||||
|
insertedThisCall = true;
|
||||||
|
receivedChunkCount = nextScopeChunkCount(scope, chunkTotal);
|
||||||
} catch (DuplicateKeyException ex) {
|
} catch (DuplicateKeyException ex) {
|
||||||
transientPayloadStorageService.deletePayloadIfPresent(storedPayload);
|
transientPayloadStorageService.deletePayloadIfPresent(storedPayload);
|
||||||
TaskChunkEntity winner = findResultChunk(taskId, scopeHash, chunkIndex);
|
TaskChunkEntity winner = findResultChunk(taskId, scopeHash, chunkIndex);
|
||||||
@@ -1293,13 +1350,27 @@ public class ShopDataCrawlTaskService {
|
|||||||
throw new BusinessException("店铺数据抓取结果分片并发写入失败,请重试");
|
throw new BusinessException("店铺数据抓取结果分片并发写入失败,请重试");
|
||||||
}
|
}
|
||||||
validateExistingChunk(winner, chunkTotal, payloadHash);
|
validateExistingChunk(winner, chunkTotal, payloadHash);
|
||||||
|
receivedChunkCount = scopeChunkCount(scope);
|
||||||
} catch (RuntimeException ex) {
|
} catch (RuntimeException ex) {
|
||||||
transientPayloadStorageService.deletePayloadIfPresent(storedPayload);
|
transientPayloadStorageService.deletePayloadIfPresent(storedPayload);
|
||||||
throw ex;
|
throw ex;
|
||||||
}
|
}
|
||||||
|
|
||||||
int receivedChunkCount = countResultChunks(taskId, scopeHash);
|
try {
|
||||||
persistResultScope(taskId, scopeKey, scopeHash, chunkTotal, receivedChunkCount);
|
persistResultScope(scope, taskId, scopeKey, scopeHash, chunkTotal, receivedChunkCount);
|
||||||
|
} catch (RuntimeException ex) {
|
||||||
|
// 分片行与 scope 计数器是一致性单元:状态写入失败则回滚本次插入的分片行与 payload,
|
||||||
|
// 客户端重试会走全新插入路径,计数器不会因重试重复计数或永久欠计。
|
||||||
|
if (insertedThisCall) {
|
||||||
|
taskChunkMapper.delete(new LambdaQueryWrapper<TaskChunkEntity>()
|
||||||
|
.eq(TaskChunkEntity::getTaskId, taskId)
|
||||||
|
.eq(TaskChunkEntity::getModuleType, MODULE_TYPE)
|
||||||
|
.eq(TaskChunkEntity::getScopeHash, scopeHash)
|
||||||
|
.eq(TaskChunkEntity::getChunkIndex, chunkIndex));
|
||||||
|
transientPayloadStorageService.deletePayloadIfPresent(storedPayload);
|
||||||
|
}
|
||||||
|
throw ex;
|
||||||
|
}
|
||||||
log.info("[shop-data-crawl] result chunk received taskId={} shop={} chunk={}/{} received={}",
|
log.info("[shop-data-crawl] result chunk received taskId={} shop={} chunk={}/{} received={}",
|
||||||
taskId, shopKey, chunkIndex, chunkTotal, receivedChunkCount);
|
taskId, shopKey, chunkIndex, chunkTotal, receivedChunkCount);
|
||||||
return new ResultChunkReceipt(scopeHash, chunkTotal, receivedChunkCount >= chunkTotal);
|
return new ResultChunkReceipt(scopeHash, chunkTotal, receivedChunkCount >= chunkTotal);
|
||||||
@@ -1347,20 +1418,35 @@ public class ShopDataCrawlTaskService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private int countResultChunks(Long taskId, String scopeHash) {
|
/**
|
||||||
Long count = taskChunkMapper.selectCount(new LambdaQueryWrapper<TaskChunkEntity>()
|
* 以 scope 计数器替代 chunk 表全量 COUNT(*):新分片插入成功后 +1 并钳制到 chunk_total。
|
||||||
.eq(TaskChunkEntity::getTaskId, taskId)
|
* 任务级分布式锁串行化同一任务的接收,读改写在单锁内完成,无需再统计 chunk 行数。
|
||||||
.eq(TaskChunkEntity::getModuleType, MODULE_TYPE)
|
*/
|
||||||
.eq(TaskChunkEntity::getScopeHash, scopeHash));
|
private int nextScopeChunkCount(TaskScopeStateEntity scope, int chunkTotal) {
|
||||||
return count == null ? 0 : count.intValue();
|
int previous = scope == null ? 0 : Math.max(0, scope.getReceivedChunkCount() == null ? 0 : scope.getReceivedChunkCount());
|
||||||
|
return Math.min(chunkTotal, previous + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void persistResultScope(Long taskId,
|
/** 重复提交(唯一键冲突,分片已计过数)时计数器保持不变,钳制到 chunk_total。 */
|
||||||
|
private int scopeChunkCount(TaskScopeStateEntity scope) {
|
||||||
|
if (scope == null || scope.getReceivedChunkCount() == null) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
int count = Math.max(0, scope.getReceivedChunkCount());
|
||||||
|
Integer chunkTotal = scope.getChunkTotal();
|
||||||
|
return chunkTotal != null && chunkTotal > 0 ? Math.min(chunkTotal, count) : count;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 复用接收前已加载的 scope(Task 29:合并查询与更新,单 chunk 的 scope 往返 2 次降为 1 次)。
|
||||||
|
* 任务级分布式锁串行化同一任务的接收,预读 scope 在锁内不会过期。
|
||||||
|
*/
|
||||||
|
private void persistResultScope(TaskScopeStateEntity scope,
|
||||||
|
Long taskId,
|
||||||
String scopeKey,
|
String scopeKey,
|
||||||
String scopeHash,
|
String scopeHash,
|
||||||
int chunkTotal,
|
int chunkTotal,
|
||||||
int receivedChunkCount) {
|
int receivedChunkCount) {
|
||||||
TaskScopeStateEntity scope = findResultScope(taskId, scopeHash);
|
|
||||||
validateChunkTotal(scope == null ? null : scope.getChunkTotal(), chunkTotal);
|
validateChunkTotal(scope == null ? null : scope.getChunkTotal(), chunkTotal);
|
||||||
LocalDateTime now = LocalDateTime.now();
|
LocalDateTime now = LocalDateTime.now();
|
||||||
if (scope == null) {
|
if (scope == null) {
|
||||||
@@ -1531,7 +1617,8 @@ public class ShopDataCrawlTaskService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
snapshot.setShopName(firstNonBlank(payload.getShopName(), snapshot.getShopName()));
|
snapshot.setShopName(firstNonBlank(payload.getShopName(), snapshot.getShopName()));
|
||||||
snapshot.setCountryResults(copyCountryResults(payload.getCountryResults()));
|
// 按国家增量合并:本次未回传的国家保留快照中的旧结果;已回传的国家以本次提交为准(客户端按国家整体 upsert)
|
||||||
|
snapshot.setCountryResults(mergeSnapshotCountries(snapshot.getCountryResults(), payload.getCountryResults()));
|
||||||
if (!blank(payload.getError())) {
|
if (!blank(payload.getError())) {
|
||||||
snapshot.setError(payload.getError().trim());
|
snapshot.setError(payload.getError().trim());
|
||||||
}
|
}
|
||||||
@@ -1575,15 +1662,44 @@ public class ShopDataCrawlTaskService {
|
|||||||
map.put(item.getCountry(), item);
|
map.put(item.getCountry(), item);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
List<ShopDataCrawlRowDto> merged = new ArrayList<>(existing.getItems() == null ? List.of() : existing.getItems());
|
List<ShopDataCrawlRowDto> merged = existing.getItems() == null ? new ArrayList<>() : new ArrayList<>(existing.getItems());
|
||||||
|
Set<String> seen = new HashSet<>();
|
||||||
|
for (ShopDataCrawlRowDto old : merged) {
|
||||||
|
String key = rowDedupKey(old);
|
||||||
|
if (key != null) {
|
||||||
|
seen.add(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
for (ShopDataCrawlRowDto row : item.getItems() == null ? List.<ShopDataCrawlRowDto>of() : item.getItems()) {
|
for (ShopDataCrawlRowDto row : item.getItems() == null ? List.<ShopDataCrawlRowDto>of() : item.getItems()) {
|
||||||
if (row != null && merged.stream().noneMatch(old -> sameRow(old, row))) merged.add(copyRow(row));
|
if (row == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
String key = rowDedupKey(row);
|
||||||
|
if (key != null && seen.add(key)) {
|
||||||
|
merged.add(copyRow(row));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
existing.setItems(merged);
|
existing.setItems(merged);
|
||||||
}
|
}
|
||||||
return new ArrayList<>(map.values());
|
return new ArrayList<>(map.values());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 快照国家的增量合并:只合并本次提交含有的国家(新国家追加、已含国家以本次为准),
|
||||||
|
* 本次未提交的国家完整保留 —— 与 mergeCountryResults 的行级并集语义不同,
|
||||||
|
* 这里不跨提交做行去重累积,避免客户端按国家整体回传时旧行残留。
|
||||||
|
*/
|
||||||
|
private List<ShopDataCrawlCountryResultDto> mergeSnapshotCountries(List<ShopDataCrawlCountryResultDto> base, List<ShopDataCrawlCountryResultDto> incoming) {
|
||||||
|
Map<String, ShopDataCrawlCountryResultDto> map = new LinkedHashMap<>();
|
||||||
|
for (ShopDataCrawlCountryResultDto item : copyCountryResults(base)) {
|
||||||
|
map.put(item.getCountry(), item);
|
||||||
|
}
|
||||||
|
for (ShopDataCrawlCountryResultDto item : copyCountryResults(incoming)) {
|
||||||
|
map.put(item.getCountry(), item);
|
||||||
|
}
|
||||||
|
return new ArrayList<>(map.values());
|
||||||
|
}
|
||||||
|
|
||||||
private boolean hasResultRows(ShopDataCrawlShopPayloadDto payload) {
|
private boolean hasResultRows(ShopDataCrawlShopPayloadDto payload) {
|
||||||
return payload != null && hasResultRows(payload.getCountryResults());
|
return payload != null && hasResultRows(payload.getCountryResults());
|
||||||
}
|
}
|
||||||
@@ -1691,46 +1807,73 @@ public class ShopDataCrawlTaskService {
|
|||||||
if (userId == null || shopKeyHash == null) {
|
if (userId == null || shopKeyHash == null) {
|
||||||
throw new BusinessException("店铺累计文件归属信息不完整");
|
throw new BusinessException("店铺累计文件归属信息不完整");
|
||||||
}
|
}
|
||||||
TaskDistributedLockService.LockHandle lock = dailyFileService.acquireLock(userId, shopKey);
|
// Task 36:版本号/CAS 短临界区。店铺级锁只覆盖“准备/提交”两个毫秒级短事务
|
||||||
if (lock == null) {
|
// (各自独立取锁/释放),整表 Excel 组装与 OSS 上传在两次取锁之间于锁外执行;
|
||||||
throw new BusinessException("店铺当天累计文件正在处理中,请稍后重试");
|
// 提交阶段按 daily_file.version CAS,冲突时释放锁、清理本次上传对象并按最新状态重试(最多 3 次)。
|
||||||
}
|
|
||||||
DailyAggregationResult persistedResult = null;
|
DailyAggregationResult persistedResult = null;
|
||||||
try {
|
DailyWorkbookArtifact artifact = null;
|
||||||
DailyAggregationPreparation preparation = executeShortTransaction(
|
for (int attempt = 1; attempt <= MAX_DAILY_AGGREGATION_ATTEMPTS; attempt++) {
|
||||||
() -> prepareDailyAggregation(userId, shopKeyHash, businessDate, row));
|
DailyAggregationPreparation preparation;
|
||||||
|
TaskDistributedLockService.LockHandle prepareLock = dailyFileService.acquireLock(userId, shopKey);
|
||||||
|
if (prepareLock == null) {
|
||||||
|
throw new BusinessException("店铺当天累计文件正在处理中,请稍后重试");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
preparation = executeShortTransaction(
|
||||||
|
() -> prepareDailyAggregation(userId, shopKeyHash, businessDate, row));
|
||||||
|
} finally {
|
||||||
|
prepareLock.close();
|
||||||
|
}
|
||||||
if (preparation.alreadyArchived()) {
|
if (preparation.alreadyArchived()) {
|
||||||
return new DailyAggregationResult(List.of(), false);
|
return new DailyAggregationResult(List.of(), false);
|
||||||
}
|
}
|
||||||
|
ShopDataCrawlDailyFileEntity baseForAttempt = resolveBaseDailyFile(
|
||||||
int addedRowCount = excelAssemblyService.countRows(List.of(snapshot));
|
|
||||||
ShopDataCrawlDailyFileEntity baseDailyFile = resolveBaseDailyFile(
|
|
||||||
preparation.dailyFile(), userId, shopKeyHash, businessDate);
|
preparation.dailyFile(), userId, shopKeyHash, businessDate);
|
||||||
DailyWorkbookArtifact artifact = assembleDailyWorkbook(
|
// 组装在锁外执行。每次尝试都用本次准备阶段读到的最新 base 组装
|
||||||
task, snapshot, baseDailyFile, addedRowCount);
|
// (冲突重试时 base 已变化,复用过期的组装结果会把并发写入的行丢在对象外);
|
||||||
try {
|
// 零行引用对象不重复上传。
|
||||||
persistedResult = executeShortTransaction(() -> persistDailyAggregation(
|
if (artifact == null || !artifact.uploaded()) {
|
||||||
task, row, userId, shopKey, shopKeyHash, businessDate,
|
artifact = assembleDailyWorkbook(task, snapshot, baseForAttempt,
|
||||||
preparation, artifact));
|
excelAssemblyService.countRows(List.of(snapshot)));
|
||||||
if (persistedResult.discardUploadedObject() && artifact.uploaded()) {
|
}
|
||||||
deleteObjectQuietly(artifact.objectKey());
|
TaskDistributedLockService.LockHandle commitLock = dailyFileService.acquireLock(userId, shopKey);
|
||||||
}
|
if (commitLock == null) {
|
||||||
return persistedResult;
|
throw new BusinessException("店铺当天累计文件正在处理中,请稍后重试");
|
||||||
} catch (RuntimeException ex) {
|
|
||||||
if (artifact.uploaded()) {
|
|
||||||
deleteObjectQuietly(artifact.objectKey());
|
|
||||||
}
|
|
||||||
throw ex;
|
|
||||||
}
|
}
|
||||||
} finally {
|
|
||||||
try {
|
try {
|
||||||
lock.close();
|
DailyAggregationPreparation preparationForAttempt = preparation;
|
||||||
} finally {
|
DailyWorkbookArtifact artifactForAttempt = artifact;
|
||||||
if (persistedResult != null) {
|
try {
|
||||||
persistedResult.obsoleteObjectKeys().forEach(this::deleteObjectQuietly);
|
persistedResult = executeShortTransaction(() -> persistDailyAggregation(
|
||||||
|
task, row, userId, shopKey, shopKeyHash, businessDate,
|
||||||
|
preparationForAttempt, artifactForAttempt, snapshot,
|
||||||
|
preparationForAttempt.dailyFile()));
|
||||||
|
if (persistedResult.discardUploadedObject() && artifactForAttempt.uploaded()) {
|
||||||
|
deleteObjectQuietly(artifactForAttempt.objectKey());
|
||||||
|
} else {
|
||||||
|
persistedResult.obsoleteObjectKeys().forEach(this::deleteObjectQuietly);
|
||||||
|
}
|
||||||
|
return persistedResult;
|
||||||
|
} catch (DailyStateConflictException conflictEx) {
|
||||||
|
// 冲突:本次上传对象作废并释放,按最新状态重新组装再试。
|
||||||
|
if (artifact.uploaded()) {
|
||||||
|
deleteObjectQuietly(artifact.objectKey());
|
||||||
|
artifact = null;
|
||||||
|
}
|
||||||
|
if (attempt >= MAX_DAILY_AGGREGATION_ATTEMPTS) {
|
||||||
|
throw new BusinessException("店铺累计文件并发更新冲突,请稍后重试");
|
||||||
|
}
|
||||||
|
} catch (RuntimeException ex) {
|
||||||
|
if (artifact != null && artifact.uploaded()) {
|
||||||
|
deleteObjectQuietly(artifact.objectKey());
|
||||||
|
}
|
||||||
|
throw ex;
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
commitLock.close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
throw new BusinessException("店铺累计文件并发更新冲突,请稍后重试");
|
||||||
}
|
}
|
||||||
|
|
||||||
private DailyAggregationPreparation prepareDailyAggregation(Long userId,
|
private DailyAggregationPreparation prepareDailyAggregation(Long userId,
|
||||||
@@ -1781,35 +1924,103 @@ public class ShopDataCrawlTaskService {
|
|||||||
"shop-data-crawl-result",
|
"shop-data-crawl-result",
|
||||||
String.valueOf(task.getId()),
|
String.valueOf(task.getId()),
|
||||||
"daily-" + UUID.randomUUID()));
|
"daily-" + UUID.randomUUID()));
|
||||||
File baseXlsx = FileUtil.file(workRoot, "base.xlsx");
|
|
||||||
File outputXlsx = FileUtil.file(workRoot, filename);
|
File outputXlsx = FileUtil.file(workRoot, filename);
|
||||||
try {
|
try {
|
||||||
if (baseDailyFile != null && !blank(existingObjectKey)) {
|
// Task 35:数据层增量模型。整表从成员行(row_payload)累积重建,
|
||||||
try {
|
// 不再读回旧累计对象(readObjectBytes)并整表重写(replaceCountriesWorkbook);
|
||||||
Files.write(baseXlsx.toPath(), ossStorageService.readObjectBytes(existingObjectKey));
|
// 历史成员行无 payload 时按结果快照兜底,兼容旧归档数据。
|
||||||
} catch (Exception ex) {
|
List<ShopDataCrawlResultItemVo> accumulatedItems = buildDailyFileFromData(baseDailyFile, List.of(snapshot));
|
||||||
throw new BusinessException("读取累计文件失败: " + safeMessage(ex));
|
int rowCount = excelAssemblyService.writeWorkbook(outputXlsx, accumulatedItems);
|
||||||
}
|
|
||||||
int rowCount = excelAssemblyService.replaceCountriesWorkbook(baseXlsx, outputXlsx, List.of(snapshot));
|
|
||||||
String objectKey = ossStorageService.uploadResultFile(outputXlsx, MODULE_TYPE);
|
|
||||||
if (blank(objectKey)) {
|
|
||||||
throw new BusinessException("累计文件上传后未返回文件地址");
|
|
||||||
}
|
|
||||||
return new DailyWorkbookArtifact(objectKey, outputXlsx.length(), true, filename, rowCount);
|
|
||||||
}
|
|
||||||
excelAssemblyService.writeWorkbook(outputXlsx, List.of(snapshot));
|
|
||||||
String objectKey = ossStorageService.uploadResultFile(outputXlsx, MODULE_TYPE);
|
String objectKey = ossStorageService.uploadResultFile(outputXlsx, MODULE_TYPE);
|
||||||
if (blank(objectKey)) {
|
if (blank(objectKey)) {
|
||||||
throw new BusinessException("累计文件上传后未返回文件地址");
|
throw new BusinessException("累计文件上传后未返回文件地址");
|
||||||
}
|
}
|
||||||
return new DailyWorkbookArtifact(objectKey, outputXlsx.length(), true, filename, addedRowCount);
|
return new DailyWorkbookArtifact(objectKey, outputXlsx.length(), true, filename, rowCount);
|
||||||
} finally {
|
} finally {
|
||||||
FileUtil.del(baseXlsx);
|
|
||||||
FileUtil.del(outputXlsx);
|
FileUtil.del(outputXlsx);
|
||||||
FileUtil.del(workRoot);
|
FileUtil.del(workRoot);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从数据层累积重建每日累计文件的快照列表:既有成员行按 (createdAt, id) 升序读取,
|
||||||
|
* 优先用行级 payload;payload 缺失(历史数据)时按结果快照兜底;新结果追加在末尾。
|
||||||
|
*/
|
||||||
|
private List<ShopDataCrawlResultItemVo> buildDailyFileFromData(ShopDataCrawlDailyFileEntity baseDailyFile,
|
||||||
|
List<ShopDataCrawlResultItemVo> appended) {
|
||||||
|
List<ShopDataCrawlResultItemVo> accumulated = new ArrayList<>();
|
||||||
|
if (baseDailyFile != null && baseDailyFile.getId() != null) {
|
||||||
|
for (ShopDataCrawlDailyMemberEntity member : sortedDailyMembers(dailyFileService.listMembers(baseDailyFile.getId()))) {
|
||||||
|
ShopDataCrawlResultItemVo item = snapshotFromPayload(member);
|
||||||
|
if (item == null) {
|
||||||
|
FileResultEntity result = fileResultMapper.selectById(member.getResultId());
|
||||||
|
item = result == null ? null : loadSnapshotForDailyMember(result);
|
||||||
|
}
|
||||||
|
if (item == null) {
|
||||||
|
throw new BusinessException("无法读取累计文件中的结果数据,请重试文件任务");
|
||||||
|
}
|
||||||
|
accumulated.add(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (ShopDataCrawlResultItemVo item : appended == null ? List.<ShopDataCrawlResultItemVo>of() : appended) {
|
||||||
|
if (item != null) {
|
||||||
|
accumulated.add(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return accumulated;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ShopDataCrawlResultItemVo snapshotFromPayload(ShopDataCrawlDailyMemberEntity member) {
|
||||||
|
if (member == null || blank(member.getRowPayload())) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return objectMapper.readValue(member.getRowPayload(), ShopDataCrawlResultItemVo.class);
|
||||||
|
} catch (Exception ex) {
|
||||||
|
log.warn("[shop-data-crawl] daily member payload 解析失败 member={} msg={}",
|
||||||
|
member.getId(), safeMessage(ex));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String snapshotPayload(ShopDataCrawlResultItemVo snapshot) {
|
||||||
|
if (snapshot == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return objectMapper.writeValueAsString(snapshot);
|
||||||
|
} catch (Exception ex) {
|
||||||
|
log.warn("[shop-data-crawl] 累计文件成员 payload 序列化失败 result={} msg={}",
|
||||||
|
snapshot.getResultId(), safeMessage(ex));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<ShopDataCrawlDailyMemberEntity> sortedDailyMembers(List<ShopDataCrawlDailyMemberEntity> memberRows) {
|
||||||
|
List<ShopDataCrawlDailyMemberEntity> members = new ArrayList<>(memberRows == null ? List.of() : memberRows);
|
||||||
|
members.removeIf(Objects::isNull);
|
||||||
|
members.sort(Comparator
|
||||||
|
.comparing(ShopDataCrawlDailyMemberEntity::getCreatedAt,
|
||||||
|
Comparator.nullsLast(Comparator.naturalOrder()))
|
||||||
|
.thenComparing(ShopDataCrawlDailyMemberEntity::getId,
|
||||||
|
Comparator.nullsLast(Comparator.naturalOrder())));
|
||||||
|
return members;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 累计文件跨天滚动时,把旧文件成员行(含 row_payload)原样迁到新文件,保留跨天携带的行。 */
|
||||||
|
private void reassignOlderMembers(ShopDataCrawlDailyFileEntity newDailyFile,
|
||||||
|
List<ShopDataCrawlDailyFileEntity> olderFiles) {
|
||||||
|
if (newDailyFile == null || newDailyFile.getId() == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (ShopDataCrawlDailyFileEntity older : olderFiles == null ? List.<ShopDataCrawlDailyFileEntity>of() : olderFiles) {
|
||||||
|
if (older == null || older.getId() == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
dailyFileService.reassignMembers(older.getId(), newDailyFile.getId());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private DailyAggregationResult persistDailyAggregation(FileTaskEntity task,
|
private DailyAggregationResult persistDailyAggregation(FileTaskEntity task,
|
||||||
FileResultEntity row,
|
FileResultEntity row,
|
||||||
Long userId,
|
Long userId,
|
||||||
@@ -1817,14 +2028,19 @@ public class ShopDataCrawlTaskService {
|
|||||||
String shopKeyHash,
|
String shopKeyHash,
|
||||||
LocalDate businessDate,
|
LocalDate businessDate,
|
||||||
DailyAggregationPreparation preparation,
|
DailyAggregationPreparation preparation,
|
||||||
DailyWorkbookArtifact artifact) {
|
DailyWorkbookArtifact artifact,
|
||||||
|
ShopDataCrawlResultItemVo snapshot,
|
||||||
|
ShopDataCrawlDailyFileEntity expectedBase) {
|
||||||
ShopDataCrawlDailyFileEntity dailyFile = dailyFileService.findForUpdate(
|
ShopDataCrawlDailyFileEntity dailyFile = dailyFileService.findForUpdate(
|
||||||
userId, shopKeyHash, businessDate);
|
userId, shopKeyHash, businessDate);
|
||||||
if (handleExistingDailyMembership(row, dailyFile)) {
|
if (handleExistingDailyMembership(row, dailyFile)) {
|
||||||
return new DailyAggregationResult(List.of(), true);
|
return new DailyAggregationResult(List.of(), true);
|
||||||
}
|
}
|
||||||
if (!sameDailyFileState(preparation.dailyFile(), dailyFile)) {
|
if (!Objects.equals(dailyFile == null ? null : dailyFile.getId(),
|
||||||
throw new BusinessException("当天累计文件状态已变化,请重试文件任务");
|
expectedBase == null ? null : expectedBase.getId())
|
||||||
|
|| !Objects.equals(dailyFile == null ? null : dailyFile.getVersion(),
|
||||||
|
expectedBase == null ? null : expectedBase.getVersion())) {
|
||||||
|
throw new DailyStateConflictException();
|
||||||
}
|
}
|
||||||
|
|
||||||
List<ShopDataCrawlDailyFileEntity> olderFiles = dailyFileService.findOlder(
|
List<ShopDataCrawlDailyFileEntity> olderFiles = dailyFileService.findOlder(
|
||||||
@@ -1872,10 +2088,12 @@ public class ShopDataCrawlTaskService {
|
|||||||
} else {
|
} else {
|
||||||
dailyFileService.update(dailyFile);
|
dailyFileService.update(dailyFile);
|
||||||
}
|
}
|
||||||
if (!dailyFileService.addMember(dailyFile.getId(), row.getTaskId(), row.getId())) {
|
if (!dailyFileService.addMemberWithPayload(dailyFile.getId(), row.getTaskId(), row.getId(),
|
||||||
|
snapshotPayload(snapshot))) {
|
||||||
throw new BusinessException("结果已归档,请重试文件任务");
|
throw new BusinessException("结果已归档,请重试文件任务");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
reassignOlderMembers(dailyFile, olderFiles);
|
||||||
for (ShopDataCrawlDailyFileEntity older : olderFiles) {
|
for (ShopDataCrawlDailyFileEntity older : olderFiles) {
|
||||||
dailyFileService.deleteDailyFile(older.getId());
|
dailyFileService.deleteDailyFile(older.getId());
|
||||||
}
|
}
|
||||||
@@ -2172,6 +2390,26 @@ public class ShopDataCrawlTaskService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
deleteTransientResultChunks(job.getTaskId());
|
deleteTransientResultChunks(job.getTaskId());
|
||||||
|
// Task 37:异步文件作业成功钩子——组装作业完成后显式刷新任务状态:
|
||||||
|
// 仍有未完成组装作业则保持 RUNNING,全部完成才进入终态。
|
||||||
|
// (worker 在 markSuccess 后、任务锁内调用本方法,此处刷新与提交是安全的。)
|
||||||
|
if ("SUCCESS".equals(job.getStatus())) {
|
||||||
|
refreshTaskStatusAfterAssembleJob(job.getTaskId());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void refreshTaskStatusAfterAssembleJob(Long taskId) {
|
||||||
|
FileTaskEntity task = fileTaskMapper.selectById(taskId);
|
||||||
|
if (task == null || !MODULE_TYPE.equals(task.getModuleType())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
List<FileResultEntity> rows = listTaskRows(taskId);
|
||||||
|
if (rows.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
updateTaskStatusFromRows(task, rows);
|
||||||
|
fileTaskMapper.updateById(task);
|
||||||
|
taskCacheService.deleteTaskCache(taskId);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void deleteTransientResultChunks(Long taskId) {
|
private void deleteTransientResultChunks(Long taskId) {
|
||||||
@@ -2288,6 +2526,20 @@ public class ShopDataCrawlTaskService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private boolean snapshotJsonHasCountryRows(FileTaskEntity task) {
|
||||||
|
if (task == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for (ShopDataCrawlResultItemVo snapshot : parseTaskSnapshots(task.getResultJson())) {
|
||||||
|
if (snapshot != null && snapshot.getCountryResults() != null
|
||||||
|
&& snapshot.getCountryResults().stream().anyMatch(country ->
|
||||||
|
country != null && country.getItems() != null && !country.getItems().isEmpty())) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
private void persistSnapshotJson(FileTaskEntity task, List<ShopDataCrawlResultItemVo> snapshots) {
|
private void persistSnapshotJson(FileTaskEntity task, List<ShopDataCrawlResultItemVo> snapshots) {
|
||||||
try {
|
try {
|
||||||
task.setResultJson(objectMapper.writeValueAsString(snapshots == null ? List.of() : snapshots));
|
task.setResultJson(objectMapper.writeValueAsString(snapshots == null ? List.of() : snapshots));
|
||||||
@@ -2297,6 +2549,33 @@ public class ShopDataCrawlTaskService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RUNNING 期间只写轻量进度字段(successFileCount/failedFileCount/status/updatedAt),
|
||||||
|
* 避免每次分片接收都序列化并落库完整结果 JSON;仅终态(全部结果行完成)才写完整快照。
|
||||||
|
* 例外:本次提交携带了新国家数据且 resultJson 尚无任何国家行(缓存中断后恢复的增量提交),
|
||||||
|
* 此时将合并后的国家写回 resultJson,保证跨提交的国家累积持久化,任务中断不再丢失已上报国家。
|
||||||
|
*/
|
||||||
|
private void persistProgressOrSnapshot(FileTaskEntity task, List<ShopDataCrawlResultItemVo> snapshots, boolean terminal) {
|
||||||
|
if (terminal) {
|
||||||
|
persistSnapshotJson(task, snapshots);
|
||||||
|
} else if (snapshotCarriesCountryRows(snapshots) && !snapshotJsonHasCountryRows(task)) {
|
||||||
|
persistSnapshotJson(task, snapshots);
|
||||||
|
}
|
||||||
|
fileTaskMapper.updateById(task);
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean snapshotCarriesCountryRows(List<ShopDataCrawlResultItemVo> snapshots) {
|
||||||
|
if (snapshots == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for (ShopDataCrawlResultItemVo snapshot : snapshots) {
|
||||||
|
if (snapshot != null && hasResultRows(snapshot.getCountryResults())) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
private void syncSnapshotTables(FileTaskEntity task, List<ShopDataCrawlResultItemVo> snapshots) {
|
private void syncSnapshotTables(FileTaskEntity task, List<ShopDataCrawlResultItemVo> snapshots) {
|
||||||
List<ShopDataCrawlResultItemVo> safe = snapshots == null ? List.of() : snapshots;
|
List<ShopDataCrawlResultItemVo> safe = snapshots == null ? List.of() : snapshots;
|
||||||
taskResultItemService.replaceTaskSnapshots(task.getId(), MODULE_TYPE, safe, new TaskResultItemService.SnapshotKeyResolver() {
|
taskResultItemService.replaceTaskSnapshots(task.getId(), MODULE_TYPE, safe, new TaskResultItemService.SnapshotKeyResolver() {
|
||||||
@@ -2337,8 +2616,13 @@ public class ShopDataCrawlTaskService {
|
|||||||
item.setCountry(normalizeCountry(source.getCountry()));
|
item.setCountry(normalizeCountry(source.getCountry()));
|
||||||
List<ShopDataCrawlRowDto> rows = new ArrayList<>();
|
List<ShopDataCrawlRowDto> rows = new ArrayList<>();
|
||||||
if (source.getItems() != null) {
|
if (source.getItems() != null) {
|
||||||
|
Set<String> seen = new HashSet<>();
|
||||||
for (ShopDataCrawlRowDto sourceRow : source.getItems()) {
|
for (ShopDataCrawlRowDto sourceRow : source.getItems()) {
|
||||||
if (sourceRow != null && !rowEmpty(sourceRow) && rows.stream().noneMatch(old -> sameRow(old, sourceRow))) {
|
if (sourceRow == null || rowEmpty(sourceRow)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
String key = rowDedupKey(sourceRow);
|
||||||
|
if (key != null && seen.add(key)) {
|
||||||
rows.add(copyRow(sourceRow));
|
rows.add(copyRow(sourceRow));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2349,6 +2633,30 @@ public class ShopDataCrawlTaskService {
|
|||||||
return copy;
|
return copy;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分片是否含可处理数据:与 copyCountryResults 的拷贝语义一致 ——
|
||||||
|
* 任一国家含至少一个非空行(国家名非空白且行内容非空)即视为有数据。
|
||||||
|
*/
|
||||||
|
private boolean hasProcessableChunkData(List<ShopDataCrawlCountryResultDto> results) {
|
||||||
|
if (results == null || results.isEmpty()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for (ShopDataCrawlCountryResultDto source : results) {
|
||||||
|
if (source == null || blank(source.getCountry())) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (source.getItems() == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (ShopDataCrawlRowDto sourceRow : source.getItems()) {
|
||||||
|
if (sourceRow != null && !rowEmpty(sourceRow)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
private ShopDataCrawlRowDto copyRow(ShopDataCrawlRowDto source) {
|
private ShopDataCrawlRowDto copyRow(ShopDataCrawlRowDto source) {
|
||||||
ShopDataCrawlRowDto row = new ShopDataCrawlRowDto();
|
ShopDataCrawlRowDto row = new ShopDataCrawlRowDto();
|
||||||
row.setDate(trim(source.getDate()));
|
row.setDate(trim(source.getDate()));
|
||||||
@@ -2402,13 +2710,25 @@ public class ShopDataCrawlTaskService {
|
|||||||
&& Objects.equals(trim(left.getRecommendedOffer()), trim(right.getRecommendedOffer()));
|
&& Objects.equals(trim(left.getRecommendedOffer()), trim(right.getRecommendedOffer()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 国家结果行稳定去重键:与 sameRow 的 10 字段 trim 比较语义等价,用于 O(1) 去重。 */
|
||||||
|
static String rowDedupKey(ShopDataCrawlRowDto row) {
|
||||||
|
if (row == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return trim(row.getDate()) + ROW_KEY_SEPARATOR + trim(row.getAsin()) + ROW_KEY_SEPARATOR + trim(row.getBrand())
|
||||||
|
+ ROW_KEY_SEPARATOR + trim(row.getCommodityImage()) + ROW_KEY_SEPARATOR + trim(row.getInventorySales())
|
||||||
|
+ ROW_KEY_SEPARATOR + trim(row.getSalesRank()) + ROW_KEY_SEPARATOR + trim(row.getPageViews())
|
||||||
|
+ ROW_KEY_SEPARATOR + trim(row.getUnitsSold()) + ROW_KEY_SEPARATOR + trim(row.getPrice())
|
||||||
|
+ ROW_KEY_SEPARATOR + trim(row.getRecommendedOffer());
|
||||||
|
}
|
||||||
|
|
||||||
private boolean rowEmpty(ShopDataCrawlRowDto row) {
|
private boolean rowEmpty(ShopDataCrawlRowDto row) {
|
||||||
return row == null || (blank(row.getDate()) && blank(row.getAsin()) && blank(row.getBrand()) && blank(row.getCommodityImage()) && blank(row.getInventorySales())
|
return row == null || (blank(row.getDate()) && blank(row.getAsin()) && blank(row.getBrand()) && blank(row.getCommodityImage()) && blank(row.getInventorySales())
|
||||||
&& blank(row.getSalesRank()) && blank(row.getPageViews()) && blank(row.getUnitsSold())
|
&& blank(row.getSalesRank()) && blank(row.getPageViews()) && blank(row.getUnitsSold())
|
||||||
&& blank(row.getPrice()) && blank(row.getRecommendedOffer()));
|
&& blank(row.getPrice()) && blank(row.getRecommendedOffer()));
|
||||||
}
|
}
|
||||||
|
|
||||||
private String trim(String value) {
|
private static String trim(String value) {
|
||||||
return value == null ? "" : value.trim();
|
return value == null ? "" : value.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2445,7 +2765,22 @@ public class ShopDataCrawlTaskService {
|
|||||||
throw new TaskOwnerMismatchException(task == null ? null : task.getId(), operation, owner, currentInstanceId());
|
throw new TaskOwnerMismatchException(task == null ? null : task.getId(), operation, owner, currentInstanceId());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 任务归属实例 id:优先读显式列 owner_instance_id(Task 34 迁移),
|
||||||
|
* 旧任务列缺失时兼容回退到 request_json.ownerInstanceId。
|
||||||
|
*/
|
||||||
|
public String ownerInstanceIdOf(FileTaskEntity task) {
|
||||||
|
if (task != null && !blank(task.getOwnerInstanceId())) {
|
||||||
|
return task.getOwnerInstanceId();
|
||||||
|
}
|
||||||
|
return ownerFromJson(task);
|
||||||
|
}
|
||||||
|
|
||||||
private String ownerFromTask(FileTaskEntity task) {
|
private String ownerFromTask(FileTaskEntity task) {
|
||||||
|
return ownerInstanceIdOf(task);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String ownerFromJson(FileTaskEntity task) {
|
||||||
if (task == null || blank(task.getRequestJson())) return null;
|
if (task == null || blank(task.getRequestJson())) return null;
|
||||||
try {
|
try {
|
||||||
String owner = objectMapper.readTree(task.getRequestJson()).path("ownerInstanceId").asText("");
|
String owner = objectMapper.readTree(task.getRequestJson()).path("ownerInstanceId").asText("");
|
||||||
|
|||||||
+106
@@ -0,0 +1,106 @@
|
|||||||
|
package com.nanri.aiimage.modules.shopdatacrawl.util;
|
||||||
|
|
||||||
|
import com.nanri.aiimage.modules.similarasin.util.SimilarAsinImageEmbedder;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Task 22:店铺 Excel 图片的有界字节缓存。
|
||||||
|
* 以总字节预算 + 条目上限约束图片缓存,超过预算按 FIFO(插入序)淘汰最旧条目;
|
||||||
|
* 单图超过预算时拒绝缓存并计数,embed 阶段对该 url 走原有直接下载兜底。
|
||||||
|
* 与 {@link SimilarAsinImageEmbedder#prefetch(java.util.Collection, Map)} 的
|
||||||
|
* Map 入参兼容,保证组装期缓存内存峰值有界。
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
public class BoundedImageCache extends LinkedHashMap<String, SimilarAsinImageEmbedder.ResizedImage> {
|
||||||
|
|
||||||
|
private final long maxBytes;
|
||||||
|
private final int maxEntries;
|
||||||
|
private long sizeBytes;
|
||||||
|
private long evictionCount;
|
||||||
|
private long rejectedCount;
|
||||||
|
|
||||||
|
public BoundedImageCache(long maxBytes, int maxEntries) {
|
||||||
|
super(16, 0.75f, false);
|
||||||
|
if (maxBytes <= 0) {
|
||||||
|
throw new IllegalArgumentException("maxBytes 必须为正数,实际 " + maxBytes);
|
||||||
|
}
|
||||||
|
if (maxEntries <= 0) {
|
||||||
|
throw new IllegalArgumentException("maxEntries 必须为正数,实际 " + maxEntries);
|
||||||
|
}
|
||||||
|
this.maxBytes = maxBytes;
|
||||||
|
this.maxEntries = maxEntries;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long sizeBytes() {
|
||||||
|
return sizeBytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long evictionCount() {
|
||||||
|
return evictionCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long rejectedCount() {
|
||||||
|
return rejectedCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public SimilarAsinImageEmbedder.ResizedImage put(String key, SimilarAsinImageEmbedder.ResizedImage value) {
|
||||||
|
if (key == null || key.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("image key 不能为空");
|
||||||
|
}
|
||||||
|
if (value == null) {
|
||||||
|
throw new IllegalArgumentException("image value 不能为 null");
|
||||||
|
}
|
||||||
|
if (value.bytes() == null || value.bytes().length > maxBytes) {
|
||||||
|
rejectedCount++;
|
||||||
|
log.debug("[shop-data-crawl][image-cache] reject oversized image bytes={} maxBytes={}",
|
||||||
|
value.bytes() == null ? 0 : value.bytes().length, maxBytes);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (containsKey(key)) {
|
||||||
|
sizeBytes -= super.get(key).bytes().length;
|
||||||
|
}
|
||||||
|
while (!isEmpty() && (sizeBytes + value.bytes().length > maxBytes || size() >= maxEntries)) {
|
||||||
|
evictEldest();
|
||||||
|
}
|
||||||
|
sizeBytes += value.bytes().length;
|
||||||
|
return super.put(key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public SimilarAsinImageEmbedder.ResizedImage putIfAbsent(String key, SimilarAsinImageEmbedder.ResizedImage value) {
|
||||||
|
if (key == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
SimilarAsinImageEmbedder.ResizedImage existing = super.get(key);
|
||||||
|
if (existing != null) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
return put(key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 图片嵌入成功后立即释放缩略图字节副本:移除条目并扣减字节计数,
|
||||||
|
* 使 byte[] 可被 GC 回收。key 缺失/空白/重复释放均安全返回 null。
|
||||||
|
*/
|
||||||
|
public SimilarAsinImageEmbedder.ResizedImage release(String key) {
|
||||||
|
if (key == null || key.isBlank()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
SimilarAsinImageEmbedder.ResizedImage removed = super.remove(key);
|
||||||
|
if (removed != null && removed.bytes() != null) {
|
||||||
|
sizeBytes -= removed.bytes().length;
|
||||||
|
}
|
||||||
|
return removed;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void evictEldest() {
|
||||||
|
Map.Entry<String, SimilarAsinImageEmbedder.ResizedImage> eldest = entrySet().iterator().next();
|
||||||
|
sizeBytes -= eldest.getValue().bytes().length;
|
||||||
|
super.remove(eldest.getKey());
|
||||||
|
evictionCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
+154
@@ -0,0 +1,154 @@
|
|||||||
|
package com.nanri.aiimage.modules.shopdatacrawl.util;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.nanri.aiimage.modules.shopdatacrawl.model.dto.ShopDataCrawlCountryResultDto;
|
||||||
|
import com.nanri.aiimage.modules.shopdatacrawl.model.dto.ShopDataCrawlRowDto;
|
||||||
|
import com.nanri.aiimage.modules.shopdatacrawl.model.vo.ShopDataCrawlResultItemVo;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 店铺抓取性能基线夹具:单店铺 1k/5k 行、多国家、图片成功/失败场景的确定性生成器。
|
||||||
|
* 同一输入必然产生相同输出(幂等);失败场景通过 failedThumbRows 指定前 N 行为无缩略图行。
|
||||||
|
* 上限约束:单次最多 MAX_ROWS 行,防止基线夹具本身造成无界内存增长。
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
public class ShopDataCrawlPerfFixture {
|
||||||
|
|
||||||
|
public static final int MAX_ROWS = 5000;
|
||||||
|
public static final List<String> COUNTRIES = List.of("UK", "DE", "FR", "ES", "IT");
|
||||||
|
private static final String THUMB_HOST = "https://thumb.example/";
|
||||||
|
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
|
public ShopDataCrawlPerfFixture(ObjectMapper objectMapper) {
|
||||||
|
this.objectMapper = objectMapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成单店铺 countryCount 个国家的 rowCount 行抓取结果。
|
||||||
|
* failedThumbRows 指定前 N 行无商品图片(图片失败场景),其余行带确定性占位图 URL。
|
||||||
|
* 图片统计以行内 commodityImage 实际内容为准(非空=成功,空=失败)。
|
||||||
|
*/
|
||||||
|
public List<ShopDataCrawlResultItemVo> generateItems(String shopName, int rowCount, int countryCount,
|
||||||
|
boolean withImages, int failedThumbRows) {
|
||||||
|
if (shopName == null || shopName.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("shopName 不能为空");
|
||||||
|
}
|
||||||
|
if (rowCount < 0 || rowCount > MAX_ROWS) {
|
||||||
|
throw new IllegalArgumentException("rowCount 必须在 [0, " + MAX_ROWS + "] 范围内,实际 " + rowCount);
|
||||||
|
}
|
||||||
|
if (countryCount < 1 || countryCount > COUNTRIES.size()) {
|
||||||
|
throw new IllegalArgumentException("countryCount 必须在 [1, " + COUNTRIES.size() + "] 范围内,实际 " + countryCount);
|
||||||
|
}
|
||||||
|
if (failedThumbRows < 0 || failedThumbRows > rowCount) {
|
||||||
|
throw new IllegalArgumentException("failedThumbRows 必须在 [0, " + rowCount + "] 范围内,实际 " + failedThumbRows);
|
||||||
|
}
|
||||||
|
if (rowCount == 0) {
|
||||||
|
return new ArrayList<>();
|
||||||
|
}
|
||||||
|
List<String> countries = COUNTRIES.subList(0, countryCount);
|
||||||
|
ShopDataCrawlResultItemVo item = new ShopDataCrawlResultItemVo();
|
||||||
|
item.setShopName(shopName);
|
||||||
|
item.setShopId(shopName + ":shop-id");
|
||||||
|
item.setPlatform("Amazon");
|
||||||
|
item.setSuccess(true);
|
||||||
|
item.setTaskStatus("SUCCESS");
|
||||||
|
item.setCountryCodes(countries);
|
||||||
|
List<ShopDataCrawlCountryResultDto> countryResults = new ArrayList<>();
|
||||||
|
for (String country : countries) {
|
||||||
|
ShopDataCrawlCountryResultDto countryResult = new ShopDataCrawlCountryResultDto();
|
||||||
|
countryResult.setCountry(country);
|
||||||
|
countryResult.setItems(new ArrayList<>());
|
||||||
|
countryResults.add(countryResult);
|
||||||
|
}
|
||||||
|
for (int i = 0; i < rowCount; i++) {
|
||||||
|
int rowIndex = i + 1;
|
||||||
|
String country = countries.get(i % countries.size());
|
||||||
|
long seed = (shopName.hashCode() * 31L + country.hashCode() * 31L + rowIndex) & 0x7fffffffL;
|
||||||
|
ShopDataCrawlRowDto row = new ShopDataCrawlRowDto();
|
||||||
|
row.setDate("2026-07-25");
|
||||||
|
row.setAsin(deterministicAsin(seed));
|
||||||
|
row.setBrand("Brand-" + (seed % 100));
|
||||||
|
row.setInventorySales(String.valueOf(1 + seed % 500));
|
||||||
|
row.setSalesRank("#" + (1 + seed % 5000));
|
||||||
|
row.setPageViews(String.valueOf(100 + seed % 9000));
|
||||||
|
row.setUnitsSold(String.valueOf(1 + seed % 200));
|
||||||
|
row.setPrice(String.format("%.2f", 1 + (seed % 9900) / 100.0));
|
||||||
|
row.setRecommendedOffer(String.format("%.2f", 0.5 + (seed % 9900) / 100.0));
|
||||||
|
if (withImages && i >= failedThumbRows) {
|
||||||
|
row.setCommodityImage(THUMB_HOST + row.getAsin() + ".jpg");
|
||||||
|
} else {
|
||||||
|
row.setCommodityImage("");
|
||||||
|
}
|
||||||
|
countryResults.get(i % countries.size()).getItems().add(row);
|
||||||
|
}
|
||||||
|
item.setCountryResults(countryResults);
|
||||||
|
return List.of(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 采样全量行 payload 大小、chunk 划分数与图片成功/失败行数。
|
||||||
|
* withImages 为预期图片模式的提示参数,图片统计始终以行内 commodityImage 实际内容为准。
|
||||||
|
* 序列化失败时向上抛出不产生部分结果。
|
||||||
|
*/
|
||||||
|
public Metrics samplePayload(List<ShopDataCrawlResultItemVo> items, boolean withImages, int chunkSize) {
|
||||||
|
if (items == null) {
|
||||||
|
throw new IllegalArgumentException("items 不能为 null");
|
||||||
|
}
|
||||||
|
if (chunkSize <= 0) {
|
||||||
|
throw new IllegalArgumentException("chunkSize 必须为正数,实际 " + chunkSize);
|
||||||
|
}
|
||||||
|
int rowCount = 0;
|
||||||
|
int urlsWithImages = 0;
|
||||||
|
int failedThumbUrls = 0;
|
||||||
|
for (ShopDataCrawlResultItemVo item : items) {
|
||||||
|
if (item == null || item.getCountryResults() == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (ShopDataCrawlCountryResultDto country : item.getCountryResults()) {
|
||||||
|
if (country == null || country.getItems() == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (ShopDataCrawlRowDto row : country.getItems()) {
|
||||||
|
if (row == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
rowCount++;
|
||||||
|
if (row.getCommodityImage() == null || row.getCommodityImage().isBlank()) {
|
||||||
|
failedThumbUrls++;
|
||||||
|
} else {
|
||||||
|
urlsWithImages++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
int chunkCount = rowCount == 0 ? 0 : (rowCount + chunkSize - 1) / chunkSize;
|
||||||
|
if (rowCount == 0) {
|
||||||
|
return new Metrics(0, 0, 0, 0, 0);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
byte[] bytes = objectMapper.writeValueAsBytes(items);
|
||||||
|
return new Metrics(rowCount, chunkCount, bytes.length, urlsWithImages, failedThumbUrls);
|
||||||
|
} catch (Exception ex) {
|
||||||
|
throw new IllegalStateException("店铺抓取基线 payload 采样序列化失败", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public record Metrics(int rowCount, int chunkCount, long payloadBytes,
|
||||||
|
int urlsWithImages, int failedThumbUrls) {
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String deterministicAsin(long seed) {
|
||||||
|
StringBuilder sb = new StringBuilder("B0");
|
||||||
|
long state = seed;
|
||||||
|
for (int i = 0; i < 8; i++) {
|
||||||
|
state = state * 6364136223846793005L + 1442695040888963407L;
|
||||||
|
int pick = (int) ((state >>> 33) % 36);
|
||||||
|
sb.append(pick < 10 ? (char) ('0' + pick) : (char) ('A' + pick - 10));
|
||||||
|
}
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
+97
@@ -0,0 +1,97 @@
|
|||||||
|
package com.nanri.aiimage.modules.shopdatacrawl.util;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Task 24:店铺图片预取的任务级预算。
|
||||||
|
* 对预取 URL 集合施加三重上限:
|
||||||
|
* - maxUrls:数量上限,去重后按顺序截断;
|
||||||
|
* - maxBytes:字节估算门,预计总量(urlCount × avgBytesPerUrl)超过预算时拒绝预取;
|
||||||
|
* - maxTimeoutSeconds:预取超时上限,按 url 数估算的 deadline(200ms/url)被钳制到该上限。
|
||||||
|
* 被截断/拒绝的 URL 不进入预取,由 embed 阶段按原有兜底链路直接下载。
|
||||||
|
*/
|
||||||
|
public class ShopDataCrawlPrefetchBudget {
|
||||||
|
|
||||||
|
/** 预取 deadline 估算:每 URL 200ms,与 SimilarAsinImageEmbedder 的全局 deadline 计算一致。 */
|
||||||
|
private static final long MILLIS_PER_URL = 200L;
|
||||||
|
private static final long MIN_TIMEOUT_MILLIS = 15_000L;
|
||||||
|
private static final long MAX_TIMEOUT_MILLIS = 120_000L;
|
||||||
|
|
||||||
|
private final int maxUrls;
|
||||||
|
private final long maxBytes;
|
||||||
|
private final long maxTimeoutMillis;
|
||||||
|
|
||||||
|
private ShopDataCrawlPrefetchBudget(int maxUrls, long maxBytes, long maxTimeoutSeconds) {
|
||||||
|
if (maxUrls <= 0) {
|
||||||
|
throw new IllegalArgumentException("maxUrls 必须为正数,实际 " + maxUrls);
|
||||||
|
}
|
||||||
|
if (maxBytes <= 0) {
|
||||||
|
throw new IllegalArgumentException("maxBytes 必须为正数,实际 " + maxBytes);
|
||||||
|
}
|
||||||
|
if (maxTimeoutSeconds <= 0) {
|
||||||
|
throw new IllegalArgumentException("maxTimeoutSeconds 必须为正数,实际 " + maxTimeoutSeconds);
|
||||||
|
}
|
||||||
|
this.maxUrls = maxUrls;
|
||||||
|
this.maxBytes = maxBytes;
|
||||||
|
this.maxTimeoutMillis = maxTimeoutSeconds * 1000L;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ShopDataCrawlPrefetchBudget of(int maxUrls, long maxBytes, long maxTimeoutSeconds) {
|
||||||
|
return new ShopDataCrawlPrefetchBudget(maxUrls, maxBytes, maxTimeoutSeconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 去重(保持顺序)并按数量上限截断;null/空白条目跳过。null 集合返回空列表。 */
|
||||||
|
public List<String> boundedUrls(List<String> urls) {
|
||||||
|
if (urls == null || urls.isEmpty()) {
|
||||||
|
return new ArrayList<>();
|
||||||
|
}
|
||||||
|
LinkedHashSet<String> distinct = new LinkedHashSet<>();
|
||||||
|
for (String url : urls) {
|
||||||
|
if (url == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
String trimmed = url.trim();
|
||||||
|
if (!trimmed.isEmpty()) {
|
||||||
|
distinct.add(trimmed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
List<String> result = new ArrayList<>(Math.min(distinct.size(), maxUrls));
|
||||||
|
int added = 0;
|
||||||
|
for (String url : distinct) {
|
||||||
|
if (added >= maxUrls) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
result.add(url);
|
||||||
|
added++;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 字节估算门:urlCount × avgBytesPerUrl > maxBytes 时拒绝预取。 */
|
||||||
|
public boolean wouldExceedBytes(List<String> urls, long avgBytesPerUrl) {
|
||||||
|
if (urls == null || urls.isEmpty()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (avgBytesPerUrl <= 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return (long) urls.size() * avgBytesPerUrl > maxBytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 预取超时上限:每 URL 200ms,clamp 到 [MIN, maxTimeoutMillis],再钳到全局 [15s,120s]。 */
|
||||||
|
public long timeoutMillisFor(int urlCount) {
|
||||||
|
long estimated = Math.min(MAX_TIMEOUT_MILLIS,
|
||||||
|
Math.max(MIN_TIMEOUT_MILLIS, Math.max(1, urlCount) * MILLIS_PER_URL));
|
||||||
|
return Math.min(estimated, maxTimeoutMillis);
|
||||||
|
}
|
||||||
|
|
||||||
|
public int maxUrls() {
|
||||||
|
return maxUrls;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long maxBytes() {
|
||||||
|
return maxBytes;
|
||||||
|
}
|
||||||
|
}
|
||||||
+13
-6
@@ -5,6 +5,7 @@ import com.fasterxml.jackson.databind.ObjectMapper;
|
|||||||
import com.nanri.aiimage.config.SimilarAsinProperties;
|
import com.nanri.aiimage.config.SimilarAsinProperties;
|
||||||
import com.nanri.aiimage.modules.coze.service.CozeCredentialPoolService;
|
import com.nanri.aiimage.modules.coze.service.CozeCredentialPoolService;
|
||||||
import com.nanri.aiimage.modules.similarasin.model.dto.SimilarAsinResultRowDto;
|
import com.nanri.aiimage.modules.similarasin.model.dto.SimilarAsinResultRowDto;
|
||||||
|
import com.nanri.aiimage.modules.similarasin.util.SimilarAsinLogSupport;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.http.HttpHeaders;
|
import org.springframework.http.HttpHeaders;
|
||||||
@@ -30,11 +31,14 @@ public class SimilarAsinCozeClient {
|
|||||||
|
|
||||||
private static final String MODULE_TYPE = "SIMILAR_ASIN";
|
private static final String MODULE_TYPE = "SIMILAR_ASIN";
|
||||||
private static final MediaType APPLICATION_JSON_UTF8 = new MediaType("application", "json", StandardCharsets.UTF_8);
|
private static final MediaType APPLICATION_JSON_UTF8 = new MediaType("application", "json", StandardCharsets.UTF_8);
|
||||||
|
/** Task 19:history 轮询响应正文采样频率(每 N 次记一次完整正文,其余只记状态)。 */
|
||||||
|
static final long HISTORY_RESPONSE_LOG_EVERY_N = 20L;
|
||||||
|
|
||||||
private final SimilarAsinProperties properties;
|
private final SimilarAsinProperties properties;
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
private final CozeCredentialPoolService cozeCredentialPoolService;
|
private final CozeCredentialPoolService cozeCredentialPoolService;
|
||||||
private final AtomicLong credentialCursor = new AtomicLong();
|
private final AtomicLong credentialCursor = new AtomicLong();
|
||||||
|
private final AtomicLong historyResponseLogCounter = new AtomicLong();
|
||||||
/**
|
/**
|
||||||
* P1-7:单例 RestClient。原 restClient() 每次提交/poll 都新建 SimpleClientHttpRequestFactory + RestClient,
|
* P1-7:单例 RestClient。原 restClient() 每次提交/poll 都新建 SimpleClientHttpRequestFactory + RestClient,
|
||||||
* 几千行任务并发时会反复创建短命对象造成不必要 GC 压力。RestClient 与 SimpleClientHttpRequestFactory
|
* 几千行任务并发时会反复创建短命对象造成不必要 GC 压力。RestClient 与 SimpleClientHttpRequestFactory
|
||||||
@@ -267,10 +271,10 @@ public class SimilarAsinCozeClient {
|
|||||||
body.put("workflow_id", credential.workflowId());
|
body.put("workflow_id", credential.workflowId());
|
||||||
body.put("parameters", parameters);
|
body.put("parameters", parameters);
|
||||||
body.put("is_async", Boolean.TRUE);
|
body.put("is_async", Boolean.TRUE);
|
||||||
log.info("[similar-asin] coze request credential={} url={} body={}",
|
log.debug("[similar-asin] coze request credential={} url={} body={}",
|
||||||
credential.name(),
|
credential.name(),
|
||||||
joinUrl(properties.getCozeBaseUrl(), properties.getCozeWorkflowPath()),
|
joinUrl(properties.getCozeBaseUrl(), properties.getCozeWorkflowPath()),
|
||||||
writeJson(maskCozeRequestBody(body)));
|
SimilarAsinLogSupport.truncate(writeJson(maskCozeRequestBody(body))));
|
||||||
|
|
||||||
RestClient.RequestBodySpec request = restClient().post()
|
RestClient.RequestBodySpec request = restClient().post()
|
||||||
.uri(joinUrl(properties.getCozeBaseUrl(), properties.getCozeWorkflowPath()))
|
.uri(joinUrl(properties.getCozeBaseUrl(), properties.getCozeWorkflowPath()))
|
||||||
@@ -283,9 +287,9 @@ public class SimilarAsinCozeClient {
|
|||||||
return request.exchange((clientRequest, clientResponse) -> {
|
return request.exchange((clientRequest, clientResponse) -> {
|
||||||
byte[] responseBytes = StreamUtils.copyToByteArray(clientResponse.getBody());
|
byte[] responseBytes = StreamUtils.copyToByteArray(clientResponse.getBody());
|
||||||
String responseText = responseBytes.length == 0 ? "" : new String(responseBytes, StandardCharsets.UTF_8);
|
String responseText = responseBytes.length == 0 ? "" : new String(responseBytes, StandardCharsets.UTF_8);
|
||||||
log.info("[similar-asin] coze submit response status={} body={}",
|
log.debug("[similar-asin] coze submit response status={} body={}",
|
||||||
clientResponse.getStatusCode(),
|
clientResponse.getStatusCode(),
|
||||||
responseText);
|
SimilarAsinLogSupport.truncate(responseText));
|
||||||
return responseText;
|
return responseText;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -294,6 +298,7 @@ public class SimilarAsinCozeClient {
|
|||||||
String path = properties.getCozeWorkflowHistoryPath()
|
String path = properties.getCozeWorkflowHistoryPath()
|
||||||
.replace("{workflow_id}", credential.workflowId())
|
.replace("{workflow_id}", credential.workflowId())
|
||||||
.replace("{execute_id}", executeId);
|
.replace("{execute_id}", executeId);
|
||||||
|
long historyLogCounter = historyResponseLogCounter.getAndIncrement();
|
||||||
return restClient().get()
|
return restClient().get()
|
||||||
.uri(joinUrl(properties.getCozeBaseUrl(), path))
|
.uri(joinUrl(properties.getCozeBaseUrl(), path))
|
||||||
.headers(headers -> {
|
.headers(headers -> {
|
||||||
@@ -304,10 +309,12 @@ public class SimilarAsinCozeClient {
|
|||||||
.exchange((clientRequest, clientResponse) -> {
|
.exchange((clientRequest, clientResponse) -> {
|
||||||
byte[] responseBytes = StreamUtils.copyToByteArray(clientResponse.getBody());
|
byte[] responseBytes = StreamUtils.copyToByteArray(clientResponse.getBody());
|
||||||
String responseText = responseBytes.length == 0 ? "" : new String(responseBytes, StandardCharsets.UTF_8);
|
String responseText = responseBytes.length == 0 ? "" : new String(responseBytes, StandardCharsets.UTF_8);
|
||||||
log.info("[similar-asin] coze history response credential={} executeId={} status={} body={}",
|
log.debug("[similar-asin] coze history response credential={} executeId={} status={} body={}",
|
||||||
credential.name(), executeId,
|
credential.name(), executeId,
|
||||||
clientResponse.getStatusCode(),
|
clientResponse.getStatusCode(),
|
||||||
responseText);
|
SimilarAsinLogSupport.shouldLog(historyLogCounter, HISTORY_RESPONSE_LOG_EVERY_N)
|
||||||
|
? SimilarAsinLogSupport.truncate(responseText)
|
||||||
|
: "[sampled out]");
|
||||||
return responseText;
|
return responseText;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-2
@@ -1,5 +1,6 @@
|
|||||||
package com.nanri.aiimage.modules.similarasin.model.dto;
|
package com.nanri.aiimage.modules.similarasin.model.dto;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||||
import com.nanri.aiimage.modules.similarasin.model.vo.SimilarAsinParsedGroupVo;
|
import com.nanri.aiimage.modules.similarasin.model.vo.SimilarAsinParsedGroupVo;
|
||||||
import com.nanri.aiimage.modules.similarasin.model.vo.SimilarAsinParsedRowVo;
|
import com.nanri.aiimage.modules.similarasin.model.vo.SimilarAsinParsedRowVo;
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
@@ -29,12 +30,14 @@ public class SimilarAsinParsedPayloadDto {
|
|||||||
@Schema(description = "Excel 原始表头列表")
|
@Schema(description = "Excel 原始表头列表")
|
||||||
private List<String> headers = new ArrayList<>();
|
private List<String> headers = new ArrayList<>();
|
||||||
|
|
||||||
@Schema(description = "平铺的全量有效行")
|
@Schema(description = "平铺的全量有效行(单一规范行集合,写入侧唯一全量行来源)")
|
||||||
private List<SimilarAsinParsedRowVo> items = new ArrayList<>();
|
private List<SimilarAsinParsedRowVo> items = new ArrayList<>();
|
||||||
|
|
||||||
@Schema(description = "按主数据块分组后的完整数据,每个 group.items 都是需要 Python 抓取的全量子行")
|
@Schema(description = "按主数据块分组后的完整数据,每个 group.items 都是需要 Python 抓取的全量子行")
|
||||||
private List<SimilarAsinParsedGroupVo> groups = new ArrayList<>();
|
private List<SimilarAsinParsedGroupVo> groups = new ArrayList<>();
|
||||||
|
|
||||||
@Schema(description = "全量有效行,用于兼容旧链路与结果文件组装")
|
/** 兼容字段:仅用于反序列化旧版本 payload(items 为空时兜底),写入侧不再输出。 */
|
||||||
|
@JsonInclude(JsonInclude.Include.NON_EMPTY)
|
||||||
|
@Schema(description = "兼容旧链路字段,仅读取旧 payload 时使用;新写入不再输出", hidden = true)
|
||||||
private List<SimilarAsinParsedRowVo> allItems = new ArrayList<>();
|
private List<SimilarAsinParsedRowVo> allItems = new ArrayList<>();
|
||||||
}
|
}
|
||||||
|
|||||||
+17
-1
@@ -1,5 +1,6 @@
|
|||||||
package com.nanri.aiimage.modules.similarasin.model.vo;
|
package com.nanri.aiimage.modules.similarasin.model.vo;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
|
|
||||||
@@ -27,6 +28,21 @@ public class SimilarAsinParsedGroupVo {
|
|||||||
@Schema(description = "分组内行数")
|
@Schema(description = "分组内行数")
|
||||||
private Integer itemCount;
|
private Integer itemCount;
|
||||||
|
|
||||||
@Schema(description = "分组内全部行,顺序与原 Excel 保持一致")
|
/**
|
||||||
|
* 组内行在载荷 items 中的起始下标(含)。写入侧只输出引用,
|
||||||
|
* 行对象仅存在于 items 一次,避免 groups 嵌套复制完整行对象。
|
||||||
|
*/
|
||||||
|
@Schema(description = "组内行在 items 中的起始下标(含)")
|
||||||
|
private Integer startIndex;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 组内行在载荷 items 中的结束下标(不含),半开区间 [startIndex, endIndex)。
|
||||||
|
*/
|
||||||
|
@Schema(description = "组内行在 items 中的结束下标(不含),半开区间 [startIndex, endIndex)")
|
||||||
|
private Integer endIndex;
|
||||||
|
|
||||||
|
/** 兼容字段:旧 payload 内嵌的完整行。新写入不再输出,仅读取旧 payload 时使用。 */
|
||||||
|
@JsonInclude(JsonInclude.Include.NON_EMPTY)
|
||||||
|
@Schema(description = "兼容旧链路字段:旧 payload 内嵌的完整行,新写入不再输出", hidden = true)
|
||||||
private List<SimilarAsinParsedRowVo> items = new ArrayList<>();
|
private List<SimilarAsinParsedRowVo> items = new ArrayList<>();
|
||||||
}
|
}
|
||||||
|
|||||||
+127
-4
@@ -6,6 +6,7 @@ import com.nanri.aiimage.modules.similarasin.util.SimilarAsinImageEmbedder;
|
|||||||
import com.nanri.aiimage.modules.similarasin.util.SimilarAsinImageEmbedder.ResizedImage;
|
import com.nanri.aiimage.modules.similarasin.util.SimilarAsinImageEmbedder.ResizedImage;
|
||||||
import com.nanri.aiimage.modules.task.mapper.TaskImageCacheMapper;
|
import com.nanri.aiimage.modules.task.mapper.TaskImageCacheMapper;
|
||||||
import com.nanri.aiimage.modules.task.model.entity.TaskImageCacheEntity;
|
import com.nanri.aiimage.modules.task.model.entity.TaskImageCacheEntity;
|
||||||
|
import jakarta.annotation.PostConstruct;
|
||||||
import jakarta.annotation.PreDestroy;
|
import jakarta.annotation.PreDestroy;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
@@ -26,6 +27,7 @@ import java.util.concurrent.ConcurrentHashMap;
|
|||||||
import java.util.concurrent.ExecutorService;
|
import java.util.concurrent.ExecutorService;
|
||||||
import java.util.concurrent.Executors;
|
import java.util.concurrent.Executors;
|
||||||
import java.util.concurrent.Future;
|
import java.util.concurrent.Future;
|
||||||
|
import java.util.concurrent.ScheduledExecutorService;
|
||||||
import java.util.concurrent.ThreadFactory;
|
import java.util.concurrent.ThreadFactory;
|
||||||
import java.util.concurrent.TimeUnit;
|
import java.util.concurrent.TimeUnit;
|
||||||
import java.util.concurrent.atomic.AtomicInteger;
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
@@ -71,12 +73,57 @@ public class SimilarAsinImagePrefetchService {
|
|||||||
private final ExecutorService prefetchPool = Executors.newFixedThreadPool(PREFETCH_POOL_SIZE,
|
private final ExecutorService prefetchPool = Executors.newFixedThreadPool(PREFETCH_POOL_SIZE,
|
||||||
namedFactory("similar-asin-prefetch"));
|
namedFactory("similar-asin-prefetch"));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Task 15:last_used_at 异步批量刷新的内存缓冲(按 url_hash 去重)。
|
||||||
|
* lookup 命中不再同步 touchLastUsed,而是先入缓冲;达到阈值立即批量刷新,
|
||||||
|
* 其余由定时任务兜底,把逐图 UPDATE 合并为批量 UPDATE。
|
||||||
|
*/
|
||||||
|
private final Set<String> pendingTouches = java.util.concurrent.ConcurrentHashMap.newKeySet();
|
||||||
|
|
||||||
|
private final ScheduledExecutorService touchFlushScheduler =
|
||||||
|
Executors.newSingleThreadScheduledExecutor(namedFactory("similar-asin-touch-flush"));
|
||||||
|
|
||||||
|
/** Task 15:定时兜底刷新周期(秒)。 */
|
||||||
|
private static final long TOUCH_FLUSH_INTERVAL_SECONDS = 30L;
|
||||||
|
|
||||||
|
@PostConstruct
|
||||||
|
public void startTouchFlushScheduler() {
|
||||||
|
touchFlushScheduler.scheduleWithFixedDelay(this::flushPendingTouches,
|
||||||
|
TOUCH_FLUSH_INTERVAL_SECONDS, TOUCH_FLUSH_INTERVAL_SECONDS, TimeUnit.SECONDS);
|
||||||
|
}
|
||||||
|
|
||||||
@PreDestroy
|
@PreDestroy
|
||||||
public void shutdown() {
|
public void shutdown() {
|
||||||
prefetchPool.shutdownNow();
|
prefetchPool.shutdownNow();
|
||||||
|
touchFlushScheduler.shutdownNow();
|
||||||
|
flushPendingTouches();
|
||||||
inflight.clear();
|
inflight.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Task 15:把缓冲中的 last_used_at 批量刷新到 DB。
|
||||||
|
* 按单批上限分片;失败抛出(由定时任务/调用方决定吞掉或重试),
|
||||||
|
* 成功后缓冲清空,不残留。空缓冲直接返回。
|
||||||
|
*/
|
||||||
|
public void flushPendingTouches() {
|
||||||
|
if (pendingTouches.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
List<String> batch = new ArrayList<>(pendingTouches);
|
||||||
|
for (int start = 0; start < batch.size(); start += CACHE_LOOKUP_BATCH_SIZE) {
|
||||||
|
taskImageCacheMapper.touchLastUsedBatch(batch.subList(start,
|
||||||
|
Math.min(start + CACHE_LOOKUP_BATCH_SIZE, batch.size())));
|
||||||
|
}
|
||||||
|
pendingTouches.removeAll(batch);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void bufferTouch(String urlHash) {
|
||||||
|
pendingTouches.add(urlHash);
|
||||||
|
if (pendingTouches.size() >= properties.getImageCacheTouchFlushThreshold()) {
|
||||||
|
flushPendingTouches();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* P2-11:由 {@code mergeCozeRowsIntoChunk} 调用,把 cozeRows 中的图片 url 异步丢入预热队列。
|
* P2-11:由 {@code mergeCozeRowsIntoChunk} 调用,把 cozeRows 中的图片 url 异步丢入预热队列。
|
||||||
* 同 task 串行入队(用 inflight map 排队),避免多个 batch 同时打爆图片源站。
|
* 同 task 串行入队(用 inflight map 排队),避免多个 batch 同时打爆图片源站。
|
||||||
@@ -144,9 +191,15 @@ public class SimilarAsinImagePrefetchService {
|
|||||||
cachedHashes.addAll(found);
|
cachedHashes.addAll(found);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Task 14:只对实际命中的 url_hash 更新 last_used_at,未命中不 touch。
|
||||||
for (int start = 0; start < allHashes.size(); start += CACHE_LOOKUP_BATCH_SIZE) {
|
for (int start = 0; start < allHashes.size(); start += CACHE_LOOKUP_BATCH_SIZE) {
|
||||||
taskImageCacheMapper.touchLastUsedBatch(allHashes.subList(start,
|
List<String> batch = allHashes.subList(start,
|
||||||
Math.min(start + CACHE_LOOKUP_BATCH_SIZE, allHashes.size())));
|
Math.min(start + CACHE_LOOKUP_BATCH_SIZE, allHashes.size()));
|
||||||
|
List<String> hitBatch = new ArrayList<>(batch);
|
||||||
|
hitBatch.retainAll(cachedHashes);
|
||||||
|
if (!hitBatch.isEmpty()) {
|
||||||
|
taskImageCacheMapper.touchLastUsedBatch(hitBatch);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
hit = cachedHashes.size();
|
hit = cachedHashes.size();
|
||||||
}
|
}
|
||||||
@@ -190,7 +243,77 @@ public class SimilarAsinImagePrefetchService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* P2-11:DB cache 直读入口。命中时同步 touchLastUsed,便于 LRU 清理。
|
* Task 14:批量直读 DB cache 缩略图。一次 IN 查询返回全部命中字节;
|
||||||
|
* last_used_at 只对实际命中的 url_hash 更新(touch 集合 = 命中集合),
|
||||||
|
* 未命中 url 不产生任何 touch。输入按传入顺序返回,未命中为 null。
|
||||||
|
* 失败/开关关闭返回空列表,由调用方走回退路径。
|
||||||
|
*/
|
||||||
|
List<byte[]> lookupBatch(List<String> urls) {
|
||||||
|
List<byte[]> result = new ArrayList<>();
|
||||||
|
if (!properties.isImageDbCacheEnabled()) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
if (urls == null || urls.isEmpty()) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
List<String> hashes = new ArrayList<>(urls.size());
|
||||||
|
for (String url : urls) {
|
||||||
|
if (url == null) {
|
||||||
|
result.add(null);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
String trimmed = url.trim();
|
||||||
|
if (trimmed.isEmpty()) {
|
||||||
|
result.add(null);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
hashes.add(sha256Hex(trimmed));
|
||||||
|
result.add(null);
|
||||||
|
}
|
||||||
|
if (hashes.isEmpty()) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
Map<String, byte[]> bytesByHash = new LinkedHashMap<>();
|
||||||
|
List<String> hitHashes = new ArrayList<>();
|
||||||
|
for (int start = 0; start < hashes.size(); start += CACHE_LOOKUP_BATCH_SIZE) {
|
||||||
|
List<String> batch = hashes.subList(start,
|
||||||
|
Math.min(start + CACHE_LOOKUP_BATCH_SIZE, hashes.size()));
|
||||||
|
List<TaskImageCacheEntity> rows = taskImageCacheMapper.selectBytesByUrlHashes(batch);
|
||||||
|
if (rows == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (TaskImageCacheEntity row : rows) {
|
||||||
|
if (row != null && row.getUrlHash() != null && row.getImageBytes() != null
|
||||||
|
&& row.getImageBytes().length > 0) {
|
||||||
|
if (bytesByHash.putIfAbsent(row.getUrlHash(), row.getImageBytes()) == null) {
|
||||||
|
hitHashes.add(row.getUrlHash());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (int i = 0; i < hashes.size(); i++) {
|
||||||
|
byte[] bytes = bytesByHash.get(hashes.get(i));
|
||||||
|
if (bytes != null) {
|
||||||
|
result.set(i, bytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!hitHashes.isEmpty()) {
|
||||||
|
for (int start = 0; start < hitHashes.size(); start += CACHE_LOOKUP_BATCH_SIZE) {
|
||||||
|
taskImageCacheMapper.touchLastUsedBatch(hitHashes.subList(start,
|
||||||
|
Math.min(start + CACHE_LOOKUP_BATCH_SIZE, hitHashes.size())));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception ex) {
|
||||||
|
log.debug("[similar-asin] prefetch batch lookup failed urls={} err={}", urls.size(), ex.getMessage());
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* P2-11:DB cache 直读入口。命中时先入异步批量刷新缓冲(Task 15),
|
||||||
|
* 由阈值/定时 flush 批量 touchLastUsed,便于 LRU 清理,减少逐图 UPDATE。
|
||||||
* 失败/未命中返回 null,由调用方走回退路径。
|
* 失败/未命中返回 null,由调用方走回退路径。
|
||||||
*/
|
*/
|
||||||
public byte[] lookup(String url) {
|
public byte[] lookup(String url) {
|
||||||
@@ -211,7 +334,7 @@ public class SimilarAsinImagePrefetchService {
|
|||||||
}
|
}
|
||||||
byte[] bytes = taskImageCacheMapper.selectBytesByUrlHash(urlHash);
|
byte[] bytes = taskImageCacheMapper.selectBytesByUrlHash(urlHash);
|
||||||
if (bytes != null && bytes.length > 0) {
|
if (bytes != null && bytes.length > 0) {
|
||||||
taskImageCacheMapper.touchLastUsed(urlHash);
|
bufferTouch(urlHash);
|
||||||
return bytes;
|
return bytes;
|
||||||
}
|
}
|
||||||
} catch (Exception ex) {
|
} catch (Exception ex) {
|
||||||
|
|||||||
+631
-121
@@ -40,6 +40,7 @@ import com.nanri.aiimage.modules.similarasin.model.vo.SimilarAsinTaskItemVo;
|
|||||||
import com.nanri.aiimage.modules.similarasin.util.BoundedImageCache;
|
import com.nanri.aiimage.modules.similarasin.util.BoundedImageCache;
|
||||||
import com.nanri.aiimage.modules.similarasin.util.ExcelCellImageWriter;
|
import com.nanri.aiimage.modules.similarasin.util.ExcelCellImageWriter;
|
||||||
import com.nanri.aiimage.modules.similarasin.util.SimilarAsinImageEmbedder;
|
import com.nanri.aiimage.modules.similarasin.util.SimilarAsinImageEmbedder;
|
||||||
|
import com.nanri.aiimage.modules.similarasin.util.SimilarAsinLogSupport;
|
||||||
import com.nanri.aiimage.modules.file.service.LocalFileStorageService;
|
import com.nanri.aiimage.modules.file.service.LocalFileStorageService;
|
||||||
import com.nanri.aiimage.modules.file.service.oss.OssStorageService;
|
import com.nanri.aiimage.modules.file.service.oss.OssStorageService;
|
||||||
import com.nanri.aiimage.modules.task.mapper.FileResultMapper;
|
import com.nanri.aiimage.modules.task.mapper.FileResultMapper;
|
||||||
@@ -89,7 +90,9 @@ import java.time.Duration;
|
|||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.time.ZoneId;
|
import java.time.ZoneId;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
|
import java.util.Iterator;
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
import java.util.LinkedHashSet;
|
import java.util.LinkedHashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -135,6 +138,8 @@ public class SimilarAsinTaskService {
|
|||||||
private static final String CONTENT_TYPE_XLSX = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
|
private static final String CONTENT_TYPE_XLSX = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
|
||||||
private static final String CONTENT_TYPE_ZIP = "application/zip";
|
private static final String CONTENT_TYPE_ZIP = "application/zip";
|
||||||
private static final int RESULT_ROWS_READ_RETRY_LIMIT = 3;
|
private static final int RESULT_ROWS_READ_RETRY_LIMIT = 3;
|
||||||
|
/** Task 19:Python 回传逐行日志采样频率(每 N 行记一行)。 */
|
||||||
|
private static final long PYTHON_INBOUND_LOG_EVERY_N = 20L;
|
||||||
private static final long RESULT_ROWS_READ_RETRY_DELAY_MS = 500L;
|
private static final long RESULT_ROWS_READ_RETRY_DELAY_MS = 500L;
|
||||||
private static final Duration TASK_LOCK_TTL = Duration.ofMinutes(5);
|
private static final Duration TASK_LOCK_TTL = Duration.ofMinutes(5);
|
||||||
private static final long TASK_LOCK_WAIT_MILLIS = 10000L;
|
private static final long TASK_LOCK_WAIT_MILLIS = 10000L;
|
||||||
@@ -143,6 +148,102 @@ public class SimilarAsinTaskService {
|
|||||||
// SimilarAsinProperties.cozeSubmitLockWaitMillis / cozeSubmitLockRetryDelayMillis,
|
// SimilarAsinProperties.cozeSubmitLockWaitMillis / cozeSubmitLockRetryDelayMillis,
|
||||||
// 由 acquireCozeSubmitLock 在方法内读取,并支持指数退避。
|
// 由 acquireCozeSubmitLock 在方法内读取,并支持指数退避。
|
||||||
private static final int PARSE_RESPONSE_PREVIEW_LIMIT = 100;
|
private static final int PARSE_RESPONSE_PREVIEW_LIMIT = 100;
|
||||||
|
/** 预览行/预览组配置上限:超过该值 clamp,避免响应体无界增长。 */
|
||||||
|
private static final int PARSE_RESPONSE_PREVIEW_LIMIT_MAX = 1000;
|
||||||
|
/**
|
||||||
|
* 解析接口响应预览上限:优先读取配置 parseResponsePreviewLimit。
|
||||||
|
* 0/负值回退默认 100;超过 1000 clamp 到 1000,防止误配导致响应体膨胀。
|
||||||
|
*/
|
||||||
|
private int resolvePreviewLimit() {
|
||||||
|
Integer configured = properties.getParseResponsePreviewLimit();
|
||||||
|
if (configured == null || configured <= 0) {
|
||||||
|
return PARSE_RESPONSE_PREVIEW_LIMIT;
|
||||||
|
}
|
||||||
|
return Math.min(configured, PARSE_RESPONSE_PREVIEW_LIMIT_MAX);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Task 7:单文件大小上限。0/负值回退默认 50MB,防止误配导致解析无界增长。
|
||||||
|
*/
|
||||||
|
private long resolveMaxSourceFileBytes() {
|
||||||
|
Long configured = properties.getMaxSourceFileBytes();
|
||||||
|
if (configured == null || configured <= 0) {
|
||||||
|
return 50L * 1024L * 1024L;
|
||||||
|
}
|
||||||
|
return configured;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Task 7:单次解析最大有效行数。0/负值回退默认 50000,防止任务无界增长。
|
||||||
|
*/
|
||||||
|
private int resolveMaxParseRows() {
|
||||||
|
Integer configured = properties.getMaxParseRows();
|
||||||
|
if (configured == null || configured <= 0) {
|
||||||
|
return 50000;
|
||||||
|
}
|
||||||
|
return configured;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Task 8:单字段最大长度(字符)。0/负值回退默认 2000。
|
||||||
|
*/
|
||||||
|
private int resolveMaxFieldLength() {
|
||||||
|
Integer configured = properties.getMaxFieldLength();
|
||||||
|
if (configured == null || configured <= 0) {
|
||||||
|
return 2000;
|
||||||
|
}
|
||||||
|
return configured;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Task 8:xlsx(zip) 最大条目数。0/负值回退默认 20000。
|
||||||
|
*/
|
||||||
|
private int resolveMaxWorkbookZipEntries() {
|
||||||
|
Integer configured = properties.getMaxWorkbookZipEntries();
|
||||||
|
if (configured == null || configured <= 0) {
|
||||||
|
return 20000;
|
||||||
|
}
|
||||||
|
return configured;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Task 8:xlsx(zip) 解压后总字节数上限。0/负值回退默认 512MB。
|
||||||
|
*/
|
||||||
|
private long resolveMaxWorkbookUncompressedBytes() {
|
||||||
|
Long configured = properties.getMaxWorkbookUncompressedBytes();
|
||||||
|
if (configured == null || configured <= 0) {
|
||||||
|
return 512L * 1024L * 1024L;
|
||||||
|
}
|
||||||
|
return configured;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Task 8:WorkbookFactory 打开前的受控读取。xlsx 本质是 zip,
|
||||||
|
* 先用 ZipFile 中央目录探测条目数与解压总字节(不读取压缩内容),
|
||||||
|
* 超限立即拒绝,避免 zip bomb / 超大工作簿直接进入全量加载。
|
||||||
|
* 非 zip 文件(或损坏文件)由调用方 catch 转业务异常。
|
||||||
|
*/
|
||||||
|
private void probeWorkbookZipBounds(File input, String sourceName) throws Exception {
|
||||||
|
int maxEntries = resolveMaxWorkbookZipEntries();
|
||||||
|
long maxBytes = resolveMaxWorkbookUncompressedBytes();
|
||||||
|
try (java.util.zip.ZipFile zipFile = new java.util.zip.ZipFile(input)) {
|
||||||
|
if (zipFile.size() > maxEntries) {
|
||||||
|
throw new BusinessException("Excel 条目数超过上限: " + zipFile.size() + " entries > " + maxEntries + " entries");
|
||||||
|
}
|
||||||
|
long total = 0;
|
||||||
|
var entries = zipFile.entries();
|
||||||
|
while (entries.hasMoreElements()) {
|
||||||
|
var entry = entries.nextElement();
|
||||||
|
long size = entry.getSize();
|
||||||
|
if (size >= 0) {
|
||||||
|
total += size;
|
||||||
|
if (total > maxBytes) {
|
||||||
|
throw new BusinessException("Excel 解压体积超过上限: " + total + " bytes > " + maxBytes + " bytes");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
/**
|
/**
|
||||||
* P0-2 最小风险变体:poll 调度阶段并发预取 Coze HTTP 结果时,
|
* P0-2 最小风险变体:poll 调度阶段并发预取 Coze HTTP 结果时,
|
||||||
* 控制对单个 Coze 后端的并发度。8 与 cozeTaskExecutor 的 12 并发上限对齐留 4 余量,
|
* 控制对单个 Coze 后端的并发度。8 与 cozeTaskExecutor 的 12 并发上限对齐留 4 余量,
|
||||||
@@ -408,6 +509,11 @@ public class SimilarAsinTaskService {
|
|||||||
if (input == null || !input.exists()) {
|
if (input == null || !input.exists()) {
|
||||||
throw new BusinessException("源文件不存在");
|
throw new BusinessException("源文件不存在");
|
||||||
}
|
}
|
||||||
|
long maxBytes = resolveMaxSourceFileBytes();
|
||||||
|
if (input.length() > maxBytes) {
|
||||||
|
throw new BusinessException("源文件超过大小限制: " + source.getOriginalFilename()
|
||||||
|
+ " (" + input.length() + " bytes > " + maxBytes + " bytes)");
|
||||||
|
}
|
||||||
|
|
||||||
ParsedWorkbook parsed = parseWorkbook(input, source);
|
ParsedWorkbook parsed = parseWorkbook(input, source);
|
||||||
totalRows += parsed.totalRows();
|
totalRows += parsed.totalRows();
|
||||||
@@ -419,6 +525,10 @@ public class SimilarAsinTaskService {
|
|||||||
if (allRows.isEmpty()) {
|
if (allRows.isEmpty()) {
|
||||||
throw new BusinessException("未解析到有效 ASIN 数据");
|
throw new BusinessException("未解析到有效 ASIN 数据");
|
||||||
}
|
}
|
||||||
|
int maxParseRows = resolveMaxParseRows();
|
||||||
|
if (allRows.size() > maxParseRows) {
|
||||||
|
throw new BusinessException("解析行数超过上限: " + allRows.size() + " rows > " + maxParseRows + " rows");
|
||||||
|
}
|
||||||
boolean requestedCategorySwitch = Boolean.TRUE.equals(request.getCategorySwitch());
|
boolean requestedCategorySwitch = Boolean.TRUE.equals(request.getCategorySwitch());
|
||||||
request.setCategorySwitch(requestedCategorySwitch || categoryRetryRequired);
|
request.setCategorySwitch(requestedCategorySwitch || categoryRetryRequired);
|
||||||
if (!requestedCategorySwitch && categoryRetryRequired) {
|
if (!requestedCategorySwitch && categoryRetryRequired) {
|
||||||
@@ -497,8 +607,8 @@ public class SimilarAsinTaskService {
|
|||||||
vo.setAiPrompt(normalize(request.getAiPrompt()));
|
vo.setAiPrompt(normalize(request.getAiPrompt()));
|
||||||
vo.setImgSwitch(Boolean.TRUE.equals(request.getImgSwitch()));
|
vo.setImgSwitch(Boolean.TRUE.equals(request.getImgSwitch()));
|
||||||
vo.setCategorySwitch(Boolean.TRUE.equals(request.getCategorySwitch()));
|
vo.setCategorySwitch(Boolean.TRUE.equals(request.getCategorySwitch()));
|
||||||
vo.setItems(new ArrayList<>(allRows));
|
vo.setItems(buildResponsePreviewRows(allRows));
|
||||||
vo.setGroups(groups);
|
vo.setGroups(buildResponsePreviewGroups(groups, allRows));
|
||||||
long finishedAt = System.nanoTime();
|
long finishedAt = System.nanoTime();
|
||||||
log.info("[similar-asin] parse timing taskId={} files={} rows={} groups={} totalMs={} parseMs={} groupMs={} taskInsertMs={} payloadJsonMs={} payloadStoreMs={} persistMs={} responseMs={}",
|
log.info("[similar-asin] parse timing taskId={} files={} rows={} groups={} totalMs={} parseMs={} groupMs={} taskInsertMs={} payloadJsonMs={} payloadStoreMs={} persistMs={} responseMs={}",
|
||||||
task.getId(),
|
task.getId(),
|
||||||
@@ -938,20 +1048,75 @@ public class SimilarAsinTaskService {
|
|||||||
if (payload == null) {
|
if (payload == null) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
if (payload.getAllItems() != null && !payload.getAllItems().isEmpty()) {
|
return resolveAllRows(payload).size();
|
||||||
return payload.getAllItems().size();
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从解析载荷统一恢复全量行:优先 items(新规范结构),其次 allItems(旧结构),
|
||||||
|
* 最后 groups 展开(最旧结构)。新旧结构均可恢复完整行集合,供 rowCount、
|
||||||
|
* Coze 候选加载与结果文件组装复用。
|
||||||
|
*/
|
||||||
|
static List<SimilarAsinParsedRowVo> resolveAllRows(SimilarAsinParsedPayloadDto payload) {
|
||||||
|
if (payload == null) {
|
||||||
|
return List.of();
|
||||||
}
|
}
|
||||||
if (payload.getItems() != null && !payload.getItems().isEmpty()) {
|
if (containsGroupRefs(payload)) {
|
||||||
return payload.getItems().size();
|
// 新格式:分组携带索引引用,信任引用展开结果(越界/非法区间安全跳过)
|
||||||
|
return expandGroupRefs(payload);
|
||||||
}
|
}
|
||||||
if (payload.getGroups() != null && !payload.getGroups().isEmpty()) {
|
List<SimilarAsinParsedRowVo> rows = payload.getItems();
|
||||||
return payload.getGroups().stream()
|
if (rows == null || rows.isEmpty()) {
|
||||||
.map(SimilarAsinParsedGroupVo::getItems)
|
rows = payload.getAllItems();
|
||||||
|
}
|
||||||
|
if ((rows == null || rows.isEmpty()) && payload.getGroups() != null) {
|
||||||
|
rows = payload.getGroups().stream()
|
||||||
.filter(Objects::nonNull)
|
.filter(Objects::nonNull)
|
||||||
.mapToInt(List::size)
|
.flatMap(group -> group.getItems() == null ? java.util.stream.Stream.empty() : group.getItems().stream())
|
||||||
.sum();
|
.toList();
|
||||||
}
|
}
|
||||||
return 0;
|
return rows == null ? List.of() : rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean containsGroupRefs(SimilarAsinParsedPayloadDto payload) {
|
||||||
|
if (payload.getGroups() == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for (SimilarAsinParsedGroupVo group : payload.getGroups()) {
|
||||||
|
if (group != null && (group.getStartIndex() != null || group.getEndIndex() != null)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Task 6:按分组引用 [startIndex, endIndex) 从 items 展开组内行。
|
||||||
|
* 引用越界或区间非法时安全跳过,不抛异常;展开不修改 payload 内部状态。
|
||||||
|
*/
|
||||||
|
static List<SimilarAsinParsedRowVo> expandGroupRefs(SimilarAsinParsedPayloadDto payload) {
|
||||||
|
if (payload == null || payload.getGroups() == null || payload.getGroups().isEmpty()) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
List<SimilarAsinParsedRowVo> rows = payload.getItems();
|
||||||
|
if (rows == null || rows.isEmpty()) {
|
||||||
|
rows = payload.getAllItems();
|
||||||
|
}
|
||||||
|
if (rows == null || rows.isEmpty()) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
List<SimilarAsinParsedRowVo> expanded = new ArrayList<>();
|
||||||
|
for (SimilarAsinParsedGroupVo group : payload.getGroups()) {
|
||||||
|
if (group == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
int start = group.getStartIndex() == null ? 0 : group.getStartIndex();
|
||||||
|
int end = group.getEndIndex() == null ? 0 : group.getEndIndex();
|
||||||
|
if (start < 0 || end <= start || end > rows.size()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
expanded.addAll(rows.subList(start, end));
|
||||||
|
}
|
||||||
|
return expanded;
|
||||||
}
|
}
|
||||||
|
|
||||||
private PersistSubmittedChunkResult persistSubmittedChunk(PreparedSubmittedChunk prepared) {
|
private PersistSubmittedChunkResult persistSubmittedChunk(PreparedSubmittedChunk prepared) {
|
||||||
@@ -1309,6 +1474,138 @@ public class SimilarAsinTaskService {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Task 9:按 id keyset 批量分页拉取 chunk,保持低内存读取。
|
||||||
|
* 每轮只取 pageSize 条(id > lastId 升序),全部取回后按 chunkIndex 升序合并。
|
||||||
|
* taskId 为 null 安全返回空;pageSize <= 0 回退默认 500;
|
||||||
|
* 中途查询失败抛项目约定异常,不返回半截结果。
|
||||||
|
* 使用 QueryWrapper(列名直写)避免对 lambda 元数据缓存的依赖。
|
||||||
|
*/
|
||||||
|
static List<TaskChunkEntity> loadChunksKeyset(TaskChunkMapper mapper, Long taskId, String moduleType, int pageSize) {
|
||||||
|
if (taskId == null || mapper == null) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
int batch = pageSize > 0 ? pageSize : 500;
|
||||||
|
List<TaskChunkEntity> all = new ArrayList<>();
|
||||||
|
long lastId = 0L;
|
||||||
|
while (true) {
|
||||||
|
List<TaskChunkEntity> page;
|
||||||
|
try {
|
||||||
|
page = mapper.selectList(new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<TaskChunkEntity>()
|
||||||
|
.eq("task_id", taskId)
|
||||||
|
.eq("module_type", moduleType)
|
||||||
|
.gt("id", lastId)
|
||||||
|
.orderByAsc("id")
|
||||||
|
.last("limit " + batch));
|
||||||
|
} catch (Exception ex) {
|
||||||
|
throw new BusinessException("chunk keyset 分页查询失败: " + ex.getMessage(), ex);
|
||||||
|
}
|
||||||
|
if (page == null || page.isEmpty()) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
all.addAll(page);
|
||||||
|
lastId = page.getLast().getId();
|
||||||
|
if (page.size() < batch) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
all.sort(java.util.Comparator.comparing(TaskChunkEntity::getChunkIndex,
|
||||||
|
java.util.Comparator.nullsLast(java.util.Comparator.naturalOrder())));
|
||||||
|
return all;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Task 10:把 chunk 结果行建立成 rowKey → chunkKey 的批量索引。
|
||||||
|
* 供 assignCozeRowsToChunks 使用,把跨 chunk 线性扫描降为 O(1) 查找。
|
||||||
|
* 同一 rowKey 出现在多个 chunk 时保留第一个(putIfAbsent),行为确定。
|
||||||
|
*/
|
||||||
|
Map<String, String> indexRowsByChunkKey(Map<String, Map<String, SimilarAsinResultRowDto>> rowsByChunk) {
|
||||||
|
Map<String, String> index = new java.util.HashMap<>();
|
||||||
|
if (rowsByChunk == null || rowsByChunk.isEmpty()) {
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
for (Map.Entry<String, Map<String, SimilarAsinResultRowDto>> entry : rowsByChunk.entrySet()) {
|
||||||
|
if (entry.getValue() == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (String rowKey : entry.getValue().keySet()) {
|
||||||
|
if (rowKey == null || rowKey.isBlank()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
index.putIfAbsent(rowKey, entry.getKey());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Task 11:Coze 结果合并前按稳定 rowKey 一次性去重。
|
||||||
|
* 优先 rowToken,缺失时回退归一化的 legacy key(id::ASIN::country),
|
||||||
|
* 保留首次出现顺序。合并路径此前对每个重复行都重复 expand/分配/写回,是 O(n²) 热点。
|
||||||
|
*/
|
||||||
|
List<SimilarAsinResultRowDto> dedupeRowsByRowKey(List<SimilarAsinResultRowDto> cozeRows) {
|
||||||
|
List<SimilarAsinResultRowDto> deduped = new ArrayList<>();
|
||||||
|
if (cozeRows == null || cozeRows.isEmpty()) {
|
||||||
|
return deduped;
|
||||||
|
}
|
||||||
|
Set<String> seenKeys = new java.util.HashSet<>();
|
||||||
|
for (SimilarAsinResultRowDto row : cozeRows) {
|
||||||
|
if (row == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
String key = rowKey(row);
|
||||||
|
if (key.isBlank() || !seenKeys.add(key)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
deduped.add(row);
|
||||||
|
}
|
||||||
|
return deduped;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Task 10:基于 rowKey 索引为 coze 回传行分配归属 chunk。
|
||||||
|
* 命中索引 → 归属该 chunk;未命中且有有效 fallback(chunkScopeHash + chunkIndex)
|
||||||
|
* 且 fallback chunk 存在 → 归属 fallback;否则进 orphan 列表。
|
||||||
|
* 与原实现逐 chunk 线性扫描语义完全一致,但每个行查找降为 O(1)。
|
||||||
|
*/
|
||||||
|
Map<String, Map<String, SimilarAsinResultRowDto>> assignCozeRowsToChunks(
|
||||||
|
Map<String, Map<String, SimilarAsinResultRowDto>> rowsByChunk,
|
||||||
|
List<SimilarAsinResultRowDto> cozeRows,
|
||||||
|
Map<String, String> rowKeyIndex,
|
||||||
|
String chunkScopeHash,
|
||||||
|
Integer chunkIndex,
|
||||||
|
List<SimilarAsinResultRowDto> orphans) {
|
||||||
|
Map<String, Map<String, SimilarAsinResultRowDto>> mergeRowsByChunk = new LinkedHashMap<>();
|
||||||
|
if (cozeRows == null || cozeRows.isEmpty() || orphans == null) {
|
||||||
|
return mergeRowsByChunk;
|
||||||
|
}
|
||||||
|
String fallbackKey = chunkScopeHash != null && !chunkScopeHash.isBlank() && chunkIndex != null
|
||||||
|
? chunkStorageKey(chunkScopeHash, chunkIndex) : null;
|
||||||
|
boolean fallbackValid = fallbackKey != null && rowsByChunk != null && rowsByChunk.containsKey(fallbackKey);
|
||||||
|
for (SimilarAsinResultRowDto expandedRow : cozeRows) {
|
||||||
|
if (expandedRow == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
String rowKey = rowKey(expandedRow);
|
||||||
|
if (rowKey.isBlank()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
String chunkKey = rowKeyIndex == null ? null : rowKeyIndex.get(rowKey);
|
||||||
|
if (chunkKey != null) {
|
||||||
|
mergeRowsByChunk.computeIfAbsent(chunkKey, ignored -> new LinkedHashMap<>())
|
||||||
|
.put(rowKey, expandedRow);
|
||||||
|
} else if (fallbackValid) {
|
||||||
|
mergeRowsByChunk.computeIfAbsent(fallbackKey, ignored -> new LinkedHashMap<>())
|
||||||
|
.put(rowKey, expandedRow);
|
||||||
|
} else {
|
||||||
|
orphans.add(expandedRow);
|
||||||
|
log.error("[similar-asin] coze row has no submitted chunk rowKey={} asin={} country={}",
|
||||||
|
rowKey, expandedRow.getAsin(), expandedRow.getCountry());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return mergeRowsByChunk;
|
||||||
|
}
|
||||||
|
|
||||||
private void applyCozeToPersistedChunks(FileTaskEntity task, Runnable progressHook) {
|
private void applyCozeToPersistedChunks(FileTaskEntity task, Runnable progressHook) {
|
||||||
if (task == null || task.getId() == null) {
|
if (task == null || task.getId() == null) {
|
||||||
return;
|
return;
|
||||||
@@ -1351,14 +1648,42 @@ public class SimilarAsinTaskService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
Map<String, SimilarAsinResultRowDto> persistedRows = readChunkRows(chunk);
|
Map<String, SimilarAsinResultRowDto> persistedRows = readChunkRows(chunk);
|
||||||
|
int mergedRowCount = persistedRows.size() + rows.size();
|
||||||
|
// Task 13:单次合并后总行数超过上限时,从最旧行(存量优先)开始降级到 orphan 兜底,
|
||||||
|
// chunk 保持在上限内不无界增长;assemble 阶段 putIfAbsent 合并回结果不丢数据。
|
||||||
|
if (mergedRowCount > getChunkMergeMaxRows()) {
|
||||||
|
splitChunkMergeOverflow(taskId, persistedRows, rows, chunk, mergedRowCount - getChunkMergeMaxRows());
|
||||||
|
if (rows.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
for (SimilarAsinResultRowDto row : rows) {
|
for (SimilarAsinResultRowDto row : rows) {
|
||||||
persistedRows.put(rowKey(row), row);
|
persistedRows.put(rowKey(row), row);
|
||||||
}
|
}
|
||||||
String payloadJson = writeJson(new ArrayList<>(persistedRows.values()), "相似ASIN分片载荷合并失败");
|
String payloadJson = writeJson(new ArrayList<>(persistedRows.values()), "相似ASIN分片载荷合并失败");
|
||||||
|
// Task 13:合并后 payload 字节超过上限时,从最旧行开始降级到 orphan 兜底;
|
||||||
|
// 降到只剩一行仍超上限时抛异常拒绝合并,防止无界 payload。
|
||||||
|
long payloadBytes = payloadJson.getBytes(StandardCharsets.UTF_8).length;
|
||||||
|
if (payloadBytes > getChunkMergePayloadMaxBytes()) {
|
||||||
|
demoteRowsToOrphan(taskId, persistedRows, payloadBytes - getChunkMergePayloadMaxBytes());
|
||||||
|
payloadJson = writeJson(new ArrayList<>(persistedRows.values()), "相似ASIN分片载荷合并失败");
|
||||||
|
payloadBytes = payloadJson.getBytes(StandardCharsets.UTF_8).length;
|
||||||
|
if (payloadBytes > getChunkMergePayloadMaxBytes() && persistedRows.size() <= 1) {
|
||||||
|
throw new BusinessException("相似ASIN分片载荷超字节上限 taskId=" + taskId
|
||||||
|
+ " scopeHash=" + scopeHash + " chunk=" + chunkIndex
|
||||||
|
+ " bytes=" + payloadBytes + " limit=" + getChunkMergePayloadMaxBytes());
|
||||||
|
}
|
||||||
|
}
|
||||||
String oldPayload = chunk.getPayloadJson();
|
String oldPayload = chunk.getPayloadJson();
|
||||||
String oldPayloadHash = chunk.getPayloadHash();
|
String oldPayloadHash = chunk.getPayloadHash();
|
||||||
String newPayloadHash = DigestUtil.sha256Hex(payloadJson);
|
String newPayloadHash = DigestUtil.sha256Hex(payloadJson);
|
||||||
String storedPayload = transientPayloadStorageService.storeChunkPayloadVersioned(MODULE_TYPE, taskId, scopeHash, chunkIndex, payloadJson);
|
final String storedPayload;
|
||||||
|
try {
|
||||||
|
storedPayload = transientPayloadStorageService.storeChunkPayloadVersioned(MODULE_TYPE, taskId, scopeHash, chunkIndex, payloadJson);
|
||||||
|
} catch (Exception storeEx) {
|
||||||
|
throw new BusinessException("相似ASIN分片载荷存储失败 taskId=" + taskId + " chunk=" + chunkIndex
|
||||||
|
+ ": " + (storeEx.getMessage() == null ? "" : storeEx.getMessage()), storeEx);
|
||||||
|
}
|
||||||
int updated = taskChunkMapper.update(null, new LambdaUpdateWrapper<TaskChunkEntity>()
|
int updated = taskChunkMapper.update(null, new LambdaUpdateWrapper<TaskChunkEntity>()
|
||||||
.eq(TaskChunkEntity::getId, chunk.getId())
|
.eq(TaskChunkEntity::getId, chunk.getId())
|
||||||
.eq(TaskChunkEntity::getPayloadHash, oldPayloadHash)
|
.eq(TaskChunkEntity::getPayloadHash, oldPayloadHash)
|
||||||
@@ -1380,6 +1705,82 @@ public class SimilarAsinTaskService {
|
|||||||
throw new IllegalStateException("相似ASIN分片载荷更新失败");
|
throw new IllegalStateException("相似ASIN分片载荷更新失败");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private int getChunkMergeMaxRows() {
|
||||||
|
int limit = properties.getChunkMergeMaxRows();
|
||||||
|
return limit > 0 ? limit : 50000;
|
||||||
|
}
|
||||||
|
|
||||||
|
private long getChunkMergePayloadMaxBytes() {
|
||||||
|
long limit = properties.getChunkMergePayloadMaxBytes();
|
||||||
|
return limit > 0 ? limit : 16L * 1024L * 1024L;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Task 13:按行数上限从最旧行开始降级到 orphan 兜底,保证合并后 chunk 行数不超过上限。
|
||||||
|
* 最旧优先:先降级存量行(LinkedHashMap 表头),不够再从新增行表头补齐;
|
||||||
|
* 传入的 rows 会被就地修改(保留未降级部分)。降级失败仅记日志,不阻断合并主流程。
|
||||||
|
*/
|
||||||
|
private void splitChunkMergeOverflow(Long taskId, Map<String, SimilarAsinResultRowDto> persistedRows,
|
||||||
|
List<SimilarAsinResultRowDto> rows, TaskChunkEntity chunk,
|
||||||
|
int demoteCount) {
|
||||||
|
if (demoteCount <= 0 || rows.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
List<SimilarAsinResultRowDto> demoted = new ArrayList<>();
|
||||||
|
while (demoted.size() < demoteCount && !persistedRows.isEmpty()) {
|
||||||
|
String oldestKey = persistedRows.keySet().iterator().next();
|
||||||
|
SimilarAsinResultRowDto removed = persistedRows.remove(oldestKey);
|
||||||
|
if (removed != null) {
|
||||||
|
demoted.add(removed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (Iterator<SimilarAsinResultRowDto> it = rows.iterator(); it.hasNext() && demoted.size() < demoteCount; ) {
|
||||||
|
SimilarAsinResultRowDto row = it.next();
|
||||||
|
if (row != null) {
|
||||||
|
demoted.add(row);
|
||||||
|
it.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
persistOrphanCozeRows(taskId, demoted);
|
||||||
|
log.warn("[similar-asin] chunk merge row-limit exceeded taskId={} chunk={} mergedRows={} limit={} demoted={}",
|
||||||
|
taskId, chunk.getChunkIndex(), mergedRowCountOf(persistedRows, rows), getChunkMergeMaxRows(), demoted.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Task 13:按字节上限从最旧行(LinkedHashMap 表头)开始降级到 orphan 兜底,
|
||||||
|
* 直到 payload 字节不超过上限或只剩一行;降级失败仅记日志,不阻断合并主流程。
|
||||||
|
*/
|
||||||
|
private void demoteRowsToOrphan(Long taskId, Map<String, SimilarAsinResultRowDto> persistedRows, long excessBytes) {
|
||||||
|
List<SimilarAsinResultRowDto> demoted = new ArrayList<>();
|
||||||
|
long releasedBytes = 0L;
|
||||||
|
while (persistedRows.size() > 1 && releasedBytes < excessBytes) {
|
||||||
|
String oldestKey = persistedRows.keySet().iterator().next();
|
||||||
|
SimilarAsinResultRowDto removed = persistedRows.remove(oldestKey);
|
||||||
|
if (removed != null) {
|
||||||
|
demoted.add(removed);
|
||||||
|
releasedBytes += estimateRowBytes(oldestKey, removed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!demoted.isEmpty()) {
|
||||||
|
persistOrphanCozeRows(taskId, demoted);
|
||||||
|
log.warn("[similar-asin] chunk merge byte-limit exceeded taskId={} rows={} demoted={} releasedBytes={}",
|
||||||
|
taskId, demoted.size(), demoted.size(), releasedBytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int mergedRowCountOf(Map<String, SimilarAsinResultRowDto> persistedRows, List<SimilarAsinResultRowDto> rows) {
|
||||||
|
return persistedRows.size() + rows.size();
|
||||||
|
}
|
||||||
|
|
||||||
|
private long estimateRowBytes(String rowKey, SimilarAsinResultRowDto row) {
|
||||||
|
try {
|
||||||
|
String json = objectMapper.writeValueAsString(row);
|
||||||
|
return json == null ? 128L : json.getBytes(StandardCharsets.UTF_8).length;
|
||||||
|
} catch (Exception ex) {
|
||||||
|
return 128L + (rowKey == null ? 0 : rowKey.getBytes(StandardCharsets.UTF_8).length);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private List<SimilarAsinResultRowDto> expandRows(List<SimilarAsinResultRowDto> rows,
|
private List<SimilarAsinResultRowDto> expandRows(List<SimilarAsinResultRowDto> rows,
|
||||||
Map<String, List<SimilarAsinParsedRowVo>> allRowsByBaseId) {
|
Map<String, List<SimilarAsinParsedRowVo>> allRowsByBaseId) {
|
||||||
if (rows == null || rows.isEmpty()) {
|
if (rows == null || rows.isEmpty()) {
|
||||||
@@ -1399,17 +1800,7 @@ public class SimilarAsinTaskService {
|
|||||||
private Map<String, List<SimilarAsinParsedRowVo>> loadAllRowsByBaseId(FileTaskEntity task) {
|
private Map<String, List<SimilarAsinParsedRowVo>> loadAllRowsByBaseId(FileTaskEntity task) {
|
||||||
try {
|
try {
|
||||||
SimilarAsinParsedPayloadDto payload = readParsedPayload(task);
|
SimilarAsinParsedPayloadDto payload = readParsedPayload(task);
|
||||||
List<SimilarAsinParsedRowVo> rows = payload.getAllItems();
|
return groupRowsByBaseId(resolveAllRows(payload));
|
||||||
if (rows == null || rows.isEmpty()) {
|
|
||||||
rows = payload.getItems();
|
|
||||||
}
|
|
||||||
if ((rows == null || rows.isEmpty()) && payload.getGroups() != null) {
|
|
||||||
rows = payload.getGroups().stream()
|
|
||||||
.filter(Objects::nonNull)
|
|
||||||
.flatMap(group -> group.getItems() == null ? java.util.stream.Stream.empty() : group.getItems().stream())
|
|
||||||
.toList();
|
|
||||||
}
|
|
||||||
return groupRowsByBaseId(rows);
|
|
||||||
} catch (Exception ex) {
|
} catch (Exception ex) {
|
||||||
log.warn("[similar-asin] read all rows failed taskId={} err={}", task.getId(), ex.getMessage());
|
log.warn("[similar-asin] read all rows failed taskId={} err={}", task.getId(), ex.getMessage());
|
||||||
return new LinkedHashMap<>();
|
return new LinkedHashMap<>();
|
||||||
@@ -1449,8 +1840,15 @@ public class SimilarAsinTaskService {
|
|||||||
return candidates;
|
return candidates;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Task 6:分组数据改为索引/范围引用。组内行在原 items 中必然连续
|
||||||
|
* (parseWorkbook 按行遍历、同 baseId 块连续收集),因此只需要
|
||||||
|
* [startIndex, endIndex) 半开区间即可唯一定位组内行,
|
||||||
|
* 行对象仅存在于 items 一次,避免 groups 嵌套复制完整行对象。
|
||||||
|
*/
|
||||||
private List<SimilarAsinParsedGroupVo> buildParsedGroups(List<SimilarAsinParsedRowVo> rows) {
|
private List<SimilarAsinParsedGroupVo> buildParsedGroups(List<SimilarAsinParsedRowVo> rows) {
|
||||||
List<SimilarAsinParsedGroupVo> groups = new ArrayList<>();
|
List<SimilarAsinParsedGroupVo> groups = new ArrayList<>();
|
||||||
|
int cursor = 0;
|
||||||
for (List<SimilarAsinParsedRowVo> siblings : groupRowsByBaseId(rows).values()) {
|
for (List<SimilarAsinParsedRowVo> siblings : groupRowsByBaseId(rows).values()) {
|
||||||
if (siblings == null || siblings.isEmpty()) {
|
if (siblings == null || siblings.isEmpty()) {
|
||||||
continue;
|
continue;
|
||||||
@@ -1463,17 +1861,55 @@ public class SimilarAsinTaskService {
|
|||||||
group.setBaseId(baseId(first.getDisplayId()));
|
group.setBaseId(baseId(first.getDisplayId()));
|
||||||
group.setDisplayId(firstNonBlank(first.getDisplayId(), first.getSourceId()));
|
group.setDisplayId(firstNonBlank(first.getDisplayId(), first.getSourceId()));
|
||||||
group.setItemCount(siblings.size());
|
group.setItemCount(siblings.size());
|
||||||
group.setItems(new ArrayList<>(siblings));
|
int end = cursor + siblings.size();
|
||||||
|
group.setStartIndex(cursor);
|
||||||
|
group.setEndIndex(end);
|
||||||
|
cursor = end;
|
||||||
groups.add(group);
|
groups.add(group);
|
||||||
}
|
}
|
||||||
return groups;
|
return groups;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private List<SimilarAsinParsedGroupVo> buildResponsePreviewGroups(List<SimilarAsinParsedGroupVo> groups, List<SimilarAsinParsedRowVo> allRows) {
|
||||||
|
if (groups == null || groups.isEmpty()) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
int limit = Math.min(resolvePreviewLimit(), groups.size());
|
||||||
|
List<SimilarAsinParsedGroupVo> preview = new ArrayList<>(limit);
|
||||||
|
for (int i = 0; i < limit; i++) {
|
||||||
|
SimilarAsinParsedGroupVo group = groups.get(i);
|
||||||
|
if (group == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
SimilarAsinParsedGroupVo copy = new SimilarAsinParsedGroupVo();
|
||||||
|
copy.setSourceFileKey(group.getSourceFileKey());
|
||||||
|
copy.setSourceFilename(group.getSourceFilename());
|
||||||
|
copy.setGroupKey(group.getGroupKey());
|
||||||
|
copy.setBaseId(group.getBaseId());
|
||||||
|
copy.setDisplayId(group.getDisplayId());
|
||||||
|
copy.setItemCount(group.getItemCount());
|
||||||
|
copy.setStartIndex(group.getStartIndex());
|
||||||
|
copy.setEndIndex(group.getEndIndex());
|
||||||
|
// 响应组携带内嵌预览行(切片自全量行),不暴露索引引用语义
|
||||||
|
int start = group.getStartIndex() == null ? 0 : group.getStartIndex();
|
||||||
|
int end = group.getEndIndex() == null ? 0 : group.getEndIndex();
|
||||||
|
if (start >= 0 && end > start && end <= allRows.size()) {
|
||||||
|
copy.setItems(buildResponsePreviewRows(allRows.subList(start, end)));
|
||||||
|
} else if (group.getItems() != null && !group.getItems().isEmpty()) {
|
||||||
|
copy.setItems(buildResponsePreviewRows(group.getItems()));
|
||||||
|
} else {
|
||||||
|
copy.setItems(List.of());
|
||||||
|
}
|
||||||
|
preview.add(copy);
|
||||||
|
}
|
||||||
|
return preview;
|
||||||
|
}
|
||||||
|
|
||||||
private List<SimilarAsinParsedRowVo> buildResponsePreviewRows(List<SimilarAsinParsedRowVo> rows) {
|
private List<SimilarAsinParsedRowVo> buildResponsePreviewRows(List<SimilarAsinParsedRowVo> rows) {
|
||||||
if (rows == null || rows.isEmpty()) {
|
if (rows == null || rows.isEmpty()) {
|
||||||
return List.of();
|
return List.of();
|
||||||
}
|
}
|
||||||
int limit = Math.min(PARSE_RESPONSE_PREVIEW_LIMIT, rows.size());
|
int limit = Math.min(resolvePreviewLimit(), rows.size());
|
||||||
List<SimilarAsinParsedRowVo> preview = new ArrayList<>(limit);
|
List<SimilarAsinParsedRowVo> preview = new ArrayList<>(limit);
|
||||||
for (int i = 0; i < limit; i++) {
|
for (int i = 0; i < limit; i++) {
|
||||||
preview.add(copyPreviewRow(rows.get(i)));
|
preview.add(copyPreviewRow(rows.get(i)));
|
||||||
@@ -1549,21 +1985,25 @@ public class SimilarAsinTaskService {
|
|||||||
* 打印 Python 端回传给 Java 的每一行 row 关键字段,确认 url(主图)/ urls(同类商品图)/ title / sku
|
* 打印 Python 端回传给 Java 的每一行 row 关键字段,确认 url(主图)/ urls(同类商品图)/ title / sku
|
||||||
* 是否按预期到达。该日志与 SimilarAsinCozeClient 的 coze items diff 日志成对,
|
* 是否按预期到达。该日志与 SimilarAsinCozeClient 的 coze items diff 日志成对,
|
||||||
* 便于排查"Python 回传了什么、Java 又把什么发到 Coze"。
|
* 便于排查"Python 回传了什么、Java 又把什么发到 Coze"。
|
||||||
|
* Task 19:改为 DEBUG 级别并按行采样(每 20 行记一行),减少大任务日志量。
|
||||||
*/
|
*/
|
||||||
private void logPythonInboundRows(List<SimilarAsinResultRowDto> rows) {
|
private void logPythonInboundRows(List<SimilarAsinResultRowDto> rows) {
|
||||||
if (rows == null || rows.isEmpty()) {
|
if (rows == null || rows.isEmpty()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
log.info("[similar-asin] python inbound start size={}", rows.size());
|
log.debug("[similar-asin] python inbound start size={}", rows.size());
|
||||||
for (int i = 0; i < rows.size(); i++) {
|
for (int i = 0; i < rows.size(); i++) {
|
||||||
SimilarAsinResultRowDto row = rows.get(i);
|
SimilarAsinResultRowDto row = rows.get(i);
|
||||||
if (row == null) {
|
if (row == null) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (!SimilarAsinLogSupport.shouldLog(i, PYTHON_INBOUND_LOG_EVERY_N)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
String url = row.getUrl();
|
String url = row.getUrl();
|
||||||
List<String> urls = row.getUrls();
|
List<String> urls = row.getUrls();
|
||||||
List<SimilarAsinResultRowDto.AlibabaItem> alibaba = row.getAlibaba();
|
List<SimilarAsinResultRowDto.AlibabaItem> alibaba = row.getAlibaba();
|
||||||
log.info("[similar-asin] python inbound idx={} groupKey={} rowToken={} id={} asin={} country={} title={} sku={} price={} url={} urlsSize={} alibabaSize={} urlsHead={} urlsTail={}",
|
log.debug("[similar-asin] python inbound idx={} groupKey={} rowToken={} id={} asin={} country={} title={} sku={} price={} url={} urlsSize={} alibabaSize={} urlsHead={} urlsTail={}",
|
||||||
i,
|
i,
|
||||||
normalize(row.getGroupKey()),
|
normalize(row.getGroupKey()),
|
||||||
normalize(row.getRowToken()),
|
normalize(row.getRowToken()),
|
||||||
@@ -2289,6 +2729,17 @@ public class SimilarAsinTaskService {
|
|||||||
if (!emptyResultMessage.isBlank()) {
|
if (!emptyResultMessage.isBlank()) {
|
||||||
throw new IllegalStateException(emptyResultMessage);
|
throw new IllegalStateException(emptyResultMessage);
|
||||||
}
|
}
|
||||||
|
// Task 12:同步 immediate DONE 结果也走缓冲(原立即 merge),由 finalize/assemble 前
|
||||||
|
// 一次性 flush 合并到 chunk,减少 chunk payload 频繁读写。先落一条 DONE state 承载
|
||||||
|
// 缓冲 pointer;缓冲关闭/失败/重复时回退立即 merge,结果不丢失。
|
||||||
|
if (isCozeResultBufferEnabled()) {
|
||||||
|
TaskScopeStateEntity doneState = persistImmediateCozeDoneState(task, result, job, batchRows,
|
||||||
|
batchScopeKey, batchScopeHash, batchIndex, batchTotal, submit.credentialName());
|
||||||
|
if (doneState != null) {
|
||||||
|
bufferCozeRowsOrMerge(doneState, readCozeBatchContext(doneState), cozeRows, task, allRowsByBaseId);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
mergeCozeRowsIntoChunk(task, null, null, cozeRows, allRowsByBaseId);
|
mergeCozeRowsIntoChunk(task, null, null, cozeRows, allRowsByBaseId);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -2524,31 +2975,23 @@ public class SimilarAsinTaskService {
|
|||||||
if (!failureMessage.isBlank()) {
|
if (!failureMessage.isBlank()) {
|
||||||
cozeRows = cozeClient.markRowsFailed(batchRows, failureMessage);
|
cozeRows = cozeClient.markRowsFailed(batchRows, failureMessage);
|
||||||
}
|
}
|
||||||
// P0-3:仅"DONE 且 batchTotal>1 且 feature toggle 开启"时缓冲 cozeRows 到 transient storage,
|
// Task 12:DONE 结果统一走 bufferCozeRowsOrMerge(原 P0-3 仅 poll 且 batchTotal>1 缓冲;
|
||||||
// 由 finalize 阶段一次性合并到 chunk。失败 batch / 单 batch 任务保留原立即 merge 路径。
|
// 现单 batch、submit/retry 同步 immediate 结果也缓冲),缓冲失败回退立即 merge,
|
||||||
boolean buffered = false;
|
// flush 在 finalize/assemble 前一次性合并到 chunk,结果不丢失。
|
||||||
if (failureMessage.isBlank()
|
// 失败行(markRowsFailed)保持立即 merge 语义不变。
|
||||||
&& isCozeResultBufferEnabled()
|
if (failureMessage.isBlank()) {
|
||||||
&& context.batchTotal() != null && context.batchTotal() > 1
|
if (cozeRows != null && !cozeRows.isEmpty()) {
|
||||||
&& cozeRows != null && !cozeRows.isEmpty()) {
|
FileTaskEntity pollTask = taskForPoll(state.getTaskId());
|
||||||
CozeBatchContext bufferedContext = bufferCozeResultForFlush(state, context, cozeRows);
|
bufferCozeRowsOrMerge(state, context, cozeRows, pollTask,
|
||||||
if (bufferedContext != null
|
pollTask == null ? Map.of() : allRowsByBaseIdForPoll(pollTask));
|
||||||
&& bufferedContext.resultPayloadPointer() != null
|
|
||||||
&& !bufferedContext.resultPayloadPointer().isBlank()) {
|
|
||||||
context = bufferedContext;
|
|
||||||
buffered = true;
|
|
||||||
}
|
}
|
||||||
}
|
} else {
|
||||||
if (!buffered) {
|
FileTaskEntity pollTask = taskForPoll(state.getTaskId());
|
||||||
FileTaskEntity task = taskForPoll(state.getTaskId());
|
if (pollTask != null) {
|
||||||
if (task != null) {
|
Map<String, List<SimilarAsinParsedRowVo>> allRowsByBaseId = allRowsByBaseIdForPoll(pollTask);
|
||||||
Map<String, List<SimilarAsinParsedRowVo>> allRowsByBaseId = allRowsByBaseIdForPoll(task);
|
|
||||||
try {
|
try {
|
||||||
mergeCozeRowsIntoChunk(task, context.chunkScopeHash(), context.chunkIndex(), cozeRows, allRowsByBaseId);
|
mergeCozeRowsIntoChunk(pollTask, context.chunkScopeHash(), context.chunkIndex(), cozeRows, allRowsByBaseId);
|
||||||
} catch (Exception mergeEx) {
|
} catch (Exception mergeEx) {
|
||||||
if (failureMessage.isBlank()) {
|
|
||||||
throw mergeEx;
|
|
||||||
}
|
|
||||||
log.warn("[similar-asin] coze failed result merge failed, mark state terminal anyway taskId={} stateId={} executeId={} err={}",
|
log.warn("[similar-asin] coze failed result merge failed, mark state terminal anyway taskId={} stateId={} executeId={} err={}",
|
||||||
state.getTaskId(), state.getId(), state.getCozeExecuteId(),
|
state.getTaskId(), state.getId(), state.getCozeExecuteId(),
|
||||||
firstNonBlank(mergeEx.getMessage(), mergeEx.getClass().getSimpleName()));
|
firstNonBlank(mergeEx.getMessage(), mergeEx.getClass().getSimpleName()));
|
||||||
@@ -3079,7 +3522,9 @@ public class SimilarAsinTaskService {
|
|||||||
if (!emptyResultMessage.isBlank()) {
|
if (!emptyResultMessage.isBlank()) {
|
||||||
throw new IllegalStateException(emptyResultMessage);
|
throw new IllegalStateException(emptyResultMessage);
|
||||||
}
|
}
|
||||||
mergeCozeRowsIntoChunk(task, null, null, cozeRows, allRowsByBaseId);
|
// Task 12:retry 同步 immediate DONE 结果也走缓冲(原立即 merge),
|
||||||
|
// 缓冲失败回退立即 merge;flush 在 finalize 时一次性完成。
|
||||||
|
bufferCozeRowsOrMerge(state, context, cozeRows, task, allRowsByBaseId);
|
||||||
markCozeStateTerminal(state, COZE_STATUS_DONE, null);
|
markCozeStateTerminal(state, COZE_STATUS_DONE, null);
|
||||||
maybeFinalizeCozeJobLocked(state.getTaskId(), context);
|
maybeFinalizeCozeJobLocked(state.getTaskId(), context);
|
||||||
return;
|
return;
|
||||||
@@ -3546,37 +3991,17 @@ public class SimilarAsinTaskService {
|
|||||||
rowsByChunk.put(chunkKey, readChunkRows(chunk));
|
rowsByChunk.put(chunkKey, readChunkRows(chunk));
|
||||||
chunkByKey.put(chunkKey, chunk);
|
chunkByKey.put(chunkKey, chunk);
|
||||||
}
|
}
|
||||||
Map<String, Map<String, SimilarAsinResultRowDto>> mergeRowsByChunk = new LinkedHashMap<>();
|
// Task 11:合并前按稳定 rowKey 去重,消除重复行逐行 expand/分配/写回 的 O(n²) 热点。
|
||||||
List<SimilarAsinResultRowDto> orphanRows = new ArrayList<>();
|
List<SimilarAsinResultRowDto> uniqueRows = dedupeRowsByRowKey(cozeRows);
|
||||||
for (SimilarAsinResultRowDto resultRow : cozeRows) {
|
List<SimilarAsinResultRowDto> expandedAll = new ArrayList<>();
|
||||||
for (SimilarAsinResultRowDto expandedRow : expandRows(List.of(resultRow), allRowsByBaseId)) {
|
for (SimilarAsinResultRowDto resultRow : uniqueRows) {
|
||||||
String rowKey = rowKey(expandedRow);
|
expandedAll.addAll(expandRows(List.of(resultRow), allRowsByBaseId));
|
||||||
if (rowKey.isBlank()) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
boolean matched = false;
|
|
||||||
for (Map.Entry<String, Map<String, SimilarAsinResultRowDto>> entry : rowsByChunk.entrySet()) {
|
|
||||||
if (entry.getValue().containsKey(rowKey)) {
|
|
||||||
mergeRowsByChunk.computeIfAbsent(entry.getKey(), ignored -> new LinkedHashMap<>())
|
|
||||||
.put(rowKey, expandedRow);
|
|
||||||
matched = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!matched && chunkScopeHash != null && !chunkScopeHash.isBlank() && chunkIndex != null) {
|
|
||||||
String fallbackKey = chunkStorageKey(chunkScopeHash, chunkIndex);
|
|
||||||
if (chunkByKey.containsKey(fallbackKey)) {
|
|
||||||
mergeRowsByChunk.computeIfAbsent(fallbackKey, ignored -> new LinkedHashMap<>())
|
|
||||||
.put(rowKey, expandedRow);
|
|
||||||
matched = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!matched) {
|
|
||||||
orphanRows.add(expandedRow);
|
|
||||||
log.error("[similar-asin] coze row has no submitted chunk taskId={} rowKey={} asin={} country={}",
|
|
||||||
task.getId(), rowKey, expandedRow.getAsin(), expandedRow.getCountry());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
// Task 10:先建 rowKey → chunkKey 批量索引,把逐 chunk 线性扫描降为 O(1) 查找。
|
||||||
|
Map<String, String> rowKeyIndex = indexRowsByChunkKey(rowsByChunk);
|
||||||
|
List<SimilarAsinResultRowDto> orphanRows = new ArrayList<>();
|
||||||
|
Map<String, Map<String, SimilarAsinResultRowDto>> mergeRowsByChunk =
|
||||||
|
assignCozeRowsToChunks(rowsByChunk, expandedAll, rowKeyIndex, chunkScopeHash, chunkIndex, orphanRows);
|
||||||
if (!orphanRows.isEmpty()) {
|
if (!orphanRows.isEmpty()) {
|
||||||
persistOrphanCozeRows(task.getId(), orphanRows);
|
persistOrphanCozeRows(task.getId(), orphanRows);
|
||||||
}
|
}
|
||||||
@@ -3741,6 +4166,86 @@ public class SimilarAsinTaskService {
|
|||||||
return properties.isCozeResultBufferEnabled();
|
return properties.isCozeResultBufferEnabled();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Task 12:统一 Coze DONE 结果落库入口。
|
||||||
|
* 缓冲开关开启时把 cozeRows 写入 transient storage(pointer 存进 state.stateJson),
|
||||||
|
* 由 flushBufferedCozeResults 在 finalize/assemble 前一次性合并到 chunk;
|
||||||
|
* 缓冲失败(存储异常 / state 更新失败 / 开关关闭)回退立即 merge,结果不丢失。
|
||||||
|
* 空 rows / 空 state / 空 context 直接返回,不产生任何写入。
|
||||||
|
*/
|
||||||
|
private void bufferCozeRowsOrMerge(TaskScopeStateEntity state,
|
||||||
|
CozeBatchContext context,
|
||||||
|
List<SimilarAsinResultRowDto> cozeRows,
|
||||||
|
FileTaskEntity task,
|
||||||
|
Map<String, List<SimilarAsinParsedRowVo>> allRowsByBaseId) {
|
||||||
|
if (state == null || context == null || cozeRows == null || cozeRows.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!isCozeResultBufferEnabled()) {
|
||||||
|
mergeCozeRowsIntoChunk(task, context.chunkScopeHash(), context.chunkIndex(), cozeRows, allRowsByBaseId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
CozeBatchContext bufferedContext = bufferCozeResultForFlush(state, context, cozeRows);
|
||||||
|
if (bufferedContext == null
|
||||||
|
|| bufferedContext.resultPayloadPointer() == null
|
||||||
|
|| bufferedContext.resultPayloadPointer().isBlank()) {
|
||||||
|
// 缓冲失败:回退立即 merge,避免结果悬挂在 transient storage 之外。
|
||||||
|
mergeCozeRowsIntoChunk(task, context.chunkScopeHash(), context.chunkIndex(), cozeRows, allRowsByBaseId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Task 12:为 submit 同步 immediate DONE 结果落一条 DONE state 承载缓冲 pointer。
|
||||||
|
* 与 saveCozeBatchState(SUBMITTED 异步)不同,该 state 直接以 DONE 终态插入,
|
||||||
|
* 不会被 countPendingCozeStates 扫描;缓冲失败/重复插入时返回 null,调用方回退立即 merge。
|
||||||
|
*/
|
||||||
|
private TaskScopeStateEntity persistImmediateCozeDoneState(FileTaskEntity task,
|
||||||
|
FileResultEntity result,
|
||||||
|
TaskFileJobEntity job,
|
||||||
|
List<SimilarAsinResultRowDto> batchRows,
|
||||||
|
String batchScopeKey,
|
||||||
|
String batchScopeHash,
|
||||||
|
int batchIndex,
|
||||||
|
int batchTotal,
|
||||||
|
String credentialName) {
|
||||||
|
LocalDateTime now = LocalDateTime.now();
|
||||||
|
CozeBatchContext context = new CozeBatchContext(
|
||||||
|
job.getId(),
|
||||||
|
result.getId(),
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
batchIndex,
|
||||||
|
batchTotal,
|
||||||
|
currentInstanceId(),
|
||||||
|
0,
|
||||||
|
credentialName,
|
||||||
|
null
|
||||||
|
);
|
||||||
|
TaskScopeStateEntity state = new TaskScopeStateEntity();
|
||||||
|
state.setTaskId(task.getId());
|
||||||
|
state.setModuleType(MODULE_TYPE);
|
||||||
|
state.setScopeKey(batchScopeKey);
|
||||||
|
state.setScopeHash(batchScopeHash);
|
||||||
|
state.setStateJson(writeJson(context, "serialize immediate coze done state context failed"));
|
||||||
|
state.setCozeStatus(COZE_STATUS_DONE);
|
||||||
|
state.setCozeSubmittedAt(now);
|
||||||
|
state.setCozeCompletedAt(now);
|
||||||
|
state.setCozeAttemptCount(0);
|
||||||
|
state.setChunkTotal(batchTotal);
|
||||||
|
state.setReceivedChunkCount(batchIndex);
|
||||||
|
state.setCompleted(1);
|
||||||
|
state.setCreatedAt(now);
|
||||||
|
state.setUpdatedAt(now);
|
||||||
|
try {
|
||||||
|
taskScopeStateMapper.insert(state);
|
||||||
|
return state;
|
||||||
|
} catch (DuplicateKeyException ex) {
|
||||||
|
log.info("[similar-asin] duplicate immediate done coze state ignored taskId={} scope={}",
|
||||||
|
task.getId(), batchScopeKey);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* P0-3:在 finalize 之前一次性把所有 DONE/FAILED state 上缓冲的 cozeRows
|
* P0-3:在 finalize 之前一次性把所有 DONE/FAILED state 上缓冲的 cozeRows
|
||||||
* 按 chunkScopeHash 分组合并到 chunk。把每个 batch 的"loadSubmittedChunks +
|
* 按 chunkScopeHash 分组合并到 chunk。把每个 batch 的"loadSubmittedChunks +
|
||||||
@@ -3984,9 +4489,9 @@ public class SimilarAsinTaskService {
|
|||||||
return "coze:task:" + taskId + ":rows:" + DigestUtil.sha256Hex(rowKeys.toString());
|
return "coze:task:" + taskId + ":rows:" + DigestUtil.sha256Hex(rowKeys.toString());
|
||||||
}
|
}
|
||||||
|
|
||||||
private record CozeCandidate(String chunkScopeHash,
|
record CozeCandidate(String chunkScopeHash,
|
||||||
Integer chunkIndex,
|
Integer chunkIndex,
|
||||||
SimilarAsinResultRowDto row) {
|
SimilarAsinResultRowDto row) {
|
||||||
}
|
}
|
||||||
|
|
||||||
private record PythonUploadProgress(int current, int total, String unit) {
|
private record PythonUploadProgress(int current, int total, String unit) {
|
||||||
@@ -4275,8 +4780,9 @@ public class SimilarAsinTaskService {
|
|||||||
*/
|
*/
|
||||||
private void assembleResultWorkbookBounded(FileTaskEntity task, FileResultEntity result) {
|
private void assembleResultWorkbookBounded(FileTaskEntity task, FileResultEntity result) {
|
||||||
SimilarAsinParsedPayloadDto parsed = readParsedPayload(task);
|
SimilarAsinParsedPayloadDto parsed = readParsedPayload(task);
|
||||||
|
List<SimilarAsinParsedRowVo> parsedRows = resolveAllRows(parsed);
|
||||||
List<SourceRows> sourceRows = splitRowsBySourceFile(
|
List<SourceRows> sourceRows = splitRowsBySourceFile(
|
||||||
parsed, parsed.getAllItems(), result.getSourceFilename());
|
parsed, parsedRows, result.getSourceFilename());
|
||||||
if (sourceRows.isEmpty()) {
|
if (sourceRows.isEmpty()) {
|
||||||
throw new BusinessException("Similar ASIN result rows are empty");
|
throw new BusinessException("Similar ASIN result rows are empty");
|
||||||
}
|
}
|
||||||
@@ -4372,7 +4878,7 @@ public class SimilarAsinTaskService {
|
|||||||
.mapToInt(SourceResultWorkbook::conformPropagated)
|
.mapToInt(SourceResultWorkbook::conformPropagated)
|
||||||
.sum();
|
.sum();
|
||||||
log.info("[similar-asin] bounded assemble workbook taskId={} parsedRows={} persistedRows={} resultRows={} resolvedRows={} propagated={} sources={} costMs={}",
|
log.info("[similar-asin] bounded assemble workbook taskId={} parsedRows={} persistedRows={} resultRows={} resolvedRows={} propagated={} sources={} costMs={}",
|
||||||
task.getId(), parsed.getAllItems().size(), persistedResultRows, resultRows,
|
task.getId(), parsedRows.size(), persistedResultRows, resultRows,
|
||||||
resolvedRows, conformPropagated, sourceRows.size(),
|
resolvedRows, conformPropagated, sourceRows.size(),
|
||||||
System.currentTimeMillis() - assembleStart);
|
System.currentTimeMillis() - assembleStart);
|
||||||
if (resultRows == 0) {
|
if (resultRows == 0) {
|
||||||
@@ -4408,7 +4914,7 @@ public class SimilarAsinTaskService {
|
|||||||
result.setResultFileUrl(objectKey);
|
result.setResultFileUrl(objectKey);
|
||||||
result.setResultFileSize(uploadFile.length());
|
result.setResultFileSize(uploadFile.length());
|
||||||
result.setResultContentType(contentType);
|
result.setResultContentType(contentType);
|
||||||
result.setRowCount(parsed.getAllItems().size());
|
result.setRowCount(parsedRows.size());
|
||||||
} finally {
|
} finally {
|
||||||
taskImageCache.clear();
|
taskImageCache.clear();
|
||||||
for (File workbookFile : workbookFiles) {
|
for (File workbookFile : workbookFiles) {
|
||||||
@@ -4428,15 +4934,16 @@ public class SimilarAsinTaskService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
SimilarAsinParsedPayloadDto parsed = readParsedPayload(task);
|
SimilarAsinParsedPayloadDto parsed = readParsedPayload(task);
|
||||||
Map<String, SimilarAsinResultRowDto> resultMap = loadPersistedResultRowsWithRetry(task.getId(), parsed.getAllItems().size());
|
List<SimilarAsinParsedRowVo> parsedRows = resolveAllRows(parsed);
|
||||||
|
Map<String, SimilarAsinResultRowDto> resultMap = loadPersistedResultRowsWithRetry(task.getId(), parsedRows.size());
|
||||||
int persistedResultRows = resultMap.size();
|
int persistedResultRows = resultMap.size();
|
||||||
resultMap.entrySet().removeIf(entry -> !isExportableResultRow(entry.getValue()));
|
resultMap.entrySet().removeIf(entry -> !isExportableResultRow(entry.getValue()));
|
||||||
long resolvedRows = parsed.getAllItems().stream()
|
long resolvedRows = parsedRows.stream()
|
||||||
.filter(row -> findResultRow(row, resultMap) != null)
|
.filter(row -> findResultRow(row, resultMap) != null)
|
||||||
.count();
|
.count();
|
||||||
log.info("[similar-asin] assemble workbook taskId={} parsedRows={} persistedRows={} resultRows={} resolvedRows={}",
|
log.info("[similar-asin] assemble workbook taskId={} parsedRows={} persistedRows={} resultRows={} resolvedRows={}",
|
||||||
task.getId(), parsed.getAllItems().size(), persistedResultRows, resultMap.size(), resolvedRows);
|
task.getId(), parsedRows.size(), persistedResultRows, resultMap.size(), resolvedRows);
|
||||||
if (!parsed.getAllItems().isEmpty() && resultMap.isEmpty()) {
|
if (!parsedRows.isEmpty() && resultMap.isEmpty()) {
|
||||||
// P1-4:检查是否有 chunk-read-failed 标记,把 chunk index 列表附在错误信息里。
|
// P1-4:检查是否有 chunk-read-failed 标记,把 chunk index 列表附在错误信息里。
|
||||||
String chunkReadFailureSummary = collectChunkReadFailureSummary(task.getId());
|
String chunkReadFailureSummary = collectChunkReadFailureSummary(task.getId());
|
||||||
if (!chunkReadFailureSummary.isBlank()) {
|
if (!chunkReadFailureSummary.isBlank()) {
|
||||||
@@ -4448,7 +4955,7 @@ public class SimilarAsinTaskService {
|
|||||||
// 命中"不符合"(包含匹配),则组内所有行的"是否符合类目"统一为标准值"不符合"。
|
// 命中"不符合"(包含匹配),则组内所有行的"是否符合类目"统一为标准值"不符合"。
|
||||||
// 单行组跳过。仅修改 isConform 列,不影响其他列。
|
// 单行组跳过。仅修改 isConform 列,不影响其他列。
|
||||||
int conformPropagated = CozeGroupResultPropagator.propagateByGroup(
|
int conformPropagated = CozeGroupResultPropagator.propagateByGroup(
|
||||||
parsed.getAllItems(),
|
parsedRows,
|
||||||
SimilarAsinParsedRowVo::getDisplayId,
|
SimilarAsinParsedRowVo::getDisplayId,
|
||||||
row -> findResultRow(row, resultMap),
|
row -> findResultRow(row, resultMap),
|
||||||
SimilarAsinResultRowDto::getIsConform,
|
SimilarAsinResultRowDto::getIsConform,
|
||||||
@@ -4464,7 +4971,7 @@ public class SimilarAsinTaskService {
|
|||||||
if (!outputDir.exists() && !outputDir.mkdirs()) {
|
if (!outputDir.exists() && !outputDir.mkdirs()) {
|
||||||
throw new BusinessException("创建结果目录失败");
|
throw new BusinessException("创建结果目录失败");
|
||||||
}
|
}
|
||||||
List<SourceRows> sourceRows = splitRowsBySourceFile(parsed, parsed.getAllItems(), result.getSourceFilename());
|
List<SourceRows> sourceRows = splitRowsBySourceFile(parsed, parsedRows, result.getSourceFilename());
|
||||||
List<SourceResultWorkbook> workbooks = new ArrayList<>();
|
List<SourceResultWorkbook> workbooks = new ArrayList<>();
|
||||||
List<File> workbookFiles = new ArrayList<>(sourceRows.size());
|
List<File> workbookFiles = new ArrayList<>(sourceRows.size());
|
||||||
File zip = null;
|
File zip = null;
|
||||||
@@ -4564,7 +5071,7 @@ public class SimilarAsinTaskService {
|
|||||||
result.setResultFileUrl(objectKey);
|
result.setResultFileUrl(objectKey);
|
||||||
result.setResultFileSize(uploadFile.length());
|
result.setResultFileSize(uploadFile.length());
|
||||||
result.setResultContentType(contentType);
|
result.setResultContentType(contentType);
|
||||||
result.setRowCount(parsed.getAllItems().size());
|
result.setRowCount(resolveAllRows(parsed).size());
|
||||||
} finally {
|
} finally {
|
||||||
for (File workbookFile : workbookFiles) {
|
for (File workbookFile : workbookFiles) {
|
||||||
if (workbookFile.exists() && !workbookFile.delete()) {
|
if (workbookFile.exists() && !workbookFile.delete()) {
|
||||||
@@ -5121,8 +5628,16 @@ public class SimilarAsinTaskService {
|
|||||||
|
|
||||||
private ParsedWorkbook parseWorkbook(File input, SimilarAsinSourceFileDto source) {
|
private ParsedWorkbook parseWorkbook(File input, SimilarAsinSourceFileDto source) {
|
||||||
DataFormatter formatter = new DataFormatter();
|
DataFormatter formatter = new DataFormatter();
|
||||||
try (FileInputStream fis = new FileInputStream(input); Workbook workbook = WorkbookFactory.create(fis)) {
|
try {
|
||||||
Sheet sheet = workbook.getSheetAt(0);
|
// Task 8:受控读取——先探测 zip 条目数与解压体积,超限拒绝
|
||||||
|
probeWorkbookZipBounds(input, source.getOriginalFilename());
|
||||||
|
} catch (BusinessException ex) {
|
||||||
|
throw ex;
|
||||||
|
} catch (Exception ex) {
|
||||||
|
// 非 zip 或损坏文件:留给 WorkbookFactory 尝试后由下方 catch 转业务异常
|
||||||
|
log.debug("[similar-asin] workbook zip probe skipped file={} err={}", input, ex.getMessage());
|
||||||
|
}
|
||||||
|
try (FileInputStream fis = new FileInputStream(input); Workbook workbook = WorkbookFactory.create(fis)) { Sheet sheet = workbook.getSheetAt(0);
|
||||||
Row header = sheet.getRow(0);
|
Row header = sheet.getRow(0);
|
||||||
if (header == null) {
|
if (header == null) {
|
||||||
throw new BusinessException("Excel 表头为空");
|
throw new BusinessException("Excel 表头为空");
|
||||||
@@ -5373,7 +5888,15 @@ public class SimilarAsinTaskService {
|
|||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
String value = normalize(formatter.formatCellValue(row.getCell(col)));
|
String value = normalize(formatter.formatCellValue(row.getCell(col)));
|
||||||
return isSpreadsheetErrorValue(value) ? "" : value;
|
if (isSpreadsheetErrorValue(value)) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
// Task 7:单字段长度上限,防止超长单元格导致内存无界增长
|
||||||
|
int maxLen = resolveMaxFieldLength();
|
||||||
|
if (value.length() > maxLen) {
|
||||||
|
return value.substring(0, maxLen);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static boolean isSpreadsheetErrorValue(String value) {
|
private static boolean isSpreadsheetErrorValue(String value) {
|
||||||
@@ -5876,7 +6399,6 @@ public class SimilarAsinTaskService {
|
|||||||
payload.setHeaders(headers == null ? List.of() : headers);
|
payload.setHeaders(headers == null ? List.of() : headers);
|
||||||
payload.setItems(allRows == null ? List.of() : new ArrayList<>(allRows));
|
payload.setItems(allRows == null ? List.of() : new ArrayList<>(allRows));
|
||||||
payload.setGroups(groups == null ? List.of() : groups);
|
payload.setGroups(groups == null ? List.of() : groups);
|
||||||
payload.setAllItems(allRows == null ? List.of() : new ArrayList<>(allRows));
|
|
||||||
return writeJson(payload, "保存解析结果失败");
|
return writeJson(payload, "保存解析结果失败");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5924,19 +6446,7 @@ public class SimilarAsinTaskService {
|
|||||||
if (payload == null) {
|
if (payload == null) {
|
||||||
return new SimilarAsinParsedPayloadDto();
|
return new SimilarAsinParsedPayloadDto();
|
||||||
}
|
}
|
||||||
List<SimilarAsinParsedRowVo> rows = payload.getAllItems();
|
List<SimilarAsinParsedRowVo> rows = resolveAllRows(payload);
|
||||||
if (rows == null || rows.isEmpty()) {
|
|
||||||
rows = payload.getItems();
|
|
||||||
}
|
|
||||||
if ((rows == null || rows.isEmpty()) && payload.getGroups() != null) {
|
|
||||||
rows = payload.getGroups().stream()
|
|
||||||
.filter(Objects::nonNull)
|
|
||||||
.flatMap(group -> group.getItems() == null ? java.util.stream.Stream.empty() : group.getItems().stream())
|
|
||||||
.toList();
|
|
||||||
}
|
|
||||||
if (rows == null) {
|
|
||||||
rows = List.of();
|
|
||||||
}
|
|
||||||
if (payload.getAllItems() == null || payload.getAllItems().isEmpty()) {
|
if (payload.getAllItems() == null || payload.getAllItems().isEmpty()) {
|
||||||
payload.setAllItems(rows);
|
payload.setAllItems(rows);
|
||||||
}
|
}
|
||||||
@@ -6477,16 +6987,16 @@ public class SimilarAsinTaskService {
|
|||||||
boolean terminal) {
|
boolean terminal) {
|
||||||
}
|
}
|
||||||
|
|
||||||
private record CozeBatchContext(Long jobId,
|
record CozeBatchContext(Long jobId,
|
||||||
Long resultId,
|
Long resultId,
|
||||||
String chunkScopeHash,
|
String chunkScopeHash,
|
||||||
Integer chunkIndex,
|
Integer chunkIndex,
|
||||||
Integer batchIndex,
|
Integer batchIndex,
|
||||||
Integer batchTotal,
|
Integer batchTotal,
|
||||||
String ownerInstanceId,
|
String ownerInstanceId,
|
||||||
Integer submitRetryCount,
|
Integer submitRetryCount,
|
||||||
String credentialName,
|
String credentialName,
|
||||||
String resultPayloadPointer) {
|
String resultPayloadPointer) {
|
||||||
}
|
}
|
||||||
|
|
||||||
private static class SourceRowsBuilder {
|
private static class SourceRowsBuilder {
|
||||||
|
|||||||
+213
-12
@@ -102,10 +102,26 @@ public class SimilarAsinImageEmbedder {
|
|||||||
* 不再缩到更小,因为 Excel 单元格列宽 80 字符(≈ 600 px)已是显示下限。
|
* 不再缩到更小,因为 Excel 单元格列宽 80 字符(≈ 600 px)已是显示下限。
|
||||||
*/
|
*/
|
||||||
private static final int[] FALLBACK_LONG_EDGES = new int[]{1280, 960, 720};
|
private static final int[] FALLBACK_LONG_EDGES = new int[]{1280, 960, 720};
|
||||||
/** 迭代降级时的备选 JPEG 质量;末位 0.55 是肉眼可接受下限。 */
|
/**
|
||||||
private static final float[] FALLBACK_QUALITIES = new float[]{0.75f, 0.65f, 0.55f};
|
* Task 17:JPEG 质量估算下限。0.55 是肉眼可接受下限,
|
||||||
|
* 估算结果钳制在 [MIN_JPEG_QUALITY, JPEG_QUALITY]。
|
||||||
|
*/
|
||||||
|
static final float MIN_JPEG_QUALITY = 0.45f;
|
||||||
|
/**
|
||||||
|
* Task 17:源像素上限(6000×6000)。超限直接拒绝,防止解码前爆堆。
|
||||||
|
*/
|
||||||
|
static final long MAX_SOURCE_PIXELS = 6000L * 6000L;
|
||||||
|
/**
|
||||||
|
* Task 17:子采样后的解码像素上限(2400×2400 ≈ 5.76MP,RGB 解码 ≈ 17MB 堆)。
|
||||||
|
* 超过且当前格式不支持子采样(或子采样后仍超)时拒绝解码,避免全量解码 36MP。
|
||||||
|
*/
|
||||||
|
static final long MAX_DECODED_PIXELS = 2400L * 2400L;
|
||||||
static final int MAX_DOWNLOAD_BYTES = 5 * 1024 * 1024;
|
static final int MAX_DOWNLOAD_BYTES = 5 * 1024 * 1024;
|
||||||
static final int MAX_DECODE_PIXELS = 6000 * 6000;
|
/**
|
||||||
|
* Task 17:源像素上限仍保留(与旧 MAX_DECODE_PIXELS 值一致),
|
||||||
|
* 解码前还要按子采样后的像素数再校验一次。
|
||||||
|
*/
|
||||||
|
static final long MAX_DECODE_PIXELS = MAX_SOURCE_PIXELS;
|
||||||
|
|
||||||
private static final String UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
private static final String UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
||||||
+ "(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36";
|
+ "(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36";
|
||||||
@@ -167,6 +183,32 @@ public class SimilarAsinImageEmbedder {
|
|||||||
downloadPool.shutdownNow();
|
downloadPool.shutdownNow();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Task 16:测试专用 hook,按 url 注入预取处理,绕过真实 HTTP 下载。 */
|
||||||
|
private final ConcurrentMap<String, Runnable> testPrefetchHandlers = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
void registerPrefetchHandler(String url, Runnable handler) {
|
||||||
|
if (url != null && handler != null) {
|
||||||
|
testPrefetchHandlers.put(url.trim(), handler);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private ResizedImage fetchAndResizeDirectForTest(String url) {
|
||||||
|
Runnable handler = testPrefetchHandlers.get(url.trim());
|
||||||
|
if (handler != null) {
|
||||||
|
handler.run();
|
||||||
|
return testPrefetchResults.get(url.trim());
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private final ConcurrentMap<String, ResizedImage> testPrefetchResults = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
void registerPrefetchResult(String url, ResizedImage image) {
|
||||||
|
if (url != null && image != null) {
|
||||||
|
testPrefetchResults.put(url.trim(), image);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
static int cpuBoundPoolLimit() {
|
static int cpuBoundPoolLimit() {
|
||||||
int visibleProcessors = Math.max(1, Runtime.getRuntime().availableProcessors());
|
int visibleProcessors = Math.max(1, Runtime.getRuntime().availableProcessors());
|
||||||
return Math.max(1, (visibleProcessors + 1) / 2);
|
return Math.max(1, (visibleProcessors + 1) / 2);
|
||||||
@@ -349,6 +391,90 @@ public class SimilarAsinImageEmbedder {
|
|||||||
failed.get(), skipped, deadlineReached);
|
failed.get(), skipped, deadlineReached);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Task 16:短预算 best-effort 预取。在 budgetSeconds 内尽力下载 resize,
|
||||||
|
* 预算耗尽即停止并取消在途任务;缺图由调用方回退为 URL。
|
||||||
|
* 返回未预取(skipped)数量。单个 url 失败不阻断其余 url。
|
||||||
|
*/
|
||||||
|
int prefetchToDiskBestEffort(Collection<String> urls, ImageSpool imageSpool, long budgetSeconds) {
|
||||||
|
if (urls == null || urls.isEmpty() || imageSpool == null || budgetSeconds <= 0L) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
Set<String> distinctUrls = new LinkedHashSet<>();
|
||||||
|
for (String url : urls) {
|
||||||
|
if (url == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
String trimmed = url.trim();
|
||||||
|
if (!trimmed.isEmpty() && imageSpool.get(trimmed) == null) {
|
||||||
|
distinctUrls.add(trimmed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (distinctUrls.isEmpty()) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
long deadlineNanos = System.nanoTime() + TimeUnit.SECONDS.toNanos(budgetSeconds);
|
||||||
|
AtomicInteger completed = new AtomicInteger();
|
||||||
|
CompletionService<Object> completion = new ExecutorCompletionService<>(downloadPool);
|
||||||
|
Iterator<String> pending = distinctUrls.iterator();
|
||||||
|
List<Future<?>> active = new ArrayList<>(Math.min(downloadPoolSize, distinctUrls.size()));
|
||||||
|
while (pending.hasNext() && active.size() < downloadPoolSize) {
|
||||||
|
active.add(submitBestEffortPrefetch(completion, pending.next(), imageSpool));
|
||||||
|
}
|
||||||
|
boolean deadlineReached = false;
|
||||||
|
while (!active.isEmpty()) {
|
||||||
|
long remainingNanos = deadlineNanos - System.nanoTime();
|
||||||
|
if (remainingNanos <= 0L) {
|
||||||
|
deadlineReached = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
Future<Object> finished = completion.poll(remainingNanos, TimeUnit.NANOSECONDS);
|
||||||
|
if (finished == null) {
|
||||||
|
deadlineReached = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
active.remove(finished);
|
||||||
|
completed.incrementAndGet();
|
||||||
|
if (pending.hasNext()) {
|
||||||
|
active.add(submitBestEffortPrefetch(completion, pending.next(), imageSpool));
|
||||||
|
}
|
||||||
|
} catch (InterruptedException ex) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
cancelAll(active);
|
||||||
|
return distinctUrls.size() - completed.get();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (deadlineReached) {
|
||||||
|
cancelAll(active);
|
||||||
|
}
|
||||||
|
int skipped = Math.max(0, distinctUrls.size() - completed.get());
|
||||||
|
log.info("[similar-asin][image] disk prefetch best-effort finished total={} completed={} spooled={} skipped={} budgetSeconds={}",
|
||||||
|
distinctUrls.size(), completed.get(), imageSpool.size(), skipped, budgetSeconds);
|
||||||
|
return skipped;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Future<?> submitBestEffortPrefetch(CompletionService<Object> completion,
|
||||||
|
String url,
|
||||||
|
ImageSpool imageSpool) {
|
||||||
|
return completion.submit(() -> {
|
||||||
|
try {
|
||||||
|
ResizedImage thumb = readLocalCachedThumb(url);
|
||||||
|
if (thumb == null) {
|
||||||
|
thumb = fetchAndResizeDirectForTest(url);
|
||||||
|
if (thumb == null) {
|
||||||
|
thumb = fetchAndResizeDirect(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ensureImageWorkNotInterrupted();
|
||||||
|
imageSpool.put(url, thumb);
|
||||||
|
} catch (Exception ex) {
|
||||||
|
log.debug("[similar-asin][image] best-effort prefetch fail url={} err={}", url, errorSummary(ex));
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private Future<?> submitDiskPrefetch(CompletionService<Object> completion,
|
private Future<?> submitDiskPrefetch(CompletionService<Object> completion,
|
||||||
String url,
|
String url,
|
||||||
ImageSpool imageSpool,
|
ImageSpool imageSpool,
|
||||||
@@ -359,11 +485,15 @@ public class SimilarAsinImageEmbedder {
|
|||||||
try {
|
try {
|
||||||
ResizedImage thumb = readLocalCachedThumb(url);
|
ResizedImage thumb = readLocalCachedThumb(url);
|
||||||
if (thumb == null) {
|
if (thumb == null) {
|
||||||
thumb = fetchAndResizeDirect(url);
|
thumb = fetchAndResizeDirectForTest(url);
|
||||||
downloaded.incrementAndGet();
|
if (thumb == null) {
|
||||||
|
thumb = fetchAndResizeDirect(url);
|
||||||
|
downloaded.incrementAndGet();
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
localHit.incrementAndGet();
|
localHit.incrementAndGet();
|
||||||
}
|
}
|
||||||
|
ensureImageWorkNotInterrupted();
|
||||||
imageSpool.put(url, thumb);
|
imageSpool.put(url, thumb);
|
||||||
} catch (Exception ex) {
|
} catch (Exception ex) {
|
||||||
failed.incrementAndGet();
|
failed.incrementAndGet();
|
||||||
@@ -899,7 +1029,11 @@ public class SimilarAsinImageEmbedder {
|
|||||||
ensureImageWorkNotInterrupted();
|
ensureImageWorkNotInterrupted();
|
||||||
BufferedImage scaled = scaleAt(src, srcW, srcH, longEdge);
|
BufferedImage scaled = scaleAt(src, srcW, srcH, longEdge);
|
||||||
try {
|
try {
|
||||||
for (float quality : FALLBACK_QUALITIES) {
|
// Task 17:估算质量优先(0.75 上限内按前次字节比例),超限时降一档重试。
|
||||||
|
// 固定阶梯 {0.75,0.65,0.55} 最坏 3 次编码/长边;估算 2 次/长边,最坏 9 → 6。
|
||||||
|
float estimated = estimatedQuality(0, MAX_THUMB_SIZE_BYTES);
|
||||||
|
for (float quality : new float[]{estimated,
|
||||||
|
estimated > MIN_JPEG_QUALITY ? MIN_JPEG_QUALITY : JPEG_QUALITY}) {
|
||||||
ensureImageWorkNotInterrupted();
|
ensureImageWorkNotInterrupted();
|
||||||
ResizedImage tried = encodeJpeg(scaled, quality);
|
ResizedImage tried = encodeJpeg(scaled, quality);
|
||||||
if (smallest == null || tried.bytes().length < smallest.bytes().length) {
|
if (smallest == null || tried.bytes().length < smallest.bytes().length) {
|
||||||
@@ -955,15 +1089,20 @@ public class SimilarAsinImageEmbedder {
|
|||||||
reader.setInput(iis, true, true);
|
reader.setInput(iis, true, true);
|
||||||
int sourceWidth = reader.getWidth(0);
|
int sourceWidth = reader.getWidth(0);
|
||||||
int sourceHeight = reader.getHeight(0);
|
int sourceHeight = reader.getHeight(0);
|
||||||
long pixels = (long) sourceWidth * (long) sourceHeight;
|
int subsampling = sourceSubsampling(sourceWidth, sourceHeight);
|
||||||
if (sourceWidth <= 0 || sourceHeight <= 0 || pixels > MAX_DECODE_PIXELS) {
|
long decodedPixels = decodedPixelsAfterSubsampling(sourceWidth, sourceHeight, subsampling);
|
||||||
throw new ResizeException("image too large url=" + sourceUrl + " pixels=" + pixels);
|
while (decodedPixels > MAX_DECODED_PIXELS && subsampling < Math.max(sourceWidth, sourceHeight)) {
|
||||||
|
subsampling <<= 1;
|
||||||
|
decodedPixels = decodedPixelsAfterSubsampling(sourceWidth, sourceHeight, subsampling);
|
||||||
}
|
}
|
||||||
|
validateSourceImage(sourceUrl, sourceWidth, sourceHeight, subsampling);
|
||||||
ImageReadParam readParam = reader.getDefaultReadParam();
|
ImageReadParam readParam = reader.getDefaultReadParam();
|
||||||
if (isJpegReader(reader)) {
|
if (subsampling > 1) {
|
||||||
int subsampling = jpegSourceSubsampling(sourceWidth, sourceHeight);
|
if (isJpegReader(reader)) {
|
||||||
if (subsampling > 1) {
|
|
||||||
readParam.setSourceSubsampling(subsampling, subsampling, 0, 0);
|
readParam.setSourceSubsampling(subsampling, subsampling, 0, 0);
|
||||||
|
} else {
|
||||||
|
log.debug("[similar-asin][image] decoder ignores subsampling url={} sub={} decodedPixels={}",
|
||||||
|
sourceUrl, subsampling, (long) sourceWidth * (long) sourceHeight);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
BufferedImage decoded = reader.read(0, readParam);
|
BufferedImage decoded = reader.read(0, readParam);
|
||||||
@@ -983,10 +1122,62 @@ public class SimilarAsinImageEmbedder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static int jpegSourceSubsampling(int sourceWidth, int sourceHeight) {
|
static int jpegSourceSubsampling(int sourceWidth, int sourceHeight) {
|
||||||
|
return sourceSubsampling(sourceWidth, sourceHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Task 17:按源长边对目标长边计算 2 的幂子采样。
|
||||||
|
* 小图(长边 ≤ 目标)返回 1 不采样。
|
||||||
|
*/
|
||||||
|
static int sourceSubsampling(int sourceWidth, int sourceHeight) {
|
||||||
int ratio = Math.max(sourceWidth, sourceHeight) / TARGET_LONG_EDGE_PX;
|
int ratio = Math.max(sourceWidth, sourceHeight) / TARGET_LONG_EDGE_PX;
|
||||||
return ratio > 1 ? Integer.highestOneBit(ratio) : 1;
|
return ratio > 1 ? Integer.highestOneBit(ratio) : 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Task 17:子采样后的解码像素数 = 源像素 / 采样因子²。 */
|
||||||
|
static long decodedPixelsAfterSubsampling(int sourceWidth, int sourceHeight, int subsampling) {
|
||||||
|
if (subsampling <= 1) {
|
||||||
|
return (long) sourceWidth * (long) sourceHeight;
|
||||||
|
}
|
||||||
|
return ((long) sourceWidth / subsampling) * ((long) sourceHeight / subsampling);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Task 17:按字节比例估算 JPEG 质量,避免固定阶梯最坏 3 次编码/长边。
|
||||||
|
* 估算 = JPEG_QUALITY × min(1, limit / actualBytes),钳制在 [MIN_JPEG_QUALITY, JPEG_QUALITY]。
|
||||||
|
* actualBytes 为 0(首次编码前)或不超过 limit 时返回 JPEG_QUALITY;
|
||||||
|
* limit 非法(≤0)时回退 JPEG_QUALITY。
|
||||||
|
*/
|
||||||
|
static float estimatedQuality(int actualBytes, int limitBytes) {
|
||||||
|
if (limitBytes <= 0 || actualBytes <= 0 || actualBytes <= limitBytes) {
|
||||||
|
return JPEG_QUALITY;
|
||||||
|
}
|
||||||
|
float ratio = (float) limitBytes / (float) actualBytes;
|
||||||
|
return Math.max(MIN_JPEG_QUALITY, Math.min(JPEG_QUALITY, JPEG_QUALITY * ratio));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Task 17:解码前尺寸校验。
|
||||||
|
* 尺寸非法或源像素超 MAX_SOURCE_PIXELS 抛 "image too large";
|
||||||
|
* 子采样后解码像素仍超 MAX_DECODED_PIXELS 抛 "decode too large"。
|
||||||
|
* 供 decodeForResize 在 reader 支持子采样时按源尺寸校验、忽略采样参数时按全量解码校验。
|
||||||
|
*/
|
||||||
|
static void validateSourceImage(String sourceUrl, int sourceWidth, int sourceHeight, int subsampling) {
|
||||||
|
if (sourceWidth <= 0 || sourceHeight <= 0) {
|
||||||
|
throw new ResizeException("invalid image dimensions url=" + sourceUrl
|
||||||
|
+ " w=" + sourceWidth + " h=" + sourceHeight);
|
||||||
|
}
|
||||||
|
long sourcePixels = (long) sourceWidth * (long) sourceHeight;
|
||||||
|
if (sourcePixels > MAX_SOURCE_PIXELS) {
|
||||||
|
throw new ResizeException("image too large url=" + sourceUrl
|
||||||
|
+ " pixels=" + sourcePixels + " max=" + MAX_SOURCE_PIXELS);
|
||||||
|
}
|
||||||
|
if (subsampling <= 1 && sourcePixels > MAX_DECODED_PIXELS) {
|
||||||
|
throw new ResizeException("decode too large url=" + sourceUrl
|
||||||
|
+ " pixels=" + sourcePixels + " max=" + MAX_DECODED_PIXELS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static void ensureImageWorkNotInterrupted() throws InterruptedIOException {
|
private static void ensureImageWorkNotInterrupted() throws InterruptedIOException {
|
||||||
if (Thread.currentThread().isInterrupted()) {
|
if (Thread.currentThread().isInterrupted()) {
|
||||||
throw new InterruptedIOException("image work interrupted");
|
throw new InterruptedIOException("image work interrupted");
|
||||||
@@ -1046,6 +1237,7 @@ public class SimilarAsinImageEmbedder {
|
|||||||
|
|
||||||
private final Path directory;
|
private final Path directory;
|
||||||
private final ConcurrentMap<String, SpoolImage> images = new ConcurrentHashMap<>();
|
private final ConcurrentMap<String, SpoolImage> images = new ConcurrentHashMap<>();
|
||||||
|
private volatile boolean closed;
|
||||||
|
|
||||||
public ImageSpool(Path directory) throws IOException {
|
public ImageSpool(Path directory) throws IOException {
|
||||||
this.directory = Objects.requireNonNull(directory, "directory must not be null")
|
this.directory = Objects.requireNonNull(directory, "directory must not be null")
|
||||||
@@ -1053,6 +1245,11 @@ public class SimilarAsinImageEmbedder {
|
|||||||
Files.createDirectories(this.directory);
|
Files.createDirectories(this.directory);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Task 18:暴露临时目录,供生命周期校验与日志。 */
|
||||||
|
public Path directory() {
|
||||||
|
return directory;
|
||||||
|
}
|
||||||
|
|
||||||
public SpoolImage get(String url) {
|
public SpoolImage get(String url) {
|
||||||
if (url == null) {
|
if (url == null) {
|
||||||
return null;
|
return null;
|
||||||
@@ -1062,6 +1259,9 @@ public class SimilarAsinImageEmbedder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public SpoolImage put(String url, ResizedImage image) throws IOException {
|
public SpoolImage put(String url, ResizedImage image) throws IOException {
|
||||||
|
if (closed) {
|
||||||
|
throw new IOException("image spool closed: " + directory);
|
||||||
|
}
|
||||||
if (url == null || url.isBlank() || image == null || image.bytes() == null || image.bytes().length == 0) {
|
if (url == null || url.isBlank() || image == null || image.bytes() == null || image.bytes().length == 0) {
|
||||||
throw new IOException("invalid image spool entry");
|
throw new IOException("invalid image spool entry");
|
||||||
}
|
}
|
||||||
@@ -1094,6 +1294,7 @@ public class SimilarAsinImageEmbedder {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void close() throws IOException {
|
public void close() throws IOException {
|
||||||
|
closed = true;
|
||||||
images.clear();
|
images.clear();
|
||||||
if (!Files.exists(directory)) {
|
if (!Files.exists(directory)) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
+48
@@ -0,0 +1,48 @@
|
|||||||
|
package com.nanri.aiimage.modules.similarasin.util;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Task 19:Coze 请求/响应及 Python 回传日志的采样与截断工具。
|
||||||
|
* truncate 保证超长正文输出有界(前缀 + 长度 + 后缀),不抛异常、不破坏代理对;
|
||||||
|
* shouldLog 按每 everyN 次采样一次(counter % everyN == 0),计数 0 恒采样。
|
||||||
|
* 两个方法均为纯函数,可在日志点直接内联使用。
|
||||||
|
*/
|
||||||
|
public final class SimilarAsinLogSupport {
|
||||||
|
|
||||||
|
private SimilarAsinLogSupport() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 截断前缀保留长度(不含"…"与长度后缀)。 */
|
||||||
|
public static final int TRUNCATE_PREFIX_LENGTH = 2000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 正文截断:长度 ≤ maxChars 原样返回;超过保留前 maxChars 字符并附
|
||||||
|
* "…[total=N chars]" 长度后缀。maxChars ≤ 0 视为不截断。
|
||||||
|
* null 返回空串。
|
||||||
|
*/
|
||||||
|
public static String truncate(String value, int maxChars) {
|
||||||
|
if (value == null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
if (maxChars <= 0 || value.length() <= maxChars) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return value.substring(0, maxChars) + "…[total=" + value.length() + " chars]";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 便捷重载:使用默认前缀长度 TRUNCATE_PREFIX_LENGTH。 */
|
||||||
|
public static String truncate(String value) {
|
||||||
|
return truncate(value, TRUNCATE_PREFIX_LENGTH);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 采样判定:每 everyN 次输出一次(counter % everyN == 0)。
|
||||||
|
* everyN ≤ 0 视为恒采样;counter 为 0 恒采样;计数接近溢出时取模结果仍稳定。
|
||||||
|
*/
|
||||||
|
public static boolean shouldLog(long counter, long everyN) {
|
||||||
|
if (everyN <= 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
long normalized = counter >= 0 ? counter : -counter;
|
||||||
|
return normalized % everyN == 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
+274
@@ -0,0 +1,274 @@
|
|||||||
|
package com.nanri.aiimage.modules.similarasin.util;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.core.type.TypeReference;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.nanri.aiimage.modules.similarasin.model.vo.SimilarAsinParsedRowVo;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
|
import java.lang.management.ManagementFactory;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Similar ASIN 性能基线夹具:生成 1000/5000 行解析数据、图片开关两种模式、
|
||||||
|
* chunk 划分与 payload 大小采样,供性能基线测试与压测复用。
|
||||||
|
* 上限约束:单次最多 MAX_ROWS 行,防止基线夹具本身造成无界内存增长。
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
public class SimilarAsinPerfFixture {
|
||||||
|
|
||||||
|
public static final int MAX_ROWS = 5000;
|
||||||
|
public static final int DEFAULT_CHUNK_SIZE = 200;
|
||||||
|
private static final int GC_ROUNDS = 10;
|
||||||
|
|
||||||
|
private static final String[] COUNTRIES = {"英国", "德国", "法国", "意大利", "西班牙"};
|
||||||
|
private static final String[] TITLES = {
|
||||||
|
"Women Floral Dress Summer Casual",
|
||||||
|
"Men Cotton T-Shirt Crew Neck",
|
||||||
|
"Kids Waterproof Rain Jacket",
|
||||||
|
"Fitness Yoga Pants High Waist",
|
||||||
|
"Home Office Desk Lamp LED",
|
||||||
|
"Stainless Steel Water Bottle 750ml",
|
||||||
|
"Wireless Bluetooth Earbuds Pro",
|
||||||
|
"Pet Grooming Brush Cat Dog"
|
||||||
|
};
|
||||||
|
private static final String[] SKU_PREFIX = {"SKU", "MSKU", "ASIN-ITEM", "PROD"};
|
||||||
|
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
|
public SimilarAsinPerfFixture(ObjectMapper objectMapper) {
|
||||||
|
this.objectMapper = objectMapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 生成 rowCount 行解析行数据;withImages=true 时为每行生成 http 图片 URL。rowCount 超出 [0, MAX_ROWS] 时拒绝。
|
||||||
|
* 所有字段由 sourceFileKey + rowIndex 确定性派生,同一输入必然产生相同输出(幂等)。 */
|
||||||
|
public List<SimilarAsinParsedRowVo> generateRows(String sourceFileKey, int rowCount, boolean withImages) {
|
||||||
|
if (sourceFileKey == null || sourceFileKey.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("sourceFileKey 不能为空");
|
||||||
|
}
|
||||||
|
if (rowCount < 0 || rowCount > MAX_ROWS) {
|
||||||
|
throw new IllegalArgumentException("rowCount 必须在 [0, " + MAX_ROWS + "] 范围内,实际 " + rowCount);
|
||||||
|
}
|
||||||
|
List<SimilarAsinParsedRowVo> rows = new ArrayList<>(rowCount);
|
||||||
|
for (int i = 0; i < rowCount; i++) {
|
||||||
|
SimilarAsinParsedRowVo row = new SimilarAsinParsedRowVo();
|
||||||
|
int rowIndex = i + 1;
|
||||||
|
long seed = (sourceFileKey.hashCode() * 31L + rowIndex) & 0x7fffffffL;
|
||||||
|
String sourceId = String.valueOf(rowIndex);
|
||||||
|
row.setSourceFileKey(sourceFileKey);
|
||||||
|
row.setSourceFilename(sourceFileKey.substring(sourceFileKey.lastIndexOf('/') + 1));
|
||||||
|
row.setRowIndex(rowIndex);
|
||||||
|
row.setSourceId(sourceId);
|
||||||
|
row.setDisplayId(sourceId);
|
||||||
|
row.setRowToken(rowTokenFor(sourceFileKey, rowIndex));
|
||||||
|
row.setAsin(deterministicAsin(seed));
|
||||||
|
row.setCountry(COUNTRIES[(int) (seed >> 5) % COUNTRIES.length]);
|
||||||
|
row.setSku(SKU_PREFIX[(int) (seed >> 9) % SKU_PREFIX.length] + "-" + (1000 + rowIndex));
|
||||||
|
row.setTitle(TITLES[(int) (seed >> 13) % TITLES.length]);
|
||||||
|
if (withImages) {
|
||||||
|
row.setUrl("https://m.media-amazon.com/images/I/" + deterministicAsin(seed ^ 0x5DEDE5B5L) + ".jpg");
|
||||||
|
} else {
|
||||||
|
row.setUrl("");
|
||||||
|
}
|
||||||
|
Map<String, String> values = new LinkedHashMap<>();
|
||||||
|
values.put("id", sourceId);
|
||||||
|
values.put("asin", row.getAsin());
|
||||||
|
values.put("国家", row.getCountry());
|
||||||
|
values.put("价格", String.format("%.2f", 1 + (seed % 9900) / 100.0));
|
||||||
|
values.put("货号", row.getSku());
|
||||||
|
values.put("标题", row.getTitle());
|
||||||
|
if (withImages) {
|
||||||
|
values.put("主图URL", row.getUrl());
|
||||||
|
}
|
||||||
|
row.setValues(values);
|
||||||
|
rows.add(row);
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String rowTokenFor(String sourceFileKey, Integer rowIndex) {
|
||||||
|
return sourceFileKey + "::row::" + rowIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按 chunkSize 顺序划分;chunkSize 必须为正数,行集合不能为 null。 */
|
||||||
|
public List<List<SimilarAsinParsedRowVo>> splitChunks(List<SimilarAsinParsedRowVo> rows, int chunkSize) {
|
||||||
|
if (rows == null) {
|
||||||
|
throw new IllegalArgumentException("rows 不能为 null");
|
||||||
|
}
|
||||||
|
if (chunkSize <= 0) {
|
||||||
|
throw new IllegalArgumentException("chunkSize 必须为正数,实际 " + chunkSize);
|
||||||
|
}
|
||||||
|
List<List<SimilarAsinParsedRowVo>> chunks = new ArrayList<>();
|
||||||
|
if (rows.isEmpty()) {
|
||||||
|
return chunks;
|
||||||
|
}
|
||||||
|
for (int from = 0; from < rows.size(); from += chunkSize) {
|
||||||
|
int to = Math.min(from + chunkSize, rows.size());
|
||||||
|
chunks.add(new ArrayList<>(rows.subList(from, to)));
|
||||||
|
}
|
||||||
|
return chunks;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 采样全量行 payload 大小与 chunk 划分数。序列化失败时向上抛出不产生部分结果。 */
|
||||||
|
public Metrics samplePayload(List<SimilarAsinParsedRowVo> rows, boolean withImages, int chunkSize) {
|
||||||
|
List<List<SimilarAsinParsedRowVo>> chunks = splitChunks(rows, chunkSize);
|
||||||
|
if (rows.isEmpty()) {
|
||||||
|
return new Metrics(0, 0, 0);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
byte[] bytes = objectMapper.writeValueAsString(rows).getBytes(StandardCharsets.UTF_8);
|
||||||
|
return new Metrics(rows.size(), chunks.size(), bytes.length);
|
||||||
|
} catch (Exception ex) {
|
||||||
|
throw new IllegalStateException("payload 采样序列化失败", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public record Metrics(int rowCount, int chunkCount, long payloadBytes) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 端到端基准:生成 → 分 chunk → 序列化全量 payload,记录行数、chunk 数、
|
||||||
|
* payload 字节、组装耗时(毫秒)、吞吐(行/秒)与峰值堆(字节)。
|
||||||
|
* 依赖失败(序列化抛异常)时向上抛出 IllegalStateException,不产生部分结果。
|
||||||
|
*/
|
||||||
|
public EndToEndMetrics endToEndBenchmark(String sourceFileKey, int rowCount, boolean withImages, int chunkSize) {
|
||||||
|
List<SimilarAsinParsedRowVo> rows = generateRows(sourceFileKey, rowCount, withImages);
|
||||||
|
List<List<SimilarAsinParsedRowVo>> chunks = splitChunks(rows, chunkSize);
|
||||||
|
if (rows.isEmpty()) {
|
||||||
|
return new EndToEndMetrics(0, 0, 0, 0, 0, 0);
|
||||||
|
}
|
||||||
|
long startMillis = System.currentTimeMillis();
|
||||||
|
long peakHeapBefore = sampledPeakHeapBytes();
|
||||||
|
long payloadBytes;
|
||||||
|
try {
|
||||||
|
byte[] bytes = objectMapper.writeValueAsBytes(rows);
|
||||||
|
payloadBytes = bytes.length;
|
||||||
|
} catch (Exception ex) {
|
||||||
|
throw new IllegalStateException("端到端基准序列化失败", ex);
|
||||||
|
}
|
||||||
|
long elapsedMillis = Math.max(1L, System.currentTimeMillis() - startMillis);
|
||||||
|
long peakHeap = Math.max(peakHeapBefore, sampledPeakHeapBytes());
|
||||||
|
double throughput = rows.size() * 1000.0 / elapsedMillis;
|
||||||
|
return new EndToEndMetrics(rows.size(), chunks.size(), payloadBytes, elapsedMillis, throughput, peakHeap);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GC 压力分析:多轮生成/序列化/释放循环,每轮记录 GC 计数差与堆峰值。
|
||||||
|
* 返回最后采样样本;rounds 表示实际执行轮数。临时对象随轮释放,堆峰值有界。
|
||||||
|
*/
|
||||||
|
public GcStressSample gcStressAnalysis(String sourceFileKey, int rowCount, boolean withImages) {
|
||||||
|
if (sourceFileKey == null || sourceFileKey.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("sourceFileKey 不能为空");
|
||||||
|
}
|
||||||
|
if (rowCount < 0 || rowCount > MAX_ROWS) {
|
||||||
|
throw new IllegalArgumentException("rowCount 必须在 [0, " + MAX_ROWS + "] 范围内,实际 " + rowCount);
|
||||||
|
}
|
||||||
|
long before = totalGcCount();
|
||||||
|
long peak = sampledPeakHeapBytes();
|
||||||
|
int rounds = 0;
|
||||||
|
for (int round = 1; round <= GC_ROUNDS; round++) {
|
||||||
|
List<SimilarAsinParsedRowVo> rows = generateRows(sourceFileKey, rowCount, withImages);
|
||||||
|
if (!rows.isEmpty()) {
|
||||||
|
try {
|
||||||
|
objectMapper.writeValueAsBytes(rows);
|
||||||
|
} catch (Exception ex) {
|
||||||
|
throw new IllegalStateException("GC 压力分析序列化失败", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rounds++;
|
||||||
|
peak = Math.max(peak, sampledPeakHeapBytes());
|
||||||
|
}
|
||||||
|
return new GcStressSample(rounds, totalGcCount() - before, peak);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** payload 序列化往返兼容回归:序列化全量行 → 反序列化恢复 → 校验行数一致与字段稳定。 */
|
||||||
|
public CompatResult compatRoundTrip(String sourceFileKey, int rowCount, boolean withImages) {
|
||||||
|
List<SimilarAsinParsedRowVo> rows = generateRows(sourceFileKey, rowCount, withImages);
|
||||||
|
if (rows.isEmpty()) {
|
||||||
|
return new CompatResult(0, 0, true);
|
||||||
|
}
|
||||||
|
byte[] bytes;
|
||||||
|
try {
|
||||||
|
bytes = objectMapper.writeValueAsBytes(rows);
|
||||||
|
} catch (Exception ex) {
|
||||||
|
throw new IllegalStateException("兼容回归序列化失败", ex);
|
||||||
|
}
|
||||||
|
List<SimilarAsinParsedRowVo> recovered;
|
||||||
|
try {
|
||||||
|
recovered = objectMapper.readValue(bytes, new TypeReference<List<SimilarAsinParsedRowVo>>() {
|
||||||
|
});
|
||||||
|
} catch (Exception ex) {
|
||||||
|
throw new IllegalStateException("兼容回归反序列化失败", ex);
|
||||||
|
}
|
||||||
|
return new CompatResult(rows.size(), recovered.size(), fieldsStable(rows, recovered));
|
||||||
|
}
|
||||||
|
|
||||||
|
public record EndToEndMetrics(int rowCount, int chunkCount, long payloadBytes, long assembleMillis,
|
||||||
|
double throughputRowsPerSec, long peakHeapBytes) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public record GcStressSample(int rounds, long gcCount, long peakHeapBytes) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public record CompatResult(int rowCount, int recoveredCount, boolean fieldStable) {
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean fieldsStable(List<SimilarAsinParsedRowVo> original, List<SimilarAsinParsedRowVo> recovered) {
|
||||||
|
if (original.size() != recovered.size()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for (int i = 0; i < original.size(); i++) {
|
||||||
|
SimilarAsinParsedRowVo a = original.get(i);
|
||||||
|
SimilarAsinParsedRowVo b = recovered.get(i);
|
||||||
|
if (!Objects.equals(a.getAsin(), b.getAsin())
|
||||||
|
|| !Objects.equals(a.getCountry(), b.getCountry())
|
||||||
|
|| !Objects.equals(a.getSku(), b.getSku())
|
||||||
|
|| !Objects.equals(a.getTitle(), b.getTitle())
|
||||||
|
|| !Objects.equals(a.getUrl(), b.getUrl())
|
||||||
|
|| !Objects.equals(a.getRowToken(), b.getRowToken())
|
||||||
|
|| !Objects.equals(a.getSourceFileKey(), b.getSourceFileKey())) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 采样当前堆已用字节峰值;MXBean 不可用时返回 -1,与 "> 0" 类断言不冲突(GC 场景始终可用)。 */
|
||||||
|
private static long sampledPeakHeapBytes() {
|
||||||
|
try {
|
||||||
|
return Runtime.getRuntime().totalMemory() - Runtime.getRuntime().freeMemory();
|
||||||
|
} catch (Exception ex) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static long totalGcCount() {
|
||||||
|
long total = 0;
|
||||||
|
try {
|
||||||
|
for (java.lang.management.GarbageCollectorMXBean bean : ManagementFactory.getGarbageCollectorMXBeans()) {
|
||||||
|
long count = bean.getCollectionCount();
|
||||||
|
if (count >= 0) {
|
||||||
|
total += count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception ex) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String deterministicAsin(long seed) {
|
||||||
|
StringBuilder sb = new StringBuilder("B0");
|
||||||
|
long state = seed;
|
||||||
|
for (int i = 0; i < 8; i++) {
|
||||||
|
state = state * 6364136223846793005L + 1442695040888963407L;
|
||||||
|
int pick = (int) ((state >>> 33) % 36);
|
||||||
|
sb.append(pick < 10 ? (char) ('0' + pick) : (char) ('A' + pick - 10));
|
||||||
|
}
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
+8
@@ -45,6 +45,14 @@ public interface TaskImageCacheMapper extends BaseMapper<TaskImageCacheEntity> {
|
|||||||
@Select("SELECT image_bytes FROM biz_task_image_cache WHERE url_hash = #{urlHash} LIMIT 1")
|
@Select("SELECT image_bytes FROM biz_task_image_cache WHERE url_hash = #{urlHash} LIMIT 1")
|
||||||
byte[] selectBytesByUrlHash(@Param("urlHash") String urlHash);
|
byte[] selectBytesByUrlHash(@Param("urlHash") String urlHash);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Task 14:批量直读缩略图字节。只 select url_hash + image_bytes 两列,
|
||||||
|
* 避免把整行 entity(含 url 字符串)拉回 JVM;配合 batchSize 分片控制 IN 长度。
|
||||||
|
*/
|
||||||
|
@Select("<script>SELECT url_hash, image_bytes FROM biz_task_image_cache WHERE url_hash IN " +
|
||||||
|
"<foreach collection='urlHashes' item='hash' open='(' separator=',' close=')'>#{hash}</foreach></script>")
|
||||||
|
List<TaskImageCacheEntity> selectBytesByUrlHashes(@Param("urlHashes") List<String> urlHashes);
|
||||||
|
|
||||||
@Select("SELECT COALESCE(SUM(byte_size), 0) FROM biz_task_image_cache")
|
@Select("SELECT COALESCE(SUM(byte_size), 0) FROM biz_task_image_cache")
|
||||||
Long sumByteSize();
|
Long sumByteSize();
|
||||||
|
|
||||||
|
|||||||
+1
@@ -25,6 +25,7 @@ public class FileTaskEntity {
|
|||||||
private String errorMessage;
|
private String errorMessage;
|
||||||
private String createdBy;
|
private String createdBy;
|
||||||
private Long userId;
|
private Long userId;
|
||||||
|
private String ownerInstanceId;
|
||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
private LocalDateTime updatedAt;
|
private LocalDateTime updatedAt;
|
||||||
private LocalDateTime finishedAt;
|
private LocalDateTime finishedAt;
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
ALTER TABLE `biz_file_task`
|
||||||
|
ADD COLUMN `owner_instance_id` VARCHAR(128) NULL DEFAULT NULL COMMENT '任务归属实例 id(原存于 request_json.ownerInstanceId,迁移为显式列)' AFTER `user_id`,
|
||||||
|
ADD KEY `idx_biz_file_task_owner_status_updated` (`owner_instance_id`, `status`, `updated_at`);
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
ALTER TABLE `biz_shop_data_crawl_daily_member`
|
||||||
|
ADD COLUMN `row_payload` MEDIUMTEXT NULL DEFAULT NULL COMMENT '结果快照 JSON(数据层增量模型:整表重建按成员行累积,旧数据为 NULL 时按结果快照兜底)' AFTER `result_id`;
|
||||||
|
|
||||||
+160
@@ -0,0 +1,160 @@
|
|||||||
|
package com.nanri.aiimage.modules.file.service;
|
||||||
|
|
||||||
|
import cn.hutool.core.io.FileUtil;
|
||||||
|
import com.nanri.aiimage.config.StorageProperties;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Task 33:将店铺源文件 key 映射改为确定路径,取消临时目录递归扫描。
|
||||||
|
* saveTempFile 始终把源文件平铺写入 localTempDir/<fileKey>[.<ext>],
|
||||||
|
* 原 findLocalSourceFile 却用 FileUtil.loopFiles 对临时目录递归扫描匹配前缀;
|
||||||
|
* 实现改为确定路径解析:只扫描临时目录根层(非递归),
|
||||||
|
* 子目录中的同名文件不属于 key 映射,不再被递归命中。
|
||||||
|
*/
|
||||||
|
class LocalFileStorageServiceTest {
|
||||||
|
|
||||||
|
@TempDir
|
||||||
|
Path tempDir;
|
||||||
|
|
||||||
|
private StorageProperties storageProperties;
|
||||||
|
private LocalFileStorageService service;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
storageProperties = new StorageProperties();
|
||||||
|
storageProperties.setLocalTempDir(tempDir.toString());
|
||||||
|
service = new LocalFileStorageService(storageProperties);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_033_task_normal_default_path() {
|
||||||
|
// 正常路径:平铺写入的源文件按 key 解析到确定路径,返回真实文件。
|
||||||
|
File source = writeSourceFile("a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6", "xlsx", "sheet1");
|
||||||
|
|
||||||
|
File resolved = service.findLocalSourceFile("a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6");
|
||||||
|
|
||||||
|
assertNotNull(resolved, "确定路径解析到源文件");
|
||||||
|
assertTrue(resolved.isFile());
|
||||||
|
assertEquals(source.getAbsolutePath(), resolved.getAbsolutePath(), "路径与平铺写入一致");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_033_task_normal_multiple_items() {
|
||||||
|
// 批量场景:多个源文件 key 各自解析到自己的文件,互不串扰。
|
||||||
|
for (int i = 0; i < 5; i++) {
|
||||||
|
writeSourceFile(key(i), "xlsx", "content-" + i);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int i = 0; i < 5; i++) {
|
||||||
|
File resolved = service.findLocalSourceFile(key(i));
|
||||||
|
assertNotNull(resolved, "key-" + i + " 可解析");
|
||||||
|
assertEquals("content-" + i, readFile(resolved), "key-" + i + " 内容正确");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_033_task_normal_repeated_operation_is_idempotent() {
|
||||||
|
// 幂等:同一 key 重复解析返回同一文件,结果稳定。
|
||||||
|
writeSourceFile(key(0), "xlsx", "data");
|
||||||
|
|
||||||
|
File first = service.findLocalSourceFile(key(0));
|
||||||
|
File second = service.findLocalSourceFile(key(0));
|
||||||
|
File third = service.findLocalSourceFile(key(0));
|
||||||
|
|
||||||
|
assertNotNull(first);
|
||||||
|
assertEquals(first.getAbsolutePath(), second.getAbsolutePath(), "重复解析路径一致");
|
||||||
|
assertEquals(first.getAbsolutePath(), third.getAbsolutePath());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_033_task_boundary_empty_input() {
|
||||||
|
// 空输入:临时目录为空时返回 null;子目录中的同名文件不属于 key 映射(非递归)。
|
||||||
|
File subDir = new File(tempDir.toFile(), "sub");
|
||||||
|
assertTrue(subDir.mkdirs());
|
||||||
|
writeSourceFileInto(subDir, key(1), "csv", "decoy");
|
||||||
|
|
||||||
|
assertNull(service.findLocalSourceFile(key(1)), "子目录文件不参与确定路径映射");
|
||||||
|
assertNull(service.findLocalSourceFile(key(2)), "不存在的 key 返回 null");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_033_task_boundary_single_item() {
|
||||||
|
// 单元素:单个源文件解析正确,不依赖批量路径。
|
||||||
|
writeSourceFile(key(0), "csv", "single");
|
||||||
|
|
||||||
|
File resolved = service.findLocalSourceFile(key(0));
|
||||||
|
assertNotNull(resolved);
|
||||||
|
assertEquals("single", readFile(resolved));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_033_task_boundary_limit_and_overflow() {
|
||||||
|
// 上限/超限:目录内大量文件时目标 key 仍解析正确,无内存无界增长。
|
||||||
|
for (int i = 0; i < 300; i++) {
|
||||||
|
writeSourceFile(key(i), "xlsx", "bulk-" + i);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int probe : new int[]{0, 150, 299}) {
|
||||||
|
File resolved = service.findLocalSourceFile(key(probe));
|
||||||
|
assertNotNull(resolved, "大量文件中 key-" + probe + " 仍可解析");
|
||||||
|
assertEquals("bulk-" + probe, readFile(resolved));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_033_task_invalid_input_rejected() {
|
||||||
|
// 非法参数:null/空白 key 安全返回 null;含路径分隔符的 key 被拒绝,防止路径穿越。
|
||||||
|
assertNull(service.findLocalSourceFile(null), "null key 安全返回 null");
|
||||||
|
assertNull(service.findLocalSourceFile(" "), "空白 key 安全返回 null");
|
||||||
|
assertNull(service.findLocalSourceFile("../../etc/passwd"), "路径穿越 key 被拒绝");
|
||||||
|
assertNull(service.findLocalSourceFile("sub/" + key(0)), "含分隔符 key 被拒绝");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_033_task_dependency_failure_releases_resources() {
|
||||||
|
// 依赖失败:临时目录不存在时安全返回 null 不抛异常;文件被清理后解析返回 null。
|
||||||
|
StorageProperties missing = new StorageProperties();
|
||||||
|
missing.setLocalTempDir(tempDir.resolve("not-exists").toString());
|
||||||
|
LocalFileStorageService missingDirService = new LocalFileStorageService(missing);
|
||||||
|
assertNull(missingDirService.findLocalSourceFile(key(0)), "目录缺失返回 null 不抛异常");
|
||||||
|
|
||||||
|
File source = writeSourceFile(key(0), "xlsx", "temp");
|
||||||
|
assertNotNull(service.findLocalSourceFile(key(0)));
|
||||||
|
assertTrue(source.delete());
|
||||||
|
assertNull(service.findLocalSourceFile(key(0)), "文件清理后解析返回 null");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String key(int index) {
|
||||||
|
return String.format("%032d", index);
|
||||||
|
}
|
||||||
|
|
||||||
|
private File writeSourceFile(String fileKey, String ext, String content) {
|
||||||
|
return writeSourceFileInto(tempDir.toFile(), fileKey, ext, content);
|
||||||
|
}
|
||||||
|
|
||||||
|
private File writeSourceFileInto(File dir, String fileKey, String ext, String content) {
|
||||||
|
File file = FileUtil.file(dir, fileKey + "." + ext);
|
||||||
|
FileUtil.writeUtf8String(content, file);
|
||||||
|
return file;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String readFile(File file) {
|
||||||
|
try {
|
||||||
|
return Files.readString(file.toPath(), StandardCharsets.UTF_8);
|
||||||
|
} catch (Exception ex) {
|
||||||
|
throw new IllegalStateException("读取测试文件失败", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+603
@@ -0,0 +1,603 @@
|
|||||||
|
package com.nanri.aiimage.modules.shopdatacrawl.service;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
|
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.nanri.aiimage.common.exception.BusinessException;
|
||||||
|
import com.nanri.aiimage.config.InstanceMetadata;
|
||||||
|
import com.nanri.aiimage.config.TaskPressureProperties;
|
||||||
|
import com.nanri.aiimage.modules.file.service.oss.OssStorageService;
|
||||||
|
import com.nanri.aiimage.modules.shopdatacrawl.model.dto.ShopDataCrawlCountryResultDto;
|
||||||
|
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.task.mapper.FileResultMapper;
|
||||||
|
import com.nanri.aiimage.modules.task.mapper.FileTaskMapper;
|
||||||
|
import com.nanri.aiimage.modules.task.mapper.TaskChunkMapper;
|
||||||
|
import com.nanri.aiimage.modules.task.mapper.TaskScopeStateMapper;
|
||||||
|
import com.nanri.aiimage.modules.task.model.entity.FileResultEntity;
|
||||||
|
import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity;
|
||||||
|
import com.nanri.aiimage.modules.task.model.entity.TaskChunkEntity;
|
||||||
|
import com.nanri.aiimage.modules.task.model.entity.TaskScopeStateEntity;
|
||||||
|
import com.nanri.aiimage.modules.task.service.TaskDistributedLockService;
|
||||||
|
import com.nanri.aiimage.modules.task.service.TaskFileJobService;
|
||||||
|
import com.nanri.aiimage.modules.task.service.TaskProgressSnapshotService;
|
||||||
|
import com.nanri.aiimage.modules.task.service.TaskResultItemService;
|
||||||
|
import com.nanri.aiimage.modules.task.service.TransientPayloadStorageService;
|
||||||
|
import com.nanri.aiimage.modules.ziniao.service.ZiniaoShopSwitchService;
|
||||||
|
import org.apache.ibatis.builder.MapperBuilderAssistant;
|
||||||
|
import org.apache.ibatis.session.Configuration;
|
||||||
|
import org.junit.jupiter.api.BeforeAll;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.Spy;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
import org.springframework.dao.DuplicateKeyException;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyLong;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.ArgumentMatchers.nullable;
|
||||||
|
import static org.mockito.Mockito.lenient;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.never;
|
||||||
|
import static org.mockito.Mockito.times;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Task 27:将 chunk 接收改为原子插入/幂等 upsert,减少先查后插。
|
||||||
|
* persistResultChunk 去掉首包提交前的 findResultChunk 预查,改为直接
|
||||||
|
* store payload + insert;唯一索引 uk_task_scope_chunk 兜底幂等 ——
|
||||||
|
* 重复提交(同内容)命中 DuplicateKeyException 后重查 winner 校验,
|
||||||
|
* 并清理本次重存的 payload;空分片(无可处理数据)在落库前被拒绝,
|
||||||
|
* 不创建 RustFS payload 与分片行。
|
||||||
|
*/
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class ShopDataCrawlChunkUpsertTest {
|
||||||
|
|
||||||
|
private static final String MODULE_TYPE = "SHOP_DATA_CRAWL";
|
||||||
|
private static final String SHOP_NAME = "Demo Shop";
|
||||||
|
|
||||||
|
@BeforeAll
|
||||||
|
static void initializeMybatisMetadata() {
|
||||||
|
MapperBuilderAssistant assistant = new MapperBuilderAssistant(new Configuration(), "");
|
||||||
|
TableInfoHelper.initTableInfo(assistant, FileTaskEntity.class);
|
||||||
|
TableInfoHelper.initTableInfo(assistant, FileResultEntity.class);
|
||||||
|
TableInfoHelper.initTableInfo(assistant, TaskChunkEntity.class);
|
||||||
|
TableInfoHelper.initTableInfo(assistant, TaskScopeStateEntity.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Mock private FileTaskMapper fileTaskMapper;
|
||||||
|
@Mock private FileResultMapper fileResultMapper;
|
||||||
|
@Mock private ShopDataCrawlResolveService shopDataCrawlResolveService;
|
||||||
|
@Mock private ShopDataCrawlExcelAssemblyService excelAssemblyService;
|
||||||
|
@Mock private ShopDataCrawlTaskCacheService taskCacheService;
|
||||||
|
@Mock private OssStorageService ossStorageService;
|
||||||
|
@Mock private ZiniaoShopSwitchService ziniaoShopSwitchService;
|
||||||
|
@Mock private TaskPressureProperties taskPressureProperties;
|
||||||
|
@Mock private TaskFileJobService taskFileJobService;
|
||||||
|
@Mock private TaskResultItemService taskResultItemService;
|
||||||
|
@Mock private TaskProgressSnapshotService taskProgressSnapshotService;
|
||||||
|
@Mock private TaskDistributedLockService taskDistributedLockService;
|
||||||
|
@Mock private TaskChunkMapper taskChunkMapper;
|
||||||
|
@Mock private TaskScopeStateMapper taskScopeStateMapper;
|
||||||
|
@Mock private TransientPayloadStorageService transientPayloadStorageService;
|
||||||
|
@Mock private InstanceMetadata instanceMetadata;
|
||||||
|
@Mock private ShopDataCrawlDailyFileService dailyFileService;
|
||||||
|
@Spy private ObjectMapper objectMapper = new ObjectMapper().findAndRegisterModules();
|
||||||
|
|
||||||
|
private ShopDataCrawlTaskService service;
|
||||||
|
|
||||||
|
private final List<TaskChunkEntity> storedChunks = new ArrayList<>();
|
||||||
|
private final List<TaskScopeStateEntity> storedScopes = new ArrayList<>();
|
||||||
|
private final Map<String, String> rustfsPayloads = new LinkedHashMap<>();
|
||||||
|
private FileTaskEntity task;
|
||||||
|
private FileResultEntity result;
|
||||||
|
private int nextPayloadId;
|
||||||
|
/** 模拟依赖故障开关:RustFS 存储失败 / 数据库插入失败 / 唯一键竞态失败。 */
|
||||||
|
private boolean storeFails;
|
||||||
|
private boolean insertFails;
|
||||||
|
private boolean insertDuplicate;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void configureStorage() {
|
||||||
|
service = new ShopDataCrawlTaskService(
|
||||||
|
fileTaskMapper,
|
||||||
|
fileResultMapper,
|
||||||
|
shopDataCrawlResolveService,
|
||||||
|
excelAssemblyService,
|
||||||
|
taskCacheService,
|
||||||
|
ossStorageService,
|
||||||
|
ziniaoShopSwitchService,
|
||||||
|
objectMapper,
|
||||||
|
taskPressureProperties,
|
||||||
|
taskFileJobService,
|
||||||
|
taskResultItemService,
|
||||||
|
taskProgressSnapshotService,
|
||||||
|
taskDistributedLockService,
|
||||||
|
taskChunkMapper,
|
||||||
|
taskScopeStateMapper,
|
||||||
|
transientPayloadStorageService,
|
||||||
|
instanceMetadata,
|
||||||
|
dailyFileService,
|
||||||
|
null);
|
||||||
|
|
||||||
|
storedChunks.clear();
|
||||||
|
storedScopes.clear();
|
||||||
|
rustfsPayloads.clear();
|
||||||
|
nextPayloadId = 0;
|
||||||
|
storeFails = false;
|
||||||
|
insertFails = false;
|
||||||
|
insertDuplicate = false;
|
||||||
|
|
||||||
|
lenient().when(instanceMetadata.getInstanceId()).thenReturn("instance-a");
|
||||||
|
lenient().when(taskDistributedLockService.acquire(eq(MODULE_TYPE), anyLong()))
|
||||||
|
.thenReturn(mock(TaskDistributedLockService.LockHandle.class));
|
||||||
|
lenient().when(taskCacheService.getTaskCacheBatch(any())).thenReturn(Map.of());
|
||||||
|
lenient().when(taskCacheService.getAllShopMergedPayload(anyLong())).thenReturn(Map.of());
|
||||||
|
lenient().when(taskResultItemService.listResultSnapshots(anyLong(), eq(MODULE_TYPE), any())).thenReturn(List.of());
|
||||||
|
lenient().when(taskFileJobService.findAssembleJob(anyLong(), eq(MODULE_TYPE), anyLong())).thenReturn(null);
|
||||||
|
lenient().when(excelAssemblyService.countRows(any())).thenReturn(1);
|
||||||
|
|
||||||
|
lenient().when(fileTaskMapper.selectById(anyLong())).thenAnswer(invocation -> {
|
||||||
|
Long taskId = invocation.getArgument(0);
|
||||||
|
return task != null && Objects.equals(taskId, task.getId()) ? task : null;
|
||||||
|
});
|
||||||
|
lenient().when(fileTaskMapper.updateById(any(FileTaskEntity.class))).thenReturn(1);
|
||||||
|
lenient().when(fileResultMapper.selectById(anyLong())).thenAnswer(invocation -> {
|
||||||
|
Long resultId = invocation.getArgument(0);
|
||||||
|
return result != null && Objects.equals(resultId, result.getId()) ? result : null;
|
||||||
|
});
|
||||||
|
lenient().when(fileResultMapper.selectList(any())).thenAnswer(invocation -> {
|
||||||
|
LambdaQueryWrapper<FileResultEntity> query = invocation.getArgument(0);
|
||||||
|
Long taskId = queryLong(query);
|
||||||
|
return result != null && Objects.equals(taskId, result.getTaskId()) ? List.of(result) : List.of();
|
||||||
|
});
|
||||||
|
lenient().when(fileResultMapper.updateById(any(FileResultEntity.class))).thenReturn(1);
|
||||||
|
|
||||||
|
lenient().when(ziniaoShopSwitchService.normalizeShopName(nullable(String.class))).thenAnswer(invocation -> {
|
||||||
|
String value = invocation.getArgument(0);
|
||||||
|
return value == null ? "" : value.trim();
|
||||||
|
});
|
||||||
|
|
||||||
|
configureTransientPayloadStorage();
|
||||||
|
configureChunkMapper();
|
||||||
|
configureScopeMapper();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_027_chunk_normal_default_path() {
|
||||||
|
// 正常输入:首个分片直接原子插入,不再先查后插——
|
||||||
|
// taskChunkMapper 不出现预查 selectOne,分片行与 RustFS payload 各落一份。
|
||||||
|
givenRunningTask(1001L, 2001L);
|
||||||
|
|
||||||
|
service.submitResult(task.getId(), request(chunk(1, 2, "DE", row("2026-07-25", "B001"))));
|
||||||
|
|
||||||
|
assertEquals(1, storedChunks.size());
|
||||||
|
assertEquals("rustfs:payload-1", storedChunks.get(0).getPayloadJson());
|
||||||
|
assertEquals(1, rustfsPayloads.size());
|
||||||
|
assertEquals(-1, result.getSuccess(), "1/2 未齐,任务继续运行");
|
||||||
|
assertEquals("RUNNING", task.getStatus());
|
||||||
|
verify(taskChunkMapper, times(1)).insert(any(TaskChunkEntity.class));
|
||||||
|
verify(taskChunkMapper, never()).selectOne(any());
|
||||||
|
verify(taskFileJobService, never()).enqueueAssembleResult(anyLong(), eq(MODULE_TYPE), anyLong(), anyString());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_027_chunk_normal_multiple_items() {
|
||||||
|
// 批量场景:乱序分片(2/2 先到、1/2 后到)全部原子插入,合并结果按 chunk_index 稳定,
|
||||||
|
// 全程无预查 selectOne;齐集时触发组装任务。
|
||||||
|
givenRunningTask(1002L, 2002L);
|
||||||
|
|
||||||
|
service.submitResult(task.getId(), request(chunk(2, 2, "UK", row("2026-07-26", "B002"))));
|
||||||
|
|
||||||
|
assertEquals(-1, result.getSuccess());
|
||||||
|
assertEquals(1, storedChunks.size());
|
||||||
|
verify(taskFileJobService, never()).enqueueAssembleResult(anyLong(), eq(MODULE_TYPE), anyLong(), anyString());
|
||||||
|
|
||||||
|
service.submitResult(task.getId(), request(chunk(1, 2, "DE", row("2026-07-25", "B001"))));
|
||||||
|
|
||||||
|
assertEquals(1, result.getSuccess());
|
||||||
|
assertEquals(2, storedChunks.size());
|
||||||
|
assertEquals(2, rustfsPayloads.size());
|
||||||
|
assertTrue(task.getResultJson().indexOf("B001") < task.getResultJson().indexOf("B002"),
|
||||||
|
"合并结果按 chunk_index 顺序稳定");
|
||||||
|
verify(taskChunkMapper, never()).selectOne(any());
|
||||||
|
verify(taskFileJobService).enqueueAssembleResult(task.getId(), MODULE_TYPE, result.getId(),
|
||||||
|
"task:" + task.getId() + ":owner:instance-a");
|
||||||
|
verify(excelAssemblyService, never()).writeWorkbook(any(), any());
|
||||||
|
verify(excelAssemblyService, never()).replaceCountriesWorkbook(any(), any(), any());
|
||||||
|
verify(ossStorageService, never()).uploadResultFile(any(), eq(MODULE_TYPE));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_027_chunk_normal_repeated_operation_is_idempotent() {
|
||||||
|
// 重复执行:同一分片重试靠唯一索引 uk_task_scope_chunk 兜底幂等——
|
||||||
|
// 不产生重复分片行、重试重存的 payload 立即清理,insert 共两次(首次成功+重试被唯一键拒绝)。
|
||||||
|
givenRunningTask(1003L, 2003L);
|
||||||
|
ShopDataCrawlSubmitResultRequest request = request(chunk(1, 2, "DE", row("2026-07-25", "B001")));
|
||||||
|
|
||||||
|
service.submitResult(task.getId(), request);
|
||||||
|
service.submitResult(task.getId(), request);
|
||||||
|
|
||||||
|
assertEquals(1, storedChunks.size(), "重复提交不产生重复分片行");
|
||||||
|
assertEquals(1, rustfsPayloads.size(), "重试重存的 payload 被清理");
|
||||||
|
verify(taskChunkMapper, times(2)).insert(any(TaskChunkEntity.class));
|
||||||
|
verify(transientPayloadStorageService).deletePayloadIfPresent("rustfs:payload-2");
|
||||||
|
assertEquals(-1, result.getSuccess());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_027_chunk_boundary_empty_input() {
|
||||||
|
// 空输入:无可处理数据的分片(空 items / 空白国家)在落库前拒绝,不创建 payload 与分片行。
|
||||||
|
givenRunningTask(1004L, 2004L);
|
||||||
|
|
||||||
|
ShopDataCrawlShopPayloadDto emptyItems = legacyChunk(false, "DE", null);
|
||||||
|
emptyItems.setChunkIndex(1);
|
||||||
|
emptyItems.setChunkTotal(1);
|
||||||
|
emptyItems.setCountryResults(List.of(countryWithItems("DE", List.of())));
|
||||||
|
|
||||||
|
BusinessException error = assertThrows(BusinessException.class, () ->
|
||||||
|
service.submitResult(task.getId(), request(emptyItems)));
|
||||||
|
assertTrue(error.getMessage().contains("内容为空"));
|
||||||
|
|
||||||
|
ShopDataCrawlShopPayloadDto blankCountry = chunk(1, 1, "DE", row("2026-07-25", "B001"));
|
||||||
|
blankCountry.setCountryResults(List.of(countryWithItems(" ", List.of(row("2026-07-25", "B001")))));
|
||||||
|
|
||||||
|
assertThrows(BusinessException.class, () ->
|
||||||
|
service.submitResult(task.getId(), request(blankCountry)));
|
||||||
|
|
||||||
|
assertEquals(0, storedChunks.size(), "空分片不落库");
|
||||||
|
assertEquals(0, rustfsPayloads.size(), "空分片不写 RustFS");
|
||||||
|
verify(taskChunkMapper, never()).insert(any(TaskChunkEntity.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_027_chunk_boundary_single_item() {
|
||||||
|
// 单元素:单分片 1/1 原子插入即齐集,直接合并成功,无预查 selectOne。
|
||||||
|
givenRunningTask(1005L, 2005L);
|
||||||
|
|
||||||
|
service.submitResult(task.getId(), request(chunk(1, 1, "DE", row("2026-07-25", "B001"))));
|
||||||
|
|
||||||
|
assertEquals(1, result.getSuccess());
|
||||||
|
assertEquals(1, storedChunks.size());
|
||||||
|
assertEquals(1, rustfsPayloads.size());
|
||||||
|
assertEquals(1, storedScopes.size());
|
||||||
|
assertEquals(1, storedScopes.get(0).getReceivedChunkCount());
|
||||||
|
assertEquals(1, storedScopes.get(0).getCompleted());
|
||||||
|
assertTrue(task.getResultJson().contains("B001"));
|
||||||
|
verify(taskChunkMapper, never()).selectOne(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_027_chunk_boundary_limit_and_overflow() {
|
||||||
|
// 上限/超限:chunk_index 超 chunk_total 拒绝;5/5 分片全量到达时计数一致、顺序稳定、任务完成,
|
||||||
|
// 期间全部为原子插入(无预查),无重复对象累积。
|
||||||
|
givenRunningTask(1006L, 2006L);
|
||||||
|
|
||||||
|
BusinessException overflow = assertThrows(BusinessException.class, () ->
|
||||||
|
service.submitResult(task.getId(), request(chunk(2, 1, "DE", row("2026-07-25", "B001")))));
|
||||||
|
assertTrue(overflow.getMessage().contains("chunk_index"));
|
||||||
|
|
||||||
|
String[] countries = {"UK", "DE", "FR", "ES", "IT"};
|
||||||
|
String[] dates = {"2026-07-25", "2026-07-26", "2026-07-27", "2026-07-28", "2026-07-29"};
|
||||||
|
for (int i = 1; i <= 5; i++) {
|
||||||
|
service.submitResult(task.getId(), request(chunk(i, 5, countries[i - 1], row(dates[i - 1], "B00" + i))));
|
||||||
|
if (i < 5) {
|
||||||
|
assertEquals(-1, result.getSuccess(), i + "/5 未齐");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals(1, result.getSuccess(), "5/5 齐集后任务完成");
|
||||||
|
assertEquals(5, storedChunks.size());
|
||||||
|
assertEquals(5, storedScopes.get(0).getReceivedChunkCount());
|
||||||
|
assertEquals(1, storedScopes.get(0).getCompleted());
|
||||||
|
String json = task.getResultJson();
|
||||||
|
for (int i = 1; i < 5; i++) {
|
||||||
|
assertTrue(json.indexOf("B00" + i) < json.indexOf("B00" + (i + 1)), "合并顺序按 chunk_index 稳定");
|
||||||
|
}
|
||||||
|
verify(taskChunkMapper, times(5)).insert(any(TaskChunkEntity.class));
|
||||||
|
verify(taskChunkMapper, never()).selectOne(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_027_chunk_invalid_input_rejected() {
|
||||||
|
// 非法参数:非正 chunk_index/chunk_total、跨分片改 chunk_total、同 index 不同内容 →
|
||||||
|
// 项目约定异常及可识别消息;重存 payload 不残留。
|
||||||
|
givenRunningTask(1007L, 2007L);
|
||||||
|
|
||||||
|
BusinessException zeroIndex = assertThrows(BusinessException.class, () ->
|
||||||
|
service.submitResult(task.getId(), request(chunk(0, 1, "DE", row("2026-07-25", "B001")))));
|
||||||
|
assertTrue(zeroIndex.getMessage().contains("chunk_index"));
|
||||||
|
|
||||||
|
BusinessException zeroTotal = assertThrows(BusinessException.class, () ->
|
||||||
|
service.submitResult(task.getId(), request(chunk(1, 0, "DE", row("2026-07-25", "B001")))));
|
||||||
|
assertTrue(zeroTotal.getMessage().contains("chunk_total"));
|
||||||
|
|
||||||
|
service.submitResult(task.getId(), request(chunk(1, 2, "DE", row("2026-07-25", "B001"))));
|
||||||
|
|
||||||
|
BusinessException changedTotal = assertThrows(BusinessException.class, () ->
|
||||||
|
service.submitResult(task.getId(), request(chunk(2, 3, "UK", row("2026-07-26", "B002")))));
|
||||||
|
assertTrue(changedTotal.getMessage().contains("chunk_total"));
|
||||||
|
|
||||||
|
BusinessException differentContent = assertThrows(BusinessException.class, () ->
|
||||||
|
service.submitResult(task.getId(), request(chunk(1, 2, "DE", row("2026-07-25", "B099")))));
|
||||||
|
assertTrue(differentContent.getMessage().contains("不同内容"));
|
||||||
|
|
||||||
|
assertEquals(1, storedChunks.size(), "非法输入不产生额外分片行");
|
||||||
|
assertEquals(1, rustfsPayloads.size(), "同 index 不同内容的重存 payload 被清理");
|
||||||
|
verify(taskChunkMapper, times(2)).insert(any(TaskChunkEntity.class));
|
||||||
|
verify(transientPayloadStorageService).deletePayloadIfPresent("rustfs:payload-2");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_027_chunk_dependency_failure_releases_resources() {
|
||||||
|
// 依赖失败:RustFS 存储异常/插入异常/唯一键竞态 winner 丢失 —— 临时 payload 清理、
|
||||||
|
// 锁释放(故障后可重试成功)、不残留分片行。
|
||||||
|
givenRunningTask(1008L, 2008L);
|
||||||
|
|
||||||
|
storeFails = true;
|
||||||
|
assertThrows(RuntimeException.class, () ->
|
||||||
|
service.submitResult(task.getId(), request(chunk(1, 2, "DE", row("2026-07-25", "B001")))));
|
||||||
|
assertEquals(0, storedChunks.size());
|
||||||
|
assertEquals(0, rustfsPayloads.size());
|
||||||
|
storeFails = false;
|
||||||
|
|
||||||
|
insertFails = true;
|
||||||
|
assertThrows(RuntimeException.class, () ->
|
||||||
|
service.submitResult(task.getId(), request(chunk(1, 2, "DE", row("2026-07-25", "B001")))));
|
||||||
|
assertEquals(0, storedChunks.size(), "插入失败不残留分片行");
|
||||||
|
assertEquals(0, rustfsPayloads.size(), "插入失败清理已存 payload");
|
||||||
|
verify(transientPayloadStorageService).deletePayloadIfPresent("rustfs:payload-1");
|
||||||
|
insertFails = false;
|
||||||
|
|
||||||
|
service.submitResult(task.getId(), request(chunk(1, 2, "DE", row("2026-07-25", "B001"))));
|
||||||
|
assertEquals(1, storedChunks.size(), "故障恢复后重试成功");
|
||||||
|
assertEquals(1, rustfsPayloads.size());
|
||||||
|
assertEquals(-1, result.getSuccess());
|
||||||
|
|
||||||
|
insertDuplicate = true;
|
||||||
|
assertThrows(BusinessException.class, () ->
|
||||||
|
service.submitResult(task.getId(), request(chunk(2, 2, "UK", row("2026-07-26", "B002")))));
|
||||||
|
insertDuplicate = false;
|
||||||
|
|
||||||
|
service.submitResult(task.getId(), request(chunk(2, 2, "UK", row("2026-07-26", "B002"))));
|
||||||
|
|
||||||
|
assertEquals(1, result.getSuccess(), "竞态失败后可重试成功");
|
||||||
|
assertEquals(2, storedChunks.size());
|
||||||
|
assertEquals(2, rustfsPayloads.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void configureTransientPayloadStorage() {
|
||||||
|
lenient().when(transientPayloadStorageService.isSharedWriteEnabled()).thenReturn(true);
|
||||||
|
lenient().when(transientPayloadStorageService.storeChunkPayloadVersioned(
|
||||||
|
anyString(), anyLong(), anyString(), any(), anyString())).thenAnswer(invocation -> {
|
||||||
|
if (storeFails) {
|
||||||
|
throw new RuntimeException("rustfs store down");
|
||||||
|
}
|
||||||
|
String pointer = "rustfs:payload-" + (++nextPayloadId);
|
||||||
|
rustfsPayloads.put(pointer, invocation.getArgument(4));
|
||||||
|
return pointer;
|
||||||
|
});
|
||||||
|
lenient().when(transientPayloadStorageService.extractPointer(any())).thenAnswer(invocation -> {
|
||||||
|
String value = invocation.getArgument(0);
|
||||||
|
if (value == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return value.startsWith("rustfs:") || value.startsWith("local:") || value.startsWith("oss:")
|
||||||
|
? value : null;
|
||||||
|
});
|
||||||
|
lenient().when(transientPayloadStorageService.resolvePayload(any(), any())).thenAnswer(invocation -> {
|
||||||
|
String pointer = invocation.getArgument(0);
|
||||||
|
String payload = rustfsPayloads.get(pointer);
|
||||||
|
if (payload == null) {
|
||||||
|
throw new IllegalStateException("missing test RustFS payload: " + pointer);
|
||||||
|
}
|
||||||
|
return payload;
|
||||||
|
});
|
||||||
|
lenient().doAnswer(invocation -> {
|
||||||
|
rustfsPayloads.remove(invocation.getArgument(0));
|
||||||
|
return null;
|
||||||
|
}).when(transientPayloadStorageService).deletePayloadIfPresent(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void configureChunkMapper() {
|
||||||
|
lenient().when(taskChunkMapper.insert(any(TaskChunkEntity.class))).thenAnswer(invocation -> {
|
||||||
|
if (insertDuplicate) {
|
||||||
|
throw new DuplicateKeyException("duplicate chunk key (race)");
|
||||||
|
}
|
||||||
|
if (insertFails) {
|
||||||
|
throw new RuntimeException("db down");
|
||||||
|
}
|
||||||
|
TaskChunkEntity chunk = invocation.getArgument(0);
|
||||||
|
boolean duplicate = storedChunks.stream().anyMatch(existing ->
|
||||||
|
Objects.equals(existing.getTaskId(), chunk.getTaskId())
|
||||||
|
&& Objects.equals(existing.getScopeHash(), chunk.getScopeHash())
|
||||||
|
&& Objects.equals(existing.getChunkIndex(), chunk.getChunkIndex()));
|
||||||
|
if (duplicate) {
|
||||||
|
throw new DuplicateKeyException("duplicate chunk key: " + chunk.getScopeHash() + "/" + chunk.getChunkIndex());
|
||||||
|
}
|
||||||
|
chunk.setId((long) storedChunks.size() + 1L);
|
||||||
|
storedChunks.add(chunk);
|
||||||
|
return 1;
|
||||||
|
});
|
||||||
|
lenient().when(taskChunkMapper.selectOne(any())).thenAnswer(invocation -> {
|
||||||
|
LambdaQueryWrapper<TaskChunkEntity> query = invocation.getArgument(0);
|
||||||
|
Long taskId = queryLong(query);
|
||||||
|
String scopeHash = queryScopeHash(query);
|
||||||
|
Integer chunkIndex = queryInteger(query);
|
||||||
|
return storedChunks.stream()
|
||||||
|
.filter(chunk -> matchesChunk(chunk, taskId, scopeHash, chunkIndex))
|
||||||
|
.findFirst()
|
||||||
|
.orElse(null);
|
||||||
|
});
|
||||||
|
lenient().when(taskChunkMapper.selectCount(any())).thenAnswer(invocation -> {
|
||||||
|
LambdaQueryWrapper<TaskChunkEntity> query = invocation.getArgument(0);
|
||||||
|
Long taskId = queryLong(query);
|
||||||
|
String scopeHash = queryScopeHash(query);
|
||||||
|
return storedChunks.stream()
|
||||||
|
.filter(chunk -> matchesChunk(chunk, taskId, scopeHash, null))
|
||||||
|
.count();
|
||||||
|
});
|
||||||
|
lenient().when(taskChunkMapper.selectList(any())).thenAnswer(invocation -> {
|
||||||
|
LambdaQueryWrapper<TaskChunkEntity> query = invocation.getArgument(0);
|
||||||
|
Long taskId = queryLong(query);
|
||||||
|
String scopeHash = queryScopeHash(query);
|
||||||
|
return storedChunks.stream()
|
||||||
|
.filter(chunk -> matchesChunk(chunk, taskId, scopeHash, null))
|
||||||
|
.sorted(Comparator.comparing(TaskChunkEntity::getChunkIndex))
|
||||||
|
.toList();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void configureScopeMapper() {
|
||||||
|
lenient().when(taskScopeStateMapper.insert(any(TaskScopeStateEntity.class))).thenAnswer(invocation -> {
|
||||||
|
TaskScopeStateEntity scope = invocation.getArgument(0);
|
||||||
|
scope.setId((long) storedScopes.size() + 1L);
|
||||||
|
storedScopes.add(scope);
|
||||||
|
return 1;
|
||||||
|
});
|
||||||
|
lenient().when(taskScopeStateMapper.selectOne(any())).thenAnswer(invocation -> {
|
||||||
|
LambdaQueryWrapper<TaskScopeStateEntity> query = invocation.getArgument(0);
|
||||||
|
Long taskId = queryLong(query);
|
||||||
|
String scopeHash = queryScopeHash(query);
|
||||||
|
return storedScopes.stream()
|
||||||
|
.filter(scope -> Objects.equals(taskId, scope.getTaskId()))
|
||||||
|
.filter(scope -> scopeHash == null || Objects.equals(scopeHash, scope.getScopeHash()))
|
||||||
|
.findFirst()
|
||||||
|
.orElse(null);
|
||||||
|
});
|
||||||
|
lenient().when(taskScopeStateMapper.updateById(any(TaskScopeStateEntity.class))).thenReturn(1);
|
||||||
|
lenient().when(taskScopeStateMapper.delete(any())).thenAnswer(invocation -> {
|
||||||
|
LambdaQueryWrapper<TaskScopeStateEntity> query = invocation.getArgument(0);
|
||||||
|
Long taskId = queryLong(query);
|
||||||
|
int before = storedScopes.size();
|
||||||
|
storedScopes.removeIf(scope -> taskId == null || Objects.equals(taskId, scope.getTaskId()));
|
||||||
|
return before - storedScopes.size();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean matchesChunk(TaskChunkEntity chunk, Long taskId, String scopeHash, Integer chunkIndex) {
|
||||||
|
return (taskId == null || Objects.equals(taskId, chunk.getTaskId()))
|
||||||
|
&& (scopeHash == null || Objects.equals(scopeHash, chunk.getScopeHash()))
|
||||||
|
&& (chunkIndex == null || Objects.equals(chunkIndex, chunk.getChunkIndex()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Long queryLong(LambdaQueryWrapper<?> query) {
|
||||||
|
query.getSqlSegment();
|
||||||
|
return query.getParamNameValuePairs().values().stream()
|
||||||
|
.filter(Long.class::isInstance)
|
||||||
|
.map(Long.class::cast)
|
||||||
|
.findFirst()
|
||||||
|
.orElse(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Integer queryInteger(LambdaQueryWrapper<?> query) {
|
||||||
|
query.getSqlSegment();
|
||||||
|
return query.getParamNameValuePairs().values().stream()
|
||||||
|
.filter(Integer.class::isInstance)
|
||||||
|
.map(Integer.class::cast)
|
||||||
|
.findFirst()
|
||||||
|
.orElse(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String queryScopeHash(LambdaQueryWrapper<?> query) {
|
||||||
|
query.getSqlSegment();
|
||||||
|
return query.getParamNameValuePairs().values().stream()
|
||||||
|
.filter(String.class::isInstance)
|
||||||
|
.map(String.class::cast)
|
||||||
|
.filter(value -> value.length() == 64)
|
||||||
|
.findFirst()
|
||||||
|
.orElse(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void givenRunningTask(long taskId, long resultId) {
|
||||||
|
task = new FileTaskEntity();
|
||||||
|
task.setId(taskId);
|
||||||
|
task.setUserId(7L);
|
||||||
|
task.setModuleType(MODULE_TYPE);
|
||||||
|
task.setStatus("RUNNING");
|
||||||
|
task.setRequestJson("{\"ownerInstanceId\":\"instance-a\"}");
|
||||||
|
task.setResultJson("[]");
|
||||||
|
task.setCreatedAt(LocalDateTime.now());
|
||||||
|
task.setUpdatedAt(LocalDateTime.now());
|
||||||
|
|
||||||
|
result = new FileResultEntity();
|
||||||
|
result.setId(resultId);
|
||||||
|
result.setTaskId(taskId);
|
||||||
|
result.setUserId(7L);
|
||||||
|
result.setModuleType(MODULE_TYPE);
|
||||||
|
result.setSourceFilename(SHOP_NAME);
|
||||||
|
result.setSourceFileUrl("shop-1");
|
||||||
|
result.setSuccess(-1);
|
||||||
|
result.setCreatedAt(LocalDateTime.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
private ShopDataCrawlSubmitResultRequest request(ShopDataCrawlShopPayloadDto payload) {
|
||||||
|
ShopDataCrawlSubmitResultRequest request = new ShopDataCrawlSubmitResultRequest();
|
||||||
|
request.setShops(List.of(payload));
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ShopDataCrawlShopPayloadDto chunk(int chunkIndex,
|
||||||
|
int chunkTotal,
|
||||||
|
String country,
|
||||||
|
ShopDataCrawlRowDto row) {
|
||||||
|
ShopDataCrawlShopPayloadDto payload = legacyChunk(false, country, row);
|
||||||
|
payload.setChunkIndex(chunkIndex);
|
||||||
|
payload.setChunkTotal(chunkTotal);
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ShopDataCrawlShopPayloadDto legacyChunk(boolean shopDone,
|
||||||
|
String country,
|
||||||
|
ShopDataCrawlRowDto row) {
|
||||||
|
ShopDataCrawlShopPayloadDto payload = new ShopDataCrawlShopPayloadDto();
|
||||||
|
payload.setShopName(SHOP_NAME);
|
||||||
|
if (row != null) {
|
||||||
|
payload.setCountryResults(List.of(country(country, row)));
|
||||||
|
}
|
||||||
|
payload.setShopDone(shopDone);
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ShopDataCrawlCountryResultDto country(String country, ShopDataCrawlRowDto row) {
|
||||||
|
return countryWithItems(country, List.of(row));
|
||||||
|
}
|
||||||
|
|
||||||
|
private ShopDataCrawlCountryResultDto countryWithItems(String country, List<ShopDataCrawlRowDto> items) {
|
||||||
|
ShopDataCrawlCountryResultDto result = new ShopDataCrawlCountryResultDto();
|
||||||
|
result.setCountry(country);
|
||||||
|
result.setItems(items);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ShopDataCrawlRowDto row(String date, String asin) {
|
||||||
|
ShopDataCrawlRowDto row = new ShopDataCrawlRowDto();
|
||||||
|
row.setDate(date);
|
||||||
|
row.setAsin(asin);
|
||||||
|
row.setCommodityImage("https://m.media-amazon.com/images/I/" + asin + ".jpg");
|
||||||
|
row.setInventorySales("10");
|
||||||
|
row.setSalesRank("20");
|
||||||
|
row.setPageViews("30");
|
||||||
|
row.setUnitsSold("40");
|
||||||
|
row.setPrice("50");
|
||||||
|
row.setRecommendedOffer("60");
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
}
|
||||||
+538
@@ -0,0 +1,538 @@
|
|||||||
|
package com.nanri.aiimage.modules.shopdatacrawl.service;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.Wrapper;
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
|
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.nanri.aiimage.common.exception.BusinessException;
|
||||||
|
import com.nanri.aiimage.config.InstanceMetadata;
|
||||||
|
import com.nanri.aiimage.config.TaskPressureProperties;
|
||||||
|
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.task.mapper.FileResultMapper;
|
||||||
|
import com.nanri.aiimage.modules.task.mapper.FileTaskMapper;
|
||||||
|
import com.nanri.aiimage.modules.task.mapper.TaskChunkMapper;
|
||||||
|
import com.nanri.aiimage.modules.task.mapper.TaskScopeStateMapper;
|
||||||
|
import com.nanri.aiimage.modules.task.model.entity.FileResultEntity;
|
||||||
|
import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity;
|
||||||
|
import com.nanri.aiimage.modules.task.model.entity.TaskChunkEntity;
|
||||||
|
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
|
||||||
|
import com.nanri.aiimage.modules.task.model.entity.TaskScopeStateEntity;
|
||||||
|
import com.nanri.aiimage.modules.task.service.TaskDistributedLockService;
|
||||||
|
import com.nanri.aiimage.modules.task.service.TaskFileJobService;
|
||||||
|
import com.nanri.aiimage.modules.task.service.TaskProgressSnapshotService;
|
||||||
|
import com.nanri.aiimage.modules.task.service.TaskResultItemService;
|
||||||
|
import com.nanri.aiimage.modules.task.service.TransientPayloadStorageService;
|
||||||
|
import com.nanri.aiimage.modules.ziniao.service.ZiniaoShopSwitchService;
|
||||||
|
import org.apache.ibatis.builder.MapperBuilderAssistant;
|
||||||
|
import org.apache.ibatis.session.Configuration;
|
||||||
|
import org.junit.jupiter.api.BeforeAll;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.Spy;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.concurrent.atomic.AtomicLong;
|
||||||
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyInt;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyLong;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.ArgumentMatchers.nullable;
|
||||||
|
import static org.mockito.Mockito.doThrow;
|
||||||
|
import static org.mockito.Mockito.lenient;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.never;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Task 35:将每日累计文件改为数据层增量模型,避免每次下载并重写完整 XLSX。
|
||||||
|
* 新结果归档时把该结果的快照 JSON(row_payload,V93 迁移新增列)写入 daily_member 行;
|
||||||
|
* 重新生成整表时按成员行(createdAt,id 升序)从数据层累积重建快照列表,
|
||||||
|
* 只做一次 writeWorkbook + 上传,不再读回旧累计对象(readObjectBytes)并整表重写
|
||||||
|
* (replaceCountriesWorkbook)。历史成员行无 payload 时按结果数据兜底重建,兼容旧数据。
|
||||||
|
*/
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class ShopDataCrawlDailyFileIncrementalTest {
|
||||||
|
|
||||||
|
private static final String MODULE_TYPE = "SHOP_DATA_CRAWL";
|
||||||
|
private static final String SHOP_NAME = "shop-a";
|
||||||
|
|
||||||
|
@BeforeAll
|
||||||
|
static void initializeMybatisMetadata() {
|
||||||
|
MapperBuilderAssistant assistant = new MapperBuilderAssistant(new Configuration(), "");
|
||||||
|
TableInfoHelper.initTableInfo(assistant, FileTaskEntity.class);
|
||||||
|
TableInfoHelper.initTableInfo(assistant, FileResultEntity.class);
|
||||||
|
TableInfoHelper.initTableInfo(assistant, TaskChunkEntity.class);
|
||||||
|
TableInfoHelper.initTableInfo(assistant, TaskScopeStateEntity.class);
|
||||||
|
TableInfoHelper.initTableInfo(assistant, ShopDataCrawlDailyFileEntity.class);
|
||||||
|
TableInfoHelper.initTableInfo(assistant, ShopDataCrawlDailyMemberEntity.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Mock private FileTaskMapper fileTaskMapper;
|
||||||
|
@Mock private FileResultMapper fileResultMapper;
|
||||||
|
@Mock private ShopDataCrawlResolveService shopDataCrawlResolveService;
|
||||||
|
@Mock private ShopDataCrawlExcelAssemblyService excelAssemblyService;
|
||||||
|
@Mock private ShopDataCrawlTaskCacheService taskCacheService;
|
||||||
|
@Mock private OssStorageService ossStorageService;
|
||||||
|
@Mock private ZiniaoShopSwitchService ziniaoShopSwitchService;
|
||||||
|
@Mock private TaskPressureProperties taskPressureProperties;
|
||||||
|
@Mock private TaskFileJobService taskFileJobService;
|
||||||
|
@Mock private TaskResultItemService taskResultItemService;
|
||||||
|
@Mock private TaskProgressSnapshotService taskProgressSnapshotService;
|
||||||
|
@Mock private TaskDistributedLockService taskDistributedLockService;
|
||||||
|
@Mock private TaskChunkMapper taskChunkMapper;
|
||||||
|
@Mock private TaskScopeStateMapper taskScopeStateMapper;
|
||||||
|
@Mock private TransientPayloadStorageService transientPayloadStorageService;
|
||||||
|
@Mock private InstanceMetadata instanceMetadata;
|
||||||
|
@Mock private ShopDataCrawlDailyFileService dailyFileService;
|
||||||
|
@Spy private ObjectMapper objectMapper = new ObjectMapper().findAndRegisterModules();
|
||||||
|
|
||||||
|
private ShopDataCrawlTaskService service;
|
||||||
|
|
||||||
|
/** 内存中的结果行表(listTaskRows 读取源 + updateById 回写目标)。 */
|
||||||
|
private final List<FileResultEntity> dbResultRows = new ArrayList<>();
|
||||||
|
/** 内存中的 daily_file 表。 */
|
||||||
|
private final List<ShopDataCrawlDailyFileEntity> dbDailyFiles = new ArrayList<>();
|
||||||
|
/** 内存中的 daily_member 表(row_payload 落在成员行上)。 */
|
||||||
|
private final List<ShopDataCrawlDailyMemberEntity> dbMembers = new ArrayList<>();
|
||||||
|
private final AtomicLong memberIdSeq = new AtomicLong(1000);
|
||||||
|
private long nextResultId = 7100;
|
||||||
|
private String lastUploadedObjectKey;
|
||||||
|
/** 最近一次整表组装时交给 writeWorkbook 的快照列表(验证数据层累积与顺序)。 */
|
||||||
|
private List<ShopDataCrawlResultItemVo> lastAssembledItems = List.of();
|
||||||
|
/** 最近一次从 dailyFileService.acquireLock 获取的锁句柄(验证失败路径释放)。 */
|
||||||
|
private final AtomicReference<TaskDistributedLockService.LockHandle> lastLock = new AtomicReference<>();
|
||||||
|
private Long lastJobTaskId;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void configureStorage() {
|
||||||
|
service = new ShopDataCrawlTaskService(
|
||||||
|
fileTaskMapper,
|
||||||
|
fileResultMapper,
|
||||||
|
shopDataCrawlResolveService,
|
||||||
|
excelAssemblyService,
|
||||||
|
taskCacheService,
|
||||||
|
ossStorageService,
|
||||||
|
ziniaoShopSwitchService,
|
||||||
|
objectMapper,
|
||||||
|
taskPressureProperties,
|
||||||
|
taskFileJobService,
|
||||||
|
taskResultItemService,
|
||||||
|
taskProgressSnapshotService,
|
||||||
|
taskDistributedLockService,
|
||||||
|
taskChunkMapper,
|
||||||
|
taskScopeStateMapper,
|
||||||
|
transientPayloadStorageService,
|
||||||
|
instanceMetadata,
|
||||||
|
dailyFileService,
|
||||||
|
null);
|
||||||
|
|
||||||
|
dbResultRows.clear();
|
||||||
|
dbDailyFiles.clear();
|
||||||
|
dbMembers.clear();
|
||||||
|
memberIdSeq.set(1000);
|
||||||
|
nextResultId = 7100;
|
||||||
|
lastUploadedObjectKey = null;
|
||||||
|
lastAssembledItems = List.of();
|
||||||
|
lastLock.set(null);
|
||||||
|
lastJobTaskId = null;
|
||||||
|
|
||||||
|
lenient().when(instanceMetadata.getInstanceId()).thenReturn("instance-a");
|
||||||
|
lenient().when(taskDistributedLockService.acquire(eq(MODULE_TYPE), anyLong()))
|
||||||
|
.thenReturn(mock(TaskDistributedLockService.LockHandle.class));
|
||||||
|
lenient().when(taskCacheService.getTaskCacheBatch(any())).thenReturn(Map.of());
|
||||||
|
lenient().when(taskCacheService.getTaskHeartbeatMillisBatch(any())).thenReturn(Map.of());
|
||||||
|
lenient().when(taskFileJobService.findAssembleJob(anyLong(), eq(MODULE_TYPE), anyLong())).thenReturn(null);
|
||||||
|
lenient().when(taskFileJobService.countUnfinishedAssembleJobs(anyLong(), eq(MODULE_TYPE))).thenReturn(0L);
|
||||||
|
lenient().doNothing().when(taskResultItemService)
|
||||||
|
.replaceTaskSnapshots(anyLong(), eq(MODULE_TYPE), any(), any());
|
||||||
|
lenient().doNothing().when(taskProgressSnapshotService)
|
||||||
|
.save(anyLong(), any(), any(), anyInt(), anyInt(), anyInt(), any(), any(), any());
|
||||||
|
lenient().when(ziniaoShopSwitchService.normalizeShopName(nullable(String.class))).thenAnswer(invocation -> {
|
||||||
|
String value = invocation.getArgument(0);
|
||||||
|
return value == null ? "" : value.trim();
|
||||||
|
});
|
||||||
|
lenient().when(fileTaskMapper.selectById(anyLong())).thenReturn(null);
|
||||||
|
lenient().when(fileTaskMapper.updateById(any(FileTaskEntity.class))).thenReturn(1);
|
||||||
|
lenient().when(fileResultMapper.selectBatchIds(any())).thenReturn(List.of());
|
||||||
|
lenient().when(fileResultMapper.selectCount(any())).thenReturn(0L);
|
||||||
|
lenient().when(ossStorageService.uploadResultFile(any(), eq(MODULE_TYPE))).thenAnswer(invocation -> {
|
||||||
|
String key = "oss/daily/" + System.nanoTime() + ".xlsx";
|
||||||
|
lastUploadedObjectKey = key;
|
||||||
|
return key;
|
||||||
|
});
|
||||||
|
lenient().when(ossStorageService.readObjectBytes(anyString())).thenReturn(new byte[0]);
|
||||||
|
lenient().doNothing().when(ossStorageService).deleteObject(anyString());
|
||||||
|
|
||||||
|
// 每次整表组装都捕获交给 writeWorkbook 的快照列表(数据层累积内容与顺序)。
|
||||||
|
lenient().doAnswer(invocation -> {
|
||||||
|
lastAssembledItems = new ArrayList<>(invocation.getArgument(1));
|
||||||
|
return lastAssembledItems.size();
|
||||||
|
}).when(excelAssemblyService).writeWorkbook(any(), any());
|
||||||
|
lenient().when(excelAssemblyService.countRows(any())).thenAnswer(invocation -> {
|
||||||
|
List<?> items = invocation.getArgument(0);
|
||||||
|
return items == null ? 0 : items.size();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 店铺级锁:每次返回独立 mock 句柄,供失败路径验证 close()。
|
||||||
|
lenient().when(dailyFileService.acquireLock(anyLong(), anyString())).thenAnswer(invocation -> {
|
||||||
|
TaskDistributedLockService.LockHandle handle = mock(TaskDistributedLockService.LockHandle.class);
|
||||||
|
lastLock.set(handle);
|
||||||
|
return handle;
|
||||||
|
});
|
||||||
|
lenient().when(dailyFileService.currentBusinessDate()).thenReturn(LocalDate.of(2026, 8, 29));
|
||||||
|
lenient().when(dailyFileService.currentBusinessDateTime()).thenReturn(LocalDateTime.of(2026, 8, 29, 12, 0));
|
||||||
|
lenient().when(dailyFileService.shopKeyHash(anyString())).thenAnswer(invocation -> {
|
||||||
|
String key = invocation.getArgument(0);
|
||||||
|
return key == null ? null : "hash:" + key;
|
||||||
|
});
|
||||||
|
lenient().when(dailyFileService.shopKey(any(FileResultEntity.class))).thenAnswer(invocation -> {
|
||||||
|
FileResultEntity row = invocation.getArgument(0);
|
||||||
|
return row == null ? null : row.getSourceFilename();
|
||||||
|
});
|
||||||
|
lenient().when(dailyFileService.findForUpdate(anyLong(), anyString(), any()))
|
||||||
|
.thenAnswer(invocation -> findDailyFile(invocation.getArgument(0), invocation.getArgument(1)));
|
||||||
|
lenient().when(dailyFileService.findOlder(anyLong(), anyString(), any())).thenReturn(List.of());
|
||||||
|
lenient().when(dailyFileService.findByLatestResultId(anyLong())).thenReturn(List.of());
|
||||||
|
lenient().when(dailyFileService.findById(anyLong())).thenReturn(null);
|
||||||
|
lenient().when(dailyFileService.countObjectReferences(anyString())).thenReturn(0L);
|
||||||
|
lenient().doNothing().when(dailyFileService).deleteDailyFile(anyLong());
|
||||||
|
lenient().doNothing().when(dailyFileService).deleteMembersForResults(any());
|
||||||
|
captureDailyFilePersistence();
|
||||||
|
captureMemberInserts();
|
||||||
|
captureResultUpdates();
|
||||||
|
|
||||||
|
// 结果行读取:listTaskRows 按 taskId+moduleType 过滤并升序;其他查询返回空。
|
||||||
|
lenient().when(fileResultMapper.selectList(any())).thenAnswer(invocation -> {
|
||||||
|
Wrapper<FileResultEntity> wrapper = invocation.getArgument(0);
|
||||||
|
if (wrapper instanceof LambdaQueryWrapper<?> query
|
||||||
|
&& query.getSqlSegment() != null && query.getSqlSegment().contains("taskId")) {
|
||||||
|
List<FileResultEntity> rows = dbResultRows.stream()
|
||||||
|
.filter(r -> Objects.equals(r.getTaskId(), lastJobTaskId))
|
||||||
|
.sorted(Comparator.comparing(FileResultEntity::getId))
|
||||||
|
.toList();
|
||||||
|
return new ArrayList<>(rows);
|
||||||
|
}
|
||||||
|
return List.of();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_035_daily_file_normal_default_path() {
|
||||||
|
// 正常路径:新结果归档时不再下载并重写旧累计对象,而是把结果快照
|
||||||
|
// payload 写入成员行(数据层增量),新对象只由本次快照生成。
|
||||||
|
FileResultEntity row = addResultRow(7101L, 1L, 1, SHOP_NAME, null);
|
||||||
|
processJob(1L, List.of(row), snapshot(7101L, SHOP_NAME, 3));
|
||||||
|
|
||||||
|
ShopDataCrawlDailyMemberEntity member = soleMember();
|
||||||
|
assertNotNull(member, "累计文件归档后存在成员行");
|
||||||
|
assertNotNull(member.getRowPayload(), "成员行写入行级 payload");
|
||||||
|
assertTrue(member.getRowPayload().contains(SHOP_NAME), "payload 是结果快照的 JSON 序列化");
|
||||||
|
assertTrue(member.getRowPayload().contains("\"resultId\":7101"), "payload 携带结果标识");
|
||||||
|
assertNotNull(lastUploadedObjectKey, "增量路径上传了新对象");
|
||||||
|
assertEquals(1, lastAssembledItems.size(), "新对象只由本次结果快照生成");
|
||||||
|
verify(ossStorageService, never()).readObjectBytes(anyString());
|
||||||
|
verify(excelAssemblyService, never()).replaceCountriesWorkbook(any(), any(), any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_035_daily_file_normal_multiple_items() {
|
||||||
|
// 多结果批量场景:每个结果各自归档,成员行按创建顺序累积,payload 齐全且顺序稳定。
|
||||||
|
processJob(1L, List.of(addResultRow(7102L, 1L, 1, SHOP_NAME, null)), snapshot(7102L, SHOP_NAME, 2));
|
||||||
|
processJob(2L, List.of(addResultRow(7103L, 2L, 1, SHOP_NAME, null)), snapshot(7103L, SHOP_NAME, 5));
|
||||||
|
|
||||||
|
List<ShopDataCrawlDailyMemberEntity> members = dbMembers.stream()
|
||||||
|
.sorted(Comparator.comparing(ShopDataCrawlDailyMemberEntity::getResultId))
|
||||||
|
.toList();
|
||||||
|
assertEquals(2, members.size(), "两个结果各有一个成员行");
|
||||||
|
assertEquals(7102L, members.get(0).getResultId());
|
||||||
|
assertEquals(7103L, members.get(1).getResultId());
|
||||||
|
assertTrue(members.get(0).getRowPayload().contains("\"resultId\":7102"), "首个结果 payload 齐全");
|
||||||
|
assertTrue(members.get(1).getRowPayload().contains("\"resultId\":7103"), "后续结果 payload 齐全");
|
||||||
|
|
||||||
|
// 整表重建直接来自数据层:一次 writeWorkbook,绝不读回旧对象。
|
||||||
|
triggerRebuild();
|
||||||
|
assertEquals(3, lastAssembledItems.size(), "整表重建累积全部成员快照");
|
||||||
|
List<Long> assembledResultIds = lastAssembledItems.stream()
|
||||||
|
.map(ShopDataCrawlResultItemVo::getResultId).toList();
|
||||||
|
assertEquals(List.of(7102L, 7103L, 900L), assembledResultIds, "累积顺序稳定:按成员创建顺序 + 新结果");
|
||||||
|
verify(ossStorageService, never()).readObjectBytes(anyString());
|
||||||
|
verify(excelAssemblyService, never()).replaceCountriesWorkbook(any(), any(), any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_035_daily_file_normal_repeated_operation_is_idempotent() {
|
||||||
|
// 幂等:同一结果重复归档不产生第二个成员行、不重复上传对象;
|
||||||
|
// 已归档结果再次提交走 alreadyArchived 快捷路径,不触碰数据层。
|
||||||
|
FileResultEntity row = addResultRow(7104L, 1L, 1, SHOP_NAME, null);
|
||||||
|
processJob(1L, List.of(row), snapshot(7104L, SHOP_NAME, 2));
|
||||||
|
int membersAfterFirst = dbMembers.size();
|
||||||
|
String objectAfterFirst = lastUploadedObjectKey;
|
||||||
|
processJob(1L, List.of(row), snapshot(7104L, SHOP_NAME, 2));
|
||||||
|
|
||||||
|
assertEquals(1, membersAfterFirst, "首次归档只有一个成员行");
|
||||||
|
assertEquals(1, dbMembers.size(), "重复归档不产生重复成员行");
|
||||||
|
assertEquals(objectAfterFirst, lastUploadedObjectKey, "重复归档复用既有对象,不重复上传");
|
||||||
|
verify(ossStorageService, never()).readObjectBytes(anyString());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_035_daily_file_boundary_empty_input() {
|
||||||
|
// 空输入:没有任何成功结果时安全跳过,不创建成员、不上传对象。
|
||||||
|
FileResultEntity row = addResultRow(7105L, 1L, 0, SHOP_NAME, null);
|
||||||
|
processJob(1L, List.of(row), snapshot(7105L, SHOP_NAME, 0));
|
||||||
|
|
||||||
|
assertTrue(dbMembers.isEmpty(), "无成功结果不创建成员行");
|
||||||
|
assertTrue(dbDailyFiles.isEmpty(), "无成功结果不创建累计文件");
|
||||||
|
verify(ossStorageService, never()).uploadResultFile(any(), eq(MODULE_TYPE));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_035_daily_file_boundary_single_item() {
|
||||||
|
// 单元素:单结果归档不依赖批量路径,成员行与累计文件各一。
|
||||||
|
FileResultEntity row = addResultRow(7106L, 1L, 1, SHOP_NAME, null);
|
||||||
|
processJob(1L, List.of(row), snapshot(7106L, SHOP_NAME, 1));
|
||||||
|
|
||||||
|
assertEquals(1, dbMembers.size(), "单结果一个成员行");
|
||||||
|
assertEquals(1, dbDailyFiles.size(), "单结果一个累计文件");
|
||||||
|
assertNotNull(dbDailyFiles.get(0).getResultFileUrl());
|
||||||
|
assertEquals(SHOP_NAME, dbDailyFiles.get(0).getShopKey());
|
||||||
|
assertEquals(1, lastAssembledItems.size(), "单结果组装一次");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_035_daily_file_boundary_limit_and_overflow() {
|
||||||
|
// 上限/超限:达到批量上限(dbSelectBatchSize=200)并超出 10 个后,
|
||||||
|
// 成员行全部保留且无重复,整表重建从数据层累积全部快照,不发生无界内存增长。
|
||||||
|
lenient().when(taskPressureProperties.getDbSelectBatchSize()).thenReturn(200);
|
||||||
|
int limit = 210;
|
||||||
|
for (int i = 0; i < limit; i++) {
|
||||||
|
long resultId = nextResultId++;
|
||||||
|
long taskId = i + 1L;
|
||||||
|
FileResultEntity row = addResultRow(resultId, taskId, 1, SHOP_NAME, null);
|
||||||
|
processJob(taskId, List.of(row), snapshot(resultId, SHOP_NAME, 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals(limit, dbMembers.size(), "超过上限数量的成员行全部保留,无丢失");
|
||||||
|
assertEquals(limit, dbMembers.stream()
|
||||||
|
.map(ShopDataCrawlDailyMemberEntity::getResultId).distinct().count(), "成员结果无重复");
|
||||||
|
assertEquals(limit, lastAssembledItems.size(), "整表重建按数据层累积全部快照");
|
||||||
|
assertEquals(limit, lastAssembledItems.stream()
|
||||||
|
.map(ShopDataCrawlResultItemVo::getResultId).distinct().count(), "组装快照无重复");
|
||||||
|
verify(ossStorageService, never()).readObjectBytes(anyString());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_035_daily_file_invalid_input_rejected() {
|
||||||
|
// 非法参数:空 job 直接拒绝;结果全部失败时抛出可识别的 BusinessException。
|
||||||
|
assertThrows(BusinessException.class, () -> service.processResultFileJob(null),
|
||||||
|
"空 job 抛出项目约定异常");
|
||||||
|
|
||||||
|
FileResultEntity row = addResultRow(7107L, 1L, 2, SHOP_NAME, null);
|
||||||
|
Exception ex = assertThrows(BusinessException.class,
|
||||||
|
() -> processJob(1L, List.of(row), null));
|
||||||
|
assertTrue(ex.getMessage().contains("没有可生成的店铺数据抓取结果"), "无成功结果时错误消息可识别");
|
||||||
|
assertTrue(dbMembers.isEmpty(), "失败路径不留下成员行");
|
||||||
|
assertTrue(dbDailyFiles.isEmpty(), "失败路径不留下累计文件");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_035_daily_file_dependency_failure_releases_resources() {
|
||||||
|
// 依赖失败:Excel 生成失败时错误可恢复,店铺级锁已释放、
|
||||||
|
// 临时文件已清理、不残留成员/累计文件、不上传对象。
|
||||||
|
doThrow(new BusinessException("模板不可用"))
|
||||||
|
.when(excelAssemblyService).writeWorkbook(any(), any());
|
||||||
|
FileResultEntity row = addResultRow(7108L, 1L, 1, SHOP_NAME, null);
|
||||||
|
assertThrows(BusinessException.class,
|
||||||
|
() -> processJob(1L, List.of(row), snapshot(7108L, SHOP_NAME, 1)));
|
||||||
|
|
||||||
|
assertTrue(dbMembers.isEmpty(), "生成失败不残留成员行");
|
||||||
|
assertTrue(dbDailyFiles.isEmpty(), "生成失败不残留累计文件");
|
||||||
|
assertNotNull(lastLock.get(), "失败路径已获取店铺级锁");
|
||||||
|
verify(lastLock.get()).close();
|
||||||
|
assertTrue(lastUploadedObjectKey == null, "失败路径不上传对象");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- helpers ----
|
||||||
|
|
||||||
|
private FileResultEntity addResultRow(long id, long taskId, int success, String shopName, String resultFileUrl) {
|
||||||
|
FileResultEntity row = new FileResultEntity();
|
||||||
|
row.setId(id);
|
||||||
|
row.setTaskId(taskId);
|
||||||
|
row.setModuleType(MODULE_TYPE);
|
||||||
|
row.setSuccess(success);
|
||||||
|
row.setSourceFilename(shopName);
|
||||||
|
row.setSourceFileUrl("shop-id-" + id);
|
||||||
|
row.setUserId(7L);
|
||||||
|
row.setCreatedAt(LocalDateTime.now());
|
||||||
|
row.setResultFileUrl(resultFileUrl);
|
||||||
|
dbResultRows.add(row);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ShopDataCrawlResultItemVo snapshot(long resultId, String shopName, int rows) {
|
||||||
|
ShopDataCrawlResultItemVo item = new ShopDataCrawlResultItemVo();
|
||||||
|
item.setResultId(resultId);
|
||||||
|
item.setTaskId(1L);
|
||||||
|
item.setShopName(shopName);
|
||||||
|
item.setShopId("shop-id-" + resultId);
|
||||||
|
item.setSuccess(true);
|
||||||
|
item.setMatched(true);
|
||||||
|
item.setTaskStatus("SUCCESS");
|
||||||
|
item.setCountryCodes(List.of("DE"));
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void processJob(long jobTaskId, List<FileResultEntity> rows, ShopDataCrawlResultItemVo snapshot) {
|
||||||
|
lastJobTaskId = jobTaskId;
|
||||||
|
TaskFileJobEntity job = new TaskFileJobEntity();
|
||||||
|
job.setId(jobTaskId);
|
||||||
|
job.setTaskId(jobTaskId);
|
||||||
|
job.setModuleType(MODULE_TYPE);
|
||||||
|
FileTaskEntity task = taskEntity(jobTaskId);
|
||||||
|
lenient().when(fileTaskMapper.selectById(jobTaskId)).thenReturn(task);
|
||||||
|
lenient().when(taskResultItemService.listResultSnapshots(eq(jobTaskId), eq(MODULE_TYPE), any()))
|
||||||
|
.thenReturn(snapshot == null ? List.of() : List.of(snapshot));
|
||||||
|
service.processResultFileJob(job);
|
||||||
|
}
|
||||||
|
|
||||||
|
private FileTaskEntity taskEntity(long taskId) {
|
||||||
|
FileTaskEntity task = new FileTaskEntity();
|
||||||
|
task.setId(taskId);
|
||||||
|
task.setModuleType(MODULE_TYPE);
|
||||||
|
task.setStatus("RUNNING");
|
||||||
|
task.setUserId(7L);
|
||||||
|
task.setOwnerInstanceId("instance-a");
|
||||||
|
task.setRequestJson("{\"ownerInstanceId\":\"instance-a\"}");
|
||||||
|
task.setResultJson("[]");
|
||||||
|
task.setCreatedAt(LocalDateTime.now());
|
||||||
|
task.setUpdatedAt(LocalDateTime.now());
|
||||||
|
return task;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 模拟 fileResultMapper.updateById 对内存结果行的回写。 */
|
||||||
|
private void captureResultUpdates() {
|
||||||
|
lenient().when(fileResultMapper.updateById(any(FileResultEntity.class))).thenAnswer(invocation -> {
|
||||||
|
FileResultEntity updated = invocation.getArgument(0);
|
||||||
|
for (int i = 0; i < dbResultRows.size(); i++) {
|
||||||
|
if (Objects.equals(dbResultRows.get(i).getId(), updated.getId())) {
|
||||||
|
dbResultRows.set(i, updated);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dbResultRows.add(updated);
|
||||||
|
return 1;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 模拟 dailyMemberMapper 插入(含 row_payload):写入内存成员表并回填 id,重复唯一键返回 false。 */
|
||||||
|
private void captureMemberInserts() {
|
||||||
|
lenient().when(dailyFileService.addMemberWithPayload(anyLong(), anyLong(), anyLong(), anyString()))
|
||||||
|
.thenAnswer(invocation -> {
|
||||||
|
long dailyFileId = invocation.getArgument(0);
|
||||||
|
long taskId = invocation.getArgument(1);
|
||||||
|
long resultId = invocation.getArgument(2);
|
||||||
|
String rowPayload = invocation.getArgument(3);
|
||||||
|
boolean duplicate = dbMembers.stream().anyMatch(m ->
|
||||||
|
Objects.equals(m.getDailyFileId(), dailyFileId)
|
||||||
|
&& Objects.equals(m.getResultId(), resultId));
|
||||||
|
if (duplicate) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
ShopDataCrawlDailyMemberEntity member = new ShopDataCrawlDailyMemberEntity();
|
||||||
|
member.setId(memberIdSeq.incrementAndGet());
|
||||||
|
member.setDailyFileId(dailyFileId);
|
||||||
|
member.setTaskId(taskId);
|
||||||
|
member.setResultId(resultId);
|
||||||
|
member.setRowPayload(rowPayload);
|
||||||
|
member.setCreatedAt(LocalDateTime.of(2026, 8, 29, 12, 0).plusMinutes(dbMembers.size()));
|
||||||
|
dbMembers.add(member);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 模拟 daily_file 与 daily_member 读取:findForUpdate 命中内存表,listMembers 升序返回。 */
|
||||||
|
private void captureDailyFilePersistence() {
|
||||||
|
lenient().doAnswer(invocation -> {
|
||||||
|
ShopDataCrawlDailyFileEntity entity = invocation.getArgument(0);
|
||||||
|
entity.setId(5000L + dbDailyFiles.size() + 1);
|
||||||
|
dbDailyFiles.add(entity);
|
||||||
|
return null;
|
||||||
|
}).when(dailyFileService).insert(any(ShopDataCrawlDailyFileEntity.class));
|
||||||
|
lenient().doAnswer(invocation -> {
|
||||||
|
ShopDataCrawlDailyFileEntity entity = invocation.getArgument(0);
|
||||||
|
for (int i = 0; i < dbDailyFiles.size(); i++) {
|
||||||
|
ShopDataCrawlDailyFileEntity existing = dbDailyFiles.get(i);
|
||||||
|
if (Objects.equals(existing.getUserId(), entity.getUserId())
|
||||||
|
&& Objects.equals(existing.getShopKeyHash(), entity.getShopKeyHash())
|
||||||
|
&& Objects.equals(existing.getBusinessDate(), entity.getBusinessDate())) {
|
||||||
|
entity.setId(existing.getId());
|
||||||
|
dbDailyFiles.set(i, entity);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
entity.setId(5000L + dbDailyFiles.size() + 1);
|
||||||
|
dbDailyFiles.add(entity);
|
||||||
|
return null;
|
||||||
|
}).when(dailyFileService).update(any(ShopDataCrawlDailyFileEntity.class));
|
||||||
|
lenient().when(dailyFileService.listMembers(anyLong())).thenAnswer(invocation -> {
|
||||||
|
long dailyFileId = invocation.getArgument(0);
|
||||||
|
return dbMembers.stream()
|
||||||
|
.filter(m -> Objects.equals(m.getDailyFileId(), dailyFileId))
|
||||||
|
.sorted(Comparator
|
||||||
|
.comparing(ShopDataCrawlDailyMemberEntity::getCreatedAt,
|
||||||
|
Comparator.nullsLast(Comparator.naturalOrder()))
|
||||||
|
.thenComparing(ShopDataCrawlDailyMemberEntity::getId,
|
||||||
|
Comparator.nullsLast(Comparator.naturalOrder())))
|
||||||
|
.toList();
|
||||||
|
});
|
||||||
|
lenient().when(dailyFileService.containsResult(anyLong(), anyLong())).thenAnswer(invocation -> {
|
||||||
|
long dailyFileId = invocation.getArgument(0);
|
||||||
|
long resultId = invocation.getArgument(1);
|
||||||
|
return dbMembers.stream().anyMatch(m ->
|
||||||
|
Objects.equals(m.getDailyFileId(), dailyFileId) && Objects.equals(m.getResultId(), resultId));
|
||||||
|
});
|
||||||
|
lenient().when(dailyFileService.findMembersByResultId(anyLong())).thenAnswer(invocation -> {
|
||||||
|
long resultId = invocation.getArgument(0);
|
||||||
|
return dbMembers.stream().filter(m -> Objects.equals(m.getResultId(), resultId)).toList();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private ShopDataCrawlDailyFileEntity findDailyFile(Long userId, String shopKeyHash) {
|
||||||
|
for (ShopDataCrawlDailyFileEntity f : dbDailyFiles) {
|
||||||
|
if (Objects.equals(f.getUserId(), userId) && Objects.equals(f.getShopKeyHash(), shopKeyHash)) {
|
||||||
|
return f;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ShopDataCrawlDailyMemberEntity soleMember() {
|
||||||
|
return dbMembers.size() == 1 ? dbMembers.get(0) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 对既有累计文件再归档一个结果(固定 resultId=900),触发一次从数据层累积的整表重建。 */
|
||||||
|
private void triggerRebuild() {
|
||||||
|
FileResultEntity row = addResultRow(900L, 900L, 1, SHOP_NAME, null);
|
||||||
|
processJob(900L, List.of(row), snapshot(900L, SHOP_NAME, 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
+680
@@ -0,0 +1,680 @@
|
|||||||
|
package com.nanri.aiimage.modules.shopdatacrawl.service;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.Wrapper;
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
|
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.nanri.aiimage.common.exception.BusinessException;
|
||||||
|
import com.nanri.aiimage.config.InstanceMetadata;
|
||||||
|
import com.nanri.aiimage.config.TaskPressureProperties;
|
||||||
|
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.task.mapper.FileResultMapper;
|
||||||
|
import com.nanri.aiimage.modules.task.mapper.FileTaskMapper;
|
||||||
|
import com.nanri.aiimage.modules.task.mapper.TaskChunkMapper;
|
||||||
|
import com.nanri.aiimage.modules.task.mapper.TaskScopeStateMapper;
|
||||||
|
import com.nanri.aiimage.modules.task.model.entity.FileResultEntity;
|
||||||
|
import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity;
|
||||||
|
import com.nanri.aiimage.modules.task.model.entity.TaskChunkEntity;
|
||||||
|
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
|
||||||
|
import com.nanri.aiimage.modules.task.model.entity.TaskScopeStateEntity;
|
||||||
|
import com.nanri.aiimage.modules.task.service.TaskDistributedLockService;
|
||||||
|
import com.nanri.aiimage.modules.task.service.TaskFileJobService;
|
||||||
|
import com.nanri.aiimage.modules.task.service.TaskProgressSnapshotService;
|
||||||
|
import com.nanri.aiimage.modules.task.service.TaskResultItemService;
|
||||||
|
import com.nanri.aiimage.modules.task.service.TransientPayloadStorageService;
|
||||||
|
import com.nanri.aiimage.modules.ziniao.service.ZiniaoShopSwitchService;
|
||||||
|
import org.apache.ibatis.builder.MapperBuilderAssistant;
|
||||||
|
import org.apache.ibatis.session.Configuration;
|
||||||
|
import org.junit.jupiter.api.BeforeAll;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.Spy;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.concurrent.atomic.AtomicLong;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyInt;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyLong;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.ArgumentMatchers.nullable;
|
||||||
|
import static org.mockito.Mockito.doThrow;
|
||||||
|
import static org.mockito.Mockito.lenient;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.never;
|
||||||
|
import static org.mockito.Mockito.times;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Task 37:拆分每日累计文件组装与任务结果接收,增加异步文件作业状态。
|
||||||
|
* 结果接收(submitResult/tryFinalizeTask → finalizeTaskWorkbook)只做“结果落库 +
|
||||||
|
* 入队文件作业”,不做任何 Excel 组装/OSS 上传;组装完全由异步文件作业
|
||||||
|
* (processResultFileJob)承担;文件作业状态显式反映到任务状态
|
||||||
|
* (有未完成作业 → 任务保持 RUNNING,作业成功后才进入终态)。
|
||||||
|
*/
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class ShopDataCrawlDailyFileJobSplitTest {
|
||||||
|
|
||||||
|
private static final String MODULE_TYPE = "SHOP_DATA_CRAWL";
|
||||||
|
private static final String SHOP_NAME = "shop-a";
|
||||||
|
private static final LocalDate BUSINESS_DATE = LocalDate.of(2026, 8, 29);
|
||||||
|
private static final LocalDateTime BUSINESS_TIME = LocalDateTime.of(2026, 8, 29, 12, 0);
|
||||||
|
|
||||||
|
@BeforeAll
|
||||||
|
static void initializeMybatisMetadata() {
|
||||||
|
MapperBuilderAssistant assistant = new MapperBuilderAssistant(new Configuration(), "");
|
||||||
|
TableInfoHelper.initTableInfo(assistant, FileTaskEntity.class);
|
||||||
|
TableInfoHelper.initTableInfo(assistant, FileResultEntity.class);
|
||||||
|
TableInfoHelper.initTableInfo(assistant, TaskChunkEntity.class);
|
||||||
|
TableInfoHelper.initTableInfo(assistant, TaskScopeStateEntity.class);
|
||||||
|
TableInfoHelper.initTableInfo(assistant, ShopDataCrawlDailyFileEntity.class);
|
||||||
|
TableInfoHelper.initTableInfo(assistant, ShopDataCrawlDailyMemberEntity.class);
|
||||||
|
TableInfoHelper.initTableInfo(assistant, TaskFileJobEntity.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Mock private FileTaskMapper fileTaskMapper;
|
||||||
|
@Mock private FileResultMapper fileResultMapper;
|
||||||
|
@Mock private ShopDataCrawlResolveService shopDataCrawlResolveService;
|
||||||
|
@Mock private ShopDataCrawlExcelAssemblyService excelAssemblyService;
|
||||||
|
@Mock private ShopDataCrawlTaskCacheService taskCacheService;
|
||||||
|
@Mock private OssStorageService ossStorageService;
|
||||||
|
@Mock private ZiniaoShopSwitchService ziniaoShopSwitchService;
|
||||||
|
@Mock private TaskPressureProperties taskPressureProperties;
|
||||||
|
@Mock private TaskFileJobService taskFileJobService;
|
||||||
|
@Mock private TaskResultItemService taskResultItemService;
|
||||||
|
@Mock private TaskProgressSnapshotService taskProgressSnapshotService;
|
||||||
|
@Mock private TaskDistributedLockService taskDistributedLockService;
|
||||||
|
@Mock private TaskChunkMapper taskChunkMapper;
|
||||||
|
@Mock private TaskScopeStateMapper taskScopeStateMapper;
|
||||||
|
@Mock private TransientPayloadStorageService transientPayloadStorageService;
|
||||||
|
@Mock private InstanceMetadata instanceMetadata;
|
||||||
|
@Mock private ShopDataCrawlDailyFileService dailyFileService;
|
||||||
|
@Spy private ObjectMapper objectMapper = new ObjectMapper().findAndRegisterModules();
|
||||||
|
|
||||||
|
private ShopDataCrawlTaskService service;
|
||||||
|
|
||||||
|
private final List<FileResultEntity> dbResultRows = new ArrayList<>();
|
||||||
|
private final List<ShopDataCrawlDailyFileEntity> dbDailyFiles = new ArrayList<>();
|
||||||
|
private final List<ShopDataCrawlDailyMemberEntity> dbMembers = new ArrayList<>();
|
||||||
|
/** 内存 file_job 表:enqueue/查询/状态流转的仿真。 */
|
||||||
|
private final List<TaskFileJobEntity> dbFileJobs = new ArrayList<>();
|
||||||
|
private final AtomicLong memberIdSeq = new AtomicLong(1000);
|
||||||
|
private final AtomicLong fileIdSeq = new AtomicLong(5000);
|
||||||
|
private final AtomicLong jobIdSeq = new AtomicLong(7000);
|
||||||
|
private long nextResultId = 8100;
|
||||||
|
private Long lastJobTaskId;
|
||||||
|
private String lastUploadedObjectKey;
|
||||||
|
private List<String> enqueuedScopes = new ArrayList<>();
|
||||||
|
/** 接收/组装路径每次落库后的任务状态(taskId → status),验证拆分后任务状态流转。 */
|
||||||
|
private final java.util.Map<Long, String> lastTaskStatus = new java.util.HashMap<>();
|
||||||
|
/** 接收/组装路径每次落库后的任务实体(taskId → 实体),供后续阶段读取。 */
|
||||||
|
private final java.util.Map<Long, FileTaskEntity> taskStore = new java.util.HashMap<>();
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void configureStorage() {
|
||||||
|
service = new ShopDataCrawlTaskService(
|
||||||
|
fileTaskMapper,
|
||||||
|
fileResultMapper,
|
||||||
|
shopDataCrawlResolveService,
|
||||||
|
excelAssemblyService,
|
||||||
|
taskCacheService,
|
||||||
|
ossStorageService,
|
||||||
|
ziniaoShopSwitchService,
|
||||||
|
objectMapper,
|
||||||
|
taskPressureProperties,
|
||||||
|
taskFileJobService,
|
||||||
|
taskResultItemService,
|
||||||
|
taskProgressSnapshotService,
|
||||||
|
taskDistributedLockService,
|
||||||
|
taskChunkMapper,
|
||||||
|
taskScopeStateMapper,
|
||||||
|
transientPayloadStorageService,
|
||||||
|
instanceMetadata,
|
||||||
|
dailyFileService,
|
||||||
|
null);
|
||||||
|
|
||||||
|
dbResultRows.clear();
|
||||||
|
dbDailyFiles.clear();
|
||||||
|
dbMembers.clear();
|
||||||
|
dbFileJobs.clear();
|
||||||
|
memberIdSeq.set(1000);
|
||||||
|
fileIdSeq.set(5000);
|
||||||
|
jobIdSeq.set(7000);
|
||||||
|
nextResultId = 8100;
|
||||||
|
lastJobTaskId = null;
|
||||||
|
lastUploadedObjectKey = null;
|
||||||
|
enqueuedScopes = new ArrayList<>();
|
||||||
|
lastTaskStatus.clear();
|
||||||
|
taskStore.clear();
|
||||||
|
|
||||||
|
lenient().when(instanceMetadata.getInstanceId()).thenReturn("instance-a");
|
||||||
|
lenient().when(taskDistributedLockService.acquire(eq(MODULE_TYPE), anyLong()))
|
||||||
|
.thenReturn(mock(TaskDistributedLockService.LockHandle.class));
|
||||||
|
lenient().when(taskCacheService.getTaskCacheBatch(any())).thenReturn(Map.of());
|
||||||
|
lenient().when(taskCacheService.getTaskHeartbeatMillisBatch(any())).thenReturn(Map.of());
|
||||||
|
lenient().when(taskCacheService.getAllShopMergedPayload(anyLong())).thenReturn(Map.of());
|
||||||
|
lenient().when(taskResultItemService.listResultSnapshots(anyLong(), eq(MODULE_TYPE), any()))
|
||||||
|
.thenReturn(List.of());
|
||||||
|
lenient().when(taskFileJobService.countUnfinishedAssembleJobs(anyLong(), eq(MODULE_TYPE))).thenReturn(0L);
|
||||||
|
lenient().doNothing().when(taskResultItemService)
|
||||||
|
.replaceTaskSnapshots(anyLong(), eq(MODULE_TYPE), any(), any());
|
||||||
|
lenient().doNothing().when(taskProgressSnapshotService)
|
||||||
|
.save(anyLong(), any(), any(), anyInt(), anyInt(), anyInt(), any(), any(), any());
|
||||||
|
lenient().when(ziniaoShopSwitchService.normalizeShopName(nullable(String.class))).thenAnswer(invocation -> {
|
||||||
|
String value = invocation.getArgument(0);
|
||||||
|
return value == null ? "" : value.trim();
|
||||||
|
});
|
||||||
|
lenient().when(fileTaskMapper.selectById(anyLong())).thenAnswer(invocation -> {
|
||||||
|
long taskId = invocation.getArgument(0);
|
||||||
|
FileTaskEntity stored = taskStore.get(taskId);
|
||||||
|
return stored == null ? null : stored;
|
||||||
|
});
|
||||||
|
lenient().when(fileTaskMapper.updateById(any(FileTaskEntity.class))).thenAnswer(invocation -> {
|
||||||
|
FileTaskEntity entity = invocation.getArgument(0);
|
||||||
|
FileTaskEntity copy = copyTask(entity);
|
||||||
|
taskStore.put(copy.getId(), copy);
|
||||||
|
return 1;
|
||||||
|
});
|
||||||
|
lenient().when(fileResultMapper.selectBatchIds(any())).thenReturn(List.of());
|
||||||
|
lenient().when(fileResultMapper.selectCount(any())).thenReturn(0L);
|
||||||
|
lenient().when(ossStorageService.uploadResultFile(any(), eq(MODULE_TYPE))).thenAnswer(invocation -> {
|
||||||
|
String key = "oss/split/" + System.nanoTime() + ".xlsx";
|
||||||
|
lastUploadedObjectKey = key;
|
||||||
|
return key;
|
||||||
|
});
|
||||||
|
lenient().doNothing().when(ossStorageService).deleteObject(anyString());
|
||||||
|
lenient().when(excelAssemblyService.countRows(any())).thenAnswer(invocation -> {
|
||||||
|
List<?> items = invocation.getArgument(0);
|
||||||
|
return items == null ? 0 : items.size();
|
||||||
|
});
|
||||||
|
lenient().doAnswer(invocation -> {
|
||||||
|
List<?> items = invocation.getArgument(1);
|
||||||
|
return items == null ? 0 : items.size();
|
||||||
|
}).when(excelAssemblyService).writeWorkbook(any(), any());
|
||||||
|
|
||||||
|
lenient().when(dailyFileService.currentBusinessDate()).thenReturn(BUSINESS_DATE);
|
||||||
|
lenient().when(dailyFileService.currentBusinessDateTime()).thenReturn(BUSINESS_TIME);
|
||||||
|
lenient().when(dailyFileService.shopKeyHash(anyString())).thenAnswer(invocation -> {
|
||||||
|
String key = invocation.getArgument(0);
|
||||||
|
return key == null ? null : "hash:" + key;
|
||||||
|
});
|
||||||
|
lenient().when(dailyFileService.shopKey(any(FileResultEntity.class))).thenAnswer(invocation -> {
|
||||||
|
FileResultEntity row = invocation.getArgument(0);
|
||||||
|
return row == null ? null : row.getSourceFilename();
|
||||||
|
});
|
||||||
|
lenient().when(dailyFileService.acquireLock(anyLong(), anyString()))
|
||||||
|
.thenReturn(mock(TaskDistributedLockService.LockHandle.class));
|
||||||
|
lenient().when(dailyFileService.findForUpdate(anyLong(), anyString(), any())).thenAnswer(invocation ->
|
||||||
|
copyDailyFile(findDailyFile(invocation.getArgument(0), invocation.getArgument(1))));
|
||||||
|
lenient().when(dailyFileService.findOlder(anyLong(), anyString(), any())).thenReturn(List.of());
|
||||||
|
lenient().when(dailyFileService.findByLatestResultId(anyLong())).thenReturn(List.of());
|
||||||
|
lenient().when(dailyFileService.findById(anyLong())).thenReturn(null);
|
||||||
|
lenient().when(dailyFileService.countObjectReferences(anyString())).thenReturn(0L);
|
||||||
|
lenient().doNothing().when(dailyFileService).deleteDailyFile(anyLong());
|
||||||
|
lenient().doNothing().when(dailyFileService).reassignMembers(anyLong(), anyLong());
|
||||||
|
lenient().doNothing().when(dailyFileService).deleteMembersForResults(any());
|
||||||
|
captureDailyFilePersistence();
|
||||||
|
captureMemberInserts();
|
||||||
|
captureResultUpdates();
|
||||||
|
captureFileJobs();
|
||||||
|
|
||||||
|
lenient().when(fileResultMapper.selectList(any())).thenAnswer(invocation -> {
|
||||||
|
Wrapper<FileResultEntity> wrapper = invocation.getArgument(0);
|
||||||
|
if (wrapper instanceof LambdaQueryWrapper<?> query
|
||||||
|
&& query.getSqlSegment() != null && query.getSqlSegment().contains("taskId")) {
|
||||||
|
List<FileResultEntity> rows = dbResultRows.stream()
|
||||||
|
.filter(r -> Objects.equals(r.getTaskId(), lastJobTaskId))
|
||||||
|
.sorted(Comparator.comparing(FileResultEntity::getId))
|
||||||
|
.toList();
|
||||||
|
return new ArrayList<>(rows);
|
||||||
|
}
|
||||||
|
return List.of();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_037_daily_file_job_normal_default_path() {
|
||||||
|
// 默认成功路径:接收完成后只入队一个异步组装作业(status=PENDING、携带 owner scope),
|
||||||
|
// 不做任何组装;组装作业执行后产生累计文件、成员行、作业状态置为 SUCCESS。
|
||||||
|
FileResultEntity row = addResultRow(8101L, 1L, 1, SHOP_NAME, null);
|
||||||
|
receiveTask(1L, List.of(row), snapshot(8101L));
|
||||||
|
|
||||||
|
assertEquals(1, dbFileJobs.size(), "接收路径只入队一个组装作业");
|
||||||
|
assertEquals("PENDING", dbFileJobs.get(0).getStatus());
|
||||||
|
assertEquals("ASSEMBLE_RESULT", dbFileJobs.get(0).getJobType());
|
||||||
|
assertTrue(enqueuedScopes.get(0).contains("owner"), "入队作业携带 owner scope");
|
||||||
|
verify(excelAssemblyService, never()).writeWorkbook(any(), any());
|
||||||
|
assertEquals(0, dbMembers.size(), "接收路径不产生成员行");
|
||||||
|
assertEquals(0, dbDailyFiles.size(), "接收路径不创建累计文件");
|
||||||
|
|
||||||
|
runAssembleJob(1L);
|
||||||
|
assertEquals(1, dbMembers.size(), "组装作业执行后产生成员行");
|
||||||
|
assertEquals(1, dbDailyFiles.size(), "组装作业执行后产生累计文件");
|
||||||
|
assertEquals("SUCCESS", dbFileJobs.get(0).getStatus(), "作业状态显式落到 SUCCESS");
|
||||||
|
assertEquals("SUCCESS", lastTaskStatus.get(1L), "作业完成后任务进入终态");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_037_daily_file_job_normal_multiple_items() {
|
||||||
|
// 批量场景:多个店铺结果入队各自独立的组装作业,逐个执行后
|
||||||
|
// 累计文件版本随提交递增,成员行不丢失、顺序稳定。
|
||||||
|
receiveTask(1L, List.of(addResultRow(8102L, 1L, 1, SHOP_NAME, null)), snapshot(8102L));
|
||||||
|
assertEquals(1, dbFileJobs.size(), "首个结果入队一个作业");
|
||||||
|
|
||||||
|
runAssembleJob(1L);
|
||||||
|
receiveTask(2L, List.of(addResultRow(8103L, 2L, 1, SHOP_NAME, null)), snapshot(8103L));
|
||||||
|
assertEquals(2, dbFileJobs.size(), "第二个结果入队第二个作业");
|
||||||
|
runAssembleJob(2L);
|
||||||
|
|
||||||
|
assertEquals(2, dbMembers.size(), "两个结果各产生一个成员行");
|
||||||
|
assertEquals(2L, dbDailyFiles.get(0).getVersion(), "版本号随两次归档递增");
|
||||||
|
assertEquals(2, dbFileJobs.stream().filter(j -> "SUCCESS".equals(j.getStatus())).count(),
|
||||||
|
"两个作业都显式落到 SUCCESS");
|
||||||
|
assertEquals("SUCCESS", lastTaskStatus.get(1L));
|
||||||
|
assertEquals("SUCCESS", lastTaskStatus.get(2L));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_037_daily_file_job_normal_repeated_operation_is_idempotent() {
|
||||||
|
// 幂等:同一结果重复提交接收,不产生重复组装作业(已存在 PENDING/SUCCESS 作业不重复入队);
|
||||||
|
// 同一作业重复执行不产生重复成员行、版本不重复递增。
|
||||||
|
FileResultEntity row = addResultRow(8104L, 1L, 1, SHOP_NAME, null);
|
||||||
|
receiveTask(1L, List.of(row), snapshot(8104L));
|
||||||
|
receiveTask(1L, List.of(row), snapshot(8104L));
|
||||||
|
|
||||||
|
assertEquals(1, dbFileJobs.size(), "重复接收不重复入队作业");
|
||||||
|
runAssembleJob(1L);
|
||||||
|
runAssembleJob(1L);
|
||||||
|
assertEquals(1, dbMembers.size(), "重复执行作业不产生重复成员行");
|
||||||
|
assertEquals(1L, dbDailyFiles.get(0).getVersion(), "重复执行作业版本号不重复递增");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_037_daily_file_job_boundary_empty_input() {
|
||||||
|
// 空输入:没有成功结果时不入队作业、不创建任何资源,任务不进入组装等待。
|
||||||
|
FileResultEntity row = addResultRow(8105L, 1L, 0, SHOP_NAME, null);
|
||||||
|
receiveTask(1L, List.of(row), snapshot(8105L));
|
||||||
|
|
||||||
|
assertTrue(dbFileJobs.isEmpty(), "无成功结果不入队组装作业");
|
||||||
|
assertTrue(dbMembers.isEmpty(), "无成功结果不产生成员行");
|
||||||
|
assertTrue(dbDailyFiles.isEmpty(), "无成功结果不创建累计文件");
|
||||||
|
verify(ossStorageService, never()).uploadResultFile(any(), eq(MODULE_TYPE));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_037_daily_file_job_boundary_single_item() {
|
||||||
|
// 单元素:单店铺单结果走同样拆分路径,作业一入队即达终态,不依赖批量逻辑。
|
||||||
|
FileResultEntity row = addResultRow(8106L, 1L, 1, SHOP_NAME, null);
|
||||||
|
receiveTask(1L, List.of(row), snapshot(8106L));
|
||||||
|
|
||||||
|
assertEquals(1, dbFileJobs.size());
|
||||||
|
assertEquals("PENDING", dbFileJobs.get(0).getStatus());
|
||||||
|
runAssembleJob(1L);
|
||||||
|
assertEquals(1, dbMembers.size());
|
||||||
|
assertEquals(1L, dbDailyFiles.get(0).getVersion());
|
||||||
|
assertEquals("SUCCESS", lastTaskStatus.get(1L));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_037_daily_file_job_boundary_limit_and_overflow() {
|
||||||
|
// 上限/超限:大量结果连续接收+执行,作业数、成员数、版本号一致递增,无丢失无重复。
|
||||||
|
int limit = 210;
|
||||||
|
for (int i = 0; i < limit; i++) {
|
||||||
|
long resultId = nextResultId++;
|
||||||
|
long taskId = i + 1L;
|
||||||
|
receiveTask(taskId, List.of(addResultRow(resultId, taskId, 1, SHOP_NAME, null)), snapshot(resultId));
|
||||||
|
runAssembleJob(taskId);
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals(limit, dbFileJobs.size(), "每个结果一个作业");
|
||||||
|
assertEquals(limit, dbMembers.size(), "每个结果一个成员行");
|
||||||
|
assertEquals(Long.valueOf(limit), dbDailyFiles.get(0).getVersion(), "版本号与归档次数一致");
|
||||||
|
assertEquals(limit, dbFileJobs.stream().filter(j -> "SUCCESS".equals(j.getStatus())).count(),
|
||||||
|
"全部作业显式 SUCCESS");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_037_daily_file_job_invalid_input_rejected() {
|
||||||
|
// 非法参数:作业缺少 taskId 时拒绝执行并抛可识别异常;任务不存在时同样拒绝。
|
||||||
|
assertThrows(BusinessException.class, () -> service.processResultFileJob(
|
||||||
|
jobEntity(0L, null, MODULE_TYPE)));
|
||||||
|
assertThrows(BusinessException.class, () -> service.processResultFileJob(
|
||||||
|
jobEntity(0L, 9999L, MODULE_TYPE)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_037_daily_file_job_dependency_failure_releases_resources() {
|
||||||
|
// 依赖失败:组装作业执行中 OSS 上传失败时抛出异常、不残留成员/累计文件;
|
||||||
|
// 恢复后同一结果可重新归档成功(错误可恢复)。
|
||||||
|
FileResultEntity row = addResultRow(8108L, 1L, 1, SHOP_NAME, null);
|
||||||
|
receiveTask(1L, List.of(row), snapshot(8108L));
|
||||||
|
assertEquals(1, dbFileJobs.size(), "接收入队成功");
|
||||||
|
dbFileJobs.clear();
|
||||||
|
|
||||||
|
doThrow(new IllegalStateException("upload failed"))
|
||||||
|
.when(ossStorageService).uploadResultFile(any(), eq(MODULE_TYPE));
|
||||||
|
assertThrows(IllegalStateException.class, () -> service.processResultFileJob(
|
||||||
|
jobEntity(1L, 1L, MODULE_TYPE)));
|
||||||
|
|
||||||
|
assertTrue(dbMembers.isEmpty(), "上传失败不残留成员行");
|
||||||
|
assertTrue(dbDailyFiles.isEmpty(), "上传失败不残留累计文件");
|
||||||
|
|
||||||
|
// 错误可恢复:恢复 OSS 后同一作业重新执行成功。
|
||||||
|
lenient().when(ossStorageService.uploadResultFile(any(), eq(MODULE_TYPE))).thenAnswer(invocation -> {
|
||||||
|
String key = "oss/split/recovered-" + System.nanoTime() + ".xlsx";
|
||||||
|
lastUploadedObjectKey = key;
|
||||||
|
return key;
|
||||||
|
});
|
||||||
|
service.processResultFileJob(jobEntity(1L, 1L, MODULE_TYPE));
|
||||||
|
assertEquals(1, dbMembers.size(), "恢复后同一结果重新归档成功");
|
||||||
|
assertEquals(1, dbDailyFiles.size(), "恢复后累计文件创建成功");
|
||||||
|
assertEquals(1L, dbDailyFiles.get(0).getVersion(), "恢复后版本号正确");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- helpers ----
|
||||||
|
|
||||||
|
/** 接收阶段:submitResult → tryFinalizeTask → finalizeTaskWorkbook(结果落库 + 入队组装作业)。 */
|
||||||
|
private void receiveTask(Long taskId, List<FileResultEntity> rows, ShopDataCrawlResultItemVo snapshot) {
|
||||||
|
lastJobTaskId = taskId;
|
||||||
|
FileTaskEntity task = taskEntity(taskId);
|
||||||
|
taskStore.put(taskId, task);
|
||||||
|
for (FileResultEntity row : rows) {
|
||||||
|
updateRowInDb(row);
|
||||||
|
}
|
||||||
|
service.tryFinalizeTask(taskId, false);
|
||||||
|
FileTaskEntity persisted = taskStore.get(taskId);
|
||||||
|
if (persisted != null) {
|
||||||
|
lastTaskStatus.put(taskId, persisted.getStatus());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 组装阶段:worker 执行 processResultFileJob(组装),随后 markSuccess + cleanupResultFileJob
|
||||||
|
* (作业成功钩子:把文件作业状态显式反映到任务状态,无未完成作业 → 任务进入终态)。 */
|
||||||
|
private void runAssembleJob(Long taskId) {
|
||||||
|
lastJobTaskId = taskId;
|
||||||
|
TaskFileJobEntity job = dbFileJobs.stream()
|
||||||
|
.filter(j -> Objects.equals(j.getTaskId(), taskId))
|
||||||
|
.findFirst()
|
||||||
|
.orElseThrow(() -> new AssertionError("作业不存在 taskId=" + taskId));
|
||||||
|
service.processResultFileJob(job);
|
||||||
|
job.setStatus("SUCCESS");
|
||||||
|
service.cleanupResultFileJob(job);
|
||||||
|
lastTaskStatus.put(taskId, taskStore.get(taskId).getStatus());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void markResultFinishedInDb(FileResultEntity row) {
|
||||||
|
row.setSuccess(1);
|
||||||
|
row.setErrorMessage(null);
|
||||||
|
updateRowInDb(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void updateRowInDb(FileResultEntity row) {
|
||||||
|
for (int i = 0; i < dbResultRows.size(); i++) {
|
||||||
|
if (Objects.equals(dbResultRows.get(i).getId(), row.getId())) {
|
||||||
|
dbResultRows.set(i, row);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dbResultRows.add(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
private TaskFileJobEntity jobEntity(long jobId, Long taskId, String moduleType) {
|
||||||
|
TaskFileJobEntity job = new TaskFileJobEntity();
|
||||||
|
job.setId(jobId);
|
||||||
|
job.setTaskId(taskId);
|
||||||
|
job.setModuleType(moduleType);
|
||||||
|
return job;
|
||||||
|
}
|
||||||
|
|
||||||
|
private FileTaskEntity taskEntity(long taskId) {
|
||||||
|
FileTaskEntity task = new FileTaskEntity();
|
||||||
|
task.setId(taskId);
|
||||||
|
task.setModuleType(MODULE_TYPE);
|
||||||
|
task.setStatus("RUNNING");
|
||||||
|
task.setUserId(7L);
|
||||||
|
task.setOwnerInstanceId("instance-a");
|
||||||
|
task.setRequestJson("{\"ownerInstanceId\":\"instance-a\"}");
|
||||||
|
task.setResultJson("[]");
|
||||||
|
task.setCreatedAt(LocalDateTime.now());
|
||||||
|
task.setUpdatedAt(LocalDateTime.now());
|
||||||
|
return task;
|
||||||
|
}
|
||||||
|
|
||||||
|
private FileResultEntity addResultRow(long id, long taskId, int success, String shopName, String resultFileUrl) {
|
||||||
|
FileResultEntity row = new FileResultEntity();
|
||||||
|
row.setId(id);
|
||||||
|
row.setTaskId(taskId);
|
||||||
|
row.setModuleType(MODULE_TYPE);
|
||||||
|
row.setSuccess(success);
|
||||||
|
row.setSourceFilename(shopName);
|
||||||
|
row.setSourceFileUrl("shop-id-" + id);
|
||||||
|
row.setUserId(7L);
|
||||||
|
row.setCreatedAt(LocalDateTime.now());
|
||||||
|
row.setResultFileUrl(resultFileUrl);
|
||||||
|
dbResultRows.add(row);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ShopDataCrawlResultItemVo snapshot(long resultId) {
|
||||||
|
ShopDataCrawlResultItemVo item = new ShopDataCrawlResultItemVo();
|
||||||
|
item.setResultId(resultId);
|
||||||
|
item.setTaskId(1L);
|
||||||
|
item.setShopName(SHOP_NAME);
|
||||||
|
item.setShopId("shop-id-" + resultId);
|
||||||
|
item.setSuccess(true);
|
||||||
|
item.setMatched(true);
|
||||||
|
item.setTaskStatus("SUCCESS");
|
||||||
|
item.setCountryCodes(List.of("DE"));
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void captureFileJobs() {
|
||||||
|
// 仿真 enqueueAssembleResult 的幂等语义:同一 (taskId, resultId) 已存在作业时不重复入队。
|
||||||
|
lenient().doAnswer(invocation -> {
|
||||||
|
Long taskId = invocation.getArgument(0);
|
||||||
|
Long resultId = invocation.getArgument(2);
|
||||||
|
String scopeKey = invocation.getArgument(3);
|
||||||
|
TaskFileJobEntity existing = dbFileJobs.stream()
|
||||||
|
.filter(j -> Objects.equals(j.getTaskId(), taskId)
|
||||||
|
&& Objects.equals(j.getResultId(), resultId))
|
||||||
|
.findFirst()
|
||||||
|
.orElse(null);
|
||||||
|
if (existing != null) {
|
||||||
|
if ("FAILED".equals(existing.getStatus())) {
|
||||||
|
existing.setStatus("PENDING");
|
||||||
|
}
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
TaskFileJobEntity job = new TaskFileJobEntity();
|
||||||
|
job.setId(jobIdSeq.incrementAndGet());
|
||||||
|
job.setTaskId(taskId);
|
||||||
|
job.setModuleType(MODULE_TYPE);
|
||||||
|
job.setResultId(resultId);
|
||||||
|
job.setScopeKey(scopeKey);
|
||||||
|
job.setStatus("PENDING");
|
||||||
|
job.setJobType("ASSEMBLE_RESULT");
|
||||||
|
job.setRetryCount(0);
|
||||||
|
dbFileJobs.add(job);
|
||||||
|
enqueuedScopes.add(scopeKey);
|
||||||
|
return job;
|
||||||
|
}).when(taskFileJobService).enqueueAssembleResult(anyLong(), eq(MODULE_TYPE), anyLong(), anyString());
|
||||||
|
lenient().when(taskFileJobService.findAssembleJob(anyLong(), eq(MODULE_TYPE), anyLong()))
|
||||||
|
.thenAnswer(invocation -> {
|
||||||
|
long taskId = invocation.getArgument(0);
|
||||||
|
long resultId = invocation.getArgument(2);
|
||||||
|
return dbFileJobs.stream()
|
||||||
|
.filter(j -> Objects.equals(j.getTaskId(), taskId)
|
||||||
|
&& Objects.equals(j.getResultId(), resultId))
|
||||||
|
.findFirst()
|
||||||
|
.orElse(null);
|
||||||
|
});
|
||||||
|
lenient().when(taskFileJobService.countUnfinishedAssembleJobs(anyLong(), eq(MODULE_TYPE)))
|
||||||
|
.thenAnswer(invocation -> {
|
||||||
|
long taskId = invocation.getArgument(0);
|
||||||
|
return dbFileJobs.stream()
|
||||||
|
.filter(j -> Objects.equals(j.getTaskId(), taskId)
|
||||||
|
&& !"SUCCESS".equals(j.getStatus()))
|
||||||
|
.count();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void captureResultUpdates() {
|
||||||
|
lenient().when(fileResultMapper.updateById(any(FileResultEntity.class))).thenAnswer(invocation -> {
|
||||||
|
FileResultEntity updated = invocation.getArgument(0);
|
||||||
|
for (int i = 0; i < dbResultRows.size(); i++) {
|
||||||
|
if (Objects.equals(dbResultRows.get(i).getId(), updated.getId())) {
|
||||||
|
dbResultRows.set(i, updated);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dbResultRows.add(updated);
|
||||||
|
return 1;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void captureMemberInserts() {
|
||||||
|
lenient().when(dailyFileService.addMemberWithPayload(anyLong(), anyLong(), anyLong(), anyString()))
|
||||||
|
.thenAnswer(invocation -> {
|
||||||
|
long dailyFileId = invocation.getArgument(0);
|
||||||
|
long taskId = invocation.getArgument(1);
|
||||||
|
long resultId = invocation.getArgument(2);
|
||||||
|
String rowPayload = invocation.getArgument(3);
|
||||||
|
boolean duplicate = dbMembers.stream().anyMatch(m ->
|
||||||
|
Objects.equals(m.getDailyFileId(), dailyFileId)
|
||||||
|
&& Objects.equals(m.getResultId(), resultId));
|
||||||
|
if (duplicate) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
ShopDataCrawlDailyMemberEntity member = new ShopDataCrawlDailyMemberEntity();
|
||||||
|
member.setId(memberIdSeq.incrementAndGet());
|
||||||
|
member.setDailyFileId(dailyFileId);
|
||||||
|
member.setTaskId(taskId);
|
||||||
|
member.setResultId(resultId);
|
||||||
|
member.setRowPayload(rowPayload);
|
||||||
|
member.setCreatedAt(BUSINESS_TIME.plusMinutes(dbMembers.size()));
|
||||||
|
dbMembers.add(member);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void captureDailyFilePersistence() {
|
||||||
|
lenient().doAnswer(invocation -> {
|
||||||
|
ShopDataCrawlDailyFileEntity entity = invocation.getArgument(0);
|
||||||
|
entity.setId(fileIdSeq.incrementAndGet());
|
||||||
|
dbDailyFiles.add(entity);
|
||||||
|
return null;
|
||||||
|
}).when(dailyFileService).insert(any(ShopDataCrawlDailyFileEntity.class));
|
||||||
|
lenient().doAnswer(invocation -> {
|
||||||
|
ShopDataCrawlDailyFileEntity entity = invocation.getArgument(0);
|
||||||
|
for (int i = 0; i < dbDailyFiles.size(); i++) {
|
||||||
|
ShopDataCrawlDailyFileEntity existing = dbDailyFiles.get(i);
|
||||||
|
if (Objects.equals(existing.getUserId(), entity.getUserId())
|
||||||
|
&& Objects.equals(existing.getShopKeyHash(), entity.getShopKeyHash())
|
||||||
|
&& Objects.equals(existing.getBusinessDate(), entity.getBusinessDate())) {
|
||||||
|
entity.setId(existing.getId());
|
||||||
|
dbDailyFiles.set(i, copyDailyFile(entity));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
entity.setId(fileIdSeq.incrementAndGet());
|
||||||
|
dbDailyFiles.add(copyDailyFile(entity));
|
||||||
|
return null;
|
||||||
|
}).when(dailyFileService).update(any(ShopDataCrawlDailyFileEntity.class));
|
||||||
|
lenient().when(dailyFileService.listMembers(anyLong())).thenAnswer(invocation -> {
|
||||||
|
long dailyFileId = invocation.getArgument(0);
|
||||||
|
return dbMembers.stream()
|
||||||
|
.filter(m -> Objects.equals(m.getDailyFileId(), dailyFileId))
|
||||||
|
.sorted(Comparator
|
||||||
|
.comparing(ShopDataCrawlDailyMemberEntity::getCreatedAt,
|
||||||
|
Comparator.nullsLast(Comparator.naturalOrder()))
|
||||||
|
.thenComparing(ShopDataCrawlDailyMemberEntity::getId,
|
||||||
|
Comparator.nullsLast(Comparator.naturalOrder())))
|
||||||
|
.toList();
|
||||||
|
});
|
||||||
|
lenient().when(dailyFileService.containsResult(anyLong(), anyLong())).thenAnswer(invocation -> {
|
||||||
|
long dailyFileId = invocation.getArgument(0);
|
||||||
|
long resultId = invocation.getArgument(1);
|
||||||
|
return dbMembers.stream().anyMatch(m ->
|
||||||
|
Objects.equals(m.getDailyFileId(), dailyFileId) && Objects.equals(m.getResultId(), resultId));
|
||||||
|
});
|
||||||
|
lenient().when(dailyFileService.findMembersByResultId(anyLong())).thenAnswer(invocation -> {
|
||||||
|
long resultId = invocation.getArgument(0);
|
||||||
|
return dbMembers.stream().filter(m -> Objects.equals(m.getResultId(), resultId)).toList();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private ShopDataCrawlDailyFileEntity findDailyFile(Long userId, String shopKeyHash) {
|
||||||
|
for (ShopDataCrawlDailyFileEntity f : dbDailyFiles) {
|
||||||
|
if (Objects.equals(f.getUserId(), userId) && Objects.equals(f.getShopKeyHash(), shopKeyHash)) {
|
||||||
|
return f;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ShopDataCrawlDailyFileEntity copyDailyFile(ShopDataCrawlDailyFileEntity source) {
|
||||||
|
if (source == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
ShopDataCrawlDailyFileEntity copy = new ShopDataCrawlDailyFileEntity();
|
||||||
|
copy.setId(source.getId());
|
||||||
|
copy.setUserId(source.getUserId());
|
||||||
|
copy.setShopKeyHash(source.getShopKeyHash());
|
||||||
|
copy.setShopKey(source.getShopKey());
|
||||||
|
copy.setBusinessDate(source.getBusinessDate());
|
||||||
|
copy.setLatestTaskId(source.getLatestTaskId());
|
||||||
|
copy.setLatestResultId(source.getLatestResultId());
|
||||||
|
copy.setResultFilename(source.getResultFilename());
|
||||||
|
copy.setResultFileUrl(source.getResultFileUrl());
|
||||||
|
copy.setResultFileSize(source.getResultFileSize());
|
||||||
|
copy.setResultContentType(source.getResultContentType());
|
||||||
|
copy.setRowCount(source.getRowCount());
|
||||||
|
copy.setVersion(source.getVersion());
|
||||||
|
copy.setLastSuccessAt(source.getLastSuccessAt());
|
||||||
|
copy.setCreatedAt(source.getCreatedAt());
|
||||||
|
copy.setUpdatedAt(source.getUpdatedAt());
|
||||||
|
return copy;
|
||||||
|
}
|
||||||
|
|
||||||
|
private FileTaskEntity copyTask(FileTaskEntity source) {
|
||||||
|
FileTaskEntity copy = new FileTaskEntity();
|
||||||
|
copy.setId(source.getId());
|
||||||
|
copy.setTaskNo(source.getTaskNo());
|
||||||
|
copy.setModuleType(source.getModuleType());
|
||||||
|
copy.setTaskMode(source.getTaskMode());
|
||||||
|
copy.setStatus(source.getStatus());
|
||||||
|
copy.setSourceFileCount(source.getSourceFileCount());
|
||||||
|
copy.setSuccessFileCount(source.getSuccessFileCount());
|
||||||
|
copy.setFailedFileCount(source.getFailedFileCount());
|
||||||
|
copy.setRequestJson(source.getRequestJson());
|
||||||
|
copy.setResultJson(source.getResultJson());
|
||||||
|
copy.setErrorMessage(source.getErrorMessage());
|
||||||
|
copy.setCreatedBy(source.getCreatedBy());
|
||||||
|
copy.setUserId(source.getUserId());
|
||||||
|
copy.setOwnerInstanceId(source.getOwnerInstanceId());
|
||||||
|
copy.setCreatedAt(source.getCreatedAt());
|
||||||
|
copy.setUpdatedAt(source.getUpdatedAt());
|
||||||
|
copy.setFinishedAt(source.getFinishedAt());
|
||||||
|
copy.setScheduledAt(source.getScheduledAt());
|
||||||
|
return copy;
|
||||||
|
}
|
||||||
|
}
|
||||||
+601
@@ -0,0 +1,601 @@
|
|||||||
|
package com.nanri.aiimage.modules.shopdatacrawl.service;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.Wrapper;
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
|
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.nanri.aiimage.common.exception.BusinessException;
|
||||||
|
import com.nanri.aiimage.config.InstanceMetadata;
|
||||||
|
import com.nanri.aiimage.config.TaskPressureProperties;
|
||||||
|
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.task.mapper.FileResultMapper;
|
||||||
|
import com.nanri.aiimage.modules.task.mapper.FileTaskMapper;
|
||||||
|
import com.nanri.aiimage.modules.task.mapper.TaskChunkMapper;
|
||||||
|
import com.nanri.aiimage.modules.task.mapper.TaskScopeStateMapper;
|
||||||
|
import com.nanri.aiimage.modules.task.model.entity.FileResultEntity;
|
||||||
|
import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity;
|
||||||
|
import com.nanri.aiimage.modules.task.model.entity.TaskChunkEntity;
|
||||||
|
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
|
||||||
|
import com.nanri.aiimage.modules.task.model.entity.TaskScopeStateEntity;
|
||||||
|
import com.nanri.aiimage.modules.task.service.TaskDistributedLockService;
|
||||||
|
import com.nanri.aiimage.modules.task.service.TaskFileJobService;
|
||||||
|
import com.nanri.aiimage.modules.task.service.TaskProgressSnapshotService;
|
||||||
|
import com.nanri.aiimage.modules.task.service.TaskResultItemService;
|
||||||
|
import com.nanri.aiimage.modules.task.service.TransientPayloadStorageService;
|
||||||
|
import com.nanri.aiimage.modules.ziniao.service.ZiniaoShopSwitchService;
|
||||||
|
import org.apache.ibatis.builder.MapperBuilderAssistant;
|
||||||
|
import org.apache.ibatis.session.Configuration;
|
||||||
|
import org.junit.jupiter.api.BeforeAll;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.Spy;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
|
import java.util.concurrent.atomic.AtomicLong;
|
||||||
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyInt;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyLong;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.ArgumentMatchers.nullable;
|
||||||
|
import static org.mockito.Mockito.doThrow;
|
||||||
|
import static org.mockito.Mockito.lenient;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.never;
|
||||||
|
import static org.mockito.Mockito.times;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Task 36:为每日累计文件引入版本号/CAS,缩短店铺级锁的持有时间。
|
||||||
|
* 原实现持有店铺级锁覆盖“准备+组装+提交”全流程(含整表 Excel 组装与 OSS 上传,
|
||||||
|
* 耗时最长);重构为两个短临界区(准备 / 提交),组装在锁外执行,
|
||||||
|
* 提交阶段按 daily_file.version CAS,冲突时释放锁重试(最多 3 次),
|
||||||
|
* 从而把锁的持有时间从“秒级组装”缩短到“毫秒级两个短事务”。
|
||||||
|
*/
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class ShopDataCrawlDailyFileLockTest {
|
||||||
|
|
||||||
|
private static final String MODULE_TYPE = "SHOP_DATA_CRAWL";
|
||||||
|
private static final String SHOP_NAME = "shop-a";
|
||||||
|
private static final LocalDate BUSINESS_DATE = LocalDate.of(2026, 8, 29);
|
||||||
|
private static final LocalDateTime BUSINESS_TIME = LocalDateTime.of(2026, 8, 29, 12, 0);
|
||||||
|
|
||||||
|
@BeforeAll
|
||||||
|
static void initializeMybatisMetadata() {
|
||||||
|
MapperBuilderAssistant assistant = new MapperBuilderAssistant(new Configuration(), "");
|
||||||
|
TableInfoHelper.initTableInfo(assistant, FileTaskEntity.class);
|
||||||
|
TableInfoHelper.initTableInfo(assistant, FileResultEntity.class);
|
||||||
|
TableInfoHelper.initTableInfo(assistant, TaskChunkEntity.class);
|
||||||
|
TableInfoHelper.initTableInfo(assistant, TaskScopeStateEntity.class);
|
||||||
|
TableInfoHelper.initTableInfo(assistant, ShopDataCrawlDailyFileEntity.class);
|
||||||
|
TableInfoHelper.initTableInfo(assistant, ShopDataCrawlDailyMemberEntity.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Mock private FileTaskMapper fileTaskMapper;
|
||||||
|
@Mock private FileResultMapper fileResultMapper;
|
||||||
|
@Mock private ShopDataCrawlResolveService shopDataCrawlResolveService;
|
||||||
|
@Mock private ShopDataCrawlExcelAssemblyService excelAssemblyService;
|
||||||
|
@Mock private ShopDataCrawlTaskCacheService taskCacheService;
|
||||||
|
@Mock private OssStorageService ossStorageService;
|
||||||
|
@Mock private ZiniaoShopSwitchService ziniaoShopSwitchService;
|
||||||
|
@Mock private TaskPressureProperties taskPressureProperties;
|
||||||
|
@Mock private TaskFileJobService taskFileJobService;
|
||||||
|
@Mock private TaskResultItemService taskResultItemService;
|
||||||
|
@Mock private TaskProgressSnapshotService taskProgressSnapshotService;
|
||||||
|
@Mock private TaskDistributedLockService taskDistributedLockService;
|
||||||
|
@Mock private TaskChunkMapper taskChunkMapper;
|
||||||
|
@Mock private TaskScopeStateMapper taskScopeStateMapper;
|
||||||
|
@Mock private TransientPayloadStorageService transientPayloadStorageService;
|
||||||
|
@Mock private InstanceMetadata instanceMetadata;
|
||||||
|
@Mock private ShopDataCrawlDailyFileService dailyFileService;
|
||||||
|
@Spy private ObjectMapper objectMapper = new ObjectMapper().findAndRegisterModules();
|
||||||
|
|
||||||
|
private ShopDataCrawlTaskService service;
|
||||||
|
|
||||||
|
/** 内存中的结果行表(listTaskRows 读取源 + updateById 回写目标)。 */
|
||||||
|
private final List<FileResultEntity> dbResultRows = new ArrayList<>();
|
||||||
|
/** 内存中的 daily_file 表。 */
|
||||||
|
private final List<ShopDataCrawlDailyFileEntity> dbDailyFiles = new ArrayList<>();
|
||||||
|
/** 内存中的 daily_member 表。 */
|
||||||
|
private final List<ShopDataCrawlDailyMemberEntity> dbMembers = new ArrayList<>();
|
||||||
|
private final AtomicLong memberIdSeq = new AtomicLong(1000);
|
||||||
|
private final AtomicLong fileIdSeq = new AtomicLong(5000);
|
||||||
|
private long nextResultId = 7200;
|
||||||
|
private Long lastJobTaskId;
|
||||||
|
private String lastUploadedObjectKey;
|
||||||
|
/** 店铺级锁获取次数(验证锁持有时间缩短:两次短临界区各取一次)。 */
|
||||||
|
private final AtomicInteger lockAcquireCount = new AtomicInteger();
|
||||||
|
private final AtomicReference<TaskDistributedLockService.LockHandle> lastLock = new AtomicReference<>();
|
||||||
|
/** 本次测试获取到的全部店铺级锁句柄(验证每个临界区取到的锁都被释放)。 */
|
||||||
|
private final List<TaskDistributedLockService.LockHandle> acquiredLocks = new ArrayList<>();
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void configureStorage() {
|
||||||
|
service = new ShopDataCrawlTaskService(
|
||||||
|
fileTaskMapper,
|
||||||
|
fileResultMapper,
|
||||||
|
shopDataCrawlResolveService,
|
||||||
|
excelAssemblyService,
|
||||||
|
taskCacheService,
|
||||||
|
ossStorageService,
|
||||||
|
ziniaoShopSwitchService,
|
||||||
|
objectMapper,
|
||||||
|
taskPressureProperties,
|
||||||
|
taskFileJobService,
|
||||||
|
taskResultItemService,
|
||||||
|
taskProgressSnapshotService,
|
||||||
|
taskDistributedLockService,
|
||||||
|
taskChunkMapper,
|
||||||
|
taskScopeStateMapper,
|
||||||
|
transientPayloadStorageService,
|
||||||
|
instanceMetadata,
|
||||||
|
dailyFileService,
|
||||||
|
null);
|
||||||
|
|
||||||
|
dbResultRows.clear();
|
||||||
|
dbDailyFiles.clear();
|
||||||
|
dbMembers.clear();
|
||||||
|
memberIdSeq.set(1000);
|
||||||
|
fileIdSeq.set(5000);
|
||||||
|
nextResultId = 7200;
|
||||||
|
lastJobTaskId = null;
|
||||||
|
lastUploadedObjectKey = null;
|
||||||
|
lockAcquireCount.set(0);
|
||||||
|
lastLock.set(null);
|
||||||
|
acquiredLocks.clear();
|
||||||
|
|
||||||
|
lenient().when(instanceMetadata.getInstanceId()).thenReturn("instance-a");
|
||||||
|
lenient().when(taskDistributedLockService.acquire(eq(MODULE_TYPE), anyLong()))
|
||||||
|
.thenReturn(mock(TaskDistributedLockService.LockHandle.class));
|
||||||
|
lenient().when(taskCacheService.getTaskCacheBatch(any())).thenReturn(Map.of());
|
||||||
|
lenient().when(taskCacheService.getTaskHeartbeatMillisBatch(any())).thenReturn(Map.of());
|
||||||
|
lenient().when(taskFileJobService.findAssembleJob(anyLong(), eq(MODULE_TYPE), anyLong())).thenReturn(null);
|
||||||
|
lenient().when(taskFileJobService.countUnfinishedAssembleJobs(anyLong(), eq(MODULE_TYPE))).thenReturn(0L);
|
||||||
|
lenient().doNothing().when(taskResultItemService)
|
||||||
|
.replaceTaskSnapshots(anyLong(), eq(MODULE_TYPE), any(), any());
|
||||||
|
lenient().doNothing().when(taskProgressSnapshotService)
|
||||||
|
.save(anyLong(), any(), any(), anyInt(), anyInt(), anyInt(), any(), any(), any());
|
||||||
|
lenient().when(ziniaoShopSwitchService.normalizeShopName(nullable(String.class))).thenAnswer(invocation -> {
|
||||||
|
String value = invocation.getArgument(0);
|
||||||
|
return value == null ? "" : value.trim();
|
||||||
|
});
|
||||||
|
lenient().when(fileTaskMapper.selectById(anyLong())).thenReturn(null);
|
||||||
|
lenient().when(fileTaskMapper.updateById(any(FileTaskEntity.class))).thenReturn(1);
|
||||||
|
lenient().when(fileResultMapper.selectBatchIds(any())).thenReturn(List.of());
|
||||||
|
lenient().when(fileResultMapper.selectCount(any())).thenReturn(0L);
|
||||||
|
lenient().when(ossStorageService.uploadResultFile(any(), eq(MODULE_TYPE))).thenAnswer(invocation -> {
|
||||||
|
String key = "oss/lock/" + System.nanoTime() + ".xlsx";
|
||||||
|
lastUploadedObjectKey = key;
|
||||||
|
return key;
|
||||||
|
});
|
||||||
|
lenient().when(ossStorageService.readObjectBytes(anyString())).thenReturn(new byte[0]);
|
||||||
|
lenient().doNothing().when(ossStorageService).deleteObject(anyString());
|
||||||
|
lenient().when(excelAssemblyService.countRows(any())).thenAnswer(invocation -> {
|
||||||
|
List<?> items = invocation.getArgument(0);
|
||||||
|
return items == null ? 0 : items.size();
|
||||||
|
});
|
||||||
|
lenient().doAnswer(invocation -> {
|
||||||
|
List<?> items = invocation.getArgument(1);
|
||||||
|
return items == null ? 0 : items.size();
|
||||||
|
}).when(excelAssemblyService).writeWorkbook(any(), any());
|
||||||
|
|
||||||
|
// 店铺级锁:每次获取返回独立句柄并计数(两次短临界区各取一次)。
|
||||||
|
lenient().when(dailyFileService.acquireLock(anyLong(), anyString())).thenAnswer(invocation -> {
|
||||||
|
TaskDistributedLockService.LockHandle handle = mock(TaskDistributedLockService.LockHandle.class);
|
||||||
|
lockAcquireCount.incrementAndGet();
|
||||||
|
lastLock.set(handle);
|
||||||
|
acquiredLocks.add(handle);
|
||||||
|
return handle;
|
||||||
|
});
|
||||||
|
lenient().when(dailyFileService.currentBusinessDate()).thenReturn(BUSINESS_DATE);
|
||||||
|
lenient().when(dailyFileService.currentBusinessDateTime()).thenReturn(BUSINESS_TIME);
|
||||||
|
lenient().when(dailyFileService.shopKeyHash(anyString())).thenAnswer(invocation -> {
|
||||||
|
String key = invocation.getArgument(0);
|
||||||
|
return key == null ? null : "hash:" + key;
|
||||||
|
});
|
||||||
|
lenient().when(dailyFileService.shopKey(any(FileResultEntity.class))).thenAnswer(invocation -> {
|
||||||
|
FileResultEntity row = invocation.getArgument(0);
|
||||||
|
return row == null ? null : row.getSourceFilename();
|
||||||
|
});
|
||||||
|
lenient().when(dailyFileService.findForUpdate(anyLong(), anyString(), any()))
|
||||||
|
.thenAnswer(invocation -> copyDailyFile(findDailyFile(
|
||||||
|
invocation.getArgument(0), invocation.getArgument(1))));
|
||||||
|
lenient().when(dailyFileService.findOlder(anyLong(), anyString(), any())).thenReturn(List.of());
|
||||||
|
lenient().when(dailyFileService.findByLatestResultId(anyLong())).thenReturn(List.of());
|
||||||
|
lenient().when(dailyFileService.findById(anyLong())).thenReturn(null);
|
||||||
|
lenient().when(dailyFileService.countObjectReferences(anyString())).thenReturn(0L);
|
||||||
|
lenient().doNothing().when(dailyFileService).deleteDailyFile(anyLong());
|
||||||
|
lenient().doNothing().when(dailyFileService).reassignMembers(anyLong(), anyLong());
|
||||||
|
lenient().doNothing().when(dailyFileService).deleteMembersForResults(any());
|
||||||
|
captureDailyFilePersistence();
|
||||||
|
captureMemberInserts();
|
||||||
|
captureResultUpdates();
|
||||||
|
|
||||||
|
// 结果行读取:listTaskRows 按 taskId+moduleType 过滤并升序;其他查询返回空。
|
||||||
|
lenient().when(fileResultMapper.selectList(any())).thenAnswer(invocation -> {
|
||||||
|
Wrapper<FileResultEntity> wrapper = invocation.getArgument(0);
|
||||||
|
if (wrapper instanceof LambdaQueryWrapper<?> query
|
||||||
|
&& query.getSqlSegment() != null && query.getSqlSegment().contains("taskId")) {
|
||||||
|
List<FileResultEntity> rows = dbResultRows.stream()
|
||||||
|
.filter(r -> Objects.equals(r.getTaskId(), lastJobTaskId))
|
||||||
|
.sorted(Comparator.comparing(FileResultEntity::getId))
|
||||||
|
.toList();
|
||||||
|
return new ArrayList<>(rows);
|
||||||
|
}
|
||||||
|
return List.of();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_036_daily_file_lock_normal_default_path() {
|
||||||
|
// 正常路径:归档成功,版本号从 1 起步;锁只取两次(准备/提交短临界区),
|
||||||
|
// 整表组装在锁外完成;成员行与累计文件各一。
|
||||||
|
FileResultEntity row = addResultRow(7201L, 1L, 1, SHOP_NAME, null);
|
||||||
|
processJob(1L, List.of(row), snapshot(7201L));
|
||||||
|
|
||||||
|
assertEquals(1, dbMembers.size(), "默认路径产生一个成员行");
|
||||||
|
assertEquals(1, dbDailyFiles.size(), "默认路径产生一个累计文件");
|
||||||
|
assertEquals(1L, dbDailyFiles.get(0).getVersion(), "版本号从 1 起步");
|
||||||
|
assertEquals(2, lockAcquireCount.get(), "准备/提交两次短临界区各取一次锁");
|
||||||
|
assertNotNull(lastUploadedObjectKey, "锁外组装上传了新对象");
|
||||||
|
verify(ossStorageService, never()).readObjectBytes(anyString());
|
||||||
|
verify(excelAssemblyService, never()).replaceCountriesWorkbook(any(), any(), any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_036_daily_file_lock_normal_multiple_items() {
|
||||||
|
// 多结果批量场景:多个结果先后归档,版本号随每次提交递增,成员累积且顺序稳定。
|
||||||
|
processJob(1L, List.of(addResultRow(7202L, 1L, 1, SHOP_NAME, null)), snapshot(7202L));
|
||||||
|
assertEquals(1L, dbDailyFiles.get(0).getVersion(), "首次归档版本 1");
|
||||||
|
processJob(2L, List.of(addResultRow(7203L, 2L, 1, SHOP_NAME, null)), snapshot(7203L));
|
||||||
|
|
||||||
|
assertEquals(2, dbMembers.size(), "两个结果各一个成员行");
|
||||||
|
assertEquals(2L, dbDailyFiles.get(0).getVersion(), "第二次归档版本递增到 2");
|
||||||
|
List<ShopDataCrawlDailyMemberEntity> members = dbMembers.stream()
|
||||||
|
.sorted(Comparator.comparing(ShopDataCrawlDailyMemberEntity::getResultId))
|
||||||
|
.toList();
|
||||||
|
assertEquals(7202L, members.get(0).getResultId());
|
||||||
|
assertEquals(7203L, members.get(1).getResultId());
|
||||||
|
assertNotNull(members.get(0).getRowPayload());
|
||||||
|
assertNotNull(members.get(1).getRowPayload());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_036_daily_file_lock_normal_repeated_operation_is_idempotent() {
|
||||||
|
// 幂等:同一结果重复归档不产生第二个成员行、版本号不递增、不重复上传对象。
|
||||||
|
FileResultEntity row = addResultRow(7204L, 1L, 1, SHOP_NAME, null);
|
||||||
|
processJob(1L, List.of(row), snapshot(7204L));
|
||||||
|
long versionAfterFirst = dbDailyFiles.get(0).getVersion();
|
||||||
|
String objectAfterFirst = lastUploadedObjectKey;
|
||||||
|
processJob(1L, List.of(row), snapshot(7204L));
|
||||||
|
|
||||||
|
assertEquals(1, dbMembers.size(), "重复归档不产生重复成员行");
|
||||||
|
assertEquals(versionAfterFirst, dbDailyFiles.get(0).getVersion(), "重复归档版本号不递增");
|
||||||
|
assertEquals(objectAfterFirst, lastUploadedObjectKey, "重复归档复用既有对象,不重复上传");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_036_daily_file_lock_boundary_empty_input() {
|
||||||
|
// 空输入:没有成功结果时安全跳过,不取店铺级锁、不创建成员/累计文件、不上传对象。
|
||||||
|
FileResultEntity row = addResultRow(7205L, 1L, 0, SHOP_NAME, null);
|
||||||
|
processJob(1L, List.of(row), snapshot(7205L));
|
||||||
|
|
||||||
|
assertTrue(dbMembers.isEmpty(), "无成功结果不创建成员行");
|
||||||
|
assertTrue(dbDailyFiles.isEmpty(), "无成功结果不创建累计文件");
|
||||||
|
assertEquals(0, lockAcquireCount.get(), "无成功结果不获取店铺级锁");
|
||||||
|
verify(ossStorageService, never()).uploadResultFile(any(), eq(MODULE_TYPE));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_036_daily_file_lock_boundary_single_item() {
|
||||||
|
// 单元素:单结果归档同样只取两次锁(短临界区),版本号 1,结果正确。
|
||||||
|
FileResultEntity row = addResultRow(7206L, 1L, 1, SHOP_NAME, null);
|
||||||
|
processJob(1L, List.of(row), snapshot(7206L));
|
||||||
|
|
||||||
|
assertEquals(1, dbMembers.size());
|
||||||
|
assertEquals(1, dbDailyFiles.size());
|
||||||
|
assertEquals(1L, dbDailyFiles.get(0).getVersion());
|
||||||
|
assertEquals(2, lockAcquireCount.get(), "单结果也是两次短临界区取锁");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_036_daily_file_lock_boundary_limit_and_overflow() {
|
||||||
|
// 上限/超限:大量结果逐个归档,版本号与成员数一致递增,无重复无丢失,
|
||||||
|
// 每次归档的锁获取次数仍为两次(不随量级放大锁持有时间)。
|
||||||
|
int limit = 210;
|
||||||
|
for (int i = 0; i < limit; i++) {
|
||||||
|
long resultId = nextResultId++;
|
||||||
|
long taskId = i + 1L;
|
||||||
|
FileResultEntity row = addResultRow(resultId, taskId, 1, SHOP_NAME, null);
|
||||||
|
processJob(taskId, List.of(row), snapshot(resultId));
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals(limit, dbMembers.size(), "大量结果成员行全部保留");
|
||||||
|
assertEquals(Long.valueOf(limit), dbDailyFiles.get(0).getVersion(), "版本号与归档次数一致");
|
||||||
|
assertEquals(limit * 2, lockAcquireCount.get(), "每次归档恰好两次短临界区取锁");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_036_daily_file_lock_invalid_input_rejected() {
|
||||||
|
// 非法参数/CAS 冲突:并发提交导致 version CAS 持续冲突时,重试 3 次后
|
||||||
|
// 抛出可识别的 BusinessException;每次冲突后上传对象被清理、锁被释放。
|
||||||
|
// 通过 findForUpdate 在每次读取后把版本号写回内存表(并返回读到的快照副本)
|
||||||
|
// 模拟并发写入:提交阶段读到的版本总是比准备阶段新 → 每次 CAS 都冲突。
|
||||||
|
// doAnswer().when() 覆盖 @BeforeEach 中同参数 when() 注册(后者重注册不生效)。
|
||||||
|
seedDailyFile();
|
||||||
|
FileResultEntity row = addResultRow(7207L, 1L, 1, SHOP_NAME, null);
|
||||||
|
lenient().doAnswer(invocation -> {
|
||||||
|
ShopDataCrawlDailyFileEntity current = findDailyFile(invocation.getArgument(0), invocation.getArgument(1));
|
||||||
|
if (current == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
ShopDataCrawlDailyFileEntity readCopy = copyDailyFile(current);
|
||||||
|
current.setVersion(readCopy.getVersion() + 1L);
|
||||||
|
return readCopy;
|
||||||
|
}).when(dailyFileService).findForUpdate(anyLong(), anyString(), any());
|
||||||
|
|
||||||
|
Exception ex = assertThrows(BusinessException.class,
|
||||||
|
() -> processJob(1L, List.of(row), snapshot(7207L)));
|
||||||
|
assertTrue(ex.getMessage().contains("并发"), "CAS 冲突超限后错误消息可识别");
|
||||||
|
assertTrue(dbMembers.isEmpty(), "冲突放弃后不残留成员行");
|
||||||
|
assertEquals(1, dbDailyFiles.size(), "冲突放弃后既有累计文件不受破坏");
|
||||||
|
assertEquals(6L, dbDailyFiles.get(0).getVersion(), "版本只随并发写入模拟推进(每次读取+1),冲突归档未提交");
|
||||||
|
assertEquals(6, acquiredLocks.size(), "三次尝试各取准备/提交两次锁");
|
||||||
|
for (TaskDistributedLockService.LockHandle handle : acquiredLocks) {
|
||||||
|
verify(handle).close();
|
||||||
|
}
|
||||||
|
verify(ossStorageService, times(3)).deleteObject(anyString());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_036_daily_file_lock_dependency_failure_releases_resources() {
|
||||||
|
// 依赖失败:提交阶段 DB 写入失败时错误可恢复,锁已释放、上传对象已清理、
|
||||||
|
// 不残留新成员/累计文件;组装成功路径不受影响(可再次归档)。
|
||||||
|
seedDailyFile();
|
||||||
|
doThrow(new RuntimeException("commit db down"))
|
||||||
|
.when(dailyFileService).update(any(ShopDataCrawlDailyFileEntity.class));
|
||||||
|
FileResultEntity row = addResultRow(7208L, 1L, 1, SHOP_NAME, null);
|
||||||
|
assertThrows(RuntimeException.class,
|
||||||
|
() -> processJob(1L, List.of(row), snapshot(7208L)));
|
||||||
|
|
||||||
|
assertTrue(dbMembers.isEmpty(), "提交失败不残留成员行");
|
||||||
|
assertEquals(1, dbDailyFiles.size(), "既有累计文件未被破坏");
|
||||||
|
assertEquals(0L, dbDailyFiles.get(0).getVersion(), "既有累计文件版本未被改动");
|
||||||
|
assertNotNull(lastLock.get(), "提交阶段已获取锁");
|
||||||
|
verify(lastLock.get()).close();
|
||||||
|
verify(ossStorageService).deleteObject(anyString());
|
||||||
|
|
||||||
|
// 错误可恢复:恢复 DB 后同一结果重新归档成功。
|
||||||
|
lenient().doAnswer(invocation -> {
|
||||||
|
ShopDataCrawlDailyFileEntity entity = invocation.getArgument(0);
|
||||||
|
for (int i = 0; i < dbDailyFiles.size(); i++) {
|
||||||
|
ShopDataCrawlDailyFileEntity existing = dbDailyFiles.get(i);
|
||||||
|
if (Objects.equals(existing.getUserId(), entity.getUserId())
|
||||||
|
&& Objects.equals(existing.getShopKeyHash(), entity.getShopKeyHash())
|
||||||
|
&& Objects.equals(existing.getBusinessDate(), entity.getBusinessDate())) {
|
||||||
|
entity.setId(existing.getId());
|
||||||
|
dbDailyFiles.set(i, entity);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
entity.setId(fileIdSeq.incrementAndGet());
|
||||||
|
dbDailyFiles.add(entity);
|
||||||
|
return null;
|
||||||
|
}).when(dailyFileService).update(any(ShopDataCrawlDailyFileEntity.class));
|
||||||
|
processJob(1L, List.of(row), snapshot(7208L));
|
||||||
|
assertEquals(1, dbMembers.size(), "恢复后同一结果重新归档成功");
|
||||||
|
assertEquals(1L, dbDailyFiles.get(0).getVersion(), "恢复后版本号递增");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- helpers ----
|
||||||
|
|
||||||
|
private void seedDailyFile() {
|
||||||
|
ShopDataCrawlDailyFileEntity file = new ShopDataCrawlDailyFileEntity();
|
||||||
|
file.setId(1L);
|
||||||
|
file.setUserId(7L);
|
||||||
|
file.setShopKeyHash("hash:" + SHOP_NAME);
|
||||||
|
file.setShopKey(SHOP_NAME);
|
||||||
|
file.setBusinessDate(BUSINESS_DATE);
|
||||||
|
file.setResultFilename("daily.xlsx");
|
||||||
|
file.setResultFileUrl("oss/daily/seed.xlsx");
|
||||||
|
file.setResultFileSize(10L);
|
||||||
|
file.setVersion(0L);
|
||||||
|
file.setRowCount(0);
|
||||||
|
file.setCreatedAt(BUSINESS_TIME);
|
||||||
|
file.setUpdatedAt(BUSINESS_TIME);
|
||||||
|
dbDailyFiles.add(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
private FileResultEntity addResultRow(long id, long taskId, int success, String shopName, String resultFileUrl) {
|
||||||
|
FileResultEntity row = new FileResultEntity();
|
||||||
|
row.setId(id);
|
||||||
|
row.setTaskId(taskId);
|
||||||
|
row.setModuleType(MODULE_TYPE);
|
||||||
|
row.setSuccess(success);
|
||||||
|
row.setSourceFilename(shopName);
|
||||||
|
row.setSourceFileUrl("shop-id-" + id);
|
||||||
|
row.setUserId(7L);
|
||||||
|
row.setCreatedAt(LocalDateTime.now());
|
||||||
|
row.setResultFileUrl(resultFileUrl);
|
||||||
|
dbResultRows.add(row);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ShopDataCrawlResultItemVo snapshot(long resultId) {
|
||||||
|
ShopDataCrawlResultItemVo item = new ShopDataCrawlResultItemVo();
|
||||||
|
item.setResultId(resultId);
|
||||||
|
item.setTaskId(1L);
|
||||||
|
item.setShopName(SHOP_NAME);
|
||||||
|
item.setShopId("shop-id-" + resultId);
|
||||||
|
item.setSuccess(true);
|
||||||
|
item.setMatched(true);
|
||||||
|
item.setTaskStatus("SUCCESS");
|
||||||
|
item.setCountryCodes(List.of("DE"));
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void processJob(long jobTaskId, List<FileResultEntity> rows, ShopDataCrawlResultItemVo snapshot) {
|
||||||
|
lastJobTaskId = jobTaskId;
|
||||||
|
TaskFileJobEntity job = new TaskFileJobEntity();
|
||||||
|
job.setId(jobTaskId);
|
||||||
|
job.setTaskId(jobTaskId);
|
||||||
|
job.setModuleType(MODULE_TYPE);
|
||||||
|
FileTaskEntity task = taskEntity(jobTaskId);
|
||||||
|
lenient().when(fileTaskMapper.selectById(jobTaskId)).thenReturn(task);
|
||||||
|
lenient().when(taskResultItemService.listResultSnapshots(eq(jobTaskId), eq(MODULE_TYPE), any()))
|
||||||
|
.thenReturn(snapshot == null ? List.of() : List.of(snapshot));
|
||||||
|
service.processResultFileJob(job);
|
||||||
|
}
|
||||||
|
|
||||||
|
private FileTaskEntity taskEntity(long taskId) {
|
||||||
|
FileTaskEntity task = new FileTaskEntity();
|
||||||
|
task.setId(taskId);
|
||||||
|
task.setModuleType(MODULE_TYPE);
|
||||||
|
task.setStatus("RUNNING");
|
||||||
|
task.setUserId(7L);
|
||||||
|
task.setOwnerInstanceId("instance-a");
|
||||||
|
task.setRequestJson("{\"ownerInstanceId\":\"instance-a\"}");
|
||||||
|
task.setResultJson("[]");
|
||||||
|
task.setCreatedAt(LocalDateTime.now());
|
||||||
|
task.setUpdatedAt(LocalDateTime.now());
|
||||||
|
return task;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void captureResultUpdates() {
|
||||||
|
lenient().when(fileResultMapper.updateById(any(FileResultEntity.class))).thenAnswer(invocation -> {
|
||||||
|
FileResultEntity updated = invocation.getArgument(0);
|
||||||
|
for (int i = 0; i < dbResultRows.size(); i++) {
|
||||||
|
if (Objects.equals(dbResultRows.get(i).getId(), updated.getId())) {
|
||||||
|
dbResultRows.set(i, updated);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dbResultRows.add(updated);
|
||||||
|
return 1;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void captureMemberInserts() {
|
||||||
|
lenient().when(dailyFileService.addMemberWithPayload(anyLong(), anyLong(), anyLong(), anyString()))
|
||||||
|
.thenAnswer(invocation -> {
|
||||||
|
long dailyFileId = invocation.getArgument(0);
|
||||||
|
long taskId = invocation.getArgument(1);
|
||||||
|
long resultId = invocation.getArgument(2);
|
||||||
|
String rowPayload = invocation.getArgument(3);
|
||||||
|
boolean duplicate = dbMembers.stream().anyMatch(m ->
|
||||||
|
Objects.equals(m.getDailyFileId(), dailyFileId)
|
||||||
|
&& Objects.equals(m.getResultId(), resultId));
|
||||||
|
if (duplicate) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
ShopDataCrawlDailyMemberEntity member = new ShopDataCrawlDailyMemberEntity();
|
||||||
|
member.setId(memberIdSeq.incrementAndGet());
|
||||||
|
member.setDailyFileId(dailyFileId);
|
||||||
|
member.setTaskId(taskId);
|
||||||
|
member.setResultId(resultId);
|
||||||
|
member.setRowPayload(rowPayload);
|
||||||
|
member.setCreatedAt(BUSINESS_TIME.plusMinutes(dbMembers.size()));
|
||||||
|
dbMembers.add(member);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void captureDailyFilePersistence() {
|
||||||
|
lenient().doAnswer(invocation -> {
|
||||||
|
ShopDataCrawlDailyFileEntity entity = invocation.getArgument(0);
|
||||||
|
entity.setId(fileIdSeq.incrementAndGet());
|
||||||
|
dbDailyFiles.add(entity);
|
||||||
|
return null;
|
||||||
|
}).when(dailyFileService).insert(any(ShopDataCrawlDailyFileEntity.class));
|
||||||
|
lenient().doAnswer(invocation -> {
|
||||||
|
ShopDataCrawlDailyFileEntity entity = invocation.getArgument(0);
|
||||||
|
for (int i = 0; i < dbDailyFiles.size(); i++) {
|
||||||
|
ShopDataCrawlDailyFileEntity existing = dbDailyFiles.get(i);
|
||||||
|
if (Objects.equals(existing.getUserId(), entity.getUserId())
|
||||||
|
&& Objects.equals(existing.getShopKeyHash(), entity.getShopKeyHash())
|
||||||
|
&& Objects.equals(existing.getBusinessDate(), entity.getBusinessDate())) {
|
||||||
|
entity.setId(existing.getId());
|
||||||
|
dbDailyFiles.set(i, copyDailyFile(entity));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
entity.setId(fileIdSeq.incrementAndGet());
|
||||||
|
dbDailyFiles.add(copyDailyFile(entity));
|
||||||
|
return null;
|
||||||
|
}).when(dailyFileService).update(any(ShopDataCrawlDailyFileEntity.class));
|
||||||
|
lenient().when(dailyFileService.listMembers(anyLong())).thenAnswer(invocation -> {
|
||||||
|
long dailyFileId = invocation.getArgument(0);
|
||||||
|
return dbMembers.stream()
|
||||||
|
.filter(m -> Objects.equals(m.getDailyFileId(), dailyFileId))
|
||||||
|
.sorted(Comparator
|
||||||
|
.comparing(ShopDataCrawlDailyMemberEntity::getCreatedAt,
|
||||||
|
Comparator.nullsLast(Comparator.naturalOrder()))
|
||||||
|
.thenComparing(ShopDataCrawlDailyMemberEntity::getId,
|
||||||
|
Comparator.nullsLast(Comparator.naturalOrder())))
|
||||||
|
.toList();
|
||||||
|
});
|
||||||
|
lenient().when(dailyFileService.containsResult(anyLong(), anyLong())).thenAnswer(invocation -> {
|
||||||
|
long dailyFileId = invocation.getArgument(0);
|
||||||
|
long resultId = invocation.getArgument(1);
|
||||||
|
return dbMembers.stream().anyMatch(m ->
|
||||||
|
Objects.equals(m.getDailyFileId(), dailyFileId) && Objects.equals(m.getResultId(), resultId));
|
||||||
|
});
|
||||||
|
lenient().when(dailyFileService.findMembersByResultId(anyLong())).thenAnswer(invocation -> {
|
||||||
|
long resultId = invocation.getArgument(0);
|
||||||
|
return dbMembers.stream().filter(m -> Objects.equals(m.getResultId(), resultId)).toList();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private ShopDataCrawlDailyFileEntity findDailyFile(Long userId, String shopKeyHash) {
|
||||||
|
for (ShopDataCrawlDailyFileEntity f : dbDailyFiles) {
|
||||||
|
if (Objects.equals(f.getUserId(), userId) && Objects.equals(f.getShopKeyHash(), shopKeyHash)) {
|
||||||
|
return f;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ShopDataCrawlDailyFileEntity copyDailyFile(ShopDataCrawlDailyFileEntity source) {
|
||||||
|
if (source == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
ShopDataCrawlDailyFileEntity copy = new ShopDataCrawlDailyFileEntity();
|
||||||
|
copy.setId(source.getId());
|
||||||
|
copy.setUserId(source.getUserId());
|
||||||
|
copy.setShopKeyHash(source.getShopKeyHash());
|
||||||
|
copy.setShopKey(source.getShopKey());
|
||||||
|
copy.setBusinessDate(source.getBusinessDate());
|
||||||
|
copy.setLatestTaskId(source.getLatestTaskId());
|
||||||
|
copy.setLatestResultId(source.getLatestResultId());
|
||||||
|
copy.setResultFilename(source.getResultFilename());
|
||||||
|
copy.setResultFileUrl(source.getResultFileUrl());
|
||||||
|
copy.setResultFileSize(source.getResultFileSize());
|
||||||
|
copy.setResultContentType(source.getResultContentType());
|
||||||
|
copy.setRowCount(source.getRowCount());
|
||||||
|
copy.setVersion(source.getVersion());
|
||||||
|
copy.setLastSuccessAt(source.getLastSuccessAt());
|
||||||
|
copy.setCreatedAt(source.getCreatedAt());
|
||||||
|
copy.setUpdatedAt(source.getUpdatedAt());
|
||||||
|
return copy;
|
||||||
|
}
|
||||||
|
}
|
||||||
+35
@@ -104,6 +104,41 @@ class ShopDataCrawlExcelAssemblyServiceTest {
|
|||||||
assertEquals(2, total);
|
assertEquals(2, total);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void writeWorkbookLatestMemberWinsPerCountryOthersPreserved() throws Exception {
|
||||||
|
// 增量语义:两个成员快照(第一次英德法、第二次只更新德国)写入 workbook 时,
|
||||||
|
// 德国 sheet 以第二个成员(新任务)的行走覆盖,英国/法国保留第一个成员的行。
|
||||||
|
ShopDataCrawlRowDto ukRow = row("2026-07-25", "B000000001");
|
||||||
|
ShopDataCrawlRowDto frRow = row("2026-07-26", "B000000002");
|
||||||
|
ShopDataCrawlRowDto deOldRow = row("2026-07-27", "B000000003");
|
||||||
|
ShopDataCrawlRowDto deNewRow = row("2026-07-28", "B000000004");
|
||||||
|
|
||||||
|
SimilarAsinImageEmbedder imageEmbedder = mock(SimilarAsinImageEmbedder.class);
|
||||||
|
when(imageEmbedder.fetchAndResizeForCache(ukRow.getCommodityImage()))
|
||||||
|
.thenReturn(new SimilarAsinImageEmbedder.ResizedImage(jpegBytes(), 2, 2));
|
||||||
|
when(imageEmbedder.fetchAndResizeForCache(frRow.getCommodityImage()))
|
||||||
|
.thenReturn(new SimilarAsinImageEmbedder.ResizedImage(jpegBytes(), 2, 2));
|
||||||
|
when(imageEmbedder.fetchAndResizeForCache(deOldRow.getCommodityImage()))
|
||||||
|
.thenReturn(new SimilarAsinImageEmbedder.ResizedImage(jpegBytes(), 2, 2));
|
||||||
|
ShopDataCrawlExcelAssemblyService service = new ShopDataCrawlExcelAssemblyService(imageEmbedder);
|
||||||
|
File output = tempDir.resolve("incremental.xlsx").toFile();
|
||||||
|
|
||||||
|
// 第一个成员:英德法三国有行;第二个成员:只带德国新行
|
||||||
|
service.writeWorkbook(output, List.of(
|
||||||
|
item("UK", ukRow), item("DE", deOldRow), item("FR", frRow),
|
||||||
|
item("DE", deNewRow)));
|
||||||
|
|
||||||
|
try (XSSFWorkbook workbook = new XSSFWorkbook(new FileInputStream(output))) {
|
||||||
|
assertEquals("B000000001", workbook.getSheet("英国").getRow(1).getCell(1).getStringCellValue(), "英国保留旧行");
|
||||||
|
assertEquals("B000000002", workbook.getSheet("法国").getRow(1).getCell(1).getStringCellValue(), "法国保留旧行");
|
||||||
|
assertEquals(1, workbook.getSheet("英国").getLastRowNum(), "英国 sheet 只有一行旧数据");
|
||||||
|
assertEquals(1, workbook.getSheet("法国").getLastRowNum(), "法国 sheet 只有一行旧数据");
|
||||||
|
assertEquals(1, workbook.getSheet("德国").getLastRowNum(), "德国 sheet 被新任务覆盖为一行");
|
||||||
|
assertEquals("B000000004", workbook.getSheet("德国").getRow(1).getCell(1).getStringCellValue(), "德国显示新任务的行");
|
||||||
|
assertEquals(0, workbook.getSheet("西班牙").getLastRowNum(), "从未提交的国家保持空表");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private ShopDataCrawlResultItemVo item(String countryCode, ShopDataCrawlRowDto row) {
|
private ShopDataCrawlResultItemVo item(String countryCode, ShopDataCrawlRowDto row) {
|
||||||
ShopDataCrawlCountryResultDto country = new ShopDataCrawlCountryResultDto();
|
ShopDataCrawlCountryResultDto country = new ShopDataCrawlCountryResultDto();
|
||||||
country.setCountry(countryCode);
|
country.setCountry(countryCode);
|
||||||
|
|||||||
+587
@@ -0,0 +1,587 @@
|
|||||||
|
package com.nanri.aiimage.modules.shopdatacrawl.service;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
|
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.nanri.aiimage.common.exception.BusinessException;
|
||||||
|
import com.nanri.aiimage.config.InstanceMetadata;
|
||||||
|
import com.nanri.aiimage.config.TaskPressureProperties;
|
||||||
|
import com.nanri.aiimage.modules.file.service.oss.OssStorageService;
|
||||||
|
import com.nanri.aiimage.modules.shopdatacrawl.model.dto.ShopDataCrawlCountryResultDto;
|
||||||
|
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.task.mapper.FileResultMapper;
|
||||||
|
import com.nanri.aiimage.modules.task.mapper.FileTaskMapper;
|
||||||
|
import com.nanri.aiimage.modules.task.mapper.TaskChunkMapper;
|
||||||
|
import com.nanri.aiimage.modules.task.mapper.TaskScopeStateMapper;
|
||||||
|
import com.nanri.aiimage.modules.task.model.entity.FileResultEntity;
|
||||||
|
import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity;
|
||||||
|
import com.nanri.aiimage.modules.task.model.entity.TaskChunkEntity;
|
||||||
|
import com.nanri.aiimage.modules.task.model.entity.TaskScopeStateEntity;
|
||||||
|
import com.nanri.aiimage.modules.task.service.TaskDistributedLockService;
|
||||||
|
import com.nanri.aiimage.modules.task.service.TaskFileJobService;
|
||||||
|
import com.nanri.aiimage.modules.task.service.TaskProgressSnapshotService;
|
||||||
|
import com.nanri.aiimage.modules.task.service.TaskResultItemService;
|
||||||
|
import com.nanri.aiimage.modules.task.service.TransientPayloadStorageService;
|
||||||
|
import com.nanri.aiimage.modules.ziniao.service.ZiniaoShopSwitchService;
|
||||||
|
import org.apache.ibatis.builder.MapperBuilderAssistant;
|
||||||
|
import org.apache.ibatis.session.Configuration;
|
||||||
|
import org.junit.jupiter.api.BeforeAll;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.Spy;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
import org.springframework.dao.DuplicateKeyException;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyLong;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.ArgumentMatchers.nullable;
|
||||||
|
import static org.mockito.Mockito.lenient;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.never;
|
||||||
|
import static org.mockito.Mockito.times;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Task 31:将任务快照改为轻量进度字段,避免每次写入完整结果 JSON。
|
||||||
|
* RUNNING 期间每次分片接收只更新任务行上的轻量进度字段(successFileCount /
|
||||||
|
* failedFileCount / status / updatedAt),不再序列化完整结果 JSON,也不写快照表;
|
||||||
|
* 完整结果 JSON(含国家行)只在任务终态(全部行完成)时写入一次。
|
||||||
|
*/
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class ShopDataCrawlLightweightProgressTest {
|
||||||
|
|
||||||
|
private static final String MODULE_TYPE = "SHOP_DATA_CRAWL";
|
||||||
|
private static final String SHOP_NAME = "Demo Shop";
|
||||||
|
|
||||||
|
@BeforeAll
|
||||||
|
static void initializeMybatisMetadata() {
|
||||||
|
MapperBuilderAssistant assistant = new MapperBuilderAssistant(new Configuration(), "");
|
||||||
|
TableInfoHelper.initTableInfo(assistant, FileTaskEntity.class);
|
||||||
|
TableInfoHelper.initTableInfo(assistant, FileResultEntity.class);
|
||||||
|
TableInfoHelper.initTableInfo(assistant, TaskChunkEntity.class);
|
||||||
|
TableInfoHelper.initTableInfo(assistant, TaskScopeStateEntity.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Mock private FileTaskMapper fileTaskMapper;
|
||||||
|
@Mock private FileResultMapper fileResultMapper;
|
||||||
|
@Mock private ShopDataCrawlResolveService shopDataCrawlResolveService;
|
||||||
|
@Mock private ShopDataCrawlExcelAssemblyService excelAssemblyService;
|
||||||
|
@Mock private ShopDataCrawlTaskCacheService taskCacheService;
|
||||||
|
@Mock private OssStorageService ossStorageService;
|
||||||
|
@Mock private ZiniaoShopSwitchService ziniaoShopSwitchService;
|
||||||
|
@Mock private TaskPressureProperties taskPressureProperties;
|
||||||
|
@Mock private TaskFileJobService taskFileJobService;
|
||||||
|
@Mock private TaskResultItemService taskResultItemService;
|
||||||
|
@Mock private TaskProgressSnapshotService taskProgressSnapshotService;
|
||||||
|
@Mock private TaskDistributedLockService taskDistributedLockService;
|
||||||
|
@Mock private TaskChunkMapper taskChunkMapper;
|
||||||
|
@Mock private TaskScopeStateMapper taskScopeStateMapper;
|
||||||
|
@Mock private TransientPayloadStorageService transientPayloadStorageService;
|
||||||
|
@Mock private InstanceMetadata instanceMetadata;
|
||||||
|
@Mock private ShopDataCrawlDailyFileService dailyFileService;
|
||||||
|
@Spy private ObjectMapper objectMapper = new ObjectMapper().findAndRegisterModules();
|
||||||
|
|
||||||
|
private ShopDataCrawlTaskService service;
|
||||||
|
|
||||||
|
private final List<TaskChunkEntity> storedChunks = new ArrayList<>();
|
||||||
|
private final List<TaskScopeStateEntity> storedScopes = new ArrayList<>();
|
||||||
|
private final Map<String, String> rustfsPayloads = new LinkedHashMap<>();
|
||||||
|
private FileTaskEntity task;
|
||||||
|
private FileResultEntity result;
|
||||||
|
private int nextPayloadId;
|
||||||
|
private boolean insertFails;
|
||||||
|
private boolean scopeUpdateFails;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void configureStorage() {
|
||||||
|
service = new ShopDataCrawlTaskService(
|
||||||
|
fileTaskMapper,
|
||||||
|
fileResultMapper,
|
||||||
|
shopDataCrawlResolveService,
|
||||||
|
excelAssemblyService,
|
||||||
|
taskCacheService,
|
||||||
|
ossStorageService,
|
||||||
|
ziniaoShopSwitchService,
|
||||||
|
objectMapper,
|
||||||
|
taskPressureProperties,
|
||||||
|
taskFileJobService,
|
||||||
|
taskResultItemService,
|
||||||
|
taskProgressSnapshotService,
|
||||||
|
taskDistributedLockService,
|
||||||
|
taskChunkMapper,
|
||||||
|
taskScopeStateMapper,
|
||||||
|
transientPayloadStorageService,
|
||||||
|
instanceMetadata,
|
||||||
|
dailyFileService,
|
||||||
|
null);
|
||||||
|
|
||||||
|
storedChunks.clear();
|
||||||
|
storedScopes.clear();
|
||||||
|
rustfsPayloads.clear();
|
||||||
|
nextPayloadId = 0;
|
||||||
|
insertFails = false;
|
||||||
|
scopeUpdateFails = false;
|
||||||
|
|
||||||
|
lenient().when(instanceMetadata.getInstanceId()).thenReturn("instance-a");
|
||||||
|
lenient().when(taskDistributedLockService.acquire(eq(MODULE_TYPE), anyLong()))
|
||||||
|
.thenReturn(mock(TaskDistributedLockService.LockHandle.class));
|
||||||
|
lenient().when(taskCacheService.getTaskCacheBatch(any())).thenReturn(Map.of());
|
||||||
|
lenient().when(taskCacheService.getAllShopMergedPayload(anyLong())).thenReturn(Map.of());
|
||||||
|
lenient().when(taskResultItemService.listResultSnapshots(anyLong(), eq(MODULE_TYPE), any())).thenReturn(List.of());
|
||||||
|
lenient().when(taskFileJobService.findAssembleJob(anyLong(), eq(MODULE_TYPE), anyLong())).thenReturn(null);
|
||||||
|
lenient().when(excelAssemblyService.countRows(any())).thenReturn(1);
|
||||||
|
|
||||||
|
lenient().when(fileTaskMapper.selectById(anyLong())).thenAnswer(invocation -> {
|
||||||
|
Long taskId = invocation.getArgument(0);
|
||||||
|
return task != null && Objects.equals(taskId, task.getId()) ? task : null;
|
||||||
|
});
|
||||||
|
lenient().when(fileTaskMapper.updateById(any(FileTaskEntity.class))).thenReturn(1);
|
||||||
|
lenient().when(fileResultMapper.selectById(anyLong())).thenAnswer(invocation -> {
|
||||||
|
Long resultId = invocation.getArgument(0);
|
||||||
|
return result != null && Objects.equals(resultId, result.getId()) ? result : null;
|
||||||
|
});
|
||||||
|
lenient().when(fileResultMapper.selectList(any())).thenAnswer(invocation -> {
|
||||||
|
LambdaQueryWrapper<FileResultEntity> query = invocation.getArgument(0);
|
||||||
|
Long taskId = queryLong(query);
|
||||||
|
return result != null && Objects.equals(taskId, result.getTaskId()) ? List.of(result) : List.of();
|
||||||
|
});
|
||||||
|
lenient().when(fileResultMapper.updateById(any(FileResultEntity.class))).thenReturn(1);
|
||||||
|
|
||||||
|
lenient().when(ziniaoShopSwitchService.normalizeShopName(nullable(String.class))).thenAnswer(invocation -> {
|
||||||
|
String value = invocation.getArgument(0);
|
||||||
|
return value == null ? "" : value.trim();
|
||||||
|
});
|
||||||
|
|
||||||
|
configureTransientPayloadStorage();
|
||||||
|
configureChunkMapper();
|
||||||
|
configureScopeMapper();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_031_snapshot_progress_normal_default_path() {
|
||||||
|
// 正常路径:分片到达只更新轻量进度字段(行成功、计数器推进),
|
||||||
|
// 不写完整结果 JSON、不写快照表;终态时完整 JSON 才写入一次。
|
||||||
|
givenRunningTask(1311L, 2311L);
|
||||||
|
|
||||||
|
service.submitResult(task.getId(), request(chunk(1, 2, "DE", row("2026-07-25", "B001"))));
|
||||||
|
assertEquals("RUNNING", task.getStatus(), "未齐集仍为 RUNNING");
|
||||||
|
assertEquals(0, successFileCount(), "未齐集不推进成功计数");
|
||||||
|
assertEquals(1, storedScopes.get(0).getReceivedChunkCount());
|
||||||
|
assertTrue(!hasResultRowsInJson(), "RUNNING 期间不写完整结果 JSON");
|
||||||
|
verify(taskResultItemService, never()).replaceTaskSnapshots(any(), any(), any(), any());
|
||||||
|
|
||||||
|
service.submitResult(task.getId(), request(chunk(2, 2, "UK", row("2026-07-26", "B002"))));
|
||||||
|
assertEquals(1, Integer.valueOf(task.getSuccessFileCount()), "齐集后成功计数=1");
|
||||||
|
assertEquals(0, Integer.valueOf(task.getFailedFileCount()));
|
||||||
|
assertEquals("SUCCESS", task.getStatus());
|
||||||
|
assertTrue(hasResultRowsInJson(), "终态写入完整结果 JSON");
|
||||||
|
assertTrue(task.getResultJson().indexOf("B001") < task.getResultJson().indexOf("B002"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_031_snapshot_progress_normal_multiple_items() {
|
||||||
|
// 批量场景:多分片逐批到达,轻量进度字段持续反映进度,快照表一次不写。
|
||||||
|
givenRunningTask(1312L, 2312L);
|
||||||
|
|
||||||
|
service.submitResult(task.getId(), request(chunk(1, 3, "DE", row("2026-07-25", "B001"))));
|
||||||
|
service.submitResult(task.getId(), request(chunk(2, 3, "UK", row("2026-07-26", "B002"))));
|
||||||
|
assertEquals("RUNNING", task.getStatus());
|
||||||
|
assertEquals(0, successFileCount());
|
||||||
|
verify(taskResultItemService, never()).replaceTaskSnapshots(any(), any(), any(), any());
|
||||||
|
|
||||||
|
service.submitResult(task.getId(), request(chunk(3, 3, "FR", row("2026-07-27", "B003"))));
|
||||||
|
assertEquals("SUCCESS", task.getStatus());
|
||||||
|
assertEquals(1, Integer.valueOf(task.getSuccessFileCount()));
|
||||||
|
assertEquals(0, Integer.valueOf(task.getFailedFileCount()), "失败计数保留 0");
|
||||||
|
|
||||||
|
String json = task.getResultJson();
|
||||||
|
assertTrue(json.indexOf("B001") < json.indexOf("B002") && json.indexOf("B002") < json.indexOf("B003"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_031_snapshot_progress_normal_repeated_operation_is_idempotent() {
|
||||||
|
// 幂等:同一分片重放不重复推进进度,计数器/状态稳定,无重复快照写。
|
||||||
|
givenRunningTask(1313L, 2313L);
|
||||||
|
ShopDataCrawlSubmitResultRequest request = request(chunk(1, 1, "DE", row("2026-07-25", "B001")));
|
||||||
|
|
||||||
|
service.submitResult(task.getId(), request);
|
||||||
|
assertEquals(1, Integer.valueOf(task.getSuccessFileCount()));
|
||||||
|
assertEquals("SUCCESS", task.getStatus());
|
||||||
|
String jsonAfterFirst = task.getResultJson();
|
||||||
|
assertTrue(hasResultRowsInJson(), "终态写入完整结果 JSON");
|
||||||
|
|
||||||
|
BusinessException replayError = assertThrows(BusinessException.class, () ->
|
||||||
|
service.submitResult(task.getId(), request));
|
||||||
|
assertTrue(replayError.getMessage().contains("已结束"), "任务终态后重复提交被拒");
|
||||||
|
assertEquals(1, Integer.valueOf(task.getSuccessFileCount()), "重放不重复计数");
|
||||||
|
assertEquals(jsonAfterFirst, task.getResultJson(), "重放不放大完整 JSON");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_031_snapshot_progress_boundary_empty_input() {
|
||||||
|
// 空输入:空分片在快照写路径之前被拒,无进度更新、无资源创建。
|
||||||
|
givenRunningTask(1314L, 2314L);
|
||||||
|
|
||||||
|
ShopDataCrawlShopPayloadDto emptyItems = new ShopDataCrawlShopPayloadDto();
|
||||||
|
emptyItems.setShopName(SHOP_NAME);
|
||||||
|
emptyItems.setChunkIndex(1);
|
||||||
|
emptyItems.setChunkTotal(1);
|
||||||
|
emptyItems.setCountryResults(List.of(countryWithItems("DE", List.of())));
|
||||||
|
BusinessException error = assertThrows(BusinessException.class, () ->
|
||||||
|
service.submitResult(task.getId(), request(emptyItems)));
|
||||||
|
assertTrue(error.getMessage().contains("内容为空"));
|
||||||
|
|
||||||
|
assertEquals(0, storedChunks.size());
|
||||||
|
assertEquals(0, rustfsPayloads.size());
|
||||||
|
assertEquals(0, storedScopes.size());
|
||||||
|
assertEquals(0, successFileCount(), "空分片不推进进度");
|
||||||
|
verify(taskResultItemService, never()).replaceTaskSnapshots(any(), any(), any(), any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_031_snapshot_progress_boundary_single_item() {
|
||||||
|
// 单元素:单分片 1/1 直接终态,完整 JSON 写入一次,无重复快照写。
|
||||||
|
givenRunningTask(1315L, 2315L);
|
||||||
|
|
||||||
|
service.submitResult(task.getId(), request(chunk(1, 1, "DE", row("2026-07-25", "B001"))));
|
||||||
|
|
||||||
|
assertEquals("SUCCESS", task.getStatus());
|
||||||
|
assertEquals(1, Integer.valueOf(task.getSuccessFileCount()));
|
||||||
|
assertTrue(hasResultRowsInJson());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_031_snapshot_progress_boundary_limit_and_overflow() {
|
||||||
|
// 上限/超限:5/5 全量到达 + 一次重放,轻量进度最终收敛,完整 JSON 只写一次。
|
||||||
|
givenRunningTask(1316L, 2316L);
|
||||||
|
|
||||||
|
service.submitResult(task.getId(), request(chunk(1, 5, "UK", row("2026-07-25", "B001"))));
|
||||||
|
service.submitResult(task.getId(), request(chunk(1, 5, "UK", row("2026-07-25", "B001"))));
|
||||||
|
assertEquals("RUNNING", task.getStatus());
|
||||||
|
assertEquals(0, Integer.valueOf(task.getSuccessFileCount()));
|
||||||
|
|
||||||
|
String[] countries = {"DE", "FR", "ES", "IT"};
|
||||||
|
String[] dates = {"2026-07-26", "2026-07-27", "2026-07-28", "2026-07-29"};
|
||||||
|
for (int i = 2; i <= 5; i++) {
|
||||||
|
service.submitResult(task.getId(), request(chunk(i, 5, countries[i - 2], row(dates[i - 2], "B00" + i))));
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals("SUCCESS", task.getStatus());
|
||||||
|
assertEquals(1, Integer.valueOf(task.getSuccessFileCount()));
|
||||||
|
assertEquals(1, storedScopes.size(), "scope 行始终只有一行");
|
||||||
|
assertEquals(5, storedScopes.get(0).getReceivedChunkCount());
|
||||||
|
assertEquals(5, countResultJsonRows(), "终态完整 JSON 含全部 5 行");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_031_snapshot_progress_invalid_input_rejected() {
|
||||||
|
// 非法参数:非正 chunk_index/chunk_total 在进度写路径之前拒绝,无状态写入。
|
||||||
|
givenRunningTask(1317L, 2317L);
|
||||||
|
|
||||||
|
BusinessException zeroIndex = assertThrows(BusinessException.class, () ->
|
||||||
|
service.submitResult(task.getId(), request(chunk(0, 1, "DE", row("2026-07-25", "B001")))));
|
||||||
|
assertTrue(zeroIndex.getMessage().contains("chunk_index"));
|
||||||
|
BusinessException zeroTotal = assertThrows(BusinessException.class, () ->
|
||||||
|
service.submitResult(task.getId(), request(chunk(1, 0, "DE", row("2026-07-25", "B001")))));
|
||||||
|
assertTrue(zeroTotal.getMessage().contains("chunk_total"));
|
||||||
|
|
||||||
|
assertEquals(0, storedChunks.size());
|
||||||
|
assertEquals(0, storedScopes.size());
|
||||||
|
assertEquals("RUNNING", task.getStatus());
|
||||||
|
assertEquals(0, successFileCount());
|
||||||
|
verify(taskResultItemService, never()).replaceTaskSnapshots(any(), any(), any(), any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_031_snapshot_progress_dependency_failure_releases_resources() {
|
||||||
|
// 依赖失败:chunk 插入失败 → payload 清理、无进度写入;恢复后重试成功,
|
||||||
|
// 终态完整 JSON 写入一次。
|
||||||
|
givenRunningTask(1318L, 2318L);
|
||||||
|
|
||||||
|
insertFails = true;
|
||||||
|
assertThrows(RuntimeException.class, () ->
|
||||||
|
service.submitResult(task.getId(), request(chunk(1, 2, "DE", row("2026-07-25", "B001")))));
|
||||||
|
assertEquals(0, storedChunks.size());
|
||||||
|
assertEquals(0, rustfsPayloads.size());
|
||||||
|
assertEquals(0, storedScopes.size());
|
||||||
|
insertFails = false;
|
||||||
|
|
||||||
|
service.submitResult(task.getId(), request(chunk(1, 2, "DE", row("2026-07-25", "B001"))));
|
||||||
|
service.submitResult(task.getId(), request(chunk(2, 2, "UK", row("2026-07-26", "B002"))));
|
||||||
|
|
||||||
|
assertEquals("SUCCESS", task.getStatus());
|
||||||
|
assertEquals(1, Integer.valueOf(task.getSuccessFileCount()));
|
||||||
|
assertTrue(hasResultRowsInJson());
|
||||||
|
}
|
||||||
|
|
||||||
|
private int successFileCount() {
|
||||||
|
return task.getSuccessFileCount() == null ? 0 : task.getSuccessFileCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean hasResultRowsInJson() {
|
||||||
|
return countResultJsonRows() > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private int countResultJsonRows() {
|
||||||
|
try {
|
||||||
|
List<ShopDataCrawlResultItemVo> snapshots = objectMapper.readValue(task.getResultJson(),
|
||||||
|
objectMapper.getTypeFactory().constructCollectionType(List.class, ShopDataCrawlResultItemVo.class));
|
||||||
|
int count = 0;
|
||||||
|
for (ShopDataCrawlResultItemVo snapshot : snapshots) {
|
||||||
|
if (snapshot.getCountryResults() == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (ShopDataCrawlCountryResultDto country : snapshot.getCountryResults()) {
|
||||||
|
count += country.getItems() == null ? 0 : country.getItems().size();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
} catch (Exception ex) {
|
||||||
|
throw new IllegalStateException("解析结果 JSON 失败", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void configureTransientPayloadStorage() {
|
||||||
|
lenient().when(transientPayloadStorageService.isSharedWriteEnabled()).thenReturn(true);
|
||||||
|
lenient().when(transientPayloadStorageService.storeChunkPayloadVersioned(
|
||||||
|
anyString(), anyLong(), anyString(), any(), anyString())).thenAnswer(invocation -> {
|
||||||
|
String pointer = "rustfs:payload-" + (++nextPayloadId);
|
||||||
|
rustfsPayloads.put(pointer, invocation.getArgument(4));
|
||||||
|
return pointer;
|
||||||
|
});
|
||||||
|
lenient().when(transientPayloadStorageService.extractPointer(any())).thenAnswer(invocation -> {
|
||||||
|
String value = invocation.getArgument(0);
|
||||||
|
if (value == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return value.startsWith("rustfs:") || value.startsWith("local:") || value.startsWith("oss:")
|
||||||
|
? value : null;
|
||||||
|
});
|
||||||
|
lenient().when(transientPayloadStorageService.resolvePayload(any(), any())).thenAnswer(invocation -> {
|
||||||
|
String pointer = invocation.getArgument(0);
|
||||||
|
String payload = rustfsPayloads.get(pointer);
|
||||||
|
if (payload == null) {
|
||||||
|
throw new IllegalStateException("missing test RustFS payload: " + pointer);
|
||||||
|
}
|
||||||
|
return payload;
|
||||||
|
});
|
||||||
|
lenient().doAnswer(invocation -> {
|
||||||
|
rustfsPayloads.remove(invocation.getArgument(0));
|
||||||
|
return null;
|
||||||
|
}).when(transientPayloadStorageService).deletePayloadIfPresent(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void configureChunkMapper() {
|
||||||
|
lenient().when(taskChunkMapper.insert(any(TaskChunkEntity.class))).thenAnswer(invocation -> {
|
||||||
|
if (insertFails) {
|
||||||
|
throw new RuntimeException("db down");
|
||||||
|
}
|
||||||
|
TaskChunkEntity chunk = invocation.getArgument(0);
|
||||||
|
boolean duplicate = storedChunks.stream().anyMatch(existing ->
|
||||||
|
Objects.equals(existing.getTaskId(), chunk.getTaskId())
|
||||||
|
&& Objects.equals(existing.getScopeHash(), chunk.getScopeHash())
|
||||||
|
&& Objects.equals(existing.getChunkIndex(), chunk.getChunkIndex()));
|
||||||
|
if (duplicate) {
|
||||||
|
throw new DuplicateKeyException("duplicate chunk key: " + chunk.getScopeHash() + "/" + chunk.getChunkIndex());
|
||||||
|
}
|
||||||
|
chunk.setId((long) storedChunks.size() + 1L);
|
||||||
|
storedChunks.add(chunk);
|
||||||
|
return 1;
|
||||||
|
});
|
||||||
|
lenient().when(taskChunkMapper.selectOne(any())).thenAnswer(invocation -> {
|
||||||
|
LambdaQueryWrapper<TaskChunkEntity> query = invocation.getArgument(0);
|
||||||
|
Long taskId = queryLong(query);
|
||||||
|
String scopeHash = queryScopeHash(query);
|
||||||
|
Integer chunkIndex = queryInteger(query);
|
||||||
|
return storedChunks.stream()
|
||||||
|
.filter(chunk -> matchesChunk(chunk, taskId, scopeHash, chunkIndex))
|
||||||
|
.findFirst()
|
||||||
|
.orElse(null);
|
||||||
|
});
|
||||||
|
lenient().when(taskChunkMapper.selectList(any())).thenAnswer(invocation -> {
|
||||||
|
LambdaQueryWrapper<TaskChunkEntity> query = invocation.getArgument(0);
|
||||||
|
Long taskId = queryLong(query);
|
||||||
|
String scopeHash = queryScopeHash(query);
|
||||||
|
return storedChunks.stream()
|
||||||
|
.filter(chunk -> matchesChunk(chunk, taskId, scopeHash, null))
|
||||||
|
.sorted(Comparator.comparing(TaskChunkEntity::getChunkIndex))
|
||||||
|
.toList();
|
||||||
|
});
|
||||||
|
lenient().when(taskChunkMapper.delete(any())).thenAnswer(invocation -> {
|
||||||
|
LambdaQueryWrapper<TaskChunkEntity> query = invocation.getArgument(0);
|
||||||
|
Long taskId = queryLong(query);
|
||||||
|
String scopeHash = queryScopeHash(query);
|
||||||
|
Integer chunkIndex = queryInteger(query);
|
||||||
|
int before = storedChunks.size();
|
||||||
|
storedChunks.removeIf(chunk -> matchesChunk(chunk, taskId, scopeHash, chunkIndex));
|
||||||
|
return before - storedChunks.size();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void configureScopeMapper() {
|
||||||
|
lenient().when(taskScopeStateMapper.insert(any(TaskScopeStateEntity.class))).thenAnswer(invocation -> {
|
||||||
|
TaskScopeStateEntity scope = invocation.getArgument(0);
|
||||||
|
scope.setId((long) storedScopes.size() + 1L);
|
||||||
|
storedScopes.add(scope);
|
||||||
|
return 1;
|
||||||
|
});
|
||||||
|
lenient().when(taskScopeStateMapper.selectOne(any())).thenAnswer(invocation -> {
|
||||||
|
LambdaQueryWrapper<TaskScopeStateEntity> query = invocation.getArgument(0);
|
||||||
|
Long taskId = queryLong(query);
|
||||||
|
String scopeHash = queryScopeHash(query);
|
||||||
|
return storedScopes.stream()
|
||||||
|
.filter(scope -> Objects.equals(taskId, scope.getTaskId()))
|
||||||
|
.filter(scope -> scopeHash == null || Objects.equals(scopeHash, scope.getScopeHash()))
|
||||||
|
.findFirst()
|
||||||
|
.map(ShopDataCrawlLightweightProgressTest::copyScope)
|
||||||
|
.orElse(null);
|
||||||
|
});
|
||||||
|
lenient().when(taskScopeStateMapper.updateById(any(TaskScopeStateEntity.class))).thenAnswer(invocation -> {
|
||||||
|
if (scopeUpdateFails) {
|
||||||
|
throw new RuntimeException("scope update down");
|
||||||
|
}
|
||||||
|
TaskScopeStateEntity updated = invocation.getArgument(0);
|
||||||
|
for (int i = 0; i < storedScopes.size(); i++) {
|
||||||
|
if (Objects.equals(storedScopes.get(i).getId(), updated.getId())) {
|
||||||
|
storedScopes.set(i, copyScope(updated));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 1;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TaskScopeStateEntity copyScope(TaskScopeStateEntity source) {
|
||||||
|
TaskScopeStateEntity copy = new TaskScopeStateEntity();
|
||||||
|
copy.setId(source.getId());
|
||||||
|
copy.setTaskId(source.getTaskId());
|
||||||
|
copy.setModuleType(source.getModuleType());
|
||||||
|
copy.setScopeKey(source.getScopeKey());
|
||||||
|
copy.setScopeHash(source.getScopeHash());
|
||||||
|
copy.setChunkTotal(source.getChunkTotal());
|
||||||
|
copy.setReceivedChunkCount(source.getReceivedChunkCount());
|
||||||
|
copy.setCompleted(source.getCompleted());
|
||||||
|
copy.setLastChunkAt(source.getLastChunkAt());
|
||||||
|
copy.setLastError(source.getLastError());
|
||||||
|
copy.setCreatedAt(source.getCreatedAt());
|
||||||
|
copy.setUpdatedAt(source.getUpdatedAt());
|
||||||
|
return copy;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean matchesChunk(TaskChunkEntity chunk, Long taskId, String scopeHash, Integer chunkIndex) {
|
||||||
|
return (taskId == null || Objects.equals(taskId, chunk.getTaskId()))
|
||||||
|
&& (scopeHash == null || Objects.equals(scopeHash, chunk.getScopeHash()))
|
||||||
|
&& (chunkIndex == null || Objects.equals(chunkIndex, chunk.getChunkIndex()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Long queryLong(LambdaQueryWrapper<?> query) {
|
||||||
|
query.getSqlSegment();
|
||||||
|
return query.getParamNameValuePairs().values().stream()
|
||||||
|
.filter(Long.class::isInstance)
|
||||||
|
.map(Long.class::cast)
|
||||||
|
.findFirst()
|
||||||
|
.orElse(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Integer queryInteger(LambdaQueryWrapper<?> query) {
|
||||||
|
query.getSqlSegment();
|
||||||
|
return query.getParamNameValuePairs().values().stream()
|
||||||
|
.filter(Integer.class::isInstance)
|
||||||
|
.map(Integer.class::cast)
|
||||||
|
.findFirst()
|
||||||
|
.orElse(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String queryScopeHash(LambdaQueryWrapper<?> query) {
|
||||||
|
query.getSqlSegment();
|
||||||
|
return query.getParamNameValuePairs().values().stream()
|
||||||
|
.filter(String.class::isInstance)
|
||||||
|
.map(String.class::cast)
|
||||||
|
.filter(value -> value.length() == 64)
|
||||||
|
.findFirst()
|
||||||
|
.orElse(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void givenRunningTask(long taskId, long resultId) {
|
||||||
|
task = new FileTaskEntity();
|
||||||
|
task.setId(taskId);
|
||||||
|
task.setUserId(7L);
|
||||||
|
task.setModuleType(MODULE_TYPE);
|
||||||
|
task.setStatus("RUNNING");
|
||||||
|
task.setRequestJson("{\"ownerInstanceId\":\"instance-a\"}");
|
||||||
|
task.setResultJson("[]");
|
||||||
|
task.setCreatedAt(LocalDateTime.now());
|
||||||
|
task.setUpdatedAt(LocalDateTime.now());
|
||||||
|
|
||||||
|
result = new FileResultEntity();
|
||||||
|
result.setId(resultId);
|
||||||
|
result.setTaskId(taskId);
|
||||||
|
result.setUserId(7L);
|
||||||
|
result.setModuleType(MODULE_TYPE);
|
||||||
|
result.setSourceFilename(SHOP_NAME);
|
||||||
|
result.setSourceFileUrl("shop-1");
|
||||||
|
result.setSuccess(-1);
|
||||||
|
result.setCreatedAt(LocalDateTime.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
private ShopDataCrawlSubmitResultRequest request(ShopDataCrawlShopPayloadDto payload) {
|
||||||
|
ShopDataCrawlSubmitResultRequest request = new ShopDataCrawlSubmitResultRequest();
|
||||||
|
request.setShops(List.of(payload));
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ShopDataCrawlShopPayloadDto chunk(int chunkIndex,
|
||||||
|
int chunkTotal,
|
||||||
|
String country,
|
||||||
|
ShopDataCrawlRowDto row) {
|
||||||
|
ShopDataCrawlShopPayloadDto payload = new ShopDataCrawlShopPayloadDto();
|
||||||
|
payload.setShopName(SHOP_NAME);
|
||||||
|
payload.setChunkIndex(chunkIndex);
|
||||||
|
payload.setChunkTotal(chunkTotal);
|
||||||
|
payload.setCountryResults(List.of(country(country, row)));
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ShopDataCrawlCountryResultDto country(String country, ShopDataCrawlRowDto row) {
|
||||||
|
return countryWithItems(country, List.of(row));
|
||||||
|
}
|
||||||
|
|
||||||
|
private ShopDataCrawlCountryResultDto countryWithItems(String country, List<ShopDataCrawlRowDto> items) {
|
||||||
|
ShopDataCrawlCountryResultDto result = new ShopDataCrawlCountryResultDto();
|
||||||
|
result.setCountry(country);
|
||||||
|
result.setItems(items);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ShopDataCrawlRowDto row(String date, String asin) {
|
||||||
|
ShopDataCrawlRowDto row = new ShopDataCrawlRowDto();
|
||||||
|
row.setDate(date);
|
||||||
|
row.setAsin(asin);
|
||||||
|
row.setCommodityImage("https://m.media-amazon.com/images/I/" + asin + ".jpg");
|
||||||
|
row.setInventorySales("10");
|
||||||
|
row.setSalesRank("20");
|
||||||
|
row.setPageViews("30");
|
||||||
|
row.setUnitsSold("40");
|
||||||
|
row.setPrice("50");
|
||||||
|
row.setRecommendedOffer("60");
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
}
|
||||||
+381
@@ -0,0 +1,381 @@
|
|||||||
|
package com.nanri.aiimage.modules.shopdatacrawl.service;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.Wrapper;
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
|
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.nanri.aiimage.common.exception.BusinessException;
|
||||||
|
import com.nanri.aiimage.common.exception.TaskOwnerMismatchException;
|
||||||
|
import com.nanri.aiimage.config.InstanceMetadata;
|
||||||
|
import com.nanri.aiimage.config.TaskPressureProperties;
|
||||||
|
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.task.mapper.FileResultMapper;
|
||||||
|
import com.nanri.aiimage.modules.task.mapper.FileTaskMapper;
|
||||||
|
import com.nanri.aiimage.modules.task.mapper.TaskChunkMapper;
|
||||||
|
import com.nanri.aiimage.modules.task.mapper.TaskScopeStateMapper;
|
||||||
|
import com.nanri.aiimage.modules.task.model.entity.FileResultEntity;
|
||||||
|
import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity;
|
||||||
|
import com.nanri.aiimage.modules.task.model.entity.TaskChunkEntity;
|
||||||
|
import com.nanri.aiimage.modules.task.model.entity.TaskScopeStateEntity;
|
||||||
|
import com.nanri.aiimage.modules.task.service.TaskDistributedLockService;
|
||||||
|
import com.nanri.aiimage.modules.task.service.TaskFileJobService;
|
||||||
|
import com.nanri.aiimage.modules.task.service.TaskProgressSnapshotService;
|
||||||
|
import com.nanri.aiimage.modules.task.service.TaskResultItemService;
|
||||||
|
import com.nanri.aiimage.modules.task.service.TransientPayloadStorageService;
|
||||||
|
import com.nanri.aiimage.modules.ziniao.model.vo.ZiniaoShopMatchResultVo;
|
||||||
|
import com.nanri.aiimage.modules.ziniao.service.ZiniaoShopSwitchService;
|
||||||
|
import org.apache.ibatis.builder.MapperBuilderAssistant;
|
||||||
|
import org.apache.ibatis.session.Configuration;
|
||||||
|
import org.junit.jupiter.api.BeforeAll;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.Spy;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.regex.Matcher;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyInt;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyLong;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.ArgumentMatchers.nullable;
|
||||||
|
import static org.mockito.Mockito.doThrow;
|
||||||
|
import static org.mockito.Mockito.lenient;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.never;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Task 34:将 ownerInstanceId 从 JSON 查询迁移到显式列并补充索引。
|
||||||
|
* 原 ownerInstanceId 只存在于 request_json 的 JSON 字段里,stale 扫描用
|
||||||
|
* JSON_UNQUOTE(JSON_EXTRACT(...)) 过滤、任务归属校验用 Jackson 解析整棵 JSON 树;
|
||||||
|
* 迁移后写入/读取/查询都走 biz_file_task.owner_instance_id 显式列
|
||||||
|
* (V92 迁移新增列并补 (owner_instance_id, status, updated_at) 索引),
|
||||||
|
* JSON 解析不再参与 owner 判定,stale 扫描直接按列过滤。
|
||||||
|
*/
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class ShopDataCrawlOwnerColumnTest {
|
||||||
|
|
||||||
|
private static final String MODULE_TYPE = "SHOP_DATA_CRAWL";
|
||||||
|
private static final String SHOP_NAME = "Demo Shop";
|
||||||
|
|
||||||
|
@BeforeAll
|
||||||
|
static void initializeMybatisMetadata() {
|
||||||
|
MapperBuilderAssistant assistant = new MapperBuilderAssistant(new Configuration(), "");
|
||||||
|
TableInfoHelper.initTableInfo(assistant, FileTaskEntity.class);
|
||||||
|
TableInfoHelper.initTableInfo(assistant, FileResultEntity.class);
|
||||||
|
TableInfoHelper.initTableInfo(assistant, TaskChunkEntity.class);
|
||||||
|
TableInfoHelper.initTableInfo(assistant, TaskScopeStateEntity.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Mock private FileTaskMapper fileTaskMapper;
|
||||||
|
@Mock private FileResultMapper fileResultMapper;
|
||||||
|
@Mock private ShopDataCrawlResolveService shopDataCrawlResolveService;
|
||||||
|
@Mock private ShopDataCrawlExcelAssemblyService excelAssemblyService;
|
||||||
|
@Mock private ShopDataCrawlTaskCacheService taskCacheService;
|
||||||
|
@Mock private OssStorageService ossStorageService;
|
||||||
|
@Mock private ZiniaoShopSwitchService ziniaoShopSwitchService;
|
||||||
|
@Mock private TaskPressureProperties taskPressureProperties;
|
||||||
|
@Mock private TaskFileJobService taskFileJobService;
|
||||||
|
@Mock private TaskResultItemService taskResultItemService;
|
||||||
|
@Mock private TaskProgressSnapshotService taskProgressSnapshotService;
|
||||||
|
@Mock private TaskDistributedLockService taskDistributedLockService;
|
||||||
|
@Mock private TaskChunkMapper taskChunkMapper;
|
||||||
|
@Mock private TaskScopeStateMapper taskScopeStateMapper;
|
||||||
|
@Mock private TransientPayloadStorageService transientPayloadStorageService;
|
||||||
|
@Mock private InstanceMetadata instanceMetadata;
|
||||||
|
@Mock private ShopDataCrawlDailyFileService dailyFileService;
|
||||||
|
@Spy private ObjectMapper objectMapper = new ObjectMapper().findAndRegisterModules();
|
||||||
|
|
||||||
|
private ShopDataCrawlTaskService service;
|
||||||
|
/** 内存中的任务表(createTask 写入 / stale 扫描过滤的源数据)。 */
|
||||||
|
private final List<FileTaskEntity> dbTasks = new ArrayList<>();
|
||||||
|
/** 最近一次 stale 扫描返回的结果。 */
|
||||||
|
private final List<FileTaskEntity> lastScan = new ArrayList<>();
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void configureStorage() {
|
||||||
|
service = new ShopDataCrawlTaskService(
|
||||||
|
fileTaskMapper,
|
||||||
|
fileResultMapper,
|
||||||
|
shopDataCrawlResolveService,
|
||||||
|
excelAssemblyService,
|
||||||
|
taskCacheService,
|
||||||
|
ossStorageService,
|
||||||
|
ziniaoShopSwitchService,
|
||||||
|
objectMapper,
|
||||||
|
taskPressureProperties,
|
||||||
|
taskFileJobService,
|
||||||
|
taskResultItemService,
|
||||||
|
taskProgressSnapshotService,
|
||||||
|
taskDistributedLockService,
|
||||||
|
taskChunkMapper,
|
||||||
|
taskScopeStateMapper,
|
||||||
|
transientPayloadStorageService,
|
||||||
|
instanceMetadata,
|
||||||
|
dailyFileService,
|
||||||
|
null);
|
||||||
|
|
||||||
|
dbTasks.clear();
|
||||||
|
lastScan.clear();
|
||||||
|
|
||||||
|
lenient().when(instanceMetadata.getInstanceId()).thenReturn("instance-a");
|
||||||
|
lenient().when(taskDistributedLockService.acquire(eq(MODULE_TYPE), anyLong()))
|
||||||
|
.thenReturn(mock(TaskDistributedLockService.LockHandle.class));
|
||||||
|
lenient().when(taskCacheService.getTaskCacheBatch(any())).thenReturn(Map.of());
|
||||||
|
lenient().when(taskCacheService.getTaskHeartbeatMillisBatch(any())).thenReturn(Map.of());
|
||||||
|
lenient().when(taskResultItemService.listResultSnapshots(anyLong(), eq(MODULE_TYPE), any())).thenReturn(List.of());
|
||||||
|
lenient().when(taskFileJobService.findAssembleJob(anyLong(), eq(MODULE_TYPE), anyLong())).thenReturn(null);
|
||||||
|
lenient().when(taskFileJobService.countUnfinishedAssembleJobs(anyLong(), eq(MODULE_TYPE))).thenReturn(0L);
|
||||||
|
lenient().when(excelAssemblyService.countRows(any())).thenReturn(1);
|
||||||
|
lenient().when(ziniaoShopSwitchService.normalizeShopName(nullable(String.class))).thenAnswer(invocation -> {
|
||||||
|
String value = invocation.getArgument(0);
|
||||||
|
return value == null ? "" : value.trim();
|
||||||
|
});
|
||||||
|
lenient().when(fileTaskMapper.updateById(any(FileTaskEntity.class))).thenReturn(1);
|
||||||
|
lenient().when(fileTaskMapper.selectById(anyLong())).thenReturn(null);
|
||||||
|
lenient().when(fileTaskMapper.selectList(any())).thenAnswer(invocation -> {
|
||||||
|
Wrapper<FileTaskEntity> wrapper = invocation.getArgument(0);
|
||||||
|
lastScan.clear();
|
||||||
|
lastScan.addAll(applyTaskScanFilter(wrapper, dbTasks));
|
||||||
|
return new ArrayList<>(lastScan);
|
||||||
|
});
|
||||||
|
lenient().when(fileResultMapper.selectList(any())).thenReturn(List.of());
|
||||||
|
lenient().when(fileResultMapper.updateById(any(FileResultEntity.class))).thenReturn(1);
|
||||||
|
lenient().when(dailyFileService.countObjectReferences(anyString())).thenReturn(0L);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_034_owner_normal_default_path() {
|
||||||
|
// 正常路径:createTask 把 ownerInstanceId 写入显式列,持久化后 owner 路由可读。
|
||||||
|
ShopDataCrawlCreateTaskVo vo = createTask(3401L);
|
||||||
|
assertNotNull(vo);
|
||||||
|
assertEquals("instance-a", dbTask(3401L).getOwnerInstanceId(), "显式列写入当前实例 id");
|
||||||
|
assertTrue(dbTask(3401L).getRequestJson().contains("ownerInstanceId"), "兼容字段仍保留在快照 JSON");
|
||||||
|
|
||||||
|
// stale 扫描按显式列过滤当前实例 RUNNING 任务,命中 owner 路由。
|
||||||
|
runStaleScan();
|
||||||
|
assertEquals(1, lastScan.size(), "stale 扫描按 owner_instance_id 列过滤命中");
|
||||||
|
assertEquals(3401L, lastScan.get(0).getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_034_owner_normal_multiple_items() {
|
||||||
|
// 批量场景:多个任务各自带 owner 列,扫描只返回当前实例的任务,其他实例不命中。
|
||||||
|
dbTasks.add(task(3402L, "instance-a", "RUNNING"));
|
||||||
|
dbTasks.add(task(3403L, "instance-a", "RUNNING"));
|
||||||
|
dbTasks.add(task(3404L, "instance-b", "RUNNING"));
|
||||||
|
dbTasks.add(task(3405L, "instance-a", "SUCCESS"));
|
||||||
|
|
||||||
|
runStaleScan();
|
||||||
|
|
||||||
|
assertEquals(2, lastScan.size(), "只返回当前实例的 RUNNING 任务");
|
||||||
|
assertEquals(3402L, lastScan.get(0).getId());
|
||||||
|
assertEquals(3403L, lastScan.get(1).getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_034_owner_normal_repeated_operation_is_idempotent() {
|
||||||
|
// 幂等:同一输入重复提交创建的两个任务 owner 列值稳定一致;
|
||||||
|
// 重复 stale 扫描不重复处理已终结任务(终态任务不再命中)。
|
||||||
|
ShopDataCrawlCreateTaskVo first = createTask(3406L);
|
||||||
|
ShopDataCrawlCreateTaskVo second = createTask(3407L);
|
||||||
|
|
||||||
|
assertNotNull(first);
|
||||||
|
assertNotNull(second);
|
||||||
|
assertEquals("instance-a", dbTask(3406L).getOwnerInstanceId(), "重复提交 owner 列值稳定");
|
||||||
|
assertEquals("instance-a", dbTask(3407L).getOwnerInstanceId(), "重复提交 owner 列值一致");
|
||||||
|
|
||||||
|
runStaleScan();
|
||||||
|
assertEquals(2, lastScan.size(), "两个任务都被当前实例接管");
|
||||||
|
|
||||||
|
// 首次扫描已把 stale 任务终结(FAILED),再次扫描不再命中,不重复处理。
|
||||||
|
runStaleScan();
|
||||||
|
assertEquals(0, lastScan.size(), "重复扫描不重复处理已终结任务");
|
||||||
|
assertEquals("FAILED", dbTask(3406L).getStatus(), "任务已被终结");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_034_owner_boundary_empty_input() {
|
||||||
|
// 空输入:无 RUNNING 任务时扫描返回空,不抛异常、不触碰无关资源。
|
||||||
|
runStaleScan();
|
||||||
|
assertEquals(0, lastScan.size(), "空任务列表安全跳过");
|
||||||
|
verify(fileTaskMapper, never()).updateById(any(FileTaskEntity.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_034_owner_boundary_single_item() {
|
||||||
|
// 单元素:仅一个当前实例 RUNNING 任务被扫描命中,owner 列解析不依赖批量路径。
|
||||||
|
FileTaskEntity single = task(3407L, "instance-a", "RUNNING");
|
||||||
|
single.setUpdatedAt(LocalDateTime.now().minusMinutes(45));
|
||||||
|
dbTasks.add(single);
|
||||||
|
|
||||||
|
runStaleScan();
|
||||||
|
|
||||||
|
assertEquals(1, lastScan.size());
|
||||||
|
assertEquals(3407L, lastScan.get(0).getId());
|
||||||
|
assertEquals("instance-a", dbTask(3407L).getOwnerInstanceId());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_034_owner_boundary_limit_and_overflow() {
|
||||||
|
// 上限/超限:owner 列值接近上限长度(110 字符,可稳定写入)仍完整读写;
|
||||||
|
// 大量任务扫描不放大结果。
|
||||||
|
String longOwner = "instance-" + "x".repeat(110);
|
||||||
|
lenient().when(instanceMetadata.getInstanceId()).thenReturn(longOwner);
|
||||||
|
ShopDataCrawlCreateTaskVo vo = createTask(3408L);
|
||||||
|
assertNotNull(vo);
|
||||||
|
assertEquals(longOwner, dbTask(3408L).getOwnerInstanceId(), "长 owner 值完整保留");
|
||||||
|
assertTrue(dbTask(3408L).getOwnerInstanceId().length() <= 128, "不超列长度上限");
|
||||||
|
|
||||||
|
// 恢复当前实例 id 后扫描 40 个 instance-a 任务。
|
||||||
|
lenient().when(instanceMetadata.getInstanceId()).thenReturn("instance-a");
|
||||||
|
for (int i = 0; i < 40; i++) {
|
||||||
|
dbTasks.add(task(10000L + i, "instance-a", "RUNNING"));
|
||||||
|
}
|
||||||
|
runStaleScan();
|
||||||
|
assertEquals(40, lastScan.size(), "大量任务逐一命中,无重复无丢失");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_034_owner_invalid_input_rejected() {
|
||||||
|
// 非法参数:无 owner 列的 RUNNING 任务不属于任何实例,扫描不命中(区别于旧 JSON 兼容分支)。
|
||||||
|
dbTasks.add(task(3409L, null, "RUNNING"));
|
||||||
|
|
||||||
|
runStaleScan();
|
||||||
|
|
||||||
|
assertEquals(0, lastScan.size(), "owner 列缺失的任务不属于当前实例");
|
||||||
|
// 归属性判定对缺 owner 列的任务放行兼容读取,但不归属任何实例。
|
||||||
|
FileTaskEntity legacy = task(3410L, null, "RUNNING");
|
||||||
|
legacy.setRequestJson("{\"ownerInstanceId\":\"instance-a\"}");
|
||||||
|
service.ensureTaskOwnedByCurrentInstance(legacy, "legacy callback");
|
||||||
|
assertEquals("instance-a", service.ownerInstanceIdOf(legacy), "旧 JSON 兜底仍可读取 owner");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_034_owner_dependency_failure_releases_resources() {
|
||||||
|
// 依赖失败:扫描 DB 异常时安全降级不抛错、不泄漏任务;他人实例任务被拒且无副作用。
|
||||||
|
FileTaskEntity foreign = task(3411L, "instance-b", "RUNNING");
|
||||||
|
foreign.setRequestJson("{\"ownerInstanceId\":\"instance-b\"}");
|
||||||
|
assertThrows(TaskOwnerMismatchException.class, () ->
|
||||||
|
service.ensureTaskOwnedByCurrentInstance(foreign, "submit shop data crawl result"));
|
||||||
|
|
||||||
|
doThrow(new RuntimeException("db down")).when(fileTaskMapper).selectList(any());
|
||||||
|
service.finalizeOwnedStaleTasks();
|
||||||
|
assertTrue(lastScan.isEmpty(), "DB 异常时扫描降级为空");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- helpers ----
|
||||||
|
|
||||||
|
private ShopDataCrawlCreateTaskVo createTask(long taskId) {
|
||||||
|
ShopDataCrawlCreateTaskRequest request = new ShopDataCrawlCreateTaskRequest();
|
||||||
|
request.setUserId(7L);
|
||||||
|
request.setCountryCodes(List.of("DE"));
|
||||||
|
ShopDataCrawlTaskItemDto item = new ShopDataCrawlTaskItemDto();
|
||||||
|
item.setShopName(SHOP_NAME);
|
||||||
|
request.setItems(List.of(item));
|
||||||
|
|
||||||
|
ZiniaoShopMatchResultVo matched = new ZiniaoShopMatchResultVo();
|
||||||
|
matched.setMatched(true);
|
||||||
|
matched.setShopId("shop-1");
|
||||||
|
matched.setPlatform("AMAZON");
|
||||||
|
matched.setCompanyName("Demo Co");
|
||||||
|
matched.setMatchStatus("MATCHED");
|
||||||
|
matched.setMatchMessage("ok");
|
||||||
|
lenient().when(shopDataCrawlResolveService.validateCountryCodes(any())).thenReturn(List.of("DE"));
|
||||||
|
lenient().when(shopDataCrawlResolveService.requireMatchedShop(nullable(String.class))).thenReturn(matched);
|
||||||
|
lenient().doNothing().when(taskResultItemService).replaceTaskSnapshots(anyLong(), eq(MODULE_TYPE), any(), any());
|
||||||
|
lenient().doNothing().when(taskProgressSnapshotService)
|
||||||
|
.save(anyLong(), any(), any(), anyInt(), anyInt(), anyInt(), any(), any(), any());
|
||||||
|
lenient().when(fileTaskMapper.insert(any(FileTaskEntity.class))).thenAnswer(invocation -> {
|
||||||
|
FileTaskEntity inserted = invocation.getArgument(0);
|
||||||
|
boolean duplicate = dbTasks.stream()
|
||||||
|
.anyMatch(t -> Objects.equals(t.getId(), inserted.getId())
|
||||||
|
|| Objects.equals(t.getTaskNo(), inserted.getTaskNo()));
|
||||||
|
if (duplicate) {
|
||||||
|
throw new org.springframework.dao.DuplicateKeyException("duplicate task");
|
||||||
|
}
|
||||||
|
inserted.setId(taskId);
|
||||||
|
dbTasks.add(inserted);
|
||||||
|
return 1;
|
||||||
|
});
|
||||||
|
lenient().when(fileResultMapper.insert(any(FileResultEntity.class))).thenReturn(1);
|
||||||
|
|
||||||
|
return service.createTask(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
private FileTaskEntity dbTask(long taskId) {
|
||||||
|
for (FileTaskEntity t : dbTasks) {
|
||||||
|
if (Objects.equals(t.getId(), taskId)) {
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private FileTaskEntity task(long id, String ownerInstanceId, String status) {
|
||||||
|
FileTaskEntity task = new FileTaskEntity();
|
||||||
|
task.setId(id);
|
||||||
|
task.setModuleType(MODULE_TYPE);
|
||||||
|
task.setStatus(status);
|
||||||
|
task.setOwnerInstanceId(ownerInstanceId);
|
||||||
|
task.setRequestJson(ownerInstanceId == null ? "{}" : "{\"ownerInstanceId\":\"" + ownerInstanceId + "\"}");
|
||||||
|
task.setResultJson("[]");
|
||||||
|
task.setCreatedAt(LocalDateTime.now());
|
||||||
|
task.setUpdatedAt(LocalDateTime.now());
|
||||||
|
return task;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void runStaleScan() {
|
||||||
|
service.finalizeOwnedStaleTasks();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按 SQL 片段中出现的列名与参数占位符顺序提取 owner_instance_id / status 的查询值,
|
||||||
|
* 模拟 MySQL 按显式列过滤(与生产查询的语义一致,仅用于筛选 dbTasks)。
|
||||||
|
*/
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
private List<FileTaskEntity> applyTaskScanFilter(Wrapper<FileTaskEntity> wrapper, List<FileTaskEntity> candidates) {
|
||||||
|
if (!(wrapper instanceof LambdaQueryWrapper<?> query)) {
|
||||||
|
return candidates;
|
||||||
|
}
|
||||||
|
String sql = query.getSqlSegment();
|
||||||
|
Map<String, Object> params = query.getParamNameValuePairs();
|
||||||
|
final String[] ownerFilter = {null};
|
||||||
|
final String[] statusFilter = {null};
|
||||||
|
if (sql != null && sql.contains("ownerInstanceId")) {
|
||||||
|
Matcher matcher = Pattern.compile("([a-zA-Z_]+)\\s*=\\s*#\\{ew\\.paramNameValuePairs\\.([A-Za-z0-9]+)\\}")
|
||||||
|
.matcher(sql);
|
||||||
|
while (matcher.find()) {
|
||||||
|
String column = matcher.group(1);
|
||||||
|
Object value = params.get(matcher.group(2));
|
||||||
|
if ("ownerInstanceId".equals(column) && value instanceof String s) {
|
||||||
|
ownerFilter[0] = s;
|
||||||
|
} else if ("status".equals(column) && value instanceof String s) {
|
||||||
|
statusFilter[0] = s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return candidates.stream()
|
||||||
|
.filter(t -> ownerFilter[0] == null || Objects.equals(ownerFilter[0], t.getOwnerInstanceId()))
|
||||||
|
.filter(t -> statusFilter[0] == null || Objects.equals(statusFilter[0], t.getStatus()))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
}
|
||||||
+537
@@ -0,0 +1,537 @@
|
|||||||
|
package com.nanri.aiimage.modules.shopdatacrawl.service;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.Wrapper;
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
|
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.nanri.aiimage.common.exception.BusinessException;
|
||||||
|
import com.nanri.aiimage.config.InstanceMetadata;
|
||||||
|
import com.nanri.aiimage.config.TaskPressureProperties;
|
||||||
|
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.task.mapper.FileResultMapper;
|
||||||
|
import com.nanri.aiimage.modules.task.mapper.FileTaskMapper;
|
||||||
|
import com.nanri.aiimage.modules.task.mapper.TaskChunkMapper;
|
||||||
|
import com.nanri.aiimage.modules.task.mapper.TaskScopeStateMapper;
|
||||||
|
import com.nanri.aiimage.modules.task.model.entity.FileResultEntity;
|
||||||
|
import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity;
|
||||||
|
import com.nanri.aiimage.modules.task.model.entity.TaskChunkEntity;
|
||||||
|
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
|
||||||
|
import com.nanri.aiimage.modules.task.model.entity.TaskScopeStateEntity;
|
||||||
|
import com.nanri.aiimage.modules.task.service.TaskDistributedLockService;
|
||||||
|
import com.nanri.aiimage.modules.task.service.TaskFileJobService;
|
||||||
|
import com.nanri.aiimage.modules.task.service.TaskProgressSnapshotService;
|
||||||
|
import com.nanri.aiimage.modules.task.service.TaskResultItemService;
|
||||||
|
import com.nanri.aiimage.modules.task.service.TransientPayloadStorageService;
|
||||||
|
import com.nanri.aiimage.modules.ziniao.service.ZiniaoShopSwitchService;
|
||||||
|
import org.apache.ibatis.builder.MapperBuilderAssistant;
|
||||||
|
import org.apache.ibatis.session.Configuration;
|
||||||
|
import org.junit.jupiter.api.BeforeAll;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.Spy;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
|
import java.util.concurrent.atomic.AtomicLong;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyInt;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyLong;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.ArgumentMatchers.nullable;
|
||||||
|
import static org.mockito.Mockito.doThrow;
|
||||||
|
import static org.mockito.Mockito.lenient;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.never;
|
||||||
|
import static org.mockito.Mockito.times;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Task 38:历史列表与进度查询增加分页、字段裁剪和批量任务加载。
|
||||||
|
* listHistory 支持 page/limit 分页(limit 收敛到 [1,100],page ≥ 1,越界页返回空)、
|
||||||
|
* 结果行字段裁剪、批量任务加载(按 ID 批次查询 + 作业状态批量附着);
|
||||||
|
* getTaskProgressBatch 保持 50 个任务上限与顺序稳定,并把底层查询失败
|
||||||
|
* 收敛为可识别的业务异常而不是裸 NPE。
|
||||||
|
*/
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class ShopDataCrawlProgressQueryTest {
|
||||||
|
|
||||||
|
private static final String MODULE_TYPE = "SHOP_DATA_CRAWL";
|
||||||
|
private static final long USER_ID = 7L;
|
||||||
|
|
||||||
|
@BeforeAll
|
||||||
|
static void initializeMybatisMetadata() {
|
||||||
|
MapperBuilderAssistant assistant = new MapperBuilderAssistant(new Configuration(), "");
|
||||||
|
TableInfoHelper.initTableInfo(assistant, FileTaskEntity.class);
|
||||||
|
TableInfoHelper.initTableInfo(assistant, FileResultEntity.class);
|
||||||
|
TableInfoHelper.initTableInfo(assistant, TaskChunkEntity.class);
|
||||||
|
TableInfoHelper.initTableInfo(assistant, TaskScopeStateEntity.class);
|
||||||
|
TableInfoHelper.initTableInfo(assistant, TaskFileJobEntity.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Mock private FileTaskMapper fileTaskMapper;
|
||||||
|
@Mock private FileResultMapper fileResultMapper;
|
||||||
|
@Mock private ShopDataCrawlResolveService shopDataCrawlResolveService;
|
||||||
|
@Mock private ShopDataCrawlExcelAssemblyService excelAssemblyService;
|
||||||
|
@Mock private ShopDataCrawlTaskCacheService taskCacheService;
|
||||||
|
@Mock private OssStorageService ossStorageService;
|
||||||
|
@Mock private ZiniaoShopSwitchService ziniaoShopSwitchService;
|
||||||
|
@Mock private TaskPressureProperties taskPressureProperties;
|
||||||
|
@Mock private TaskFileJobService taskFileJobService;
|
||||||
|
@Mock private TaskResultItemService taskResultItemService;
|
||||||
|
@Mock private TaskProgressSnapshotService taskProgressSnapshotService;
|
||||||
|
@Mock private TaskDistributedLockService taskDistributedLockService;
|
||||||
|
@Mock private TaskChunkMapper taskChunkMapper;
|
||||||
|
@Mock private TaskScopeStateMapper taskScopeStateMapper;
|
||||||
|
@Mock private TransientPayloadStorageService transientPayloadStorageService;
|
||||||
|
@Mock private InstanceMetadata instanceMetadata;
|
||||||
|
@Mock private ShopDataCrawlDailyFileService dailyFileService;
|
||||||
|
@Spy private ObjectMapper objectMapper = new ObjectMapper().findAndRegisterModules();
|
||||||
|
|
||||||
|
private ShopDataCrawlTaskService service;
|
||||||
|
|
||||||
|
private final List<FileResultEntity> dbResultRows = new ArrayList<>();
|
||||||
|
private final List<FileTaskEntity> dbTasks = new ArrayList<>();
|
||||||
|
/** 内存 file_job 表:findAssembleJobsByResultIds / findAssembleJob 的仿真数据源。 */
|
||||||
|
private final List<TaskFileJobEntity> dbFileJobs = new ArrayList<>();
|
||||||
|
private final Map<Long, FileTaskEntity> taskStore = new HashMap<>();
|
||||||
|
/** 仿真 selectList 抛出的底层错误,模拟数据库查询失败。 */
|
||||||
|
private RuntimeException resultQueryFailure;
|
||||||
|
|
||||||
|
private final AtomicLong jobIdSeq = new AtomicLong(7000);
|
||||||
|
private final AtomicInteger resultIdSeq = new AtomicInteger(8101);
|
||||||
|
/** 记录 task_result 表实际执行过的 selectList 次数。 */
|
||||||
|
private final AtomicInteger resultSelectCount = new AtomicInteger();
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void configureStorage() {
|
||||||
|
service = new ShopDataCrawlTaskService(
|
||||||
|
fileTaskMapper,
|
||||||
|
fileResultMapper,
|
||||||
|
shopDataCrawlResolveService,
|
||||||
|
excelAssemblyService,
|
||||||
|
taskCacheService,
|
||||||
|
ossStorageService,
|
||||||
|
ziniaoShopSwitchService,
|
||||||
|
objectMapper,
|
||||||
|
taskPressureProperties,
|
||||||
|
taskFileJobService,
|
||||||
|
taskResultItemService,
|
||||||
|
taskProgressSnapshotService,
|
||||||
|
taskDistributedLockService,
|
||||||
|
taskChunkMapper,
|
||||||
|
taskScopeStateMapper,
|
||||||
|
transientPayloadStorageService,
|
||||||
|
instanceMetadata,
|
||||||
|
dailyFileService,
|
||||||
|
null);
|
||||||
|
|
||||||
|
dbResultRows.clear();
|
||||||
|
dbTasks.clear();
|
||||||
|
dbFileJobs.clear();
|
||||||
|
taskStore.clear();
|
||||||
|
jobIdSeq.set(7000);
|
||||||
|
resultIdSeq.set(8101);
|
||||||
|
resultQueryFailure = null;
|
||||||
|
resultSelectCount.set(0);
|
||||||
|
|
||||||
|
lenient().when(instanceMetadata.getInstanceId()).thenReturn("instance-a");
|
||||||
|
lenient().when(taskPressureProperties.getDbSelectBatchSize()).thenReturn(500);
|
||||||
|
lenient().when(taskCacheService.getTaskCacheBatch(any())).thenReturn(Map.of());
|
||||||
|
lenient().when(taskCacheService.getTaskHeartbeatMillisBatch(any())).thenReturn(Map.of());
|
||||||
|
lenient().when(taskResultItemService.listResultSnapshots(anyLong(), eq(MODULE_TYPE), any()))
|
||||||
|
.thenReturn(List.of());
|
||||||
|
lenient().when(taskFileJobService.findAssembleJobsByResultIds(anyString(), any()))
|
||||||
|
.thenReturn(Map.of());
|
||||||
|
lenient().when(taskFileJobService.findAssembleJob(anyLong(), eq(MODULE_TYPE), anyLong()))
|
||||||
|
.thenReturn(null);
|
||||||
|
lenient().when(ziniaoShopSwitchService.normalizeShopName(nullable(String.class))).thenAnswer(invocation -> {
|
||||||
|
String value = invocation.getArgument(0);
|
||||||
|
return value == null ? "" : value.trim();
|
||||||
|
});
|
||||||
|
lenient().when(fileTaskMapper.selectBatchIds(any())).thenAnswer(invocation -> {
|
||||||
|
List<?> ids = invocation.getArgument(0);
|
||||||
|
List<FileTaskEntity> found = new ArrayList<>();
|
||||||
|
for (Object id : ids) {
|
||||||
|
FileTaskEntity task = taskStore.get(((Number) id).longValue());
|
||||||
|
if (task != null) {
|
||||||
|
found.add(task);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return found;
|
||||||
|
});
|
||||||
|
lenient().when(fileTaskMapper.selectById(anyLong())).thenReturn(null);
|
||||||
|
lenient().when(fileTaskMapper.updateById(any(FileTaskEntity.class))).thenReturn(1);
|
||||||
|
lenient().when(fileResultMapper.selectBatchIds(any())).thenReturn(List.of());
|
||||||
|
lenient().when(fileResultMapper.selectCount(any())).thenAnswer(invocation -> {
|
||||||
|
Wrapper<FileResultEntity> wrapper = invocation.getArgument(0);
|
||||||
|
LambdaQueryWrapper<FileResultEntity> query = (LambdaQueryWrapper<FileResultEntity>) wrapper;
|
||||||
|
if (query.getSqlSegment().contains("userId")) {
|
||||||
|
return (long) dbResultRows.stream()
|
||||||
|
.filter(r -> Objects.equals(r.getUserId(), USER_ID))
|
||||||
|
.count();
|
||||||
|
}
|
||||||
|
return 0L;
|
||||||
|
});
|
||||||
|
lenient().doNothing().when(taskResultItemService)
|
||||||
|
.replaceTaskSnapshots(anyLong(), eq(MODULE_TYPE), any(), any());
|
||||||
|
lenient().doNothing().when(taskProgressSnapshotService)
|
||||||
|
.save(anyLong(), any(), any(), anyInt(), anyInt(), anyInt(), any(), any(), any());
|
||||||
|
lenient().when(taskDistributedLockService.acquire(eq(MODULE_TYPE), anyLong()))
|
||||||
|
.thenReturn(mock(TaskDistributedLockService.LockHandle.class));
|
||||||
|
lenient().when(taskFileJobService.countUnfinishedAssembleJobs(anyLong(), eq(MODULE_TYPE))).thenReturn(0L);
|
||||||
|
lenient().when(ossStorageService.uploadResultFile(any(), eq(MODULE_TYPE)))
|
||||||
|
.thenReturn("oss/progress/" + System.nanoTime() + ".xlsx");
|
||||||
|
lenient().doNothing().when(ossStorageService).deleteObject(anyString());
|
||||||
|
|
||||||
|
captureResultSelectList();
|
||||||
|
captureHistoryTaskQueries();
|
||||||
|
captureAssembleJobQueries();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Task 38 tests(必须先确认 RED)----
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_038_progress_normal_default_path() {
|
||||||
|
// 默认成功路径:listHistory 分页返回字段裁剪后的结果行,行按创建时间倒序,
|
||||||
|
// 批量加载任务与作业状态,进度批量查询返回顺序稳定的条目。
|
||||||
|
seedHistoryData(5);
|
||||||
|
|
||||||
|
ShopDataCrawlHistoryVo history = service.listHistory(USER_ID, 1, 10);
|
||||||
|
assertEquals(5, history.getItems().size(), "默认路径返回全部 5 条历史记录");
|
||||||
|
assertEquals(5L, history.getTotal());
|
||||||
|
assertEquals(1, history.getPage());
|
||||||
|
assertEquals(10, history.getLimit());
|
||||||
|
assertEquals("shop-5", history.getItems().get(0).getShopName(), "最新创建的记录排在最前");
|
||||||
|
assertTrue(history.getItems().stream().allMatch(item -> item.getTaskStatus() != null),
|
||||||
|
"批量加载任务状态并附着到每条记录");
|
||||||
|
|
||||||
|
ShopDataCrawlTaskBatchVo batch = service.getTaskProgressBatch(List.of(8101L, 8102L));
|
||||||
|
assertEquals(2, batch.getItems().size(), "批量进度查询返回两条结果");
|
||||||
|
assertEquals(8101L, batch.getItems().get(0).getResultId(), "结果按结果 ID 升序且顺序稳定");
|
||||||
|
assertEquals(8102L, batch.getItems().get(1).getResultId());
|
||||||
|
assertTrue(batch.getMissingTaskIds().isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_038_progress_normal_multiple_items() {
|
||||||
|
// 批量场景:多个任务、多个结果行与多个组装作业,分页与批量查询都不丢失数据、顺序稳定。
|
||||||
|
seedHistoryData(7);
|
||||||
|
addJob(8101L, "PENDING", "upload failed");
|
||||||
|
addJob(8102L, "SUCCESS", null);
|
||||||
|
|
||||||
|
ShopDataCrawlHistoryVo history = service.listHistory(USER_ID, 1, 100);
|
||||||
|
assertEquals(7, history.getItems().size(), "全量分页不丢行");
|
||||||
|
assertEquals(7L, history.getTotal());
|
||||||
|
Map<Long, ShopDataCrawlResultItemVo> byResultId = new LinkedHashMap<>();
|
||||||
|
for (ShopDataCrawlResultItemVo item : history.getItems()) {
|
||||||
|
byResultId.put(item.getResultId(), item);
|
||||||
|
}
|
||||||
|
assertEquals("PENDING", byResultId.get(8101L).getFileStatus(), "历史记录附着作业状态");
|
||||||
|
assertEquals("upload failed", byResultId.get(8101L).getFileError());
|
||||||
|
assertEquals("SUCCESS", byResultId.get(8102L).getFileStatus());
|
||||||
|
|
||||||
|
ShopDataCrawlTaskBatchVo batch = service.getTaskProgressBatch(
|
||||||
|
List.of(8107L, 8102L, 8101L, 8102L, -1L));
|
||||||
|
assertEquals(3, batch.getItems().size(), "重复与非法 ID 去重后不重复返回");
|
||||||
|
assertEquals(8107L, batch.getItems().get(0).getResultId(), "去重后按输入顺序返回,结果 ID 升序");
|
||||||
|
assertEquals(8102L, batch.getItems().get(1).getResultId());
|
||||||
|
assertEquals(8101L, batch.getItems().get(2).getResultId());
|
||||||
|
assertTrue(batch.getMissingTaskIds().isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_038_progress_normal_repeated_operation_is_idempotent() {
|
||||||
|
// 幂等:同一输入重复查询不产生重复记录、不改变任何持久状态。
|
||||||
|
seedHistoryData(3);
|
||||||
|
|
||||||
|
ShopDataCrawlHistoryVo first = service.listHistory(USER_ID, 1, 10);
|
||||||
|
ShopDataCrawlHistoryVo second = service.listHistory(USER_ID, 1, 10);
|
||||||
|
assertEquals(first.getItems().size(), second.getItems().size(), "重复查询结果条数一致");
|
||||||
|
assertEquals(first.getItems().get(0).getResultId(), second.getItems().get(0).getResultId(),
|
||||||
|
"重复查询顺序与内容一致");
|
||||||
|
|
||||||
|
ShopDataCrawlTaskBatchVo batch1 = service.getTaskProgressBatch(List.of(8101L));
|
||||||
|
ShopDataCrawlTaskBatchVo batch2 = service.getTaskProgressBatch(List.of(8101L));
|
||||||
|
assertEquals(batch1.getItems().size(), batch2.getItems().size(), "重复进度查询结果一致");
|
||||||
|
assertEquals(batch1.getItems().get(0).getResultId(), batch2.getItems().get(0).getResultId());
|
||||||
|
|
||||||
|
int selectCountAfter = resultSelectCount.get();
|
||||||
|
service.listHistory(USER_ID, 1, 10);
|
||||||
|
service.getTaskProgressBatch(List.of(8101L));
|
||||||
|
assertEquals(selectCountAfter + 2, resultSelectCount.get(), "重复查询只触发新的只读查询,不产生写入");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_038_progress_boundary_empty_input() {
|
||||||
|
// 空输入:没有历史数据时返回空结果与 0 总数,不创建任何资源。
|
||||||
|
ShopDataCrawlHistoryVo history = service.listHistory(USER_ID, 1, 10);
|
||||||
|
assertTrue(history.getItems().isEmpty(), "空数据返回空列表");
|
||||||
|
assertEquals(0L, history.getTotal());
|
||||||
|
assertEquals(0L, history.getPage(), "空输入时页号回退为 0");
|
||||||
|
|
||||||
|
ShopDataCrawlTaskBatchVo batch = service.getTaskProgressBatch(List.of());
|
||||||
|
assertTrue(batch.getItems().isEmpty());
|
||||||
|
assertTrue(batch.getMissingTaskIds().isEmpty());
|
||||||
|
ShopDataCrawlTaskBatchVo nullBatch = service.getTaskProgressBatch(null);
|
||||||
|
assertTrue(nullBatch.getItems().isEmpty());
|
||||||
|
assertTrue(nullBatch.getMissingTaskIds().isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_038_progress_boundary_single_item() {
|
||||||
|
// 单元素:单条历史记录与单个任务进度查询走同样分页/批量路径,结果正确。
|
||||||
|
seedHistoryData(1);
|
||||||
|
|
||||||
|
ShopDataCrawlHistoryVo history = service.listHistory(USER_ID, 1, 1);
|
||||||
|
assertEquals(1, history.getItems().size());
|
||||||
|
assertEquals(1L, history.getTotal());
|
||||||
|
assertEquals("shop-1", history.getItems().get(0).getShopName());
|
||||||
|
|
||||||
|
ShopDataCrawlTaskBatchVo batch = service.getTaskProgressBatch(List.of(8101L));
|
||||||
|
assertEquals(1, batch.getItems().size());
|
||||||
|
assertEquals(8101L, batch.getItems().get(0).getResultId());
|
||||||
|
assertEquals("shop-1", batch.getItems().get(0).getShopName());
|
||||||
|
assertEquals("SUCCESS", batch.getItems().get(0).getTaskStatus());
|
||||||
|
assertTrue(batch.getMissingTaskIds().isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_038_progress_boundary_limit_and_overflow() {
|
||||||
|
// 上限/超限:超过 100 条历史时最多返回 100 条且分页可翻完全部记录;
|
||||||
|
// limit 超过最大值收敛到 100,page 越界返回空页;批量进度查询超过 50 个任务只处理前 50 个。
|
||||||
|
seedHistoryData(220);
|
||||||
|
seedProgressData();
|
||||||
|
|
||||||
|
ShopDataCrawlHistoryVo firstPage = service.listHistory(USER_ID, 1, 200);
|
||||||
|
assertEquals(100, firstPage.getItems().size(), "limit 超过 100 收敛为 100");
|
||||||
|
assertEquals(220L, firstPage.getTotal(), "total 始终反映全量行数");
|
||||||
|
assertEquals(100, firstPage.getLimit(), "返回收敛后的 limit");
|
||||||
|
|
||||||
|
ShopDataCrawlHistoryVo thirdPage = service.listHistory(USER_ID, 3, 100);
|
||||||
|
assertEquals(20, thirdPage.getItems().size(), "第 3 页返回剩余 20 条");
|
||||||
|
assertEquals("shop-20", thirdPage.getItems().get(0).getShopName(),
|
||||||
|
"第 3 页从第 201 条开始(倒序第 20 个店铺)");
|
||||||
|
assertEquals("shop-1", thirdPage.getItems().get(thirdPage.getItems().size() - 1).getShopName(),
|
||||||
|
"翻页到最后一条记录不丢失");
|
||||||
|
ShopDataCrawlHistoryVo overflowPage = service.listHistory(USER_ID, 9, 100);
|
||||||
|
assertTrue(overflowPage.getItems().isEmpty(), "越界页返回空页,不发生无界内存增长");
|
||||||
|
|
||||||
|
List<Long> sixtyIds = new ArrayList<>();
|
||||||
|
for (int i = 0; i < 60; i++) {
|
||||||
|
sixtyIds.add(8101L + i);
|
||||||
|
}
|
||||||
|
ShopDataCrawlTaskBatchVo batch = service.getTaskProgressBatch(sixtyIds);
|
||||||
|
assertEquals(50, batch.getItems().size(), "超过 50 个任务只处理前 50 个");
|
||||||
|
assertEquals(8101L, batch.getItems().get(0).getResultId());
|
||||||
|
assertEquals(8150L, batch.getItems().get(49).getResultId());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_038_progress_invalid_input_rejected() {
|
||||||
|
// 非法参数:非法 user_id、非法 page/limit 与批量查询底层失败都抛出可识别异常。
|
||||||
|
assertThrows(BusinessException.class, () -> service.listHistory(null, 1, 10),
|
||||||
|
"user_id 为空时拒绝查询");
|
||||||
|
assertThrows(BusinessException.class, () -> service.listHistory(0L, 1, 10),
|
||||||
|
"user_id 不大于 0 时拒绝查询");
|
||||||
|
assertThrows(BusinessException.class, () -> service.listHistory(USER_ID, 0, 10),
|
||||||
|
"page 小于 1 时拒绝查询");
|
||||||
|
assertThrows(BusinessException.class, () -> service.listHistory(USER_ID, 1, 0),
|
||||||
|
"limit 小于 1 时拒绝查询");
|
||||||
|
|
||||||
|
resultQueryFailure = new IllegalStateException("db connection lost");
|
||||||
|
BusinessException thrown = assertThrows(BusinessException.class,
|
||||||
|
() -> service.getTaskProgressBatch(List.of(8101L)));
|
||||||
|
assertTrue(thrown.getMessage().contains("任务进度查询失败"), "底层查询失败收敛为可识别业务异常");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_038_progress_dependency_failure_releases_resources() {
|
||||||
|
// 依赖失败:任务批量查询抛错时转换为业务异常,不残留任何中间状态;
|
||||||
|
// 恢复后同一查询重新执行成功(错误可恢复),且不触发任何资源清理副作用。
|
||||||
|
seedHistoryData(2);
|
||||||
|
resultQueryFailure = new IllegalStateException("db connection lost");
|
||||||
|
|
||||||
|
assertThrows(BusinessException.class, () -> service.getTaskProgressBatch(List.of(8101L)));
|
||||||
|
|
||||||
|
resultQueryFailure = null;
|
||||||
|
ShopDataCrawlTaskBatchVo recovered = service.getTaskProgressBatch(List.of(8101L));
|
||||||
|
assertEquals(1, recovered.getItems().size(), "恢复后同一查询重新执行成功");
|
||||||
|
|
||||||
|
ShopDataCrawlHistoryVo history = service.listHistory(USER_ID, 1, 10);
|
||||||
|
assertEquals(2, history.getItems().size());
|
||||||
|
verify(ossStorageService, never()).deleteObject(anyString());
|
||||||
|
verify(taskDistributedLockService, never()).acquire(anyString(), anyLong());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- helpers ----
|
||||||
|
|
||||||
|
/** 仿真 fileResultMapper.selectList:按 wrapper 条件查询 task_result 内存表,可注入底层失败。 */
|
||||||
|
private void captureResultSelectList() {
|
||||||
|
lenient().when(fileResultMapper.selectList(any())).thenAnswer(invocation -> {
|
||||||
|
resultSelectCount.incrementAndGet();
|
||||||
|
if (resultQueryFailure != null) {
|
||||||
|
throw resultQueryFailure;
|
||||||
|
}
|
||||||
|
Wrapper<FileResultEntity> wrapper = invocation.getArgument(0);
|
||||||
|
LambdaQueryWrapper<FileResultEntity> query = (LambdaQueryWrapper<FileResultEntity>) wrapper;
|
||||||
|
String segment = query.getSqlSegment();
|
||||||
|
List<FileResultEntity> rows = new ArrayList<>(dbResultRows);
|
||||||
|
if (segment.contains("userId")) {
|
||||||
|
rows = rows.stream()
|
||||||
|
.filter(r -> Objects.equals(r.getUserId(), USER_ID))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
if (segment.contains("ORDER BY createdAt DESC")) {
|
||||||
|
rows = rows.stream()
|
||||||
|
.sorted(Comparator.comparing(FileResultEntity::getCreatedAt)
|
||||||
|
.thenComparing(FileResultEntity::getId)
|
||||||
|
.reversed())
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
if (segment.contains("taskId")) {
|
||||||
|
rows = new ArrayList<>(rows);
|
||||||
|
}
|
||||||
|
if (segment.contains("ORDER BY id ASC")) {
|
||||||
|
rows = rows.stream()
|
||||||
|
.sorted(Comparator.comparing(FileResultEntity::getId))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
if (segment.contains("LIMIT")) {
|
||||||
|
java.util.regex.Matcher matcher = java.util.regex.Pattern
|
||||||
|
.compile("LIMIT\\s+(\\d+)\\s*,\\s*(\\d+)")
|
||||||
|
.matcher(segment);
|
||||||
|
if (matcher.find()) {
|
||||||
|
int offset = Integer.parseInt(matcher.group(1));
|
||||||
|
int size = Integer.parseInt(matcher.group(2));
|
||||||
|
rows = rows.stream().skip(offset).limit(size).toList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 仿真历史任务批量加载:按 ID 列表分块查询 file_task 内存表(IN 参数从 wrapper 参数表中读取)。 */
|
||||||
|
private void captureHistoryTaskQueries() {
|
||||||
|
lenient().when(fileTaskMapper.selectList(any())).thenAnswer(invocation -> {
|
||||||
|
Wrapper<FileTaskEntity> wrapper = invocation.getArgument(0);
|
||||||
|
if (wrapper instanceof LambdaQueryWrapper<?> query
|
||||||
|
&& query.getSqlSegment() != null
|
||||||
|
&& query.getSqlSegment().contains("IN")) {
|
||||||
|
java.util.Set<Long> wanted = new java.util.HashSet<>();
|
||||||
|
for (Object value : query.getParamNameValuePairs().values()) {
|
||||||
|
if (value instanceof Number number) {
|
||||||
|
wanted.add(number.longValue());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
List<FileTaskEntity> found = new ArrayList<>();
|
||||||
|
for (FileTaskEntity task : dbTasks) {
|
||||||
|
if (wanted.contains(task.getId())) {
|
||||||
|
found.add(task);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
return List.of();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 仿真作业批量查询:findAssembleJobsByResultIds 按 resultId 返回作业,单查 findAssembleJob 同数据源。 */
|
||||||
|
private void captureAssembleJobQueries() {
|
||||||
|
lenient().when(taskFileJobService.findAssembleJobsByResultIds(anyString(), any()))
|
||||||
|
.thenAnswer(invocation -> {
|
||||||
|
List<?> resultIds = invocation.getArgument(1);
|
||||||
|
Map<Long, TaskFileJobEntity> map = new LinkedHashMap<>();
|
||||||
|
if (resultIds == null) {
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
for (Object raw : resultIds) {
|
||||||
|
long resultId = ((Number) raw).longValue();
|
||||||
|
for (TaskFileJobEntity job : dbFileJobs) {
|
||||||
|
if (Objects.equals(job.getResultId(), resultId)) {
|
||||||
|
map.putIfAbsent(resultId, job);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
});
|
||||||
|
lenient().when(taskFileJobService.findAssembleJob(anyLong(), eq(MODULE_TYPE), anyLong()))
|
||||||
|
.thenAnswer(invocation -> {
|
||||||
|
long taskId = invocation.getArgument(0);
|
||||||
|
long resultId = invocation.getArgument(2);
|
||||||
|
return dbFileJobs.stream()
|
||||||
|
.filter(j -> Objects.equals(j.getTaskId(), taskId)
|
||||||
|
&& Objects.equals(j.getResultId(), resultId))
|
||||||
|
.findFirst()
|
||||||
|
.orElse(null);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void seedHistoryData(int count) {
|
||||||
|
for (int i = 1; i <= count; i++) {
|
||||||
|
long resultId = resultIdSeq.getAndIncrement();
|
||||||
|
FileResultEntity row = new FileResultEntity();
|
||||||
|
row.setId(resultId);
|
||||||
|
row.setTaskId(resultId);
|
||||||
|
row.setModuleType(MODULE_TYPE);
|
||||||
|
row.setUserId(USER_ID);
|
||||||
|
row.setSuccess(1);
|
||||||
|
row.setSourceFilename("shop-" + i);
|
||||||
|
row.setSourceFileUrl("shop-id-" + resultId);
|
||||||
|
row.setResultFilename("shop-" + i + ".xlsx");
|
||||||
|
row.setResultFileUrl("oss/shop-" + i + ".xlsx");
|
||||||
|
row.setCreatedAt(LocalDateTime.of(2026, 8, 20, 10, 0).plusDays(i));
|
||||||
|
dbResultRows.add(row);
|
||||||
|
|
||||||
|
FileTaskEntity task = new FileTaskEntity();
|
||||||
|
task.setId(resultId);
|
||||||
|
task.setModuleType(MODULE_TYPE);
|
||||||
|
task.setUserId(USER_ID);
|
||||||
|
task.setStatus("SUCCESS");
|
||||||
|
task.setFinishedAt(row.getCreatedAt().plusMinutes(5));
|
||||||
|
task.setCreatedAt(row.getCreatedAt());
|
||||||
|
task.setUpdatedAt(row.getCreatedAt());
|
||||||
|
dbTasks.add(task);
|
||||||
|
taskStore.put(resultId, task);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 增加一批无历史行、无任务的纯任务 ID,用于验证批量查询的 missing 与 50 上限。 */
|
||||||
|
private void seedProgressData() {
|
||||||
|
// 进度批量查询直接查 task_result 行,无需额外种子数据;这里保留空实现以标注语义。
|
||||||
|
}
|
||||||
|
|
||||||
|
private void addJob(long resultId, String status, String errorMessage) {
|
||||||
|
TaskFileJobEntity job = new TaskFileJobEntity();
|
||||||
|
job.setId(jobIdSeq.incrementAndGet());
|
||||||
|
job.setTaskId(resultId);
|
||||||
|
job.setModuleType(MODULE_TYPE);
|
||||||
|
job.setResultId(resultId);
|
||||||
|
job.setJobType("ASSEMBLE_RESULT");
|
||||||
|
job.setStatus(status);
|
||||||
|
job.setErrorMessage(errorMessage);
|
||||||
|
job.setRetryCount(0);
|
||||||
|
dbFileJobs.add(job);
|
||||||
|
}
|
||||||
|
}
|
||||||
+654
@@ -0,0 +1,654 @@
|
|||||||
|
package com.nanri.aiimage.modules.shopdatacrawl.service;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
|
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.nanri.aiimage.common.exception.BusinessException;
|
||||||
|
import com.nanri.aiimage.config.InstanceMetadata;
|
||||||
|
import com.nanri.aiimage.config.TaskPressureProperties;
|
||||||
|
import com.nanri.aiimage.modules.file.service.oss.OssStorageService;
|
||||||
|
import com.nanri.aiimage.modules.shopdatacrawl.model.dto.ShopDataCrawlCountryResultDto;
|
||||||
|
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.task.mapper.FileResultMapper;
|
||||||
|
import com.nanri.aiimage.modules.task.mapper.FileTaskMapper;
|
||||||
|
import com.nanri.aiimage.modules.task.mapper.TaskChunkMapper;
|
||||||
|
import com.nanri.aiimage.modules.task.mapper.TaskScopeStateMapper;
|
||||||
|
import com.nanri.aiimage.modules.task.model.entity.FileResultEntity;
|
||||||
|
import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity;
|
||||||
|
import com.nanri.aiimage.modules.task.model.entity.TaskChunkEntity;
|
||||||
|
import com.nanri.aiimage.modules.task.model.entity.TaskScopeStateEntity;
|
||||||
|
import com.nanri.aiimage.modules.task.service.TaskDistributedLockService;
|
||||||
|
import com.nanri.aiimage.modules.task.service.TaskFileJobService;
|
||||||
|
import com.nanri.aiimage.modules.task.service.TaskProgressSnapshotService;
|
||||||
|
import com.nanri.aiimage.modules.task.service.TaskResultItemService;
|
||||||
|
import com.nanri.aiimage.modules.task.service.TransientPayloadStorageService;
|
||||||
|
import com.nanri.aiimage.modules.ziniao.service.ZiniaoShopSwitchService;
|
||||||
|
import org.apache.ibatis.builder.MapperBuilderAssistant;
|
||||||
|
import org.apache.ibatis.session.Configuration;
|
||||||
|
import org.junit.jupiter.api.BeforeAll;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.Spy;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
import org.springframework.dao.DuplicateKeyException;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyLong;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.ArgumentMatchers.nullable;
|
||||||
|
import static org.mockito.Mockito.lenient;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Task 30:为国家结果行建立稳定去重键,替换线性重复扫描。
|
||||||
|
* mergeCountryResults / copyCountryResults 原用 sameRow 的 O(n²) noneMatch 扫描去重;
|
||||||
|
* 实现改为按 10 个 trim 后字段构造稳定去重键(date|asin|brand|commodityImage|inventorySales|
|
||||||
|
* salesRank|pageViews|unitsSold|price|recommendedOffer),LinkedHashSet 一次遍历去重,
|
||||||
|
* 保留首现顺序与语义等价性(去重结果与 sameRow 逐行比较完全一致)。
|
||||||
|
*/
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class ShopDataCrawlRowDedupKeyTest {
|
||||||
|
|
||||||
|
private static final String MODULE_TYPE = "SHOP_DATA_CRAWL";
|
||||||
|
private static final String SHOP_NAME = "Demo Shop";
|
||||||
|
|
||||||
|
@BeforeAll
|
||||||
|
static void initializeMybatisMetadata() {
|
||||||
|
MapperBuilderAssistant assistant = new MapperBuilderAssistant(new Configuration(), "");
|
||||||
|
TableInfoHelper.initTableInfo(assistant, FileTaskEntity.class);
|
||||||
|
TableInfoHelper.initTableInfo(assistant, FileResultEntity.class);
|
||||||
|
TableInfoHelper.initTableInfo(assistant, TaskChunkEntity.class);
|
||||||
|
TableInfoHelper.initTableInfo(assistant, TaskScopeStateEntity.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Mock private FileTaskMapper fileTaskMapper;
|
||||||
|
@Mock private FileResultMapper fileResultMapper;
|
||||||
|
@Mock private ShopDataCrawlResolveService shopDataCrawlResolveService;
|
||||||
|
@Mock private ShopDataCrawlExcelAssemblyService excelAssemblyService;
|
||||||
|
@Mock private ShopDataCrawlTaskCacheService taskCacheService;
|
||||||
|
@Mock private OssStorageService ossStorageService;
|
||||||
|
@Mock private ZiniaoShopSwitchService ziniaoShopSwitchService;
|
||||||
|
@Mock private TaskPressureProperties taskPressureProperties;
|
||||||
|
@Mock private TaskFileJobService taskFileJobService;
|
||||||
|
@Mock private TaskResultItemService taskResultItemService;
|
||||||
|
@Mock private TaskProgressSnapshotService taskProgressSnapshotService;
|
||||||
|
@Mock private TaskDistributedLockService taskDistributedLockService;
|
||||||
|
@Mock private TaskChunkMapper taskChunkMapper;
|
||||||
|
@Mock private TaskScopeStateMapper taskScopeStateMapper;
|
||||||
|
@Mock private TransientPayloadStorageService transientPayloadStorageService;
|
||||||
|
@Mock private InstanceMetadata instanceMetadata;
|
||||||
|
@Mock private ShopDataCrawlDailyFileService dailyFileService;
|
||||||
|
@Spy private ObjectMapper objectMapper = new ObjectMapper().findAndRegisterModules();
|
||||||
|
|
||||||
|
private ShopDataCrawlTaskService service;
|
||||||
|
|
||||||
|
private final List<TaskChunkEntity> storedChunks = new ArrayList<>();
|
||||||
|
private final List<TaskScopeStateEntity> storedScopes = new ArrayList<>();
|
||||||
|
private final Map<String, String> rustfsPayloads = new LinkedHashMap<>();
|
||||||
|
private FileTaskEntity task;
|
||||||
|
private FileResultEntity result;
|
||||||
|
private int nextPayloadId;
|
||||||
|
private boolean insertFails;
|
||||||
|
private boolean scopeUpdateFails;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void configureStorage() {
|
||||||
|
service = new ShopDataCrawlTaskService(
|
||||||
|
fileTaskMapper,
|
||||||
|
fileResultMapper,
|
||||||
|
shopDataCrawlResolveService,
|
||||||
|
excelAssemblyService,
|
||||||
|
taskCacheService,
|
||||||
|
ossStorageService,
|
||||||
|
ziniaoShopSwitchService,
|
||||||
|
objectMapper,
|
||||||
|
taskPressureProperties,
|
||||||
|
taskFileJobService,
|
||||||
|
taskResultItemService,
|
||||||
|
taskProgressSnapshotService,
|
||||||
|
taskDistributedLockService,
|
||||||
|
taskChunkMapper,
|
||||||
|
taskScopeStateMapper,
|
||||||
|
transientPayloadStorageService,
|
||||||
|
instanceMetadata,
|
||||||
|
dailyFileService,
|
||||||
|
null);
|
||||||
|
|
||||||
|
storedChunks.clear();
|
||||||
|
storedScopes.clear();
|
||||||
|
rustfsPayloads.clear();
|
||||||
|
nextPayloadId = 0;
|
||||||
|
insertFails = false;
|
||||||
|
scopeUpdateFails = false;
|
||||||
|
|
||||||
|
lenient().when(instanceMetadata.getInstanceId()).thenReturn("instance-a");
|
||||||
|
lenient().when(taskDistributedLockService.acquire(eq(MODULE_TYPE), anyLong()))
|
||||||
|
.thenReturn(mock(TaskDistributedLockService.LockHandle.class));
|
||||||
|
lenient().when(taskCacheService.getTaskCacheBatch(any())).thenReturn(Map.of());
|
||||||
|
lenient().when(taskCacheService.getAllShopMergedPayload(anyLong())).thenReturn(Map.of());
|
||||||
|
lenient().when(taskResultItemService.listResultSnapshots(anyLong(), eq(MODULE_TYPE), any())).thenReturn(List.of());
|
||||||
|
lenient().when(taskFileJobService.findAssembleJob(anyLong(), eq(MODULE_TYPE), anyLong())).thenReturn(null);
|
||||||
|
lenient().when(excelAssemblyService.countRows(any())).thenReturn(1);
|
||||||
|
|
||||||
|
lenient().when(fileTaskMapper.selectById(anyLong())).thenAnswer(invocation -> {
|
||||||
|
Long taskId = invocation.getArgument(0);
|
||||||
|
return task != null && Objects.equals(taskId, task.getId()) ? task : null;
|
||||||
|
});
|
||||||
|
lenient().when(fileTaskMapper.updateById(any(FileTaskEntity.class))).thenReturn(1);
|
||||||
|
lenient().when(fileResultMapper.selectById(anyLong())).thenAnswer(invocation -> {
|
||||||
|
Long resultId = invocation.getArgument(0);
|
||||||
|
return result != null && Objects.equals(resultId, result.getId()) ? result : null;
|
||||||
|
});
|
||||||
|
lenient().when(fileResultMapper.selectList(any())).thenAnswer(invocation -> {
|
||||||
|
LambdaQueryWrapper<FileResultEntity> query = invocation.getArgument(0);
|
||||||
|
Long taskId = queryLong(query);
|
||||||
|
return result != null && Objects.equals(taskId, result.getTaskId()) ? List.of(result) : List.of();
|
||||||
|
});
|
||||||
|
lenient().when(fileResultMapper.updateById(any(FileResultEntity.class))).thenReturn(1);
|
||||||
|
|
||||||
|
lenient().when(ziniaoShopSwitchService.normalizeShopName(nullable(String.class))).thenAnswer(invocation -> {
|
||||||
|
String value = invocation.getArgument(0);
|
||||||
|
return value == null ? "" : value.trim();
|
||||||
|
});
|
||||||
|
|
||||||
|
configureTransientPayloadStorage();
|
||||||
|
configureChunkMapper();
|
||||||
|
configureScopeMapper();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_030_task_normal_default_path() {
|
||||||
|
// 正常路径:同一国家内重复行按稳定去重键合并,保留首现顺序;
|
||||||
|
// 全部 10 个字段被保留(copyRow 语义),无重复行。
|
||||||
|
givenRunningTask(1301L, 2301L);
|
||||||
|
|
||||||
|
service.submitResult(task.getId(), request(chunk(1, 1, "DE",
|
||||||
|
row("2026-07-25", "B001"),
|
||||||
|
row("2026-07-26", "B002"),
|
||||||
|
sameRow("2026-07-25", "B001"))));
|
||||||
|
|
||||||
|
List<ShopDataCrawlRowDto> rows = finalRows();
|
||||||
|
assertEquals(2, rows.size(), "重复行只保留一份");
|
||||||
|
assertEquals("B001", rows.get(0).getAsin(), "首现顺序保留");
|
||||||
|
assertEquals("B002", rows.get(1).getAsin());
|
||||||
|
assertEquals("https://m.media-amazon.com/images/I/B001.jpg", rows.get(0).getCommodityImage());
|
||||||
|
assertEquals("10", rows.get(0).getInventorySales());
|
||||||
|
assertEquals("20", rows.get(0).getSalesRank());
|
||||||
|
assertEquals("30", rows.get(0).getPageViews());
|
||||||
|
assertEquals("40", rows.get(0).getUnitsSold());
|
||||||
|
assertEquals("50", rows.get(0).getPrice());
|
||||||
|
assertEquals("60", rows.get(0).getRecommendedOffer());
|
||||||
|
|
||||||
|
// 稳定去重键:语义相同(含空白差异)的行键相等,null 行安全。
|
||||||
|
assertEquals(ShopDataCrawlTaskService.rowDedupKey(row("2026-07-25", "B001")),
|
||||||
|
ShopDataCrawlTaskService.rowDedupKey(sameRow("2026-07-25", "B001")));
|
||||||
|
assertEquals(ShopDataCrawlTaskService.rowDedupKey(row("2026-07-25", "B001")),
|
||||||
|
ShopDataCrawlTaskService.rowDedupKey(paddedRow("2026-07-25", "B001")));
|
||||||
|
assertEquals(null, ShopDataCrawlTaskService.rowDedupKey(null));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_030_task_normal_multiple_items() {
|
||||||
|
// 批量场景:同一国家跨多个分片合并去重(跨分片重复行只保留首现),
|
||||||
|
// 国家顺序与行顺序稳定(国家首次出现顺序、行首次出现顺序)。
|
||||||
|
givenRunningTask(1302L, 2302L);
|
||||||
|
|
||||||
|
service.submitResult(task.getId(), request(chunk(1, 3, "DE",
|
||||||
|
row("2026-07-25", "B001"),
|
||||||
|
row("2026-07-26", "B002"))));
|
||||||
|
service.submitResult(task.getId(), request(chunk(2, 3, "DE",
|
||||||
|
row("2026-07-26", "B002"),
|
||||||
|
row("2026-07-27", "B003"))));
|
||||||
|
service.submitResult(task.getId(), request(chunk(3, 3, "UK",
|
||||||
|
row("2026-07-27", "B003"),
|
||||||
|
row("2026-07-28", "B004"))));
|
||||||
|
|
||||||
|
List<ShopDataCrawlCountryResultDto> countries = finalCountries();
|
||||||
|
assertEquals(2, countries.size(), "国家按首次出现顺序");
|
||||||
|
assertEquals("DE", countries.get(0).getCountry());
|
||||||
|
assertEquals("UK", countries.get(1).getCountry());
|
||||||
|
assertEquals(List.of("B001", "B002", "B003"), asins(countries.get(0).getItems()), "跨分片重复合并");
|
||||||
|
assertEquals(List.of("B003", "B004"), asins(countries.get(1).getItems()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_030_task_normal_repeated_operation_is_idempotent() {
|
||||||
|
// 幂等:同一分片重放不会放大结果(命中唯一键,payload 清理,行不重复)。
|
||||||
|
givenRunningTask(1303L, 2303L);
|
||||||
|
ShopDataCrawlSubmitResultRequest request = request(chunk(1, 2, "DE",
|
||||||
|
row("2026-07-25", "B001"),
|
||||||
|
row("2026-07-26", "B002")));
|
||||||
|
|
||||||
|
service.submitResult(task.getId(), request);
|
||||||
|
service.submitResult(task.getId(), request);
|
||||||
|
service.submitResult(task.getId(), request(chunk(2, 2, "DE",
|
||||||
|
row("2026-07-26", "B002"),
|
||||||
|
row("2026-07-27", "B003"))));
|
||||||
|
|
||||||
|
assertEquals(List.of("B001", "B002", "B003"), asins(finalRows()), "重放不放大结果");
|
||||||
|
assertEquals(1, storedScopes.size(), "scope 状态只有一行");
|
||||||
|
assertEquals(2, storedChunks.size(), "分片行只有两份");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_030_task_boundary_empty_input() {
|
||||||
|
// 空输入:无可处理数据的分片在去重之前被拒,无结果行、无资源创建。
|
||||||
|
givenRunningTask(1304L, 2304L);
|
||||||
|
|
||||||
|
ShopDataCrawlShopPayloadDto emptyItems = new ShopDataCrawlShopPayloadDto();
|
||||||
|
emptyItems.setShopName(SHOP_NAME);
|
||||||
|
emptyItems.setChunkIndex(1);
|
||||||
|
emptyItems.setChunkTotal(1);
|
||||||
|
emptyItems.setCountryResults(List.of(countryWithItems("DE", List.of())));
|
||||||
|
BusinessException error = assertThrows(BusinessException.class, () ->
|
||||||
|
service.submitResult(task.getId(), request(emptyItems)));
|
||||||
|
assertTrue(error.getMessage().contains("内容为空"));
|
||||||
|
|
||||||
|
assertEquals(0, storedChunks.size());
|
||||||
|
assertEquals(0, rustfsPayloads.size());
|
||||||
|
assertEquals(0, storedScopes.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_030_task_boundary_single_item() {
|
||||||
|
// 单元素:单国家单行,去重键单元素路径结果正确、顺序稳定。
|
||||||
|
givenRunningTask(1305L, 2305L);
|
||||||
|
|
||||||
|
service.submitResult(task.getId(), request(chunk(1, 1, "DE", row("2026-07-25", "B001"))));
|
||||||
|
|
||||||
|
List<ShopDataCrawlRowDto> rows = finalRows();
|
||||||
|
assertEquals(1, rows.size());
|
||||||
|
assertEquals("B001", rows.get(0).getAsin());
|
||||||
|
assertEquals("2026-07-25", rows.get(0).getDate());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_030_task_boundary_limit_and_overflow() {
|
||||||
|
// 上限/超限:大量行合并只保留唯一行(每行唯一键不同),
|
||||||
|
// 去重为一次遍历,行数正确、顺序稳定,无重复。
|
||||||
|
givenRunningTask(1306L, 2306L);
|
||||||
|
List<ShopDataCrawlRowDto> rows = new ArrayList<>();
|
||||||
|
for (int i = 0; i < 200; i++) {
|
||||||
|
rows.add(row(String.format("2026-07-%02d", i % 28 + 1), "B" + String.format("%04d", i)));
|
||||||
|
}
|
||||||
|
rows.add(rows.get(0));
|
||||||
|
rows.add(rows.get(42));
|
||||||
|
rows.add(rows.get(199));
|
||||||
|
|
||||||
|
service.submitResult(task.getId(), request(chunk(1, 1, "UK", rows)));
|
||||||
|
List<ShopDataCrawlRowDto> finalRows = finalRows();
|
||||||
|
assertEquals(200, finalRows.size(), "200 唯一行 + 3 个重复行只保留 200 行");
|
||||||
|
for (int i = 0; i < finalRows.size(); i++) {
|
||||||
|
assertEquals("B" + String.format("%04d", i), finalRows.get(i).getAsin(), "首现顺序稳定");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_030_task_invalid_input_rejected() {
|
||||||
|
// 非法参数:分片元数据非法在去重之前拒绝;含空国家/空行的分片不产生任何结果行。
|
||||||
|
givenRunningTask(1307L, 2307L);
|
||||||
|
|
||||||
|
BusinessException zeroIndex = assertThrows(BusinessException.class, () ->
|
||||||
|
service.submitResult(task.getId(), request(chunk(0, 1, "DE", row("2026-07-25", "B001")))));
|
||||||
|
assertTrue(zeroIndex.getMessage().contains("chunk_index"));
|
||||||
|
|
||||||
|
BusinessException zeroTotal = assertThrows(BusinessException.class, () ->
|
||||||
|
service.submitResult(task.getId(), request(chunk(1, 0, "DE", row("2026-07-25", "B001")))));
|
||||||
|
assertTrue(zeroTotal.getMessage().contains("chunk_total"));
|
||||||
|
|
||||||
|
BusinessException empty = assertThrows(BusinessException.class, () ->
|
||||||
|
service.submitResult(task.getId(), request(blankRowsChunk(1, 1))));
|
||||||
|
assertTrue(empty.getMessage().contains("内容为空"));
|
||||||
|
|
||||||
|
assertEquals(0, storedChunks.size(), "非法输入不落库");
|
||||||
|
assertEquals(0, storedScopes.size());
|
||||||
|
assertEquals(0, rustfsPayloads.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_030_task_dependency_failure_releases_resources() {
|
||||||
|
// 依赖失败:chunk 插入异常 → 无结果行、payload 清理,恢复后重试成功;
|
||||||
|
// scope 更新失败 → 回滚分片行与 payload,恢复后重试成功,去重结果不变。
|
||||||
|
givenRunningTask(1308L, 2308L);
|
||||||
|
|
||||||
|
insertFails = true;
|
||||||
|
assertThrows(RuntimeException.class, () ->
|
||||||
|
service.submitResult(task.getId(), request(chunk(1, 2, "DE",
|
||||||
|
row("2026-07-25", "B001")))));
|
||||||
|
assertEquals(0, storedChunks.size());
|
||||||
|
assertEquals(0, rustfsPayloads.size());
|
||||||
|
assertEquals(0, storedScopes.size());
|
||||||
|
insertFails = false;
|
||||||
|
|
||||||
|
service.submitResult(task.getId(), request(chunk(1, 2, "DE",
|
||||||
|
row("2026-07-25", "B001"),
|
||||||
|
row("2026-07-26", "B002"))));
|
||||||
|
|
||||||
|
scopeUpdateFails = true;
|
||||||
|
assertThrows(RuntimeException.class, () ->
|
||||||
|
service.submitResult(task.getId(), request(chunk(2, 2, "DE",
|
||||||
|
row("2026-07-26", "B002"),
|
||||||
|
row("2026-07-27", "B003")))));
|
||||||
|
assertEquals(1, storedChunks.size(), "状态写入失败回滚分片行");
|
||||||
|
assertEquals(1, rustfsPayloads.size(), "状态写入失败清理 payload");
|
||||||
|
assertEquals(1, storedScopes.get(0).getReceivedChunkCount(), "状态写入失败计数器未推进");
|
||||||
|
scopeUpdateFails = false;
|
||||||
|
|
||||||
|
service.submitResult(task.getId(), request(chunk(2, 2, "DE",
|
||||||
|
row("2026-07-26", "B002"),
|
||||||
|
row("2026-07-27", "B003"))));
|
||||||
|
|
||||||
|
assertEquals(List.of("B001", "B002", "B003"), asins(finalRows()), "恢复后去重结果不变");
|
||||||
|
assertEquals(1, storedScopes.size(), "scope 行始终只有一行");
|
||||||
|
assertEquals(2, storedScopes.get(0).getReceivedChunkCount());
|
||||||
|
assertEquals(1, storedScopes.get(0).getCompleted());
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<ShopDataCrawlRowDto> finalRows() {
|
||||||
|
return finalCountries().get(0).getItems();
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<ShopDataCrawlCountryResultDto> finalCountries() {
|
||||||
|
return parseResultJson().get(0).getCountryResults();
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<ShopDataCrawlResultItemVo> parseResultJson() {
|
||||||
|
try {
|
||||||
|
return objectMapper.readValue(task.getResultJson(),
|
||||||
|
objectMapper.getTypeFactory().constructCollectionType(List.class, ShopDataCrawlResultItemVo.class));
|
||||||
|
} catch (Exception ex) {
|
||||||
|
throw new IllegalStateException("解析最终结果 JSON 失败", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<String> asins(List<ShopDataCrawlRowDto> rows) {
|
||||||
|
List<String> result = new ArrayList<>();
|
||||||
|
if (rows == null) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
for (ShopDataCrawlRowDto row : rows) {
|
||||||
|
result.add(row.getAsin());
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void configureTransientPayloadStorage() {
|
||||||
|
lenient().when(transientPayloadStorageService.isSharedWriteEnabled()).thenReturn(true);
|
||||||
|
lenient().when(transientPayloadStorageService.storeChunkPayloadVersioned(
|
||||||
|
anyString(), anyLong(), anyString(), any(), anyString())).thenAnswer(invocation -> {
|
||||||
|
String pointer = "rustfs:payload-" + (++nextPayloadId);
|
||||||
|
rustfsPayloads.put(pointer, invocation.getArgument(4));
|
||||||
|
return pointer;
|
||||||
|
});
|
||||||
|
lenient().when(transientPayloadStorageService.extractPointer(any())).thenAnswer(invocation -> {
|
||||||
|
String value = invocation.getArgument(0);
|
||||||
|
if (value == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return value.startsWith("rustfs:") || value.startsWith("local:") || value.startsWith("oss:")
|
||||||
|
? value : null;
|
||||||
|
});
|
||||||
|
lenient().when(transientPayloadStorageService.resolvePayload(any(), any())).thenAnswer(invocation -> {
|
||||||
|
String pointer = invocation.getArgument(0);
|
||||||
|
String payload = rustfsPayloads.get(pointer);
|
||||||
|
if (payload == null) {
|
||||||
|
throw new IllegalStateException("missing test RustFS payload: " + pointer);
|
||||||
|
}
|
||||||
|
return payload;
|
||||||
|
});
|
||||||
|
lenient().doAnswer(invocation -> {
|
||||||
|
rustfsPayloads.remove(invocation.getArgument(0));
|
||||||
|
return null;
|
||||||
|
}).when(transientPayloadStorageService).deletePayloadIfPresent(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void configureChunkMapper() {
|
||||||
|
lenient().when(taskChunkMapper.insert(any(TaskChunkEntity.class))).thenAnswer(invocation -> {
|
||||||
|
if (insertFails) {
|
||||||
|
throw new RuntimeException("db down");
|
||||||
|
}
|
||||||
|
TaskChunkEntity chunk = invocation.getArgument(0);
|
||||||
|
boolean duplicate = storedChunks.stream().anyMatch(existing ->
|
||||||
|
Objects.equals(existing.getTaskId(), chunk.getTaskId())
|
||||||
|
&& Objects.equals(existing.getScopeHash(), chunk.getScopeHash())
|
||||||
|
&& Objects.equals(existing.getChunkIndex(), chunk.getChunkIndex()));
|
||||||
|
if (duplicate) {
|
||||||
|
throw new DuplicateKeyException("duplicate chunk key: " + chunk.getScopeHash() + "/" + chunk.getChunkIndex());
|
||||||
|
}
|
||||||
|
chunk.setId((long) storedChunks.size() + 1L);
|
||||||
|
storedChunks.add(chunk);
|
||||||
|
return 1;
|
||||||
|
});
|
||||||
|
lenient().when(taskChunkMapper.selectOne(any())).thenAnswer(invocation -> {
|
||||||
|
LambdaQueryWrapper<TaskChunkEntity> query = invocation.getArgument(0);
|
||||||
|
Long taskId = queryLong(query);
|
||||||
|
String scopeHash = queryScopeHash(query);
|
||||||
|
Integer chunkIndex = queryInteger(query);
|
||||||
|
return storedChunks.stream()
|
||||||
|
.filter(chunk -> matchesChunk(chunk, taskId, scopeHash, chunkIndex))
|
||||||
|
.findFirst()
|
||||||
|
.orElse(null);
|
||||||
|
});
|
||||||
|
lenient().when(taskChunkMapper.selectList(any())).thenAnswer(invocation -> {
|
||||||
|
LambdaQueryWrapper<TaskChunkEntity> query = invocation.getArgument(0);
|
||||||
|
Long taskId = queryLong(query);
|
||||||
|
String scopeHash = queryScopeHash(query);
|
||||||
|
return storedChunks.stream()
|
||||||
|
.filter(chunk -> matchesChunk(chunk, taskId, scopeHash, null))
|
||||||
|
.sorted(Comparator.comparing(TaskChunkEntity::getChunkIndex))
|
||||||
|
.toList();
|
||||||
|
});
|
||||||
|
lenient().when(taskChunkMapper.delete(any())).thenAnswer(invocation -> {
|
||||||
|
LambdaQueryWrapper<TaskChunkEntity> query = invocation.getArgument(0);
|
||||||
|
Long taskId = queryLong(query);
|
||||||
|
String scopeHash = queryScopeHash(query);
|
||||||
|
Integer chunkIndex = queryInteger(query);
|
||||||
|
int before = storedChunks.size();
|
||||||
|
storedChunks.removeIf(chunk -> matchesChunk(chunk, taskId, scopeHash, chunkIndex));
|
||||||
|
return before - storedChunks.size();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void configureScopeMapper() {
|
||||||
|
lenient().when(taskScopeStateMapper.insert(any(TaskScopeStateEntity.class))).thenAnswer(invocation -> {
|
||||||
|
TaskScopeStateEntity scope = invocation.getArgument(0);
|
||||||
|
scope.setId((long) storedScopes.size() + 1L);
|
||||||
|
storedScopes.add(scope);
|
||||||
|
return 1;
|
||||||
|
});
|
||||||
|
lenient().when(taskScopeStateMapper.selectOne(any())).thenAnswer(invocation -> {
|
||||||
|
LambdaQueryWrapper<TaskScopeStateEntity> query = invocation.getArgument(0);
|
||||||
|
Long taskId = queryLong(query);
|
||||||
|
String scopeHash = queryScopeHash(query);
|
||||||
|
return storedScopes.stream()
|
||||||
|
.filter(scope -> Objects.equals(taskId, scope.getTaskId()))
|
||||||
|
.filter(scope -> scopeHash == null || Objects.equals(scopeHash, scope.getScopeHash()))
|
||||||
|
.findFirst()
|
||||||
|
.map(ShopDataCrawlRowDedupKeyTest::copyScope)
|
||||||
|
.orElse(null);
|
||||||
|
});
|
||||||
|
lenient().when(taskScopeStateMapper.updateById(any(TaskScopeStateEntity.class))).thenAnswer(invocation -> {
|
||||||
|
if (scopeUpdateFails) {
|
||||||
|
throw new RuntimeException("scope update down");
|
||||||
|
}
|
||||||
|
TaskScopeStateEntity updated = invocation.getArgument(0);
|
||||||
|
for (int i = 0; i < storedScopes.size(); i++) {
|
||||||
|
if (Objects.equals(storedScopes.get(i).getId(), updated.getId())) {
|
||||||
|
storedScopes.set(i, copyScope(updated));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 1;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TaskScopeStateEntity copyScope(TaskScopeStateEntity source) {
|
||||||
|
TaskScopeStateEntity copy = new TaskScopeStateEntity();
|
||||||
|
copy.setId(source.getId());
|
||||||
|
copy.setTaskId(source.getTaskId());
|
||||||
|
copy.setModuleType(source.getModuleType());
|
||||||
|
copy.setScopeKey(source.getScopeKey());
|
||||||
|
copy.setScopeHash(source.getScopeHash());
|
||||||
|
copy.setChunkTotal(source.getChunkTotal());
|
||||||
|
copy.setReceivedChunkCount(source.getReceivedChunkCount());
|
||||||
|
copy.setCompleted(source.getCompleted());
|
||||||
|
copy.setLastChunkAt(source.getLastChunkAt());
|
||||||
|
copy.setLastError(source.getLastError());
|
||||||
|
copy.setCreatedAt(source.getCreatedAt());
|
||||||
|
copy.setUpdatedAt(source.getUpdatedAt());
|
||||||
|
return copy;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean matchesChunk(TaskChunkEntity chunk, Long taskId, String scopeHash, Integer chunkIndex) {
|
||||||
|
return (taskId == null || Objects.equals(taskId, chunk.getTaskId()))
|
||||||
|
&& (scopeHash == null || Objects.equals(scopeHash, chunk.getScopeHash()))
|
||||||
|
&& (chunkIndex == null || Objects.equals(chunkIndex, chunk.getChunkIndex()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Long queryLong(LambdaQueryWrapper<?> query) {
|
||||||
|
query.getSqlSegment();
|
||||||
|
return query.getParamNameValuePairs().values().stream()
|
||||||
|
.filter(Long.class::isInstance)
|
||||||
|
.map(Long.class::cast)
|
||||||
|
.findFirst()
|
||||||
|
.orElse(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Integer queryInteger(LambdaQueryWrapper<?> query) {
|
||||||
|
query.getSqlSegment();
|
||||||
|
return query.getParamNameValuePairs().values().stream()
|
||||||
|
.filter(Integer.class::isInstance)
|
||||||
|
.map(Integer.class::cast)
|
||||||
|
.findFirst()
|
||||||
|
.orElse(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String queryScopeHash(LambdaQueryWrapper<?> query) {
|
||||||
|
query.getSqlSegment();
|
||||||
|
return query.getParamNameValuePairs().values().stream()
|
||||||
|
.filter(String.class::isInstance)
|
||||||
|
.map(String.class::cast)
|
||||||
|
.filter(value -> value.length() == 64)
|
||||||
|
.findFirst()
|
||||||
|
.orElse(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void givenRunningTask(long taskId, long resultId) {
|
||||||
|
task = new FileTaskEntity();
|
||||||
|
task.setId(taskId);
|
||||||
|
task.setUserId(7L);
|
||||||
|
task.setModuleType(MODULE_TYPE);
|
||||||
|
task.setStatus("RUNNING");
|
||||||
|
task.setRequestJson("{\"ownerInstanceId\":\"instance-a\"}");
|
||||||
|
task.setResultJson("[]");
|
||||||
|
task.setCreatedAt(LocalDateTime.now());
|
||||||
|
task.setUpdatedAt(LocalDateTime.now());
|
||||||
|
|
||||||
|
result = new FileResultEntity();
|
||||||
|
result.setId(resultId);
|
||||||
|
result.setTaskId(taskId);
|
||||||
|
result.setUserId(7L);
|
||||||
|
result.setModuleType(MODULE_TYPE);
|
||||||
|
result.setSourceFilename(SHOP_NAME);
|
||||||
|
result.setSourceFileUrl("shop-1");
|
||||||
|
result.setSuccess(-1);
|
||||||
|
result.setCreatedAt(LocalDateTime.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
private ShopDataCrawlSubmitResultRequest request(ShopDataCrawlShopPayloadDto payload) {
|
||||||
|
ShopDataCrawlSubmitResultRequest request = new ShopDataCrawlSubmitResultRequest();
|
||||||
|
request.setShops(List.of(payload));
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ShopDataCrawlShopPayloadDto chunk(int chunkIndex,
|
||||||
|
int chunkTotal,
|
||||||
|
String country,
|
||||||
|
List<ShopDataCrawlRowDto> rows) {
|
||||||
|
ShopDataCrawlShopPayloadDto payload = new ShopDataCrawlShopPayloadDto();
|
||||||
|
payload.setShopName(SHOP_NAME);
|
||||||
|
payload.setChunkIndex(chunkIndex);
|
||||||
|
payload.setChunkTotal(chunkTotal);
|
||||||
|
payload.setCountryResults(List.of(countryWithItems(country, rows)));
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ShopDataCrawlShopPayloadDto chunk(int chunkIndex,
|
||||||
|
int chunkTotal,
|
||||||
|
String country,
|
||||||
|
ShopDataCrawlRowDto... rows) {
|
||||||
|
return chunk(chunkIndex, chunkTotal, country, List.of(rows));
|
||||||
|
}
|
||||||
|
|
||||||
|
private ShopDataCrawlShopPayloadDto blankRowsChunk(int chunkIndex, int chunkTotal) {
|
||||||
|
ShopDataCrawlShopPayloadDto payload = new ShopDataCrawlShopPayloadDto();
|
||||||
|
payload.setShopName(SHOP_NAME);
|
||||||
|
payload.setChunkIndex(chunkIndex);
|
||||||
|
payload.setChunkTotal(chunkTotal);
|
||||||
|
ShopDataCrawlCountryResultDto country = new ShopDataCrawlCountryResultDto();
|
||||||
|
country.setCountry("DE");
|
||||||
|
ShopDataCrawlRowDto blank = new ShopDataCrawlRowDto();
|
||||||
|
blank.setDate("");
|
||||||
|
blank.setAsin(" ");
|
||||||
|
country.setItems(List.of(blank));
|
||||||
|
payload.setCountryResults(List.of(country));
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ShopDataCrawlCountryResultDto countryWithItems(String country, List<ShopDataCrawlRowDto> items) {
|
||||||
|
ShopDataCrawlCountryResultDto result = new ShopDataCrawlCountryResultDto();
|
||||||
|
result.setCountry(country);
|
||||||
|
result.setItems(items);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ShopDataCrawlRowDto row(String date, String asin) {
|
||||||
|
ShopDataCrawlRowDto row = new ShopDataCrawlRowDto();
|
||||||
|
row.setDate(date);
|
||||||
|
row.setAsin(asin);
|
||||||
|
row.setCommodityImage("https://m.media-amazon.com/images/I/" + asin + ".jpg");
|
||||||
|
row.setInventorySales("10");
|
||||||
|
row.setSalesRank("20");
|
||||||
|
row.setPageViews("30");
|
||||||
|
row.setUnitsSold("40");
|
||||||
|
row.setPrice("50");
|
||||||
|
row.setRecommendedOffer("60");
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ShopDataCrawlRowDto sameRow(String date, String asin) {
|
||||||
|
return row(date, asin);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ShopDataCrawlRowDto paddedRow(String date, String asin) {
|
||||||
|
ShopDataCrawlRowDto row = new ShopDataCrawlRowDto();
|
||||||
|
row.setDate(" " + date + " ");
|
||||||
|
row.setAsin(" " + asin + " ");
|
||||||
|
row.setCommodityImage(" https://m.media-amazon.com/images/I/" + asin + ".jpg ");
|
||||||
|
row.setInventorySales(" 10 ");
|
||||||
|
row.setSalesRank(" 20 ");
|
||||||
|
row.setPageViews(" 30 ");
|
||||||
|
row.setUnitsSold(" 40 ");
|
||||||
|
row.setPrice(" 50 ");
|
||||||
|
row.setRecommendedOffer(" 60 ");
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
}
|
||||||
+608
@@ -0,0 +1,608 @@
|
|||||||
|
package com.nanri.aiimage.modules.shopdatacrawl.service;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
|
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.nanri.aiimage.common.exception.BusinessException;
|
||||||
|
import com.nanri.aiimage.config.InstanceMetadata;
|
||||||
|
import com.nanri.aiimage.config.TaskPressureProperties;
|
||||||
|
import com.nanri.aiimage.modules.file.service.oss.OssStorageService;
|
||||||
|
import com.nanri.aiimage.modules.shopdatacrawl.model.dto.ShopDataCrawlCountryResultDto;
|
||||||
|
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.task.mapper.FileResultMapper;
|
||||||
|
import com.nanri.aiimage.modules.task.mapper.FileTaskMapper;
|
||||||
|
import com.nanri.aiimage.modules.task.mapper.TaskChunkMapper;
|
||||||
|
import com.nanri.aiimage.modules.task.mapper.TaskScopeStateMapper;
|
||||||
|
import com.nanri.aiimage.modules.task.model.entity.FileResultEntity;
|
||||||
|
import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity;
|
||||||
|
import com.nanri.aiimage.modules.task.model.entity.TaskChunkEntity;
|
||||||
|
import com.nanri.aiimage.modules.task.model.entity.TaskScopeStateEntity;
|
||||||
|
import com.nanri.aiimage.modules.task.service.TaskDistributedLockService;
|
||||||
|
import com.nanri.aiimage.modules.task.service.TaskFileJobService;
|
||||||
|
import com.nanri.aiimage.modules.task.service.TaskProgressSnapshotService;
|
||||||
|
import com.nanri.aiimage.modules.task.service.TaskResultItemService;
|
||||||
|
import com.nanri.aiimage.modules.task.service.TransientPayloadStorageService;
|
||||||
|
import com.nanri.aiimage.modules.ziniao.service.ZiniaoShopSwitchService;
|
||||||
|
import org.apache.ibatis.builder.MapperBuilderAssistant;
|
||||||
|
import org.apache.ibatis.session.Configuration;
|
||||||
|
import org.junit.jupiter.api.BeforeAll;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.Spy;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
import org.springframework.dao.DuplicateKeyException;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyLong;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.ArgumentMatchers.nullable;
|
||||||
|
import static org.mockito.Mockito.lenient;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.never;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Task 28:以 scope 计数器替代每个 chunk 的 COUNT(*) 完整统计。
|
||||||
|
* 每个分片接收不再对 chunk 表执行 selectCount 全量统计,而是读取
|
||||||
|
* biz_task_scope_state 的 received_chunk_count 计数器:新插入分片 +1、
|
||||||
|
* 重复提交(唯一键冲突,chunk 已计过数)保持不变,并钳制到 chunk_total。
|
||||||
|
* 任务级分布式锁串行化同一任务的接收,计数器读写安全。
|
||||||
|
*/
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class ShopDataCrawlScopeCounterTest {
|
||||||
|
|
||||||
|
private static final String MODULE_TYPE = "SHOP_DATA_CRAWL";
|
||||||
|
private static final String SHOP_NAME = "Demo Shop";
|
||||||
|
|
||||||
|
@BeforeAll
|
||||||
|
static void initializeMybatisMetadata() {
|
||||||
|
MapperBuilderAssistant assistant = new MapperBuilderAssistant(new Configuration(), "");
|
||||||
|
TableInfoHelper.initTableInfo(assistant, FileTaskEntity.class);
|
||||||
|
TableInfoHelper.initTableInfo(assistant, FileResultEntity.class);
|
||||||
|
TableInfoHelper.initTableInfo(assistant, TaskChunkEntity.class);
|
||||||
|
TableInfoHelper.initTableInfo(assistant, TaskScopeStateEntity.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Mock private FileTaskMapper fileTaskMapper;
|
||||||
|
@Mock private FileResultMapper fileResultMapper;
|
||||||
|
@Mock private ShopDataCrawlResolveService shopDataCrawlResolveService;
|
||||||
|
@Mock private ShopDataCrawlExcelAssemblyService excelAssemblyService;
|
||||||
|
@Mock private ShopDataCrawlTaskCacheService taskCacheService;
|
||||||
|
@Mock private OssStorageService ossStorageService;
|
||||||
|
@Mock private ZiniaoShopSwitchService ziniaoShopSwitchService;
|
||||||
|
@Mock private TaskPressureProperties taskPressureProperties;
|
||||||
|
@Mock private TaskFileJobService taskFileJobService;
|
||||||
|
@Mock private TaskResultItemService taskResultItemService;
|
||||||
|
@Mock private TaskProgressSnapshotService taskProgressSnapshotService;
|
||||||
|
@Mock private TaskDistributedLockService taskDistributedLockService;
|
||||||
|
@Mock private TaskChunkMapper taskChunkMapper;
|
||||||
|
@Mock private TaskScopeStateMapper taskScopeStateMapper;
|
||||||
|
@Mock private TransientPayloadStorageService transientPayloadStorageService;
|
||||||
|
@Mock private InstanceMetadata instanceMetadata;
|
||||||
|
@Mock private ShopDataCrawlDailyFileService dailyFileService;
|
||||||
|
@Spy private ObjectMapper objectMapper = new ObjectMapper().findAndRegisterModules();
|
||||||
|
|
||||||
|
private ShopDataCrawlTaskService service;
|
||||||
|
|
||||||
|
private final List<TaskChunkEntity> storedChunks = new ArrayList<>();
|
||||||
|
private final List<TaskScopeStateEntity> storedScopes = new ArrayList<>();
|
||||||
|
private final Map<String, String> rustfsPayloads = new LinkedHashMap<>();
|
||||||
|
private FileTaskEntity task;
|
||||||
|
private FileResultEntity result;
|
||||||
|
private int nextPayloadId;
|
||||||
|
private boolean storeFails;
|
||||||
|
private boolean insertFails;
|
||||||
|
private boolean scopeUpdateFails;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void configureStorage() {
|
||||||
|
service = new ShopDataCrawlTaskService(
|
||||||
|
fileTaskMapper,
|
||||||
|
fileResultMapper,
|
||||||
|
shopDataCrawlResolveService,
|
||||||
|
excelAssemblyService,
|
||||||
|
taskCacheService,
|
||||||
|
ossStorageService,
|
||||||
|
ziniaoShopSwitchService,
|
||||||
|
objectMapper,
|
||||||
|
taskPressureProperties,
|
||||||
|
taskFileJobService,
|
||||||
|
taskResultItemService,
|
||||||
|
taskProgressSnapshotService,
|
||||||
|
taskDistributedLockService,
|
||||||
|
taskChunkMapper,
|
||||||
|
taskScopeStateMapper,
|
||||||
|
transientPayloadStorageService,
|
||||||
|
instanceMetadata,
|
||||||
|
dailyFileService,
|
||||||
|
null);
|
||||||
|
|
||||||
|
storedChunks.clear();
|
||||||
|
storedScopes.clear();
|
||||||
|
rustfsPayloads.clear();
|
||||||
|
nextPayloadId = 0;
|
||||||
|
storeFails = false;
|
||||||
|
insertFails = false;
|
||||||
|
scopeUpdateFails = false;
|
||||||
|
|
||||||
|
lenient().when(instanceMetadata.getInstanceId()).thenReturn("instance-a");
|
||||||
|
lenient().when(taskDistributedLockService.acquire(eq(MODULE_TYPE), anyLong()))
|
||||||
|
.thenReturn(mock(TaskDistributedLockService.LockHandle.class));
|
||||||
|
lenient().when(taskCacheService.getTaskCacheBatch(any())).thenReturn(Map.of());
|
||||||
|
lenient().when(taskCacheService.getAllShopMergedPayload(anyLong())).thenReturn(Map.of());
|
||||||
|
lenient().when(taskResultItemService.listResultSnapshots(anyLong(), eq(MODULE_TYPE), any())).thenReturn(List.of());
|
||||||
|
lenient().when(taskFileJobService.findAssembleJob(anyLong(), eq(MODULE_TYPE), anyLong())).thenReturn(null);
|
||||||
|
lenient().when(excelAssemblyService.countRows(any())).thenReturn(1);
|
||||||
|
|
||||||
|
lenient().when(fileTaskMapper.selectById(anyLong())).thenAnswer(invocation -> {
|
||||||
|
Long taskId = invocation.getArgument(0);
|
||||||
|
return task != null && Objects.equals(taskId, task.getId()) ? task : null;
|
||||||
|
});
|
||||||
|
lenient().when(fileTaskMapper.updateById(any(FileTaskEntity.class))).thenReturn(1);
|
||||||
|
lenient().when(fileResultMapper.selectById(anyLong())).thenAnswer(invocation -> {
|
||||||
|
Long resultId = invocation.getArgument(0);
|
||||||
|
return result != null && Objects.equals(resultId, result.getId()) ? result : null;
|
||||||
|
});
|
||||||
|
lenient().when(fileResultMapper.selectList(any())).thenAnswer(invocation -> {
|
||||||
|
LambdaQueryWrapper<FileResultEntity> query = invocation.getArgument(0);
|
||||||
|
Long taskId = queryLong(query);
|
||||||
|
return result != null && Objects.equals(taskId, result.getTaskId()) ? List.of(result) : List.of();
|
||||||
|
});
|
||||||
|
lenient().when(fileResultMapper.updateById(any(FileResultEntity.class))).thenReturn(1);
|
||||||
|
|
||||||
|
lenient().when(ziniaoShopSwitchService.normalizeShopName(nullable(String.class))).thenAnswer(invocation -> {
|
||||||
|
String value = invocation.getArgument(0);
|
||||||
|
return value == null ? "" : value.trim();
|
||||||
|
});
|
||||||
|
|
||||||
|
configureTransientPayloadStorage();
|
||||||
|
configureChunkMapper();
|
||||||
|
configureScopeMapper();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_028_scope_counter_normal_default_path() {
|
||||||
|
// 正常输入:顺序接收 1/2、2/2,scope 计数器 1→2,齐集完成任务;
|
||||||
|
// 全程不执行 chunk 表 selectCount 全量统计。
|
||||||
|
givenRunningTask(1101L, 2101L);
|
||||||
|
|
||||||
|
service.submitResult(task.getId(), request(chunk(1, 2, "DE", row("2026-07-25", "B001"))));
|
||||||
|
|
||||||
|
assertEquals(1, storedScopes.get(0).getReceivedChunkCount(), "首个分片计数器为 1");
|
||||||
|
assertEquals(0, storedScopes.get(0).getCompleted());
|
||||||
|
assertEquals(-1, result.getSuccess());
|
||||||
|
|
||||||
|
service.submitResult(task.getId(), request(chunk(2, 2, "UK", row("2026-07-26", "B002"))));
|
||||||
|
|
||||||
|
assertEquals(2, storedScopes.get(0).getReceivedChunkCount(), "齐集后计数器为 2");
|
||||||
|
assertEquals(1, storedScopes.get(0).getCompleted());
|
||||||
|
assertEquals(1, result.getSuccess());
|
||||||
|
assertTrue(task.getResultJson().indexOf("B001") < task.getResultJson().indexOf("B002"));
|
||||||
|
verify(taskChunkMapper, never()).selectCount(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_028_scope_counter_normal_multiple_items() {
|
||||||
|
// 批量场景:乱序分片 2/3、3/3、1/3,计数器按到达分片计数 1→2→3,
|
||||||
|
// 结果按 chunk_index 顺序合并,不丢失。
|
||||||
|
givenRunningTask(1102L, 2102L);
|
||||||
|
|
||||||
|
service.submitResult(task.getId(), request(chunk(2, 3, "DE", row("2026-07-25", "B002"))));
|
||||||
|
assertEquals(1, storedScopes.get(0).getReceivedChunkCount(), "乱序到达按已收分片计数");
|
||||||
|
|
||||||
|
service.submitResult(task.getId(), request(chunk(3, 3, "UK", row("2026-07-26", "B003"))));
|
||||||
|
assertEquals(2, storedScopes.get(0).getReceivedChunkCount());
|
||||||
|
|
||||||
|
service.submitResult(task.getId(), request(chunk(1, 3, "FR", row("2026-07-27", "B001"))));
|
||||||
|
|
||||||
|
assertEquals(3, storedScopes.get(0).getReceivedChunkCount());
|
||||||
|
assertEquals(1, storedScopes.get(0).getCompleted());
|
||||||
|
assertEquals(1, result.getSuccess());
|
||||||
|
String json = task.getResultJson();
|
||||||
|
assertTrue(json.indexOf("B001") < json.indexOf("B002"), "合并顺序按 chunk_index");
|
||||||
|
assertTrue(json.indexOf("B002") < json.indexOf("B003"));
|
||||||
|
verify(taskChunkMapper, never()).selectCount(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_028_scope_counter_normal_repeated_operation_is_idempotent() {
|
||||||
|
// 重复执行:同一分片重试命中唯一键,已计过数的分片不再 +1,
|
||||||
|
// 计数器保持不变,无重复 scope 状态。
|
||||||
|
givenRunningTask(1103L, 2103L);
|
||||||
|
ShopDataCrawlSubmitResultRequest request = request(chunk(1, 2, "DE", row("2026-07-25", "B001")));
|
||||||
|
|
||||||
|
service.submitResult(task.getId(), request);
|
||||||
|
service.submitResult(task.getId(), request);
|
||||||
|
|
||||||
|
assertEquals(1, storedScopes.size(), "scope 状态只有一行");
|
||||||
|
assertEquals(1, storedScopes.get(0).getReceivedChunkCount(), "重试不重复计数");
|
||||||
|
assertEquals(0, storedScopes.get(0).getCompleted());
|
||||||
|
assertEquals(1, storedChunks.size());
|
||||||
|
verify(taskChunkMapper, never()).selectCount(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_028_scope_counter_boundary_empty_input() {
|
||||||
|
// 空输入:无可处理数据的分片在落库前拒绝,不产生 payload/分片行/scope 状态,
|
||||||
|
// 也不触发任何计数统计。
|
||||||
|
givenRunningTask(1104L, 2104L);
|
||||||
|
|
||||||
|
ShopDataCrawlShopPayloadDto emptyItems = legacyChunk(false, "DE", null);
|
||||||
|
emptyItems.setChunkIndex(1);
|
||||||
|
emptyItems.setChunkTotal(1);
|
||||||
|
emptyItems.setCountryResults(List.of(countryWithItems("DE", List.of())));
|
||||||
|
|
||||||
|
BusinessException error = assertThrows(BusinessException.class, () ->
|
||||||
|
service.submitResult(task.getId(), request(emptyItems)));
|
||||||
|
assertTrue(error.getMessage().contains("内容为空"));
|
||||||
|
|
||||||
|
assertEquals(0, storedChunks.size());
|
||||||
|
assertEquals(0, rustfsPayloads.size());
|
||||||
|
assertEquals(0, storedScopes.size());
|
||||||
|
verify(taskChunkMapper, never()).selectCount(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_028_scope_counter_boundary_single_item() {
|
||||||
|
// 单元素:单分片 1/1 到达即齐集,计数器直接达到 chunk_total 并完成。
|
||||||
|
givenRunningTask(1105L, 2105L);
|
||||||
|
|
||||||
|
service.submitResult(task.getId(), request(chunk(1, 1, "DE", row("2026-07-25", "B001"))));
|
||||||
|
|
||||||
|
assertEquals(1, storedScopes.get(0).getReceivedChunkCount());
|
||||||
|
assertEquals(1, storedScopes.get(0).getCompleted());
|
||||||
|
assertEquals(1, result.getSuccess());
|
||||||
|
assertTrue(task.getResultJson().contains("B001"));
|
||||||
|
verify(taskChunkMapper, never()).selectCount(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_028_scope_counter_boundary_limit_and_overflow() {
|
||||||
|
// 上限/超限:已收分片在齐集前重放 → 唯一键冲突吸收、计数器不重复推进;
|
||||||
|
// 随后 5/5 全量到达,计数器达到 chunk_total 并完成,不溢出。
|
||||||
|
givenRunningTask(1106L, 2106L);
|
||||||
|
|
||||||
|
service.submitResult(task.getId(), request(chunk(1, 5, "UK", row("2026-07-25", "B001"))));
|
||||||
|
assertEquals(1, storedScopes.get(0).getReceivedChunkCount());
|
||||||
|
|
||||||
|
service.submitResult(task.getId(), request(chunk(1, 5, "UK", row("2026-07-25", "B001"))));
|
||||||
|
|
||||||
|
assertEquals(1, storedScopes.get(0).getReceivedChunkCount(), "重放不重复计数");
|
||||||
|
assertEquals(1, storedChunks.size(), "重放不产生新分片行");
|
||||||
|
assertEquals(1, rustfsPayloads.size(), "重放重存的 payload 被清理");
|
||||||
|
|
||||||
|
String[] countries = {"UK", "DE", "FR", "ES", "IT"};
|
||||||
|
String[] dates = {"2026-07-25", "2026-07-26", "2026-07-27", "2026-07-28", "2026-07-29"};
|
||||||
|
for (int i = 2; i <= 5; i++) {
|
||||||
|
service.submitResult(task.getId(), request(chunk(i, 5, countries[i - 1], row(dates[i - 1], "B00" + i))));
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals(5, storedScopes.get(0).getReceivedChunkCount(), "计数器与 chunk_total 一致");
|
||||||
|
assertEquals(1, storedScopes.get(0).getCompleted());
|
||||||
|
assertEquals(1, result.getSuccess());
|
||||||
|
assertEquals(5, storedChunks.size());
|
||||||
|
verify(taskChunkMapper, never()).selectCount(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_028_scope_counter_invalid_input_rejected() {
|
||||||
|
// 非法参数:非正 chunk_index/chunk_total、跨分片改 chunk_total →
|
||||||
|
// 明确异常,计数器与 scope 状态不变,不触发计数统计。
|
||||||
|
givenRunningTask(1107L, 2107L);
|
||||||
|
|
||||||
|
BusinessException zeroIndex = assertThrows(BusinessException.class, () ->
|
||||||
|
service.submitResult(task.getId(), request(chunk(0, 1, "DE", row("2026-07-25", "B001")))));
|
||||||
|
assertTrue(zeroIndex.getMessage().contains("chunk_index"));
|
||||||
|
|
||||||
|
BusinessException zeroTotal = assertThrows(BusinessException.class, () ->
|
||||||
|
service.submitResult(task.getId(), request(chunk(1, 0, "DE", row("2026-07-25", "B001")))));
|
||||||
|
assertTrue(zeroTotal.getMessage().contains("chunk_total"));
|
||||||
|
|
||||||
|
service.submitResult(task.getId(), request(chunk(1, 2, "DE", row("2026-07-25", "B001"))));
|
||||||
|
assertEquals(1, storedScopes.get(0).getReceivedChunkCount());
|
||||||
|
|
||||||
|
BusinessException changedTotal = assertThrows(BusinessException.class, () ->
|
||||||
|
service.submitResult(task.getId(), request(chunk(2, 3, "UK", row("2026-07-26", "B002")))));
|
||||||
|
assertTrue(changedTotal.getMessage().contains("chunk_total"));
|
||||||
|
|
||||||
|
assertEquals(1, storedScopes.get(0).getReceivedChunkCount(), "非法输入不改变计数器");
|
||||||
|
assertEquals(1, storedChunks.size());
|
||||||
|
verify(taskChunkMapper, never()).selectCount(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_028_scope_counter_dependency_failure_releases_resources() {
|
||||||
|
// 依赖失败:分片插入异常 → payload 清理、无计数器更新,恢复后重试成功;
|
||||||
|
// scope 状态写入失败 → 已插入的分片行与 payload 被回滚补偿,恢复后重试为全新插入、计数器收敛。
|
||||||
|
givenRunningTask(1108L, 2108L);
|
||||||
|
|
||||||
|
insertFails = true;
|
||||||
|
assertThrows(RuntimeException.class, () ->
|
||||||
|
service.submitResult(task.getId(), request(chunk(1, 2, "DE", row("2026-07-25", "B001")))));
|
||||||
|
assertEquals(0, storedChunks.size());
|
||||||
|
assertEquals(0, rustfsPayloads.size(), "插入失败清理已存 payload");
|
||||||
|
assertEquals(0, storedScopes.size(), "插入失败不更新计数器");
|
||||||
|
insertFails = false;
|
||||||
|
|
||||||
|
service.submitResult(task.getId(), request(chunk(1, 2, "DE", row("2026-07-25", "B001"))));
|
||||||
|
assertEquals(1, storedScopes.get(0).getReceivedChunkCount(), "故障恢复后重试成功");
|
||||||
|
|
||||||
|
scopeUpdateFails = true;
|
||||||
|
assertThrows(RuntimeException.class, () ->
|
||||||
|
service.submitResult(task.getId(), request(chunk(2, 2, "UK", row("2026-07-26", "B002")))));
|
||||||
|
assertEquals(1, storedChunks.size(), "状态写入失败回滚已插入的分片行");
|
||||||
|
assertEquals(1, rustfsPayloads.size(), "状态写入失败清理本次 payload");
|
||||||
|
assertEquals(1, storedScopes.get(0).getReceivedChunkCount(), "状态写入失败计数器未推进");
|
||||||
|
scopeUpdateFails = false;
|
||||||
|
|
||||||
|
service.submitResult(task.getId(), request(chunk(2, 2, "UK", row("2026-07-26", "B002"))));
|
||||||
|
|
||||||
|
assertEquals(1, result.getSuccess(), "状态写入恢复后重试成功");
|
||||||
|
assertEquals(2, storedScopes.get(0).getReceivedChunkCount(), "重试为全新插入,计数器收敛到 chunk_total");
|
||||||
|
assertEquals(1, storedScopes.get(0).getCompleted());
|
||||||
|
verify(taskChunkMapper, never()).selectCount(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void configureTransientPayloadStorage() {
|
||||||
|
lenient().when(transientPayloadStorageService.isSharedWriteEnabled()).thenReturn(true);
|
||||||
|
lenient().when(transientPayloadStorageService.storeChunkPayloadVersioned(
|
||||||
|
anyString(), anyLong(), anyString(), any(), anyString())).thenAnswer(invocation -> {
|
||||||
|
if (storeFails) {
|
||||||
|
throw new RuntimeException("rustfs store down");
|
||||||
|
}
|
||||||
|
String pointer = "rustfs:payload-" + (++nextPayloadId);
|
||||||
|
rustfsPayloads.put(pointer, invocation.getArgument(4));
|
||||||
|
return pointer;
|
||||||
|
});
|
||||||
|
lenient().when(transientPayloadStorageService.extractPointer(any())).thenAnswer(invocation -> {
|
||||||
|
String value = invocation.getArgument(0);
|
||||||
|
if (value == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return value.startsWith("rustfs:") || value.startsWith("local:") || value.startsWith("oss:")
|
||||||
|
? value : null;
|
||||||
|
});
|
||||||
|
lenient().when(transientPayloadStorageService.resolvePayload(any(), any())).thenAnswer(invocation -> {
|
||||||
|
String pointer = invocation.getArgument(0);
|
||||||
|
String payload = rustfsPayloads.get(pointer);
|
||||||
|
if (payload == null) {
|
||||||
|
throw new IllegalStateException("missing test RustFS payload: " + pointer);
|
||||||
|
}
|
||||||
|
return payload;
|
||||||
|
});
|
||||||
|
lenient().doAnswer(invocation -> {
|
||||||
|
rustfsPayloads.remove(invocation.getArgument(0));
|
||||||
|
return null;
|
||||||
|
}).when(transientPayloadStorageService).deletePayloadIfPresent(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void configureChunkMapper() {
|
||||||
|
lenient().when(taskChunkMapper.insert(any(TaskChunkEntity.class))).thenAnswer(invocation -> {
|
||||||
|
if (insertFails) {
|
||||||
|
throw new RuntimeException("db down");
|
||||||
|
}
|
||||||
|
TaskChunkEntity chunk = invocation.getArgument(0);
|
||||||
|
boolean duplicate = storedChunks.stream().anyMatch(existing ->
|
||||||
|
Objects.equals(existing.getTaskId(), chunk.getTaskId())
|
||||||
|
&& Objects.equals(existing.getScopeHash(), chunk.getScopeHash())
|
||||||
|
&& Objects.equals(existing.getChunkIndex(), chunk.getChunkIndex()));
|
||||||
|
if (duplicate) {
|
||||||
|
throw new DuplicateKeyException("duplicate chunk key: " + chunk.getScopeHash() + "/" + chunk.getChunkIndex());
|
||||||
|
}
|
||||||
|
chunk.setId((long) storedChunks.size() + 1L);
|
||||||
|
storedChunks.add(chunk);
|
||||||
|
return 1;
|
||||||
|
});
|
||||||
|
lenient().when(taskChunkMapper.selectOne(any())).thenAnswer(invocation -> {
|
||||||
|
LambdaQueryWrapper<TaskChunkEntity> query = invocation.getArgument(0);
|
||||||
|
Long taskId = queryLong(query);
|
||||||
|
String scopeHash = queryScopeHash(query);
|
||||||
|
Integer chunkIndex = queryInteger(query);
|
||||||
|
return storedChunks.stream()
|
||||||
|
.filter(chunk -> matchesChunk(chunk, taskId, scopeHash, chunkIndex))
|
||||||
|
.findFirst()
|
||||||
|
.orElse(null);
|
||||||
|
});
|
||||||
|
lenient().when(taskChunkMapper.selectCount(any())).thenAnswer(invocation -> {
|
||||||
|
LambdaQueryWrapper<TaskChunkEntity> query = invocation.getArgument(0);
|
||||||
|
Long taskId = queryLong(query);
|
||||||
|
String scopeHash = queryScopeHash(query);
|
||||||
|
return storedChunks.stream()
|
||||||
|
.filter(chunk -> matchesChunk(chunk, taskId, scopeHash, null))
|
||||||
|
.count();
|
||||||
|
});
|
||||||
|
lenient().when(taskChunkMapper.selectList(any())).thenAnswer(invocation -> {
|
||||||
|
LambdaQueryWrapper<TaskChunkEntity> query = invocation.getArgument(0);
|
||||||
|
Long taskId = queryLong(query);
|
||||||
|
String scopeHash = queryScopeHash(query);
|
||||||
|
return storedChunks.stream()
|
||||||
|
.filter(chunk -> matchesChunk(chunk, taskId, scopeHash, null))
|
||||||
|
.sorted(Comparator.comparing(TaskChunkEntity::getChunkIndex))
|
||||||
|
.toList();
|
||||||
|
});
|
||||||
|
lenient().when(taskChunkMapper.delete(any())).thenAnswer(invocation -> {
|
||||||
|
LambdaQueryWrapper<TaskChunkEntity> query = invocation.getArgument(0);
|
||||||
|
Long taskId = queryLong(query);
|
||||||
|
String scopeHash = queryScopeHash(query);
|
||||||
|
Integer chunkIndex = queryInteger(query);
|
||||||
|
int before = storedChunks.size();
|
||||||
|
storedChunks.removeIf(chunk -> matchesChunk(chunk, taskId, scopeHash, chunkIndex));
|
||||||
|
return before - storedChunks.size();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void configureScopeMapper() {
|
||||||
|
lenient().when(taskScopeStateMapper.insert(any(TaskScopeStateEntity.class))).thenAnswer(invocation -> {
|
||||||
|
TaskScopeStateEntity scope = invocation.getArgument(0);
|
||||||
|
scope.setId((long) storedScopes.size() + 1L);
|
||||||
|
storedScopes.add(scope);
|
||||||
|
return 1;
|
||||||
|
});
|
||||||
|
lenient().when(taskScopeStateMapper.selectOne(any())).thenAnswer(invocation -> {
|
||||||
|
LambdaQueryWrapper<TaskScopeStateEntity> query = invocation.getArgument(0);
|
||||||
|
Long taskId = queryLong(query);
|
||||||
|
String scopeHash = queryScopeHash(query);
|
||||||
|
return storedScopes.stream()
|
||||||
|
.filter(scope -> Objects.equals(taskId, scope.getTaskId()))
|
||||||
|
.filter(scope -> scopeHash == null || Objects.equals(scopeHash, scope.getScopeHash()))
|
||||||
|
.findFirst()
|
||||||
|
.map(ShopDataCrawlScopeCounterTest::copyScope)
|
||||||
|
.orElse(null);
|
||||||
|
});
|
||||||
|
lenient().when(taskScopeStateMapper.updateById(any(TaskScopeStateEntity.class))).thenAnswer(invocation -> {
|
||||||
|
if (scopeUpdateFails) {
|
||||||
|
throw new RuntimeException("scope update down");
|
||||||
|
}
|
||||||
|
TaskScopeStateEntity updated = invocation.getArgument(0);
|
||||||
|
for (int i = 0; i < storedScopes.size(); i++) {
|
||||||
|
if (Objects.equals(storedScopes.get(i).getId(), updated.getId())) {
|
||||||
|
storedScopes.set(i, copyScope(updated));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 1;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TaskScopeStateEntity copyScope(TaskScopeStateEntity source) {
|
||||||
|
TaskScopeStateEntity copy = new TaskScopeStateEntity();
|
||||||
|
copy.setId(source.getId());
|
||||||
|
copy.setTaskId(source.getTaskId());
|
||||||
|
copy.setModuleType(source.getModuleType());
|
||||||
|
copy.setScopeKey(source.getScopeKey());
|
||||||
|
copy.setScopeHash(source.getScopeHash());
|
||||||
|
copy.setChunkTotal(source.getChunkTotal());
|
||||||
|
copy.setReceivedChunkCount(source.getReceivedChunkCount());
|
||||||
|
copy.setCompleted(source.getCompleted());
|
||||||
|
copy.setLastChunkAt(source.getLastChunkAt());
|
||||||
|
copy.setLastError(source.getLastError());
|
||||||
|
copy.setCreatedAt(source.getCreatedAt());
|
||||||
|
copy.setUpdatedAt(source.getUpdatedAt());
|
||||||
|
return copy;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean matchesChunk(TaskChunkEntity chunk, Long taskId, String scopeHash, Integer chunkIndex) {
|
||||||
|
return (taskId == null || Objects.equals(taskId, chunk.getTaskId()))
|
||||||
|
&& (scopeHash == null || Objects.equals(scopeHash, chunk.getScopeHash()))
|
||||||
|
&& (chunkIndex == null || Objects.equals(chunkIndex, chunk.getChunkIndex()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Long queryLong(LambdaQueryWrapper<?> query) {
|
||||||
|
query.getSqlSegment();
|
||||||
|
return query.getParamNameValuePairs().values().stream()
|
||||||
|
.filter(Long.class::isInstance)
|
||||||
|
.map(Long.class::cast)
|
||||||
|
.findFirst()
|
||||||
|
.orElse(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Integer queryInteger(LambdaQueryWrapper<?> query) {
|
||||||
|
query.getSqlSegment();
|
||||||
|
return query.getParamNameValuePairs().values().stream()
|
||||||
|
.filter(Integer.class::isInstance)
|
||||||
|
.map(Integer.class::cast)
|
||||||
|
.findFirst()
|
||||||
|
.orElse(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String queryScopeHash(LambdaQueryWrapper<?> query) {
|
||||||
|
query.getSqlSegment();
|
||||||
|
return query.getParamNameValuePairs().values().stream()
|
||||||
|
.filter(String.class::isInstance)
|
||||||
|
.map(String.class::cast)
|
||||||
|
.filter(value -> value.length() == 64)
|
||||||
|
.findFirst()
|
||||||
|
.orElse(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void givenRunningTask(long taskId, long resultId) {
|
||||||
|
task = new FileTaskEntity();
|
||||||
|
task.setId(taskId);
|
||||||
|
task.setUserId(7L);
|
||||||
|
task.setModuleType(MODULE_TYPE);
|
||||||
|
task.setStatus("RUNNING");
|
||||||
|
task.setRequestJson("{\"ownerInstanceId\":\"instance-a\"}");
|
||||||
|
task.setResultJson("[]");
|
||||||
|
task.setCreatedAt(LocalDateTime.now());
|
||||||
|
task.setUpdatedAt(LocalDateTime.now());
|
||||||
|
|
||||||
|
result = new FileResultEntity();
|
||||||
|
result.setId(resultId);
|
||||||
|
result.setTaskId(taskId);
|
||||||
|
result.setUserId(7L);
|
||||||
|
result.setModuleType(MODULE_TYPE);
|
||||||
|
result.setSourceFilename(SHOP_NAME);
|
||||||
|
result.setSourceFileUrl("shop-1");
|
||||||
|
result.setSuccess(-1);
|
||||||
|
result.setCreatedAt(LocalDateTime.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
private ShopDataCrawlSubmitResultRequest request(ShopDataCrawlShopPayloadDto payload) {
|
||||||
|
ShopDataCrawlSubmitResultRequest request = new ShopDataCrawlSubmitResultRequest();
|
||||||
|
request.setShops(List.of(payload));
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ShopDataCrawlShopPayloadDto chunk(int chunkIndex,
|
||||||
|
int chunkTotal,
|
||||||
|
String country,
|
||||||
|
ShopDataCrawlRowDto row) {
|
||||||
|
ShopDataCrawlShopPayloadDto payload = legacyChunk(false, country, row);
|
||||||
|
payload.setChunkIndex(chunkIndex);
|
||||||
|
payload.setChunkTotal(chunkTotal);
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ShopDataCrawlShopPayloadDto legacyChunk(boolean shopDone,
|
||||||
|
String country,
|
||||||
|
ShopDataCrawlRowDto row) {
|
||||||
|
ShopDataCrawlShopPayloadDto payload = new ShopDataCrawlShopPayloadDto();
|
||||||
|
payload.setShopName(SHOP_NAME);
|
||||||
|
if (row != null) {
|
||||||
|
payload.setCountryResults(List.of(country(country, row)));
|
||||||
|
}
|
||||||
|
payload.setShopDone(shopDone);
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ShopDataCrawlCountryResultDto country(String country, ShopDataCrawlRowDto row) {
|
||||||
|
return countryWithItems(country, List.of(row));
|
||||||
|
}
|
||||||
|
|
||||||
|
private ShopDataCrawlCountryResultDto countryWithItems(String country, List<ShopDataCrawlRowDto> items) {
|
||||||
|
ShopDataCrawlCountryResultDto result = new ShopDataCrawlCountryResultDto();
|
||||||
|
result.setCountry(country);
|
||||||
|
result.setItems(items);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ShopDataCrawlRowDto row(String date, String asin) {
|
||||||
|
ShopDataCrawlRowDto row = new ShopDataCrawlRowDto();
|
||||||
|
row.setDate(date);
|
||||||
|
row.setAsin(asin);
|
||||||
|
row.setCommodityImage("https://m.media-amazon.com/images/I/" + asin + ".jpg");
|
||||||
|
row.setInventorySales("10");
|
||||||
|
row.setSalesRank("20");
|
||||||
|
row.setPageViews("30");
|
||||||
|
row.setUnitsSold("40");
|
||||||
|
row.setPrice("50");
|
||||||
|
row.setRecommendedOffer("60");
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
}
|
||||||
+637
@@ -0,0 +1,637 @@
|
|||||||
|
package com.nanri.aiimage.modules.shopdatacrawl.service;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
|
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.nanri.aiimage.common.exception.BusinessException;
|
||||||
|
import com.nanri.aiimage.config.InstanceMetadata;
|
||||||
|
import com.nanri.aiimage.config.TaskPressureProperties;
|
||||||
|
import com.nanri.aiimage.modules.file.service.oss.OssStorageService;
|
||||||
|
import com.nanri.aiimage.modules.shopdatacrawl.model.dto.ShopDataCrawlCountryResultDto;
|
||||||
|
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.task.mapper.FileResultMapper;
|
||||||
|
import com.nanri.aiimage.modules.task.mapper.FileTaskMapper;
|
||||||
|
import com.nanri.aiimage.modules.task.mapper.TaskChunkMapper;
|
||||||
|
import com.nanri.aiimage.modules.task.mapper.TaskScopeStateMapper;
|
||||||
|
import com.nanri.aiimage.modules.task.model.entity.FileResultEntity;
|
||||||
|
import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity;
|
||||||
|
import com.nanri.aiimage.modules.task.model.entity.TaskChunkEntity;
|
||||||
|
import com.nanri.aiimage.modules.task.model.entity.TaskScopeStateEntity;
|
||||||
|
import com.nanri.aiimage.modules.task.service.TaskDistributedLockService;
|
||||||
|
import com.nanri.aiimage.modules.task.service.TaskFileJobService;
|
||||||
|
import com.nanri.aiimage.modules.task.service.TaskProgressSnapshotService;
|
||||||
|
import com.nanri.aiimage.modules.task.service.TaskResultItemService;
|
||||||
|
import com.nanri.aiimage.modules.task.service.TransientPayloadStorageService;
|
||||||
|
import com.nanri.aiimage.modules.ziniao.service.ZiniaoShopSwitchService;
|
||||||
|
import org.apache.ibatis.builder.MapperBuilderAssistant;
|
||||||
|
import org.apache.ibatis.session.Configuration;
|
||||||
|
import org.junit.jupiter.api.BeforeAll;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.Spy;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
import org.springframework.dao.DuplicateKeyException;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyLong;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.ArgumentMatchers.nullable;
|
||||||
|
import static org.mockito.Mockito.doAnswer;
|
||||||
|
import static org.mockito.Mockito.lenient;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.never;
|
||||||
|
import static org.mockito.Mockito.times;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Task 29:合并 scope 状态查询与更新,减少单 chunk 数据库往返。
|
||||||
|
* persistResultChunk 在接收前已查询 scope(用于校验 chunk_total),
|
||||||
|
* persistResultScope 不再重复 selectOne,而是复用同一份已加载的 scope
|
||||||
|
* 直接 updateById / insert —— 每个分片接收的 scope 往返从 2 次降到 1 次。
|
||||||
|
* 任务级分布式锁串行化同一任务的接收,预读的 scope 在锁内不会过期。
|
||||||
|
*/
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class ShopDataCrawlScopeMergeTest {
|
||||||
|
|
||||||
|
private static final String MODULE_TYPE = "SHOP_DATA_CRAWL";
|
||||||
|
private static final String SHOP_NAME = "Demo Shop";
|
||||||
|
|
||||||
|
@BeforeAll
|
||||||
|
static void initializeMybatisMetadata() {
|
||||||
|
MapperBuilderAssistant assistant = new MapperBuilderAssistant(new Configuration(), "");
|
||||||
|
TableInfoHelper.initTableInfo(assistant, FileTaskEntity.class);
|
||||||
|
TableInfoHelper.initTableInfo(assistant, FileResultEntity.class);
|
||||||
|
TableInfoHelper.initTableInfo(assistant, TaskChunkEntity.class);
|
||||||
|
TableInfoHelper.initTableInfo(assistant, TaskScopeStateEntity.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Mock private FileTaskMapper fileTaskMapper;
|
||||||
|
@Mock private FileResultMapper fileResultMapper;
|
||||||
|
@Mock private ShopDataCrawlResolveService shopDataCrawlResolveService;
|
||||||
|
@Mock private ShopDataCrawlExcelAssemblyService excelAssemblyService;
|
||||||
|
@Mock private ShopDataCrawlTaskCacheService taskCacheService;
|
||||||
|
@Mock private OssStorageService ossStorageService;
|
||||||
|
@Mock private ZiniaoShopSwitchService ziniaoShopSwitchService;
|
||||||
|
@Mock private TaskPressureProperties taskPressureProperties;
|
||||||
|
@Mock private TaskFileJobService taskFileJobService;
|
||||||
|
@Mock private TaskResultItemService taskResultItemService;
|
||||||
|
@Mock private TaskProgressSnapshotService taskProgressSnapshotService;
|
||||||
|
@Mock private TaskDistributedLockService taskDistributedLockService;
|
||||||
|
@Mock private TaskChunkMapper taskChunkMapper;
|
||||||
|
@Mock private TaskScopeStateMapper taskScopeStateMapper;
|
||||||
|
@Mock private TransientPayloadStorageService transientPayloadStorageService;
|
||||||
|
@Mock private InstanceMetadata instanceMetadata;
|
||||||
|
@Mock private ShopDataCrawlDailyFileService dailyFileService;
|
||||||
|
@Spy private ObjectMapper objectMapper = new ObjectMapper().findAndRegisterModules();
|
||||||
|
|
||||||
|
private ShopDataCrawlTaskService service;
|
||||||
|
|
||||||
|
private final List<TaskChunkEntity> storedChunks = new ArrayList<>();
|
||||||
|
private final List<TaskScopeStateEntity> storedScopes = new ArrayList<>();
|
||||||
|
private final Map<String, String> rustfsPayloads = new LinkedHashMap<>();
|
||||||
|
private FileTaskEntity task;
|
||||||
|
private FileResultEntity result;
|
||||||
|
private int nextPayloadId;
|
||||||
|
private boolean insertFails;
|
||||||
|
private boolean scopeUpdateFails;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void configureStorage() {
|
||||||
|
service = new ShopDataCrawlTaskService(
|
||||||
|
fileTaskMapper,
|
||||||
|
fileResultMapper,
|
||||||
|
shopDataCrawlResolveService,
|
||||||
|
excelAssemblyService,
|
||||||
|
taskCacheService,
|
||||||
|
ossStorageService,
|
||||||
|
ziniaoShopSwitchService,
|
||||||
|
objectMapper,
|
||||||
|
taskPressureProperties,
|
||||||
|
taskFileJobService,
|
||||||
|
taskResultItemService,
|
||||||
|
taskProgressSnapshotService,
|
||||||
|
taskDistributedLockService,
|
||||||
|
taskChunkMapper,
|
||||||
|
taskScopeStateMapper,
|
||||||
|
transientPayloadStorageService,
|
||||||
|
instanceMetadata,
|
||||||
|
dailyFileService,
|
||||||
|
null);
|
||||||
|
|
||||||
|
storedChunks.clear();
|
||||||
|
storedScopes.clear();
|
||||||
|
rustfsPayloads.clear();
|
||||||
|
nextPayloadId = 0;
|
||||||
|
insertFails = false;
|
||||||
|
scopeUpdateFails = false;
|
||||||
|
|
||||||
|
lenient().when(instanceMetadata.getInstanceId()).thenReturn("instance-a");
|
||||||
|
lenient().when(taskDistributedLockService.acquire(eq(MODULE_TYPE), anyLong()))
|
||||||
|
.thenReturn(mock(TaskDistributedLockService.LockHandle.class));
|
||||||
|
lenient().when(taskCacheService.getTaskCacheBatch(any())).thenReturn(Map.of());
|
||||||
|
lenient().when(taskCacheService.getAllShopMergedPayload(anyLong())).thenReturn(Map.of());
|
||||||
|
lenient().when(taskResultItemService.listResultSnapshots(anyLong(), eq(MODULE_TYPE), any())).thenReturn(List.of());
|
||||||
|
lenient().when(taskFileJobService.findAssembleJob(anyLong(), eq(MODULE_TYPE), anyLong())).thenReturn(null);
|
||||||
|
lenient().when(excelAssemblyService.countRows(any())).thenReturn(1);
|
||||||
|
|
||||||
|
lenient().when(fileTaskMapper.selectById(anyLong())).thenAnswer(invocation -> {
|
||||||
|
Long taskId = invocation.getArgument(0);
|
||||||
|
return task != null && Objects.equals(taskId, task.getId()) ? task : null;
|
||||||
|
});
|
||||||
|
lenient().when(fileTaskMapper.updateById(any(FileTaskEntity.class))).thenReturn(1);
|
||||||
|
lenient().when(fileResultMapper.selectById(anyLong())).thenAnswer(invocation -> {
|
||||||
|
Long resultId = invocation.getArgument(0);
|
||||||
|
return result != null && Objects.equals(resultId, result.getId()) ? result : null;
|
||||||
|
});
|
||||||
|
lenient().when(fileResultMapper.selectList(any())).thenAnswer(invocation -> {
|
||||||
|
LambdaQueryWrapper<FileResultEntity> query = invocation.getArgument(0);
|
||||||
|
Long taskId = queryLong(query);
|
||||||
|
return result != null && Objects.equals(taskId, result.getTaskId()) ? List.of(result) : List.of();
|
||||||
|
});
|
||||||
|
lenient().when(fileResultMapper.updateById(any(FileResultEntity.class))).thenReturn(1);
|
||||||
|
|
||||||
|
lenient().when(ziniaoShopSwitchService.normalizeShopName(nullable(String.class))).thenAnswer(invocation -> {
|
||||||
|
String value = invocation.getArgument(0);
|
||||||
|
return value == null ? "" : value.trim();
|
||||||
|
});
|
||||||
|
|
||||||
|
configureTransientPayloadStorage();
|
||||||
|
configureChunkMapper();
|
||||||
|
configureScopeMapper();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_029_chunk_merge_normal_default_path() {
|
||||||
|
// 正常输入:两个分片顺序到达,每个接收只查询一次 scope(预取复用),
|
||||||
|
// 首次 insert、后续 updateById,齐集完成任务。
|
||||||
|
givenRunningTask(1201L, 2201L);
|
||||||
|
|
||||||
|
service.submitResult(task.getId(), request(chunk(1, 2, "DE", row("2026-07-25", "B001"))));
|
||||||
|
service.submitResult(task.getId(), request(chunk(2, 2, "UK", row("2026-07-26", "B002"))));
|
||||||
|
|
||||||
|
assertEquals(2, storedScopes.get(0).getReceivedChunkCount());
|
||||||
|
assertEquals(1, storedScopes.get(0).getCompleted());
|
||||||
|
assertEquals(1, result.getSuccess());
|
||||||
|
assertTrue(task.getResultJson().indexOf("B001") < task.getResultJson().indexOf("B002"));
|
||||||
|
// 每次接收 1 次 scope 查询(预取复用,不再重复查询)+ 1 次状态写入;
|
||||||
|
// 第 1 次提交后任务仍 RUNNING,tryFinalizeTask 补偿探针再查 1 次。
|
||||||
|
verify(taskScopeStateMapper, times(3)).selectOne(any());
|
||||||
|
verify(taskScopeStateMapper, times(1)).insert(any(TaskScopeStateEntity.class));
|
||||||
|
verify(taskScopeStateMapper, times(1)).updateById(any(TaskScopeStateEntity.class));
|
||||||
|
verify(taskChunkMapper, never()).selectCount(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_029_chunk_merge_normal_multiple_items() {
|
||||||
|
// 批量场景:乱序 2/3、3/3、1/3,每个接收一次 scope 查询,结果按 chunk_index 稳定合并。
|
||||||
|
givenRunningTask(1202L, 2202L);
|
||||||
|
|
||||||
|
service.submitResult(task.getId(), request(chunk(2, 3, "DE", row("2026-07-25", "B002"))));
|
||||||
|
service.submitResult(task.getId(), request(chunk(3, 3, "UK", row("2026-07-26", "B003"))));
|
||||||
|
service.submitResult(task.getId(), request(chunk(1, 3, "FR", row("2026-07-27", "B001"))));
|
||||||
|
|
||||||
|
assertEquals(3, storedScopes.get(0).getReceivedChunkCount());
|
||||||
|
assertEquals(1, storedScopes.get(0).getCompleted());
|
||||||
|
assertEquals(1, result.getSuccess());
|
||||||
|
String json = task.getResultJson();
|
||||||
|
assertTrue(json.indexOf("B001") < json.indexOf("B002"));
|
||||||
|
assertTrue(json.indexOf("B002") < json.indexOf("B003"));
|
||||||
|
verify(taskScopeStateMapper, times(5)).selectOne(any());
|
||||||
|
verify(taskScopeStateMapper, times(1)).insert(any(TaskScopeStateEntity.class));
|
||||||
|
verify(taskScopeStateMapper, times(2)).updateById(any(TaskScopeStateEntity.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_029_chunk_merge_normal_repeated_operation_is_idempotent() {
|
||||||
|
// 重复执行:同一分片重试命中唯一键,counter 不重复推进;
|
||||||
|
// 每个接收一次 scope 查询 + 一次状态写入,无重复 scope 行。
|
||||||
|
givenRunningTask(1203L, 2203L);
|
||||||
|
ShopDataCrawlSubmitResultRequest request = request(chunk(1, 2, "DE", row("2026-07-25", "B001")));
|
||||||
|
|
||||||
|
service.submitResult(task.getId(), request);
|
||||||
|
service.submitResult(task.getId(), request);
|
||||||
|
|
||||||
|
assertEquals(1, storedScopes.size(), "scope 状态只有一行");
|
||||||
|
assertEquals(1, storedScopes.get(0).getReceivedChunkCount(), "重试不重复计数");
|
||||||
|
assertEquals(1, storedChunks.size());
|
||||||
|
// 两次提交各 1 次 scope 查询,两次提交后任务均 RUNNING,finalize 探针各 1 次。
|
||||||
|
verify(taskScopeStateMapper, times(4)).selectOne(any());
|
||||||
|
verify(taskScopeStateMapper, times(1)).insert(any(TaskScopeStateEntity.class));
|
||||||
|
verify(taskScopeStateMapper, times(1)).updateById(any(TaskScopeStateEntity.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_029_chunk_merge_boundary_empty_input() {
|
||||||
|
// 空输入:无可处理数据的分片在 scope 查询前拒绝,无任何数据库往返与资源创建。
|
||||||
|
givenRunningTask(1204L, 2204L);
|
||||||
|
|
||||||
|
ShopDataCrawlShopPayloadDto emptyItems = legacyChunk(false, "DE", null);
|
||||||
|
emptyItems.setChunkIndex(1);
|
||||||
|
emptyItems.setChunkTotal(1);
|
||||||
|
emptyItems.setCountryResults(List.of(countryWithItems("DE", List.of())));
|
||||||
|
|
||||||
|
BusinessException error = assertThrows(BusinessException.class, () ->
|
||||||
|
service.submitResult(task.getId(), request(emptyItems)));
|
||||||
|
assertTrue(error.getMessage().contains("内容为空"));
|
||||||
|
|
||||||
|
assertEquals(0, storedChunks.size());
|
||||||
|
assertEquals(0, rustfsPayloads.size());
|
||||||
|
assertEquals(0, storedScopes.size());
|
||||||
|
// 空分片在锁内最终化探针之前被拒,不触发任何 scope 查询。
|
||||||
|
verify(taskScopeStateMapper, never()).selectOne(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_029_submit_preserves_previous_countries_across_submissions() {
|
||||||
|
// 回归:客户端先提交 DE(未完结),随后缓存/内存中的累积状态丢失(重启、缓存失效),
|
||||||
|
// 再提交 UK 并标记完结。修复前 mergePayloadIntoSnapshot 整体替换 countryResults,
|
||||||
|
// 且 RUNNING 期间不写 resultJson,DE 在快照中永久丢失;修复后 DE 必须保留。
|
||||||
|
Map<String, ShopDataCrawlShopPayloadDto> mergedByShop = new LinkedHashMap<>();
|
||||||
|
when(taskCacheService.getShopMergedPayload(anyLong(), anyString())).thenAnswer(
|
||||||
|
invocation -> mergedByShop.get(invocation.getArgument(1)));
|
||||||
|
doAnswer(invocation -> {
|
||||||
|
mergedByShop.put(invocation.getArgument(1), invocation.getArgument(2));
|
||||||
|
return null;
|
||||||
|
}).when(taskCacheService).saveShopMergedPayload(anyLong(), anyString(), any(ShopDataCrawlShopPayloadDto.class));
|
||||||
|
|
||||||
|
givenRunningTask(1205L, 2205L);
|
||||||
|
|
||||||
|
// 第一次提交:只带 DE 国家,未完结(生产上每次增量提交 shopDone=false)
|
||||||
|
ShopDataCrawlShopPayloadDto first = legacyChunk(false, "DE", row("2026-07-25", "B001"));
|
||||||
|
service.submitResult(task.getId(), request(first));
|
||||||
|
|
||||||
|
// 模拟重启:Redis/RustFS 中的累积 payload 丢失
|
||||||
|
mergedByShop.clear();
|
||||||
|
|
||||||
|
// 第二次提交:只带 UK 国家,标记完结(生产上收尾包 shopDone=true,不带其他国家)
|
||||||
|
ShopDataCrawlShopPayloadDto second = legacyChunk(true, "UK", row("2026-07-26", "B002"));
|
||||||
|
service.submitResult(task.getId(), request(second));
|
||||||
|
|
||||||
|
assertEquals(1, result.getSuccess(), "任务应收尾成功");
|
||||||
|
String json = task.getResultJson();
|
||||||
|
assertTrue(json.contains("\"DE\""), "前次提交的 DE 国家必须保留: " + json);
|
||||||
|
assertTrue(json.contains("\"UK\""), "后次提交的 UK 国家必须写入: " + json);
|
||||||
|
assertTrue(json.indexOf("B001") < json.indexOf("B002"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_029_chunk_merge_boundary_single_item() {
|
||||||
|
// 单元素:单分片 1/1,一次 scope 查询 + 一次 insert 即齐集完成。
|
||||||
|
givenRunningTask(1205L, 2205L);
|
||||||
|
|
||||||
|
service.submitResult(task.getId(), request(chunk(1, 1, "DE", row("2026-07-25", "B001"))));
|
||||||
|
|
||||||
|
assertEquals(1, storedScopes.get(0).getReceivedChunkCount());
|
||||||
|
assertEquals(1, storedScopes.get(0).getCompleted());
|
||||||
|
assertEquals(1, result.getSuccess());
|
||||||
|
verify(taskScopeStateMapper, times(1)).selectOne(any());
|
||||||
|
verify(taskScopeStateMapper, times(1)).insert(any(TaskScopeStateEntity.class));
|
||||||
|
verify(taskScopeStateMapper, never()).updateById(any(TaskScopeStateEntity.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_029_chunk_merge_boundary_limit_and_overflow() {
|
||||||
|
// 上限/超限:5/5 全量到达 + 齐集前一次重放,每个接收一次 scope 查询;
|
||||||
|
// 重放 counter 不推进、不新增 scope 行,最终收敛到 chunk_total。
|
||||||
|
givenRunningTask(1206L, 2206L);
|
||||||
|
|
||||||
|
service.submitResult(task.getId(), request(chunk(1, 5, "UK", row("2026-07-25", "B001"))));
|
||||||
|
service.submitResult(task.getId(), request(chunk(1, 5, "UK", row("2026-07-25", "B001"))));
|
||||||
|
|
||||||
|
assertEquals(1, storedScopes.get(0).getReceivedChunkCount(), "重放不重复计数");
|
||||||
|
assertEquals(1, storedScopes.size());
|
||||||
|
|
||||||
|
String[] countries = {"UK", "DE", "FR", "ES", "IT"};
|
||||||
|
String[] dates = {"2026-07-25", "2026-07-26", "2026-07-27", "2026-07-28", "2026-07-29"};
|
||||||
|
for (int i = 2; i <= 5; i++) {
|
||||||
|
service.submitResult(task.getId(), request(chunk(i, 5, countries[i - 1], row(dates[i - 1], "B00" + i))));
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals(5, storedScopes.get(0).getReceivedChunkCount());
|
||||||
|
assertEquals(1, storedScopes.get(0).getCompleted());
|
||||||
|
assertEquals(1, result.getSuccess());
|
||||||
|
assertEquals(1, storedScopes.size(), "scope 行始终只有一行");
|
||||||
|
// 6 次接收各 1 次 scope 查询,5 次未齐集的 finalize 探针各 1 次(第 6 次齐集,完成路径不重复探针)。
|
||||||
|
verify(taskScopeStateMapper, times(11)).selectOne(any());
|
||||||
|
verify(taskScopeStateMapper, times(1)).insert(any(TaskScopeStateEntity.class));
|
||||||
|
verify(taskScopeStateMapper, times(5)).updateById(any(TaskScopeStateEntity.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_029_chunk_merge_invalid_input_rejected() {
|
||||||
|
// 非法参数:非正 chunk_index/chunk_total 在 scope 查询前拒绝;
|
||||||
|
// 跨分片改 chunk_total 在预取校验处拒绝,不产生状态写入。
|
||||||
|
givenRunningTask(1207L, 2207L);
|
||||||
|
|
||||||
|
BusinessException zeroIndex = assertThrows(BusinessException.class, () ->
|
||||||
|
service.submitResult(task.getId(), request(chunk(0, 1, "DE", row("2026-07-25", "B001")))));
|
||||||
|
assertTrue(zeroIndex.getMessage().contains("chunk_index"));
|
||||||
|
|
||||||
|
BusinessException zeroTotal = assertThrows(BusinessException.class, () ->
|
||||||
|
service.submitResult(task.getId(), request(chunk(1, 0, "DE", row("2026-07-25", "B001")))));
|
||||||
|
assertTrue(zeroTotal.getMessage().contains("chunk_total"));
|
||||||
|
|
||||||
|
verify(taskScopeStateMapper, never()).selectOne(any());
|
||||||
|
|
||||||
|
service.submitResult(task.getId(), request(chunk(1, 2, "DE", row("2026-07-25", "B001"))));
|
||||||
|
|
||||||
|
BusinessException changedTotal = assertThrows(BusinessException.class, () ->
|
||||||
|
service.submitResult(task.getId(), request(chunk(2, 3, "UK", row("2026-07-26", "B002")))));
|
||||||
|
assertTrue(changedTotal.getMessage().contains("chunk_total"));
|
||||||
|
|
||||||
|
assertEquals(1, storedScopes.get(0).getReceivedChunkCount());
|
||||||
|
assertEquals(1, storedChunks.size());
|
||||||
|
// 3 次查询:chunk(1,2) 预取 + 其 RUNNING finalize 探针 + changedTotal 提交的预取(随后在校验处拒绝)。
|
||||||
|
verify(taskScopeStateMapper, times(3)).selectOne(any());
|
||||||
|
verify(taskScopeStateMapper, times(1)).insert(any(TaskScopeStateEntity.class));
|
||||||
|
verify(taskScopeStateMapper, never()).updateById(any(TaskScopeStateEntity.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_029_chunk_merge_dependency_failure_releases_resources() {
|
||||||
|
// 依赖失败:分片插入异常 → payload 清理、无状态写入,恢复后重试成功;
|
||||||
|
// scope 更新失败 → 回滚本次插入的分片行与 payload,恢复后重试为全新插入并完成。
|
||||||
|
givenRunningTask(1208L, 2208L);
|
||||||
|
|
||||||
|
insertFails = true;
|
||||||
|
assertThrows(RuntimeException.class, () ->
|
||||||
|
service.submitResult(task.getId(), request(chunk(1, 2, "DE", row("2026-07-25", "B001")))));
|
||||||
|
assertEquals(0, storedChunks.size());
|
||||||
|
assertEquals(0, rustfsPayloads.size());
|
||||||
|
assertEquals(0, storedScopes.size(), "插入失败不产生 scope 行");
|
||||||
|
insertFails = false;
|
||||||
|
|
||||||
|
service.submitResult(task.getId(), request(chunk(1, 2, "DE", row("2026-07-25", "B001"))));
|
||||||
|
assertEquals(1, storedScopes.get(0).getReceivedChunkCount());
|
||||||
|
|
||||||
|
scopeUpdateFails = true;
|
||||||
|
assertThrows(RuntimeException.class, () ->
|
||||||
|
service.submitResult(task.getId(), request(chunk(2, 2, "UK", row("2026-07-26", "B002")))));
|
||||||
|
assertEquals(1, storedChunks.size(), "状态写入失败回滚分片行");
|
||||||
|
assertEquals(1, rustfsPayloads.size(), "状态写入失败清理 payload");
|
||||||
|
assertEquals(1, storedScopes.get(0).getReceivedChunkCount(), "状态写入失败计数器未推进");
|
||||||
|
scopeUpdateFails = false;
|
||||||
|
|
||||||
|
service.submitResult(task.getId(), request(chunk(2, 2, "UK", row("2026-07-26", "B002"))));
|
||||||
|
|
||||||
|
assertEquals(1, result.getSuccess(), "恢复后重试成功");
|
||||||
|
assertEquals(2, storedScopes.get(0).getReceivedChunkCount());
|
||||||
|
assertEquals(1, storedScopes.get(0).getCompleted());
|
||||||
|
// 5 次 scope 查询:4 次接收各 1 次(含 1 次插入失败、1 次状态写入失败)+ chunk1 成功后的 finalize 探针。
|
||||||
|
verify(taskScopeStateMapper, times(5)).selectOne(any());
|
||||||
|
verify(taskScopeStateMapper, times(1)).insert(any(TaskScopeStateEntity.class));
|
||||||
|
verify(taskScopeStateMapper, times(2)).updateById(any(TaskScopeStateEntity.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void configureTransientPayloadStorage() {
|
||||||
|
lenient().when(transientPayloadStorageService.isSharedWriteEnabled()).thenReturn(true);
|
||||||
|
lenient().when(transientPayloadStorageService.storeChunkPayloadVersioned(
|
||||||
|
anyString(), anyLong(), anyString(), any(), anyString())).thenAnswer(invocation -> {
|
||||||
|
String pointer = "rustfs:payload-" + (++nextPayloadId);
|
||||||
|
rustfsPayloads.put(pointer, invocation.getArgument(4));
|
||||||
|
return pointer;
|
||||||
|
});
|
||||||
|
lenient().when(transientPayloadStorageService.extractPointer(any())).thenAnswer(invocation -> {
|
||||||
|
String value = invocation.getArgument(0);
|
||||||
|
if (value == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return value.startsWith("rustfs:") || value.startsWith("local:") || value.startsWith("oss:")
|
||||||
|
? value : null;
|
||||||
|
});
|
||||||
|
lenient().when(transientPayloadStorageService.resolvePayload(any(), any())).thenAnswer(invocation -> {
|
||||||
|
String pointer = invocation.getArgument(0);
|
||||||
|
String payload = rustfsPayloads.get(pointer);
|
||||||
|
if (payload == null) {
|
||||||
|
throw new IllegalStateException("missing test RustFS payload: " + pointer);
|
||||||
|
}
|
||||||
|
return payload;
|
||||||
|
});
|
||||||
|
lenient().doAnswer(invocation -> {
|
||||||
|
rustfsPayloads.remove(invocation.getArgument(0));
|
||||||
|
return null;
|
||||||
|
}).when(transientPayloadStorageService).deletePayloadIfPresent(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void configureChunkMapper() {
|
||||||
|
lenient().when(taskChunkMapper.insert(any(TaskChunkEntity.class))).thenAnswer(invocation -> {
|
||||||
|
if (insertFails) {
|
||||||
|
throw new RuntimeException("db down");
|
||||||
|
}
|
||||||
|
TaskChunkEntity chunk = invocation.getArgument(0);
|
||||||
|
boolean duplicate = storedChunks.stream().anyMatch(existing ->
|
||||||
|
Objects.equals(existing.getTaskId(), chunk.getTaskId())
|
||||||
|
&& Objects.equals(existing.getScopeHash(), chunk.getScopeHash())
|
||||||
|
&& Objects.equals(existing.getChunkIndex(), chunk.getChunkIndex()));
|
||||||
|
if (duplicate) {
|
||||||
|
throw new DuplicateKeyException("duplicate chunk key: " + chunk.getScopeHash() + "/" + chunk.getChunkIndex());
|
||||||
|
}
|
||||||
|
chunk.setId((long) storedChunks.size() + 1L);
|
||||||
|
storedChunks.add(chunk);
|
||||||
|
return 1;
|
||||||
|
});
|
||||||
|
lenient().when(taskChunkMapper.selectOne(any())).thenAnswer(invocation -> {
|
||||||
|
LambdaQueryWrapper<TaskChunkEntity> query = invocation.getArgument(0);
|
||||||
|
Long taskId = queryLong(query);
|
||||||
|
String scopeHash = queryScopeHash(query);
|
||||||
|
Integer chunkIndex = queryInteger(query);
|
||||||
|
return storedChunks.stream()
|
||||||
|
.filter(chunk -> matchesChunk(chunk, taskId, scopeHash, chunkIndex))
|
||||||
|
.findFirst()
|
||||||
|
.orElse(null);
|
||||||
|
});
|
||||||
|
lenient().when(taskChunkMapper.selectList(any())).thenAnswer(invocation -> {
|
||||||
|
LambdaQueryWrapper<TaskChunkEntity> query = invocation.getArgument(0);
|
||||||
|
Long taskId = queryLong(query);
|
||||||
|
String scopeHash = queryScopeHash(query);
|
||||||
|
return storedChunks.stream()
|
||||||
|
.filter(chunk -> matchesChunk(chunk, taskId, scopeHash, null))
|
||||||
|
.sorted(Comparator.comparing(TaskChunkEntity::getChunkIndex))
|
||||||
|
.toList();
|
||||||
|
});
|
||||||
|
lenient().when(taskChunkMapper.delete(any())).thenAnswer(invocation -> {
|
||||||
|
LambdaQueryWrapper<TaskChunkEntity> query = invocation.getArgument(0);
|
||||||
|
Long taskId = queryLong(query);
|
||||||
|
String scopeHash = queryScopeHash(query);
|
||||||
|
Integer chunkIndex = queryInteger(query);
|
||||||
|
int before = storedChunks.size();
|
||||||
|
storedChunks.removeIf(chunk -> matchesChunk(chunk, taskId, scopeHash, chunkIndex));
|
||||||
|
return before - storedChunks.size();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void configureScopeMapper() {
|
||||||
|
lenient().when(taskScopeStateMapper.insert(any(TaskScopeStateEntity.class))).thenAnswer(invocation -> {
|
||||||
|
TaskScopeStateEntity scope = invocation.getArgument(0);
|
||||||
|
scope.setId((long) storedScopes.size() + 1L);
|
||||||
|
storedScopes.add(scope);
|
||||||
|
return 1;
|
||||||
|
});
|
||||||
|
lenient().when(taskScopeStateMapper.selectOne(any())).thenAnswer(invocation -> {
|
||||||
|
LambdaQueryWrapper<TaskScopeStateEntity> query = invocation.getArgument(0);
|
||||||
|
Long taskId = queryLong(query);
|
||||||
|
String scopeHash = queryScopeHash(query);
|
||||||
|
return storedScopes.stream()
|
||||||
|
.filter(scope -> Objects.equals(taskId, scope.getTaskId()))
|
||||||
|
.filter(scope -> scopeHash == null || Objects.equals(scopeHash, scope.getScopeHash()))
|
||||||
|
.findFirst()
|
||||||
|
.map(ShopDataCrawlScopeMergeTest::copyScope)
|
||||||
|
.orElse(null);
|
||||||
|
});
|
||||||
|
lenient().when(taskScopeStateMapper.updateById(any(TaskScopeStateEntity.class))).thenAnswer(invocation -> {
|
||||||
|
if (scopeUpdateFails) {
|
||||||
|
throw new RuntimeException("scope update down");
|
||||||
|
}
|
||||||
|
TaskScopeStateEntity updated = invocation.getArgument(0);
|
||||||
|
for (int i = 0; i < storedScopes.size(); i++) {
|
||||||
|
if (Objects.equals(storedScopes.get(i).getId(), updated.getId())) {
|
||||||
|
storedScopes.set(i, copyScope(updated));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 1;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TaskScopeStateEntity copyScope(TaskScopeStateEntity source) {
|
||||||
|
TaskScopeStateEntity copy = new TaskScopeStateEntity();
|
||||||
|
copy.setId(source.getId());
|
||||||
|
copy.setTaskId(source.getTaskId());
|
||||||
|
copy.setModuleType(source.getModuleType());
|
||||||
|
copy.setScopeKey(source.getScopeKey());
|
||||||
|
copy.setScopeHash(source.getScopeHash());
|
||||||
|
copy.setChunkTotal(source.getChunkTotal());
|
||||||
|
copy.setReceivedChunkCount(source.getReceivedChunkCount());
|
||||||
|
copy.setCompleted(source.getCompleted());
|
||||||
|
copy.setLastChunkAt(source.getLastChunkAt());
|
||||||
|
copy.setLastError(source.getLastError());
|
||||||
|
copy.setCreatedAt(source.getCreatedAt());
|
||||||
|
copy.setUpdatedAt(source.getUpdatedAt());
|
||||||
|
return copy;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean matchesChunk(TaskChunkEntity chunk, Long taskId, String scopeHash, Integer chunkIndex) {
|
||||||
|
return (taskId == null || Objects.equals(taskId, chunk.getTaskId()))
|
||||||
|
&& (scopeHash == null || Objects.equals(scopeHash, chunk.getScopeHash()))
|
||||||
|
&& (chunkIndex == null || Objects.equals(chunkIndex, chunk.getChunkIndex()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Long queryLong(LambdaQueryWrapper<?> query) {
|
||||||
|
query.getSqlSegment();
|
||||||
|
return query.getParamNameValuePairs().values().stream()
|
||||||
|
.filter(Long.class::isInstance)
|
||||||
|
.map(Long.class::cast)
|
||||||
|
.findFirst()
|
||||||
|
.orElse(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Integer queryInteger(LambdaQueryWrapper<?> query) {
|
||||||
|
query.getSqlSegment();
|
||||||
|
return query.getParamNameValuePairs().values().stream()
|
||||||
|
.filter(Integer.class::isInstance)
|
||||||
|
.map(Integer.class::cast)
|
||||||
|
.findFirst()
|
||||||
|
.orElse(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String queryScopeHash(LambdaQueryWrapper<?> query) {
|
||||||
|
query.getSqlSegment();
|
||||||
|
return query.getParamNameValuePairs().values().stream()
|
||||||
|
.filter(String.class::isInstance)
|
||||||
|
.map(String.class::cast)
|
||||||
|
.filter(value -> value.length() == 64)
|
||||||
|
.findFirst()
|
||||||
|
.orElse(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void givenRunningTask(long taskId, long resultId) {
|
||||||
|
task = new FileTaskEntity();
|
||||||
|
task.setId(taskId);
|
||||||
|
task.setUserId(7L);
|
||||||
|
task.setModuleType(MODULE_TYPE);
|
||||||
|
task.setStatus("RUNNING");
|
||||||
|
task.setRequestJson("{\"ownerInstanceId\":\"instance-a\"}");
|
||||||
|
task.setResultJson("[]");
|
||||||
|
task.setCreatedAt(LocalDateTime.now());
|
||||||
|
task.setUpdatedAt(LocalDateTime.now());
|
||||||
|
|
||||||
|
result = new FileResultEntity();
|
||||||
|
result.setId(resultId);
|
||||||
|
result.setTaskId(taskId);
|
||||||
|
result.setUserId(7L);
|
||||||
|
result.setModuleType(MODULE_TYPE);
|
||||||
|
result.setSourceFilename(SHOP_NAME);
|
||||||
|
result.setSourceFileUrl("shop-1");
|
||||||
|
result.setSuccess(-1);
|
||||||
|
result.setCreatedAt(LocalDateTime.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
private ShopDataCrawlSubmitResultRequest request(ShopDataCrawlShopPayloadDto payload) {
|
||||||
|
ShopDataCrawlSubmitResultRequest request = new ShopDataCrawlSubmitResultRequest();
|
||||||
|
request.setShops(List.of(payload));
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ShopDataCrawlShopPayloadDto chunk(int chunkIndex,
|
||||||
|
int chunkTotal,
|
||||||
|
String country,
|
||||||
|
ShopDataCrawlRowDto row) {
|
||||||
|
ShopDataCrawlShopPayloadDto payload = legacyChunk(false, country, row);
|
||||||
|
payload.setChunkIndex(chunkIndex);
|
||||||
|
payload.setChunkTotal(chunkTotal);
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ShopDataCrawlShopPayloadDto legacyChunk(boolean shopDone,
|
||||||
|
String country,
|
||||||
|
ShopDataCrawlRowDto row) {
|
||||||
|
ShopDataCrawlShopPayloadDto payload = new ShopDataCrawlShopPayloadDto();
|
||||||
|
payload.setShopName(SHOP_NAME);
|
||||||
|
if (row != null) {
|
||||||
|
payload.setCountryResults(List.of(country(country, row)));
|
||||||
|
}
|
||||||
|
payload.setShopDone(shopDone);
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ShopDataCrawlCountryResultDto country(String country, ShopDataCrawlRowDto row) {
|
||||||
|
return countryWithItems(country, List.of(row));
|
||||||
|
}
|
||||||
|
|
||||||
|
private ShopDataCrawlCountryResultDto countryWithItems(String country, List<ShopDataCrawlRowDto> items) {
|
||||||
|
ShopDataCrawlCountryResultDto result = new ShopDataCrawlCountryResultDto();
|
||||||
|
result.setCountry(country);
|
||||||
|
result.setItems(items);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ShopDataCrawlRowDto row(String date, String asin) {
|
||||||
|
ShopDataCrawlRowDto row = new ShopDataCrawlRowDto();
|
||||||
|
row.setDate(date);
|
||||||
|
row.setAsin(asin);
|
||||||
|
row.setCommodityImage("https://m.media-amazon.com/images/I/" + asin + ".jpg");
|
||||||
|
row.setInventorySales("10");
|
||||||
|
row.setSalesRank("20");
|
||||||
|
row.setPageViews("30");
|
||||||
|
row.setUnitsSold("40");
|
||||||
|
row.setPrice("50");
|
||||||
|
row.setRecommendedOffer("60");
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
}
|
||||||
+301
@@ -0,0 +1,301 @@
|
|||||||
|
package com.nanri.aiimage.modules.shopdatacrawl.service;
|
||||||
|
|
||||||
|
import com.nanri.aiimage.common.exception.BusinessException;
|
||||||
|
import com.nanri.aiimage.modules.shopdatacrawl.model.dto.ShopDataCrawlCountryResultDto;
|
||||||
|
import com.nanri.aiimage.modules.shopdatacrawl.model.dto.ShopDataCrawlRowDto;
|
||||||
|
import com.nanri.aiimage.modules.shopdatacrawl.model.vo.ShopDataCrawlResultItemVo;
|
||||||
|
import com.nanri.aiimage.modules.similarasin.util.SimilarAsinImageEmbedder;
|
||||||
|
import org.apache.poi.openxml4j.util.ZipSecureFile;
|
||||||
|
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
|
||||||
|
import org.junit.jupiter.api.BeforeAll;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
|
||||||
|
import javax.imageio.ImageIO;
|
||||||
|
import java.awt.image.BufferedImage;
|
||||||
|
import java.io.ByteArrayOutputStream;
|
||||||
|
import java.io.File;
|
||||||
|
import java.io.FileInputStream;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Task 25:店铺结果 workbook 的 SXSSF 流式写入路径。
|
||||||
|
* SXSSFWorkbook 与现有"读模板行样式→清空重写"流程不兼容(模板行不可见、
|
||||||
|
* 无法删除),故评估结论为:模板路径保留 XSSFWorkbook,另提供独立 streaming 写入器
|
||||||
|
* writeWorkbookStreaming —— 空构造 SXSSFWorkbook + 自建 5 个国家工作表与表头,
|
||||||
|
* 数据行按 rowAccessWindow spill 到磁盘,写入后 dispose() 释放临时 spill 文件。
|
||||||
|
*/
|
||||||
|
class ShopDataCrawlStreamingWorkbookTest {
|
||||||
|
@TempDir Path tempDir;
|
||||||
|
|
||||||
|
private static final String OK_URL = "https://thumb.example/ok.jpg";
|
||||||
|
private static final String FAIL_URL = "https://thumb.example/fail.jpg";
|
||||||
|
|
||||||
|
@BeforeAll
|
||||||
|
static void relaxZipSecurity() {
|
||||||
|
// SXSSF 写出含大量图片的 workbook 内部条目数超过 POI 5.2.5 默认防护阈值,
|
||||||
|
// 测试读回断言时需要放行(生产读回路径不涉及此规模)。
|
||||||
|
ZipSecureFile.setMaxFileCount(2_000_000L);
|
||||||
|
ZipSecureFile.setMinInflateRatio(0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SimilarAsinImageEmbedder okEmbedder() {
|
||||||
|
SimilarAsinImageEmbedder imageEmbedder = mock(SimilarAsinImageEmbedder.class);
|
||||||
|
when(imageEmbedder.fetchAndResizeForCache(any())).thenAnswer(
|
||||||
|
invocation -> new SimilarAsinImageEmbedder.ResizedImage(jpegBytes(), 2, 2));
|
||||||
|
return imageEmbedder;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_025_workbook_normal_default_path() throws Exception {
|
||||||
|
// 正常输入:1000 行 5 国 streaming 写入,表头/行数/图片完整。
|
||||||
|
SimilarAsinImageEmbedder imageEmbedder = okEmbedder();
|
||||||
|
ShopDataCrawlExcelAssemblyService service =
|
||||||
|
new ShopDataCrawlExcelAssemblyService(imageEmbedder, 64L * 1024 * 1024);
|
||||||
|
List<ShopDataCrawlResultItemVo> items = items(1000, 5, 0);
|
||||||
|
|
||||||
|
File output = tempDir.resolve("streaming.xlsx").toFile();
|
||||||
|
int written = service.writeWorkbookStreaming(output, items, 100);
|
||||||
|
|
||||||
|
assertEquals(1000, written, "返回实际写入行数");
|
||||||
|
try (XSSFWorkbook workbook = new XSSFWorkbook(new FileInputStream(output))) {
|
||||||
|
assertEquals(ShopDataCrawlExcelAssemblyService.SHEETS, sheetNames(workbook));
|
||||||
|
for (int i = 0; i < ShopDataCrawlExcelAssemblyService.SHEETS.size(); i++) {
|
||||||
|
assertEquals(200, workbook.getSheetAt(i).getLastRowNum(), "每国 200 行");
|
||||||
|
assertEquals(ShopDataCrawlExcelAssemblyService.HEADERS.get(0),
|
||||||
|
workbook.getSheetAt(i).getRow(0).getCell(0).getStringCellValue());
|
||||||
|
assertEquals(ShopDataCrawlExcelAssemblyService.HEADERS.get(1),
|
||||||
|
workbook.getSheetAt(i).getRow(0).getCell(1).getStringCellValue());
|
||||||
|
}
|
||||||
|
assertEquals(1000, workbook.getAllPictures().size(), "1000 个唯一 URL 各嵌入一张图");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_025_workbook_normal_multiple_items() throws Exception {
|
||||||
|
// 批量场景:两个任务项行合并写入,行数合计正确、顺序稳定。
|
||||||
|
ShopDataCrawlExcelAssemblyService service =
|
||||||
|
new ShopDataCrawlExcelAssemblyService(okEmbedder(), 64L * 1024 * 1024);
|
||||||
|
List<ShopDataCrawlResultItemVo> all = new ArrayList<>(items(600, 5, 0));
|
||||||
|
all.addAll(items(400, 5, 0));
|
||||||
|
|
||||||
|
File output = tempDir.resolve("streaming-multi.xlsx").toFile();
|
||||||
|
int written = service.writeWorkbookStreaming(output, all, 100);
|
||||||
|
assertEquals(1000, written);
|
||||||
|
try (XSSFWorkbook workbook = new XSSFWorkbook(new FileInputStream(output))) {
|
||||||
|
int total = 0;
|
||||||
|
for (int i = 0; i < 5; i++) {
|
||||||
|
total += workbook.getSheetAt(i).getLastRowNum();
|
||||||
|
}
|
||||||
|
assertEquals(1000, total, "行数合计不丢失");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_025_workbook_normal_repeated_operation_is_idempotent() throws Exception {
|
||||||
|
// 重复执行:同一输入两次 streaming 写出,行数/图片数一致。
|
||||||
|
ShopDataCrawlExcelAssemblyService service =
|
||||||
|
new ShopDataCrawlExcelAssemblyService(okEmbedder(), 64L * 1024 * 1024);
|
||||||
|
List<ShopDataCrawlResultItemVo> items = items(500, 5, 0);
|
||||||
|
File firstOut = tempDir.resolve("streaming-idem-1.xlsx").toFile();
|
||||||
|
File secondOut = tempDir.resolve("streaming-idem-2.xlsx").toFile();
|
||||||
|
|
||||||
|
int first = service.writeWorkbookStreaming(firstOut, items, 100);
|
||||||
|
int second = service.writeWorkbookStreaming(secondOut, items, 100);
|
||||||
|
assertEquals(first, second, "重复写入行数一致");
|
||||||
|
try (XSSFWorkbook wb1 = new XSSFWorkbook(new FileInputStream(firstOut));
|
||||||
|
XSSFWorkbook wb2 = new XSSFWorkbook(new FileInputStream(secondOut))) {
|
||||||
|
assertEquals(wb1.getAllPictures().size(), wb2.getAllPictures().size(), "图片数一致");
|
||||||
|
assertEquals(wb1.getSheet("英国").getLastRowNum(), wb2.getSheet("英国").getLastRowNum());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_025_workbook_boundary_empty_input() throws Exception {
|
||||||
|
// 空输入:0 行写入只产出表头,无图片,返回 0。
|
||||||
|
ShopDataCrawlExcelAssemblyService service =
|
||||||
|
new ShopDataCrawlExcelAssemblyService(okEmbedder(), 64L * 1024 * 1024);
|
||||||
|
File output = tempDir.resolve("streaming-empty.xlsx").toFile();
|
||||||
|
int written = service.writeWorkbookStreaming(output, List.of(), 100);
|
||||||
|
|
||||||
|
assertEquals(0, written);
|
||||||
|
try (XSSFWorkbook workbook = new XSSFWorkbook(new FileInputStream(output))) {
|
||||||
|
for (int i = 0; i < 5; i++) {
|
||||||
|
assertEquals(0, workbook.getSheetAt(i).getLastRowNum(), "只有表头行");
|
||||||
|
}
|
||||||
|
assertEquals(0, workbook.getAllPictures().size());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_025_workbook_boundary_single_item() throws Exception {
|
||||||
|
// 单元素:1 行 1 国写入,不依赖批量路径,图片嵌入。
|
||||||
|
ShopDataCrawlExcelAssemblyService service =
|
||||||
|
new ShopDataCrawlExcelAssemblyService(okEmbedder(), 64L * 1024 * 1024);
|
||||||
|
List<ShopDataCrawlResultItemVo> items = items(1, 1, 0);
|
||||||
|
File output = tempDir.resolve("streaming-single.xlsx").toFile();
|
||||||
|
int written = service.writeWorkbookStreaming(output, items, 100);
|
||||||
|
|
||||||
|
assertEquals(1, written);
|
||||||
|
try (XSSFWorkbook workbook = new XSSFWorkbook(new FileInputStream(output))) {
|
||||||
|
assertEquals(1, workbook.getSheet("英国").getLastRowNum());
|
||||||
|
assertEquals("B000000000", workbook.getSheet("英国").getRow(1).getCell(1).getStringCellValue());
|
||||||
|
assertEquals(1, workbook.getAllPictures().size());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_025_workbook_boundary_limit_and_overflow() throws Exception {
|
||||||
|
// 上限/超限:5000 行大文件在 window=100 下 spill 写入,行数完整不丢失;
|
||||||
|
// 图片下载失败的行兜底为 URL 文本,不阻塞写入。
|
||||||
|
SimilarAsinImageEmbedder imageEmbedder = mock(SimilarAsinImageEmbedder.class);
|
||||||
|
when(imageEmbedder.fetchAndResizeForCache(any())).thenAnswer(invocation -> {
|
||||||
|
String url = invocation.getArgument(0);
|
||||||
|
if (url.contains("fail")) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return new SimilarAsinImageEmbedder.ResizedImage(jpegBytes(), 2, 2);
|
||||||
|
});
|
||||||
|
ShopDataCrawlExcelAssemblyService service =
|
||||||
|
new ShopDataCrawlExcelAssemblyService(imageEmbedder, 64L * 1024 * 1024);
|
||||||
|
|
||||||
|
List<ShopDataCrawlResultItemVo> items = items(5000, 5, 0);
|
||||||
|
for (int i = 0; i < 5000; i += 2) {
|
||||||
|
setImage(items, i, FAIL_URL);
|
||||||
|
}
|
||||||
|
File output = tempDir.resolve("streaming-5000.xlsx").toFile();
|
||||||
|
long start = System.currentTimeMillis();
|
||||||
|
int written = service.writeWorkbookStreaming(output, items, 100);
|
||||||
|
long elapsed = System.currentTimeMillis() - start;
|
||||||
|
|
||||||
|
assertEquals(5000, written, "5000 行 spill 写入不丢失");
|
||||||
|
assertTrue(elapsed < 60_000, "5000 行写入须在预算内完成,实际=" + elapsed + "ms");
|
||||||
|
try (XSSFWorkbook workbook = new XSSFWorkbook(new FileInputStream(output))) {
|
||||||
|
int total = 0;
|
||||||
|
for (int i = 0; i < 5; i++) {
|
||||||
|
total += workbook.getSheetAt(i).getLastRowNum();
|
||||||
|
}
|
||||||
|
assertEquals(5000, total, "读回行数完整");
|
||||||
|
assertEquals(2500, workbook.getAllPictures().size(), "2500 行成功图片嵌入,失败行兜底");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_025_workbook_invalid_input_rejected() throws Exception {
|
||||||
|
// 非法参数:null 输出/空输出目录/非正 window/null items → 明确异常。
|
||||||
|
ShopDataCrawlExcelAssemblyService service =
|
||||||
|
new ShopDataCrawlExcelAssemblyService(okEmbedder(), 64L * 1024 * 1024);
|
||||||
|
List<ShopDataCrawlResultItemVo> items = items(10, 1, 0);
|
||||||
|
File output = tempDir.resolve("streaming-invalid.xlsx").toFile();
|
||||||
|
|
||||||
|
assertThrows(BusinessException.class, () -> service.writeWorkbookStreaming(null, items, 100));
|
||||||
|
assertThrows(BusinessException.class,
|
||||||
|
() -> service.writeWorkbookStreaming(tempDir.resolve("no-dir").resolve("x.xlsx").toFile(), items, 100));
|
||||||
|
assertThrows(IllegalArgumentException.class,
|
||||||
|
() -> service.writeWorkbookStreaming(output, items, 0));
|
||||||
|
assertThrows(IllegalArgumentException.class,
|
||||||
|
() -> service.writeWorkbookStreaming(output, items, -5));
|
||||||
|
assertThrows(IllegalArgumentException.class,
|
||||||
|
() -> service.writeWorkbookStreaming(output, null, 100));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_025_workbook_dependency_failure_releases_resources() throws Exception {
|
||||||
|
// 依赖失败:图片下载抛异常 → 单行兜底 URL 文本不中断整表;
|
||||||
|
// 写入失败(目标不可写)抛 BusinessException,重复调用稳定(spill 文件可重建)。
|
||||||
|
SimilarAsinImageEmbedder imageEmbedder = mock(SimilarAsinImageEmbedder.class);
|
||||||
|
when(imageEmbedder.fetchAndResizeForCache(any())).thenThrow(
|
||||||
|
new RuntimeException("image service down"));
|
||||||
|
ShopDataCrawlExcelAssemblyService service =
|
||||||
|
new ShopDataCrawlExcelAssemblyService(imageEmbedder, 64L * 1024 * 1024);
|
||||||
|
|
||||||
|
List<ShopDataCrawlResultItemVo> items = items(100, 1, 0);
|
||||||
|
File output = tempDir.resolve("streaming-fail.xlsx").toFile();
|
||||||
|
int written = service.writeWorkbookStreaming(output, items, 100);
|
||||||
|
assertEquals(100, written, "图片失败不阻塞行写入");
|
||||||
|
try (XSSFWorkbook workbook = new XSSFWorkbook(new FileInputStream(output))) {
|
||||||
|
assertEquals(100, workbook.getSheet("英国").getLastRowNum());
|
||||||
|
assertEquals(0, workbook.getAllPictures().size(), "无图片嵌入");
|
||||||
|
assertEquals(OK_URL + "?i=0",
|
||||||
|
workbook.getSheet("英国").getRow(1).getCell(2).getStringCellValue(), "兜底 URL 文本");
|
||||||
|
}
|
||||||
|
|
||||||
|
File locked = tempDir.resolve("locked").toFile();
|
||||||
|
assertTrue(locked.mkdir(), "创建目录占位模拟不可写目标");
|
||||||
|
assertThrows(BusinessException.class,
|
||||||
|
() -> service.writeWorkbookStreaming(locked, items, 100));
|
||||||
|
|
||||||
|
File retried = tempDir.resolve("streaming-retry.xlsx").toFile();
|
||||||
|
assertEquals(100, service.writeWorkbookStreaming(retried, items, 100), "失败后重试成功,spill 可重建");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void setImage(List<ShopDataCrawlResultItemVo> items, int index, String url) {
|
||||||
|
int i = 0;
|
||||||
|
for (ShopDataCrawlResultItemVo item : items) {
|
||||||
|
for (ShopDataCrawlCountryResultDto country : item.getCountryResults()) {
|
||||||
|
for (ShopDataCrawlRowDto row : country.getItems()) {
|
||||||
|
if (i == index) {
|
||||||
|
row.setCommodityImage(url);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<ShopDataCrawlResultItemVo> items(int rowCount, int countryCount, int failEvery) {
|
||||||
|
List<ShopDataCrawlResultItemVo> items = new ArrayList<>();
|
||||||
|
ShopDataCrawlResultItemVo item = new ShopDataCrawlResultItemVo();
|
||||||
|
item.setSuccess(true);
|
||||||
|
List<ShopDataCrawlCountryResultDto> countryResults = new ArrayList<>();
|
||||||
|
List<String> countries = ShopDataCrawlExcelAssemblyService.COUNTRIES.subList(0, countryCount);
|
||||||
|
for (String country : countries) {
|
||||||
|
ShopDataCrawlCountryResultDto countryResult = new ShopDataCrawlCountryResultDto();
|
||||||
|
countryResult.setCountry(country);
|
||||||
|
countryResult.setItems(new ArrayList<>());
|
||||||
|
countryResults.add(countryResult);
|
||||||
|
}
|
||||||
|
for (int i = 0; i < rowCount; i++) {
|
||||||
|
ShopDataCrawlRowDto row = new ShopDataCrawlRowDto();
|
||||||
|
row.setDate("2026-07-25");
|
||||||
|
row.setAsin("B0" + String.format("%08d", i));
|
||||||
|
row.setBrand("Brand");
|
||||||
|
row.setCommodityImage(OK_URL + "?i=" + i);
|
||||||
|
row.setInventorySales("11");
|
||||||
|
row.setSalesRank("22");
|
||||||
|
row.setPageViews("33");
|
||||||
|
row.setUnitsSold("44");
|
||||||
|
row.setPrice("12.50");
|
||||||
|
row.setRecommendedOffer("12.00");
|
||||||
|
countryResults.get(i % countryCount).getItems().add(row);
|
||||||
|
}
|
||||||
|
item.setCountryResults(countryResults);
|
||||||
|
items.add(item);
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<String> sheetNames(XSSFWorkbook workbook) {
|
||||||
|
List<String> names = new ArrayList<>();
|
||||||
|
for (int i = 0; i < workbook.getNumberOfSheets(); i++) {
|
||||||
|
names.add(workbook.getSheetAt(i).getSheetName());
|
||||||
|
}
|
||||||
|
return names;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] jpegBytes() throws Exception {
|
||||||
|
BufferedImage image = new BufferedImage(2, 2, BufferedImage.TYPE_INT_RGB);
|
||||||
|
ByteArrayOutputStream output = new ByteArrayOutputStream();
|
||||||
|
ImageIO.write(image, "jpg", output);
|
||||||
|
return output.toByteArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
+191
@@ -0,0 +1,191 @@
|
|||||||
|
package com.nanri.aiimage.modules.shopdatacrawl.service;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.nanri.aiimage.config.TaskPressureProperties;
|
||||||
|
import com.nanri.aiimage.modules.shopdatacrawl.model.dto.ShopDataCrawlShopPayloadDto;
|
||||||
|
import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity;
|
||||||
|
import com.nanri.aiimage.modules.task.service.TaskScopePayloadStorageService;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.MockitoAnnotations;
|
||||||
|
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||||
|
import org.springframework.data.redis.core.ValueOperations;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.Mockito.lenient;
|
||||||
|
import static org.mockito.Mockito.never;
|
||||||
|
import static org.mockito.Mockito.times;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Task 32:将 task entity 本地缓存替换为有容量和过期回收的实现。
|
||||||
|
* 原 taskEntityLocalCache 是无界 ConcurrentHashMap,只随 deleteTaskCache 清理;
|
||||||
|
* 实现改为有容量上限(localTaskEntityCacheCapacity,默认 512)的本地缓存:
|
||||||
|
* 插入时按时间戳 LRU 淘汰最旧条目,读取时回收过期条目,保证内存有界。
|
||||||
|
*/
|
||||||
|
class ShopDataCrawlTaskCacheServiceTest {
|
||||||
|
|
||||||
|
@Mock private StringRedisTemplate stringRedisTemplate;
|
||||||
|
@Mock private TaskScopePayloadStorageService taskScopePayloadStorageService;
|
||||||
|
@Mock private ValueOperations<String, String> valueOperations;
|
||||||
|
|
||||||
|
private TaskPressureProperties properties;
|
||||||
|
private ObjectMapper objectMapper;
|
||||||
|
private ShopDataCrawlTaskCacheService cacheService;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
MockitoAnnotations.openMocks(this);
|
||||||
|
properties = new TaskPressureProperties();
|
||||||
|
properties.setLocalTaskEntityCacheMillis(3000);
|
||||||
|
properties.setLocalTaskEntityCacheCapacity(4);
|
||||||
|
objectMapper = new ObjectMapper().findAndRegisterModules();
|
||||||
|
cacheService = new ShopDataCrawlTaskCacheService(
|
||||||
|
stringRedisTemplate,
|
||||||
|
objectMapper,
|
||||||
|
properties,
|
||||||
|
taskScopePayloadStorageService);
|
||||||
|
|
||||||
|
lenient().when(stringRedisTemplate.opsForValue()).thenReturn(valueOperations);
|
||||||
|
lenient().when(valueOperations.multiGet(any())).thenReturn(List.of());
|
||||||
|
lenient().when(valueOperations.get(anyString())).thenReturn(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_032_cache_normal_default_path() {
|
||||||
|
// 正常路径:缓存保存后立即可读,命中返回相同内容且不回落 Redis/DB。
|
||||||
|
FileTaskEntity task = task(101L, "RUNNING");
|
||||||
|
cacheService.saveTaskCache(task);
|
||||||
|
|
||||||
|
Map<Long, FileTaskEntity> cached = cacheService.getTaskCacheBatch(List.of(101L));
|
||||||
|
assertEquals(1, cached.size());
|
||||||
|
assertEquals("RUNNING", cached.get(101L).getStatus());
|
||||||
|
verify(valueOperations, never()).multiGet(any());
|
||||||
|
assertEquals(1, cacheService.localCacheSize());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_032_cache_normal_multiple_items() {
|
||||||
|
// 批量场景:多个任务缓存互不丢失,批量读取全部命中,顺序稳定。
|
||||||
|
for (long id = 201L; id <= 206L; id++) {
|
||||||
|
cacheService.saveTaskCache(task(id, "RUNNING"));
|
||||||
|
}
|
||||||
|
// 容量 4,保存 6 个后本地只保留最近 4 个(按时间戳淘汰最旧)。
|
||||||
|
assertEquals(4, cacheService.localCacheSize());
|
||||||
|
|
||||||
|
Map<Long, FileTaskEntity> cached = cacheService.getTaskCacheBatch(List.of(205L, 206L, 203L));
|
||||||
|
assertEquals(3, cached.size(), "仍在本地缓存的条目全部命中");
|
||||||
|
assertEquals("RUNNING", cached.get(205L).getStatus());
|
||||||
|
assertEquals("RUNNING", cached.get(206L).getStatus());
|
||||||
|
assertEquals("RUNNING", cached.get(203L).getStatus());
|
||||||
|
// 被淘汰的最旧条目(201、202)不产生本地命中。
|
||||||
|
assertTrue(cacheService.getTaskCacheBatch(List.of(201L)).isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_032_cache_normal_repeated_operation_is_idempotent() {
|
||||||
|
// 幂等:重复保存同一任务不放大缓存条目,内容为最新。
|
||||||
|
FileTaskEntity task = task(301L, "RUNNING");
|
||||||
|
cacheService.saveTaskCache(task);
|
||||||
|
FileTaskEntity updated = task(301L, "SUCCESS");
|
||||||
|
cacheService.saveTaskCache(updated);
|
||||||
|
FileTaskEntity again = task(301L, "SUCCESS");
|
||||||
|
cacheService.saveTaskCache(again);
|
||||||
|
|
||||||
|
assertEquals(1, cacheService.localCacheSize(), "同一任务重复保存只占一条");
|
||||||
|
Map<Long, FileTaskEntity> cached = cacheService.getTaskCacheBatch(List.of(301L));
|
||||||
|
assertEquals("SUCCESS", cached.get(301L).getStatus(), "内容为最新");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_032_cache_boundary_empty_input() {
|
||||||
|
// 空输入:空列表/空任务安全返回,不创建缓存条目、不访问 Redis。
|
||||||
|
assertTrue(cacheService.getTaskCacheBatch(List.of()).isEmpty());
|
||||||
|
cacheService.saveTaskCache(null);
|
||||||
|
cacheService.saveTaskCache(taskWithoutId());
|
||||||
|
assertEquals(0, cacheService.localCacheSize());
|
||||||
|
verify(stringRedisTemplate, never()).opsForValue();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_032_cache_boundary_single_item() {
|
||||||
|
// 单元素:单任务缓存命中,不依赖批量路径;删除后条目释放。
|
||||||
|
FileTaskEntity task = task(501L, "RUNNING");
|
||||||
|
cacheService.saveTaskCache(task);
|
||||||
|
Map<Long, FileTaskEntity> cached = cacheService.getTaskCacheBatch(List.of(501L));
|
||||||
|
assertEquals(1, cached.size());
|
||||||
|
|
||||||
|
cacheService.deleteTaskCache(501L);
|
||||||
|
assertEquals(0, cacheService.localCacheSize(), "删除释放本地条目");
|
||||||
|
assertTrue(cacheService.getTaskCacheBatch(List.of(501L)).isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_032_cache_boundary_limit_and_overflow() {
|
||||||
|
// 上限/超限:容量 4 保存 100 个任务,本地条目不超过容量,最近条目仍在。
|
||||||
|
for (long id = 601L; id <= 700L; id++) {
|
||||||
|
cacheService.saveTaskCache(task(id, "RUNNING"));
|
||||||
|
}
|
||||||
|
assertEquals(4, cacheService.localCacheSize(), "超量保存后本地条目不超过容量上限");
|
||||||
|
Map<Long, FileTaskEntity> cached = cacheService.getTaskCacheBatch(List.of(697L, 698L, 699L, 700L));
|
||||||
|
assertEquals(4, cached.size(), "最近保存的条目全部命中");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_032_cache_invalid_input_rejected() {
|
||||||
|
// 非法参数:非正 taskId 的批量读取/删除安全返回,不访问 Redis 或存储。
|
||||||
|
assertTrue(cacheService.getTaskCacheBatch(List.of(0L, -5L)).isEmpty());
|
||||||
|
cacheService.deleteTaskCache(null);
|
||||||
|
cacheService.deleteTaskCache(0L);
|
||||||
|
verify(taskScopePayloadStorageService, never()).deleteTaskScopePayloads(any(), any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_032_cache_dependency_failure_releases_resources() {
|
||||||
|
// 依赖失败:Redis 异常时本地缓存降级可用(不抛异常),条目可回收;
|
||||||
|
// 删除时 Redis 异常不影响本地条目释放。
|
||||||
|
FileTaskEntity task = task(801L, "RUNNING");
|
||||||
|
cacheService.saveTaskCache(task);
|
||||||
|
assertEquals(1, cacheService.localCacheSize());
|
||||||
|
|
||||||
|
Map<Long, FileTaskEntity> cached = cacheService.getTaskCacheBatch(List.of(801L));
|
||||||
|
assertEquals(1, cached.size(), "本地命中不回落 Redis");
|
||||||
|
verify(valueOperations, never()).get(anyString());
|
||||||
|
|
||||||
|
// 本地条目被淘汰后读取回落 Redis,Redis 异常时降级返回空而不抛错。
|
||||||
|
cacheService.deleteTaskCache(801L);
|
||||||
|
lenient().when(valueOperations.get(anyString())).thenThrow(new RuntimeException("redis down"));
|
||||||
|
assertTrue(cacheService.getTaskCacheBatch(List.of(801L)).isEmpty(), "Redis 异常降级为空,不抛异常");
|
||||||
|
|
||||||
|
cacheService.deleteTaskCache(801L);
|
||||||
|
assertEquals(0, cacheService.localCacheSize(), "删除仍释放本地条目");
|
||||||
|
verify(taskScopePayloadStorageService, times(2)).deleteTaskScopePayloads(eq(801L), any());
|
||||||
|
}
|
||||||
|
|
||||||
|
private FileTaskEntity task(long id, String status) {
|
||||||
|
FileTaskEntity task = new FileTaskEntity();
|
||||||
|
task.setId(id);
|
||||||
|
task.setModuleType("SHOP_DATA_CRAWL");
|
||||||
|
task.setStatus(status);
|
||||||
|
task.setCreatedAt(LocalDateTime.now());
|
||||||
|
task.setUpdatedAt(LocalDateTime.now());
|
||||||
|
return task;
|
||||||
|
}
|
||||||
|
|
||||||
|
private FileTaskEntity taskWithoutId() {
|
||||||
|
FileTaskEntity task = new FileTaskEntity();
|
||||||
|
task.setModuleType("SHOP_DATA_CRAWL");
|
||||||
|
task.setStatus("RUNNING");
|
||||||
|
return task;
|
||||||
|
}
|
||||||
|
}
|
||||||
+14
-1
@@ -35,6 +35,7 @@ import org.junit.jupiter.api.extension.ExtendWith;
|
|||||||
import org.mockito.Mock;
|
import org.mockito.Mock;
|
||||||
import org.mockito.Spy;
|
import org.mockito.Spy;
|
||||||
import org.mockito.junit.jupiter.MockitoExtension;
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
import org.springframework.dao.DuplicateKeyException;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
@@ -213,7 +214,10 @@ class ShopDataCrawlTaskServiceChunkTest {
|
|||||||
|
|
||||||
assertEquals(1, storedChunks.size());
|
assertEquals(1, storedChunks.size());
|
||||||
assertEquals(1, rustfsPayloads.size());
|
assertEquals(1, rustfsPayloads.size());
|
||||||
verify(taskChunkMapper, times(1)).insert(any(TaskChunkEntity.class));
|
// 原子插入路径:首次 insert 成功,重试命中唯一索引 uk_task_scope_chunk 抛键冲突被吸收;
|
||||||
|
// 重试重存的 payload 立即清理,不产生重复分片。
|
||||||
|
verify(taskChunkMapper, times(2)).insert(any(TaskChunkEntity.class));
|
||||||
|
verify(transientPayloadStorageService).deletePayloadIfPresent("rustfs:payload-2");
|
||||||
assertEquals(-1, result.getSuccess());
|
assertEquals(-1, result.getSuccess());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -388,6 +392,15 @@ class ShopDataCrawlTaskServiceChunkTest {
|
|||||||
private void configureChunkMapper() {
|
private void configureChunkMapper() {
|
||||||
lenient().when(taskChunkMapper.insert(any(TaskChunkEntity.class))).thenAnswer(invocation -> {
|
lenient().when(taskChunkMapper.insert(any(TaskChunkEntity.class))).thenAnswer(invocation -> {
|
||||||
TaskChunkEntity chunk = invocation.getArgument(0);
|
TaskChunkEntity chunk = invocation.getArgument(0);
|
||||||
|
// 与 V30 的 uk_task_scope_chunk 唯一索引一致:同 (task_id, scope_hash, chunk_index) 重复插入抛键冲突。
|
||||||
|
boolean duplicate = storedChunks.stream().anyMatch(existing ->
|
||||||
|
Objects.equals(existing.getTaskId(), chunk.getTaskId())
|
||||||
|
&& Objects.equals(existing.getScopeHash(), chunk.getScopeHash())
|
||||||
|
&& Objects.equals(existing.getChunkIndex(), chunk.getChunkIndex()));
|
||||||
|
if (duplicate) {
|
||||||
|
throw new DuplicateKeyException("duplicate chunk key: "
|
||||||
|
+ chunk.getScopeHash() + "/" + chunk.getChunkIndex());
|
||||||
|
}
|
||||||
chunk.setId((long) storedChunks.size() + 1L);
|
chunk.setId((long) storedChunks.size() + 1L);
|
||||||
storedChunks.add(chunk);
|
storedChunks.add(chunk);
|
||||||
return 1;
|
return 1;
|
||||||
|
|||||||
+36
-21
@@ -132,7 +132,7 @@ class ShopDataCrawlTaskServiceRetentionTest {
|
|||||||
when(dailyFileService.findMembersByResultId(RESULT_ID)).thenReturn(List.of());
|
when(dailyFileService.findMembersByResultId(RESULT_ID)).thenReturn(List.of());
|
||||||
when(dailyFileService.findOlder(USER_ID, "hash-1", BUSINESS_DATE)).thenReturn(List.of());
|
when(dailyFileService.findOlder(USER_ID, "hash-1", BUSINESS_DATE)).thenReturn(List.of());
|
||||||
when(dailyFileService.countObjectReferences(anyString())).thenReturn(0L);
|
when(dailyFileService.countObjectReferences(anyString())).thenReturn(0L);
|
||||||
when(dailyFileService.addMember(anyLong(), anyLong(), anyLong())).thenReturn(true);
|
when(dailyFileService.addMemberWithPayload(anyLong(), anyLong(), anyLong(), anyString())).thenReturn(true);
|
||||||
when(transactionManager.getTransaction(any())).thenReturn(transactionStatus);
|
when(transactionManager.getTransaction(any())).thenReturn(transactionStatus);
|
||||||
doAnswer(invocation -> {
|
doAnswer(invocation -> {
|
||||||
ShopDataCrawlDailyFileEntity entity = invocation.getArgument(0);
|
ShopDataCrawlDailyFileEntity entity = invocation.getArgument(0);
|
||||||
@@ -146,12 +146,13 @@ class ShopDataCrawlTaskServiceRetentionTest {
|
|||||||
when(fileResultMapper.selectList(any())).thenReturn(List.of(currentRow), List.of(currentRow));
|
when(fileResultMapper.selectList(any())).thenReturn(List.of(currentRow), List.of(currentRow));
|
||||||
when(dailyFileService.findForUpdate(USER_ID, "hash-1", BUSINESS_DATE)).thenReturn(null);
|
when(dailyFileService.findForUpdate(USER_ID, "hash-1", BUSINESS_DATE)).thenReturn(null);
|
||||||
when(ossStorageService.uploadResultFile(any(), eq(MODULE_TYPE))).thenReturn("result/new.xlsx");
|
when(ossStorageService.uploadResultFile(any(), eq(MODULE_TYPE))).thenReturn("result/new.xlsx");
|
||||||
|
when(excelAssemblyService.writeWorkbook(any(), any())).thenReturn(1);
|
||||||
|
|
||||||
service.processResultFileJob(job);
|
service.processResultFileJob(job);
|
||||||
|
|
||||||
verify(excelAssemblyService).writeWorkbook(any(), eq(List.of(snapshot)));
|
verify(excelAssemblyService).writeWorkbook(any(), eq(List.of(snapshot)));
|
||||||
verify(excelAssemblyService, never()).replaceCountriesWorkbook(any(), any(), any());
|
verify(excelAssemblyService, never()).replaceCountriesWorkbook(any(), any(), any());
|
||||||
verify(dailyFileService).addMember(301L, TASK_ID, RESULT_ID);
|
verify(dailyFileService).addMemberWithPayload(eq(301L), eq(TASK_ID), eq(RESULT_ID), anyString());
|
||||||
assertEquals("result/new.xlsx", currentRow.getResultFileUrl());
|
assertEquals("result/new.xlsx", currentRow.getResultFileUrl());
|
||||||
assertEquals(1, currentRow.getRowCount());
|
assertEquals(1, currentRow.getRowCount());
|
||||||
|
|
||||||
@@ -169,22 +170,31 @@ class ShopDataCrawlTaskServiceRetentionTest {
|
|||||||
FileTaskEntity previousTask = task();
|
FileTaskEntity previousTask = task();
|
||||||
previousTask.setId(100L);
|
previousTask.setId(100L);
|
||||||
ShopDataCrawlDailyFileEntity daily = daily("result/old.xlsx", 2);
|
ShopDataCrawlDailyFileEntity daily = daily("result/old.xlsx", 2);
|
||||||
|
ShopDataCrawlResultItemVo previousSnapshot = snapshot(200L, 100L);
|
||||||
|
ShopDataCrawlDailyMemberEntity previousMember = member(301L, 100L, 200L, BUSINESS_TIME.minusMinutes(10));
|
||||||
|
previousMember.setRowPayload("{\"resultId\":200,\"taskId\":100,\"shopName\":\"Demo Shop\","
|
||||||
|
+ "\"shopId\":\"shop-1\",\"success\":true,\"countryResults\":[]}");
|
||||||
when(fileResultMapper.selectList(any())).thenReturn(List.of(currentRow), List.of(previous, currentRow));
|
when(fileResultMapper.selectList(any())).thenReturn(List.of(currentRow), List.of(previous, currentRow));
|
||||||
when(fileTaskMapper.selectBatchIds(List.of(100L))).thenReturn(List.of(previousTask));
|
when(fileTaskMapper.selectBatchIds(List.of(100L))).thenReturn(List.of(previousTask));
|
||||||
when(dailyFileService.findForUpdate(USER_ID, "hash-1", BUSINESS_DATE)).thenReturn(daily);
|
when(dailyFileService.findForUpdate(USER_ID, "hash-1", BUSINESS_DATE)).thenReturn(daily);
|
||||||
when(dailyFileService.containsResult(301L, RESULT_ID)).thenReturn(false);
|
when(dailyFileService.containsResult(301L, RESULT_ID)).thenReturn(false);
|
||||||
when(ossStorageService.readObjectBytes("result/old.xlsx")).thenReturn(new byte[]{1, 2, 3});
|
when(dailyFileService.listMembers(301L)).thenReturn(List.of(previousMember));
|
||||||
|
when(taskResultItemService.getResultSnapshot(
|
||||||
|
100L, MODULE_TYPE, 200L, ShopDataCrawlResultItemVo.class)).thenReturn(previousSnapshot);
|
||||||
when(ossStorageService.uploadResultFile(any(), eq(MODULE_TYPE))).thenReturn("result/new.xlsx");
|
when(ossStorageService.uploadResultFile(any(), eq(MODULE_TYPE))).thenReturn("result/new.xlsx");
|
||||||
when(excelAssemblyService.replaceCountriesWorkbook(any(), any(), any())).thenReturn(3);
|
when(excelAssemblyService.writeWorkbook(any(), any())).thenReturn(2);
|
||||||
|
|
||||||
service.processResultFileJob(job);
|
service.processResultFileJob(job);
|
||||||
|
|
||||||
verify(excelAssemblyService).replaceCountriesWorkbook(any(), any(), eq(List.of(snapshot)));
|
// Task 35:整表从数据层成员行(row_payload + 本次快照)累积重建,不再读回旧对象。
|
||||||
|
verify(excelAssemblyService).writeWorkbook(any(), eq(List.of(previousSnapshot, snapshot)));
|
||||||
|
verify(excelAssemblyService, never()).replaceCountriesWorkbook(any(), any(), any());
|
||||||
|
verify(ossStorageService, never()).readObjectBytes(anyString());
|
||||||
verify(dailyFileService).update(daily);
|
verify(dailyFileService).update(daily);
|
||||||
verify(ossStorageService).deleteObject("result/old.xlsx");
|
verify(ossStorageService).deleteObject("result/old.xlsx");
|
||||||
assertNull(previous.getResultFileUrl());
|
assertNull(previous.getResultFileUrl());
|
||||||
assertEquals("result/new.xlsx", currentRow.getResultFileUrl());
|
assertEquals("result/new.xlsx", currentRow.getResultFileUrl());
|
||||||
assertEquals(3, currentRow.getRowCount());
|
assertEquals(2, currentRow.getRowCount());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -212,13 +222,13 @@ class ShopDataCrawlTaskServiceRetentionTest {
|
|||||||
return 1;
|
return 1;
|
||||||
}).when(excelAssemblyService).countRows(any());
|
}).when(excelAssemblyService).countRows(any());
|
||||||
doAnswer(invocation -> {
|
doAnswer(invocation -> {
|
||||||
assertFalse(transactionActive.get(), "workbook download must run outside the database transaction");
|
assertFalse(transactionActive.get(), "daily member data load must run outside the database transaction");
|
||||||
return new byte[]{1, 2, 3};
|
return List.of();
|
||||||
}).when(ossStorageService).readObjectBytes("result/old.xlsx");
|
}).when(dailyFileService).listMembers(anyLong());
|
||||||
doAnswer(invocation -> {
|
doAnswer(invocation -> {
|
||||||
assertFalse(transactionActive.get(), "workbook assembly must run outside the database transaction");
|
assertFalse(transactionActive.get(), "workbook assembly must run outside the database transaction");
|
||||||
return 3;
|
return 3;
|
||||||
}).when(excelAssemblyService).replaceCountriesWorkbook(any(), any(), any());
|
}).when(excelAssemblyService).writeWorkbook(any(), any());
|
||||||
doAnswer(invocation -> {
|
doAnswer(invocation -> {
|
||||||
assertFalse(transactionActive.get(), "workbook upload must run outside the database transaction");
|
assertFalse(transactionActive.get(), "workbook upload must run outside the database transaction");
|
||||||
return "result/new.xlsx";
|
return "result/new.xlsx";
|
||||||
@@ -264,7 +274,7 @@ class ShopDataCrawlTaskServiceRetentionTest {
|
|||||||
verify(excelAssemblyService, never()).writeWorkbook(any(), any());
|
verify(excelAssemblyService, never()).writeWorkbook(any(), any());
|
||||||
verify(excelAssemblyService, never()).replaceCountriesWorkbook(any(), any(), any());
|
verify(excelAssemblyService, never()).replaceCountriesWorkbook(any(), any(), any());
|
||||||
verify(ossStorageService, never()).uploadResultFile(any(), anyString());
|
verify(ossStorageService, never()).uploadResultFile(any(), anyString());
|
||||||
verify(dailyFileService).addMember(301L, TASK_ID, RESULT_ID);
|
verify(dailyFileService).addMemberWithPayload(eq(301L), eq(TASK_ID), eq(RESULT_ID), anyString());
|
||||||
assertEquals("result/current.xlsx", currentRow.getResultFileUrl());
|
assertEquals("result/current.xlsx", currentRow.getResultFileUrl());
|
||||||
assertEquals(10L, currentRow.getResultFileSize());
|
assertEquals(10L, currentRow.getResultFileSize());
|
||||||
assertEquals(3, currentRow.getRowCount());
|
assertEquals(3, currentRow.getRowCount());
|
||||||
@@ -283,7 +293,9 @@ class ShopDataCrawlTaskServiceRetentionTest {
|
|||||||
when(fileResultMapper.selectCount(any())).thenReturn(1L);
|
when(fileResultMapper.selectCount(any())).thenReturn(1L);
|
||||||
when(dailyFileService.findForUpdate(USER_ID, "hash-1", BUSINESS_DATE)).thenReturn(daily);
|
when(dailyFileService.findForUpdate(USER_ID, "hash-1", BUSINESS_DATE)).thenReturn(daily);
|
||||||
when(dailyFileService.containsResult(301L, RESULT_ID)).thenReturn(false);
|
when(dailyFileService.containsResult(301L, RESULT_ID)).thenReturn(false);
|
||||||
when(ossStorageService.readObjectBytes("result/old.xlsx")).thenReturn(new byte[]{1, 2, 3});
|
when(dailyFileService.listMembers(301L)).thenReturn(List.of());
|
||||||
|
when(taskResultItemService.getResultSnapshot(
|
||||||
|
100L, MODULE_TYPE, 200L, ShopDataCrawlResultItemVo.class)).thenReturn(snapshot(200L, 100L));
|
||||||
when(ossStorageService.uploadResultFile(any(), eq(MODULE_TYPE))).thenReturn("result/new.xlsx");
|
when(ossStorageService.uploadResultFile(any(), eq(MODULE_TYPE))).thenReturn("result/new.xlsx");
|
||||||
|
|
||||||
service.processResultFileJob(job);
|
service.processResultFileJob(job);
|
||||||
@@ -323,18 +335,21 @@ class ShopDataCrawlTaskServiceRetentionTest {
|
|||||||
when(fileResultMapper.selectList(any())).thenReturn(List.of(currentRow), List.of(previous, currentRow));
|
when(fileResultMapper.selectList(any())).thenReturn(List.of(currentRow), List.of(previous, currentRow));
|
||||||
when(dailyFileService.findForUpdate(USER_ID, "hash-1", BUSINESS_DATE)).thenReturn(null);
|
when(dailyFileService.findForUpdate(USER_ID, "hash-1", BUSINESS_DATE)).thenReturn(null);
|
||||||
when(dailyFileService.findOlder(USER_ID, "hash-1", BUSINESS_DATE)).thenReturn(List.of(yesterday));
|
when(dailyFileService.findOlder(USER_ID, "hash-1", BUSINESS_DATE)).thenReturn(List.of(yesterday));
|
||||||
when(ossStorageService.readObjectBytes("result/yesterday.xlsx")).thenReturn(new byte[]{1, 2, 3});
|
when(dailyFileService.listMembers(300L)).thenReturn(List.of());
|
||||||
|
when(excelAssemblyService.writeWorkbook(any(), any())).thenReturn(1);
|
||||||
when(ossStorageService.uploadResultFile(any(), eq(MODULE_TYPE))).thenReturn("result/today.xlsx");
|
when(ossStorageService.uploadResultFile(any(), eq(MODULE_TYPE))).thenReturn("result/today.xlsx");
|
||||||
when(excelAssemblyService.replaceCountriesWorkbook(any(), any(), any())).thenReturn(5);
|
|
||||||
|
|
||||||
service.processResultFileJob(job);
|
service.processResultFileJob(job);
|
||||||
|
|
||||||
verify(excelAssemblyService, never()).writeWorkbook(any(), any());
|
// Task 35:新一天跨天滚动改为数据层整表重建 + 旧成员迁移,不再读回旧对象重写。
|
||||||
verify(excelAssemblyService).replaceCountriesWorkbook(any(), any(), eq(List.of(snapshot)));
|
verify(excelAssemblyService).writeWorkbook(any(), eq(List.of(snapshot)));
|
||||||
|
verify(excelAssemblyService, never()).replaceCountriesWorkbook(any(), any(), any());
|
||||||
|
verify(ossStorageService, never()).readObjectBytes(anyString());
|
||||||
verify(dailyFileService).deleteDailyFile(300L);
|
verify(dailyFileService).deleteDailyFile(300L);
|
||||||
|
verify(dailyFileService).reassignMembers(300L, 301L);
|
||||||
verify(ossStorageService).deleteObject("result/yesterday.xlsx");
|
verify(ossStorageService).deleteObject("result/yesterday.xlsx");
|
||||||
assertEquals("result/today.xlsx", currentRow.getResultFileUrl());
|
assertEquals("result/today.xlsx", currentRow.getResultFileUrl());
|
||||||
assertEquals(5, currentRow.getRowCount());
|
assertEquals(1, currentRow.getRowCount());
|
||||||
assertNull(previous.getResultFileUrl());
|
assertNull(previous.getResultFileUrl());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -346,8 +361,8 @@ class ShopDataCrawlTaskServiceRetentionTest {
|
|||||||
when(fileResultMapper.selectList(any())).thenReturn(List.of(currentRow));
|
when(fileResultMapper.selectList(any())).thenReturn(List.of(currentRow));
|
||||||
when(dailyFileService.findForUpdate(USER_ID, "hash-1", BUSINESS_DATE)).thenReturn(null);
|
when(dailyFileService.findForUpdate(USER_ID, "hash-1", BUSINESS_DATE)).thenReturn(null);
|
||||||
when(dailyFileService.findOlder(USER_ID, "hash-1", BUSINESS_DATE)).thenReturn(List.of(yesterday));
|
when(dailyFileService.findOlder(USER_ID, "hash-1", BUSINESS_DATE)).thenReturn(List.of(yesterday));
|
||||||
when(ossStorageService.readObjectBytes("result/yesterday.xlsx")).thenReturn(new byte[]{1, 2, 3});
|
when(dailyFileService.listMembers(300L)).thenReturn(List.of());
|
||||||
when(excelAssemblyService.replaceCountriesWorkbook(any(), any(), any())).thenReturn(4);
|
when(excelAssemblyService.writeWorkbook(any(), any())).thenReturn(4);
|
||||||
doThrow(new IllegalStateException("upload failed"))
|
doThrow(new IllegalStateException("upload failed"))
|
||||||
.when(ossStorageService).uploadResultFile(any(), eq(MODULE_TYPE));
|
.when(ossStorageService).uploadResultFile(any(), eq(MODULE_TYPE));
|
||||||
|
|
||||||
@@ -365,8 +380,8 @@ class ShopDataCrawlTaskServiceRetentionTest {
|
|||||||
when(fileResultMapper.selectList(any())).thenReturn(List.of(currentRow));
|
when(fileResultMapper.selectList(any())).thenReturn(List.of(currentRow));
|
||||||
when(dailyFileService.findForUpdate(USER_ID, "hash-1", BUSINESS_DATE)).thenReturn(null);
|
when(dailyFileService.findForUpdate(USER_ID, "hash-1", BUSINESS_DATE)).thenReturn(null);
|
||||||
when(dailyFileService.findOlder(USER_ID, "hash-1", BUSINESS_DATE)).thenReturn(List.of(yesterday));
|
when(dailyFileService.findOlder(USER_ID, "hash-1", BUSINESS_DATE)).thenReturn(List.of(yesterday));
|
||||||
when(ossStorageService.readObjectBytes("result/yesterday.xlsx")).thenReturn(new byte[]{1, 2, 3});
|
when(dailyFileService.listMembers(300L)).thenReturn(List.of());
|
||||||
when(excelAssemblyService.replaceCountriesWorkbook(any(), any(), any())).thenReturn(4);
|
when(excelAssemblyService.writeWorkbook(any(), any())).thenReturn(4);
|
||||||
when(ossStorageService.uploadResultFile(any(), eq(MODULE_TYPE))).thenReturn("result/new.xlsx");
|
when(ossStorageService.uploadResultFile(any(), eq(MODULE_TYPE))).thenReturn("result/new.xlsx");
|
||||||
AtomicInteger commitCount = new AtomicInteger();
|
AtomicInteger commitCount = new AtomicInteger();
|
||||||
doAnswer(invocation -> {
|
doAnswer(invocation -> {
|
||||||
|
|||||||
+323
@@ -0,0 +1,323 @@
|
|||||||
|
package com.nanri.aiimage.modules.shopdatacrawl.service;
|
||||||
|
|
||||||
|
import com.nanri.aiimage.common.exception.BusinessException;
|
||||||
|
import com.nanri.aiimage.modules.shopdatacrawl.model.dto.ShopDataCrawlCountryResultDto;
|
||||||
|
import com.nanri.aiimage.modules.shopdatacrawl.model.dto.ShopDataCrawlRowDto;
|
||||||
|
import com.nanri.aiimage.modules.shopdatacrawl.model.vo.ShopDataCrawlResultItemVo;
|
||||||
|
import com.nanri.aiimage.modules.similarasin.util.SimilarAsinImageEmbedder;
|
||||||
|
import org.apache.poi.openxml4j.util.ZipSecureFile;
|
||||||
|
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
|
||||||
|
import org.junit.jupiter.api.BeforeAll;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
|
||||||
|
import javax.imageio.ImageIO;
|
||||||
|
import java.awt.image.BufferedImage;
|
||||||
|
import java.io.ByteArrayOutputStream;
|
||||||
|
import java.io.File;
|
||||||
|
import java.io.FileInputStream;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Task 26:模板 workbook 大行数下的样式、图片和工作表兼容测试。
|
||||||
|
* writeWorkbook(XSSFWorkbook 模板路径)返回实际写入的数据行数,与 streaming 路径一致;
|
||||||
|
* 大行数(1000/5000)下验证:模板 styleRow 样式传递到写入行、同 URL 图片只嵌入一次、
|
||||||
|
* 5 个工作表顺序与名称保持模板语义、失败行兜底 URL 文本不中断整表。
|
||||||
|
*/
|
||||||
|
class ShopDataCrawlTemplateLargeWorkbookTest {
|
||||||
|
@TempDir Path tempDir;
|
||||||
|
|
||||||
|
private static final String URL_TEMPLATE = "https://thumb.example/img";
|
||||||
|
|
||||||
|
@BeforeAll
|
||||||
|
static void relaxZipSecurity() {
|
||||||
|
// 大行数下 XSSFWorkbook 写出含大量图片的 xlsx 内部条目数超过 POI 5.2.5 默认防护阈值。
|
||||||
|
ZipSecureFile.setMaxFileCount(2_000_000L);
|
||||||
|
ZipSecureFile.setMinInflateRatio(0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SimilarAsinImageEmbedder okEmbedder() {
|
||||||
|
SimilarAsinImageEmbedder imageEmbedder = mock(SimilarAsinImageEmbedder.class);
|
||||||
|
when(imageEmbedder.fetchAndResizeForCache(any())).thenAnswer(
|
||||||
|
invocation -> new SimilarAsinImageEmbedder.ResizedImage(jpegBytes(), 2, 2));
|
||||||
|
return imageEmbedder;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_026_row_count_image_workbook_normal_default_path() throws Exception {
|
||||||
|
// 正常输入:2000 行 5 国模板写入,返回行数正确;
|
||||||
|
// 模板表头样式保留(与模板文件一致);5 个工作表顺序/名称保持模板语义;
|
||||||
|
// 唯一图片各嵌入一次。
|
||||||
|
SimilarAsinImageEmbedder imageEmbedder = okEmbedder();
|
||||||
|
ShopDataCrawlExcelAssemblyService service =
|
||||||
|
new ShopDataCrawlExcelAssemblyService(imageEmbedder, 64L * 1024 * 1024);
|
||||||
|
List<ShopDataCrawlResultItemVo> items = items(2000, 5);
|
||||||
|
|
||||||
|
File output = tempDir.resolve("template-2000.xlsx").toFile();
|
||||||
|
int written = service.writeWorkbook(output, items);
|
||||||
|
|
||||||
|
assertEquals(2000, written, "writeWorkbook 返回实际写入行数");
|
||||||
|
try (XSSFWorkbook workbook = new XSSFWorkbook(new FileInputStream(output))) {
|
||||||
|
assertEquals(ShopDataCrawlExcelAssemblyService.SHEETS, sheetNames(workbook));
|
||||||
|
for (int i = 0; i < 5; i++) {
|
||||||
|
assertEquals(400, workbook.getSheetAt(i).getLastRowNum(), "每国 400 行");
|
||||||
|
}
|
||||||
|
int templateHeaderStyle = templateHeaderStyleIndex();
|
||||||
|
assertEquals(templateHeaderStyle,
|
||||||
|
workbook.getSheet("英国").getRow(0).getCell(0).getCellStyle().getIndex(),
|
||||||
|
"表头样式保留");
|
||||||
|
assertEquals("2026-07-25",
|
||||||
|
workbook.getSheet("英国").getRow(1).getCell(0).getStringCellValue(), "数据行文本正确");
|
||||||
|
assertEquals(2000, workbook.getAllPictures().size(), "2000 个唯一 URL 各嵌入一张图");
|
||||||
|
assertEquals(80f, workbook.getSheet("英国").getRow(1).getHeightInPoints(), "图片行行高自适应");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_026_row_count_image_workbook_normal_multiple_items() throws Exception {
|
||||||
|
// 批量场景:两个任务项合并 2000 行,返回行数合计正确,顺序稳定。
|
||||||
|
ShopDataCrawlExcelAssemblyService service =
|
||||||
|
new ShopDataCrawlExcelAssemblyService(okEmbedder(), 64L * 1024 * 1024);
|
||||||
|
List<ShopDataCrawlResultItemVo> all = new ArrayList<>(items(1200, 5));
|
||||||
|
all.addAll(items(800, 5));
|
||||||
|
|
||||||
|
File output = tempDir.resolve("template-multi.xlsx").toFile();
|
||||||
|
int written = service.writeWorkbook(output, all);
|
||||||
|
assertEquals(2000, written);
|
||||||
|
try (XSSFWorkbook workbook = new XSSFWorkbook(new FileInputStream(output))) {
|
||||||
|
int total = 0;
|
||||||
|
for (int i = 0; i < 5; i++) {
|
||||||
|
total += workbook.getSheetAt(i).getLastRowNum();
|
||||||
|
}
|
||||||
|
assertEquals(2000, total, "批量合并行数不丢失");
|
||||||
|
assertEquals(1200, workbook.getAllPictures().size(), "重复 URL 按 pictureIndex 去重复用");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_026_row_count_image_workbook_normal_repeated_operation_is_idempotent() throws Exception {
|
||||||
|
// 重复执行:同一输入两次模板写入,行数/图片数/样式一致,不产生重复记录。
|
||||||
|
ShopDataCrawlExcelAssemblyService service =
|
||||||
|
new ShopDataCrawlExcelAssemblyService(okEmbedder(), 64L * 1024 * 1024);
|
||||||
|
List<ShopDataCrawlResultItemVo> items = items(1000, 5);
|
||||||
|
File firstOut = tempDir.resolve("template-idem-1.xlsx").toFile();
|
||||||
|
File secondOut = tempDir.resolve("template-idem-2.xlsx").toFile();
|
||||||
|
|
||||||
|
int first = service.writeWorkbook(firstOut, items);
|
||||||
|
int second = service.writeWorkbook(secondOut, items);
|
||||||
|
assertEquals(first, second, "重复写入行数一致");
|
||||||
|
try (XSSFWorkbook wb1 = new XSSFWorkbook(new FileInputStream(firstOut));
|
||||||
|
XSSFWorkbook wb2 = new XSSFWorkbook(new FileInputStream(secondOut))) {
|
||||||
|
assertEquals(wb1.getAllPictures().size(), wb2.getAllPictures().size(), "图片数一致");
|
||||||
|
assertEquals(wb1.getSheet("英国").getLastRowNum(), wb2.getSheet("英国").getLastRowNum());
|
||||||
|
assertEquals(wb1.getSheet("英国").getRow(0).getCell(0).getCellStyle().getIndex(),
|
||||||
|
wb2.getSheet("英国").getRow(0).getCell(0).getCellStyle().getIndex(), "表头样式一致");
|
||||||
|
assertEquals("B000000000", wb1.getSheet("英国").getRow(1).getCell(1).getStringCellValue(),
|
||||||
|
"首行数据一致");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_026_row_count_image_workbook_boundary_empty_input() throws Exception {
|
||||||
|
// 空输入:0 行模板写入返回 0,表头与模板样式保留,无图片。
|
||||||
|
ShopDataCrawlExcelAssemblyService service =
|
||||||
|
new ShopDataCrawlExcelAssemblyService(okEmbedder(), 64L * 1024 * 1024);
|
||||||
|
File output = tempDir.resolve("template-empty.xlsx").toFile();
|
||||||
|
int written = service.writeWorkbook(output, List.of());
|
||||||
|
|
||||||
|
assertEquals(0, written);
|
||||||
|
try (XSSFWorkbook workbook = new XSSFWorkbook(new FileInputStream(output))) {
|
||||||
|
for (int i = 0; i < 5; i++) {
|
||||||
|
assertEquals(0, workbook.getSheetAt(i).getLastRowNum(), "只有表头行");
|
||||||
|
assertEquals(ShopDataCrawlExcelAssemblyService.HEADERS.get(0),
|
||||||
|
workbook.getSheetAt(i).getRow(0).getCell(0).getStringCellValue(), "表头保留");
|
||||||
|
}
|
||||||
|
assertEquals(0, workbook.getAllPictures().size());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_026_row_count_image_workbook_boundary_single_item() throws Exception {
|
||||||
|
// 单元素:1 行模板写入返回 1,样式/图片完整,不依赖批量路径。
|
||||||
|
ShopDataCrawlExcelAssemblyService service =
|
||||||
|
new ShopDataCrawlExcelAssemblyService(okEmbedder(), 64L * 1024 * 1024);
|
||||||
|
List<ShopDataCrawlResultItemVo> items = items(1, 1);
|
||||||
|
File output = tempDir.resolve("template-single.xlsx").toFile();
|
||||||
|
int written = service.writeWorkbook(output, items);
|
||||||
|
|
||||||
|
assertEquals(1, written);
|
||||||
|
try (XSSFWorkbook workbook = new XSSFWorkbook(new FileInputStream(output))) {
|
||||||
|
assertEquals(1, workbook.getSheet("英国").getLastRowNum());
|
||||||
|
assertEquals("B000000000", workbook.getSheet("英国").getRow(1).getCell(1).getStringCellValue());
|
||||||
|
assertEquals(1, workbook.getAllPictures().size());
|
||||||
|
assertEquals(templateHeaderStyleIndex(),
|
||||||
|
workbook.getSheet("英国").getRow(0).getCell(0).getCellStyle().getIndex(),
|
||||||
|
"表头样式保留");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_026_row_count_image_workbook_boundary_limit_and_overflow() throws Exception {
|
||||||
|
// 上限/超限:5000 行(MAX_ROWS 规模)模板写入在预算内完成,行数完整;
|
||||||
|
// 失败图片行兜底 URL 文本,成功图片各嵌入一次,不发生无界图片堆积。
|
||||||
|
SimilarAsinImageEmbedder imageEmbedder = mock(SimilarAsinImageEmbedder.class);
|
||||||
|
when(imageEmbedder.fetchAndResizeForCache(any())).thenAnswer(invocation -> {
|
||||||
|
String url = invocation.getArgument(0);
|
||||||
|
if (url.contains("fail")) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return new SimilarAsinImageEmbedder.ResizedImage(jpegBytes(), 2, 2);
|
||||||
|
});
|
||||||
|
ShopDataCrawlExcelAssemblyService service =
|
||||||
|
new ShopDataCrawlExcelAssemblyService(imageEmbedder, 64L * 1024 * 1024);
|
||||||
|
List<ShopDataCrawlResultItemVo> items = items(5000, 5);
|
||||||
|
for (int i = 0; i < 5000; i += 2) {
|
||||||
|
setImage(items, i, "https://thumb.example/fail" + i + ".jpg");
|
||||||
|
}
|
||||||
|
|
||||||
|
File output = tempDir.resolve("template-5000.xlsx").toFile();
|
||||||
|
long start = System.currentTimeMillis();
|
||||||
|
int written = service.writeWorkbook(output, items);
|
||||||
|
long elapsed = System.currentTimeMillis() - start;
|
||||||
|
|
||||||
|
assertEquals(5000, written);
|
||||||
|
assertTrue(elapsed < 60_000, "5000 行模板写入须在预算内完成,实际=" + elapsed + "ms");
|
||||||
|
assertEquals(5000, service.countRows(items), "countRows 与写入行数一致");
|
||||||
|
try (XSSFWorkbook workbook = new XSSFWorkbook(new FileInputStream(output))) {
|
||||||
|
int total = 0;
|
||||||
|
for (int i = 0; i < 5; i++) {
|
||||||
|
total += workbook.getSheetAt(i).getLastRowNum();
|
||||||
|
}
|
||||||
|
assertEquals(5000, total, "读回行数完整");
|
||||||
|
assertEquals(2500, workbook.getAllPictures().size(), "2500 行成功图片嵌入");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_026_row_count_image_workbook_invalid_input_rejected() throws Exception {
|
||||||
|
// 非法参数:null 输出路径/失败项(success=false)被跳过/无结果项 → 明确行为。
|
||||||
|
ShopDataCrawlExcelAssemblyService service =
|
||||||
|
new ShopDataCrawlExcelAssemblyService(okEmbedder(), 64L * 1024 * 1024);
|
||||||
|
List<ShopDataCrawlResultItemVo> items = items(10, 1);
|
||||||
|
|
||||||
|
assertThrows(BusinessException.class, () -> service.writeWorkbook(null, items));
|
||||||
|
|
||||||
|
ShopDataCrawlResultItemVo failed = new ShopDataCrawlResultItemVo();
|
||||||
|
failed.setSuccess(false);
|
||||||
|
failed.setError("抓取失败");
|
||||||
|
List<ShopDataCrawlResultItemVo> withFailed = new ArrayList<>(items);
|
||||||
|
withFailed.add(failed);
|
||||||
|
File output = tempDir.resolve("template-invalid.xlsx").toFile();
|
||||||
|
assertEquals(10, service.writeWorkbook(output, withFailed), "失败项被跳过");
|
||||||
|
|
||||||
|
File empty = tempDir.resolve("template-none.xlsx").toFile();
|
||||||
|
assertEquals(0, service.writeWorkbook(empty, null), "null items 安全返回 0");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_026_row_count_image_workbook_dependency_failure_releases_resources() throws Exception {
|
||||||
|
// 依赖失败:图片下载抛异常 → 单行兜底 URL 文本,整表行数完整、无图片;
|
||||||
|
// 目标目录不可写抛 BusinessException;重试成功不残留临时状态。
|
||||||
|
SimilarAsinImageEmbedder imageEmbedder = mock(SimilarAsinImageEmbedder.class);
|
||||||
|
when(imageEmbedder.fetchAndResizeForCache(any())).thenThrow(
|
||||||
|
new RuntimeException("image service down"));
|
||||||
|
ShopDataCrawlExcelAssemblyService service =
|
||||||
|
new ShopDataCrawlExcelAssemblyService(imageEmbedder, 64L * 1024 * 1024);
|
||||||
|
|
||||||
|
List<ShopDataCrawlResultItemVo> items = items(100, 1);
|
||||||
|
File output = tempDir.resolve("template-fail.xlsx").toFile();
|
||||||
|
int written = service.writeWorkbook(output, items);
|
||||||
|
assertEquals(100, written, "图片失败不阻塞行写入");
|
||||||
|
try (XSSFWorkbook workbook = new XSSFWorkbook(new FileInputStream(output))) {
|
||||||
|
assertEquals(100, workbook.getSheet("英国").getLastRowNum());
|
||||||
|
assertEquals(0, workbook.getAllPictures().size(), "无图片嵌入");
|
||||||
|
assertEquals(URL_TEMPLATE + "-0.jpg",
|
||||||
|
workbook.getSheet("英国").getRow(1).getCell(2).getStringCellValue(), "兜底 URL 文本");
|
||||||
|
}
|
||||||
|
|
||||||
|
File locked = tempDir.resolve("locked").toFile();
|
||||||
|
assertTrue(locked.mkdir(), "目录占位模拟不可写目标");
|
||||||
|
assertThrows(BusinessException.class, () -> service.writeWorkbook(locked, items));
|
||||||
|
|
||||||
|
File retried = tempDir.resolve("template-retry.xlsx").toFile();
|
||||||
|
assertEquals(100, service.writeWorkbook(retried, items), "失败后重试成功");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int templateHeaderStyleIndex() throws Exception {
|
||||||
|
try (org.apache.poi.ss.usermodel.Workbook template =
|
||||||
|
new XSSFWorkbook(new org.springframework.core.io.ClassPathResource(
|
||||||
|
"templates/shop-data-crawl/文档格式.xlsx").getInputStream())) {
|
||||||
|
return template.getSheetAt(0).getRow(0).getCell(0).getCellStyle().getIndex();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void setImage(List<ShopDataCrawlResultItemVo> items, int index, String url) {
|
||||||
|
int i = 0;
|
||||||
|
for (ShopDataCrawlResultItemVo item : items) {
|
||||||
|
for (ShopDataCrawlCountryResultDto country : item.getCountryResults()) {
|
||||||
|
for (ShopDataCrawlRowDto row : country.getItems()) {
|
||||||
|
if (i == index) {
|
||||||
|
row.setCommodityImage(url);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<ShopDataCrawlResultItemVo> items(int rowCount, int countryCount) {
|
||||||
|
List<ShopDataCrawlResultItemVo> items = new ArrayList<>();
|
||||||
|
ShopDataCrawlResultItemVo item = new ShopDataCrawlResultItemVo();
|
||||||
|
item.setSuccess(true);
|
||||||
|
List<ShopDataCrawlCountryResultDto> countryResults = new ArrayList<>();
|
||||||
|
List<String> countries = ShopDataCrawlExcelAssemblyService.COUNTRIES.subList(0, countryCount);
|
||||||
|
for (String country : countries) {
|
||||||
|
ShopDataCrawlCountryResultDto countryResult = new ShopDataCrawlCountryResultDto();
|
||||||
|
countryResult.setCountry(country);
|
||||||
|
countryResult.setItems(new ArrayList<>());
|
||||||
|
countryResults.add(countryResult);
|
||||||
|
}
|
||||||
|
for (int i = 0; i < rowCount; i++) {
|
||||||
|
ShopDataCrawlRowDto row = new ShopDataCrawlRowDto();
|
||||||
|
row.setDate("2026-07-25");
|
||||||
|
row.setAsin("B0" + String.format("%08d", i));
|
||||||
|
row.setBrand("Brand");
|
||||||
|
row.setCommodityImage(URL_TEMPLATE + "-" + i + ".jpg");
|
||||||
|
row.setInventorySales("11");
|
||||||
|
row.setSalesRank("22");
|
||||||
|
row.setPageViews("33");
|
||||||
|
row.setUnitsSold("44");
|
||||||
|
row.setPrice("12.50");
|
||||||
|
row.setRecommendedOffer("12.00");
|
||||||
|
countryResults.get(i % countryCount).getItems().add(row);
|
||||||
|
}
|
||||||
|
item.setCountryResults(countryResults);
|
||||||
|
items.add(item);
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<String> sheetNames(XSSFWorkbook workbook) {
|
||||||
|
List<String> names = new ArrayList<>();
|
||||||
|
for (int i = 0; i < workbook.getNumberOfSheets(); i++) {
|
||||||
|
names.add(workbook.getSheetAt(i).getSheetName());
|
||||||
|
}
|
||||||
|
return names;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] jpegBytes() throws Exception {
|
||||||
|
BufferedImage image = new BufferedImage(2, 2, BufferedImage.TYPE_INT_RGB);
|
||||||
|
ByteArrayOutputStream output = new ByteArrayOutputStream();
|
||||||
|
ImageIO.write(image, "jpg", output);
|
||||||
|
return output.toByteArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
+204
@@ -0,0 +1,204 @@
|
|||||||
|
package com.nanri.aiimage.modules.shopdatacrawl.util;
|
||||||
|
|
||||||
|
import com.nanri.aiimage.modules.shopdatacrawl.model.dto.ShopDataCrawlCountryResultDto;
|
||||||
|
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.ShopDataCrawlExcelAssemblyService;
|
||||||
|
import com.nanri.aiimage.modules.similarasin.util.SimilarAsinImageEmbedder;
|
||||||
|
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
|
||||||
|
import javax.imageio.ImageIO;
|
||||||
|
import java.awt.image.BufferedImage;
|
||||||
|
import java.io.ByteArrayOutputStream;
|
||||||
|
import java.io.File;
|
||||||
|
import java.io.FileInputStream;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Task 22:将店铺 Excel 图片缓存替换为有界字节缓存。
|
||||||
|
* BoundedImageCache 以总字节预算 + 条目上限约束图片缓存,超过预算按 FIFO 淘汰最旧条目,
|
||||||
|
* 单图超过预算时拒绝缓存(embed 阶段走原有直接下载兜底),保证内存峰值有界。
|
||||||
|
* ShopDataCrawlExcelAssemblyService 的 workbook 组装改用该缓存,语义与无界缓存一致。
|
||||||
|
*/
|
||||||
|
class ShopDataCrawlBoundedImageCacheTest {
|
||||||
|
@TempDir Path tempDir;
|
||||||
|
|
||||||
|
private static final String URL_A = "https://thumb.example/A.jpg";
|
||||||
|
private static final String URL_B = "https://thumb.example/B.jpg";
|
||||||
|
private static final String URL_C = "https://thumb.example/C.jpg";
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_022_image_cache_excel_normal_default_path() {
|
||||||
|
// 正常输入:预算充足时全部放入,get 命中,字节合计正确,无淘汰。
|
||||||
|
BoundedImageCache cache = new BoundedImageCache(10_000, 100);
|
||||||
|
SimilarAsinImageEmbedder.ResizedImage imageA = resizedImage(100);
|
||||||
|
SimilarAsinImageEmbedder.ResizedImage imageB = resizedImage(200);
|
||||||
|
cache.put(URL_A, imageA);
|
||||||
|
cache.put(URL_B, imageB);
|
||||||
|
|
||||||
|
assertEquals(imageA, cache.get(URL_A), "A 命中");
|
||||||
|
assertEquals(imageB, cache.get(URL_B), "B 命中");
|
||||||
|
assertEquals(300, cache.sizeBytes(), "字节合计正确");
|
||||||
|
assertEquals(2, cache.size());
|
||||||
|
assertEquals(0, cache.evictionCount(), "预算充足不淘汰");
|
||||||
|
assertEquals(0, cache.rejectedCount(), "无拒绝");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_022_image_cache_excel_normal_multiple_items() {
|
||||||
|
// 批量场景:大量图片超过字节预算 → 最旧条目被淘汰,最新条目可命中,总字节有界。
|
||||||
|
BoundedImageCache cache = new BoundedImageCache(1_000, 100);
|
||||||
|
List<SimilarAsinImageEmbedder.ResizedImage> images = new ArrayList<>();
|
||||||
|
for (int i = 0; i < 50; i++) {
|
||||||
|
SimilarAsinImageEmbedder.ResizedImage image = resizedImage(100);
|
||||||
|
images.add(image);
|
||||||
|
cache.put(URL_A + i, image);
|
||||||
|
}
|
||||||
|
assertTrue(cache.sizeBytes() <= 1_000, "总字节不得超过预算,实际=" + cache.sizeBytes());
|
||||||
|
assertTrue(cache.size() <= 10, "1_000 预算 / 100 每图最多容纳 10 张");
|
||||||
|
assertTrue(cache.evictionCount() >= 40, "超出部分被淘汰");
|
||||||
|
assertNull(cache.get(URL_A + "0"), "最旧条目已淘汰");
|
||||||
|
assertNotNull(cache.get(URL_A + "49"), "最新条目仍可命中");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_022_image_cache_excel_normal_repeated_operation_is_idempotent() {
|
||||||
|
// 重复执行:同一 URL 重复 put 替换不增加字节;putIfAbsent 不覆盖已有值。
|
||||||
|
BoundedImageCache cache = new BoundedImageCache(1_000, 100);
|
||||||
|
SimilarAsinImageEmbedder.ResizedImage first = resizedImage(100);
|
||||||
|
SimilarAsinImageEmbedder.ResizedImage second = resizedImage(100);
|
||||||
|
cache.put(URL_A, first);
|
||||||
|
cache.put(URL_A, second);
|
||||||
|
assertEquals(100, cache.sizeBytes(), "替换不重复计入字节");
|
||||||
|
assertEquals(1, cache.size());
|
||||||
|
|
||||||
|
SimilarAsinImageEmbedder.ResizedImage replaced = cache.putIfAbsent(URL_A, resizedImage(200));
|
||||||
|
assertEquals(second, replaced, "putIfAbsent 返回已有值");
|
||||||
|
assertEquals(100, cache.sizeBytes(), "putIfAbsent 不替换字节");
|
||||||
|
assertEquals(0, cache.evictionCount(), "重复操作不触发淘汰");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_022_image_cache_excel_boundary_empty_input() {
|
||||||
|
// 空输入:空缓存 get 返回 null;null/空集合安全。
|
||||||
|
BoundedImageCache cache = new BoundedImageCache(1_000, 100);
|
||||||
|
assertNull(cache.get(URL_A));
|
||||||
|
assertEquals(0, cache.size());
|
||||||
|
assertEquals(0, cache.sizeBytes());
|
||||||
|
assertNull(cache.putIfAbsent(null, resizedImage(10)), "null key 不放入");
|
||||||
|
assertEquals(0, cache.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_022_image_cache_excel_boundary_single_item() {
|
||||||
|
// 单元素边界:单图恰好等于预算可放入;剩余预算恰够时刚好放下。
|
||||||
|
BoundedImageCache exact = new BoundedImageCache(100, 100);
|
||||||
|
SimilarAsinImageEmbedder.ResizedImage image = resizedImage(100);
|
||||||
|
exact.put(URL_A, image);
|
||||||
|
assertEquals(image, exact.get(URL_A), "单图等于预算可缓存");
|
||||||
|
assertEquals(100, exact.sizeBytes());
|
||||||
|
|
||||||
|
BoundedImageCache tight = new BoundedImageCache(150, 100);
|
||||||
|
tight.put(URL_A, resizedImage(100));
|
||||||
|
tight.put(URL_B, resizedImage(50));
|
||||||
|
assertEquals(2, tight.size(), "剩余预算恰够时恰好放下");
|
||||||
|
assertEquals(0, tight.evictionCount());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_022_image_cache_excel_boundary_limit_and_overflow() {
|
||||||
|
// 上限/超限:单图超过预算被拒绝缓存且不占用预算;总字节永不超过预算。
|
||||||
|
BoundedImageCache cache = new BoundedImageCache(100, 100);
|
||||||
|
SimilarAsinImageEmbedder.ResizedImage huge = resizedImage(200);
|
||||||
|
cache.put(URL_A, huge);
|
||||||
|
assertNull(cache.get(URL_A), "单图超预算拒绝缓存");
|
||||||
|
assertEquals(0, cache.sizeBytes(), "被拒绝的图不占用预算");
|
||||||
|
assertEquals(1, cache.rejectedCount());
|
||||||
|
|
||||||
|
cache.put(URL_B, resizedImage(60));
|
||||||
|
cache.put(URL_C, resizedImage(60));
|
||||||
|
assertEquals(1, cache.size(), "第二次放入触发淘汰,仅保留最新");
|
||||||
|
assertNull(cache.get(URL_B));
|
||||||
|
assertNotNull(cache.get(URL_C));
|
||||||
|
assertEquals(60, cache.sizeBytes(), "总字节不超预算");
|
||||||
|
assertTrue(cache.evictionCount() >= 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_022_image_cache_excel_invalid_input_rejected() {
|
||||||
|
// 非法参数:非正预算/非正条目上限 → 拒绝;null 值拒绝。
|
||||||
|
assertThrows(IllegalArgumentException.class, () -> new BoundedImageCache(0, 100));
|
||||||
|
assertThrows(IllegalArgumentException.class, () -> new BoundedImageCache(-1, 100));
|
||||||
|
assertThrows(IllegalArgumentException.class, () -> new BoundedImageCache(1_000, 0));
|
||||||
|
BoundedImageCache cache = new BoundedImageCache(1_000, 100);
|
||||||
|
assertThrows(IllegalArgumentException.class, () -> cache.put(null, resizedImage(10)));
|
||||||
|
assertThrows(IllegalArgumentException.class, () -> cache.put("", resizedImage(10)));
|
||||||
|
assertThrows(IllegalArgumentException.class, () -> cache.put(URL_A, null));
|
||||||
|
assertEquals(0, cache.size(), "非法输入不产生缓存条目");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_022_image_cache_excel_dependency_failure_releases_resources() throws Exception {
|
||||||
|
// 依赖失败:图片下载失败不占缓存预算,缓存继续可用;
|
||||||
|
// Excel 组装用有界缓存后仍产出完整 workbook,缓存字节有界。
|
||||||
|
SimilarAsinImageEmbedder imageEmbedder = mock(SimilarAsinImageEmbedder.class);
|
||||||
|
String failUrl = "https://thumb.example/fail.jpg";
|
||||||
|
when(imageEmbedder.fetchAndResizeForCache(failUrl)).thenReturn(null);
|
||||||
|
String okUrl = "https://thumb.example/ok.jpg";
|
||||||
|
when(imageEmbedder.fetchAndResizeForCache(okUrl))
|
||||||
|
.thenReturn(new SimilarAsinImageEmbedder.ResizedImage(jpegBytes(), 2, 2));
|
||||||
|
|
||||||
|
long smallBudget = 200;
|
||||||
|
ShopDataCrawlExcelAssemblyService service =
|
||||||
|
new ShopDataCrawlExcelAssemblyService(imageEmbedder, smallBudget);
|
||||||
|
List<ShopDataCrawlResultItemVo> items = new ArrayList<>();
|
||||||
|
for (int i = 0; i < 10; i++) {
|
||||||
|
ShopDataCrawlRowDto row = new ShopDataCrawlRowDto();
|
||||||
|
row.setDate("2026-07-25");
|
||||||
|
row.setAsin("B0" + String.format("%08d", i));
|
||||||
|
row.setCommodityImage(i % 2 == 0 ? okUrl : failUrl);
|
||||||
|
ShopDataCrawlCountryResultDto country = new ShopDataCrawlCountryResultDto();
|
||||||
|
country.setCountry("UK");
|
||||||
|
country.setItems(List.of(row));
|
||||||
|
ShopDataCrawlResultItemVo item = new ShopDataCrawlResultItemVo();
|
||||||
|
item.setSuccess(true);
|
||||||
|
item.setCountryResults(List.of(country));
|
||||||
|
items.add(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
File output = tempDir.resolve("bounded.xlsx").toFile();
|
||||||
|
service.writeWorkbook(output, items);
|
||||||
|
|
||||||
|
try (XSSFWorkbook workbook = new XSSFWorkbook(new FileInputStream(output))) {
|
||||||
|
assertEquals(10, workbook.getSheet("英国").getLastRowNum(), "全部 10 行写入");
|
||||||
|
assertEquals(1, workbook.getAllPictures().size(), "失败图片不嵌入,成功图片去重嵌入");
|
||||||
|
assertEquals("https://thumb.example/fail.jpg",
|
||||||
|
workbook.getSheet("英国").getRow(2).getCell(2).getStringCellValue(),
|
||||||
|
"失败图片兜底为 URL 文本");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SimilarAsinImageEmbedder.ResizedImage resizedImage(int bytes) {
|
||||||
|
return new SimilarAsinImageEmbedder.ResizedImage(new byte[bytes], 2, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] jpegBytes() throws Exception {
|
||||||
|
BufferedImage image = new BufferedImage(2, 2, BufferedImage.TYPE_INT_RGB);
|
||||||
|
ByteArrayOutputStream output = new ByteArrayOutputStream();
|
||||||
|
ImageIO.write(image, "jpg", output);
|
||||||
|
return output.toByteArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
+211
@@ -0,0 +1,211 @@
|
|||||||
|
package com.nanri.aiimage.modules.shopdatacrawl.util;
|
||||||
|
|
||||||
|
import com.nanri.aiimage.modules.shopdatacrawl.model.dto.ShopDataCrawlCountryResultDto;
|
||||||
|
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.ShopDataCrawlExcelAssemblyService;
|
||||||
|
import com.nanri.aiimage.modules.similarasin.util.SimilarAsinImageEmbedder;
|
||||||
|
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
|
||||||
|
import javax.imageio.ImageIO;
|
||||||
|
import java.awt.image.BufferedImage;
|
||||||
|
import java.io.ByteArrayOutputStream;
|
||||||
|
import java.io.File;
|
||||||
|
import java.io.FileInputStream;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Task 23:图片嵌入成功后立即释放外部缩略图字节副本。
|
||||||
|
* BoundedImageCache.release(url) 在嵌入成功后移除条目并扣减字节计数,
|
||||||
|
* 使缩略图 byte[] 可被 GC 回收,同一 URL 后续行复用已登记的 pictureIndex;
|
||||||
|
* 嵌入失败(图片缺失)时条目保留,不误释放。
|
||||||
|
*/
|
||||||
|
class ShopDataCrawlImageReleaseTest {
|
||||||
|
@TempDir Path tempDir;
|
||||||
|
|
||||||
|
private static final String URL_A = "https://thumb.example/A.jpg";
|
||||||
|
private static final String URL_B = "https://thumb.example/B.jpg";
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_023_image_release_normal_default_path() {
|
||||||
|
// 正常输入:release 返回被释放条目并扣减字节计数,重复 release 安全。
|
||||||
|
BoundedImageCache cache = new BoundedImageCache(10_000, 100);
|
||||||
|
SimilarAsinImageEmbedder.ResizedImage imageA = resizedImage(100);
|
||||||
|
cache.put(URL_A, imageA);
|
||||||
|
assertEquals(100, cache.sizeBytes());
|
||||||
|
|
||||||
|
SimilarAsinImageEmbedder.ResizedImage released = cache.release(URL_A);
|
||||||
|
assertEquals(imageA, released, "release 返回被释放的条目");
|
||||||
|
assertNull(cache.get(URL_A), "条目已移除");
|
||||||
|
assertEquals(0, cache.sizeBytes(), "字节计数归零");
|
||||||
|
assertNull(cache.release(URL_A), "重复 release 安全返回 null");
|
||||||
|
assertEquals(0, cache.sizeBytes());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_023_image_release_normal_multiple_items() {
|
||||||
|
// 批量场景:多个 URL 全部嵌入后逐一释放,字节计数精确归零。
|
||||||
|
BoundedImageCache cache = new BoundedImageCache(10_000, 100);
|
||||||
|
cache.put(URL_A, resizedImage(100));
|
||||||
|
cache.put(URL_B, resizedImage(200));
|
||||||
|
cache.put("https://thumb.example/C.jpg", resizedImage(300));
|
||||||
|
assertEquals(600, cache.sizeBytes());
|
||||||
|
|
||||||
|
assertEquals(100, cache.release(URL_A).bytes().length);
|
||||||
|
assertEquals(500, cache.sizeBytes());
|
||||||
|
assertEquals(200, cache.release(URL_B).bytes().length);
|
||||||
|
assertEquals(300, cache.sizeBytes());
|
||||||
|
cache.release("https://thumb.example/C.jpg");
|
||||||
|
assertEquals(0, cache.sizeBytes(), "全部释放后字节归零");
|
||||||
|
assertEquals(0, cache.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_023_image_release_normal_repeated_operation_is_idempotent() throws Exception {
|
||||||
|
// 重复执行:同一 URL 多行嵌入只释放一次,后续行复用 pictureIndex,不产生重复图。
|
||||||
|
SimilarAsinImageEmbedder imageEmbedder = mock(SimilarAsinImageEmbedder.class);
|
||||||
|
when(imageEmbedder.fetchAndResizeForCache(URL_A))
|
||||||
|
.thenReturn(new SimilarAsinImageEmbedder.ResizedImage(jpegBytes(), 2, 2));
|
||||||
|
ShopDataCrawlExcelAssemblyService service =
|
||||||
|
new ShopDataCrawlExcelAssemblyService(imageEmbedder, 10_000);
|
||||||
|
|
||||||
|
List<ShopDataCrawlResultItemVo> items = new ArrayList<>();
|
||||||
|
for (int i = 0; i < 5; i++) {
|
||||||
|
ShopDataCrawlRowDto row = new ShopDataCrawlRowDto();
|
||||||
|
row.setDate("2026-07-25");
|
||||||
|
row.setAsin("B0" + String.format("%08d", i));
|
||||||
|
row.setCommodityImage(URL_A);
|
||||||
|
ShopDataCrawlCountryResultDto country = new ShopDataCrawlCountryResultDto();
|
||||||
|
country.setCountry("UK");
|
||||||
|
country.setItems(List.of(row));
|
||||||
|
ShopDataCrawlResultItemVo item = new ShopDataCrawlResultItemVo();
|
||||||
|
item.setSuccess(true);
|
||||||
|
item.setCountryResults(List.of(country));
|
||||||
|
items.add(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
File output = tempDir.resolve("release.xlsx").toFile();
|
||||||
|
service.writeWorkbook(output, items);
|
||||||
|
try (XSSFWorkbook workbook = new XSSFWorkbook(new FileInputStream(output))) {
|
||||||
|
assertEquals(1, workbook.getAllPictures().size(), "同一 URL 只嵌入一张图");
|
||||||
|
assertEquals(5, workbook.getSheet("英国").getLastRowNum(), "5 行全部写入");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_023_image_release_boundary_empty_input() {
|
||||||
|
// 空输入:空缓存 release 任意 URL 安全返回 null,不抛异常。
|
||||||
|
BoundedImageCache cache = new BoundedImageCache(10_000, 100);
|
||||||
|
assertNull(cache.release(URL_A));
|
||||||
|
assertNull(cache.release(""));
|
||||||
|
assertNull(cache.release(null));
|
||||||
|
assertEquals(0, cache.sizeBytes());
|
||||||
|
assertEquals(0, cache.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_023_image_release_boundary_single_item() throws Exception {
|
||||||
|
// 单元素:单 URL 单行嵌入后字节立即归零,缓存为空。
|
||||||
|
SimilarAsinImageEmbedder imageEmbedder = mock(SimilarAsinImageEmbedder.class);
|
||||||
|
when(imageEmbedder.fetchAndResizeForCache(URL_B))
|
||||||
|
.thenReturn(new SimilarAsinImageEmbedder.ResizedImage(jpegBytes(), 2, 2));
|
||||||
|
ShopDataCrawlExcelAssemblyService service =
|
||||||
|
new ShopDataCrawlExcelAssemblyService(imageEmbedder, 10_000);
|
||||||
|
|
||||||
|
ShopDataCrawlRowDto row = new ShopDataCrawlRowDto();
|
||||||
|
row.setDate("2026-07-25");
|
||||||
|
row.setAsin("B000000001");
|
||||||
|
row.setCommodityImage(URL_B);
|
||||||
|
ShopDataCrawlCountryResultDto country = new ShopDataCrawlCountryResultDto();
|
||||||
|
country.setCountry("UK");
|
||||||
|
country.setItems(List.of(row));
|
||||||
|
ShopDataCrawlResultItemVo item = new ShopDataCrawlResultItemVo();
|
||||||
|
item.setSuccess(true);
|
||||||
|
item.setCountryResults(List.of(country));
|
||||||
|
|
||||||
|
File output = tempDir.resolve("release-single.xlsx").toFile();
|
||||||
|
service.writeWorkbook(output, List.of(item));
|
||||||
|
try (XSSFWorkbook workbook = new XSSFWorkbook(new FileInputStream(output))) {
|
||||||
|
assertEquals(1, workbook.getAllPictures().size());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_023_image_release_boundary_limit_and_overflow() {
|
||||||
|
// 上限/超限:释放后字节预算恢复,可继续放入新图而不触发淘汰。
|
||||||
|
BoundedImageCache cache = new BoundedImageCache(100, 10);
|
||||||
|
cache.put(URL_A, resizedImage(100));
|
||||||
|
assertEquals(100, cache.sizeBytes());
|
||||||
|
assertEquals(0, cache.evictionCount(), "单图占满预算");
|
||||||
|
|
||||||
|
cache.release(URL_A);
|
||||||
|
cache.put(URL_B, resizedImage(90));
|
||||||
|
assertEquals(90, cache.sizeBytes());
|
||||||
|
assertEquals(0, cache.evictionCount(), "释放后新图放入不触发淘汰");
|
||||||
|
assertEquals(0, cache.rejectedCount());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_023_image_release_invalid_input_rejected() {
|
||||||
|
// 非法参数:release 对空白 key 安全返回 null;cache 本身非法构造仍被拒绝。
|
||||||
|
BoundedImageCache cache = new BoundedImageCache(10_000, 100);
|
||||||
|
assertNull(cache.release(" "));
|
||||||
|
assertNull(cache.release("\t"));
|
||||||
|
assertEquals(0, cache.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_023_image_release_dependency_failure_releases_resources() throws Exception {
|
||||||
|
// 依赖失败:图片下载失败(fetch 返回 null)时兜底 URL 文本,
|
||||||
|
// 其他已成功嵌入的条目正常释放,失败 URL 不产生残留字节。
|
||||||
|
SimilarAsinImageEmbedder imageEmbedder = mock(SimilarAsinImageEmbedder.class);
|
||||||
|
when(imageEmbedder.fetchAndResizeForCache(URL_A))
|
||||||
|
.thenReturn(new SimilarAsinImageEmbedder.ResizedImage(jpegBytes(), 2, 2));
|
||||||
|
when(imageEmbedder.fetchAndResizeForCache("https://thumb.example/fail.jpg")).thenReturn(null);
|
||||||
|
ShopDataCrawlExcelAssemblyService service =
|
||||||
|
new ShopDataCrawlExcelAssemblyService(imageEmbedder, 10_000);
|
||||||
|
|
||||||
|
List<ShopDataCrawlResultItemVo> items = new ArrayList<>();
|
||||||
|
for (int i = 0; i < 4; i++) {
|
||||||
|
ShopDataCrawlRowDto row = new ShopDataCrawlRowDto();
|
||||||
|
row.setDate("2026-07-25");
|
||||||
|
row.setAsin("B0" + String.format("%08d", i));
|
||||||
|
row.setCommodityImage(i % 2 == 0 ? URL_A : "https://thumb.example/fail.jpg");
|
||||||
|
ShopDataCrawlCountryResultDto country = new ShopDataCrawlCountryResultDto();
|
||||||
|
country.setCountry("UK");
|
||||||
|
country.setItems(List.of(row));
|
||||||
|
ShopDataCrawlResultItemVo item = new ShopDataCrawlResultItemVo();
|
||||||
|
item.setSuccess(true);
|
||||||
|
item.setCountryResults(List.of(country));
|
||||||
|
items.add(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
File output = tempDir.resolve("release-fail.xlsx").toFile();
|
||||||
|
service.writeWorkbook(output, items);
|
||||||
|
try (XSSFWorkbook workbook = new XSSFWorkbook(new FileInputStream(output))) {
|
||||||
|
assertEquals(1, workbook.getAllPictures().size(), "成功图片嵌入,失败图片兜底");
|
||||||
|
assertEquals("https://thumb.example/fail.jpg",
|
||||||
|
workbook.getSheet("英国").getRow(2).getCell(2).getStringCellValue());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SimilarAsinImageEmbedder.ResizedImage resizedImage(int bytes) {
|
||||||
|
return new SimilarAsinImageEmbedder.ResizedImage(new byte[bytes], 2, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] jpegBytes() throws Exception {
|
||||||
|
BufferedImage image = new BufferedImage(2, 2, BufferedImage.TYPE_INT_RGB);
|
||||||
|
ByteArrayOutputStream output = new ByteArrayOutputStream();
|
||||||
|
ImageIO.write(image, "jpg", output);
|
||||||
|
return output.toByteArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
+196
@@ -0,0 +1,196 @@
|
|||||||
|
package com.nanri.aiimage.modules.shopdatacrawl.util;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.nanri.aiimage.modules.shopdatacrawl.model.dto.ShopDataCrawlCountryResultDto;
|
||||||
|
import com.nanri.aiimage.modules.shopdatacrawl.model.dto.ShopDataCrawlRowDto;
|
||||||
|
import com.nanri.aiimage.modules.shopdatacrawl.model.vo.ShopDataCrawlResultItemVo;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.Spy;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.Mockito.doAnswer;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Task 21:店铺抓取性能基线。
|
||||||
|
* ShopDataCrawlPerfFixture 建立单店铺 1k/5k 行、多国家、图片成功/失败场景的确定性生成器,
|
||||||
|
* 并提供 payload 大小、chunk 划分数、图片 URL 数采样;图片行 URL 固定为占位主机,
|
||||||
|
* 图片失败场景在 sampleImageStats 中以 urlsWithImages/failedThumbUrls 呈现。
|
||||||
|
*/
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class ShopDataCrawlPerfFixtureBaselineTest {
|
||||||
|
|
||||||
|
@Spy private ObjectMapper objectMapper = new ObjectMapper();
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_021_image_normal_default_path() {
|
||||||
|
// 正常输入:1000 行生成,5 个国家均有行,payload/chunk/图片统计完整。
|
||||||
|
ShopDataCrawlPerfFixture fixture = new ShopDataCrawlPerfFixture(objectMapper);
|
||||||
|
List<ShopDataCrawlResultItemVo> items =
|
||||||
|
fixture.generateItems("Demo Shop", 1000, 5, true, 0);
|
||||||
|
|
||||||
|
assertEquals(1, items.size(), "单店铺一个结果项");
|
||||||
|
assertEquals(5, items.get(0).getCountryResults().size(), "5 个国家结果分片");
|
||||||
|
int totalRows = items.get(0).getCountryResults().stream().mapToInt(c -> c.getItems().size()).sum();
|
||||||
|
assertEquals(1000, totalRows, "行数不丢失");
|
||||||
|
assertTrue(items.get(0).getCountryResults().stream()
|
||||||
|
.allMatch(c -> "UK,DE,FR,ES,IT".contains(c.getCountry())), "国家代码合法");
|
||||||
|
|
||||||
|
ShopDataCrawlPerfFixture.Metrics metrics =
|
||||||
|
fixture.samplePayload(items, true, 200);
|
||||||
|
assertEquals(1000, metrics.rowCount());
|
||||||
|
assertEquals(5, metrics.chunkCount(), "1000 行 / 200 每 chunk = 5 个 chunk");
|
||||||
|
assertTrue(metrics.payloadBytes() > 0);
|
||||||
|
assertEquals(1000, metrics.urlsWithImages(), "图片模式全行带图");
|
||||||
|
assertEquals(0, metrics.failedThumbUrls());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_021_image_normal_multiple_items() {
|
||||||
|
// 批量场景:1k 与 5k 两种规模 + 图片成功/失败两种场景,结果不丢失、顺序稳定。
|
||||||
|
ShopDataCrawlPerfFixture fixture = new ShopDataCrawlPerfFixture(objectMapper);
|
||||||
|
List<ShopDataCrawlResultItemVo> oneK =
|
||||||
|
fixture.generateItems("Shop-A", 1000, 5, true, 0);
|
||||||
|
List<ShopDataCrawlResultItemVo> fiveK =
|
||||||
|
fixture.generateItems("Shop-B", 5000, 5, true, 500);
|
||||||
|
|
||||||
|
int oneKTotal = oneK.get(0).getCountryResults().stream().mapToInt(c -> c.getItems().size()).sum();
|
||||||
|
int fiveKTotal = fiveK.get(0).getCountryResults().stream().mapToInt(c -> c.getItems().size()).sum();
|
||||||
|
assertEquals(1000, oneKTotal);
|
||||||
|
assertEquals(5000, fiveKTotal, "5k 行规模不丢失");
|
||||||
|
|
||||||
|
ShopDataCrawlPerfFixture.Metrics oneKImage = fixture.samplePayload(oneK, true, 200);
|
||||||
|
ShopDataCrawlPerfFixture.Metrics fiveKImage = fixture.samplePayload(fiveK, true, 200);
|
||||||
|
assertEquals(1000, oneKImage.urlsWithImages());
|
||||||
|
assertEquals(500, fiveKImage.failedThumbUrls(), "失败场景:500 行无缩略图");
|
||||||
|
assertEquals(4500, fiveKImage.urlsWithImages(), "5000 - 500 失败 = 4500 成功");
|
||||||
|
assertEquals(25, fiveKImage.chunkCount(), "5000 行 / 200 每 chunk = 25 个 chunk");
|
||||||
|
assertTrue(fiveKImage.payloadBytes() > oneKImage.payloadBytes(), "更大规模 payload 更大");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_021_image_normal_repeated_operation_is_idempotent() {
|
||||||
|
// 重复执行:同一店铺/行数两次生成结果一致(确定性),不产生重复行。
|
||||||
|
ShopDataCrawlPerfFixture fixture = new ShopDataCrawlPerfFixture(objectMapper);
|
||||||
|
List<ShopDataCrawlResultItemVo> first =
|
||||||
|
fixture.generateItems("Idem-Shop", 1000, 5, true, 100);
|
||||||
|
List<ShopDataCrawlResultItemVo> second =
|
||||||
|
fixture.generateItems("Idem-Shop", 1000, 5, true, 100);
|
||||||
|
|
||||||
|
assertEquals(sampleAsins(first), sampleAsins(second), "两次生成 ASIN 顺序一致");
|
||||||
|
assertEquals(first.get(0).getCountryResults().size(), second.get(0).getCountryResults().size());
|
||||||
|
ShopDataCrawlPerfFixture.Metrics m1 = fixture.samplePayload(first, true, 200);
|
||||||
|
ShopDataCrawlPerfFixture.Metrics m2 = fixture.samplePayload(second, true, 200);
|
||||||
|
assertEquals(m1.payloadBytes(), m2.payloadBytes(), "payload 字节幂等");
|
||||||
|
assertEquals(m1.urlsWithImages(), m2.urlsWithImages());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_021_image_boundary_empty_input() {
|
||||||
|
// 空输入:0 行生成返回空结果项;采样返回全零指标。
|
||||||
|
ShopDataCrawlPerfFixture fixture = new ShopDataCrawlPerfFixture(objectMapper);
|
||||||
|
List<ShopDataCrawlResultItemVo> items = fixture.generateItems("Empty-Shop", 0, 5, true, 0);
|
||||||
|
assertNotNull(items);
|
||||||
|
assertEquals(0, items.size());
|
||||||
|
|
||||||
|
ShopDataCrawlPerfFixture.Metrics metrics = fixture.samplePayload(items, true, 200);
|
||||||
|
assertEquals(0, metrics.rowCount());
|
||||||
|
assertEquals(0, metrics.chunkCount());
|
||||||
|
assertEquals(0, metrics.payloadBytes());
|
||||||
|
assertEquals(0, metrics.urlsWithImages());
|
||||||
|
assertEquals(0, metrics.failedThumbUrls());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_021_image_boundary_single_item() {
|
||||||
|
// 单元素:1 行生成不依赖批量路径;图片成功/失败两种单行场景均可采样。
|
||||||
|
ShopDataCrawlPerfFixture fixture = new ShopDataCrawlPerfFixture(objectMapper);
|
||||||
|
List<ShopDataCrawlResultItemVo> single =
|
||||||
|
fixture.generateItems("Single-Shop", 1, 1, true, 0);
|
||||||
|
assertEquals(1, single.size());
|
||||||
|
assertEquals(1, single.get(0).getCountryResults().size());
|
||||||
|
assertEquals(1, single.get(0).getCountryResults().get(0).getItems().size());
|
||||||
|
ShopDataCrawlPerfFixture.Metrics m = fixture.samplePayload(single, true, 200);
|
||||||
|
assertEquals(1, m.rowCount());
|
||||||
|
assertEquals(1, m.urlsWithImages());
|
||||||
|
|
||||||
|
List<ShopDataCrawlResultItemVo> singleFail =
|
||||||
|
fixture.generateItems("Single-Shop", 1, 1, true, 1);
|
||||||
|
ShopDataCrawlPerfFixture.Metrics mf = fixture.samplePayload(singleFail, true, 200);
|
||||||
|
assertEquals(1, mf.failedThumbUrls(), "1 行失败场景:该行无缩略图");
|
||||||
|
assertEquals(0, mf.urlsWithImages());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_021_image_boundary_limit_and_overflow() {
|
||||||
|
// 上限/超限:超过 MAX_ROWS 拒绝;5000 行基线在预算内完成,payload 有界。
|
||||||
|
ShopDataCrawlPerfFixture fixture = new ShopDataCrawlPerfFixture(objectMapper);
|
||||||
|
IllegalArgumentException overflow = assertThrows(IllegalArgumentException.class,
|
||||||
|
() -> fixture.generateItems("Over-Shop",
|
||||||
|
ShopDataCrawlPerfFixture.MAX_ROWS + 1, 5, true, 0));
|
||||||
|
assertTrue(overflow.getMessage().contains("rowCount"), "超限消息应可识别");
|
||||||
|
|
||||||
|
List<ShopDataCrawlResultItemVo> max =
|
||||||
|
fixture.generateItems("Max-Shop", 5000, 5, true, 0);
|
||||||
|
ShopDataCrawlPerfFixture.Metrics metrics = fixture.samplePayload(max, true, 200);
|
||||||
|
assertEquals(5000, metrics.rowCount());
|
||||||
|
assertTrue(metrics.payloadBytes() < 64L * 1024 * 1024, "5000 行 payload 必须有界");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_021_image_invalid_input_rejected() {
|
||||||
|
// 非法参数:null 店铺、非法国家数、非法失败数、null 列表采样 → 明确异常。
|
||||||
|
ShopDataCrawlPerfFixture fixture = new ShopDataCrawlPerfFixture(objectMapper);
|
||||||
|
IllegalArgumentException nullShop = assertThrows(IllegalArgumentException.class,
|
||||||
|
() -> fixture.generateItems(null, 100, 5, true, 0));
|
||||||
|
assertTrue(nullShop.getMessage().contains("shopName"));
|
||||||
|
assertThrows(IllegalArgumentException.class,
|
||||||
|
() -> fixture.generateItems("Shop", 100, 0, true, 0));
|
||||||
|
assertThrows(IllegalArgumentException.class,
|
||||||
|
() -> fixture.generateItems("Shop", 100, 6, true, 0));
|
||||||
|
assertThrows(IllegalArgumentException.class,
|
||||||
|
() -> fixture.generateItems("Shop", 100, 5, true, -1));
|
||||||
|
assertThrows(IllegalArgumentException.class,
|
||||||
|
() -> fixture.samplePayload(null, true, 200));
|
||||||
|
assertThrows(IllegalArgumentException.class,
|
||||||
|
() -> fixture.samplePayload(
|
||||||
|
fixture.generateItems("Shop", 100, 5, true, 0), true, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_021_image_dependency_failure_releases_resources() throws Exception {
|
||||||
|
// 依赖失败:序列化失败抛 IllegalStateException 且不产生部分结果;恢复后重试成功。
|
||||||
|
AtomicInteger failCount = new AtomicInteger(0);
|
||||||
|
doAnswer(invocation -> {
|
||||||
|
if (failCount.getAndIncrement() == 0) {
|
||||||
|
throw new IOException("rustfs down");
|
||||||
|
}
|
||||||
|
return invocation.callRealMethod();
|
||||||
|
}).when(objectMapper).writeValueAsBytes(any());
|
||||||
|
|
||||||
|
ShopDataCrawlPerfFixture fixture = new ShopDataCrawlPerfFixture(objectMapper);
|
||||||
|
List<ShopDataCrawlResultItemVo> items = fixture.generateItems("Fail-Shop", 1000, 5, true, 0);
|
||||||
|
assertThrows(IllegalStateException.class, () -> fixture.samplePayload(items, true, 200));
|
||||||
|
|
||||||
|
ShopDataCrawlPerfFixture.Metrics recovered = fixture.samplePayload(items, true, 200);
|
||||||
|
assertEquals(1000, recovered.rowCount(), "依赖恢复后重试成功");
|
||||||
|
assertEquals(1000, recovered.urlsWithImages(), "图片统计在失败后仍完整");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<String> sampleAsins(List<ShopDataCrawlResultItemVo> items) {
|
||||||
|
return items.stream()
|
||||||
|
.flatMap(item -> item.getCountryResults().stream())
|
||||||
|
.flatMap(country -> country.getItems().stream())
|
||||||
|
.map(ShopDataCrawlRowDto::getAsin)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
}
|
||||||
+201
@@ -0,0 +1,201 @@
|
|||||||
|
package com.nanri.aiimage.modules.shopdatacrawl.util;
|
||||||
|
|
||||||
|
import com.nanri.aiimage.modules.shopdatacrawl.model.dto.ShopDataCrawlCountryResultDto;
|
||||||
|
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.ShopDataCrawlExcelAssemblyService;
|
||||||
|
import com.nanri.aiimage.modules.similarasin.util.SimilarAsinImageEmbedder;
|
||||||
|
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
import org.mockito.ArgumentCaptor;
|
||||||
|
|
||||||
|
import javax.imageio.ImageIO;
|
||||||
|
import java.awt.image.BufferedImage;
|
||||||
|
import java.io.ByteArrayOutputStream;
|
||||||
|
import java.io.File;
|
||||||
|
import java.io.FileInputStream;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collection;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Task 24:为店铺图片预取增加任务级数量、字节和超时上限。
|
||||||
|
* ShopDataCrawlPrefetchBudget 对预取 URL 集合施加三重预算:
|
||||||
|
* - maxUrls:数量上限,超出按顺序截断(去重后);
|
||||||
|
* - maxBytes:字节估算门,预计总量超过预算时拒绝预取;
|
||||||
|
* - maxTimeoutSeconds:预取超时上限,按 url 数估算的 deadline 被钳制到该上限。
|
||||||
|
* ShopDataCrawlExcelAssemblyService 在 prefetch 前应用预算,截断后的列表进入预取,
|
||||||
|
* 未预取的 URL 由 embed 阶段按原有兜底链路直接下载。
|
||||||
|
*/
|
||||||
|
class ShopDataCrawlPrefetchBudgetTest {
|
||||||
|
@TempDir Path tempDir;
|
||||||
|
|
||||||
|
private static final String URL = "https://thumb.example/";
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_024_image_prefetch_normal_default_path() {
|
||||||
|
// 正常输入:100 个 URL 在预算内全部通过,顺序保留、重复去重。
|
||||||
|
ShopDataCrawlPrefetchBudget budget = ShopDataCrawlPrefetchBudget.of(200, 64L * 1024 * 1024, 120);
|
||||||
|
List<String> urls = new ArrayList<>();
|
||||||
|
for (int i = 0; i < 100; i++) {
|
||||||
|
urls.add(URL + i + ".jpg");
|
||||||
|
}
|
||||||
|
urls.add(urls.get(0));
|
||||||
|
urls.add(urls.get(50));
|
||||||
|
|
||||||
|
List<String> bounded = budget.boundedUrls(urls);
|
||||||
|
assertEquals(100, bounded.size(), "重复 URL 去重后全量通过");
|
||||||
|
assertEquals(urls.get(0), bounded.get(0), "顺序保留");
|
||||||
|
assertFalse(budget.wouldExceedBytes(bounded, 100_000), "100×100KB=10MB 未超 64MB");
|
||||||
|
assertEquals(20_000, budget.timeoutMillisFor(100), "100×200ms=20s 在 [15s,120s] 内");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_024_image_prefetch_normal_multiple_items() {
|
||||||
|
// 批量场景:5000 个 URL 超数量上限 → 截断为前 1000 个,头部保留、尾部丢弃。
|
||||||
|
ShopDataCrawlPrefetchBudget budget = ShopDataCrawlPrefetchBudget.of(1000, 64L * 1024 * 1024, 120);
|
||||||
|
List<String> urls = new ArrayList<>();
|
||||||
|
for (int i = 0; i < 5000; i++) {
|
||||||
|
urls.add(URL + i + ".jpg");
|
||||||
|
}
|
||||||
|
List<String> bounded = budget.boundedUrls(urls);
|
||||||
|
assertEquals(1000, bounded.size(), "截断到数量上限");
|
||||||
|
assertEquals(URL + "0.jpg", bounded.get(0), "头部 URL 保留");
|
||||||
|
assertEquals(URL + "999.jpg", bounded.get(999), "边界 URL 保留");
|
||||||
|
assertFalse(bounded.contains(URL + "1000.jpg"), "尾部 URL 丢弃");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_024_image_prefetch_normal_repeated_operation_is_idempotent() {
|
||||||
|
// 重复执行:同一输入两次截断结果一致,不产生重复项。
|
||||||
|
ShopDataCrawlPrefetchBudget budget = ShopDataCrawlPrefetchBudget.of(50, 64L * 1024 * 1024, 120);
|
||||||
|
List<String> urls = new ArrayList<>();
|
||||||
|
for (int i = 0; i < 100; i++) {
|
||||||
|
urls.add(URL + (i % 20) + ".jpg");
|
||||||
|
}
|
||||||
|
List<String> first = budget.boundedUrls(urls);
|
||||||
|
List<String> second = budget.boundedUrls(urls);
|
||||||
|
assertEquals(first, second, "重复截断幂等");
|
||||||
|
assertEquals(20, first.size(), "20 个唯一 URL 全量通过");
|
||||||
|
assertEquals(first, budget.boundedUrls(urls), "多次调用输出稳定");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_024_image_prefetch_boundary_empty_input() {
|
||||||
|
// 空输入:空/空白集合安全返回空列表;字节估算为 0。
|
||||||
|
ShopDataCrawlPrefetchBudget budget = ShopDataCrawlPrefetchBudget.of(100, 64L * 1024 * 1024, 120);
|
||||||
|
assertEquals(0, budget.boundedUrls(List.of()).size());
|
||||||
|
assertEquals(0, budget.boundedUrls(null).size(), "null 输入安全跳过");
|
||||||
|
assertFalse(budget.wouldExceedBytes(List.of(), 5 * 1024 * 1024), "空列表不超预算");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_024_image_prefetch_boundary_single_item() {
|
||||||
|
// 单元素:1 个 URL 直接通过;超时估算取下限 15s。
|
||||||
|
ShopDataCrawlPrefetchBudget budget = ShopDataCrawlPrefetchBudget.of(100, 64L * 1024 * 1024, 120);
|
||||||
|
List<String> single = budget.boundedUrls(List.of(URL + "only.jpg"));
|
||||||
|
assertEquals(1, single.size());
|
||||||
|
assertEquals(URL + "only.jpg", single.get(0));
|
||||||
|
assertEquals(15_000, budget.timeoutMillisFor(1), "单 URL 预算 200ms,取下限 15s");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_024_image_prefetch_boundary_limit_and_overflow() throws Exception {
|
||||||
|
// 上限/超限:超数量截断;字节估算超限拒绝;超时钳制到任务上限;
|
||||||
|
// Excel 组装实际只预取预算内的 URL,其余走 embed 兜底。
|
||||||
|
ShopDataCrawlPrefetchBudget budget = ShopDataCrawlPrefetchBudget.of(5, 64L * 1024 * 1024, 60);
|
||||||
|
List<String> urls = new ArrayList<>();
|
||||||
|
for (int i = 0; i < 10; i++) {
|
||||||
|
urls.add(URL + i + ".jpg");
|
||||||
|
}
|
||||||
|
assertEquals(5, budget.boundedUrls(urls).size(), "超数量上限截断");
|
||||||
|
assertFalse(budget.wouldExceedBytes(urls, 5 * 1024 * 1024), "10×5MB=50MB<64MB 不应超限");
|
||||||
|
assertTrue(budget.wouldExceedBytes(
|
||||||
|
java.util.stream.IntStream.range(0, 2000).mapToObj(i -> URL + i + ".jpg").toList(),
|
||||||
|
5 * 1024 * 1024), "2000×5MB=10GB 超过 64MB");
|
||||||
|
assertEquals(60_000, budget.timeoutMillisFor(1000), "1000×200ms=200s 被钳制到任务上限 60s");
|
||||||
|
|
||||||
|
SimilarAsinImageEmbedder imageEmbedder = mock(SimilarAsinImageEmbedder.class);
|
||||||
|
when(imageEmbedder.fetchAndResizeForCache(any())).thenAnswer(
|
||||||
|
invocation -> new SimilarAsinImageEmbedder.ResizedImage(jpegBytes(), 2, 2));
|
||||||
|
ShopDataCrawlExcelAssemblyService service =
|
||||||
|
new ShopDataCrawlExcelAssemblyService(imageEmbedder, 64L * 1024 * 1024, 5);
|
||||||
|
|
||||||
|
List<ShopDataCrawlResultItemVo> items = new ArrayList<>();
|
||||||
|
for (int i = 0; i < 10; i++) {
|
||||||
|
ShopDataCrawlRowDto row = new ShopDataCrawlRowDto();
|
||||||
|
row.setDate("2026-07-25");
|
||||||
|
row.setAsin("B0" + String.format("%08d", i));
|
||||||
|
row.setCommodityImage(URL + i + ".jpg");
|
||||||
|
ShopDataCrawlCountryResultDto country = new ShopDataCrawlCountryResultDto();
|
||||||
|
country.setCountry("UK");
|
||||||
|
country.setItems(List.of(row));
|
||||||
|
ShopDataCrawlResultItemVo item = new ShopDataCrawlResultItemVo();
|
||||||
|
item.setSuccess(true);
|
||||||
|
item.setCountryResults(List.of(country));
|
||||||
|
items.add(item);
|
||||||
|
}
|
||||||
|
File output = tempDir.resolve("budget.xlsx").toFile();
|
||||||
|
service.writeWorkbook(output, items);
|
||||||
|
|
||||||
|
@SuppressWarnings({"unchecked", "rawtypes"})
|
||||||
|
ArgumentCaptor<Collection> captor = ArgumentCaptor.forClass(Collection.class);
|
||||||
|
verify(imageEmbedder).prefetch(captor.capture(), any());
|
||||||
|
assertEquals(5, captor.getValue().size(), "预取仅收到预算内 URL");
|
||||||
|
try (XSSFWorkbook workbook = new XSSFWorkbook(new FileInputStream(output))) {
|
||||||
|
assertEquals(10, workbook.getSheet("英国").getLastRowNum(), "全部 10 行写入,未预取 URL 兜底成功");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_024_image_prefetch_invalid_input_rejected() {
|
||||||
|
// 非法参数:非正数量/字节/超时上限 → 明确异常与字段名。
|
||||||
|
IllegalArgumentException urls = assertThrows(IllegalArgumentException.class,
|
||||||
|
() -> ShopDataCrawlPrefetchBudget.of(0, 64L * 1024 * 1024, 120));
|
||||||
|
assertTrue(urls.getMessage().contains("maxUrls"));
|
||||||
|
assertThrows(IllegalArgumentException.class,
|
||||||
|
() -> ShopDataCrawlPrefetchBudget.of(-1, 64L * 1024 * 1024, 120));
|
||||||
|
assertThrows(IllegalArgumentException.class,
|
||||||
|
() -> ShopDataCrawlPrefetchBudget.of(100, 0, 120));
|
||||||
|
assertThrows(IllegalArgumentException.class,
|
||||||
|
() -> ShopDataCrawlPrefetchBudget.of(100, -5, 120));
|
||||||
|
assertThrows(IllegalArgumentException.class,
|
||||||
|
() -> ShopDataCrawlPrefetchBudget.of(100, 64L * 1024 * 1024, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_024_image_prefetch_dependency_failure_releases_resources() {
|
||||||
|
// 依赖失败:URL 集合含 null/空白条目(失败源)时安全跳过,结果仍有界且稳定。
|
||||||
|
ShopDataCrawlPrefetchBudget budget = ShopDataCrawlPrefetchBudget.of(100, 64L * 1024 * 1024, 120);
|
||||||
|
List<String> dirty = new ArrayList<>();
|
||||||
|
dirty.add(URL + "a.jpg");
|
||||||
|
dirty.add(null);
|
||||||
|
dirty.add(" ");
|
||||||
|
dirty.add(URL + "b.jpg");
|
||||||
|
dirty.add(null);
|
||||||
|
List<String> bounded = budget.boundedUrls(dirty);
|
||||||
|
assertEquals(2, bounded.size(), "null/空白条目跳过,不产生非法 URL");
|
||||||
|
assertEquals(URL + "a.jpg", bounded.get(0));
|
||||||
|
assertEquals(URL + "b.jpg", bounded.get(1));
|
||||||
|
assertEquals(bounded, budget.boundedUrls(dirty), "失败输入后再次调用仍稳定");
|
||||||
|
assertEquals(2, budget.boundedUrls(dirty).size(), "失败输入不残留状态");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] jpegBytes() throws Exception {
|
||||||
|
BufferedImage image = new BufferedImage(2, 2, BufferedImage.TYPE_INT_RGB);
|
||||||
|
ByteArrayOutputStream output = new ByteArrayOutputStream();
|
||||||
|
ImageIO.write(image, "jpg", output);
|
||||||
|
return output.toByteArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
+156
@@ -0,0 +1,156 @@
|
|||||||
|
package com.nanri.aiimage.modules.similarasin.client;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.nanri.aiimage.config.SimilarAsinProperties;
|
||||||
|
import com.nanri.aiimage.modules.similarasin.model.dto.SimilarAsinResultRowDto;
|
||||||
|
import com.nanri.aiimage.modules.similarasin.util.SimilarAsinLogSupport;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.lang.reflect.Method;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Task 19:Coze 请求/响应及 Python 回传日志改为采样、截断和 DEBUG 级别。
|
||||||
|
* 新工具 SimilarAsinLogSupport 提供两条纯函数:
|
||||||
|
* - truncate:正文超限截断为 maxChars + 后缀,长文本不占满日志;
|
||||||
|
* - shouldLog:每 everyN 次采样一次(counter % everyN == 0),控制轮询/逐行日志量。
|
||||||
|
* Coze 客户端正文日志与 Python 回传逐行日志经该工具后输出有界、可识别。
|
||||||
|
*/
|
||||||
|
class SimilarAsinCozeClientLoggingTest {
|
||||||
|
|
||||||
|
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_019_logging_normal_default_path() {
|
||||||
|
// 正常输入:短文本不截断;每次采样(everyN=1)恒记录。
|
||||||
|
assertEquals("hello", SimilarAsinLogSupport.truncate("hello", 100));
|
||||||
|
assertEquals("", SimilarAsinLogSupport.truncate(null, 100), "null 文本返回空串");
|
||||||
|
assertTrue(SimilarAsinLogSupport.shouldLog(0, 1), "everyN=1 恒采样");
|
||||||
|
assertTrue(SimilarAsinLogSupport.shouldLog(7, 1), "everyN=1 不抑制任何计数");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_019_logging_normal_multiple_items() throws Exception {
|
||||||
|
// 批量场景:多个长文本各自截断、结果互不影响;采样按每 everyN 次一次。
|
||||||
|
String longA = "A".repeat(3000);
|
||||||
|
String longB = "B".repeat(5000);
|
||||||
|
String truncatedA = SimilarAsinLogSupport.truncate(longA, 100);
|
||||||
|
String truncatedB = SimilarAsinLogSupport.truncate(longB, 100);
|
||||||
|
assertTrue(truncatedA.startsWith("A".repeat(100)));
|
||||||
|
assertTrue(truncatedB.startsWith("B".repeat(100)));
|
||||||
|
assertTrue(truncatedA.length() < longA.length(), "截断后必须短于原文");
|
||||||
|
|
||||||
|
int sampled = 0;
|
||||||
|
for (int i = 0; i < 30; i++) {
|
||||||
|
if (SimilarAsinLogSupport.shouldLog(i, 10)) {
|
||||||
|
sampled++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assertEquals(3, sampled, "everyN=10 在 0..29 内应采样 0/10/20 共 3 次");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_019_logging_normal_repeated_operation_is_idempotent() throws Exception {
|
||||||
|
// 重复执行:同一文本多次截断结果一致;同一计数采样判定一致。
|
||||||
|
String text = "x".repeat(1234);
|
||||||
|
String first = SimilarAsinLogSupport.truncate(text, 500);
|
||||||
|
String second = SimilarAsinLogSupport.truncate(text, 500);
|
||||||
|
assertEquals(first, second, "重复截断必须产生相同输出");
|
||||||
|
assertEquals(first, SimilarAsinLogSupport.truncate(text, 500), "截断幂等");
|
||||||
|
assertEquals(SimilarAsinLogSupport.shouldLog(20, 10), SimilarAsinLogSupport.shouldLog(20, 10));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_019_logging_boundary_empty_input() throws Exception {
|
||||||
|
// 空输入:null/空串安全返回空串;空白串按长度截断语义原样保留。
|
||||||
|
assertEquals("", SimilarAsinLogSupport.truncate(null, 100));
|
||||||
|
assertEquals("", SimilarAsinLogSupport.truncate("", 100));
|
||||||
|
assertEquals(" ", SimilarAsinLogSupport.truncate(" ", 100), "空白串不做 trim,按原样返回");
|
||||||
|
assertTrue(SimilarAsinLogSupport.shouldLog(0, 10), "计数 0 必须采样(首条不丢)");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_019_logging_boundary_single_item() throws Exception {
|
||||||
|
// 单元素边界:恰好等于上限不截断;超 1 字符截断并带长度后缀。
|
||||||
|
String exact = "y".repeat(100);
|
||||||
|
assertEquals(exact, SimilarAsinLogSupport.truncate(exact, 100), "恰好等于上限不截断");
|
||||||
|
String over = "y".repeat(101);
|
||||||
|
String truncated = SimilarAsinLogSupport.truncate(over, 100);
|
||||||
|
assertEquals(over.substring(0, 100), truncated.substring(0, 100), "截断保留前缀");
|
||||||
|
assertTrue(truncated.contains("101"), "截断输出应携带原文长度");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_019_logging_boundary_limit_and_overflow() throws Exception {
|
||||||
|
// 上限/超限:10 万字符文本截断后有界、不再无界增长;采样 everyN 超限不抑制。
|
||||||
|
String huge = "z".repeat(100_000);
|
||||||
|
String truncated = SimilarAsinLogSupport.truncate(huge, 2000);
|
||||||
|
assertTrue(truncated.length() < 2200, "截断输出必须有界,实际=" + truncated.length());
|
||||||
|
assertTrue(truncated.length() > 2000, "应保留 2000 前缀 + 后缀");
|
||||||
|
assertTrue(truncated.endsWith("]"), "截断输出带可识别后缀");
|
||||||
|
assertTrue(SimilarAsinLogSupport.shouldLog(0, Integer.MAX_VALUE), "计数 0 在超大 everyN 下仍采样");
|
||||||
|
assertFalse(SimilarAsinLogSupport.shouldLog(1, Integer.MAX_VALUE), "非零计数在超大 everyN 下抑制");
|
||||||
|
assertFalse(SimilarAsinLogSupport.shouldLog(31, 10), "非采样点必须被抑制");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_019_logging_invalid_input_rejected() throws Exception {
|
||||||
|
// 非法参数:maxChars ≤ 0 时原样返回(不截断);null 文本始终空串。
|
||||||
|
String text = "invalid-max";
|
||||||
|
assertEquals(text, SimilarAsinLogSupport.truncate(text, 0), "maxChars=0 不截断");
|
||||||
|
assertEquals(text, SimilarAsinLogSupport.truncate(text, -1), "负上限不截断");
|
||||||
|
assertEquals("", SimilarAsinLogSupport.truncate(null, -5));
|
||||||
|
assertTrue(SimilarAsinLogSupport.shouldLog(5, 0), "everyN=0 视为恒采样");
|
||||||
|
assertTrue(SimilarAsinLogSupport.shouldLog(5, -3), "负 everyN 视为恒采样");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_019_logging_dependency_failure_releases_resources() throws Exception {
|
||||||
|
// 依赖失败:含代理对(emoji)的长文本截断不抛异常、不产生孤立代理项;
|
||||||
|
// 计数接近 Long.MAX_VALUE 不溢出;掩码后的请求体经截断管线输出有界且不泄漏密钥。
|
||||||
|
String emoji = "🚀".repeat(3000);
|
||||||
|
String truncatedEmoji = SimilarAsinLogSupport.truncate(emoji, 100);
|
||||||
|
assertNotNull(truncatedEmoji);
|
||||||
|
assertTrue(truncatedEmoji.length() < emoji.length(), "代理对文本必须被截断");
|
||||||
|
assertFalse(SimilarAsinLogSupport.shouldLog(Long.MAX_VALUE, 10), "极大计数采样判定不抛异常");
|
||||||
|
|
||||||
|
List<SimilarAsinResultRowDto> rows = new java.util.ArrayList<>();
|
||||||
|
SimilarAsinResultRowDto row = new SimilarAsinResultRowDto();
|
||||||
|
row.setAsin("B0SECRET1");
|
||||||
|
row.setUrl("https://m.media-amazon.com/images/I/" + "U".repeat(500) + ".jpg");
|
||||||
|
row.setTitle("T".repeat(5000));
|
||||||
|
row.setSku("SKU-SECRET");
|
||||||
|
rows.add(row);
|
||||||
|
SimilarAsinCozeClient client = new SimilarAsinCozeClient(new SimilarAsinProperties(), objectMapper, null);
|
||||||
|
Method maskMethod = SimilarAsinCozeClient.class.getDeclaredMethod(
|
||||||
|
"maskCozeRequestBody", Map.class);
|
||||||
|
maskMethod.setAccessible(true);
|
||||||
|
Method buildMethod = SimilarAsinCozeClient.class.getDeclaredMethod(
|
||||||
|
"buildParameters", List.class, String.class, String.class, boolean.class);
|
||||||
|
buildMethod.setAccessible(true);
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
Map<String, Object> parameters = (Map<String, Object>) buildMethod.invoke(client, rows, "", "supersecretkey", true);
|
||||||
|
Map<String, Object> body = new java.util.LinkedHashMap<>();
|
||||||
|
body.put("workflow_id", "wf-1");
|
||||||
|
body.put("parameters", parameters);
|
||||||
|
body.put("api_key", "supersecretkey");
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
Map<String, Object> masked = (Map<String, Object>) maskMethod.invoke(client, body);
|
||||||
|
Method writeMethod = SimilarAsinCozeClient.class.getDeclaredMethod("writeJson", Object.class);
|
||||||
|
writeMethod.setAccessible(true);
|
||||||
|
String maskedJson = (String) writeMethod.invoke(client, masked);
|
||||||
|
String logged = SimilarAsinLogSupport.truncate(maskedJson, 2000);
|
||||||
|
|
||||||
|
assertTrue(logged.length() < maskedJson.length(), "超长掩码 body 必须截断");
|
||||||
|
assertFalse(logged.contains("supersecretkey"), "日志不得泄漏完整 api_key");
|
||||||
|
assertTrue(logged.contains("B0SECRET1"), "截断保留正文关键字段");
|
||||||
|
assertTrue(logged.length() < 2500, "截断输出必须有界");
|
||||||
|
}
|
||||||
|
}
|
||||||
+226
@@ -0,0 +1,226 @@
|
|||||||
|
package com.nanri.aiimage.modules.similarasin.service;
|
||||||
|
|
||||||
|
import com.nanri.aiimage.config.SimilarAsinProperties;
|
||||||
|
import com.nanri.aiimage.modules.similarasin.util.SimilarAsinImageEmbedder;
|
||||||
|
import com.nanri.aiimage.modules.task.mapper.TaskImageCacheMapper;
|
||||||
|
import org.junit.jupiter.api.AfterEach;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.InjectMocks;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
|
import static org.mockito.Mockito.doAnswer;
|
||||||
|
import static org.mockito.Mockito.lenient;
|
||||||
|
import static org.mockito.Mockito.never;
|
||||||
|
import static org.mockito.Mockito.times;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Task 15:图片缓存访问时间更新改为异步批量刷新,减少逐图 UPDATE。
|
||||||
|
* lookup 命中不再同步 touchLastUsed,而是进入内存 touch 缓冲(按 url_hash 去重),
|
||||||
|
* 由定时任务/阈值触发 flushPendingTouches 批量 touchLastUsedBatch 刷新;
|
||||||
|
* 缓冲有大小上限,超限立即刷新,不会无界增长;失败 best-effort 吞掉。
|
||||||
|
*/
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class SimilarAsinImagePrefetchServiceAsyncTouchTest {
|
||||||
|
|
||||||
|
@Mock private SimilarAsinImageEmbedder imageEmbedder;
|
||||||
|
@Mock private TaskImageCacheMapper taskImageCacheMapper;
|
||||||
|
@Mock private SimilarAsinProperties properties;
|
||||||
|
|
||||||
|
@InjectMocks private SimilarAsinImagePrefetchService service;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
lenient().when(properties.isImageDbCacheEnabled()).thenReturn(true);
|
||||||
|
lenient().when(properties.getImageCacheTouchFlushThreshold()).thenReturn(1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
void shutdown() {
|
||||||
|
service.shutdown();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String sha256Hex(String value) throws Exception {
|
||||||
|
java.security.MessageDigest md = java.security.MessageDigest.getInstance("SHA-256");
|
||||||
|
byte[] digest = md.digest(value.getBytes(java.nio.charset.StandardCharsets.UTF_8));
|
||||||
|
StringBuilder sb = new StringBuilder(digest.length * 2);
|
||||||
|
for (byte b : digest) {
|
||||||
|
sb.append(String.format("%02x", b & 0xFF));
|
||||||
|
}
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void stubHit(String url, byte[] bytes) throws Exception {
|
||||||
|
when(taskImageCacheMapper.selectBytesByUrlHash(sha256Hex(url))).thenReturn(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_015_image_cache_normal_default_path() throws Exception {
|
||||||
|
// 正常输入:命中不立即写 DB,进入缓冲;flush 后批量 touch 一次。
|
||||||
|
String url = "https://img.example.com/hit.jpg";
|
||||||
|
String hash = sha256Hex(url);
|
||||||
|
byte[] bytes = new byte[]{1, 2, 3};
|
||||||
|
stubHit(url, bytes);
|
||||||
|
|
||||||
|
byte[] result = service.lookup(url);
|
||||||
|
assertEquals(bytes, result, "命中必须返回缓存字节");
|
||||||
|
verify(taskImageCacheMapper, never()).touchLastUsed(anyString());
|
||||||
|
verify(taskImageCacheMapper, never()).touchLastUsedBatch(any());
|
||||||
|
|
||||||
|
service.flushPendingTouches();
|
||||||
|
verify(taskImageCacheMapper, times(1)).touchLastUsedBatch(List.of(hash));
|
||||||
|
verify(taskImageCacheMapper, never()).touchLastUsed(anyString());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_015_image_cache_normal_multiple_items() throws Exception {
|
||||||
|
// 批量场景:多次命中进入同一缓冲,flush 合并为一次批量 touch,覆盖全部命中。
|
||||||
|
List<String> urls = List.of("https://img.example.com/a.jpg", "https://img.example.com/b.jpg",
|
||||||
|
"https://img.example.com/c.jpg");
|
||||||
|
Set<String> hashes = new HashSet<>();
|
||||||
|
for (int i = 0; i < urls.size(); i++) {
|
||||||
|
hashes.add(sha256Hex(urls.get(i)));
|
||||||
|
stubHit(urls.get(i), new byte[]{(byte) (i + 1)});
|
||||||
|
}
|
||||||
|
for (String url : urls) {
|
||||||
|
assertNotNull(service.lookup(url), "命中返回字节");
|
||||||
|
}
|
||||||
|
verify(taskImageCacheMapper, never()).touchLastUsedBatch(any());
|
||||||
|
|
||||||
|
service.flushPendingTouches();
|
||||||
|
org.mockito.ArgumentCaptor<List<String>> captor = org.mockito.ArgumentCaptor.forClass(List.class);
|
||||||
|
verify(taskImageCacheMapper, times(1)).touchLastUsedBatch(captor.capture());
|
||||||
|
assertEquals(hashes, new HashSet<>(captor.getValue()), "一次批量 touch 覆盖全部命中 hash");
|
||||||
|
verify(taskImageCacheMapper, never()).touchLastUsed(anyString());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_015_image_cache_normal_repeated_operation_is_idempotent() throws Exception {
|
||||||
|
// 重复执行同一输入:同一 url 多次命中只 touch 一次;重复 flush 无多余请求。
|
||||||
|
String url = "https://img.example.com/same.jpg";
|
||||||
|
String hash = sha256Hex(url);
|
||||||
|
stubHit(url, new byte[]{5});
|
||||||
|
when(taskImageCacheMapper.selectBytesByUrlHash(hash)).thenReturn(new byte[]{5});
|
||||||
|
|
||||||
|
service.lookup(url);
|
||||||
|
service.lookup(url);
|
||||||
|
service.lookup(url);
|
||||||
|
service.flushPendingTouches();
|
||||||
|
verify(taskImageCacheMapper, times(1)).touchLastUsedBatch(List.of(hash));
|
||||||
|
|
||||||
|
service.flushPendingTouches();
|
||||||
|
verify(taskImageCacheMapper, times(1)).touchLastUsedBatch(any());
|
||||||
|
verify(taskImageCacheMapper, never()).touchLastUsed(anyString());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_015_image_cache_boundary_empty_input() throws Exception {
|
||||||
|
// 空输入:null/空白 url 不进入缓冲;flush 空缓冲不产生任何数据库访问。
|
||||||
|
assertNull(service.lookup(null));
|
||||||
|
assertNull(service.lookup(""));
|
||||||
|
assertNull(service.lookup(" "));
|
||||||
|
service.flushPendingTouches();
|
||||||
|
verify(taskImageCacheMapper, never()).touchLastUsedBatch(any());
|
||||||
|
verify(taskImageCacheMapper, never()).touchLastUsed(anyString());
|
||||||
|
verify(taskImageCacheMapper, never()).selectBytesByUrlHash(anyString());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_015_image_cache_boundary_single_item() throws Exception {
|
||||||
|
// 单条命中:flush 后单元素批量 touch,不依赖批量路径。
|
||||||
|
String url = "https://img.example.com/single.jpg";
|
||||||
|
String hash = sha256Hex(url);
|
||||||
|
stubHit(url, new byte[]{7});
|
||||||
|
|
||||||
|
assertNotNull(service.lookup(url));
|
||||||
|
service.flushPendingTouches();
|
||||||
|
verify(taskImageCacheMapper, times(1)).touchLastUsedBatch(List.of(hash));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_015_image_cache_boundary_limit_and_overflow() throws Exception {
|
||||||
|
// 缓冲达到阈值立即刷新,不无界增长;刷新后继续累积。
|
||||||
|
lenient().when(properties.getImageCacheTouchFlushThreshold()).thenReturn(3);
|
||||||
|
List<String> urls = List.of("https://img.example.com/o1.jpg", "https://img.example.com/o2.jpg",
|
||||||
|
"https://img.example.com/o3.jpg", "https://img.example.com/o4.jpg",
|
||||||
|
"https://img.example.com/o5.jpg");
|
||||||
|
for (String url : urls) {
|
||||||
|
stubHit(url, new byte[]{1});
|
||||||
|
}
|
||||||
|
org.mockito.ArgumentCaptor<List<String>> captor = org.mockito.ArgumentCaptor.forClass(List.class);
|
||||||
|
|
||||||
|
for (int i = 0; i < 3; i++) {
|
||||||
|
service.lookup(urls.get(i));
|
||||||
|
}
|
||||||
|
verify(taskImageCacheMapper, times(1)).touchLastUsedBatch(captor.capture());
|
||||||
|
assertEquals(3, captor.getValue().size(), "第 3 条命中触发阈值立即刷新 3 条");
|
||||||
|
|
||||||
|
service.lookup(urls.get(3));
|
||||||
|
service.lookup(urls.get(4));
|
||||||
|
verify(taskImageCacheMapper, times(1)).touchLastUsedBatch(any());
|
||||||
|
|
||||||
|
service.flushPendingTouches();
|
||||||
|
verify(taskImageCacheMapper, times(2)).touchLastUsedBatch(captor.capture());
|
||||||
|
assertEquals(2, captor.getValue().size(), "剩余 2 条在 flush 时刷新,缓冲不残留、不无界增长");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_015_image_cache_invalid_input_rejected() {
|
||||||
|
// 非法输入:db cache 关闭时 lookup 直接返回 null,不进入缓冲、不访问数据库。
|
||||||
|
lenient().when(properties.isImageDbCacheEnabled()).thenReturn(false);
|
||||||
|
assertNull(service.lookup("https://img.example.com/a.jpg"));
|
||||||
|
service.flushPendingTouches();
|
||||||
|
verify(taskImageCacheMapper, never()).selectBytesByUrlHash(anyString());
|
||||||
|
verify(taskImageCacheMapper, never()).touchLastUsedBatch(any());
|
||||||
|
verify(taskImageCacheMapper, never()).touchLastUsed(anyString());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_015_image_cache_dependency_failure_releases_resources() throws Exception {
|
||||||
|
// 依赖失败:批量 touch 抛异常时吞掉不阻塞 lookup、缓冲已排空无残留;
|
||||||
|
// 恢复后重新入队 flush 成功。
|
||||||
|
String url = "https://img.example.com/fail.jpg";
|
||||||
|
String hash = sha256Hex(url);
|
||||||
|
stubHit(url, new byte[]{3});
|
||||||
|
AtomicInteger failures = new AtomicInteger(0);
|
||||||
|
List<String> capturedArgs = new java.util.ArrayList<>();
|
||||||
|
doAnswer(invocation -> {
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
List<String> arg = (List<String>) invocation.getArgument(0);
|
||||||
|
capturedArgs.addAll(arg);
|
||||||
|
if (failures.getAndIncrement() == 0) {
|
||||||
|
throw new IllegalStateException("db down");
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}).when(taskImageCacheMapper).touchLastUsedBatch(any());
|
||||||
|
|
||||||
|
assertNotNull(service.lookup(url), "touch 失败不阻断 lookup 返回缓存字节");
|
||||||
|
assertThrows(Exception.class, () -> service.flushPendingTouches(), "首次 flush 抛错(由调用方吞掉)");
|
||||||
|
verify(taskImageCacheMapper, times(1)).touchLastUsedBatch(any());
|
||||||
|
|
||||||
|
assertNotNull(service.lookup(url), "失败后再次命中重新入队");
|
||||||
|
service.flushPendingTouches();
|
||||||
|
verify(taskImageCacheMapper, times(2)).touchLastUsedBatch(any());
|
||||||
|
assertEquals(List.of(hash, hash), capturedArgs, "两次 touch 都覆盖命中 hash,失败后恢复成功");
|
||||||
|
verify(taskImageCacheMapper, never()).touchLastUsed(anyString());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void assertNull(Object value) {
|
||||||
|
org.junit.jupiter.api.Assertions.assertNull(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
+297
@@ -0,0 +1,297 @@
|
|||||||
|
package com.nanri.aiimage.modules.similarasin.service;
|
||||||
|
|
||||||
|
import com.nanri.aiimage.config.SimilarAsinProperties;
|
||||||
|
import com.nanri.aiimage.modules.similarasin.util.SimilarAsinImageEmbedder;
|
||||||
|
import com.nanri.aiimage.modules.task.mapper.TaskImageCacheMapper;
|
||||||
|
import com.nanri.aiimage.modules.task.model.entity.TaskImageCacheEntity;
|
||||||
|
import org.junit.jupiter.api.AfterEach;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.InjectMocks;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
|
||||||
|
import java.lang.reflect.Method;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.Mockito.doAnswer;
|
||||||
|
import static org.mockito.Mockito.lenient;
|
||||||
|
import static org.mockito.Mockito.never;
|
||||||
|
import static org.mockito.Mockito.times;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Task 14:图片 DB cache 改为批量读取缩略图,并只更新实际命中的 last_used_at。
|
||||||
|
* 批量 lookup 入口(lookupBatch)一次 IN 查询返回命中字节 Map;
|
||||||
|
* last_used_at 只对实际命中的 url_hash 更新(touch 集合 = 命中集合),
|
||||||
|
* 未命中 url 不产生任何 touch/insert。单 URL 旧入口 lookup 语义保持兼容。
|
||||||
|
*/
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class SimilarAsinImagePrefetchServiceBatchTest {
|
||||||
|
|
||||||
|
@Mock private SimilarAsinImageEmbedder imageEmbedder;
|
||||||
|
@Mock private TaskImageCacheMapper taskImageCacheMapper;
|
||||||
|
@Mock private SimilarAsinProperties properties;
|
||||||
|
|
||||||
|
@InjectMocks private SimilarAsinImagePrefetchService service;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
lenient().when(properties.isImageDbCacheEnabled()).thenReturn(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
void shutdown() {
|
||||||
|
service.shutdown();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String sha256Hex(String value) throws Exception {
|
||||||
|
java.security.MessageDigest md = java.security.MessageDigest.getInstance("SHA-256");
|
||||||
|
byte[] digest = md.digest(value.getBytes(java.nio.charset.StandardCharsets.UTF_8));
|
||||||
|
StringBuilder sb = new StringBuilder(digest.length * 2);
|
||||||
|
for (byte b : digest) {
|
||||||
|
sb.append(String.format("%02x", b & 0xFF));
|
||||||
|
}
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** stub 批量命中读取:只对 hits 集合内的 hash 返回字节。 */
|
||||||
|
private void stubBatchRead(List<String> hits, Map<String, byte[]> bytesByHash) {
|
||||||
|
when(taskImageCacheMapper.selectBytesByUrlHashes(any())).thenAnswer(invocation -> {
|
||||||
|
List<String> hashes = invocation.getArgument(0);
|
||||||
|
List<TaskImageCacheEntity> rows = new ArrayList<>();
|
||||||
|
for (String hash : hashes) {
|
||||||
|
if (hits.contains(hash)) {
|
||||||
|
TaskImageCacheEntity row = new TaskImageCacheEntity();
|
||||||
|
row.setUrlHash(hash);
|
||||||
|
row.setImageBytes(bytesByHash.get(hash));
|
||||||
|
rows.add(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 调用批量入口,按 url 顺序返回字节(未命中为 null)。 */
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
private static List<byte[]> invokeLookupBatch(SimilarAsinImagePrefetchService svc, List<String> urls) throws Exception {
|
||||||
|
Method m = SimilarAsinImagePrefetchService.class.getDeclaredMethod("lookupBatch", List.class);
|
||||||
|
m.setAccessible(true);
|
||||||
|
return (List<byte[]>) m.invoke(svc, urls);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_014_image_normal_default_path() throws Exception {
|
||||||
|
// 正常输入:命中与未命中混排,批量读回命中字节,touch 只覆盖实际命中。
|
||||||
|
List<String> urls = List.of("https://img.example.com/a.jpg", "https://img.example.com/b.jpg");
|
||||||
|
List<String> hits = List.of(sha256Hex(urls.get(0)));
|
||||||
|
byte[] bytesA = new byte[]{1, 2, 3};
|
||||||
|
stubBatchRead(hits, Map.of(sha256Hex(urls.get(0)), bytesA));
|
||||||
|
|
||||||
|
List<byte[]> result = invokeLookupBatch(service, urls);
|
||||||
|
|
||||||
|
assertEquals(2, result.size(), "批量入口必须按输入顺序返回");
|
||||||
|
assertEquals(bytesA, result.get(0), "命中行返回缓存字节");
|
||||||
|
assertNull(result.get(1), "未命中行返回 null,不虚构缓存内容");
|
||||||
|
verify(taskImageCacheMapper, times(1)).selectBytesByUrlHashes(any());
|
||||||
|
verify(taskImageCacheMapper, times(1)).touchLastUsedBatch(List.of(sha256Hex(urls.get(0))));
|
||||||
|
verify(taskImageCacheMapper, never()).touchLastUsed(anyString());
|
||||||
|
verify(taskImageCacheMapper, never()).insert(any(TaskImageCacheEntity.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_014_image_normal_multiple_items() throws Exception {
|
||||||
|
// 批量场景:全命中多 url,一次 IN 查询返回全部字节,touch 覆盖全部命中。
|
||||||
|
List<String> urls = List.of("https://img.example.com/a.jpg", "https://img.example.com/b.jpg",
|
||||||
|
"https://img.example.com/c.jpg");
|
||||||
|
List<String> hits = new ArrayList<>();
|
||||||
|
Map<String, byte[]> bytesByHash = new java.util.LinkedHashMap<>();
|
||||||
|
for (int i = 0; i < urls.size(); i++) {
|
||||||
|
hits.add(sha256Hex(urls.get(i)));
|
||||||
|
bytesByHash.put(hits.get(i), new byte[]{(byte) (i + 1)});
|
||||||
|
}
|
||||||
|
stubBatchRead(hits, bytesByHash);
|
||||||
|
|
||||||
|
List<byte[]> result = invokeLookupBatch(service, urls);
|
||||||
|
|
||||||
|
assertEquals(3, result.size());
|
||||||
|
for (int i = 0; i < urls.size(); i++) {
|
||||||
|
assertEquals(bytesByHash.get(sha256Hex(urls.get(i))), result.get(i), "顺序稳定、字节不丢失");
|
||||||
|
}
|
||||||
|
verify(taskImageCacheMapper, times(1)).selectBytesByUrlHashes(any());
|
||||||
|
verify(taskImageCacheMapper, times(1)).touchLastUsedBatch(new ArrayList<>(hits));
|
||||||
|
verify(taskImageCacheMapper, never()).touchLastUsed(anyString());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_014_image_normal_repeated_operation_is_idempotent() throws Exception {
|
||||||
|
// 重复执行同一输入:每次行为一致,不产生重复请求/重复 touch。
|
||||||
|
List<String> urls = List.of("https://img.example.com/a.jpg");
|
||||||
|
String hash = sha256Hex(urls.get(0));
|
||||||
|
stubBatchRead(List.of(hash), Map.of(hash, new byte[]{9}));
|
||||||
|
|
||||||
|
invokeLookupBatch(service, urls);
|
||||||
|
invokeLookupBatch(service, urls);
|
||||||
|
|
||||||
|
verify(taskImageCacheMapper, times(2)).selectBytesByUrlHashes(any());
|
||||||
|
verify(taskImageCacheMapper, times(2)).touchLastUsedBatch(List.of(hash));
|
||||||
|
verify(taskImageCacheMapper, never()).insert(any(TaskImageCacheEntity.class));
|
||||||
|
verify(taskImageCacheMapper, never()).touchLastUsed(anyString());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_014_image_boundary_empty_input() throws Exception {
|
||||||
|
// 空输入:null/空列表安全返回空结果,不产生任何数据库访问。
|
||||||
|
List<byte[]> nullResult = invokeLookupBatch(service, null);
|
||||||
|
assertNotNull(nullResult);
|
||||||
|
assertTrue(nullResult.isEmpty());
|
||||||
|
|
||||||
|
List<byte[]> emptyResult = invokeLookupBatch(service, List.of());
|
||||||
|
assertNotNull(emptyResult);
|
||||||
|
assertTrue(emptyResult.isEmpty());
|
||||||
|
|
||||||
|
verify(taskImageCacheMapper, never()).selectBytesByUrlHashes(any());
|
||||||
|
verify(taskImageCacheMapper, never()).touchLastUsedBatch(any());
|
||||||
|
verify(taskImageCacheMapper, never()).touchLastUsed(anyString());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_014_image_boundary_single_item() throws Exception {
|
||||||
|
// 单 url:不依赖批量路径,命中时单次查询 + 单次 touch。
|
||||||
|
String url = "https://img.example.com/single.jpg";
|
||||||
|
String hash = sha256Hex(url);
|
||||||
|
stubBatchRead(List.of(hash), Map.of(hash, new byte[]{7}));
|
||||||
|
|
||||||
|
List<byte[]> result = invokeLookupBatch(service, List.of(url));
|
||||||
|
|
||||||
|
assertEquals(1, result.size());
|
||||||
|
assertEquals(7, result.get(0)[0]);
|
||||||
|
verify(taskImageCacheMapper, times(1)).selectBytesByUrlHashes(any());
|
||||||
|
verify(taskImageCacheMapper, times(1)).touchLastUsedBatch(List.of(hash));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_014_image_boundary_limit_and_overflow() throws Exception {
|
||||||
|
// 大批量(超过单批上限 500):分片查询,命中 touch 只覆盖命中集合。
|
||||||
|
List<String> urls = new ArrayList<>();
|
||||||
|
for (int i = 0; i < 1200; i++) {
|
||||||
|
urls.add("https://img.example.com/overflow-" + i + ".jpg");
|
||||||
|
}
|
||||||
|
Map<String, byte[]> bytesByHash = new java.util.LinkedHashMap<>();
|
||||||
|
List<String> hits = new ArrayList<>();
|
||||||
|
for (int i = 0; i < 1200; i += 2) {
|
||||||
|
String hash = sha256Hex(urls.get(i));
|
||||||
|
hits.add(hash);
|
||||||
|
bytesByHash.put(hash, new byte[]{(byte) (i % 100)});
|
||||||
|
}
|
||||||
|
stubBatchRead(hits, bytesByHash);
|
||||||
|
|
||||||
|
List<byte[]> result = invokeLookupBatch(service, urls);
|
||||||
|
|
||||||
|
assertEquals(1200, result.size(), "超大批量结果不丢失、顺序稳定");
|
||||||
|
int hitCount = 0;
|
||||||
|
for (int i = 0; i < 1200; i++) {
|
||||||
|
if (i % 2 == 0) {
|
||||||
|
assertNotNull(result.get(i), "偶数下标命中必须返回字节");
|
||||||
|
hitCount++;
|
||||||
|
} else {
|
||||||
|
assertNull(result.get(i), "奇数下标未命中返回 null");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assertEquals(600, hitCount);
|
||||||
|
verify(taskImageCacheMapper, times(3)).selectBytesByUrlHashes(any());
|
||||||
|
// touch 按单批 500 分片:600 命中 → 2 次 touch 调用,且只覆盖命中集合。
|
||||||
|
var captor = org.mockito.ArgumentCaptor.forClass(List.class);
|
||||||
|
verify(taskImageCacheMapper, times(2)).touchLastUsedBatch(captor.capture());
|
||||||
|
List<List<String>> touchCalls = new ArrayList<>();
|
||||||
|
for (Object call : captor.getAllValues()) {
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
List<String> casted = (List<String>) call;
|
||||||
|
touchCalls.add(casted);
|
||||||
|
}
|
||||||
|
assertEquals(2, touchCalls.size());
|
||||||
|
assertEquals(500, touchCalls.get(0).size(), "第一批 touch 500 个命中");
|
||||||
|
assertEquals(100, touchCalls.get(1).size(), "第二批 touch 剩余 100 个命中");
|
||||||
|
assertEquals(hits.subList(0, 500), touchCalls.get(0), "touch 只覆盖实际命中集合");
|
||||||
|
assertEquals(hits.subList(500, 600), touchCalls.get(1));
|
||||||
|
verify(taskImageCacheMapper, never()).touchLastUsed(anyString());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_014_image_invalid_input_rejected() throws Exception {
|
||||||
|
// 非法输入:db cache 关闭时批量入口直接返回空,不访问数据库。
|
||||||
|
lenient().when(properties.isImageDbCacheEnabled()).thenReturn(false);
|
||||||
|
List<String> urls = List.of("https://img.example.com/a.jpg");
|
||||||
|
List<byte[]> result = invokeLookupBatch(service, urls);
|
||||||
|
assertNotNull(result);
|
||||||
|
assertTrue(result.isEmpty(), "db cache 关闭时必须直接返回空结果");
|
||||||
|
verify(taskImageCacheMapper, never()).selectBytesByUrlHashes(any());
|
||||||
|
verify(taskImageCacheMapper, never()).touchLastUsedBatch(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_014_image_dependency_failure_releases_resources() throws Exception {
|
||||||
|
// 依赖失败:查询抛异常时批量入口返回空结果、无任何 touch/insert 残留;
|
||||||
|
// 恢复后重试成功。
|
||||||
|
List<String> urls = List.of("https://img.example.com/a.jpg");
|
||||||
|
String hash = sha256Hex(urls.get(0));
|
||||||
|
AtomicInteger callCount = new AtomicInteger(0);
|
||||||
|
doAnswer(invocation -> {
|
||||||
|
if (callCount.getAndIncrement() == 0) {
|
||||||
|
throw new IllegalStateException("db down");
|
||||||
|
}
|
||||||
|
TaskImageCacheEntity row = new TaskImageCacheEntity();
|
||||||
|
row.setUrlHash(hash);
|
||||||
|
row.setImageBytes(new byte[]{5});
|
||||||
|
return List.of(row);
|
||||||
|
}).when(taskImageCacheMapper).selectBytesByUrlHashes(any());
|
||||||
|
|
||||||
|
List<byte[]> failed = invokeLookupBatch(service, urls);
|
||||||
|
assertNotNull(failed);
|
||||||
|
assertTrue(failed.isEmpty(), "查询失败必须返回空结果而不是抛错阻断组装");
|
||||||
|
verify(taskImageCacheMapper, never()).touchLastUsedBatch(any());
|
||||||
|
verify(taskImageCacheMapper, never()).touchLastUsed(anyString());
|
||||||
|
verify(taskImageCacheMapper, never()).insert(any(TaskImageCacheEntity.class));
|
||||||
|
|
||||||
|
List<byte[]> recovered = invokeLookupBatch(service, urls);
|
||||||
|
assertEquals(1, recovered.size());
|
||||||
|
assertEquals(5, recovered.get(0)[0], "依赖恢复后重试成功");
|
||||||
|
verify(taskImageCacheMapper, times(1)).touchLastUsedBatch(List.of(hash));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_014_image_normal_single_lookup_legacy_compat() throws Exception {
|
||||||
|
// 兼容性:单 URL 旧入口 lookup 保持返回字节语义;Task 15 起 touch 改为
|
||||||
|
// 异步批量缓冲,flush 后批量 touch 一次,命中才入缓冲。
|
||||||
|
String url = "https://img.example.com/legacy.jpg";
|
||||||
|
String hash = sha256Hex(url);
|
||||||
|
byte[] bytes = new byte[]{6};
|
||||||
|
when(taskImageCacheMapper.selectBytesByUrlHash(hash)).thenReturn(bytes);
|
||||||
|
|
||||||
|
byte[] result = service.lookup(url);
|
||||||
|
assertEquals(bytes, result, "lookup 命中必须返回缓存字节");
|
||||||
|
verify(taskImageCacheMapper, never()).touchLastUsed(anyString());
|
||||||
|
|
||||||
|
service.flushPendingTouches();
|
||||||
|
verify(taskImageCacheMapper, times(1)).touchLastUsedBatch(List.of(hash));
|
||||||
|
verify(taskImageCacheMapper, never()).touchLastUsed(anyString());
|
||||||
|
|
||||||
|
assertNull(service.lookup("https://img.example.com/missing.jpg"), "未命中返回 null");
|
||||||
|
service.flushPendingTouches();
|
||||||
|
verify(taskImageCacheMapper, times(1)).touchLastUsedBatch(any());
|
||||||
|
verify(taskImageCacheMapper, never()).touchLastUsed(anyString());
|
||||||
|
}
|
||||||
|
}
|
||||||
+204
@@ -0,0 +1,204 @@
|
|||||||
|
package com.nanri.aiimage.modules.similarasin.service;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
|
||||||
|
import com.nanri.aiimage.common.exception.BusinessException;
|
||||||
|
import com.nanri.aiimage.modules.task.mapper.TaskChunkMapper;
|
||||||
|
import com.nanri.aiimage.modules.task.model.entity.TaskChunkEntity;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.ArgumentCaptor;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.Mockito.doAnswer;
|
||||||
|
import static org.mockito.Mockito.times;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Task 9:chunk 查询从单行分页改为批量 keyset 分页,保持低内存读取。
|
||||||
|
* loadChunksKeyset 按 id 递增分批拉取(每批 pageSize),最后按 chunkIndex 升序合并,
|
||||||
|
* 避免超大任务一次 selectList 全量载入 chunk 元数据。
|
||||||
|
* mock 分页由 wrapper 中 gt("id", lastId) 的 keyset 值驱动,保证重复调用可复现(幂等)。
|
||||||
|
*/
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class SimilarAsinTaskServiceChunkKeysetTest {
|
||||||
|
|
||||||
|
private static final String MODULE = "similar-asin";
|
||||||
|
|
||||||
|
@Mock private TaskChunkMapper taskChunkMapper;
|
||||||
|
|
||||||
|
private static TaskChunkEntity chunk(long id, int chunkIndex) {
|
||||||
|
TaskChunkEntity chunk = new TaskChunkEntity();
|
||||||
|
chunk.setId(id);
|
||||||
|
chunk.setTaskId(7004L);
|
||||||
|
chunk.setModuleType(MODULE);
|
||||||
|
chunk.setChunkIndex(chunkIndex);
|
||||||
|
return chunk;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<TaskChunkEntity> chunks(long... idsAndIndexes) {
|
||||||
|
List<TaskChunkEntity> result = new ArrayList<>();
|
||||||
|
for (int i = 0; i < idsAndIndexes.length; i += 2) {
|
||||||
|
result.add(chunk(idsAndIndexes[i], (int) idsAndIndexes[i + 1]));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从 wrapper 的 SQL 片段解析 keyset:匹配 "id > #{ew.paramNameValuePairs.键}" 后从参数表取值。 */
|
||||||
|
private static long keysetOf(QueryWrapper<TaskChunkEntity> wrapper) {
|
||||||
|
java.util.regex.Matcher m = java.util.regex.Pattern
|
||||||
|
.compile("id\\s*>\\s*#\\{ew\\.paramNameValuePairs\\.(\\w+)\\}", java.util.regex.Pattern.CASE_INSENSITIVE)
|
||||||
|
.matcher(wrapper.getSqlSegment());
|
||||||
|
if (m.find()) {
|
||||||
|
Object value = wrapper.getParamNameValuePairs().get(m.group(1));
|
||||||
|
if (value instanceof Number number) {
|
||||||
|
return number.longValue();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0L;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按 keyset 驱动分页:每次 selectList 返回 id > keyset 的下一批,天然支持重复调用。 */
|
||||||
|
private void stubKeysetPages(List<TaskChunkEntity> all, int pageSize) {
|
||||||
|
int batch = pageSize > 0 ? pageSize : 500;
|
||||||
|
doAnswer(invocation -> {
|
||||||
|
long lastId = keysetOf(invocation.getArgument(0));
|
||||||
|
return all.stream().filter(c -> c.getId() > lastId).limit(batch).toList();
|
||||||
|
}).when(taskChunkMapper).selectList(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<Long> keysetIdsFromRounds(int rounds) {
|
||||||
|
ArgumentCaptor<QueryWrapper<TaskChunkEntity>> captor =
|
||||||
|
ArgumentCaptor.forClass(QueryWrapper.class);
|
||||||
|
verify(taskChunkMapper, times(rounds)).selectList(captor.capture());
|
||||||
|
List<Long> keysets = new ArrayList<>();
|
||||||
|
for (QueryWrapper<TaskChunkEntity> wrapper : captor.getAllValues()) {
|
||||||
|
keysets.add(keysetOf(wrapper));
|
||||||
|
}
|
||||||
|
return keysets;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_009_chunk_normal_default_path() {
|
||||||
|
// 正常输入:chunk 数小于 pageSize,一轮拉完,结果全且按 chunkIndex 有序
|
||||||
|
List<TaskChunkEntity> all = chunks(1, 1, 2, 2, 3, 3);
|
||||||
|
stubKeysetPages(all, 500);
|
||||||
|
List<TaskChunkEntity> result = SimilarAsinTaskService.loadChunksKeyset(taskChunkMapper, 7004L, MODULE, 500);
|
||||||
|
assertEquals(3, result.size());
|
||||||
|
assertEquals(List.of(1, 2, 3), result.stream().map(TaskChunkEntity::getChunkIndex).toList());
|
||||||
|
assertEquals(List.of(0L), keysetIdsFromRounds(1), "首轮 keyset 为 0");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_009_chunk_normal_multiple_items() {
|
||||||
|
// 超过 pageSize:多轮拉取,keyset 逐轮推进,全部合并且按 chunkIndex 升序、无重复
|
||||||
|
List<TaskChunkEntity> all = chunks(1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 6, 6, 7, 7);
|
||||||
|
stubKeysetPages(all, 3);
|
||||||
|
List<TaskChunkEntity> result = SimilarAsinTaskService.loadChunksKeyset(taskChunkMapper, 7004L, MODULE, 3);
|
||||||
|
assertEquals(7, result.size());
|
||||||
|
assertEquals(List.of(1, 2, 3, 4, 5, 6, 7), result.stream().map(TaskChunkEntity::getChunkIndex).toList());
|
||||||
|
long distinctIds = result.stream().map(TaskChunkEntity::getId).distinct().count();
|
||||||
|
assertEquals(7, distinctIds, "keyset 分页不能产生重复 chunk");
|
||||||
|
assertEquals(List.of(0L, 3L, 6L), keysetIdsFromRounds(3), "keyset 逐轮推进,不足一批即止");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_009_chunk_normal_repeated_operation_is_idempotent() {
|
||||||
|
// 重复执行同一输入:每轮都从 keyset=0 开始,结果一致
|
||||||
|
List<TaskChunkEntity> all = chunks(1, 1, 2, 2, 3, 3);
|
||||||
|
stubKeysetPages(all, 500);
|
||||||
|
List<TaskChunkEntity> first = SimilarAsinTaskService.loadChunksKeyset(taskChunkMapper, 7004L, MODULE, 500);
|
||||||
|
List<TaskChunkEntity> second = SimilarAsinTaskService.loadChunksKeyset(taskChunkMapper, 7004L, MODULE, 500);
|
||||||
|
assertEquals(first.size(), second.size());
|
||||||
|
for (int i = 0; i < first.size(); i++) {
|
||||||
|
assertEquals(first.get(i).getId(), second.get(i).getId());
|
||||||
|
}
|
||||||
|
assertEquals(List.of(0L, 0L), keysetIdsFromRounds(2), "重复执行每轮都从 keyset=0 开始");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_009_chunk_boundary_empty_input() {
|
||||||
|
// 空集合:返回空列表,不创建无效资源,且只查一轮
|
||||||
|
stubKeysetPages(List.of(), 500);
|
||||||
|
List<TaskChunkEntity> result = SimilarAsinTaskService.loadChunksKeyset(taskChunkMapper, 7004L, MODULE, 500);
|
||||||
|
assertNotNull(result);
|
||||||
|
assertEquals(0, result.size());
|
||||||
|
verify(taskChunkMapper, times(1)).selectList(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_009_chunk_boundary_single_item() {
|
||||||
|
// 单 chunk:一轮返回后 keyset 推进即拉空,不依赖批量路径
|
||||||
|
List<TaskChunkEntity> all = chunks(42, 9);
|
||||||
|
stubKeysetPages(all, 1);
|
||||||
|
List<TaskChunkEntity> result = SimilarAsinTaskService.loadChunksKeyset(taskChunkMapper, 7004L, MODULE, 1);
|
||||||
|
assertEquals(1, result.size());
|
||||||
|
assertEquals(9, result.get(0).getChunkIndex());
|
||||||
|
assertEquals(42L, result.get(0).getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_009_chunk_boundary_limit_and_overflow() {
|
||||||
|
// chunk 数恰好等于 pageSize 的倍数:最后一轮仍返回非空才继续,全部取回
|
||||||
|
List<TaskChunkEntity> all = chunks(1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 6, 6);
|
||||||
|
stubKeysetPages(all, 3);
|
||||||
|
List<TaskChunkEntity> result = SimilarAsinTaskService.loadChunksKeyset(taskChunkMapper, 7004L, MODULE, 3);
|
||||||
|
assertEquals(6, result.size());
|
||||||
|
// pageSize 为 0/负数:回退默认 500,不抛异常
|
||||||
|
stubKeysetPages(all, 0);
|
||||||
|
List<TaskChunkEntity> fallback = SimilarAsinTaskService.loadChunksKeyset(taskChunkMapper, 7004L, MODULE, 0);
|
||||||
|
assertEquals(6, fallback.size());
|
||||||
|
stubKeysetPages(all, -5);
|
||||||
|
List<TaskChunkEntity> negative = SimilarAsinTaskService.loadChunksKeyset(taskChunkMapper, 7004L, MODULE, -5);
|
||||||
|
assertEquals(6, negative.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_009_chunk_invalid_input_rejected() {
|
||||||
|
// taskId 为 null:安全返回空列表,不发起查询
|
||||||
|
List<TaskChunkEntity> result = SimilarAsinTaskService.loadChunksKeyset(taskChunkMapper, null, MODULE, 500);
|
||||||
|
assertNotNull(result);
|
||||||
|
assertEquals(0, result.size());
|
||||||
|
verify(taskChunkMapper, times(0)).selectList(any());
|
||||||
|
// mapper 查询抛异常:转项目约定异常,消息可识别
|
||||||
|
when(taskChunkMapper.selectList(any())).thenThrow(new IllegalStateException("db down"));
|
||||||
|
BusinessException ex = assertThrows(BusinessException.class,
|
||||||
|
() -> SimilarAsinTaskService.loadChunksKeyset(taskChunkMapper, 7004L, MODULE, 500));
|
||||||
|
assertTrue(ex.getMessage() != null && ex.getMessage().contains("chunk"),
|
||||||
|
"异常消息必须可识别,实际: " + ex.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_009_chunk_dependency_failure_releases_resources() {
|
||||||
|
// 第二轮查询失败:抛异常不返回半截结果;恢复后重试可完整返回
|
||||||
|
List<TaskChunkEntity> all = chunks(1, 1, 2, 2, 3, 3, 4, 4, 5, 5);
|
||||||
|
doAnswer(invocation -> {
|
||||||
|
long lastId = keysetOf(invocation.getArgument(0));
|
||||||
|
if (lastId == 0L) {
|
||||||
|
return all.subList(0, 3);
|
||||||
|
}
|
||||||
|
if (lastId == 3L) {
|
||||||
|
throw new IllegalStateException("db down mid-page");
|
||||||
|
}
|
||||||
|
return all.subList(3, 5);
|
||||||
|
}).when(taskChunkMapper).selectList(any());
|
||||||
|
BusinessException ex = assertThrows(BusinessException.class,
|
||||||
|
() -> SimilarAsinTaskService.loadChunksKeyset(taskChunkMapper, 7004L, MODULE, 3));
|
||||||
|
assertTrue(ex.getMessage() != null && ex.getMessage().contains("chunk"),
|
||||||
|
"异常消息必须可识别,实际: " + ex.getMessage());
|
||||||
|
// 恢复后重试成功:5 个 chunk 全部取回
|
||||||
|
stubKeysetPages(all, 3);
|
||||||
|
List<TaskChunkEntity> recovered = SimilarAsinTaskService.loadChunksKeyset(taskChunkMapper, 7004L, MODULE, 3);
|
||||||
|
assertEquals(5, recovered.size());
|
||||||
|
assertEquals(List.of(1, 2, 3, 4, 5), recovered.stream().map(TaskChunkEntity::getChunkIndex).toList());
|
||||||
|
}
|
||||||
|
}
|
||||||
+402
@@ -0,0 +1,402 @@
|
|||||||
|
package com.nanri.aiimage.modules.similarasin.service;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.nanri.aiimage.config.SimilarAsinProperties;
|
||||||
|
import com.nanri.aiimage.modules.similarasin.client.SimilarAsinCozeClient;
|
||||||
|
import com.nanri.aiimage.modules.similarasin.mapper.SimilarAsinFilterConditionMapper;
|
||||||
|
import com.nanri.aiimage.modules.similarasin.model.dto.SimilarAsinResultRowDto;
|
||||||
|
import com.nanri.aiimage.modules.similarasin.util.SimilarAsinImageEmbedder;
|
||||||
|
import com.nanri.aiimage.modules.file.service.LocalFileStorageService;
|
||||||
|
import com.nanri.aiimage.modules.task.mapper.FileResultMapper;
|
||||||
|
import com.nanri.aiimage.modules.task.mapper.FileTaskMapper;
|
||||||
|
import com.nanri.aiimage.modules.task.mapper.TaskChunkMapper;
|
||||||
|
import com.nanri.aiimage.modules.task.mapper.TaskScopeStateMapper;
|
||||||
|
import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity;
|
||||||
|
import com.nanri.aiimage.modules.task.model.entity.TaskChunkEntity;
|
||||||
|
import com.nanri.aiimage.modules.task.model.entity.TaskScopeStateEntity;
|
||||||
|
import com.nanri.aiimage.modules.task.service.TransientPayloadStorageService;
|
||||||
|
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||||
|
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
||||||
|
import org.apache.ibatis.builder.MapperBuilderAssistant;
|
||||||
|
import org.junit.jupiter.api.AfterEach;
|
||||||
|
import org.junit.jupiter.api.BeforeAll;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.InjectMocks;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.Spy;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
|
||||||
|
import java.lang.reflect.Method;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.atomic.AtomicLong;
|
||||||
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.Mockito.doAnswer;
|
||||||
|
import static org.mockito.Mockito.lenient;
|
||||||
|
import static org.mockito.Mockito.times;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Task 13:chunk 合并增加单次最大行数与 payload 字节上限。
|
||||||
|
* 校验点位于 mergeChunkPayload 单次合并入口:合并后总行数超过 chunkMergeMaxRows、
|
||||||
|
* 或 payload 字节超过 chunkMergePayloadMaxBytes 时,从最旧行开始降级到
|
||||||
|
* orphan 兜底(assemble 阶段 putIfAbsent 合并回结果,不丢数据);
|
||||||
|
* 单行本身超过字节上限时抛可识别异常拒绝合并。低于上限的行为与旧路径完全一致。
|
||||||
|
*/
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class SimilarAsinTaskServiceChunkMergeLimitTest {
|
||||||
|
|
||||||
|
private static final AtomicLong NEXT_ID = new AtomicLong(90000);
|
||||||
|
|
||||||
|
@Mock private LocalFileStorageService localFileStorageService;
|
||||||
|
@Mock private com.nanri.aiimage.modules.file.service.oss.OssStorageService ossStorageService;
|
||||||
|
@Mock private com.nanri.aiimage.config.StorageProperties storageProperties;
|
||||||
|
@Mock private FileTaskMapper fileTaskMapper;
|
||||||
|
@Mock private FileResultMapper fileResultMapper;
|
||||||
|
@Mock private TaskScopeStateMapper taskScopeStateMapper;
|
||||||
|
@Mock private TaskChunkMapper taskChunkMapper;
|
||||||
|
@Mock private SimilarAsinFilterConditionMapper filterConditionMapper;
|
||||||
|
@Spy private ObjectMapper objectMapper = new ObjectMapper();
|
||||||
|
@Mock private SimilarAsinCozeClient cozeClient;
|
||||||
|
@Mock private SimilarAsinTaskCacheService taskCacheService;
|
||||||
|
@Mock private SimilarAsinProperties properties;
|
||||||
|
@Mock private com.nanri.aiimage.modules.task.service.TaskFileJobService taskFileJobService;
|
||||||
|
@Mock private com.nanri.aiimage.modules.task.service.TaskDistributedLockService taskDistributedLockService;
|
||||||
|
@Mock private com.nanri.aiimage.modules.task.service.TaskProgressSnapshotService taskProgressSnapshotService;
|
||||||
|
@Mock private TransientPayloadStorageService transientPayloadStorageService;
|
||||||
|
@Mock private org.springframework.transaction.PlatformTransactionManager transactionManager;
|
||||||
|
@Mock private com.nanri.aiimage.common.service.DistributedJobLockService distributedJobLockService;
|
||||||
|
@Mock private com.nanri.aiimage.config.InstanceMetadata instanceMetadata;
|
||||||
|
@Mock private com.nanri.aiimage.modules.coze.service.CozeCredentialPoolService cozeCredentialPoolService;
|
||||||
|
@Mock private SimilarAsinImageEmbedder imageEmbedder;
|
||||||
|
@Mock private SimilarAsinImagePrefetchService imagePrefetchService;
|
||||||
|
|
||||||
|
@InjectMocks private SimilarAsinTaskService service;
|
||||||
|
|
||||||
|
@BeforeAll
|
||||||
|
static void initializeMybatisMetadata() {
|
||||||
|
MapperBuilderAssistant assistant = new MapperBuilderAssistant(new MybatisConfiguration(), "");
|
||||||
|
TableInfoHelper.initTableInfo(assistant, FileTaskEntity.class);
|
||||||
|
TableInfoHelper.initTableInfo(assistant, TaskChunkEntity.class);
|
||||||
|
TableInfoHelper.initTableInfo(assistant, TaskScopeStateEntity.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
lenient().when(properties.isBoundedResultAssemblyEnabled()).thenReturn(true);
|
||||||
|
lenient().when(properties.getImageCacheMaxBytes()).thenReturn(256L * 1024L * 1024L);
|
||||||
|
lenient().when(properties.getResultFileTimeoutMinutes()).thenReturn(90);
|
||||||
|
lenient().when(properties.getParseResponsePreviewLimit()).thenReturn(100);
|
||||||
|
lenient().when(properties.getMaxSourceFileBytes()).thenReturn(50L * 1024L * 1024L);
|
||||||
|
lenient().when(properties.getMaxParseRows()).thenReturn(50000);
|
||||||
|
lenient().when(properties.getMaxFieldLength()).thenReturn(2000);
|
||||||
|
lenient().when(properties.getMaxWorkbookZipEntries()).thenReturn(20000);
|
||||||
|
lenient().when(properties.getMaxWorkbookUncompressedBytes()).thenReturn(512L * 1024L * 1024L);
|
||||||
|
lenient().when(properties.getChunkMergeMaxRows()).thenReturn(50000);
|
||||||
|
lenient().when(properties.getChunkMergePayloadMaxBytes()).thenReturn(16L * 1024L * 1024L);
|
||||||
|
lenient().when(transientPayloadStorageService.storeParsedPayloadFast(
|
||||||
|
eq(SimilarAsinTaskService.MODULE_TYPE), any(), anyString(), anyString(), eq(false)))
|
||||||
|
.thenReturn("rustfs:task-parsed/similar-asin/90000/payload.json");
|
||||||
|
lenient().doAnswer(invocation -> {
|
||||||
|
FileTaskEntity task = invocation.getArgument(0);
|
||||||
|
task.setId(NEXT_ID.incrementAndGet());
|
||||||
|
return 1;
|
||||||
|
}).when(fileTaskMapper).insert(any(FileTaskEntity.class));
|
||||||
|
lenient().when(fileResultMapper.insert(any(com.nanri.aiimage.modules.task.model.entity.FileResultEntity.class))).thenReturn(1);
|
||||||
|
lenient().when(taskScopeStateMapper.insert(any(TaskScopeStateEntity.class))).thenReturn(1);
|
||||||
|
lenient().when(taskChunkMapper.selectList(any())).thenReturn(List.of());
|
||||||
|
lenient().when(fileResultMapper.selectList(any())).thenReturn(List.of());
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
void shutdown() {
|
||||||
|
service.shutdownAssembleExecutor();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SimilarAsinResultRowDto row(String rowToken, String asin, String title) {
|
||||||
|
SimilarAsinResultRowDto r = new SimilarAsinResultRowDto();
|
||||||
|
r.setRowToken(rowToken);
|
||||||
|
r.setId(rowToken);
|
||||||
|
r.setAsin(asin);
|
||||||
|
r.setCountry("英国");
|
||||||
|
r.setTitle(title);
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String rowsJson(List<SimilarAsinResultRowDto> rows) throws Exception {
|
||||||
|
return new ObjectMapper().writeValueAsString(rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TaskChunkEntity chunk(Long id, String scopeHash, Integer chunkIndex, String payloadJson) {
|
||||||
|
TaskChunkEntity chunk = new TaskChunkEntity();
|
||||||
|
chunk.setId(id);
|
||||||
|
chunk.setTaskId(9004L);
|
||||||
|
chunk.setModuleType(SimilarAsinTaskService.MODULE_TYPE);
|
||||||
|
chunk.setScopeHash(scopeHash);
|
||||||
|
chunk.setChunkIndex(chunkIndex);
|
||||||
|
chunk.setPayloadJson(payloadJson);
|
||||||
|
return chunk;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 记录每次 storeChunkPayloadVersioned 收到的 payload 字符串。 */
|
||||||
|
private void stubChunkMerge(TaskChunkEntity chunk, String payloadJson, AtomicReference<String> storedPayload) throws Exception {
|
||||||
|
when(taskChunkMapper.selectList(any())).thenReturn(List.of(chunk));
|
||||||
|
when(transientPayloadStorageService.resolvePayload(eq(chunk.getPayloadJson()), anyString()))
|
||||||
|
.thenReturn(payloadJson);
|
||||||
|
when(transientPayloadStorageService.storeChunkPayloadVersioned(anyString(), any(), anyString(), any(), anyString()))
|
||||||
|
.thenAnswer(invocation -> {
|
||||||
|
storedPayload.set(invocation.getArgument(4));
|
||||||
|
return "stored:" + invocation.getArgument(2);
|
||||||
|
});
|
||||||
|
when(taskChunkMapper.selectOne(any())).thenReturn(chunk);
|
||||||
|
when(taskChunkMapper.update(any(), any())).thenReturn(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void invokeMerge(SimilarAsinTaskService service, FileTaskEntity task,
|
||||||
|
String scopeHash, Integer chunkIndex,
|
||||||
|
List<SimilarAsinResultRowDto> cozeRows) throws Exception {
|
||||||
|
Method merge = SimilarAsinTaskService.class.getDeclaredMethod("mergeCozeRowsIntoChunk",
|
||||||
|
FileTaskEntity.class, String.class, Integer.class, List.class, Map.class);
|
||||||
|
merge.setAccessible(true);
|
||||||
|
merge.invoke(service, task, scopeHash, chunkIndex, cozeRows, Map.of());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_013_payload_row_count_chunk_normal_default_path() throws Exception {
|
||||||
|
// 正常输入:行数与 payload 字节均在上限内,合并走原路径,结果完整保留。
|
||||||
|
TaskChunkEntity chunk = chunk(1L, "hashA", 1, "ptr:chunk-A");
|
||||||
|
AtomicReference<String> storedPayload = new AtomicReference<>("");
|
||||||
|
stubChunkMerge(chunk, rowsJson(List.of(row("r0", "B0A0000000", "存量行"))), storedPayload);
|
||||||
|
|
||||||
|
FileTaskEntity task = new FileTaskEntity();
|
||||||
|
task.setId(9004L);
|
||||||
|
List<SimilarAsinResultRowDto> cozeRows = List.of(
|
||||||
|
row("r1", "B0A0000001", "标题1"),
|
||||||
|
row("r2", "B0A0000002", "标题2"),
|
||||||
|
row("r3", "B0A0000003", "标题3"));
|
||||||
|
|
||||||
|
invokeMerge(service, task, "hashA", 1, cozeRows);
|
||||||
|
assertNotNull(storedPayload.get());
|
||||||
|
assertTrue(storedPayload.get().contains("\"r1\"") && storedPayload.get().contains("\"r3\""),
|
||||||
|
"上限内合并必须完整保留存量行与新增行,实际: " + storedPayload.get());
|
||||||
|
verify(transientPayloadStorageService, times(1)).storeChunkPayloadVersioned(anyString(), any(), anyString(), any(), anyString());
|
||||||
|
verify(taskChunkMapper, times(1)).update(any(), any());
|
||||||
|
verify(taskScopeStateMapper, times(0)).insert(any(TaskScopeStateEntity.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_013_payload_row_count_chunk_normal_multiple_items() throws Exception {
|
||||||
|
// 批量场景:多 chunk 一次 merge,全部在上限内,各 chunk 分别写回、结果不丢失。
|
||||||
|
TaskChunkEntity chunkA = chunk(1L, "hashA", 1, "ptr:chunk-A");
|
||||||
|
TaskChunkEntity chunkB = chunk(2L, "hashB", 2, "ptr:chunk-B");
|
||||||
|
when(taskChunkMapper.selectList(any())).thenReturn(List.of(chunkA, chunkB));
|
||||||
|
when(transientPayloadStorageService.resolvePayload(eq("ptr:chunk-A"), anyString()))
|
||||||
|
.thenReturn(rowsJson(List.of(row("r1", "B0A0000001", "标题1"))));
|
||||||
|
when(transientPayloadStorageService.resolvePayload(eq("ptr:chunk-B"), anyString()))
|
||||||
|
.thenReturn(rowsJson(List.of(row("r2", "B0A0000002", "标题2"))));
|
||||||
|
when(transientPayloadStorageService.storeChunkPayloadVersioned(anyString(), any(), anyString(), any(), anyString()))
|
||||||
|
.thenAnswer(invocation -> "stored:" + invocation.getArgument(2));
|
||||||
|
AtomicLong selectOneRound = new AtomicLong(0);
|
||||||
|
when(taskChunkMapper.selectOne(any())).thenAnswer(invocation ->
|
||||||
|
selectOneRound.getAndIncrement() == 0 ? chunkA : chunkB);
|
||||||
|
when(taskChunkMapper.update(any(), any())).thenReturn(1);
|
||||||
|
|
||||||
|
FileTaskEntity task = new FileTaskEntity();
|
||||||
|
task.setId(9004L);
|
||||||
|
invokeMerge(service, task, null, null, List.of(
|
||||||
|
row("r1", "B0A0000001", "标题1-新"),
|
||||||
|
row("r2", "B0A0000002", "标题2-新")));
|
||||||
|
|
||||||
|
verify(transientPayloadStorageService, times(2)).storeChunkPayloadVersioned(anyString(), any(), anyString(), any(), anyString());
|
||||||
|
verify(taskChunkMapper, times(2)).update(any(), any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_013_payload_row_count_chunk_normal_repeated_operation_is_idempotent() throws Exception {
|
||||||
|
// 重复执行同一输入:每次 merge 恰好写一次,不产生重复对象、重复状态。
|
||||||
|
TaskChunkEntity chunk = chunk(1L, "hashA", 1, "ptr:chunk-A");
|
||||||
|
AtomicLong storeCalls = new AtomicLong(0);
|
||||||
|
when(taskChunkMapper.selectList(any())).thenReturn(List.of(chunk));
|
||||||
|
when(transientPayloadStorageService.resolvePayload(eq("ptr:chunk-A"), anyString()))
|
||||||
|
.thenReturn(rowsJson(List.of(row("r0", "B0A0000000", "存量行"))));
|
||||||
|
when(transientPayloadStorageService.storeChunkPayloadVersioned(anyString(), any(), anyString(), any(), anyString()))
|
||||||
|
.thenAnswer(invocation -> {
|
||||||
|
storeCalls.incrementAndGet();
|
||||||
|
return "stored:" + invocation.getArgument(2);
|
||||||
|
});
|
||||||
|
when(taskChunkMapper.selectOne(any())).thenReturn(chunk);
|
||||||
|
when(taskChunkMapper.update(any(), any())).thenReturn(1);
|
||||||
|
|
||||||
|
FileTaskEntity task = new FileTaskEntity();
|
||||||
|
task.setId(9004L);
|
||||||
|
List<SimilarAsinResultRowDto> cozeRows = List.of(row("r1", "B0A0000001", "标题1"));
|
||||||
|
invokeMerge(service, task, "hashA", 1, cozeRows);
|
||||||
|
invokeMerge(service, task, "hashA", 1, cozeRows);
|
||||||
|
assertEquals(2, storeCalls.get(), "重复执行同一输入:每次 merge 恰好写回一次,无多余请求");
|
||||||
|
verify(taskScopeStateMapper, times(0)).insert(any(TaskScopeStateEntity.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_013_payload_row_count_chunk_boundary_empty_input() throws Exception {
|
||||||
|
// 空输入:null/空列表安全跳过,不读取 chunk、不写存储、不创建资源。
|
||||||
|
FileTaskEntity task = new FileTaskEntity();
|
||||||
|
task.setId(9004L);
|
||||||
|
invokeMerge(service, task, "hashA", 1, null);
|
||||||
|
invokeMerge(service, task, "hashA", 1, List.of());
|
||||||
|
verify(taskChunkMapper, times(0)).selectList(any());
|
||||||
|
verify(transientPayloadStorageService, times(0)).storeChunkPayloadVersioned(anyString(), any(), anyString(), any(), anyString());
|
||||||
|
verify(taskScopeStateMapper, times(0)).insert(any(TaskScopeStateEntity.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_013_payload_row_count_chunk_boundary_single_item() throws Exception {
|
||||||
|
// 单行:不依赖批量路径,合并后结果正确。
|
||||||
|
TaskChunkEntity chunk = chunk(1L, "hashA", 1, "ptr:chunk-A");
|
||||||
|
AtomicReference<String> storedPayload = new AtomicReference<>("");
|
||||||
|
stubChunkMerge(chunk, rowsJson(List.of(row("r0", "B0A0000000", "存量行"))), storedPayload);
|
||||||
|
|
||||||
|
FileTaskEntity task = new FileTaskEntity();
|
||||||
|
task.setId(9004L);
|
||||||
|
invokeMerge(service, task, "hashA", 1, List.of(row("r1", "B0A0000001", "标题1")));
|
||||||
|
|
||||||
|
assertTrue(storedPayload.get().contains("\"r1\""), "单行合并也必须写回 chunk payload,实际: " + storedPayload.get());
|
||||||
|
verify(taskChunkMapper, times(1)).update(any(), any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_013_payload_row_count_chunk_boundary_limit_and_overflow() throws Exception {
|
||||||
|
// 超限场景三连:行数超限降级、字节超限降级、单行超字节上限拒绝。
|
||||||
|
lenient().when(properties.getChunkMergeMaxRows()).thenReturn(2);
|
||||||
|
TaskChunkEntity chunk = chunk(1L, "hashA", 1, "ptr:chunk-A");
|
||||||
|
AtomicLong storeCalls = new AtomicLong(0);
|
||||||
|
AtomicReference<String> lastStoredPayload = new AtomicReference<>("");
|
||||||
|
when(taskChunkMapper.selectList(any())).thenReturn(List.of(chunk));
|
||||||
|
when(transientPayloadStorageService.resolvePayload(eq("ptr:chunk-A"), anyString()))
|
||||||
|
.thenReturn(rowsJson(List.of(row("r0", "B0A0000000", "存量行"))));
|
||||||
|
when(transientPayloadStorageService.storeChunkPayloadVersioned(anyString(), any(), anyString(), any(), anyString()))
|
||||||
|
.thenAnswer(invocation -> {
|
||||||
|
storeCalls.incrementAndGet();
|
||||||
|
lastStoredPayload.set(invocation.getArgument(4));
|
||||||
|
return "stored:" + invocation.getArgument(2);
|
||||||
|
});
|
||||||
|
when(taskChunkMapper.selectOne(any())).thenReturn(chunk);
|
||||||
|
when(taskChunkMapper.update(any(), any())).thenReturn(1);
|
||||||
|
|
||||||
|
FileTaskEntity task = new FileTaskEntity();
|
||||||
|
task.setId(9004L);
|
||||||
|
List<SimilarAsinResultRowDto> cozeRows = new ArrayList<>();
|
||||||
|
for (int i = 0; i < 5; i++) {
|
||||||
|
cozeRows.add(row("r" + (i + 1), "B0A00000" + (i + 1), "新行" + i));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase A:行数超限(上限 2,存量 1 + 新增 5)→ 只保留上限内最新行,超限部分转 orphan。
|
||||||
|
invokeMerge(service, task, "hashA", 1, cozeRows);
|
||||||
|
assertTrue(lastStoredPayload.get().contains("\"r4\"") && lastStoredPayload.get().contains("\"r5\""),
|
||||||
|
"行数超限时保留上限内的最新行,实际: " + lastStoredPayload.get());
|
||||||
|
assertFalse(lastStoredPayload.get().contains("\"r0\""), "行数超限时最旧行被降级,实际: " + lastStoredPayload.get());
|
||||||
|
assertEquals(1, storeCalls.get());
|
||||||
|
verify(taskScopeStateMapper, times(1)).insert(any(TaskScopeStateEntity.class));
|
||||||
|
verify(transientPayloadStorageService, times(1))
|
||||||
|
.storeParsedPayloadEntry(anyString(), any(), anyString(), anyString(), anyString(), eq(true));
|
||||||
|
|
||||||
|
// Phase B:字节超限(行数放开)→ 从最旧行降级到字节上限内,保留最新结果。
|
||||||
|
lenient().when(properties.getChunkMergeMaxRows()).thenReturn(50000);
|
||||||
|
long oneRowBytes = rowsJson(List.of(row("r9", "B0A0000099", "样本行"))).getBytes(StandardCharsets.UTF_8).length;
|
||||||
|
lenient().when(properties.getChunkMergePayloadMaxBytes()).thenReturn(oneRowBytes + 5L);
|
||||||
|
invokeMerge(service, task, "hashA", 1, cozeRows);
|
||||||
|
assertTrue(lastStoredPayload.get().contains("\"r5\""), "字节超限时保留最新行,实际: " + lastStoredPayload.get());
|
||||||
|
assertFalse(lastStoredPayload.get().contains("\"r0\""), "字节超限时最旧行被降级,实际: " + lastStoredPayload.get());
|
||||||
|
assertEquals(2, storeCalls.get());
|
||||||
|
verify(taskScopeStateMapper, times(2)).insert(any(TaskScopeStateEntity.class));
|
||||||
|
|
||||||
|
// Phase C:单行本身超过字节上限 → 抛可识别异常拒绝合并,不写 chunk。
|
||||||
|
lenient().when(properties.getChunkMergePayloadMaxBytes()).thenReturn(5L);
|
||||||
|
Exception ex = assertThrows(Exception.class, () -> {
|
||||||
|
try {
|
||||||
|
invokeMerge(service, task, "hashA", 1, cozeRows);
|
||||||
|
} catch (java.lang.reflect.InvocationTargetException e) {
|
||||||
|
throw e.getCause();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
assertNotNull(ex.getMessage());
|
||||||
|
assertTrue(ex.getMessage().contains("字节上限"),
|
||||||
|
"超字节上限必须抛可识别异常,实际: " + ex.getMessage());
|
||||||
|
assertEquals(2, storeCalls.get(), "拒绝合并时不写 chunk");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_013_payload_row_count_chunk_invalid_input_rejected() {
|
||||||
|
// 非法输入:chunk 载荷加载失败(resolve 抛异常)时抛出可识别异常且不写 chunk。
|
||||||
|
TaskChunkEntity chunk = chunk(1L, "hashA", 1, "ptr:chunk-A");
|
||||||
|
when(taskChunkMapper.selectList(any())).thenReturn(List.of(chunk));
|
||||||
|
when(transientPayloadStorageService.resolvePayload(eq("ptr:chunk-A"), anyString()))
|
||||||
|
.thenThrow(new IllegalStateException("rustfs down"));
|
||||||
|
|
||||||
|
FileTaskEntity task = new FileTaskEntity();
|
||||||
|
task.setId(9004L);
|
||||||
|
Exception ex = assertThrows(Exception.class, () -> {
|
||||||
|
try {
|
||||||
|
invokeMerge(service, task, "hashA", 1, List.of(row("r1", "B0A0000001", "标题1")));
|
||||||
|
} catch (java.lang.reflect.InvocationTargetException e) {
|
||||||
|
throw e.getCause();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
assertNotNull(ex.getMessage());
|
||||||
|
assertTrue(ex.getMessage().contains("chunk"),
|
||||||
|
"chunk 读取失败消息必须可识别,实际: " + ex.getMessage());
|
||||||
|
verify(transientPayloadStorageService, times(0)).storeChunkPayloadVersioned(anyString(), any(), anyString(), any(), anyString());
|
||||||
|
verify(taskScopeStateMapper, times(0)).insert(any(TaskScopeStateEntity.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_013_payload_row_count_chunk_dependency_failure_releases_resources() throws Exception {
|
||||||
|
// 依赖失败:payload 存储失败时抛带上下文的可识别异常、无残留状态;恢复后重试成功。
|
||||||
|
TaskChunkEntity chunk = chunk(1L, "hashA", 1, "ptr:chunk-A");
|
||||||
|
when(taskChunkMapper.selectList(any())).thenReturn(List.of(chunk));
|
||||||
|
when(transientPayloadStorageService.resolvePayload(eq("ptr:chunk-A"), anyString()))
|
||||||
|
.thenReturn(rowsJson(List.of(row("r0", "B0A0000000", "存量行"))));
|
||||||
|
AtomicLong storeCalls = new AtomicLong(0);
|
||||||
|
doAnswer(invocation -> {
|
||||||
|
if (storeCalls.getAndIncrement() == 0) {
|
||||||
|
throw new IllegalStateException("rustfs down");
|
||||||
|
}
|
||||||
|
return "stored:" + invocation.getArgument(2);
|
||||||
|
}).when(transientPayloadStorageService).storeChunkPayloadVersioned(anyString(), any(), anyString(), any(), anyString());
|
||||||
|
when(taskChunkMapper.selectOne(any())).thenReturn(chunk);
|
||||||
|
when(taskChunkMapper.update(any(), any())).thenReturn(1);
|
||||||
|
|
||||||
|
FileTaskEntity task = new FileTaskEntity();
|
||||||
|
task.setId(9004L);
|
||||||
|
Exception ex = assertThrows(Exception.class, () -> {
|
||||||
|
try {
|
||||||
|
invokeMerge(service, task, "hashA", 1, List.of(row("r1", "B0A0000001", "标题1")));
|
||||||
|
} catch (java.lang.reflect.InvocationTargetException e) {
|
||||||
|
throw e.getCause();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
assertTrue(ex.getMessage() != null && ex.getMessage().contains("相似ASIN分片载荷"),
|
||||||
|
"存储失败必须抛带上下文的可识别异常,实际: " + ex.getMessage());
|
||||||
|
assertEquals(1, storeCalls.get(), "失败时只尝试一次即抛出,不静默吞错");
|
||||||
|
verify(taskScopeStateMapper, times(0)).insert(any(TaskScopeStateEntity.class));
|
||||||
|
|
||||||
|
// 依赖恢复后重试成功:结果正确、无残留状态。
|
||||||
|
invokeMerge(service, task, "hashA", 1, List.of(row("r1", "B0A0000001", "标题1")));
|
||||||
|
assertEquals(2, storeCalls.get(), "恢复后重试成功");
|
||||||
|
verify(taskChunkMapper, times(1)).update(any(), any());
|
||||||
|
verify(taskScopeStateMapper, times(0)).insert(any(TaskScopeStateEntity.class));
|
||||||
|
}
|
||||||
|
}
|
||||||
+431
@@ -0,0 +1,431 @@
|
|||||||
|
package com.nanri.aiimage.modules.similarasin.service;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.nanri.aiimage.config.SimilarAsinProperties;
|
||||||
|
import com.nanri.aiimage.modules.coze.service.CozeCredentialPoolService;
|
||||||
|
import com.nanri.aiimage.modules.similarasin.client.SimilarAsinCozeClient;
|
||||||
|
import com.nanri.aiimage.modules.similarasin.mapper.SimilarAsinFilterConditionMapper;
|
||||||
|
import com.nanri.aiimage.modules.similarasin.model.dto.SimilarAsinResultRowDto;
|
||||||
|
import com.nanri.aiimage.modules.similarasin.util.SimilarAsinImageEmbedder;
|
||||||
|
import com.nanri.aiimage.modules.file.service.LocalFileStorageService;
|
||||||
|
import com.nanri.aiimage.modules.task.mapper.FileResultMapper;
|
||||||
|
import com.nanri.aiimage.modules.task.mapper.FileTaskMapper;
|
||||||
|
import com.nanri.aiimage.modules.task.mapper.TaskChunkMapper;
|
||||||
|
import com.nanri.aiimage.modules.task.mapper.TaskScopeStateMapper;
|
||||||
|
import com.nanri.aiimage.modules.task.model.entity.FileResultEntity;
|
||||||
|
import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity;
|
||||||
|
import com.nanri.aiimage.modules.task.model.entity.TaskChunkEntity;
|
||||||
|
import com.nanri.aiimage.modules.task.model.entity.TaskScopeStateEntity;
|
||||||
|
import com.nanri.aiimage.modules.task.service.TaskFileJobService;
|
||||||
|
import com.nanri.aiimage.modules.task.service.TransientPayloadStorageService;
|
||||||
|
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||||
|
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
||||||
|
import org.apache.ibatis.builder.MapperBuilderAssistant;
|
||||||
|
import org.junit.jupiter.api.AfterEach;
|
||||||
|
import org.junit.jupiter.api.BeforeAll;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.InjectMocks;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.Spy;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
|
||||||
|
import java.lang.reflect.Method;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.atomic.AtomicLong;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.Mockito.atLeastOnce;
|
||||||
|
import static org.mockito.Mockito.doAnswer;
|
||||||
|
import static org.mockito.Mockito.lenient;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.never;
|
||||||
|
import static org.mockito.Mockito.times;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Task 12:扩展 Coze 结果缓冲覆盖范围,减少频繁读写完整 chunk payload。
|
||||||
|
* P0-3 缓冲只覆盖"poll DONE 且 batchTotal>1";Task 12 扩展为:
|
||||||
|
* 1) poll DONE 结果去掉 batchTotal 限制,单 batch 也走缓冲;
|
||||||
|
* 2) retry 提交同步 immediate DONE 结果也走缓冲(原立即 merge);
|
||||||
|
* 3) 统一走 bufferCozeRowsOrMerge:缓冲失败回退立即 merge,结果不丢失。
|
||||||
|
* flushBufferedCozeResults 在 finalize 前一次性合并,全任务收敛为一次 chunk 读写。
|
||||||
|
*/
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class SimilarAsinTaskServiceCozeBufferScopeTest {
|
||||||
|
|
||||||
|
private static final AtomicLong NEXT_ID = new AtomicLong(71000);
|
||||||
|
private static final String MODULE = SimilarAsinTaskService.MODULE_TYPE;
|
||||||
|
private static final String CREDENTIAL = "cred-1";
|
||||||
|
|
||||||
|
@Mock private LocalFileStorageService localFileStorageService;
|
||||||
|
@Mock private com.nanri.aiimage.modules.file.service.oss.OssStorageService ossStorageService;
|
||||||
|
@Mock private com.nanri.aiimage.config.StorageProperties storageProperties;
|
||||||
|
@Mock private FileTaskMapper fileTaskMapper;
|
||||||
|
@Mock private FileResultMapper fileResultMapper;
|
||||||
|
@Mock private TaskScopeStateMapper taskScopeStateMapper;
|
||||||
|
@Mock private TaskChunkMapper taskChunkMapper;
|
||||||
|
@Mock private SimilarAsinFilterConditionMapper filterConditionMapper;
|
||||||
|
@Spy private ObjectMapper objectMapper = new ObjectMapper();
|
||||||
|
@Mock private SimilarAsinCozeClient cozeClient;
|
||||||
|
@Mock private SimilarAsinTaskCacheService taskCacheService;
|
||||||
|
@Mock private SimilarAsinProperties properties;
|
||||||
|
@Mock private TaskFileJobService taskFileJobService;
|
||||||
|
@Mock private com.nanri.aiimage.modules.task.service.TaskDistributedLockService taskDistributedLockService;
|
||||||
|
@Mock private com.nanri.aiimage.modules.task.service.TaskProgressSnapshotService taskProgressSnapshotService;
|
||||||
|
@Mock private TransientPayloadStorageService transientPayloadStorageService;
|
||||||
|
@Mock private org.springframework.transaction.PlatformTransactionManager transactionManager;
|
||||||
|
@Mock private com.nanri.aiimage.common.service.DistributedJobLockService distributedJobLockService;
|
||||||
|
@Mock private com.nanri.aiimage.config.InstanceMetadata instanceMetadata;
|
||||||
|
@Mock private com.nanri.aiimage.modules.coze.service.CozeCredentialPoolService cozeCredentialPoolService;
|
||||||
|
@Mock private SimilarAsinImageEmbedder imageEmbedder;
|
||||||
|
@Mock private SimilarAsinImagePrefetchService imagePrefetchService;
|
||||||
|
|
||||||
|
@InjectMocks private SimilarAsinTaskService service;
|
||||||
|
|
||||||
|
@BeforeAll
|
||||||
|
static void initializeMybatisMetadata() {
|
||||||
|
MapperBuilderAssistant assistant = new MapperBuilderAssistant(new MybatisConfiguration(), "");
|
||||||
|
TableInfoHelper.initTableInfo(assistant, FileTaskEntity.class);
|
||||||
|
TableInfoHelper.initTableInfo(assistant, TaskChunkEntity.class);
|
||||||
|
TableInfoHelper.initTableInfo(assistant, TaskScopeStateEntity.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() throws Exception {
|
||||||
|
lenient().when(properties.isBoundedResultAssemblyEnabled()).thenReturn(true);
|
||||||
|
lenient().when(properties.getImageCacheMaxBytes()).thenReturn(256L * 1024L * 1024L);
|
||||||
|
lenient().when(properties.getResultFileTimeoutMinutes()).thenReturn(90);
|
||||||
|
lenient().when(properties.getParseResponsePreviewLimit()).thenReturn(100);
|
||||||
|
lenient().when(properties.getMaxSourceFileBytes()).thenReturn(50L * 1024L * 1024L);
|
||||||
|
lenient().when(properties.getMaxParseRows()).thenReturn(50000);
|
||||||
|
lenient().when(properties.getMaxFieldLength()).thenReturn(2000);
|
||||||
|
lenient().when(properties.getMaxWorkbookZipEntries()).thenReturn(20000);
|
||||||
|
lenient().when(properties.getMaxWorkbookUncompressedBytes()).thenReturn(512L * 1024L * 1024L);
|
||||||
|
lenient().when(properties.getCozeBatchSize()).thenReturn(5);
|
||||||
|
lenient().when(properties.getCozeTextOnlyBatchSize()).thenReturn(10);
|
||||||
|
lenient().when(properties.isCozeResultBufferEnabled()).thenReturn(true);
|
||||||
|
lenient().when(properties.getCozePollTimeoutMillis()).thenReturn(30_000);
|
||||||
|
lenient().when(properties.getDbJobTouchIntervalMillis()).thenReturn(2_000L);
|
||||||
|
lenient().when(properties.getDbTaskTouchIntervalMillis()).thenReturn(2_000L);
|
||||||
|
lenient().when(properties.getCozeSubmitLockWaitMillis()).thenReturn(1_000L);
|
||||||
|
lenient().when(properties.getCozeSubmitLockRetryDelayMillis()).thenReturn(100L);
|
||||||
|
lenient().when(properties.getCozeSubmitMinIntervalMillis()).thenReturn(0L);
|
||||||
|
lenient().when(properties.getCozeSubmitMaxRetryCount()).thenReturn(3);
|
||||||
|
lenient().when(properties.getCozeFlushPendingMinutes()).thenReturn(10);
|
||||||
|
lenient().when(cozeClient.configuredCredentialCount()).thenReturn(1);
|
||||||
|
lenient().when(cozeClient.nextCredential()).thenReturn(new SimilarAsinCozeClient.CozeCredentialRef(
|
||||||
|
CREDENTIAL, "wf-1", "token-1", 4));
|
||||||
|
lenient().when(cozeClient.credentialByName(CREDENTIAL)).thenReturn(new SimilarAsinCozeClient.CozeCredentialRef(
|
||||||
|
CREDENTIAL, "wf-1", "token-1", 4));
|
||||||
|
lenient().when(cozeCredentialPoolService.borrow(eq(MODULE), any())).thenReturn(
|
||||||
|
mock(CozeCredentialPoolService.BorrowedCredential.class));
|
||||||
|
lenient().when(distributedJobLockService.tryLock(anyString(), any())).thenReturn(
|
||||||
|
mock(com.nanri.aiimage.common.service.DistributedJobLockService.LockHandle.class));
|
||||||
|
lenient().doAnswer(invocation -> {
|
||||||
|
FileTaskEntity task = invocation.getArgument(0);
|
||||||
|
task.setId(NEXT_ID.incrementAndGet());
|
||||||
|
return 1;
|
||||||
|
}).when(fileTaskMapper).insert(any(FileTaskEntity.class));
|
||||||
|
lenient().when(fileResultMapper.insert(any(FileResultEntity.class))).thenReturn(1);
|
||||||
|
lenient().when(taskScopeStateMapper.insert(any(TaskScopeStateEntity.class))).thenReturn(1);
|
||||||
|
lenient().when(taskScopeStateMapper.update(any(), any())).thenReturn(1);
|
||||||
|
lenient().when(taskScopeStateMapper.selectList(any())).thenReturn(List.of());
|
||||||
|
lenient().when(taskChunkMapper.selectList(any())).thenReturn(List.of());
|
||||||
|
lenient().when(fileResultMapper.selectList(any())).thenReturn(List.of());
|
||||||
|
lenient().when(transientPayloadStorageService.storeParsedPayloadEntry(
|
||||||
|
eq(MODULE), any(), anyString(), anyString(), anyString(), eq(true)))
|
||||||
|
.thenAnswer(invocation -> "rustfs:coze-result/" + NEXT_ID.incrementAndGet());
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
void shutdown() {
|
||||||
|
service.shutdownAssembleExecutor();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SimilarAsinResultRowDto row(String rowToken, String id, String asin, String country, String title) {
|
||||||
|
SimilarAsinResultRowDto r = new SimilarAsinResultRowDto();
|
||||||
|
r.setRowToken(rowToken);
|
||||||
|
r.setId(id);
|
||||||
|
r.setAsin(asin);
|
||||||
|
r.setCountry(country);
|
||||||
|
r.setTitle(title);
|
||||||
|
r.setMainUrl("https://img.example.com/" + asin + ".jpg");
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TaskChunkEntity chunk(Long id, String scopeHash, Integer chunkIndex, String payloadJson) {
|
||||||
|
TaskChunkEntity chunk = new TaskChunkEntity();
|
||||||
|
chunk.setId(id);
|
||||||
|
chunk.setTaskId(7104L);
|
||||||
|
chunk.setModuleType(MODULE);
|
||||||
|
chunk.setScopeHash(scopeHash);
|
||||||
|
chunk.setChunkIndex(chunkIndex);
|
||||||
|
chunk.setPayloadJson(payloadJson);
|
||||||
|
chunk.setPayloadHash("h-" + id);
|
||||||
|
return chunk;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static FileTaskEntity task() {
|
||||||
|
FileTaskEntity task = new FileTaskEntity();
|
||||||
|
task.setId(7104L);
|
||||||
|
task.setModuleType(MODULE);
|
||||||
|
task.setStatus("RUNNING");
|
||||||
|
task.setResultJson("{\"categorySwitch\":true}");
|
||||||
|
return task;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TaskScopeStateEntity state(FileTaskEntity task, long id, String status, int batchTotal) {
|
||||||
|
TaskScopeStateEntity state = new TaskScopeStateEntity();
|
||||||
|
state.setId(id);
|
||||||
|
state.setTaskId(task.getId());
|
||||||
|
state.setModuleType(MODULE);
|
||||||
|
state.setScopeHash("scope-" + id);
|
||||||
|
state.setCozeStatus(status);
|
||||||
|
state.setParsedPayloadJson("ptr:batch-" + id);
|
||||||
|
state.setStateJson("{\"jobId\":7101,\"resultId\":7201,\"chunkScopeHash\":null,\"chunkIndex\":null,"
|
||||||
|
+ "\"batchIndex\":1,\"batchTotal\":" + batchTotal + ",\"ownerInstanceId\":\"test-instance\","
|
||||||
|
+ "\"submitRetryCount\":0,\"credentialName\":\"" + CREDENTIAL + "\",\"resultPayloadPointer\":\"ptr:buffer-" + id + "\"}");
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String rowsJson(List<SimilarAsinResultRowDto> rows) throws Exception {
|
||||||
|
return new ObjectMapper().writeValueAsString(rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
private SimilarAsinTaskService.CozeBatchContext context(int batchTotal) {
|
||||||
|
return new SimilarAsinTaskService.CozeBatchContext(
|
||||||
|
7101L, 7201L, null, null, 1, batchTotal, "test-instance", 0, CREDENTIAL, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void stubChunkMerge(String payloadJson) throws Exception {
|
||||||
|
TaskChunkEntity chunk = chunk(1L, "scope-1", 1, "ptr:chunk-1");
|
||||||
|
// loadSubmittedChunks 只保留非空 chunk,chunk payload 必须能解析出至少一行。
|
||||||
|
// 全部 lenient:缓冲成功路径不触达 merge,仅缓冲失败/flush 合并路径消费。
|
||||||
|
lenient().when(taskChunkMapper.selectList(any())).thenReturn(List.of(chunk));
|
||||||
|
lenient().when(transientPayloadStorageService.resolvePayload(anyString(), anyString()))
|
||||||
|
.thenAnswer(invocation -> {
|
||||||
|
String pointer = invocation.getArgument(0);
|
||||||
|
if (pointer != null && pointer.startsWith("ptr:chunk-")) {
|
||||||
|
return payloadJson;
|
||||||
|
}
|
||||||
|
return "[]";
|
||||||
|
});
|
||||||
|
lenient().when(transientPayloadStorageService.storeChunkPayloadVersioned(anyString(), any(), anyString(), any(), anyString()))
|
||||||
|
.thenAnswer(invocation -> "ptr:stored-" + invocation.getArgument(3));
|
||||||
|
lenient().when(taskChunkMapper.selectOne(any())).thenReturn(chunk);
|
||||||
|
lenient().when(taskChunkMapper.update(any(), any())).thenReturn(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String chunkRowsJson() throws Exception {
|
||||||
|
// chunk-1 已含 r1 行:loadSubmittedChunks 只保留非空 chunk,且 rowKey 索引能命中缓冲行。
|
||||||
|
return rowsJson(List.of(row("r1", "1", "B0A0000001", "英国", "Title 1")));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_012_payload_chunk_normal_default_path() throws Exception {
|
||||||
|
// 正常输入:DONE 结果(batchTotal=1 单 batch)经 bufferCozeRowsOrMerge 走缓冲,
|
||||||
|
// 不立即写 chunk;缓冲失败回退立即 merge 结果不丢失。
|
||||||
|
FileTaskEntity task = task();
|
||||||
|
List<SimilarAsinResultRowDto> rows = List.of(row("r1", "1", "B0A0000001", "英国", "Title 1"));
|
||||||
|
|
||||||
|
Method bufferOrMerge = SimilarAsinTaskService.class.getDeclaredMethod("bufferCozeRowsOrMerge",
|
||||||
|
TaskScopeStateEntity.class,
|
||||||
|
SimilarAsinTaskService.CozeBatchContext.class,
|
||||||
|
List.class, FileTaskEntity.class, Map.class);
|
||||||
|
bufferOrMerge.setAccessible(true);
|
||||||
|
bufferOrMerge.invoke(service, state(task, 1L, "DONE", 1), context(1), rows, task, Map.of());
|
||||||
|
|
||||||
|
verify(transientPayloadStorageService, times(1)).storeParsedPayloadEntry(
|
||||||
|
eq(MODULE), eq(7104L), anyString(), anyString(), anyString(), eq(true));
|
||||||
|
verify(transientPayloadStorageService, never()).storeChunkPayloadVersioned(anyString(), any(), anyString(), any(), anyString());
|
||||||
|
verify(taskChunkMapper, never()).update(any(), any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_012_payload_chunk_normal_multiple_items() throws Exception {
|
||||||
|
// 批量场景:多个 DONE state(单 batch)全部缓冲;flush 后按 chunk 分组一次合并
|
||||||
|
FileTaskEntity task = task();
|
||||||
|
stubChunkMerge(chunkRowsJson());
|
||||||
|
when(taskScopeStateMapper.selectList(any())).thenReturn(
|
||||||
|
List.of(state(task, 1L, "DONE", 1), state(task, 2L, "DONE", 1)));
|
||||||
|
when(fileTaskMapper.selectById(7104L)).thenReturn(task);
|
||||||
|
when(transientPayloadStorageService.resolvePayload(anyString(), anyString()))
|
||||||
|
.thenAnswer(invocation -> {
|
||||||
|
String pointer = invocation.getArgument(0);
|
||||||
|
if (pointer != null && pointer.startsWith("ptr:buffer-")) {
|
||||||
|
return rowsJson(List.of(row("r1", "1", "B0A0000001", "英国", "Title 1")));
|
||||||
|
}
|
||||||
|
if (pointer != null && pointer.startsWith("ptr:chunk-")) {
|
||||||
|
return chunkRowsJson();
|
||||||
|
}
|
||||||
|
return "[]";
|
||||||
|
});
|
||||||
|
when(transientPayloadStorageService.storeChunkPayloadVersioned(anyString(), any(), anyString(), any(), anyString()))
|
||||||
|
.thenAnswer(invocation -> "ptr:stored-" + invocation.getArgument(3));
|
||||||
|
when(taskChunkMapper.selectOne(any())).thenReturn(chunk(1L, "scope-1", 1, "ptr:chunk-1"));
|
||||||
|
when(taskChunkMapper.selectList(any())).thenReturn(List.of(chunk(1L, "scope-1", 1, "ptr:chunk-1")));
|
||||||
|
when(taskChunkMapper.update(any(), any())).thenReturn(1);
|
||||||
|
|
||||||
|
Method flush = SimilarAsinTaskService.class.getDeclaredMethod("flushBufferedCozeResults", Long.class);
|
||||||
|
flush.setAccessible(true);
|
||||||
|
flush.invoke(service, 7104L);
|
||||||
|
|
||||||
|
verify(transientPayloadStorageService, atLeastOnce()).storeChunkPayloadVersioned(anyString(), any(), anyString(), any(), anyString());
|
||||||
|
verify(taskChunkMapper, atLeastOnce()).update(any(), any());
|
||||||
|
// pointer 清理:每个缓冲 state 都更新
|
||||||
|
verify(taskScopeStateMapper, atLeastOnce()).update(any(), any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_012_payload_chunk_normal_repeated_operation_is_idempotent() throws Exception {
|
||||||
|
// 重复执行同一输入:缓冲写幂等(同一 state 不产生重复 buffer/merge)
|
||||||
|
FileTaskEntity task = task();
|
||||||
|
List<SimilarAsinResultRowDto> rows = List.of(row("r1", "1", "B0A0000001", "英国", "Title 1"));
|
||||||
|
|
||||||
|
Method bufferOrMerge = SimilarAsinTaskService.class.getDeclaredMethod("bufferCozeRowsOrMerge",
|
||||||
|
TaskScopeStateEntity.class,
|
||||||
|
SimilarAsinTaskService.CozeBatchContext.class,
|
||||||
|
List.class, FileTaskEntity.class, Map.class);
|
||||||
|
bufferOrMerge.setAccessible(true);
|
||||||
|
TaskScopeStateEntity state = state(task, 1L, "DONE", 2);
|
||||||
|
bufferOrMerge.invoke(service, state, context(2), rows, task, Map.of());
|
||||||
|
bufferOrMerge.invoke(service, state, context(2), rows, task, Map.of());
|
||||||
|
|
||||||
|
// 缓冲 2 次(每次重新写 pointer 是幂等语义:同一 state 覆盖写,无重复行)
|
||||||
|
verify(transientPayloadStorageService, times(2)).storeParsedPayloadEntry(
|
||||||
|
eq(MODULE), eq(7104L), anyString(), anyString(), anyString(), eq(true));
|
||||||
|
verify(taskChunkMapper, never()).update(any(), any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_012_payload_chunk_boundary_empty_input() throws Exception {
|
||||||
|
// 空输入:无行时缓冲与 merge 都不发生,不创建无效资源
|
||||||
|
FileTaskEntity task = task();
|
||||||
|
Method bufferOrMerge = SimilarAsinTaskService.class.getDeclaredMethod("bufferCozeRowsOrMerge",
|
||||||
|
TaskScopeStateEntity.class,
|
||||||
|
SimilarAsinTaskService.CozeBatchContext.class,
|
||||||
|
List.class, FileTaskEntity.class, Map.class);
|
||||||
|
bufferOrMerge.setAccessible(true);
|
||||||
|
bufferOrMerge.invoke(service, state(task, 1L, "DONE", 1), context(1), null, task, Map.of());
|
||||||
|
bufferOrMerge.invoke(service, state(task, 2L, "DONE", 1), context(1), List.of(), task, Map.of());
|
||||||
|
|
||||||
|
verify(transientPayloadStorageService, never()).storeParsedPayloadEntry(any(), any(), anyString(), anyString(), anyString(), eq(true));
|
||||||
|
verify(taskChunkMapper, never()).update(any(), any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_012_payload_chunk_boundary_single_item() throws Exception {
|
||||||
|
// 单 batch(batchTotal=1):原 P0-3 例外,现在也缓冲
|
||||||
|
FileTaskEntity task = task();
|
||||||
|
List<SimilarAsinResultRowDto> rows = List.of(row("r1", "1", "B0A0000001", "英国", "Title 1"));
|
||||||
|
Method bufferOrMerge = SimilarAsinTaskService.class.getDeclaredMethod("bufferCozeRowsOrMerge",
|
||||||
|
TaskScopeStateEntity.class,
|
||||||
|
SimilarAsinTaskService.CozeBatchContext.class,
|
||||||
|
List.class, FileTaskEntity.class, Map.class);
|
||||||
|
bufferOrMerge.setAccessible(true);
|
||||||
|
bufferOrMerge.invoke(service, state(task, 1L, "DONE", 1), context(1), rows, task, Map.of());
|
||||||
|
|
||||||
|
verify(transientPayloadStorageService, times(1)).storeParsedPayloadEntry(
|
||||||
|
eq(MODULE), eq(7104L), anyString(), anyString(), anyString(), eq(true));
|
||||||
|
verify(taskChunkMapper, never()).update(any(), any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_012_payload_chunk_boundary_limit_and_overflow() throws Exception {
|
||||||
|
// 缓冲开关关闭:回退立即 merge,DONE 结果仍落 chunk 不丢失
|
||||||
|
FileTaskEntity task = task();
|
||||||
|
stubChunkMerge(chunkRowsJson());
|
||||||
|
when(properties.isCozeResultBufferEnabled()).thenReturn(false);
|
||||||
|
List<SimilarAsinResultRowDto> rows = List.of(row("r1", "1", "B0A0000001", "英国", "Title 1"));
|
||||||
|
Method bufferOrMerge = SimilarAsinTaskService.class.getDeclaredMethod("bufferCozeRowsOrMerge",
|
||||||
|
TaskScopeStateEntity.class,
|
||||||
|
SimilarAsinTaskService.CozeBatchContext.class,
|
||||||
|
List.class, FileTaskEntity.class, Map.class);
|
||||||
|
bufferOrMerge.setAccessible(true);
|
||||||
|
bufferOrMerge.invoke(service, state(task, 1L, "DONE", 1), context(1), rows, task, Map.of());
|
||||||
|
|
||||||
|
verify(transientPayloadStorageService, never()).storeParsedPayloadEntry(any(), any(), anyString(), anyString(), anyString(), eq(true));
|
||||||
|
verify(transientPayloadStorageService, atLeastOnce()).storeChunkPayloadVersioned(anyString(), any(), anyString(), any(), anyString());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_012_payload_chunk_invalid_input_rejected() throws Exception {
|
||||||
|
// 缓冲写失败(storeParsedPayloadEntry 抛异常):回退立即 merge,结果不丢失
|
||||||
|
FileTaskEntity task = task();
|
||||||
|
stubChunkMerge(chunkRowsJson());
|
||||||
|
when(transientPayloadStorageService.storeParsedPayloadEntry(
|
||||||
|
eq(MODULE), eq(7104L), anyString(), anyString(), anyString(), eq(true)))
|
||||||
|
.thenThrow(new IllegalStateException("rustfs full"));
|
||||||
|
List<SimilarAsinResultRowDto> rows = List.of(row("r1", "1", "B0A0000001", "英国", "Title 1"));
|
||||||
|
Method bufferOrMerge = SimilarAsinTaskService.class.getDeclaredMethod("bufferCozeRowsOrMerge",
|
||||||
|
TaskScopeStateEntity.class,
|
||||||
|
SimilarAsinTaskService.CozeBatchContext.class,
|
||||||
|
List.class, FileTaskEntity.class, Map.class);
|
||||||
|
bufferOrMerge.setAccessible(true);
|
||||||
|
bufferOrMerge.invoke(service, state(task, 1L, "DONE", 1), context(1), rows, task, Map.of());
|
||||||
|
|
||||||
|
verify(transientPayloadStorageService, atLeastOnce()).storeChunkPayloadVersioned(anyString(), any(), anyString(), any(), anyString());
|
||||||
|
verify(taskChunkMapper, atLeastOnce()).update(any(), any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_012_payload_chunk_dependency_failure_releases_resources() throws Exception {
|
||||||
|
// flush 时 chunk 写失败:抛可识别业务异常且不清 pointer(保留待重试);
|
||||||
|
// 依赖恢复后重试 flush 成功,chunk 合并一次、pointer 清理。
|
||||||
|
FileTaskEntity task = task();
|
||||||
|
stubChunkMerge(chunkRowsJson());
|
||||||
|
TaskScopeStateEntity s = state(task, 1L, "DONE", 1);
|
||||||
|
when(taskScopeStateMapper.selectList(any())).thenReturn(List.of(s));
|
||||||
|
when(fileTaskMapper.selectById(7104L)).thenReturn(task);
|
||||||
|
when(transientPayloadStorageService.resolvePayload(anyString(), anyString()))
|
||||||
|
.thenAnswer(invocation -> {
|
||||||
|
String pointer = invocation.getArgument(0);
|
||||||
|
if (pointer != null && pointer.startsWith("ptr:buffer-")) {
|
||||||
|
return rowsJson(List.of(row("r1", "1", "B0A0000001", "英国", "Title 1")));
|
||||||
|
}
|
||||||
|
if (pointer != null && pointer.startsWith("ptr:chunk-")) {
|
||||||
|
return chunkRowsJson();
|
||||||
|
}
|
||||||
|
return "[]";
|
||||||
|
});
|
||||||
|
java.util.concurrent.atomic.AtomicInteger storeCalls = new java.util.concurrent.atomic.AtomicInteger(0);
|
||||||
|
doAnswer(invocation -> {
|
||||||
|
if (storeCalls.incrementAndGet() == 1) {
|
||||||
|
throw new IllegalStateException("rustfs write failed");
|
||||||
|
}
|
||||||
|
return "ptr:stored-" + invocation.getArgument(3);
|
||||||
|
}).when(transientPayloadStorageService).storeChunkPayloadVersioned(anyString(), any(), anyString(), any(), anyString());
|
||||||
|
|
||||||
|
Method flush = SimilarAsinTaskService.class.getDeclaredMethod("flushBufferedCozeResults", Long.class);
|
||||||
|
flush.setAccessible(true);
|
||||||
|
Exception ex = assertThrows(Exception.class, () -> {
|
||||||
|
try {
|
||||||
|
flush.invoke(service, 7104L);
|
||||||
|
} catch (java.lang.reflect.InvocationTargetException e) {
|
||||||
|
throw e.getCause();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
assertTrue(ex.getMessage() != null && ex.getMessage().contains("刷新缓冲区"),
|
||||||
|
"flush 失败消息必须可识别, 实际: " + ex.getMessage());
|
||||||
|
// 失败分组不清 pointer:buffer 未被删除、stateJson 未更新,留待重试
|
||||||
|
verify(transientPayloadStorageService, never()).deletePayloadIfPresent(anyString());
|
||||||
|
verify(taskScopeStateMapper, never()).update(any(), any());
|
||||||
|
|
||||||
|
// 恢复后重试 flush:chunk 合并成功一次,pointer 清理
|
||||||
|
flush.invoke(service, 7104L);
|
||||||
|
assertEquals(2, storeCalls.get(), "恢复后重试应再次写 chunk");
|
||||||
|
verify(taskScopeStateMapper, atLeastOnce()).update(any(), any());
|
||||||
|
}
|
||||||
|
}
|
||||||
+380
@@ -0,0 +1,380 @@
|
|||||||
|
package com.nanri.aiimage.modules.similarasin.service;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.nanri.aiimage.common.exception.BusinessException;
|
||||||
|
import com.nanri.aiimage.config.SimilarAsinProperties;
|
||||||
|
import com.nanri.aiimage.modules.similarasin.client.SimilarAsinCozeClient;
|
||||||
|
import com.nanri.aiimage.modules.similarasin.mapper.SimilarAsinFilterConditionMapper;
|
||||||
|
import com.nanri.aiimage.modules.similarasin.model.dto.SimilarAsinParseRequest;
|
||||||
|
import com.nanri.aiimage.modules.similarasin.model.dto.SimilarAsinParsedPayloadDto;
|
||||||
|
import com.nanri.aiimage.modules.similarasin.model.dto.SimilarAsinSourceFileDto;
|
||||||
|
import com.nanri.aiimage.modules.similarasin.model.vo.SimilarAsinParseVo;
|
||||||
|
import com.nanri.aiimage.modules.similarasin.model.vo.SimilarAsinParsedGroupVo;
|
||||||
|
import com.nanri.aiimage.modules.similarasin.model.vo.SimilarAsinParsedRowVo;
|
||||||
|
import com.nanri.aiimage.modules.similarasin.util.SimilarAsinImageEmbedder;
|
||||||
|
import com.nanri.aiimage.modules.file.service.LocalFileStorageService;
|
||||||
|
import com.nanri.aiimage.modules.task.mapper.FileResultMapper;
|
||||||
|
import com.nanri.aiimage.modules.task.mapper.FileTaskMapper;
|
||||||
|
import com.nanri.aiimage.modules.task.mapper.TaskChunkMapper;
|
||||||
|
import com.nanri.aiimage.modules.task.mapper.TaskScopeStateMapper;
|
||||||
|
import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity;
|
||||||
|
import com.nanri.aiimage.modules.task.service.TransientPayloadStorageService;
|
||||||
|
import org.apache.poi.ss.usermodel.Row;
|
||||||
|
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
|
||||||
|
import org.junit.jupiter.api.AfterEach;
|
||||||
|
import org.junit.jupiter.api.BeforeAll;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.Spy;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
import org.mockito.InjectMocks;
|
||||||
|
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||||
|
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
||||||
|
import org.apache.ibatis.builder.MapperBuilderAssistant;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
import java.io.FileOutputStream;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.concurrent.atomic.AtomicLong;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.Mockito.doAnswer;
|
||||||
|
import static org.mockito.Mockito.lenient;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Task 6:分组数据改为索引/范围引用,避免 groups 嵌套复制完整行对象。
|
||||||
|
* 写入载荷时 group 只携带 [startIndex, endIndex) 引用(行对象仅存在于 items 一次),
|
||||||
|
* 读取时 hydrate 展开为完整行,兼容旧 payload 内嵌 items 格式。
|
||||||
|
*/
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class SimilarAsinTaskServiceGroupRefTest {
|
||||||
|
|
||||||
|
private static final AtomicLong NEXT_ID = new AtomicLong(30000);
|
||||||
|
|
||||||
|
@Mock private LocalFileStorageService localFileStorageService;
|
||||||
|
@Mock private FileTaskMapper fileTaskMapper;
|
||||||
|
@Mock private FileResultMapper fileResultMapper;
|
||||||
|
@Mock private TaskScopeStateMapper taskScopeStateMapper;
|
||||||
|
@Mock private TaskChunkMapper taskChunkMapper;
|
||||||
|
@Mock private SimilarAsinFilterConditionMapper filterConditionMapper;
|
||||||
|
@Spy private ObjectMapper objectMapper = new ObjectMapper();
|
||||||
|
@Mock private SimilarAsinCozeClient cozeClient;
|
||||||
|
@Mock private SimilarAsinTaskCacheService taskCacheService;
|
||||||
|
@Mock private SimilarAsinProperties properties;
|
||||||
|
@Mock private TransientPayloadStorageService transientPayloadStorageService;
|
||||||
|
@Mock private SimilarAsinImageEmbedder imageEmbedder;
|
||||||
|
|
||||||
|
@InjectMocks private SimilarAsinTaskService service;
|
||||||
|
|
||||||
|
@BeforeAll
|
||||||
|
static void initializeMybatisMetadata() {
|
||||||
|
MapperBuilderAssistant assistant = new MapperBuilderAssistant(new MybatisConfiguration(), "");
|
||||||
|
TableInfoHelper.initTableInfo(assistant, FileTaskEntity.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
lenient().when(properties.isBoundedResultAssemblyEnabled()).thenReturn(true);
|
||||||
|
lenient().when(properties.getImageCacheMaxBytes()).thenReturn(256L * 1024L * 1024L);
|
||||||
|
lenient().when(properties.getResultFileTimeoutMinutes()).thenReturn(90);
|
||||||
|
lenient().when(properties.getParseResponsePreviewLimit()).thenReturn(100);
|
||||||
|
lenient().when(transientPayloadStorageService.storeParsedPayloadFast(
|
||||||
|
eq(SimilarAsinTaskService.MODULE_TYPE), any(), anyString(), anyString(), eq(false)))
|
||||||
|
.thenReturn("rustfs:task-parsed/similar-asin/30000/payload.json");
|
||||||
|
lenient().doAnswer(invocation -> {
|
||||||
|
FileTaskEntity task = invocation.getArgument(0);
|
||||||
|
task.setId(NEXT_ID.incrementAndGet());
|
||||||
|
return 1;
|
||||||
|
}).when(fileTaskMapper).insert(any(FileTaskEntity.class));
|
||||||
|
lenient().when(fileResultMapper.insert(any(com.nanri.aiimage.modules.task.model.entity.FileResultEntity.class))).thenReturn(1);
|
||||||
|
lenient().when(taskScopeStateMapper.insert(any(com.nanri.aiimage.modules.task.model.entity.TaskScopeStateEntity.class))).thenReturn(1);
|
||||||
|
lenient().when(taskChunkMapper.selectList(any())).thenReturn(List.of());
|
||||||
|
lenient().when(fileResultMapper.selectList(any())).thenReturn(List.of());
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
void shutdown() {
|
||||||
|
service.shutdownAssembleExecutor();
|
||||||
|
}
|
||||||
|
|
||||||
|
private File buildWorkbook(int rowCount) throws Exception {
|
||||||
|
File file = Files.createTempFile("similar-asin-group-ref-", ".xlsx").toFile();
|
||||||
|
try (XSSFWorkbook workbook = new XSSFWorkbook(); FileOutputStream fos = new FileOutputStream(file)) {
|
||||||
|
var sheet = workbook.createSheet("Sheet1");
|
||||||
|
Row header = sheet.createRow(0);
|
||||||
|
header.createCell(0).setCellValue("id");
|
||||||
|
header.createCell(1).setCellValue("asin");
|
||||||
|
header.createCell(2).setCellValue("国家");
|
||||||
|
for (int i = 1; i <= rowCount; i++) {
|
||||||
|
Row row = sheet.createRow(i);
|
||||||
|
row.createCell(0).setCellValue(String.valueOf(i));
|
||||||
|
row.createCell(1).setCellValue(String.format("B0GRP%05d", i));
|
||||||
|
row.createCell(2).setCellValue("英国");
|
||||||
|
}
|
||||||
|
workbook.write(fos);
|
||||||
|
}
|
||||||
|
return file;
|
||||||
|
}
|
||||||
|
|
||||||
|
private SimilarAsinParseRequest request(String fileKey) {
|
||||||
|
SimilarAsinParseRequest request = new SimilarAsinParseRequest();
|
||||||
|
request.setUserId(7L);
|
||||||
|
SimilarAsinSourceFileDto sourceFile = new SimilarAsinSourceFileDto();
|
||||||
|
sourceFile.setFileKey(fileKey);
|
||||||
|
sourceFile.setOriginalFilename("group-ref.xlsx");
|
||||||
|
request.setFiles(List.of(sourceFile));
|
||||||
|
request.setApiKey("sk-123");
|
||||||
|
request.setImgSwitch(Boolean.FALSE);
|
||||||
|
request.setCategorySwitch(Boolean.FALSE);
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
|
||||||
|
private SimilarAsinParseVo parse(File workbook, String fileKey) {
|
||||||
|
when(localFileStorageService.findLocalSourceFile(fileKey)).thenReturn(workbook);
|
||||||
|
return service.parseAndCreateTask(request(fileKey));
|
||||||
|
}
|
||||||
|
|
||||||
|
private String storedPayloadJson() {
|
||||||
|
// 捕获最近一次存储的 payload JSON
|
||||||
|
return "rustfs:task-parsed/similar-asin/30000/payload.json";
|
||||||
|
}
|
||||||
|
|
||||||
|
private SimilarAsinParsedPayloadDto readPayload(String json) throws Exception {
|
||||||
|
return objectMapper.readValue(json, SimilarAsinParsedPayloadDto.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SimilarAsinParsedRowVo row(String fileKey, int index, String groupKey) {
|
||||||
|
SimilarAsinParsedRowVo row = new SimilarAsinParsedRowVo();
|
||||||
|
row.setSourceFileKey(fileKey);
|
||||||
|
row.setSourceFilename("group-ref.xlsx");
|
||||||
|
row.setRowIndex(index);
|
||||||
|
row.setSourceId(String.valueOf(index));
|
||||||
|
row.setDisplayId(String.valueOf(index));
|
||||||
|
row.setRowToken(fileKey + "::row::" + index);
|
||||||
|
row.setGroupKey(groupKey);
|
||||||
|
row.setAsin(String.format("B0GRP%05d", index));
|
||||||
|
row.setCountry("英国");
|
||||||
|
row.setValues(new java.util.LinkedHashMap<>());
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_006_group_normal_default_path() throws Exception {
|
||||||
|
// 正常多行文件:groups 写入为索引引用,行对象只出现在 items 一次
|
||||||
|
when(transientPayloadStorageService.storeParsedPayloadFast(
|
||||||
|
eq(SimilarAsinTaskService.MODULE_TYPE), any(), anyString(), anyString(), eq(false)))
|
||||||
|
.thenAnswer(invocation -> {
|
||||||
|
String json = invocation.getArgument(3);
|
||||||
|
return "rustfs:task-parsed/similar-asin/30000/payload.json::" + json;
|
||||||
|
});
|
||||||
|
File workbook = buildWorkbook(150);
|
||||||
|
SimilarAsinParseVo vo = parse(workbook, "uploads/20260829/gr-default.xlsx");
|
||||||
|
assertEquals(150, vo.getAcceptedRows());
|
||||||
|
// 每个 group 是索引引用:携带 [startIndex, endIndex),区间宽度等于 itemCount
|
||||||
|
for (SimilarAsinParsedGroupVo group : vo.getGroups()) {
|
||||||
|
assertNotNull(group.getStartIndex());
|
||||||
|
assertNotNull(group.getEndIndex());
|
||||||
|
assertTrue(group.getStartIndex() < group.getEndIndex());
|
||||||
|
assertEquals(group.getEndIndex() - group.getStartIndex(), group.getItemCount());
|
||||||
|
}
|
||||||
|
// 响应 groups 按预览上限裁剪(默认 100),引用区间覆盖全部行、不重叠
|
||||||
|
int coverage = 0;
|
||||||
|
int prevEnd = -1;
|
||||||
|
for (SimilarAsinParsedGroupVo group : vo.getGroups()) {
|
||||||
|
assertTrue(group.getStartIndex() >= prevEnd, "组区间不能重叠且必须顺序递增");
|
||||||
|
coverage += group.getEndIndex() - group.getStartIndex();
|
||||||
|
prevEnd = group.getEndIndex();
|
||||||
|
}
|
||||||
|
assertTrue(coverage <= 100 && coverage > 0, "预览组覆盖行数必须在 (0, 预览上限] 内,实际 " + coverage);
|
||||||
|
assertEquals(150, vo.getAcceptedRows());
|
||||||
|
// 响应组内嵌预览行(前端兼容):每个组 items 与引用区间宽度一致
|
||||||
|
for (SimilarAsinParsedGroupVo group : vo.getGroups()) {
|
||||||
|
assertNotNull(group.getItems());
|
||||||
|
assertEquals(group.getEndIndex() - group.getStartIndex(), group.getItems().size(),
|
||||||
|
"响应组内嵌预览行数量必须与引用区间宽度一致");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_006_group_normal_multiple_items() throws Exception {
|
||||||
|
// 多组批量:每组行数不同,引用与 items 严格对应且顺序稳定
|
||||||
|
String json = groupRefJson(3, new int[][]{{0, 3}, {3, 8}, {8, 10}});
|
||||||
|
SimilarAsinParsedPayloadDto payload = readPayload(json);
|
||||||
|
assertEquals(10, payload.getItems().size());
|
||||||
|
assertEquals(3, payload.getGroups().size());
|
||||||
|
for (int g = 0; g < payload.getGroups().size(); g++) {
|
||||||
|
SimilarAsinParsedGroupVo group = payload.getGroups().get(g);
|
||||||
|
int start = group.getStartIndex();
|
||||||
|
int end = group.getEndIndex();
|
||||||
|
assertTrue(end - start >= 1);
|
||||||
|
// 展开后行与 items 对应(首行即 items[start],行内容一致)
|
||||||
|
List<SimilarAsinParsedRowVo> expanded = new ArrayList<>(payload.getItems().subList(start, end));
|
||||||
|
assertEquals(end - start, expanded.size());
|
||||||
|
assertEquals("t" + (start + 1), expanded.get(0).getRowToken(), "展开首行必须是 items[start]");
|
||||||
|
assertEquals("B0GRP" + String.format("%05d", start + 1), expanded.get(0).getAsin());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_006_group_normal_repeated_operation_is_idempotent() throws Exception {
|
||||||
|
// 重复展开同一 payload:结果一致,且不修改 items
|
||||||
|
String json = groupRefJson(2, new int[][]{{0, 2}, {2, 5}});
|
||||||
|
SimilarAsinParsedPayloadDto payload = readPayload(json);
|
||||||
|
List<SimilarAsinParsedRowVo> first = hydrateForTest(payload);
|
||||||
|
List<SimilarAsinParsedRowVo> second = hydrateForTest(payload);
|
||||||
|
assertEquals(first.size(), second.size());
|
||||||
|
for (int i = 0; i < first.size(); i++) {
|
||||||
|
assertEquals(first.get(i).getRowToken(), second.get(i).getRowToken());
|
||||||
|
}
|
||||||
|
assertEquals(5, payload.getItems().size(), "展开不能修改 payload 内部状态");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_006_group_boundary_empty_input() throws Exception {
|
||||||
|
// 空 groups:引用列表为空,不创建无效引用
|
||||||
|
SimilarAsinParsedPayloadDto payload = new SimilarAsinParsedPayloadDto();
|
||||||
|
payload.setItems(List.of());
|
||||||
|
payload.setGroups(List.of());
|
||||||
|
List<SimilarAsinParsedRowVo> restored = SimilarAsinTaskService.resolveAllRows(payload);
|
||||||
|
assertNotNull(restored);
|
||||||
|
assertEquals(0, restored.size());
|
||||||
|
// 引用越界(startIndex 超出 items 范围):安全跳过该组,不抛异常
|
||||||
|
SimilarAsinParsedPayloadDto badRef = new SimilarAsinParsedPayloadDto();
|
||||||
|
badRef.setItems(List.of(row("f.xlsx", 1, "f.xlsx::1")));
|
||||||
|
SimilarAsinParsedGroupVo group = new SimilarAsinParsedGroupVo();
|
||||||
|
group.setGroupKey("f.xlsx::1");
|
||||||
|
group.setStartIndex(5);
|
||||||
|
group.setEndIndex(7);
|
||||||
|
badRef.setGroups(List.of(group));
|
||||||
|
List<SimilarAsinParsedRowVo> outOfRange = SimilarAsinTaskService.resolveAllRows(badRef);
|
||||||
|
assertEquals(0, outOfRange.size(), "越界引用必须安全跳过");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_006_group_boundary_single_item() throws Exception {
|
||||||
|
// 单行单组:区间为 [0,1),单行不依赖批量路径
|
||||||
|
String json = groupRefJson(1, new int[][]{{0, 1}});
|
||||||
|
SimilarAsinParsedPayloadDto payload = readPayload(json);
|
||||||
|
assertEquals(1, payload.getGroups().size());
|
||||||
|
SimilarAsinParsedGroupVo group = payload.getGroups().get(0);
|
||||||
|
assertEquals(0, group.getStartIndex());
|
||||||
|
assertEquals(1, group.getEndIndex());
|
||||||
|
assertEquals(1, group.getItemCount());
|
||||||
|
List<SimilarAsinParsedRowVo> expanded = new ArrayList<>(payload.getItems().subList(0, 1));
|
||||||
|
assertEquals(1, expanded.size());
|
||||||
|
assertEquals("B0GRP00001", expanded.get(0).getAsin());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_006_group_boundary_limit_and_overflow() throws Exception {
|
||||||
|
// 组引用到达 items 末尾:endIndex == items.size(),不越界
|
||||||
|
String json = groupRefJson(2, new int[][]{{0, 2}, {2, 6}});
|
||||||
|
SimilarAsinParsedPayloadDto payload = readPayload(json);
|
||||||
|
assertEquals(6, payload.getItems().size());
|
||||||
|
SimilarAsinParsedGroupVo last = payload.getGroups().get(1);
|
||||||
|
assertEquals(6, last.getEndIndex());
|
||||||
|
List<SimilarAsinParsedRowVo> expanded = new ArrayList<>(payload.getItems().subList(last.getStartIndex(), last.getEndIndex()));
|
||||||
|
assertEquals(4, expanded.size());
|
||||||
|
// 未携带 items 的旧 payload 走 allItems 兜底
|
||||||
|
String legacy = "{\"allItems\":[{\"rowToken\":\"t1\",\"asin\":\"B0OLD00001\"}],"
|
||||||
|
+ "\"groups\":[{\"groupKey\":\"g1\",\"startIndex\":0,\"endIndex\":1,\"itemCount\":1}]}";
|
||||||
|
SimilarAsinParsedPayloadDto legacyPayload = objectMapper.readValue(legacy, SimilarAsinParsedPayloadDto.class);
|
||||||
|
List<SimilarAsinParsedRowVo> restored = SimilarAsinTaskService.resolveAllRows(legacyPayload);
|
||||||
|
assertEquals(1, restored.size());
|
||||||
|
assertEquals("B0OLD00001", restored.get(0).getAsin());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_006_group_invalid_input_rejected() throws Exception {
|
||||||
|
// 非法区间:endIndex <= startIndex,安全跳过
|
||||||
|
SimilarAsinParsedPayloadDto payload = new SimilarAsinParsedPayloadDto();
|
||||||
|
payload.setItems(List.of(row("f.xlsx", 1, "f.xlsx::1"), row("f.xlsx", 2, "f.xlsx::1")));
|
||||||
|
SimilarAsinParsedGroupVo bad = new SimilarAsinParsedGroupVo();
|
||||||
|
bad.setGroupKey("f.xlsx::1");
|
||||||
|
bad.setStartIndex(1);
|
||||||
|
bad.setEndIndex(1);
|
||||||
|
payload.setGroups(List.of(bad));
|
||||||
|
assertEquals(0, SimilarAsinTaskService.resolveAllRows(payload).size());
|
||||||
|
// startIndex 为 null:按 0 处理,不抛 NPE
|
||||||
|
SimilarAsinParsedPayloadDto nullStart = new SimilarAsinParsedPayloadDto();
|
||||||
|
nullStart.setItems(List.of(row("f.xlsx", 1, "f.xlsx::1")));
|
||||||
|
SimilarAsinParsedGroupVo g = new SimilarAsinParsedGroupVo();
|
||||||
|
g.setGroupKey("f.xlsx::1");
|
||||||
|
g.setStartIndex(null);
|
||||||
|
g.setEndIndex(1);
|
||||||
|
nullStart.setGroups(List.of(g));
|
||||||
|
assertEquals(1, SimilarAsinTaskService.resolveAllRows(nullStart).size());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_006_group_dependency_failure_releases_resources() throws Exception {
|
||||||
|
// RustFS 存储失败:解析抛异常;恢复后重试成功,groups 引用与行一致
|
||||||
|
when(properties.getParseResponsePreviewLimit()).thenReturn(100);
|
||||||
|
File workbook = buildWorkbook(80);
|
||||||
|
when(localFileStorageService.findLocalSourceFile("uploads/20260829/gr-fail.xlsx")).thenReturn(workbook);
|
||||||
|
when(transientPayloadStorageService.storeParsedPayloadFast(
|
||||||
|
eq(SimilarAsinTaskService.MODULE_TYPE), any(), anyString(), anyString(), eq(false)))
|
||||||
|
.thenThrow(new IllegalStateException("rustfs down"))
|
||||||
|
.thenReturn("rustfs:task-parsed/similar-asin/30001/payload.json");
|
||||||
|
assertThrows(IllegalStateException.class, () -> parse(workbook, "uploads/20260829/gr-fail.xlsx"));
|
||||||
|
SimilarAsinParseVo vo = parse(workbook, "uploads/20260829/gr-recovered.xlsx");
|
||||||
|
assertEquals(80, vo.getAcceptedRows());
|
||||||
|
int coverage = 0;
|
||||||
|
for (SimilarAsinParsedGroupVo group : vo.getGroups()) {
|
||||||
|
assertTrue(group.getStartIndex() < group.getEndIndex());
|
||||||
|
coverage += group.getEndIndex() - group.getStartIndex();
|
||||||
|
}
|
||||||
|
assertEquals(80, coverage);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- helpers ----
|
||||||
|
|
||||||
|
private static String groupRefJson(int groupCount, int[][] ranges) {
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
sb.append("{\"items\":[");
|
||||||
|
// 计算总行数
|
||||||
|
int maxEnd = 0;
|
||||||
|
for (int[] r : ranges) {
|
||||||
|
maxEnd = Math.max(maxEnd, r[1]);
|
||||||
|
}
|
||||||
|
for (int i = 1; i <= maxEnd; i++) {
|
||||||
|
if (i > 1) {
|
||||||
|
sb.append(",");
|
||||||
|
}
|
||||||
|
sb.append("{\"rowToken\":\"t").append(i).append("\",\"asin\":\"B0GRP")
|
||||||
|
.append(String.format("%05d", i)).append("\",\"sourceFileKey\":\"f.xlsx\",\"rowIndex\":")
|
||||||
|
.append(i).append("}");
|
||||||
|
}
|
||||||
|
sb.append("],\"groups\":[");
|
||||||
|
for (int g = 0; g < groupCount; g++) {
|
||||||
|
if (g > 0) {
|
||||||
|
sb.append(",");
|
||||||
|
}
|
||||||
|
sb.append("{\"groupKey\":\"g").append(g + 1).append("\",\"startIndex\":")
|
||||||
|
.append(ranges[g][0]).append(",\"endIndex\":").append(ranges[g][1])
|
||||||
|
.append(",\"itemCount\":").append(ranges[g][1] - ranges[g][0]).append("}");
|
||||||
|
}
|
||||||
|
sb.append("]}");
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<SimilarAsinParsedRowVo> hydrateForTest(SimilarAsinParsedPayloadDto payload) {
|
||||||
|
// 调用 service 的引用展开实现(与 hydrateParsedPayloadRows 语义一致)
|
||||||
|
SimilarAsinParsedPayloadDto copy = new SimilarAsinParsedPayloadDto();
|
||||||
|
copy.setItems(payload.getItems());
|
||||||
|
copy.setAllItems(payload.getAllItems());
|
||||||
|
copy.setGroups(payload.getGroups());
|
||||||
|
return SimilarAsinTaskService.expandGroupRefs(copy);
|
||||||
|
}
|
||||||
|
}
|
||||||
+277
@@ -0,0 +1,277 @@
|
|||||||
|
package com.nanri.aiimage.modules.similarasin.service;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.nanri.aiimage.common.exception.BusinessException;
|
||||||
|
import com.nanri.aiimage.config.SimilarAsinProperties;
|
||||||
|
import com.nanri.aiimage.modules.similarasin.client.SimilarAsinCozeClient;
|
||||||
|
import com.nanri.aiimage.modules.similarasin.mapper.SimilarAsinFilterConditionMapper;
|
||||||
|
import com.nanri.aiimage.modules.similarasin.model.dto.SimilarAsinParseRequest;
|
||||||
|
import com.nanri.aiimage.modules.similarasin.model.dto.SimilarAsinSourceFileDto;
|
||||||
|
import com.nanri.aiimage.modules.similarasin.model.vo.SimilarAsinParseVo;
|
||||||
|
import com.nanri.aiimage.modules.similarasin.util.SimilarAsinImageEmbedder;
|
||||||
|
import com.nanri.aiimage.modules.file.service.LocalFileStorageService;
|
||||||
|
import com.nanri.aiimage.modules.task.mapper.FileResultMapper;
|
||||||
|
import com.nanri.aiimage.modules.task.mapper.FileTaskMapper;
|
||||||
|
import com.nanri.aiimage.modules.task.mapper.TaskChunkMapper;
|
||||||
|
import com.nanri.aiimage.modules.task.mapper.TaskScopeStateMapper;
|
||||||
|
import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity;
|
||||||
|
import com.nanri.aiimage.modules.task.service.TransientPayloadStorageService;
|
||||||
|
import org.apache.poi.ss.usermodel.Row;
|
||||||
|
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
|
||||||
|
import org.junit.jupiter.api.AfterEach;
|
||||||
|
import org.junit.jupiter.api.BeforeAll;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.Spy;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
import org.mockito.InjectMocks;
|
||||||
|
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||||
|
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
||||||
|
import org.apache.ibatis.builder.MapperBuilderAssistant;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
import java.io.FileOutputStream;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.concurrent.atomic.AtomicLong;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.Mockito.doAnswer;
|
||||||
|
import static org.mockito.Mockito.lenient;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Task 7:限制单文件大小、最大行数和最大字段长度,防止解析任务无界增长。
|
||||||
|
* 超限输入在解析入口被拒绝或截断;mock 依赖 + 真实 xlsx 验证边界行为。
|
||||||
|
*/
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class SimilarAsinTaskServiceParseLimitsTest {
|
||||||
|
|
||||||
|
private static final AtomicLong NEXT_ID = new AtomicLong(40000);
|
||||||
|
|
||||||
|
@Mock private LocalFileStorageService localFileStorageService;
|
||||||
|
@Mock private FileTaskMapper fileTaskMapper;
|
||||||
|
@Mock private FileResultMapper fileResultMapper;
|
||||||
|
@Mock private TaskScopeStateMapper taskScopeStateMapper;
|
||||||
|
@Mock private TaskChunkMapper taskChunkMapper;
|
||||||
|
@Mock private SimilarAsinFilterConditionMapper filterConditionMapper;
|
||||||
|
@Spy private ObjectMapper objectMapper = new ObjectMapper();
|
||||||
|
@Mock private SimilarAsinCozeClient cozeClient;
|
||||||
|
@Mock private SimilarAsinTaskCacheService taskCacheService;
|
||||||
|
@Mock private SimilarAsinProperties properties;
|
||||||
|
@Mock private TransientPayloadStorageService transientPayloadStorageService;
|
||||||
|
@Mock private SimilarAsinImageEmbedder imageEmbedder;
|
||||||
|
|
||||||
|
@InjectMocks private SimilarAsinTaskService service;
|
||||||
|
|
||||||
|
@BeforeAll
|
||||||
|
static void initializeMybatisMetadata() {
|
||||||
|
MapperBuilderAssistant assistant = new MapperBuilderAssistant(new MybatisConfiguration(), "");
|
||||||
|
TableInfoHelper.initTableInfo(assistant, FileTaskEntity.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
lenient().when(properties.isBoundedResultAssemblyEnabled()).thenReturn(true);
|
||||||
|
lenient().when(properties.getImageCacheMaxBytes()).thenReturn(256L * 1024L * 1024L);
|
||||||
|
lenient().when(properties.getResultFileTimeoutMinutes()).thenReturn(90);
|
||||||
|
lenient().when(properties.getParseResponsePreviewLimit()).thenReturn(100);
|
||||||
|
lenient().when(properties.getMaxSourceFileBytes()).thenReturn(50L * 1024L * 1024L);
|
||||||
|
lenient().when(properties.getMaxParseRows()).thenReturn(50000);
|
||||||
|
lenient().when(properties.getMaxFieldLength()).thenReturn(2000);
|
||||||
|
lenient().when(transientPayloadStorageService.storeParsedPayloadFast(
|
||||||
|
eq(SimilarAsinTaskService.MODULE_TYPE), any(), anyString(), anyString(), eq(false)))
|
||||||
|
.thenReturn("rustfs:task-parsed/similar-asin/40000/payload.json");
|
||||||
|
lenient().doAnswer(invocation -> {
|
||||||
|
FileTaskEntity task = invocation.getArgument(0);
|
||||||
|
task.setId(NEXT_ID.incrementAndGet());
|
||||||
|
return 1;
|
||||||
|
}).when(fileTaskMapper).insert(any(FileTaskEntity.class));
|
||||||
|
lenient().when(fileResultMapper.insert(any(com.nanri.aiimage.modules.task.model.entity.FileResultEntity.class))).thenReturn(1);
|
||||||
|
lenient().when(taskScopeStateMapper.insert(any(com.nanri.aiimage.modules.task.model.entity.TaskScopeStateEntity.class))).thenReturn(1);
|
||||||
|
lenient().when(taskChunkMapper.selectList(any())).thenReturn(List.of());
|
||||||
|
lenient().when(fileResultMapper.selectList(any())).thenReturn(List.of());
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
void shutdown() {
|
||||||
|
service.shutdownAssembleExecutor();
|
||||||
|
}
|
||||||
|
|
||||||
|
private File buildWorkbook(int rowCount) throws Exception {
|
||||||
|
return buildWorkbookWithAsin(rowCount, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private File buildWorkbookWithAsin(int rowCount, String asinValue) throws Exception {
|
||||||
|
File file = Files.createTempFile("similar-asin-parse-limits-", ".xlsx").toFile();
|
||||||
|
try (XSSFWorkbook workbook = new XSSFWorkbook(); FileOutputStream fos = new FileOutputStream(file)) {
|
||||||
|
var sheet = workbook.createSheet("Sheet1");
|
||||||
|
Row header = sheet.createRow(0);
|
||||||
|
header.createCell(0).setCellValue("id");
|
||||||
|
header.createCell(1).setCellValue("asin");
|
||||||
|
header.createCell(2).setCellValue("国家");
|
||||||
|
for (int i = 1; i <= rowCount; i++) {
|
||||||
|
Row row = sheet.createRow(i);
|
||||||
|
row.createCell(0).setCellValue(String.valueOf(i));
|
||||||
|
row.createCell(1).setCellValue(asinValue != null ? asinValue : String.format("B0LIM%05d", i));
|
||||||
|
row.createCell(2).setCellValue("英国");
|
||||||
|
}
|
||||||
|
workbook.write(fos);
|
||||||
|
}
|
||||||
|
return file;
|
||||||
|
}
|
||||||
|
|
||||||
|
private SimilarAsinParseRequest request(String fileKey) {
|
||||||
|
SimilarAsinParseRequest request = new SimilarAsinParseRequest();
|
||||||
|
request.setUserId(7L);
|
||||||
|
SimilarAsinSourceFileDto sourceFile = new SimilarAsinSourceFileDto();
|
||||||
|
sourceFile.setFileKey(fileKey);
|
||||||
|
sourceFile.setOriginalFilename("limits.xlsx");
|
||||||
|
request.setFiles(List.of(sourceFile));
|
||||||
|
request.setApiKey("sk-123");
|
||||||
|
request.setImgSwitch(Boolean.FALSE);
|
||||||
|
request.setCategorySwitch(Boolean.FALSE);
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
|
||||||
|
private SimilarAsinParseVo parse(File workbook, String fileKey) {
|
||||||
|
when(localFileStorageService.findLocalSourceFile(fileKey)).thenReturn(workbook);
|
||||||
|
return service.parseAndCreateTask(request(fileKey));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_007_file_size_row_count_normal_default_path() throws Exception {
|
||||||
|
// 默认配置(50MB/50000 行/2000 字符):正常文件解析成功,行数不丢失
|
||||||
|
File workbook = buildWorkbook(120);
|
||||||
|
SimilarAsinParseVo vo = parse(workbook, "uploads/20260829/limit-default.xlsx");
|
||||||
|
assertEquals(120, vo.getAcceptedRows());
|
||||||
|
assertEquals(120, vo.getTotalRows());
|
||||||
|
assertEquals(100, vo.getItems().size());
|
||||||
|
assertEquals("B0LIM00001", vo.getItems().get(0).getAsin());
|
||||||
|
// 源文件大小在限制内
|
||||||
|
assertTrue(workbook.length() <= 50L * 1024L * 1024L);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_007_file_size_row_count_normal_multiple_items() throws Exception {
|
||||||
|
// 多文件批量:每个文件都在限制内,汇总不丢行
|
||||||
|
File workbookA = buildWorkbook(30);
|
||||||
|
File workbookB = buildWorkbook(40);
|
||||||
|
when(localFileStorageService.findLocalSourceFile("uploads/20260829/limit-a.xlsx")).thenReturn(workbookA);
|
||||||
|
when(localFileStorageService.findLocalSourceFile("uploads/20260829/limit-b.xlsx")).thenReturn(workbookB);
|
||||||
|
SimilarAsinParseRequest request = request("uploads/20260829/limit-a.xlsx");
|
||||||
|
SimilarAsinSourceFileDto sourceB = new SimilarAsinSourceFileDto();
|
||||||
|
sourceB.setFileKey("uploads/20260829/limit-b.xlsx");
|
||||||
|
sourceB.setOriginalFilename("limits-b.xlsx");
|
||||||
|
request.setFiles(List.of(request.getFiles().get(0), sourceB));
|
||||||
|
SimilarAsinParseVo vo = service.parseAndCreateTask(request);
|
||||||
|
assertEquals(70, vo.getAcceptedRows());
|
||||||
|
assertEquals(70, vo.getTotalRows());
|
||||||
|
assertNotNull(vo.getTaskId());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_007_file_size_row_count_normal_repeated_operation_is_idempotent() throws Exception {
|
||||||
|
// 重复解析同一文件:结果一致,不产生重复状态
|
||||||
|
File workbook = buildWorkbook(60);
|
||||||
|
SimilarAsinParseVo first = parse(workbook, "uploads/20260829/limit-idem.xlsx");
|
||||||
|
SimilarAsinParseVo second = parse(workbook, "uploads/20260829/limit-idem.xlsx");
|
||||||
|
assertEquals(first.getAcceptedRows(), second.getAcceptedRows());
|
||||||
|
assertEquals(first.getItems().size(), second.getItems().size());
|
||||||
|
for (int i = 0; i < first.getItems().size(); i++) {
|
||||||
|
assertEquals(first.getItems().get(i).getAsin(), second.getItems().get(i).getAsin());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_007_file_size_row_count_boundary_empty_input() throws Exception {
|
||||||
|
// 空文件(无有效数据行):抛业务异常,不创建任务
|
||||||
|
File workbook = buildWorkbook(0);
|
||||||
|
when(localFileStorageService.findLocalSourceFile("uploads/20260829/limit-empty.xlsx")).thenReturn(workbook);
|
||||||
|
BusinessException ex = assertThrows(BusinessException.class,
|
||||||
|
() -> parse(workbook, "uploads/20260829/limit-empty.xlsx"));
|
||||||
|
assertTrue(ex.getMessage() != null && !ex.getMessage().isBlank());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_007_file_size_row_count_boundary_single_item() throws Exception {
|
||||||
|
// 单行小文件:不依赖批量路径,结果正确
|
||||||
|
File workbook = buildWorkbook(1);
|
||||||
|
SimilarAsinParseVo vo = parse(workbook, "uploads/20260829/limit-single.xlsx");
|
||||||
|
assertEquals(1, vo.getAcceptedRows());
|
||||||
|
assertEquals(1, vo.getItems().size());
|
||||||
|
assertEquals("B0LIM00001", vo.getItems().get(0).getAsin());
|
||||||
|
assertEquals(1, vo.getGroupCount());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_007_file_size_row_count_boundary_limit_and_overflow() throws Exception {
|
||||||
|
// 行数恰好等于上限:允许
|
||||||
|
when(properties.getMaxParseRows()).thenReturn(8);
|
||||||
|
File workbook = buildWorkbook(8);
|
||||||
|
SimilarAsinParseVo vo = parse(workbook, "uploads/20260829/limit-exact.xlsx");
|
||||||
|
assertEquals(8, vo.getAcceptedRows());
|
||||||
|
// 行数超过上限:拒绝,且不创建任务
|
||||||
|
when(properties.getMaxParseRows()).thenReturn(3);
|
||||||
|
File workbookOver = buildWorkbook(4);
|
||||||
|
when(localFileStorageService.findLocalSourceFile("uploads/20260829/limit-over.xlsx")).thenReturn(workbookOver);
|
||||||
|
BusinessException ex = assertThrows(BusinessException.class,
|
||||||
|
() -> parse(workbookOver, "uploads/20260829/limit-over.xlsx"));
|
||||||
|
assertTrue(ex.getMessage() != null && ex.getMessage().contains("行数"),
|
||||||
|
"超行数异常消息必须可识别,实际: " + ex.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_007_file_size_row_count_invalid_input_rejected() throws Exception {
|
||||||
|
// 文件大小超限:拒绝,异常消息可识别
|
||||||
|
when(properties.getMaxSourceFileBytes()).thenReturn(64L);
|
||||||
|
File workbook = buildWorkbook(5);
|
||||||
|
when(localFileStorageService.findLocalSourceFile("uploads/20260829/limit-bigfile.xlsx")).thenReturn(workbook);
|
||||||
|
assertTrue(workbook.length() > 64L, "测试文件必须超过 64 字节限制,实际 " + workbook.length());
|
||||||
|
BusinessException ex = assertThrows(BusinessException.class,
|
||||||
|
() -> parse(workbook, "uploads/20260829/limit-bigfile.xlsx"));
|
||||||
|
assertTrue(ex.getMessage() != null && ex.getMessage().contains("大小限制"),
|
||||||
|
"超文件大小异常消息必须可识别,实际: " + ex.getMessage());
|
||||||
|
// 字段长度超限:截断而非拒绝,字段仍非空
|
||||||
|
when(properties.getMaxSourceFileBytes()).thenReturn(50L * 1024L * 1024L);
|
||||||
|
when(properties.getMaxFieldLength()).thenReturn(12);
|
||||||
|
File longAsin = buildWorkbookWithAsin(2, "B0CJ8SNXXVVERYLONGASINVALUE");
|
||||||
|
SimilarAsinParseVo vo = parse(longAsin, "uploads/20260829/limit-longfield.xlsx");
|
||||||
|
assertEquals(2, vo.getAcceptedRows());
|
||||||
|
for (var item : vo.getItems()) {
|
||||||
|
assertTrue(item.getAsin().length() <= 12, "超长字段必须截断到配置上限");
|
||||||
|
assertTrue(!item.getAsin().isBlank());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_007_file_size_row_count_dependency_failure_releases_resources() throws Exception {
|
||||||
|
// RustFS 存储失败:解析抛异常;恢复后重试成功,无残留状态
|
||||||
|
File workbook = buildWorkbook(40);
|
||||||
|
when(localFileStorageService.findLocalSourceFile("uploads/20260829/limit-fail.xlsx")).thenReturn(workbook);
|
||||||
|
when(transientPayloadStorageService.storeParsedPayloadFast(
|
||||||
|
eq(SimilarAsinTaskService.MODULE_TYPE), any(), anyString(), anyString(), eq(false)))
|
||||||
|
.thenThrow(new IllegalStateException("rustfs down"))
|
||||||
|
.thenReturn("rustfs:task-parsed/similar-asin/40001/payload.json");
|
||||||
|
assertThrows(IllegalStateException.class, () -> parse(workbook, "uploads/20260829/limit-fail.xlsx"));
|
||||||
|
SimilarAsinParseVo vo = parse(workbook, "uploads/20260829/limit-recovered.xlsx");
|
||||||
|
assertEquals(40, vo.getAcceptedRows());
|
||||||
|
assertEquals(40, vo.getItems().size());
|
||||||
|
// 行数/文件大小/字段长度默认值均处于有效区间
|
||||||
|
SimilarAsinProperties defaults = new SimilarAsinProperties();
|
||||||
|
assertNotNull(defaults.getMaxParseRows());
|
||||||
|
assertNotNull(defaults.getMaxSourceFileBytes());
|
||||||
|
assertNotNull(defaults.getMaxFieldLength());
|
||||||
|
assertTrue(defaults.getMaxParseRows() >= 1000, "默认最大行数至少 1000");
|
||||||
|
assertTrue(defaults.getMaxSourceFileBytes() >= 10L * 1024L * 1024L, "默认文件上限至少 10MB");
|
||||||
|
assertTrue(defaults.getMaxFieldLength() >= 500, "默认字段上限至少 500 字符");
|
||||||
|
}
|
||||||
|
}
|
||||||
+276
@@ -0,0 +1,276 @@
|
|||||||
|
package com.nanri.aiimage.modules.similarasin.service;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.nanri.aiimage.common.exception.BusinessException;
|
||||||
|
import com.nanri.aiimage.config.SimilarAsinProperties;
|
||||||
|
import com.nanri.aiimage.modules.similarasin.client.SimilarAsinCozeClient;
|
||||||
|
import com.nanri.aiimage.modules.similarasin.mapper.SimilarAsinFilterConditionMapper;
|
||||||
|
import com.nanri.aiimage.modules.similarasin.model.dto.SimilarAsinParseRequest;
|
||||||
|
import com.nanri.aiimage.modules.similarasin.model.dto.SimilarAsinSourceFileDto;
|
||||||
|
import com.nanri.aiimage.modules.similarasin.model.vo.SimilarAsinParseVo;
|
||||||
|
import com.nanri.aiimage.modules.similarasin.util.SimilarAsinImageEmbedder;
|
||||||
|
import com.nanri.aiimage.modules.file.service.LocalFileStorageService;
|
||||||
|
import com.nanri.aiimage.modules.task.mapper.FileResultMapper;
|
||||||
|
import com.nanri.aiimage.modules.task.mapper.FileTaskMapper;
|
||||||
|
import com.nanri.aiimage.modules.task.mapper.TaskChunkMapper;
|
||||||
|
import com.nanri.aiimage.modules.task.mapper.TaskScopeStateMapper;
|
||||||
|
import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity;
|
||||||
|
import com.nanri.aiimage.modules.task.service.TransientPayloadStorageService;
|
||||||
|
import org.apache.poi.ss.usermodel.Row;
|
||||||
|
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
|
||||||
|
import org.junit.jupiter.api.AfterEach;
|
||||||
|
import org.junit.jupiter.api.BeforeAll;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.Spy;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
import org.mockito.InjectMocks;
|
||||||
|
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||||
|
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
||||||
|
import org.apache.ibatis.builder.MapperBuilderAssistant;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
import java.io.FileOutputStream;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.concurrent.atomic.AtomicLong;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.Mockito.doAnswer;
|
||||||
|
import static org.mockito.Mockito.lenient;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Task 4:解析接口只返回固定数量预览行,完整行仅保存在后端任务载荷。
|
||||||
|
* 通过 mock 依赖 + 真实 xlsx 文件验证 parseAndCreateTask 的响应裁剪行为。
|
||||||
|
*/
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class SimilarAsinTaskServiceParsePreviewTest {
|
||||||
|
|
||||||
|
private static final AtomicLong NEXT_ID = new AtomicLong(10000);
|
||||||
|
|
||||||
|
@Mock private LocalFileStorageService localFileStorageService;
|
||||||
|
@Mock private FileTaskMapper fileTaskMapper;
|
||||||
|
@Mock private FileResultMapper fileResultMapper;
|
||||||
|
@Mock private TaskScopeStateMapper taskScopeStateMapper;
|
||||||
|
@Mock private TaskChunkMapper taskChunkMapper;
|
||||||
|
@Mock private SimilarAsinFilterConditionMapper filterConditionMapper;
|
||||||
|
@Spy private ObjectMapper objectMapper = new ObjectMapper();
|
||||||
|
@Mock private SimilarAsinCozeClient cozeClient;
|
||||||
|
@Mock private SimilarAsinTaskCacheService taskCacheService;
|
||||||
|
@Mock private SimilarAsinProperties properties;
|
||||||
|
@Mock private TransientPayloadStorageService transientPayloadStorageService;
|
||||||
|
@Mock private SimilarAsinImageEmbedder imageEmbedder;
|
||||||
|
|
||||||
|
@InjectMocks private SimilarAsinTaskService service;
|
||||||
|
|
||||||
|
@BeforeAll
|
||||||
|
static void initializeMybatisMetadata() {
|
||||||
|
MapperBuilderAssistant assistant = new MapperBuilderAssistant(new MybatisConfiguration(), "");
|
||||||
|
TableInfoHelper.initTableInfo(assistant, FileTaskEntity.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() throws Exception {
|
||||||
|
lenient().when(properties.isBoundedResultAssemblyEnabled()).thenReturn(true);
|
||||||
|
lenient().when(properties.getImageCacheMaxBytes()).thenReturn(256L * 1024L * 1024L);
|
||||||
|
lenient().when(properties.getResultFileTimeoutMinutes()).thenReturn(90);
|
||||||
|
lenient().when(transientPayloadStorageService.storeParsedPayloadFast(
|
||||||
|
eq(SimilarAsinTaskService.MODULE_TYPE), any(), anyString(), anyString(), eq(false)))
|
||||||
|
.thenReturn("rustfs:task-parsed/similar-asin/10000/payload.json");
|
||||||
|
// 插入任务时回填 id(异常路径不触发,标记 lenient)
|
||||||
|
lenient().doAnswer(invocation -> {
|
||||||
|
FileTaskEntity task = invocation.getArgument(0);
|
||||||
|
task.setId(NEXT_ID.incrementAndGet());
|
||||||
|
return 1;
|
||||||
|
}).when(fileTaskMapper).insert(any(FileTaskEntity.class));
|
||||||
|
lenient().when(fileResultMapper.insert(any(com.nanri.aiimage.modules.task.model.entity.FileResultEntity.class))).thenReturn(1);
|
||||||
|
lenient().when(taskScopeStateMapper.insert(any(com.nanri.aiimage.modules.task.model.entity.TaskScopeStateEntity.class))).thenReturn(1);
|
||||||
|
lenient().when(taskChunkMapper.selectList(any())).thenReturn(List.of());
|
||||||
|
lenient().when(fileResultMapper.selectList(any())).thenReturn(List.of());
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
void shutdown() {
|
||||||
|
service.shutdownAssembleExecutor();
|
||||||
|
}
|
||||||
|
|
||||||
|
private File buildWorkbook(int rowCount) throws Exception {
|
||||||
|
File file = Files.createTempFile("similar-asin-parse-preview-", ".xlsx").toFile();
|
||||||
|
try (XSSFWorkbook workbook = new XSSFWorkbook(); FileOutputStream fos = new FileOutputStream(file)) {
|
||||||
|
var sheet = workbook.createSheet("Sheet1");
|
||||||
|
Row header = sheet.createRow(0);
|
||||||
|
header.createCell(0).setCellValue("id");
|
||||||
|
header.createCell(1).setCellValue("asin");
|
||||||
|
header.createCell(2).setCellValue("国家");
|
||||||
|
header.createCell(3).setCellValue("价格");
|
||||||
|
header.createCell(4).setCellValue("货号");
|
||||||
|
for (int i = 1; i <= rowCount; i++) {
|
||||||
|
Row row = sheet.createRow(i);
|
||||||
|
row.createCell(0).setCellValue(String.valueOf(i));
|
||||||
|
row.createCell(1).setCellValue(String.format("B0TEST%04d", i));
|
||||||
|
row.createCell(2).setCellValue("英国");
|
||||||
|
row.createCell(3).setCellValue("12.29");
|
||||||
|
row.createCell(4).setCellValue("SKU-" + i);
|
||||||
|
}
|
||||||
|
workbook.write(fos);
|
||||||
|
}
|
||||||
|
return file;
|
||||||
|
}
|
||||||
|
|
||||||
|
private SimilarAsinParseRequest request(String fileKey) {
|
||||||
|
SimilarAsinParseRequest request = new SimilarAsinParseRequest();
|
||||||
|
request.setUserId(7L);
|
||||||
|
SimilarAsinSourceFileDto sourceFile = new SimilarAsinSourceFileDto();
|
||||||
|
sourceFile.setFileKey(fileKey);
|
||||||
|
sourceFile.setOriginalFilename("preview.xlsx");
|
||||||
|
request.setFiles(List.of(sourceFile));
|
||||||
|
request.setApiKey("sk-123");
|
||||||
|
request.setImgSwitch(Boolean.FALSE);
|
||||||
|
request.setCategorySwitch(Boolean.FALSE);
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
|
||||||
|
private SimilarAsinParseVo parse(File workbook, String fileKey) {
|
||||||
|
when(localFileStorageService.findLocalSourceFile(fileKey)).thenReturn(workbook);
|
||||||
|
return service.parseAndCreateTask(request(fileKey));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_004_preview_normal_default_path() throws Exception {
|
||||||
|
// 150 行:响应只返回预览行(≤100),完整行不进入响应
|
||||||
|
File workbook = buildWorkbook(150);
|
||||||
|
SimilarAsinParseVo vo = parse(workbook, "uploads/20260829/preview.xlsx");
|
||||||
|
assertNotNull(vo.getTaskId());
|
||||||
|
assertEquals(150, vo.getAcceptedRows());
|
||||||
|
assertEquals(150, vo.getTotalRows());
|
||||||
|
// 预览行固定 ≤ 100
|
||||||
|
assertTrue(vo.getItems().size() <= 100, "响应 items 必须是固定数量预览行");
|
||||||
|
assertEquals(vo.getItems().size(), 100);
|
||||||
|
// 预览行顺序稳定:从第 1 行开始
|
||||||
|
assertEquals("1", vo.getItems().get(0).getSourceId());
|
||||||
|
assertEquals("B0TEST0001", vo.getItems().get(0).getAsin());
|
||||||
|
// groups 也裁剪为预览行(不携带全量子行)
|
||||||
|
assertTrue(vo.getGroups().size() <= 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_004_preview_normal_multiple_items() throws Exception {
|
||||||
|
// 5000 行大文件:响应预览行数量不随总行数增长
|
||||||
|
File workbook = buildWorkbook(5000);
|
||||||
|
SimilarAsinParseVo vo = parse(workbook, "uploads/20260829/large.xlsx");
|
||||||
|
assertEquals(5000, vo.getAcceptedRows());
|
||||||
|
assertEquals(100, vo.getItems().size());
|
||||||
|
assertTrue(vo.getGroups().size() <= 100);
|
||||||
|
// 预览行字段完整(asin/country/sku)
|
||||||
|
assertEquals("英国", vo.getItems().get(0).getCountry());
|
||||||
|
assertEquals("SKU-1", vo.getItems().get(0).getSku());
|
||||||
|
// 后 4900 行不进入响应体
|
||||||
|
boolean containsTail = vo.getItems().stream().anyMatch(item -> "B0TEST4900".equals(item.getAsin()));
|
||||||
|
assertFalse(containsTail, "响应不能包含末尾行");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_004_preview_normal_repeated_operation_is_idempotent() throws Exception {
|
||||||
|
// 同一文件重复解析:响应预览行一致,不产生重复状态
|
||||||
|
File workbook = buildWorkbook(200);
|
||||||
|
SimilarAsinParseVo first = parse(workbook, "uploads/20260829/idem.xlsx");
|
||||||
|
SimilarAsinParseVo second = parse(workbook, "uploads/20260829/idem.xlsx");
|
||||||
|
assertEquals(first.getAcceptedRows(), second.getAcceptedRows());
|
||||||
|
assertEquals(first.getItems().size(), second.getItems().size());
|
||||||
|
for (int i = 0; i < first.getItems().size(); i++) {
|
||||||
|
assertEquals(first.getItems().get(i).getAsin(), second.getItems().get(i).getAsin());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_004_preview_boundary_empty_input() throws Exception {
|
||||||
|
// 空文件(只有表头无数据行):抛项目约定异常,不创建任务
|
||||||
|
File workbook = buildWorkbook(0);
|
||||||
|
when(localFileStorageService.findLocalSourceFile("uploads/20260829/empty.xlsx")).thenReturn(workbook);
|
||||||
|
assertThrows(BusinessException.class, () -> parse(workbook, "uploads/20260829/empty.xlsx"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_004_preview_boundary_single_item() throws Exception {
|
||||||
|
// 单行文件:预览行 = 完整行,不依赖批量路径
|
||||||
|
File workbook = buildWorkbook(1);
|
||||||
|
SimilarAsinParseVo vo = parse(workbook, "uploads/20260829/single.xlsx");
|
||||||
|
assertEquals(1, vo.getAcceptedRows());
|
||||||
|
assertEquals(1, vo.getItems().size());
|
||||||
|
assertEquals("B0TEST0001", vo.getItems().get(0).getAsin());
|
||||||
|
assertEquals(1, vo.getGroupCount());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_004_preview_boundary_limit_and_overflow() throws Exception {
|
||||||
|
// 行数恰好等于预览上限(100):全部返回
|
||||||
|
File workbook = buildWorkbook(100);
|
||||||
|
SimilarAsinParseVo vo = parse(workbook, "uploads/20260829/exact.xlsx");
|
||||||
|
assertEquals(100, vo.getItems().size());
|
||||||
|
// 略超上限(101):仍裁剪到 100
|
||||||
|
File workbook101 = buildWorkbook(101);
|
||||||
|
SimilarAsinParseVo vo101 = parse(workbook101, "uploads/20260829/over.xlsx");
|
||||||
|
assertEquals(100, vo101.getItems().size());
|
||||||
|
assertFalse(vo101.getItems().stream().anyMatch(item -> "B0TEST0101".equals(item.getAsin())));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_004_preview_invalid_input_rejected() throws Exception {
|
||||||
|
// 文件不存在:抛业务异常
|
||||||
|
when(localFileStorageService.findLocalSourceFile("uploads/20260829/missing.xlsx")).thenReturn(null);
|
||||||
|
assertThrows(BusinessException.class, () -> parse(null, "uploads/20260829/missing.xlsx"));
|
||||||
|
// 空文件列表:抛业务异常
|
||||||
|
SimilarAsinParseRequest noFiles = new SimilarAsinParseRequest();
|
||||||
|
noFiles.setUserId(7L);
|
||||||
|
noFiles.setFiles(List.of());
|
||||||
|
noFiles.setApiKey("sk");
|
||||||
|
assertThrows(BusinessException.class, () -> service.parseAndCreateTask(noFiles));
|
||||||
|
// 非法 user_id
|
||||||
|
SimilarAsinParseRequest badUser = request("uploads/20260829/preview.xlsx");
|
||||||
|
badUser.setUserId(null);
|
||||||
|
assertThrows(BusinessException.class, () -> service.parseAndCreateTask(badUser));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_004_preview_dependency_failure_releases_resources() throws Exception {
|
||||||
|
// payload 存储失败:抛业务异常,不返回部分结果;随后恢复存储 mock 验证可重试
|
||||||
|
File workbook = buildWorkbook(50);
|
||||||
|
when(localFileStorageService.findLocalSourceFile("uploads/20260829/storefail.xlsx")).thenReturn(workbook);
|
||||||
|
when(transientPayloadStorageService.storeParsedPayloadFast(
|
||||||
|
eq(SimilarAsinTaskService.MODULE_TYPE), any(), anyString(), anyString(), eq(false)))
|
||||||
|
.thenThrow(new IllegalStateException("rustfs down"))
|
||||||
|
.thenReturn("rustfs:task-parsed/similar-asin/10001/payload.json");
|
||||||
|
assertThrows(IllegalStateException.class, () -> parse(workbook, "uploads/20260829/storefail.xlsx"));
|
||||||
|
// 存储恢复后,同一文件解析可正常完成
|
||||||
|
SimilarAsinParseVo vo = parse(workbook, "uploads/20260829/recovered.xlsx");
|
||||||
|
assertEquals(50, vo.getAcceptedRows());
|
||||||
|
assertEquals(50, vo.getItems().size());
|
||||||
|
// 临时文件未残留:测试结束后文件仍可删除(此处用 try-with-resources 风格验证生命周期)
|
||||||
|
List<File> stale = new ArrayList<>();
|
||||||
|
File[] tmpFiles = new File(System.getProperty("java.io.tmpdir"))
|
||||||
|
.listFiles((dir, name) -> name.startsWith("similar-asin-parse-preview-"));
|
||||||
|
if (tmpFiles != null) {
|
||||||
|
for (File f : tmpFiles) {
|
||||||
|
if (f.exists()) {
|
||||||
|
stale.add(f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 测试用临时文件仅限本次测试创建的(外部残留不统计)
|
||||||
|
for (File f : stale) {
|
||||||
|
Files.deleteIfExists(f.toPath());
|
||||||
|
}
|
||||||
|
assertTrue(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
+223
@@ -0,0 +1,223 @@
|
|||||||
|
package com.nanri.aiimage.modules.similarasin.service;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.nanri.aiimage.modules.similarasin.model.dto.SimilarAsinParsedPayloadDto;
|
||||||
|
import com.nanri.aiimage.modules.similarasin.model.dto.SimilarAsinSourceFileDto;
|
||||||
|
import com.nanri.aiimage.modules.similarasin.model.vo.SimilarAsinParsedGroupVo;
|
||||||
|
import com.nanri.aiimage.modules.similarasin.model.vo.SimilarAsinParsedRowVo;
|
||||||
|
import com.nanri.aiimage.common.exception.BusinessException;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.mockito.Mockito;
|
||||||
|
import org.springframework.test.util.ReflectionTestUtils;
|
||||||
|
|
||||||
|
import java.lang.reflect.InvocationTargetException;
|
||||||
|
import java.lang.reflect.Method;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
import static org.mockito.Mockito.CALLS_REAL_METHODS;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Task 2:解析载荷改为单一规范行集合,消除 items/groups/allItems 重复数据结构。
|
||||||
|
* 写入侧只输出 items(唯一全量行来源);旧 JSON 的 allItems 键反序列化时吸收到 items,行不丢失。
|
||||||
|
*/
|
||||||
|
class SimilarAsinTaskServicePayloadNormalizationTest {
|
||||||
|
|
||||||
|
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||||
|
|
||||||
|
private SimilarAsinTaskService service;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
service = Mockito.mock(SimilarAsinTaskService.class, CALLS_REAL_METHODS);
|
||||||
|
ReflectionTestUtils.setField(service, "objectMapper", MAPPER);
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<SimilarAsinParsedRowVo> rows(int count) {
|
||||||
|
List<SimilarAsinParsedRowVo> result = new ArrayList<>();
|
||||||
|
for (int i = 1; i <= count; i++) {
|
||||||
|
SimilarAsinParsedRowVo row = new SimilarAsinParsedRowVo();
|
||||||
|
row.setSourceFileKey("uploads/20260829/base.xlsx");
|
||||||
|
row.setSourceFilename("base.xlsx");
|
||||||
|
row.setRowIndex(i);
|
||||||
|
row.setSourceId(String.valueOf(i));
|
||||||
|
row.setDisplayId(String.valueOf(i));
|
||||||
|
row.setRowToken("uploads/20260829/base.xlsx::row::" + i);
|
||||||
|
row.setAsin("B0CJ8SNXXV");
|
||||||
|
row.setCountry("英国");
|
||||||
|
row.setPrice("12.29");
|
||||||
|
Map<String, String> values = new LinkedHashMap<>();
|
||||||
|
values.put("id", String.valueOf(i));
|
||||||
|
values.put("asin", "B0CJ8SNXXV");
|
||||||
|
values.put("国家", "英国");
|
||||||
|
values.put("价格", "12.29");
|
||||||
|
row.setValues(values);
|
||||||
|
result.add(row);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<SimilarAsinParsedGroupVo> groups(List<SimilarAsinParsedRowVo> rows) {
|
||||||
|
SimilarAsinParsedGroupVo group = new SimilarAsinParsedGroupVo();
|
||||||
|
group.setSourceFileKey("uploads/20260829/base.xlsx");
|
||||||
|
group.setSourceFilename("base.xlsx");
|
||||||
|
group.setGroupKey("uploads/20260829/base.xlsx::1@1");
|
||||||
|
group.setBaseId("1");
|
||||||
|
group.setDisplayId("1");
|
||||||
|
group.setItemCount(rows.size());
|
||||||
|
group.setItems(new ArrayList<>(rows));
|
||||||
|
return List.of(group);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String buildPayloadJson(List<SimilarAsinParsedRowVo> rows, List<SimilarAsinParsedGroupVo> groups) throws Exception {
|
||||||
|
Method method = SimilarAsinTaskService.class.getDeclaredMethod(
|
||||||
|
"buildParsedPayloadJson",
|
||||||
|
String.class, String.class, Boolean.class, Boolean.class,
|
||||||
|
List.class, List.class, List.class, List.class);
|
||||||
|
method.setAccessible(true);
|
||||||
|
return (String) method.invoke(service,
|
||||||
|
"请排查侵权风险", "sk-123", Boolean.TRUE, Boolean.FALSE,
|
||||||
|
List.of(sourceFile()), List.of("id", "asin"), groups, rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
private SimilarAsinSourceFileDto sourceFile() {
|
||||||
|
SimilarAsinSourceFileDto sourceFile = new SimilarAsinSourceFileDto();
|
||||||
|
sourceFile.setFileKey("uploads/20260829/base.xlsx");
|
||||||
|
sourceFile.setOriginalFilename("base.xlsx");
|
||||||
|
return sourceFile;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_002_parsed_payload_normal_default_path() throws Exception {
|
||||||
|
List<SimilarAsinParsedRowVo> rows = rows(100);
|
||||||
|
String json = buildPayloadJson(rows, groups(rows));
|
||||||
|
JsonNode node = MAPPER.readTree(json);
|
||||||
|
// 规范行集合只输出 items,不输出 allItems 重复结构
|
||||||
|
assertTrue(node.has("items"));
|
||||||
|
assertFalse(node.has("allItems"), "payload 必须不再序列化 allItems 重复结构");
|
||||||
|
assertEquals(100, node.get("items").size());
|
||||||
|
// groups 仍保留(Python 回传需要),但不作为全量行来源
|
||||||
|
assertTrue(node.has("groups"));
|
||||||
|
// items 中每行字段完整
|
||||||
|
JsonNode first = node.get("items").get(0);
|
||||||
|
assertEquals("uploads/20260829/base.xlsx::row::1", first.get("rowToken").asText());
|
||||||
|
assertEquals("B0CJ8SNXXV", first.get("asin").asText());
|
||||||
|
assertEquals("uploads/20260829/base.xlsx", first.get("sourceFileKey").asText());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_002_parsed_payload_normal_multiple_items() throws Exception {
|
||||||
|
List<SimilarAsinParsedRowVo> rows = rows(1000);
|
||||||
|
String json = buildPayloadJson(rows, groups(rows));
|
||||||
|
JsonNode node = MAPPER.readTree(json);
|
||||||
|
assertEquals(1000, node.get("items").size());
|
||||||
|
// 顺序稳定:rowToken 依次递增
|
||||||
|
for (int i = 0; i < 5; i++) {
|
||||||
|
assertEquals("uploads/20260829/base.xlsx::row::" + (i + 1),
|
||||||
|
node.get("items").get(i).get("rowToken").asText());
|
||||||
|
}
|
||||||
|
// 反序列化后行数不丢失
|
||||||
|
SimilarAsinParsedPayloadDto payload = MAPPER.readValue(json, SimilarAsinParsedPayloadDto.class);
|
||||||
|
assertEquals(1000, payload.getItems().size());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_002_parsed_payload_normal_repeated_operation_is_idempotent() throws Exception {
|
||||||
|
List<SimilarAsinParsedRowVo> rows = rows(200);
|
||||||
|
String first = buildPayloadJson(rows, groups(rows));
|
||||||
|
String second = buildPayloadJson(rows, groups(rows));
|
||||||
|
// 重复构建输出一致
|
||||||
|
assertEquals(MAPPER.readTree(first), MAPPER.readTree(second));
|
||||||
|
// 不产生重复记录:行 token 唯一
|
||||||
|
JsonNode items = MAPPER.readTree(first).get("items");
|
||||||
|
long distinct = java.util.stream.StreamSupport.stream(items.spliterator(), false)
|
||||||
|
.map(item -> item.get("rowToken").asText()).distinct().count();
|
||||||
|
assertEquals(200, distinct);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_002_parsed_payload_boundary_empty_input() throws Exception {
|
||||||
|
String json = buildPayloadJson(List.of(), List.of());
|
||||||
|
JsonNode node = MAPPER.readTree(json);
|
||||||
|
assertTrue(node.has("items"));
|
||||||
|
assertEquals(0, node.get("items").size());
|
||||||
|
assertFalse(node.has("allItems"));
|
||||||
|
// 空载荷反序列化安全
|
||||||
|
SimilarAsinParsedPayloadDto payload = MAPPER.readValue(json, SimilarAsinParsedPayloadDto.class);
|
||||||
|
assertNotNull(payload.getItems());
|
||||||
|
assertEquals(0, payload.getItems().size());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_002_parsed_payload_boundary_single_item() throws Exception {
|
||||||
|
List<SimilarAsinParsedRowVo> rows = rows(1);
|
||||||
|
String json = buildPayloadJson(rows, groups(rows));
|
||||||
|
JsonNode node = MAPPER.readTree(json);
|
||||||
|
assertEquals(1, node.get("items").size());
|
||||||
|
SimilarAsinParsedPayloadDto payload = MAPPER.readValue(json, SimilarAsinParsedPayloadDto.class);
|
||||||
|
assertEquals(1, payload.getItems().size());
|
||||||
|
assertEquals("uploads/20260829/base.xlsx::row::1", payload.getItems().get(0).getRowToken());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_002_parsed_payload_boundary_limit_and_overflow() throws Exception {
|
||||||
|
// null 行集合:items 输出为空数组而非 NPE/崩溃
|
||||||
|
String json = buildPayloadJson(null, null);
|
||||||
|
JsonNode node = MAPPER.readTree(json);
|
||||||
|
assertTrue(node.has("items"));
|
||||||
|
assertEquals(0, node.get("items").size());
|
||||||
|
// 大行数(5000)不触发无界增长,序列化正常
|
||||||
|
List<SimilarAsinParsedRowVo> rows = rows(5000);
|
||||||
|
JsonNode big = MAPPER.readTree(buildPayloadJson(rows, groups(rows)));
|
||||||
|
assertEquals(5000, big.get("items").size());
|
||||||
|
assertFalse(big.has("allItems"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_002_parsed_payload_invalid_input_rejected() throws Exception {
|
||||||
|
// 旧格式 JSON(含 allItems)反序列化:allItems 键被吸收进 items,不丢失行,不抛异常
|
||||||
|
String legacyJson = "{\"aiPrompt\":\"p\",\"apiKey\":\"k\",\"imgSwitch\":false,\"categorySwitch\":false,"
|
||||||
|
+ "\"sourceFiles\":[],\"headers\":[],"
|
||||||
|
+ "\"items\":[{\"rowToken\":\"t1\",\"asin\":\"B0CJ8SNXXV\"}],"
|
||||||
|
+ "\"allItems\":[{\"rowToken\":\"t1\",\"asin\":\"B0CJ8SNXXV\"},{\"rowToken\":\"t2\",\"asin\":\"B0TEST1234\"}],"
|
||||||
|
+ "\"groups\":[]}";
|
||||||
|
SimilarAsinParsedPayloadDto payload = MAPPER.readValue(legacyJson, SimilarAsinParsedPayloadDto.class);
|
||||||
|
// items 优先;allItems 仅在 items 为空时兜底吸收,避免旧数据行丢失
|
||||||
|
assertEquals(1, payload.getItems().size());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_002_parsed_payload_dependency_failure_releases_resources() throws Exception {
|
||||||
|
// 序列化器故障:抛项目约定异常(BusinessException),不产生部分结果
|
||||||
|
ObjectMapper broken = new ObjectMapper() {
|
||||||
|
@Override
|
||||||
|
public String writeValueAsString(Object value) {
|
||||||
|
throw new IllegalStateException("serializer down");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
SimilarAsinTaskService failingService = Mockito.mock(SimilarAsinTaskService.class, CALLS_REAL_METHODS);
|
||||||
|
ReflectionTestUtils.setField(failingService, "objectMapper", broken);
|
||||||
|
Method method = SimilarAsinTaskService.class.getDeclaredMethod(
|
||||||
|
"buildParsedPayloadJson",
|
||||||
|
String.class, String.class, Boolean.class, Boolean.class,
|
||||||
|
List.class, List.class, List.class, List.class);
|
||||||
|
method.setAccessible(true);
|
||||||
|
List<SimilarAsinParsedRowVo> rows = rows(100);
|
||||||
|
// 反射包装:解包 InvocationTargetException 断言 cause 为 BusinessException
|
||||||
|
InvocationTargetException thrown = assertThrows(InvocationTargetException.class, () -> method.invoke(failingService,
|
||||||
|
"p", "k", Boolean.FALSE, Boolean.FALSE, List.of(), List.of(), groups(rows), rows));
|
||||||
|
assertTrue(thrown.getCause() instanceof BusinessException);
|
||||||
|
// 恢复后(换回正常 mapper)仍能正常工作
|
||||||
|
String json = buildPayloadJson(rows, groups(rows));
|
||||||
|
assertEquals(100, MAPPER.readTree(json).get("items").size());
|
||||||
|
}
|
||||||
|
}
|
||||||
+255
@@ -0,0 +1,255 @@
|
|||||||
|
package com.nanri.aiimage.modules.similarasin.service;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.nanri.aiimage.common.exception.BusinessException;
|
||||||
|
import com.nanri.aiimage.config.SimilarAsinProperties;
|
||||||
|
import com.nanri.aiimage.modules.similarasin.client.SimilarAsinCozeClient;
|
||||||
|
import com.nanri.aiimage.modules.similarasin.mapper.SimilarAsinFilterConditionMapper;
|
||||||
|
import com.nanri.aiimage.modules.similarasin.model.dto.SimilarAsinParseRequest;
|
||||||
|
import com.nanri.aiimage.modules.similarasin.model.dto.SimilarAsinSourceFileDto;
|
||||||
|
import com.nanri.aiimage.modules.similarasin.model.vo.SimilarAsinParseVo;
|
||||||
|
import com.nanri.aiimage.modules.similarasin.util.SimilarAsinImageEmbedder;
|
||||||
|
import com.nanri.aiimage.modules.file.service.LocalFileStorageService;
|
||||||
|
import com.nanri.aiimage.modules.task.mapper.FileResultMapper;
|
||||||
|
import com.nanri.aiimage.modules.task.mapper.FileTaskMapper;
|
||||||
|
import com.nanri.aiimage.modules.task.mapper.TaskChunkMapper;
|
||||||
|
import com.nanri.aiimage.modules.task.mapper.TaskScopeStateMapper;
|
||||||
|
import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity;
|
||||||
|
import com.nanri.aiimage.modules.task.service.TransientPayloadStorageService;
|
||||||
|
import org.apache.poi.ss.usermodel.Row;
|
||||||
|
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
|
||||||
|
import org.junit.jupiter.api.AfterEach;
|
||||||
|
import org.junit.jupiter.api.BeforeAll;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.Spy;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
import org.mockito.InjectMocks;
|
||||||
|
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||||
|
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
||||||
|
import org.apache.ibatis.builder.MapperBuilderAssistant;
|
||||||
|
import org.springframework.test.util.ReflectionTestUtils;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
import java.io.FileOutputStream;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.concurrent.atomic.AtomicLong;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.Mockito.doAnswer;
|
||||||
|
import static org.mockito.Mockito.lenient;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Task 5:预览行数量增加配置边界、空文件和超限输入校验。
|
||||||
|
* 预览上限从硬编码常量改为配置驱动,并对配置值做 clamp 边界保护。
|
||||||
|
*/
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class SimilarAsinTaskServicePreviewConfigTest {
|
||||||
|
|
||||||
|
private static final AtomicLong NEXT_ID = new AtomicLong(20000);
|
||||||
|
|
||||||
|
@Mock private LocalFileStorageService localFileStorageService;
|
||||||
|
@Mock private FileTaskMapper fileTaskMapper;
|
||||||
|
@Mock private FileResultMapper fileResultMapper;
|
||||||
|
@Mock private TaskScopeStateMapper taskScopeStateMapper;
|
||||||
|
@Mock private TaskChunkMapper taskChunkMapper;
|
||||||
|
@Mock private SimilarAsinFilterConditionMapper filterConditionMapper;
|
||||||
|
@Spy private ObjectMapper objectMapper = new ObjectMapper();
|
||||||
|
@Mock private SimilarAsinCozeClient cozeClient;
|
||||||
|
@Mock private SimilarAsinTaskCacheService taskCacheService;
|
||||||
|
@Mock private SimilarAsinProperties properties;
|
||||||
|
@Mock private TransientPayloadStorageService transientPayloadStorageService;
|
||||||
|
@Mock private SimilarAsinImageEmbedder imageEmbedder;
|
||||||
|
|
||||||
|
@InjectMocks private SimilarAsinTaskService service;
|
||||||
|
|
||||||
|
@BeforeAll
|
||||||
|
static void initializeMybatisMetadata() {
|
||||||
|
MapperBuilderAssistant assistant = new MapperBuilderAssistant(new MybatisConfiguration(), "");
|
||||||
|
TableInfoHelper.initTableInfo(assistant, FileTaskEntity.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
lenient().when(properties.isBoundedResultAssemblyEnabled()).thenReturn(true);
|
||||||
|
lenient().when(properties.getImageCacheMaxBytes()).thenReturn(256L * 1024L * 1024L);
|
||||||
|
lenient().when(properties.getResultFileTimeoutMinutes()).thenReturn(90);
|
||||||
|
lenient().when(transientPayloadStorageService.storeParsedPayloadFast(
|
||||||
|
eq(SimilarAsinTaskService.MODULE_TYPE), any(), anyString(), anyString(), eq(false)))
|
||||||
|
.thenReturn("rustfs:task-parsed/similar-asin/20000/payload.json");
|
||||||
|
lenient().doAnswer(invocation -> {
|
||||||
|
FileTaskEntity task = invocation.getArgument(0);
|
||||||
|
task.setId(NEXT_ID.incrementAndGet());
|
||||||
|
return 1;
|
||||||
|
}).when(fileTaskMapper).insert(any(FileTaskEntity.class));
|
||||||
|
lenient().when(fileResultMapper.insert(any(com.nanri.aiimage.modules.task.model.entity.FileResultEntity.class))).thenReturn(1);
|
||||||
|
lenient().when(taskScopeStateMapper.insert(any(com.nanri.aiimage.modules.task.model.entity.TaskScopeStateEntity.class))).thenReturn(1);
|
||||||
|
lenient().when(taskChunkMapper.selectList(any())).thenReturn(List.of());
|
||||||
|
lenient().when(fileResultMapper.selectList(any())).thenReturn(List.of());
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
void shutdown() {
|
||||||
|
service.shutdownAssembleExecutor();
|
||||||
|
}
|
||||||
|
|
||||||
|
private File buildWorkbook(int rowCount) throws Exception {
|
||||||
|
File file = Files.createTempFile("similar-asin-preview-config-", ".xlsx").toFile();
|
||||||
|
try (XSSFWorkbook workbook = new XSSFWorkbook(); FileOutputStream fos = new FileOutputStream(file)) {
|
||||||
|
var sheet = workbook.createSheet("Sheet1");
|
||||||
|
Row header = sheet.createRow(0);
|
||||||
|
header.createCell(0).setCellValue("id");
|
||||||
|
header.createCell(1).setCellValue("asin");
|
||||||
|
header.createCell(2).setCellValue("国家");
|
||||||
|
for (int i = 1; i <= rowCount; i++) {
|
||||||
|
Row row = sheet.createRow(i);
|
||||||
|
row.createCell(0).setCellValue(String.valueOf(i));
|
||||||
|
row.createCell(1).setCellValue(String.format("B0CFG%05d", i));
|
||||||
|
row.createCell(2).setCellValue("英国");
|
||||||
|
}
|
||||||
|
workbook.write(fos);
|
||||||
|
}
|
||||||
|
return file;
|
||||||
|
}
|
||||||
|
|
||||||
|
private SimilarAsinParseRequest request(String fileKey) {
|
||||||
|
SimilarAsinParseRequest request = new SimilarAsinParseRequest();
|
||||||
|
request.setUserId(7L);
|
||||||
|
SimilarAsinSourceFileDto sourceFile = new SimilarAsinSourceFileDto();
|
||||||
|
sourceFile.setFileKey(fileKey);
|
||||||
|
sourceFile.setOriginalFilename("config.xlsx");
|
||||||
|
request.setFiles(List.of(sourceFile));
|
||||||
|
request.setApiKey("sk-123");
|
||||||
|
request.setImgSwitch(Boolean.FALSE);
|
||||||
|
request.setCategorySwitch(Boolean.FALSE);
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
|
||||||
|
private SimilarAsinParseVo parse(File workbook, String fileKey) {
|
||||||
|
when(localFileStorageService.findLocalSourceFile(fileKey)).thenReturn(workbook);
|
||||||
|
return service.parseAndCreateTask(request(fileKey));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_005_preview_row_count_normal_default_path() throws Exception {
|
||||||
|
// 默认配置 100:150 行文件返回 100 预览行
|
||||||
|
when(properties.getParseResponsePreviewLimit()).thenReturn(100);
|
||||||
|
File workbook = buildWorkbook(150);
|
||||||
|
SimilarAsinParseVo vo = parse(workbook, "uploads/20260829/cfg-default.xlsx");
|
||||||
|
assertEquals(100, vo.getItems().size());
|
||||||
|
assertEquals(150, vo.getAcceptedRows());
|
||||||
|
// 预览行从第 1 行开始
|
||||||
|
assertEquals("B0CFG00001", vo.getItems().get(0).getAsin());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_005_preview_row_count_normal_multiple_items() throws Exception {
|
||||||
|
// 配置 50:500 行文件返回 50 预览行
|
||||||
|
when(properties.getParseResponsePreviewLimit()).thenReturn(50);
|
||||||
|
File workbook = buildWorkbook(500);
|
||||||
|
SimilarAsinParseVo vo = parse(workbook, "uploads/20260829/cfg-50.xlsx");
|
||||||
|
assertEquals(50, vo.getItems().size());
|
||||||
|
assertEquals(500, vo.getAcceptedRows());
|
||||||
|
// groups 同样按配置裁剪
|
||||||
|
assertTrue(vo.getGroups().size() <= 50);
|
||||||
|
// 配置 200:300 行文件返回 200 预览行
|
||||||
|
when(properties.getParseResponsePreviewLimit()).thenReturn(200);
|
||||||
|
SimilarAsinParseVo vo2 = parse(buildWorkbook(300), "uploads/20260829/cfg-200.xlsx");
|
||||||
|
assertEquals(200, vo2.getItems().size());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_005_preview_row_count_normal_repeated_operation_is_idempotent() throws Exception {
|
||||||
|
when(properties.getParseResponsePreviewLimit()).thenReturn(100);
|
||||||
|
File workbook = buildWorkbook(250);
|
||||||
|
SimilarAsinParseVo first = parse(workbook, "uploads/20260829/cfg-idem.xlsx");
|
||||||
|
SimilarAsinParseVo second = parse(workbook, "uploads/20260829/cfg-idem.xlsx");
|
||||||
|
assertEquals(first.getItems().size(), second.getItems().size());
|
||||||
|
for (int i = 0; i < first.getItems().size(); i++) {
|
||||||
|
assertEquals(first.getItems().get(i).getAsin(), second.getItems().get(i).getAsin());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_005_preview_row_count_boundary_empty_input() throws Exception {
|
||||||
|
// 空文件(无有效数据行):抛业务异常
|
||||||
|
lenient().when(properties.getParseResponsePreviewLimit()).thenReturn(100);
|
||||||
|
File workbook = buildWorkbook(0);
|
||||||
|
when(localFileStorageService.findLocalSourceFile("uploads/20260829/cfg-empty.xlsx")).thenReturn(workbook);
|
||||||
|
assertThrows(BusinessException.class, () -> parse(workbook, "uploads/20260829/cfg-empty.xlsx"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_005_preview_row_count_boundary_single_item() throws Exception {
|
||||||
|
when(properties.getParseResponsePreviewLimit()).thenReturn(100);
|
||||||
|
File workbook = buildWorkbook(1);
|
||||||
|
SimilarAsinParseVo vo = parse(workbook, "uploads/20260829/cfg-single.xlsx");
|
||||||
|
assertEquals(1, vo.getItems().size());
|
||||||
|
assertEquals("B0CFG00001", vo.getItems().get(0).getAsin());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_005_preview_row_count_boundary_limit_and_overflow() throws Exception {
|
||||||
|
// 配置值超过最大上限(1000):clamp 到上限,不发生无界内存增长
|
||||||
|
when(properties.getParseResponsePreviewLimit()).thenReturn(50000);
|
||||||
|
File workbook = buildWorkbook(2000);
|
||||||
|
SimilarAsinParseVo vo = parse(workbook, "uploads/20260829/cfg-over.xlsx");
|
||||||
|
assertEquals(1000, vo.getItems().size(), "超限配置必须 clamp 到最大允许值");
|
||||||
|
assertEquals(2000, vo.getAcceptedRows());
|
||||||
|
// 配置值恰好等于上限:返回 1000 预览行
|
||||||
|
when(properties.getParseResponsePreviewLimit()).thenReturn(1000);
|
||||||
|
SimilarAsinParseVo vo2 = parse(buildWorkbook(1000), "uploads/20260829/cfg-exact.xlsx");
|
||||||
|
assertEquals(1000, vo2.getItems().size());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_005_preview_row_count_invalid_input_rejected() throws Exception {
|
||||||
|
// 配置为 0/负数:回退到默认值 100,不抛异常不崩溃
|
||||||
|
when(properties.getParseResponsePreviewLimit()).thenReturn(0, -5);
|
||||||
|
File workbook = buildWorkbook(300);
|
||||||
|
SimilarAsinParseVo vo = parse(workbook, "uploads/20260829/cfg-zero.xlsx");
|
||||||
|
assertEquals(100, vo.getItems().size());
|
||||||
|
SimilarAsinParseVo vo2 = parse(buildWorkbook(300), "uploads/20260829/cfg-neg.xlsx");
|
||||||
|
assertEquals(100, vo2.getItems().size());
|
||||||
|
// 配置缺失(fresh mock 未 stub,int 默认 0):同样回退默认
|
||||||
|
SimilarAsinProperties missing = org.mockito.Mockito.mock(SimilarAsinProperties.class);
|
||||||
|
lenient().when(missing.isBoundedResultAssemblyEnabled()).thenReturn(true);
|
||||||
|
lenient().when(missing.getImageCacheMaxBytes()).thenReturn(256L * 1024L * 1024L);
|
||||||
|
lenient().when(missing.getResultFileTimeoutMinutes()).thenReturn(90);
|
||||||
|
ReflectionTestUtils.setField(service, "properties", missing);
|
||||||
|
SimilarAsinParseVo vo3 = parse(buildWorkbook(300), "uploads/20260829/cfg-missing.xlsx");
|
||||||
|
assertEquals(100, vo3.getItems().size());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_005_preview_row_count_dependency_failure_releases_resources() throws Exception {
|
||||||
|
// 存储失败抛异常;恢复后解析正常
|
||||||
|
when(properties.getParseResponsePreviewLimit()).thenReturn(100);
|
||||||
|
File workbook = buildWorkbook(120);
|
||||||
|
when(localFileStorageService.findLocalSourceFile("uploads/20260829/cfg-fail.xlsx")).thenReturn(workbook);
|
||||||
|
when(transientPayloadStorageService.storeParsedPayloadFast(
|
||||||
|
eq(SimilarAsinTaskService.MODULE_TYPE), any(), anyString(), anyString(), eq(false)))
|
||||||
|
.thenThrow(new IllegalStateException("rustfs down"))
|
||||||
|
.thenReturn("rustfs:task-parsed/similar-asin/20001/payload.json");
|
||||||
|
assertThrows(IllegalStateException.class, () -> parse(workbook, "uploads/20260829/cfg-fail.xlsx"));
|
||||||
|
SimilarAsinParseVo vo = parse(workbook, "uploads/20260829/cfg-recovered.xlsx");
|
||||||
|
assertEquals(120, vo.getAcceptedRows());
|
||||||
|
assertEquals(100, vo.getItems().size());
|
||||||
|
// 配置对象默认值校验:新实例默认 100,处于 [1, 1000] 边界内
|
||||||
|
SimilarAsinProperties defaults = new SimilarAsinProperties();
|
||||||
|
assertNotNull(defaults.getParseResponsePreviewLimit());
|
||||||
|
int previewLimit = defaults.getParseResponsePreviewLimit();
|
||||||
|
assertTrue(previewLimit >= 1 && previewLimit <= 1000,
|
||||||
|
"默认预览上限必须在 [1, 1000] 内,实际 " + previewLimit);
|
||||||
|
}
|
||||||
|
}
|
||||||
+210
@@ -0,0 +1,210 @@
|
|||||||
|
package com.nanri.aiimage.modules.similarasin.service;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.nanri.aiimage.modules.similarasin.model.dto.SimilarAsinParsedPayloadDto;
|
||||||
|
import com.nanri.aiimage.modules.similarasin.model.vo.SimilarAsinParsedGroupVo;
|
||||||
|
import com.nanri.aiimage.modules.similarasin.model.vo.SimilarAsinParsedRowVo;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Task 3:保留旧 payload 读取兼容逻辑,验证新旧结构均可恢复全量行。
|
||||||
|
* resolveAllRows 统一"从 payload 恢复全量行":优先 items(新规范结构),
|
||||||
|
* 其次 allItems(旧结构),最后 groups 展开(最旧结构)。
|
||||||
|
*/
|
||||||
|
class SimilarAsinTaskServiceResolveAllRowsTest {
|
||||||
|
|
||||||
|
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||||
|
|
||||||
|
private static SimilarAsinParsedRowVo row(String fileKey, int index) {
|
||||||
|
SimilarAsinParsedRowVo row = new SimilarAsinParsedRowVo();
|
||||||
|
row.setSourceFileKey(fileKey);
|
||||||
|
row.setSourceFilename("base.xlsx");
|
||||||
|
row.setRowIndex(index);
|
||||||
|
row.setSourceId(String.valueOf(index));
|
||||||
|
row.setDisplayId(String.valueOf(index));
|
||||||
|
row.setRowToken(fileKey + "::row::" + index);
|
||||||
|
row.setAsin("B0CJ8SNXXV");
|
||||||
|
row.setCountry("英国");
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<SimilarAsinParsedRowVo> rows(String fileKey, int count) {
|
||||||
|
List<SimilarAsinParsedRowVo> result = new ArrayList<>();
|
||||||
|
for (int i = 1; i <= count; i++) {
|
||||||
|
result.add(row(fileKey, i));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SimilarAsinParsedGroupVo group(List<SimilarAsinParsedRowVo> items) {
|
||||||
|
SimilarAsinParsedGroupVo group = new SimilarAsinParsedGroupVo();
|
||||||
|
group.setSourceFileKey("uploads/20260829/base.xlsx");
|
||||||
|
group.setSourceFilename("base.xlsx");
|
||||||
|
group.setGroupKey("uploads/20260829/base.xlsx::1@1");
|
||||||
|
group.setBaseId("1");
|
||||||
|
group.setDisplayId("1");
|
||||||
|
group.setItemCount(items.size());
|
||||||
|
group.setItems(new ArrayList<>(items));
|
||||||
|
return group;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_003_payload_normal_default_path() {
|
||||||
|
// 新结构:items 有值 → 恢复全量行
|
||||||
|
SimilarAsinParsedPayloadDto payload = new SimilarAsinParsedPayloadDto();
|
||||||
|
List<SimilarAsinParsedRowVo> items = rows("uploads/20260829/base.xlsx", 100);
|
||||||
|
payload.setItems(items);
|
||||||
|
List<SimilarAsinParsedRowVo> restored = SimilarAsinTaskService.resolveAllRows(payload);
|
||||||
|
assertEquals(100, restored.size());
|
||||||
|
assertEquals("uploads/20260829/base.xlsx::row::1", restored.get(0).getRowToken());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_003_payload_normal_multiple_items() {
|
||||||
|
// 新结构 1000 行:不丢失且顺序稳定
|
||||||
|
SimilarAsinParsedPayloadDto payload = new SimilarAsinParsedPayloadDto();
|
||||||
|
List<SimilarAsinParsedRowVo> items = rows("uploads/20260829/multi.xlsx", 1000);
|
||||||
|
payload.setItems(items);
|
||||||
|
List<SimilarAsinParsedRowVo> restored = SimilarAsinTaskService.resolveAllRows(payload);
|
||||||
|
assertEquals(1000, restored.size());
|
||||||
|
for (int i = 0; i < restored.size(); i++) {
|
||||||
|
assertEquals(i + 1, restored.get(i).getRowIndex());
|
||||||
|
}
|
||||||
|
// 旧结构 1000 行:allItems 全量恢复
|
||||||
|
SimilarAsinParsedPayloadDto legacy = new SimilarAsinParsedPayloadDto();
|
||||||
|
legacy.setAllItems(items);
|
||||||
|
List<SimilarAsinParsedRowVo> restoredLegacy = SimilarAsinTaskService.resolveAllRows(legacy);
|
||||||
|
assertEquals(1000, restoredLegacy.size());
|
||||||
|
assertEquals("uploads/20260829/multi.xlsx::row::1", restoredLegacy.get(0).getRowToken());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_003_payload_normal_repeated_operation_is_idempotent() {
|
||||||
|
// 重复调用返回相同行集合(不修改 payload 本身)
|
||||||
|
SimilarAsinParsedPayloadDto payload = new SimilarAsinParsedPayloadDto();
|
||||||
|
List<SimilarAsinParsedRowVo> items = rows("uploads/20260829/idem.xlsx", 50);
|
||||||
|
payload.setItems(items);
|
||||||
|
List<SimilarAsinParsedRowVo> first = SimilarAsinTaskService.resolveAllRows(payload);
|
||||||
|
List<SimilarAsinParsedRowVo> second = SimilarAsinTaskService.resolveAllRows(payload);
|
||||||
|
assertEquals(first.size(), second.size());
|
||||||
|
for (int i = 0; i < first.size(); i++) {
|
||||||
|
assertEquals(first.get(i).getRowToken(), second.get(i).getRowToken());
|
||||||
|
}
|
||||||
|
// payload 未被修改:items 仍 50 行
|
||||||
|
assertEquals(50, payload.getItems().size());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_003_payload_boundary_empty_input() {
|
||||||
|
// 空 payload:返回空列表而非 null,不创建无效资源
|
||||||
|
SimilarAsinParsedPayloadDto empty = new SimilarAsinParsedPayloadDto();
|
||||||
|
List<SimilarAsinParsedRowVo> restored = SimilarAsinTaskService.resolveAllRows(empty);
|
||||||
|
assertNotNull(restored);
|
||||||
|
assertEquals(0, restored.size());
|
||||||
|
// items/allItems/groups 均为空的 payload
|
||||||
|
SimilarAsinParsedPayloadDto allEmpty = new SimilarAsinParsedPayloadDto();
|
||||||
|
allEmpty.setItems(List.of());
|
||||||
|
allEmpty.setAllItems(List.of());
|
||||||
|
allEmpty.setGroups(List.of());
|
||||||
|
assertEquals(0, SimilarAsinTaskService.resolveAllRows(allEmpty).size());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_003_payload_boundary_single_item() {
|
||||||
|
// 单行新结构
|
||||||
|
SimilarAsinParsedPayloadDto payload = new SimilarAsinParsedPayloadDto();
|
||||||
|
payload.setItems(rows("uploads/20260829/single.xlsx", 1));
|
||||||
|
assertEquals(1, SimilarAsinTaskService.resolveAllRows(payload).size());
|
||||||
|
// 单行旧结构(仅 groups)
|
||||||
|
SimilarAsinParsedPayloadDto legacy = new SimilarAsinParsedPayloadDto();
|
||||||
|
legacy.setGroups(List.of(group(rows("uploads/20260829/single.xlsx", 1))));
|
||||||
|
assertEquals(1, SimilarAsinTaskService.resolveAllRows(legacy).size());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_003_payload_boundary_limit_and_overflow() {
|
||||||
|
// 旧结构 allItems 5000 行全量恢复,不丢失
|
||||||
|
SimilarAsinParsedPayloadDto legacy = new SimilarAsinParsedPayloadDto();
|
||||||
|
legacy.setAllItems(rows("uploads/20260829/max.xlsx", 5000));
|
||||||
|
assertEquals(5000, SimilarAsinTaskService.resolveAllRows(legacy).size());
|
||||||
|
// items 与 allItems 同时存在:以 items 为准(新规范结构优先),不重复
|
||||||
|
SimilarAsinParsedPayloadDto both = new SimilarAsinParsedPayloadDto();
|
||||||
|
both.setItems(rows("uploads/20260829/both.xlsx", 10));
|
||||||
|
both.setAllItems(rows("uploads/20260829/both.xlsx", 20));
|
||||||
|
List<SimilarAsinParsedRowVo> restored = SimilarAsinTaskService.resolveAllRows(both);
|
||||||
|
assertEquals(10, restored.size());
|
||||||
|
// groups 也同时存在:仍以 items 为准
|
||||||
|
both.setGroups(List.of(group(rows("uploads/20260829/both.xlsx", 30))));
|
||||||
|
assertEquals(10, SimilarAsinTaskService.resolveAllRows(both).size());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_003_payload_invalid_input_rejected() {
|
||||||
|
// null payload:安全返回空列表(调用方容忍),不抛 NPE
|
||||||
|
assertEquals(0, SimilarAsinTaskService.resolveAllRows(null).size());
|
||||||
|
// groups 中含 null 元素:跳过不抛异常
|
||||||
|
SimilarAsinParsedPayloadDto messy = new SimilarAsinParsedPayloadDto();
|
||||||
|
List<SimilarAsinParsedGroupVo> groups = new ArrayList<>();
|
||||||
|
groups.add(null);
|
||||||
|
groups.add(group(rows("uploads/20260829/messy.xlsx", 5)));
|
||||||
|
messy.setGroups(groups);
|
||||||
|
assertEquals(5, SimilarAsinTaskService.resolveAllRows(messy).size());
|
||||||
|
// group.items 为 null:跳过该组
|
||||||
|
SimilarAsinParsedGroupVo nullItemsGroup = new SimilarAsinParsedGroupVo();
|
||||||
|
nullItemsGroup.setItems(null);
|
||||||
|
SimilarAsinParsedPayloadDto nullItems = new SimilarAsinParsedPayloadDto();
|
||||||
|
nullItems.setGroups(List.of(nullItemsGroup, group(rows("uploads/20260829/n2.xlsx", 3))));
|
||||||
|
assertEquals(3, SimilarAsinTaskService.resolveAllRows(nullItems).size());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_003_payload_dependency_failure_releases_resources() throws Exception {
|
||||||
|
// 旧格式 JSON(只有 allItems)反序列化 → hydrate 后 resolveAllRows 恢复全量行
|
||||||
|
String legacyJson = "{\"aiPrompt\":\"p\",\"apiKey\":\"k\",\"imgSwitch\":false,\"categorySwitch\":false,"
|
||||||
|
+ "\"sourceFiles\":[],\"headers\":[],\"groups\":[],"
|
||||||
|
+ "\"allItems\":[{\"rowToken\":\"t1\",\"asin\":\"B0AAA00001\",\"sourceFileKey\":\"uploads/20260829/a.xlsx\",\"rowIndex\":1},"
|
||||||
|
+ "{\"rowToken\":\"t2\",\"asin\":\"B0AAA00002\",\"sourceFileKey\":\"uploads/20260829/a.xlsx\",\"rowIndex\":2}]}";
|
||||||
|
SimilarAsinParsedPayloadDto payload = MAPPER.readValue(legacyJson, SimilarAsinParsedPayloadDto.class);
|
||||||
|
// 未 hydrate 时:allItems 恢复(items 为空走 allItems)
|
||||||
|
List<SimilarAsinParsedRowVo> fromLegacy = SimilarAsinTaskService.resolveAllRows(payload);
|
||||||
|
assertEquals(2, fromLegacy.size());
|
||||||
|
assertEquals("t1", fromLegacy.get(0).getRowToken());
|
||||||
|
assertEquals("B0AAA00002", fromLegacy.get(1).getAsin());
|
||||||
|
// 新格式 JSON(只有 items)反序列化 → 直接恢复
|
||||||
|
String newJson = "{\"aiPrompt\":\"p\",\"apiKey\":\"k\",\"imgSwitch\":false,\"categorySwitch\":false,"
|
||||||
|
+ "\"sourceFiles\":[],\"headers\":[],"
|
||||||
|
+ "\"items\":[{\"rowToken\":\"n1\",\"asin\":\"B0NEW00001\",\"sourceFileKey\":\"uploads/20260829/b.xlsx\",\"rowIndex\":1}],"
|
||||||
|
+ "\"groups\":[]}";
|
||||||
|
SimilarAsinParsedPayloadDto newPayload = MAPPER.readValue(newJson, SimilarAsinParsedPayloadDto.class);
|
||||||
|
List<SimilarAsinParsedRowVo> fromNew = SimilarAsinTaskService.resolveAllRows(newPayload);
|
||||||
|
assertEquals(1, fromNew.size());
|
||||||
|
assertEquals("n1", fromNew.get(0).getRowToken());
|
||||||
|
// 两种格式行数之和互不影响,恢复结果稳定
|
||||||
|
assertTrue(fromLegacy.size() == 2 && fromNew.size() == 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_003_payload_normal_groups_expansion_preserves_order() {
|
||||||
|
// 最旧结构:仅 groups 嵌套行,展开后顺序稳定(按组、组内原序)
|
||||||
|
SimilarAsinParsedPayloadDto legacy = new SimilarAsinParsedPayloadDto();
|
||||||
|
List<SimilarAsinParsedRowVo> g1 = rows("uploads/20260829/g.xlsx", 2);
|
||||||
|
List<SimilarAsinParsedRowVo> g2 = rows("uploads/20260829/g.xlsx", 3);
|
||||||
|
// 组内行号各自独立从 1 开始(真实解析语义),第二组用不同 fileKey 区分来源
|
||||||
|
List<SimilarAsinParsedRowVo> g2b = rows("uploads/20260829/g2.xlsx", 3);
|
||||||
|
legacy.setGroups(List.of(group(g1), group(g2b)));
|
||||||
|
List<SimilarAsinParsedRowVo> restored = SimilarAsinTaskService.resolveAllRows(legacy);
|
||||||
|
assertEquals(5, restored.size());
|
||||||
|
assertEquals("uploads/20260829/g.xlsx::row::1", restored.get(0).getRowToken());
|
||||||
|
assertEquals("uploads/20260829/g.xlsx::row::2", restored.get(1).getRowToken());
|
||||||
|
assertEquals("uploads/20260829/g2.xlsx::row::1", restored.get(2).getRowToken());
|
||||||
|
assertEquals("uploads/20260829/g2.xlsx::row::2", restored.get(3).getRowToken());
|
||||||
|
assertEquals("uploads/20260829/g2.xlsx::row::3", restored.get(4).getRowToken());
|
||||||
|
}
|
||||||
|
}
|
||||||
+324
@@ -0,0 +1,324 @@
|
|||||||
|
package com.nanri.aiimage.modules.similarasin.service;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.nanri.aiimage.config.SimilarAsinProperties;
|
||||||
|
import com.nanri.aiimage.modules.similarasin.client.SimilarAsinCozeClient;
|
||||||
|
import com.nanri.aiimage.modules.similarasin.mapper.SimilarAsinFilterConditionMapper;
|
||||||
|
import com.nanri.aiimage.modules.similarasin.model.dto.SimilarAsinResultRowDto;
|
||||||
|
import com.nanri.aiimage.modules.similarasin.util.SimilarAsinImageEmbedder;
|
||||||
|
import com.nanri.aiimage.modules.file.service.LocalFileStorageService;
|
||||||
|
import com.nanri.aiimage.modules.task.mapper.FileResultMapper;
|
||||||
|
import com.nanri.aiimage.modules.task.mapper.FileTaskMapper;
|
||||||
|
import com.nanri.aiimage.modules.task.mapper.TaskChunkMapper;
|
||||||
|
import com.nanri.aiimage.modules.task.mapper.TaskScopeStateMapper;
|
||||||
|
import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity;
|
||||||
|
import com.nanri.aiimage.modules.task.model.entity.TaskChunkEntity;
|
||||||
|
import com.nanri.aiimage.modules.task.service.TransientPayloadStorageService;
|
||||||
|
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||||
|
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
||||||
|
import org.apache.ibatis.builder.MapperBuilderAssistant;
|
||||||
|
import org.junit.jupiter.api.AfterEach;
|
||||||
|
import org.junit.jupiter.api.BeforeAll;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.InjectMocks;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.Spy;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
|
||||||
|
import java.lang.reflect.Method;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.atomic.AtomicLong;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.Mockito.doAnswer;
|
||||||
|
import static org.mockito.Mockito.lenient;
|
||||||
|
import static org.mockito.Mockito.times;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Task 11:Coze 结果合并的重复检测从 O(n²) 改为 HashSet/稳定 row key。
|
||||||
|
* dedupeRowsByRowKey 用 HashSet 按稳定 rowKey 一次性去重(保留顺序),
|
||||||
|
* mergeCozeRowsIntoChunk 合并前先去重,消除重复行逐行重复处理。
|
||||||
|
*/
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class SimilarAsinTaskServiceRowKeyDedupeTest {
|
||||||
|
|
||||||
|
private static final AtomicLong NEXT_ID = new AtomicLong(70000);
|
||||||
|
|
||||||
|
@Mock private LocalFileStorageService localFileStorageService;
|
||||||
|
@Mock private com.nanri.aiimage.modules.file.service.oss.OssStorageService ossStorageService;
|
||||||
|
@Mock private com.nanri.aiimage.config.StorageProperties storageProperties;
|
||||||
|
@Mock private FileTaskMapper fileTaskMapper;
|
||||||
|
@Mock private FileResultMapper fileResultMapper;
|
||||||
|
@Mock private TaskScopeStateMapper taskScopeStateMapper;
|
||||||
|
@Mock private TaskChunkMapper taskChunkMapper;
|
||||||
|
@Mock private SimilarAsinFilterConditionMapper filterConditionMapper;
|
||||||
|
@Spy private ObjectMapper objectMapper = new ObjectMapper();
|
||||||
|
@Mock private SimilarAsinCozeClient cozeClient;
|
||||||
|
@Mock private SimilarAsinTaskCacheService taskCacheService;
|
||||||
|
@Mock private SimilarAsinProperties properties;
|
||||||
|
@Mock private com.nanri.aiimage.modules.task.service.TaskFileJobService taskFileJobService;
|
||||||
|
@Mock private com.nanri.aiimage.modules.task.service.TaskDistributedLockService taskDistributedLockService;
|
||||||
|
@Mock private com.nanri.aiimage.modules.task.service.TaskProgressSnapshotService taskProgressSnapshotService;
|
||||||
|
@Mock private TransientPayloadStorageService transientPayloadStorageService;
|
||||||
|
@Mock private org.springframework.transaction.PlatformTransactionManager transactionManager;
|
||||||
|
@Mock private com.nanri.aiimage.common.service.DistributedJobLockService distributedJobLockService;
|
||||||
|
@Mock private com.nanri.aiimage.config.InstanceMetadata instanceMetadata;
|
||||||
|
@Mock private com.nanri.aiimage.modules.coze.service.CozeCredentialPoolService cozeCredentialPoolService;
|
||||||
|
@Mock private SimilarAsinImageEmbedder imageEmbedder;
|
||||||
|
@Mock private SimilarAsinImagePrefetchService imagePrefetchService;
|
||||||
|
|
||||||
|
@InjectMocks private SimilarAsinTaskService service;
|
||||||
|
|
||||||
|
@BeforeAll
|
||||||
|
static void initializeMybatisMetadata() {
|
||||||
|
MapperBuilderAssistant assistant = new MapperBuilderAssistant(new MybatisConfiguration(), "");
|
||||||
|
TableInfoHelper.initTableInfo(assistant, FileTaskEntity.class);
|
||||||
|
TableInfoHelper.initTableInfo(assistant, TaskChunkEntity.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
lenient().when(properties.isBoundedResultAssemblyEnabled()).thenReturn(true);
|
||||||
|
lenient().when(properties.getImageCacheMaxBytes()).thenReturn(256L * 1024L * 1024L);
|
||||||
|
lenient().when(properties.getResultFileTimeoutMinutes()).thenReturn(90);
|
||||||
|
lenient().when(properties.getParseResponsePreviewLimit()).thenReturn(100);
|
||||||
|
lenient().when(properties.getMaxSourceFileBytes()).thenReturn(50L * 1024L * 1024L);
|
||||||
|
lenient().when(properties.getMaxParseRows()).thenReturn(50000);
|
||||||
|
lenient().when(properties.getMaxFieldLength()).thenReturn(2000);
|
||||||
|
lenient().when(properties.getMaxWorkbookZipEntries()).thenReturn(20000);
|
||||||
|
lenient().when(properties.getMaxWorkbookUncompressedBytes()).thenReturn(512L * 1024L * 1024L);
|
||||||
|
lenient().when(transientPayloadStorageService.storeParsedPayloadFast(
|
||||||
|
eq(SimilarAsinTaskService.MODULE_TYPE), any(), anyString(), anyString(), eq(false)))
|
||||||
|
.thenReturn("rustfs:task-parsed/similar-asin/70000/payload.json");
|
||||||
|
lenient().doAnswer(invocation -> {
|
||||||
|
FileTaskEntity task = invocation.getArgument(0);
|
||||||
|
task.setId(NEXT_ID.incrementAndGet());
|
||||||
|
return 1;
|
||||||
|
}).when(fileTaskMapper).insert(any(FileTaskEntity.class));
|
||||||
|
lenient().when(fileResultMapper.insert(any(com.nanri.aiimage.modules.task.model.entity.FileResultEntity.class))).thenReturn(1);
|
||||||
|
lenient().when(taskScopeStateMapper.insert(any(com.nanri.aiimage.modules.task.model.entity.TaskScopeStateEntity.class))).thenReturn(1);
|
||||||
|
lenient().when(taskChunkMapper.selectList(any())).thenReturn(List.of());
|
||||||
|
lenient().when(fileResultMapper.selectList(any())).thenReturn(List.of());
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
void shutdown() {
|
||||||
|
service.shutdownAssembleExecutor();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SimilarAsinResultRowDto row(String rowToken, String id, String asin, String country) {
|
||||||
|
SimilarAsinResultRowDto r = new SimilarAsinResultRowDto();
|
||||||
|
r.setRowToken(rowToken);
|
||||||
|
r.setId(id);
|
||||||
|
r.setAsin(asin);
|
||||||
|
r.setCountry(country);
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String rowsJson(List<SimilarAsinResultRowDto> rows) throws Exception {
|
||||||
|
return new ObjectMapper().writeValueAsString(rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TaskChunkEntity chunk(Long id, String scopeHash, Integer chunkIndex, String payloadJson) {
|
||||||
|
TaskChunkEntity chunk = new TaskChunkEntity();
|
||||||
|
chunk.setId(id);
|
||||||
|
chunk.setTaskId(7004L);
|
||||||
|
chunk.setModuleType(SimilarAsinTaskService.MODULE_TYPE);
|
||||||
|
chunk.setScopeHash(scopeHash);
|
||||||
|
chunk.setChunkIndex(chunkIndex);
|
||||||
|
chunk.setPayloadJson(payloadJson);
|
||||||
|
return chunk;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void stubSingleChunkMerge(TaskChunkEntity chunk, String payloadJson, AtomicLong storedCounter) throws Exception {
|
||||||
|
when(taskChunkMapper.selectList(any())).thenReturn(List.of(chunk));
|
||||||
|
when(transientPayloadStorageService.resolvePayload(eq(chunk.getPayloadJson()), anyString()))
|
||||||
|
.thenReturn(payloadJson);
|
||||||
|
when(transientPayloadStorageService.storeChunkPayloadVersioned(anyString(), any(), anyString(), any(), anyString()))
|
||||||
|
.thenAnswer(invocation -> {
|
||||||
|
storedCounter.incrementAndGet();
|
||||||
|
return "stored:" + invocation.getArgument(2);
|
||||||
|
});
|
||||||
|
when(taskChunkMapper.selectOne(any())).thenReturn(chunk);
|
||||||
|
when(taskChunkMapper.update(any(), any())).thenReturn(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_011_merge_row_key_normal_default_path() throws Exception {
|
||||||
|
// 正常输入:cozeRows 含同一 rowKey 的重复行,merge 前按稳定 rowKey 去重,
|
||||||
|
// chunk payload 只写一次,结果行不重复。
|
||||||
|
TaskChunkEntity chunk = chunk(1L, "hashA", 1, "ptr:chunk-A");
|
||||||
|
AtomicLong storedCounter = new AtomicLong(0);
|
||||||
|
stubSingleChunkMerge(chunk, rowsJson(List.of(row("r1", "1", "B0A0000001", "英国"))), storedCounter);
|
||||||
|
|
||||||
|
FileTaskEntity task = new FileTaskEntity();
|
||||||
|
task.setId(7004L);
|
||||||
|
List<SimilarAsinResultRowDto> cozeRows = List.of(
|
||||||
|
row("r1", "1", "B0A0000001", "英国"),
|
||||||
|
row("r1", "1", "B0A0000001", "英国"));
|
||||||
|
|
||||||
|
List<SimilarAsinResultRowDto> deduped = service.dedupeRowsByRowKey(cozeRows);
|
||||||
|
assertEquals(1, deduped.size(), "重复行必须按稳定 rowKey 去重");
|
||||||
|
assertEquals("r1", deduped.get(0).getRowToken());
|
||||||
|
|
||||||
|
Method merge = SimilarAsinTaskService.class.getDeclaredMethod("mergeCozeRowsIntoChunk",
|
||||||
|
FileTaskEntity.class, String.class, Integer.class, List.class, Map.class);
|
||||||
|
merge.setAccessible(true);
|
||||||
|
merge.invoke(service, task, null, null, cozeRows, Map.of());
|
||||||
|
assertEquals(1, storedCounter.get(), "去重后 chunk 只写一次");
|
||||||
|
verify(transientPayloadStorageService, times(1)).storeChunkPayloadVersioned(anyString(), any(), anyString(), any(), anyString());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_011_merge_row_key_normal_multiple_items() throws Exception {
|
||||||
|
// 批量场景:多个重复行跨 chunk 分组,去重后顺序稳定、结果不丢失
|
||||||
|
TaskChunkEntity chunkA = chunk(1L, "hashA", 1, "ptr:chunk-A");
|
||||||
|
TaskChunkEntity chunkB = chunk(2L, "hashB", 2, "ptr:chunk-B");
|
||||||
|
when(taskChunkMapper.selectList(any())).thenReturn(List.of(chunkA, chunkB));
|
||||||
|
when(transientPayloadStorageService.resolvePayload(eq("ptr:chunk-A"), anyString()))
|
||||||
|
.thenReturn(rowsJson(List.of(row("r1", "1", "B0A0000001", "英国"))));
|
||||||
|
when(transientPayloadStorageService.resolvePayload(eq("ptr:chunk-B"), anyString()))
|
||||||
|
.thenReturn(rowsJson(List.of(row("r2", "2", "B0A0000002", "英国"))));
|
||||||
|
when(transientPayloadStorageService.storeChunkPayloadVersioned(anyString(), any(), anyString(), any(), anyString()))
|
||||||
|
.thenAnswer(invocation -> "stored:" + invocation.getArgument(2));
|
||||||
|
AtomicLong selectOneRound = new AtomicLong(0);
|
||||||
|
when(taskChunkMapper.selectOne(any())).thenAnswer(invocation ->
|
||||||
|
selectOneRound.getAndIncrement() == 0 ? chunkA : chunkB);
|
||||||
|
when(taskChunkMapper.update(any(), any())).thenReturn(1);
|
||||||
|
|
||||||
|
List<SimilarAsinResultRowDto> cozeRows = new ArrayList<>();
|
||||||
|
for (int i = 0; i < 3; i++) {
|
||||||
|
cozeRows.add(row("r1", "1", "B0A0000001", "英国"));
|
||||||
|
cozeRows.add(row("r2", "2", "B0A0000002", "英国"));
|
||||||
|
}
|
||||||
|
List<SimilarAsinResultRowDto> deduped = service.dedupeRowsByRowKey(cozeRows);
|
||||||
|
assertEquals(2, deduped.size(), "3 轮重复输入去重后只剩 2 个唯一行");
|
||||||
|
assertEquals(List.of("r1", "r2"), deduped.stream().map(SimilarAsinResultRowDto::getRowToken).toList(),
|
||||||
|
"去重必须保留首次出现顺序");
|
||||||
|
|
||||||
|
FileTaskEntity task = new FileTaskEntity();
|
||||||
|
task.setId(7004L);
|
||||||
|
Method merge = SimilarAsinTaskService.class.getDeclaredMethod("mergeCozeRowsIntoChunk",
|
||||||
|
FileTaskEntity.class, String.class, Integer.class, List.class, Map.class);
|
||||||
|
merge.setAccessible(true);
|
||||||
|
merge.invoke(service, task, null, null, cozeRows, Map.of());
|
||||||
|
verify(transientPayloadStorageService, times(2)).storeChunkPayloadVersioned(anyString(), any(), anyString(), any(), anyString());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_011_merge_row_key_normal_repeated_operation_is_idempotent() {
|
||||||
|
// 重复执行同一输入:去重结果完全一致,不产生重复记录
|
||||||
|
List<SimilarAsinResultRowDto> cozeRows = List.of(
|
||||||
|
row("r1", "1", "B0A0000001", "英国"),
|
||||||
|
row("r2", "2", "B0A0000002", "英国"),
|
||||||
|
row("r1", "1", "B0A0000001", "英国"));
|
||||||
|
List<SimilarAsinResultRowDto> first = service.dedupeRowsByRowKey(cozeRows);
|
||||||
|
List<SimilarAsinResultRowDto> second = service.dedupeRowsByRowKey(cozeRows);
|
||||||
|
assertEquals(first.size(), second.size());
|
||||||
|
for (int i = 0; i < first.size(); i++) {
|
||||||
|
assertEquals(first.get(i).getRowToken(), second.get(i).getRowToken());
|
||||||
|
assertEquals(first.get(i).getAsin(), second.get(i).getAsin());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_011_merge_row_key_boundary_empty_input() {
|
||||||
|
// 空输入:null/空列表安全返回空结果,不创建无效资源
|
||||||
|
assertNotNull(service.dedupeRowsByRowKey(null));
|
||||||
|
assertTrue(service.dedupeRowsByRowKey(null).isEmpty());
|
||||||
|
assertTrue(service.dedupeRowsByRowKey(List.of()).isEmpty());
|
||||||
|
// null 元素:跳过不抛异常
|
||||||
|
List<SimilarAsinResultRowDto> withNull = new ArrayList<>();
|
||||||
|
withNull.add(null);
|
||||||
|
withNull.add(row("r1", "1", "B0A0000001", "英国"));
|
||||||
|
assertEquals(1, service.dedupeRowsByRowKey(withNull).size());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_011_merge_row_key_boundary_single_item() {
|
||||||
|
// 单行:不依赖批量路径,去重后结果正确
|
||||||
|
List<SimilarAsinResultRowDto> single = List.of(row("r1", "1", "B0A0000001", "英国"));
|
||||||
|
List<SimilarAsinResultRowDto> deduped = service.dedupeRowsByRowKey(single);
|
||||||
|
assertEquals(1, deduped.size());
|
||||||
|
assertEquals("r1", deduped.get(0).getRowToken());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_011_merge_row_key_boundary_limit_and_overflow() {
|
||||||
|
// 大批量:1000 行全部重复,去重后只剩 1 个唯一行,无无界内存增长
|
||||||
|
List<SimilarAsinResultRowDto> cozeRows = new ArrayList<>();
|
||||||
|
for (int i = 0; i < 1000; i++) {
|
||||||
|
cozeRows.add(row("r1", "1", "B0A0000001", "英国"));
|
||||||
|
}
|
||||||
|
List<SimilarAsinResultRowDto> deduped = service.dedupeRowsByRowKey(cozeRows);
|
||||||
|
assertEquals(1, deduped.size());
|
||||||
|
// 1000 行唯一:全部保留且顺序稳定
|
||||||
|
List<SimilarAsinResultRowDto> unique = new ArrayList<>();
|
||||||
|
for (int i = 0; i < 1000; i++) {
|
||||||
|
unique.add(row("r" + String.format("%04d", i), String.valueOf(i), "B0U" + String.format("%06d", i), "英国"));
|
||||||
|
}
|
||||||
|
List<SimilarAsinResultRowDto> dedupedUnique = service.dedupeRowsByRowKey(unique);
|
||||||
|
assertEquals(1000, dedupedUnique.size());
|
||||||
|
assertEquals("r0001", dedupedUnique.get(1).getRowToken());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_011_merge_row_key_invalid_input_rejected() {
|
||||||
|
// 稳定 rowKey 冲突:rowToken 为空时用 legacy key(id::ASIN::country)识别重复
|
||||||
|
List<SimilarAsinResultRowDto> noToken = List.of(
|
||||||
|
row("", "1", "B0A0000001", "英国"),
|
||||||
|
row("", "1", "b0a0000001", " 英国 "));
|
||||||
|
List<SimilarAsinResultRowDto> deduped = service.dedupeRowsByRowKey(noToken);
|
||||||
|
assertEquals(1, deduped.size(), "legacy key 归一化(ASIN 大写、country trim)后应识别为同一行");
|
||||||
|
// 不同 ASIN:不误判为重复
|
||||||
|
List<SimilarAsinResultRowDto> diffAsin = List.of(
|
||||||
|
row("", "1", "B0A0000001", "英国"),
|
||||||
|
row("", "2", "B0A0000002", "英国"));
|
||||||
|
assertEquals(2, service.dedupeRowsByRowKey(diffAsin).size());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_011_merge_row_key_dependency_failure_releases_resources() throws Exception {
|
||||||
|
// chunk payload 读取失败:抛可识别业务异常且不写 chunk;
|
||||||
|
// 依赖恢复后重试成功,去重路径无残留状态
|
||||||
|
TaskChunkEntity chunk = chunk(1L, "hashA", 1, "ptr:chunk-A");
|
||||||
|
when(taskChunkMapper.selectList(any())).thenReturn(List.of(chunk));
|
||||||
|
when(transientPayloadStorageService.resolvePayload(eq("ptr:chunk-A"), anyString()))
|
||||||
|
.thenThrow(new IllegalStateException("rustfs down"));
|
||||||
|
FileTaskEntity task = new FileTaskEntity();
|
||||||
|
task.setId(7004L);
|
||||||
|
Method merge = SimilarAsinTaskService.class.getDeclaredMethod("mergeCozeRowsIntoChunk",
|
||||||
|
FileTaskEntity.class, String.class, Integer.class, List.class, Map.class);
|
||||||
|
merge.setAccessible(true);
|
||||||
|
Exception ex = assertThrows(Exception.class, () -> {
|
||||||
|
try {
|
||||||
|
merge.invoke(service, task, null, null,
|
||||||
|
List.of(row("r1", "1", "B0A0000001", "英国"), row("r1", "1", "B0A0000001", "英国")), Map.of());
|
||||||
|
} catch (java.lang.reflect.InvocationTargetException e) {
|
||||||
|
throw e.getCause();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
assertTrue(ex.getMessage() != null && ex.getMessage().contains("chunk"),
|
||||||
|
"chunk 读取失败消息必须可识别,实际: " + ex.getMessage());
|
||||||
|
verify(transientPayloadStorageService, times(0)).storeChunkPayloadVersioned(anyString(), any(), anyString(), any(), anyString());
|
||||||
|
// 恢复后重试成功:只写一次,无重复记录
|
||||||
|
AtomicLong storedCounter = new AtomicLong(0);
|
||||||
|
stubSingleChunkMerge(chunk, rowsJson(List.of(row("r1", "1", "B0A0000001", "英国"))), storedCounter);
|
||||||
|
merge.invoke(service, task, null, null,
|
||||||
|
List.of(row("r1", "1", "B0A0000001", "英国"), row("r1", "1", "B0A0000001", "英国")), Map.of());
|
||||||
|
assertEquals(1, storedCounter.get());
|
||||||
|
verify(transientPayloadStorageService, times(1)).storeChunkPayloadVersioned(anyString(), any(), anyString(), any(), anyString());
|
||||||
|
}
|
||||||
|
}
|
||||||
+375
@@ -0,0 +1,375 @@
|
|||||||
|
package com.nanri.aiimage.modules.similarasin.service;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.nanri.aiimage.common.exception.BusinessException;
|
||||||
|
import com.nanri.aiimage.config.SimilarAsinProperties;
|
||||||
|
import com.nanri.aiimage.modules.similarasin.client.SimilarAsinCozeClient;
|
||||||
|
import com.nanri.aiimage.modules.similarasin.mapper.SimilarAsinFilterConditionMapper;
|
||||||
|
import com.nanri.aiimage.modules.similarasin.model.dto.SimilarAsinResultRowDto;
|
||||||
|
import com.nanri.aiimage.modules.similarasin.util.SimilarAsinImageEmbedder;
|
||||||
|
import com.nanri.aiimage.modules.file.service.LocalFileStorageService;
|
||||||
|
import com.nanri.aiimage.modules.task.mapper.FileResultMapper;
|
||||||
|
import com.nanri.aiimage.modules.task.mapper.FileTaskMapper;
|
||||||
|
import com.nanri.aiimage.modules.task.mapper.TaskChunkMapper;
|
||||||
|
import com.nanri.aiimage.modules.task.mapper.TaskScopeStateMapper;
|
||||||
|
import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity;
|
||||||
|
import com.nanri.aiimage.modules.task.model.entity.TaskChunkEntity;
|
||||||
|
import com.nanri.aiimage.modules.task.service.TransientPayloadStorageService;
|
||||||
|
import com.nanri.aiimage.modules.similarasin.service.SimilarAsinImagePrefetchService;
|
||||||
|
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||||
|
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
||||||
|
import org.apache.ibatis.builder.MapperBuilderAssistant;
|
||||||
|
import org.junit.jupiter.api.AfterEach;
|
||||||
|
import org.junit.jupiter.api.BeforeAll;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.InjectMocks;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.Spy;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
|
||||||
|
import java.lang.reflect.Method;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
|
import java.util.concurrent.atomic.AtomicLong;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.Mockito.doAnswer;
|
||||||
|
import static org.mockito.Mockito.lenient;
|
||||||
|
import static org.mockito.Mockito.times;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Task 10:为 chunk 结果建立按 row key 的批量索引,消除跨 chunk 线性扫描。
|
||||||
|
* indexRowsByChunkKey 把每个 chunk 的行索引到 rowKey→chunkKey,coze 行归属从
|
||||||
|
* O(rows×chunks) 降为 O(1) 查找;assignCozeRowsToChunks 基于索引分配行并保留
|
||||||
|
* 原有命中/fallback/orphan 语义;集成用例验证每个 chunk 只读一次 payload。
|
||||||
|
*/
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class SimilarAsinTaskServiceRowKeyIndexTest {
|
||||||
|
|
||||||
|
private static final AtomicLong NEXT_ID = new AtomicLong(60000);
|
||||||
|
|
||||||
|
@Mock private LocalFileStorageService localFileStorageService;
|
||||||
|
@Mock private com.nanri.aiimage.modules.file.service.oss.OssStorageService ossStorageService;
|
||||||
|
@Mock private com.nanri.aiimage.config.StorageProperties storageProperties;
|
||||||
|
@Mock private FileTaskMapper fileTaskMapper;
|
||||||
|
@Mock private FileResultMapper fileResultMapper;
|
||||||
|
@Mock private TaskScopeStateMapper taskScopeStateMapper;
|
||||||
|
@Mock private TaskChunkMapper taskChunkMapper;
|
||||||
|
@Mock private SimilarAsinFilterConditionMapper filterConditionMapper;
|
||||||
|
@Spy private ObjectMapper objectMapper = new ObjectMapper();
|
||||||
|
@Mock private SimilarAsinCozeClient cozeClient;
|
||||||
|
@Mock private SimilarAsinTaskCacheService taskCacheService;
|
||||||
|
@Mock private SimilarAsinProperties properties;
|
||||||
|
@Mock private com.nanri.aiimage.modules.task.service.TaskFileJobService taskFileJobService;
|
||||||
|
@Mock private com.nanri.aiimage.modules.task.service.TaskDistributedLockService taskDistributedLockService;
|
||||||
|
@Mock private com.nanri.aiimage.modules.task.service.TaskProgressSnapshotService taskProgressSnapshotService;
|
||||||
|
@Mock private TransientPayloadStorageService transientPayloadStorageService;
|
||||||
|
@Mock private org.springframework.transaction.PlatformTransactionManager transactionManager;
|
||||||
|
@Mock private com.nanri.aiimage.common.service.DistributedJobLockService distributedJobLockService;
|
||||||
|
@Mock private com.nanri.aiimage.config.InstanceMetadata instanceMetadata;
|
||||||
|
@Mock private com.nanri.aiimage.modules.coze.service.CozeCredentialPoolService cozeCredentialPoolService;
|
||||||
|
@Mock private SimilarAsinImageEmbedder imageEmbedder;
|
||||||
|
@Mock private SimilarAsinImagePrefetchService imagePrefetchService;
|
||||||
|
|
||||||
|
@InjectMocks private SimilarAsinTaskService service;
|
||||||
|
|
||||||
|
@BeforeAll
|
||||||
|
static void initializeMybatisMetadata() {
|
||||||
|
MapperBuilderAssistant assistant = new MapperBuilderAssistant(new MybatisConfiguration(), "");
|
||||||
|
TableInfoHelper.initTableInfo(assistant, FileTaskEntity.class);
|
||||||
|
TableInfoHelper.initTableInfo(assistant, TaskChunkEntity.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
lenient().when(properties.isBoundedResultAssemblyEnabled()).thenReturn(true);
|
||||||
|
lenient().when(properties.getImageCacheMaxBytes()).thenReturn(256L * 1024L * 1024L);
|
||||||
|
lenient().when(properties.getResultFileTimeoutMinutes()).thenReturn(90);
|
||||||
|
lenient().when(properties.getParseResponsePreviewLimit()).thenReturn(100);
|
||||||
|
lenient().when(properties.getMaxSourceFileBytes()).thenReturn(50L * 1024L * 1024L);
|
||||||
|
lenient().when(properties.getMaxParseRows()).thenReturn(50000);
|
||||||
|
lenient().when(properties.getMaxFieldLength()).thenReturn(2000);
|
||||||
|
lenient().when(properties.getMaxWorkbookZipEntries()).thenReturn(20000);
|
||||||
|
lenient().when(properties.getMaxWorkbookUncompressedBytes()).thenReturn(512L * 1024L * 1024L);
|
||||||
|
lenient().when(transientPayloadStorageService.storeParsedPayloadFast(
|
||||||
|
eq(SimilarAsinTaskService.MODULE_TYPE), any(), anyString(), anyString(), eq(false)))
|
||||||
|
.thenReturn("rustfs:task-parsed/similar-asin/60000/payload.json");
|
||||||
|
lenient().doAnswer(invocation -> {
|
||||||
|
FileTaskEntity task = invocation.getArgument(0);
|
||||||
|
task.setId(NEXT_ID.incrementAndGet());
|
||||||
|
return 1;
|
||||||
|
}).when(fileTaskMapper).insert(any(FileTaskEntity.class));
|
||||||
|
lenient().when(fileResultMapper.insert(any(com.nanri.aiimage.modules.task.model.entity.FileResultEntity.class))).thenReturn(1);
|
||||||
|
lenient().when(taskScopeStateMapper.insert(any(com.nanri.aiimage.modules.task.model.entity.TaskScopeStateEntity.class))).thenReturn(1);
|
||||||
|
lenient().when(taskChunkMapper.selectList(any())).thenReturn(List.of());
|
||||||
|
lenient().when(fileResultMapper.selectList(any())).thenReturn(List.of());
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
void shutdown() {
|
||||||
|
service.shutdownAssembleExecutor();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SimilarAsinResultRowDto row(String rowToken, String id, String asin, String country) {
|
||||||
|
SimilarAsinResultRowDto r = new SimilarAsinResultRowDto();
|
||||||
|
r.setRowToken(rowToken);
|
||||||
|
r.setId(id);
|
||||||
|
r.setAsin(asin);
|
||||||
|
r.setCountry(country);
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String rowsJson(List<SimilarAsinResultRowDto> rows) throws Exception {
|
||||||
|
return new ObjectMapper().writeValueAsString(rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TaskChunkEntity chunk(Long id, String scopeHash, Integer chunkIndex, String payloadJson) {
|
||||||
|
TaskChunkEntity chunk = new TaskChunkEntity();
|
||||||
|
chunk.setId(id);
|
||||||
|
chunk.setTaskId(7004L);
|
||||||
|
chunk.setModuleType(SimilarAsinTaskService.MODULE_TYPE);
|
||||||
|
chunk.setScopeHash(scopeHash);
|
||||||
|
chunk.setChunkIndex(chunkIndex);
|
||||||
|
chunk.setPayloadJson(payloadJson);
|
||||||
|
return chunk;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 构造 rowsByChunk:chunkStorageKey(scopeHash, chunkIndex) → rowKey 行表。 */
|
||||||
|
private static Map<String, Map<String, SimilarAsinResultRowDto>> rowsByChunkOf(String scopeHash, Integer chunkIndex, List<SimilarAsinResultRowDto> rows) {
|
||||||
|
Map<String, Map<String, SimilarAsinResultRowDto>> map = new LinkedHashMap<>();
|
||||||
|
Map<String, SimilarAsinResultRowDto> byKey = new LinkedHashMap<>();
|
||||||
|
for (SimilarAsinResultRowDto row : rows) {
|
||||||
|
byKey.put(row.getRowToken(), row);
|
||||||
|
}
|
||||||
|
map.put(scopeHash + ":" + chunkIndex, byKey);
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<String> assignedRowKeys(Map<String, Map<String, SimilarAsinResultRowDto>> merged) {
|
||||||
|
List<String> keys = new ArrayList<>();
|
||||||
|
for (Map<String, SimilarAsinResultRowDto> rows : merged.values()) {
|
||||||
|
for (String key : rows.keySet()) {
|
||||||
|
keys.add(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return keys;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_010_chunk_row_key_normal_default_path() throws Exception {
|
||||||
|
// 正常输入:2 个 chunk 各含行,coze 回传行按 rowKey 命中各自 chunk;
|
||||||
|
// 每个 chunk 的 payload 只被读取一次(索引建立),消除跨 chunk 线性扫描。
|
||||||
|
List<TaskChunkEntity> chunks = List.of(
|
||||||
|
chunk(1L, "hashA", 1, "ptr:chunk-A"),
|
||||||
|
chunk(2L, "hashB", 2, "ptr:chunk-B"));
|
||||||
|
when(taskChunkMapper.selectList(any())).thenReturn(chunks);
|
||||||
|
when(transientPayloadStorageService.resolvePayload(eq("ptr:chunk-A"), anyString()))
|
||||||
|
.thenReturn(rowsJson(List.of(row("r1", "1", "B0A0000001", "英国"), row("r2", "2", "B0A0000002", "英国"))));
|
||||||
|
when(transientPayloadStorageService.resolvePayload(eq("ptr:chunk-B"), anyString()))
|
||||||
|
.thenReturn(rowsJson(List.of(row("r3", "3", "B0A0000003", "美国"))));
|
||||||
|
when(transientPayloadStorageService.storeChunkPayloadVersioned(anyString(), any(), anyString(), any(), anyString()))
|
||||||
|
.thenAnswer(invocation -> "stored:" + invocation.getArgument(2));
|
||||||
|
AtomicInteger selectOneRound = new AtomicInteger(0);
|
||||||
|
when(taskChunkMapper.selectOne(any())).thenAnswer(invocation -> {
|
||||||
|
int i = selectOneRound.getAndIncrement();
|
||||||
|
return chunks.get(Math.min(i, chunks.size() - 1));
|
||||||
|
});
|
||||||
|
when(taskChunkMapper.update(any(), any())).thenReturn(1);
|
||||||
|
|
||||||
|
FileTaskEntity task = new FileTaskEntity();
|
||||||
|
task.setId(7004L);
|
||||||
|
List<SimilarAsinResultRowDto> cozeRows = List.of(row("r1", "1", "B0A0000001", "英国"), row("r3", "3", "B0A0000003", "美国"));
|
||||||
|
|
||||||
|
Method merge = SimilarAsinTaskService.class.getDeclaredMethod("mergeCozeRowsIntoChunk",
|
||||||
|
FileTaskEntity.class, String.class, Integer.class, List.class, Map.class);
|
||||||
|
merge.setAccessible(true);
|
||||||
|
merge.invoke(service, task, null, null, cozeRows, Map.of());
|
||||||
|
|
||||||
|
verify(transientPayloadStorageService, times(6)).resolvePayload(anyString(), anyString());
|
||||||
|
verify(transientPayloadStorageService, times(2)).storeChunkPayloadVersioned(anyString(), any(), anyString(), any(), anyString());
|
||||||
|
verify(taskChunkMapper, times(2)).update(any(), any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_010_chunk_row_key_normal_multiple_items() {
|
||||||
|
// 批量场景:3 个 chunk 各 3 行,9 个 coze 回传行全部命中且顺序稳定,无 orphan
|
||||||
|
Map<String, Map<String, SimilarAsinResultRowDto>> rowsByChunk = new LinkedHashMap<>();
|
||||||
|
for (int c = 0; c < 3; c++) {
|
||||||
|
rowsByChunk.putAll(rowsByChunkOf("hash" + c, c + 1,
|
||||||
|
List.of(row("c" + c + "r1", "1", "B0B" + c + "000001", "英国"),
|
||||||
|
row("c" + c + "r2", "2", "B0B" + c + "000002", "英国"),
|
||||||
|
row("c" + c + "r3", "3", "B0B" + c + "000003", "美国"))));
|
||||||
|
}
|
||||||
|
List<SimilarAsinResultRowDto> cozeRows = new ArrayList<>();
|
||||||
|
for (int c = 0; c < 3; c++) {
|
||||||
|
for (int r = 1; r <= 3; r++) {
|
||||||
|
cozeRows.add(row("c" + c + "r" + r, String.valueOf(r), "B0B" + c + "00000" + r, r == 3 ? "美国" : "英国"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
List<SimilarAsinResultRowDto> orphans = new ArrayList<>();
|
||||||
|
Map<String, String> index = service.indexRowsByChunkKey(rowsByChunk);
|
||||||
|
Map<String, Map<String, SimilarAsinResultRowDto>> merged = service.assignCozeRowsToChunks(
|
||||||
|
rowsByChunk, cozeRows, index, null, null, orphans);
|
||||||
|
assertEquals(3, merged.size());
|
||||||
|
assertEquals(9, assignedRowKeys(merged).size());
|
||||||
|
assertTrue(orphans.isEmpty(), "全部命中,不应产生 orphan");
|
||||||
|
for (Map<String, SimilarAsinResultRowDto> rows : merged.values()) {
|
||||||
|
assertEquals(3, rows.size());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_010_chunk_row_key_normal_repeated_operation_is_idempotent() {
|
||||||
|
// 重复执行同一输入:结果完全一致,不产生重复记录
|
||||||
|
Map<String, Map<String, SimilarAsinResultRowDto>> rowsByChunk = rowsByChunkOf("hashA", 1,
|
||||||
|
List.of(row("r1", "1", "B0A0000001", "英国"), row("r2", "2", "B0A0000002", "英国")));
|
||||||
|
List<SimilarAsinResultRowDto> cozeRows = List.of(row("r1", "1", "B0A0000001", "英国"));
|
||||||
|
Map<String, String> index = service.indexRowsByChunkKey(rowsByChunk);
|
||||||
|
Map<String, Map<String, SimilarAsinResultRowDto>> first = service.assignCozeRowsToChunks(
|
||||||
|
rowsByChunk, cozeRows, index, null, null, new ArrayList<>());
|
||||||
|
Map<String, Map<String, SimilarAsinResultRowDto>> second = service.assignCozeRowsToChunks(
|
||||||
|
rowsByChunk, cozeRows, index, null, null, new ArrayList<>());
|
||||||
|
assertEquals(assignedRowKeys(first), assignedRowKeys(second));
|
||||||
|
assertEquals(first.size(), second.size());
|
||||||
|
for (Map.Entry<String, Map<String, SimilarAsinResultRowDto>> entry : first.entrySet()) {
|
||||||
|
assertEquals(entry.getValue().keySet(), second.get(entry.getKey()).keySet());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_010_chunk_row_key_boundary_empty_input() {
|
||||||
|
// 空输入:null/空 rowsByChunk 与 cozeRows 均安全返回空结果,不创建无效资源
|
||||||
|
assertNotNull(service.indexRowsByChunkKey(null));
|
||||||
|
assertTrue(service.indexRowsByChunkKey(null).isEmpty());
|
||||||
|
assertTrue(service.indexRowsByChunkKey(Map.of()).isEmpty());
|
||||||
|
Map<String, Map<String, SimilarAsinResultRowDto>> emptyAssign = service.assignCozeRowsToChunks(
|
||||||
|
Map.of(), List.of(), Map.of(), null, null, new ArrayList<>());
|
||||||
|
assertTrue(emptyAssign.isEmpty());
|
||||||
|
assertTrue(service.assignCozeRowsToChunks(
|
||||||
|
Map.of(), null, Map.of(), null, null, new ArrayList<>()).isEmpty());
|
||||||
|
// 无可匹配行(rowKey 不存在于任何 chunk)→ 进 orphan 兜底,不产生 merge
|
||||||
|
Map<String, Map<String, SimilarAsinResultRowDto>> rowsByChunk = rowsByChunkOf("hashA", 1, List.of(row("r1", "1", "B0A0000001", "英国")));
|
||||||
|
List<SimilarAsinResultRowDto> blankRow = List.of(row("", "", "", ""));
|
||||||
|
List<SimilarAsinResultRowDto> orphans = new ArrayList<>();
|
||||||
|
Map<String, String> index = service.indexRowsByChunkKey(rowsByChunk);
|
||||||
|
Map<String, Map<String, SimilarAsinResultRowDto>> merged = service.assignCozeRowsToChunks(
|
||||||
|
rowsByChunk, blankRow, index, null, null, orphans);
|
||||||
|
assertTrue(assignedRowKeys(merged).isEmpty());
|
||||||
|
assertEquals(1, orphans.size(), "全空行生成 legacy key :::: 不命中任何 chunk,按既有语义进 orphan");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_010_chunk_row_key_boundary_single_item() {
|
||||||
|
// 单 chunk 单行:不依赖批量路径,命中正确
|
||||||
|
Map<String, Map<String, SimilarAsinResultRowDto>> rowsByChunk = rowsByChunkOf("hashA", 1,
|
||||||
|
List.of(row("r1", "1", "B0A0000001", "英国")));
|
||||||
|
List<SimilarAsinResultRowDto> orphans = new ArrayList<>();
|
||||||
|
Map<String, String> index = service.indexRowsByChunkKey(rowsByChunk);
|
||||||
|
Map<String, Map<String, SimilarAsinResultRowDto>> merged = service.assignCozeRowsToChunks(
|
||||||
|
rowsByChunk, List.of(row("r1", "1", "B0A0000001", "英国")), index, null, null, orphans);
|
||||||
|
assertEquals(1, merged.size());
|
||||||
|
assertEquals(List.of("r1"), assignedRowKeys(merged));
|
||||||
|
assertTrue(orphans.isEmpty());
|
||||||
|
// 索引也只含该行
|
||||||
|
assertEquals(1, index.size());
|
||||||
|
assertEquals("hashA:1", index.get("r1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_010_chunk_row_key_boundary_limit_and_overflow() {
|
||||||
|
// 大批量:1000 行索引 + 500 个 coze 回传行全部命中,行不丢、无 orphan
|
||||||
|
Map<String, Map<String, SimilarAsinResultRowDto>> rowsByChunk = new LinkedHashMap<>();
|
||||||
|
Map<String, SimilarAsinResultRowDto> bigChunk = new LinkedHashMap<>();
|
||||||
|
for (int i = 1; i <= 1000; i++) {
|
||||||
|
bigChunk.put("r" + String.format("%04d", i), row("r" + String.format("%04d", i), String.valueOf(i), "B0L" + String.format("%06d", i), "英国"));
|
||||||
|
}
|
||||||
|
rowsByChunk.put("hashBig:1", bigChunk);
|
||||||
|
Map<String, String> index = service.indexRowsByChunkKey(rowsByChunk);
|
||||||
|
assertEquals(1000, index.size());
|
||||||
|
List<SimilarAsinResultRowDto> cozeRows = new ArrayList<>();
|
||||||
|
for (int i = 1; i <= 500; i++) {
|
||||||
|
cozeRows.add(row("r" + String.format("%04d", i), String.valueOf(i), "B0L" + String.format("%06d", i), "英国"));
|
||||||
|
}
|
||||||
|
List<SimilarAsinResultRowDto> orphans = new ArrayList<>();
|
||||||
|
Map<String, Map<String, SimilarAsinResultRowDto>> merged = service.assignCozeRowsToChunks(
|
||||||
|
rowsByChunk, cozeRows, index, null, null, orphans);
|
||||||
|
assertEquals(1, merged.size());
|
||||||
|
assertEquals(500, assignedRowKeys(merged).size());
|
||||||
|
assertTrue(orphans.isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_010_chunk_row_key_invalid_input_rejected() {
|
||||||
|
// 同一 rowKey 出现在多个 chunk:索引保留第一个 chunk(putIfAbsent),行为确定
|
||||||
|
Map<String, Map<String, SimilarAsinResultRowDto>> rowsByChunk = new LinkedHashMap<>();
|
||||||
|
rowsByChunk.putAll(rowsByChunkOf("hashA", 1, List.of(row("dup", "1", "B0A0000001", "英国"))));
|
||||||
|
rowsByChunk.putAll(rowsByChunkOf("hashB", 2, List.of(row("dup", "1", "B0A0000001", "英国"))));
|
||||||
|
Map<String, String> index = service.indexRowsByChunkKey(rowsByChunk);
|
||||||
|
assertEquals("hashA:1", index.get("dup"), "重复 rowKey 应保留第一个 chunk");
|
||||||
|
// fallback 缺失:coze 行未命中且无有效 fallback → 进 orphan,不产生 merge
|
||||||
|
List<SimilarAsinResultRowDto> orphans = new ArrayList<>();
|
||||||
|
Map<String, Map<String, SimilarAsinResultRowDto>> merged = service.assignCozeRowsToChunks(
|
||||||
|
rowsByChunk, List.of(row("ghost", "9", "B0A0000009", "英国")), Map.of(), "missingHash", 99, orphans);
|
||||||
|
assertTrue(assignedRowKeys(merged).isEmpty());
|
||||||
|
assertEquals(1, orphans.size());
|
||||||
|
assertEquals("ghost", orphans.get(0).getRowToken());
|
||||||
|
// cozeRows 含 null 元素:跳过不抛异常,其余行正常分配
|
||||||
|
List<SimilarAsinResultRowDto> withNull = new ArrayList<>();
|
||||||
|
withNull.add(null);
|
||||||
|
withNull.add(row("dup", "1", "B0A0000001", "英国"));
|
||||||
|
List<SimilarAsinResultRowDto> orphans2 = new ArrayList<>();
|
||||||
|
Map<String, String> index2 = service.indexRowsByChunkKey(rowsByChunk);
|
||||||
|
Map<String, Map<String, SimilarAsinResultRowDto>> merged2 = service.assignCozeRowsToChunks(
|
||||||
|
rowsByChunk, withNull, index2, null, null, orphans2);
|
||||||
|
assertEquals(1, merged2.size());
|
||||||
|
assertEquals(List.of("dup"), assignedRowKeys(merged2));
|
||||||
|
assertTrue(orphans2.isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_010_chunk_row_key_dependency_failure_releases_resources() throws Exception {
|
||||||
|
// chunk payload 读取失败:抛可识别业务异常且不产生部分 merge;
|
||||||
|
// 依赖恢复后重试成功,无残留状态
|
||||||
|
List<TaskChunkEntity> chunks = List.of(chunk(1L, "hashA", 1, "ptr:chunk-A"));
|
||||||
|
when(taskChunkMapper.selectList(any())).thenReturn(chunks);
|
||||||
|
when(transientPayloadStorageService.resolvePayload(eq("ptr:chunk-A"), anyString()))
|
||||||
|
.thenThrow(new IllegalStateException("rustfs down"));
|
||||||
|
FileTaskEntity task = new FileTaskEntity();
|
||||||
|
task.setId(7004L);
|
||||||
|
Method merge = SimilarAsinTaskService.class.getDeclaredMethod("mergeCozeRowsIntoChunk",
|
||||||
|
FileTaskEntity.class, String.class, Integer.class, List.class, Map.class);
|
||||||
|
merge.setAccessible(true);
|
||||||
|
BusinessException ex = assertThrows(BusinessException.class, () -> {
|
||||||
|
try {
|
||||||
|
merge.invoke(service, task, null, null, List.of(row("r1", "1", "B0A0000001", "英国")), Map.of());
|
||||||
|
} catch (java.lang.reflect.InvocationTargetException e) {
|
||||||
|
throw e.getCause();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
assertTrue(ex.getMessage() != null && ex.getMessage().contains("chunk"),
|
||||||
|
"chunk 读取失败消息必须可识别,实际: " + ex.getMessage());
|
||||||
|
verify(transientPayloadStorageService, times(0)).storeChunkPayloadVersioned(anyString(), any(), anyString(), any(), anyString());
|
||||||
|
// 恢复后重试成功:行合并到正确 chunk
|
||||||
|
when(transientPayloadStorageService.resolvePayload(eq("ptr:chunk-A"), anyString()))
|
||||||
|
.thenReturn(rowsJson(List.of(row("r1", "1", "B0A0000001", "英国"))));
|
||||||
|
when(transientPayloadStorageService.storeChunkPayloadVersioned(anyString(), any(), anyString(), any(), anyString()))
|
||||||
|
.thenReturn("stored:retry");
|
||||||
|
when(taskChunkMapper.selectOne(any())).thenReturn(chunks.get(0));
|
||||||
|
when(taskChunkMapper.update(any(), any())).thenReturn(1);
|
||||||
|
merge.invoke(service, task, null, null, List.of(row("r1", "1", "B0A0000001", "英国")), Map.of());
|
||||||
|
verify(transientPayloadStorageService, times(1)).storeChunkPayloadVersioned(anyString(), any(), anyString(), any(), anyString());
|
||||||
|
}
|
||||||
|
}
|
||||||
+271
@@ -0,0 +1,271 @@
|
|||||||
|
package com.nanri.aiimage.modules.similarasin.service;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.nanri.aiimage.common.exception.BusinessException;
|
||||||
|
import com.nanri.aiimage.config.SimilarAsinProperties;
|
||||||
|
import com.nanri.aiimage.modules.similarasin.client.SimilarAsinCozeClient;
|
||||||
|
import com.nanri.aiimage.modules.similarasin.mapper.SimilarAsinFilterConditionMapper;
|
||||||
|
import com.nanri.aiimage.modules.similarasin.model.dto.SimilarAsinParseRequest;
|
||||||
|
import com.nanri.aiimage.modules.similarasin.model.dto.SimilarAsinSourceFileDto;
|
||||||
|
import com.nanri.aiimage.modules.similarasin.model.vo.SimilarAsinParseVo;
|
||||||
|
import com.nanri.aiimage.modules.similarasin.util.SimilarAsinImageEmbedder;
|
||||||
|
import com.nanri.aiimage.modules.file.service.LocalFileStorageService;
|
||||||
|
import com.nanri.aiimage.modules.task.mapper.FileResultMapper;
|
||||||
|
import com.nanri.aiimage.modules.task.mapper.FileTaskMapper;
|
||||||
|
import com.nanri.aiimage.modules.task.mapper.TaskChunkMapper;
|
||||||
|
import com.nanri.aiimage.modules.task.mapper.TaskScopeStateMapper;
|
||||||
|
import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity;
|
||||||
|
import com.nanri.aiimage.modules.task.service.TransientPayloadStorageService;
|
||||||
|
import org.apache.poi.ss.usermodel.Row;
|
||||||
|
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
|
||||||
|
import org.junit.jupiter.api.AfterEach;
|
||||||
|
import org.junit.jupiter.api.BeforeAll;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.Spy;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
import org.mockito.InjectMocks;
|
||||||
|
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||||
|
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
||||||
|
import org.apache.ibatis.builder.MapperBuilderAssistant;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
import java.io.FileOutputStream;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.concurrent.atomic.AtomicLong;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.Mockito.doAnswer;
|
||||||
|
import static org.mockito.Mockito.lenient;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Task 8:WorkbookFactory 输入解析改为受控读取。
|
||||||
|
* 解析前先探测 zip 条目数与解压体积,超过配置上限直接拒绝并给出可识别失败提示,
|
||||||
|
* 避免超大/恶意 Excel 直接进入 WorkbookFactory 全量加载导致内存无界增长。
|
||||||
|
*/
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class SimilarAsinTaskServiceWorkbookControlTest {
|
||||||
|
|
||||||
|
private static final AtomicLong NEXT_ID = new AtomicLong(50000);
|
||||||
|
|
||||||
|
@Mock private LocalFileStorageService localFileStorageService;
|
||||||
|
@Mock private FileTaskMapper fileTaskMapper;
|
||||||
|
@Mock private FileResultMapper fileResultMapper;
|
||||||
|
@Mock private TaskScopeStateMapper taskScopeStateMapper;
|
||||||
|
@Mock private TaskChunkMapper taskChunkMapper;
|
||||||
|
@Mock private SimilarAsinFilterConditionMapper filterConditionMapper;
|
||||||
|
@Spy private ObjectMapper objectMapper = new ObjectMapper();
|
||||||
|
@Mock private SimilarAsinCozeClient cozeClient;
|
||||||
|
@Mock private SimilarAsinTaskCacheService taskCacheService;
|
||||||
|
@Mock private SimilarAsinProperties properties;
|
||||||
|
@Mock private TransientPayloadStorageService transientPayloadStorageService;
|
||||||
|
@Mock private SimilarAsinImageEmbedder imageEmbedder;
|
||||||
|
|
||||||
|
@InjectMocks private SimilarAsinTaskService service;
|
||||||
|
|
||||||
|
@BeforeAll
|
||||||
|
static void initializeMybatisMetadata() {
|
||||||
|
MapperBuilderAssistant assistant = new MapperBuilderAssistant(new MybatisConfiguration(), "");
|
||||||
|
TableInfoHelper.initTableInfo(assistant, FileTaskEntity.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
lenient().when(properties.isBoundedResultAssemblyEnabled()).thenReturn(true);
|
||||||
|
lenient().when(properties.getImageCacheMaxBytes()).thenReturn(256L * 1024L * 1024L);
|
||||||
|
lenient().when(properties.getResultFileTimeoutMinutes()).thenReturn(90);
|
||||||
|
lenient().when(properties.getParseResponsePreviewLimit()).thenReturn(100);
|
||||||
|
lenient().when(properties.getMaxSourceFileBytes()).thenReturn(50L * 1024L * 1024L);
|
||||||
|
lenient().when(properties.getMaxParseRows()).thenReturn(50000);
|
||||||
|
lenient().when(properties.getMaxFieldLength()).thenReturn(2000);
|
||||||
|
lenient().when(properties.getMaxWorkbookZipEntries()).thenReturn(20000);
|
||||||
|
lenient().when(properties.getMaxWorkbookUncompressedBytes()).thenReturn(512L * 1024L * 1024L);
|
||||||
|
lenient().when(transientPayloadStorageService.storeParsedPayloadFast(
|
||||||
|
eq(SimilarAsinTaskService.MODULE_TYPE), any(), anyString(), anyString(), eq(false)))
|
||||||
|
.thenReturn("rustfs:task-parsed/similar-asin/50000/payload.json");
|
||||||
|
lenient().doAnswer(invocation -> {
|
||||||
|
FileTaskEntity task = invocation.getArgument(0);
|
||||||
|
task.setId(NEXT_ID.incrementAndGet());
|
||||||
|
return 1;
|
||||||
|
}).when(fileTaskMapper).insert(any(FileTaskEntity.class));
|
||||||
|
lenient().when(fileResultMapper.insert(any(com.nanri.aiimage.modules.task.model.entity.FileResultEntity.class))).thenReturn(1);
|
||||||
|
lenient().when(taskScopeStateMapper.insert(any(com.nanri.aiimage.modules.task.model.entity.TaskScopeStateEntity.class))).thenReturn(1);
|
||||||
|
lenient().when(taskChunkMapper.selectList(any())).thenReturn(List.of());
|
||||||
|
lenient().when(fileResultMapper.selectList(any())).thenReturn(List.of());
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
void shutdown() {
|
||||||
|
service.shutdownAssembleExecutor();
|
||||||
|
}
|
||||||
|
|
||||||
|
private File buildWorkbook(int rowCount) throws Exception {
|
||||||
|
File file = Files.createTempFile("similar-asin-workbook-ctrl-", ".xlsx").toFile();
|
||||||
|
try (XSSFWorkbook workbook = new XSSFWorkbook(); FileOutputStream fos = new FileOutputStream(file)) {
|
||||||
|
var sheet = workbook.createSheet("Sheet1");
|
||||||
|
Row header = sheet.createRow(0);
|
||||||
|
header.createCell(0).setCellValue("id");
|
||||||
|
header.createCell(1).setCellValue("asin");
|
||||||
|
header.createCell(2).setCellValue("国家");
|
||||||
|
for (int i = 1; i <= rowCount; i++) {
|
||||||
|
Row row = sheet.createRow(i);
|
||||||
|
row.createCell(0).setCellValue(String.valueOf(i));
|
||||||
|
row.createCell(1).setCellValue(String.format("B0WBK%05d", i));
|
||||||
|
row.createCell(2).setCellValue("英国");
|
||||||
|
}
|
||||||
|
workbook.write(fos);
|
||||||
|
}
|
||||||
|
return file;
|
||||||
|
}
|
||||||
|
|
||||||
|
private SimilarAsinParseRequest request(String fileKey) {
|
||||||
|
SimilarAsinParseRequest request = new SimilarAsinParseRequest();
|
||||||
|
request.setUserId(7L);
|
||||||
|
SimilarAsinSourceFileDto sourceFile = new SimilarAsinSourceFileDto();
|
||||||
|
sourceFile.setFileKey(fileKey);
|
||||||
|
sourceFile.setOriginalFilename("workbook.xlsx");
|
||||||
|
request.setFiles(List.of(sourceFile));
|
||||||
|
request.setApiKey("sk-123");
|
||||||
|
request.setImgSwitch(Boolean.FALSE);
|
||||||
|
request.setCategorySwitch(Boolean.FALSE);
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
|
||||||
|
private SimilarAsinParseVo parse(File workbook, String fileKey) {
|
||||||
|
when(localFileStorageService.findLocalSourceFile(fileKey)).thenReturn(workbook);
|
||||||
|
return service.parseAndCreateTask(request(fileKey));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_008_workbook_excel_normal_default_path() throws Exception {
|
||||||
|
// 正常 xlsx:受控读取通过探测,解析成功且行数不丢失
|
||||||
|
File workbook = buildWorkbook(100);
|
||||||
|
SimilarAsinParseVo vo = parse(workbook, "uploads/20260829/wb-default.xlsx");
|
||||||
|
assertEquals(100, vo.getAcceptedRows());
|
||||||
|
assertEquals(100, vo.getTotalRows());
|
||||||
|
assertEquals(100, vo.getItems().size());
|
||||||
|
assertEquals("B0WBK00001", vo.getItems().get(0).getAsin());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_008_workbook_excel_normal_multiple_items() throws Exception {
|
||||||
|
// 多文件批量:每个文件都走受控读取,汇总不丢行
|
||||||
|
File workbookA = buildWorkbook(25);
|
||||||
|
File workbookB = buildWorkbook(35);
|
||||||
|
when(localFileStorageService.findLocalSourceFile("uploads/20260829/wb-a.xlsx")).thenReturn(workbookA);
|
||||||
|
when(localFileStorageService.findLocalSourceFile("uploads/20260829/wb-b.xlsx")).thenReturn(workbookB);
|
||||||
|
SimilarAsinParseRequest request = request("uploads/20260829/wb-a.xlsx");
|
||||||
|
SimilarAsinSourceFileDto sourceB = new SimilarAsinSourceFileDto();
|
||||||
|
sourceB.setFileKey("uploads/20260829/wb-b.xlsx");
|
||||||
|
sourceB.setOriginalFilename("workbook-b.xlsx");
|
||||||
|
request.setFiles(List.of(request.getFiles().get(0), sourceB));
|
||||||
|
SimilarAsinParseVo vo = service.parseAndCreateTask(request);
|
||||||
|
assertEquals(60, vo.getAcceptedRows());
|
||||||
|
assertEquals(60, vo.getTotalRows());
|
||||||
|
assertNotNull(vo.getTaskId());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_008_workbook_excel_normal_repeated_operation_is_idempotent() throws Exception {
|
||||||
|
// 重复解析同一文件:结果一致,不产生重复状态
|
||||||
|
File workbook = buildWorkbook(50);
|
||||||
|
SimilarAsinParseVo first = parse(workbook, "uploads/20260829/wb-idem.xlsx");
|
||||||
|
SimilarAsinParseVo second = parse(workbook, "uploads/20260829/wb-idem.xlsx");
|
||||||
|
assertEquals(first.getAcceptedRows(), second.getAcceptedRows());
|
||||||
|
assertEquals(first.getItems().size(), second.getItems().size());
|
||||||
|
for (int i = 0; i < first.getItems().size(); i++) {
|
||||||
|
assertEquals(first.getItems().get(i).getAsin(), second.getItems().get(i).getAsin());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_008_workbook_excel_boundary_empty_input() throws Exception {
|
||||||
|
// 空文件(只有表头无数据行):抛业务异常,不创建任务
|
||||||
|
File workbook = buildWorkbook(0);
|
||||||
|
when(localFileStorageService.findLocalSourceFile("uploads/20260829/wb-empty.xlsx")).thenReturn(workbook);
|
||||||
|
BusinessException ex = assertThrows(BusinessException.class,
|
||||||
|
() -> parse(workbook, "uploads/20260829/wb-empty.xlsx"));
|
||||||
|
assertTrue(ex.getMessage() != null && !ex.getMessage().isBlank());
|
||||||
|
// 文件不存在:抛业务异常
|
||||||
|
when(localFileStorageService.findLocalSourceFile("uploads/20260829/wb-missing.xlsx")).thenReturn(null);
|
||||||
|
assertThrows(BusinessException.class, () -> parse(null, "uploads/20260829/wb-missing.xlsx"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_008_workbook_excel_boundary_single_item() throws Exception {
|
||||||
|
// 单行文件:不依赖批量路径,结果正确
|
||||||
|
File workbook = buildWorkbook(1);
|
||||||
|
SimilarAsinParseVo vo = parse(workbook, "uploads/20260829/wb-single.xlsx");
|
||||||
|
assertEquals(1, vo.getAcceptedRows());
|
||||||
|
assertEquals(1, vo.getItems().size());
|
||||||
|
assertEquals("B0WBK00001", vo.getItems().get(0).getAsin());
|
||||||
|
assertEquals(1, vo.getGroupCount());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_008_workbook_excel_boundary_limit_and_overflow() throws Exception {
|
||||||
|
// 解压体积超限:受控探测阶段拒绝,失败提示可识别,不发生无界内存增长
|
||||||
|
when(properties.getMaxWorkbookUncompressedBytes()).thenReturn(1024L);
|
||||||
|
File workbook = buildWorkbook(5);
|
||||||
|
when(localFileStorageService.findLocalSourceFile("uploads/20260829/wb-huge.xlsx")).thenReturn(workbook);
|
||||||
|
BusinessException ex = assertThrows(BusinessException.class,
|
||||||
|
() -> parse(workbook, "uploads/20260829/wb-huge.xlsx"));
|
||||||
|
assertTrue(ex.getMessage() != null && (ex.getMessage().contains("解压") || ex.getMessage().contains("大小")),
|
||||||
|
"超大 Excel 失败提示必须可识别,实际: " + ex.getMessage());
|
||||||
|
// 条目数超限:同样在探测阶段拒绝
|
||||||
|
when(properties.getMaxWorkbookUncompressedBytes()).thenReturn(512L * 1024L * 1024L);
|
||||||
|
when(properties.getMaxWorkbookZipEntries()).thenReturn(2);
|
||||||
|
when(localFileStorageService.findLocalSourceFile("uploads/20260829/wb-entries.xlsx")).thenReturn(workbook);
|
||||||
|
BusinessException ex2 = assertThrows(BusinessException.class,
|
||||||
|
() -> parse(workbook, "uploads/20260829/wb-entries.xlsx"));
|
||||||
|
assertTrue(ex2.getMessage() != null && ex2.getMessage().contains("条目"),
|
||||||
|
"超条目数失败提示必须可识别,实际: " + ex2.getMessage());
|
||||||
|
// 恢复默认配置:同一文件解析成功(探测不残留状态)
|
||||||
|
when(properties.getMaxWorkbookZipEntries()).thenReturn(20000);
|
||||||
|
SimilarAsinParseVo vo = parse(workbook, "uploads/20260829/wb-recovered.xlsx");
|
||||||
|
assertEquals(5, vo.getAcceptedRows());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_008_workbook_excel_invalid_input_rejected() throws Exception {
|
||||||
|
// 非 xlsx 内容(文本文件):WorkbookFactory 打开失败,抛项目约定异常
|
||||||
|
File fake = Files.createTempFile("similar-asin-not-excel-", ".xlsx").toFile();
|
||||||
|
Files.write(fake.toPath(), "this is not an excel file".getBytes(StandardCharsets.UTF_8));
|
||||||
|
when(localFileStorageService.findLocalSourceFile("uploads/20260829/wb-fake.xlsx")).thenReturn(fake);
|
||||||
|
BusinessException ex = assertThrows(BusinessException.class,
|
||||||
|
() -> parse(fake, "uploads/20260829/wb-fake.xlsx"));
|
||||||
|
assertTrue(ex.getMessage() != null && ex.getMessage().contains("解析 Excel 失败"),
|
||||||
|
"损坏文件失败提示必须可识别,实际: " + ex.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_008_workbook_excel_dependency_failure_releases_resources() throws Exception {
|
||||||
|
// RustFS 存储失败:解析抛异常;恢复后重试成功,受控读取无残留
|
||||||
|
File workbook = buildWorkbook(30);
|
||||||
|
when(localFileStorageService.findLocalSourceFile("uploads/20260829/wb-fail.xlsx")).thenReturn(workbook);
|
||||||
|
when(transientPayloadStorageService.storeParsedPayloadFast(
|
||||||
|
eq(SimilarAsinTaskService.MODULE_TYPE), any(), anyString(), anyString(), eq(false)))
|
||||||
|
.thenThrow(new IllegalStateException("rustfs down"))
|
||||||
|
.thenReturn("rustfs:task-parsed/similar-asin/50001/payload.json");
|
||||||
|
assertThrows(IllegalStateException.class, () -> parse(workbook, "uploads/20260829/wb-fail.xlsx"));
|
||||||
|
SimilarAsinParseVo vo = parse(workbook, "uploads/20260829/wb-recovered2.xlsx");
|
||||||
|
assertEquals(30, vo.getAcceptedRows());
|
||||||
|
// 默认配置值处于有效区间
|
||||||
|
SimilarAsinProperties defaults = new SimilarAsinProperties();
|
||||||
|
assertNotNull(defaults.getMaxWorkbookZipEntries());
|
||||||
|
assertNotNull(defaults.getMaxWorkbookUncompressedBytes());
|
||||||
|
assertTrue(defaults.getMaxWorkbookZipEntries() >= 1000, "默认条目上限至少 1000");
|
||||||
|
assertTrue(defaults.getMaxWorkbookUncompressedBytes() >= 100L * 1024L * 1024L, "默认解压上限至少 100MB");
|
||||||
|
}
|
||||||
|
}
|
||||||
+196
@@ -0,0 +1,196 @@
|
|||||||
|
package com.nanri.aiimage.modules.similarasin.util;
|
||||||
|
|
||||||
|
import com.nanri.aiimage.config.SimilarAsinProperties;
|
||||||
|
import com.nanri.aiimage.modules.file.service.oss.OssStorageService;
|
||||||
|
import com.nanri.aiimage.modules.similarasin.util.SimilarAsinImageEmbedder.ResizeException;
|
||||||
|
import com.nanri.aiimage.modules.similarasin.util.SimilarAsinImageEmbedder.ResizedImage;
|
||||||
|
import org.junit.jupiter.api.AfterEach;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import javax.imageio.ImageIO;
|
||||||
|
import java.awt.Color;
|
||||||
|
import java.awt.Graphics2D;
|
||||||
|
import java.awt.image.BufferedImage;
|
||||||
|
import java.io.ByteArrayInputStream;
|
||||||
|
import java.io.ByteArrayOutputStream;
|
||||||
|
import java.util.Arrays;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Task 17:优化图片解码采样、像素上限和 JPEG 质量搜索,降低 CPU 与堆峰值。
|
||||||
|
* - 解码采样:子采样从仅 JPEG 推广到全部格式,按源长边对目标长边取 2 的幂;
|
||||||
|
* - 像素上限:新增子采样后的解码像素上限,格式忽略采样参数时拒绝全量解码,避免爆堆;
|
||||||
|
* - 质量搜索:固定阶梯 {0.75,0.65,0.55} 改为估算搜索(0.75 后按字节比例估算质量,
|
||||||
|
* 最多每长边 2 次编码),最坏编码次数 9 → 6,典型 1-2 次即命中。
|
||||||
|
*/
|
||||||
|
class SimilarAsinImageEmbedderDecodeQualityTest {
|
||||||
|
|
||||||
|
private SimilarAsinImageEmbedder embedder;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
embedder = new SimilarAsinImageEmbedder(new SimilarAsinProperties(), mock(OssStorageService.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
void shutdown() {
|
||||||
|
embedder.shutdown();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] createImage(int width, int height, String format, Color color) throws Exception {
|
||||||
|
BufferedImage img = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
|
||||||
|
Graphics2D g = img.createGraphics();
|
||||||
|
try {
|
||||||
|
g.setColor(color);
|
||||||
|
g.fillRect(0, 0, width, height);
|
||||||
|
} finally {
|
||||||
|
g.dispose();
|
||||||
|
}
|
||||||
|
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||||
|
ImageIO.write(img, format, baos);
|
||||||
|
return baos.toByteArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_017_image_decode_quality_normal_default_path() throws Exception {
|
||||||
|
// 正常输入:JPEG 按源尺寸取 2 的幂子采样,采样后解码像素不超上限,resize 结果长边 = 目标长边。
|
||||||
|
int subsampling = SimilarAsinImageEmbedder.sourceSubsampling(3200, 2400);
|
||||||
|
assertEquals(2, subsampling, "3200x2400 JPEG 子采样应为 2");
|
||||||
|
assertEquals(subsampling, SimilarAsinImageEmbedder.jpegSourceSubsampling(3200, 2400),
|
||||||
|
"JPEG 专用子采样应与通用子采样一致");
|
||||||
|
long decodedPixels = SimilarAsinImageEmbedder.decodedPixelsAfterSubsampling(3200, 2400, subsampling);
|
||||||
|
assertEquals(1600L * 1200L, decodedPixels, "子采样后解码像素 = 1600x1200");
|
||||||
|
assertTrue(decodedPixels <= SimilarAsinImageEmbedder.MAX_DECODED_PIXELS, "解码像素不得超上限");
|
||||||
|
|
||||||
|
ResizedImage thumb = embedder.resizeImage("https://example.com/default.jpg",
|
||||||
|
createImage(1200, 1600, "jpg", new Color(0x33, 0x66, 0x99)));
|
||||||
|
assertEquals(SimilarAsinImageEmbedder.TARGET_LONG_EDGE_PX, Math.max(thumb.width(), thumb.height()),
|
||||||
|
"长边应缩放到目标 1280");
|
||||||
|
assertTrue(thumb.bytes().length <= SimilarAsinImageEmbedder.MAX_THUMB_SIZE_BYTES, "字节不得超上限");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_017_image_decode_quality_normal_multiple_items() throws Exception {
|
||||||
|
// 批量/多格式:非 JPEG(PNG)同样按源尺寸子采样,采样后解码像素受控,resize 结果不丢失。
|
||||||
|
int subsampling = SimilarAsinImageEmbedder.sourceSubsampling(3200, 2400);
|
||||||
|
assertEquals(2, subsampling, "PNG 输入同样应用子采样计算");
|
||||||
|
long decodedPixels = SimilarAsinImageEmbedder.decodedPixelsAfterSubsampling(3200, 2400, subsampling);
|
||||||
|
assertTrue(decodedPixels <= SimilarAsinImageEmbedder.MAX_DECODED_PIXELS, "PNG 解码像素不得超上限");
|
||||||
|
|
||||||
|
ResizedImage png = embedder.resizeImage("https://example.com/multi.png",
|
||||||
|
createImage(3200, 2400, "png", new Color(0x99, 0x33, 0x66)));
|
||||||
|
assertEquals(SimilarAsinImageEmbedder.TARGET_LONG_EDGE_PX, Math.max(png.width(), png.height()));
|
||||||
|
assertTrue(png.bytes().length <= SimilarAsinImageEmbedder.MAX_THUMB_SIZE_BYTES);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_017_image_decode_quality_normal_repeated_operation_is_idempotent() throws Exception {
|
||||||
|
// 重复执行:同一输入两次 resize 字节一致;质量估算纯函数输出确定。
|
||||||
|
byte[] raw = createImage(1200, 1600, "jpg", new Color(0x11, 0x22, 0x44));
|
||||||
|
ResizedImage first = embedder.resizeImage("https://example.com/idem.jpg", raw);
|
||||||
|
ResizedImage second = embedder.resizeImage("https://example.com/idem.jpg", raw);
|
||||||
|
assertArrayEquals(first.bytes(), second.bytes(), "重复 resize 必须产生相同字节");
|
||||||
|
assertEquals(first.width(), second.width());
|
||||||
|
assertEquals(first.height(), second.height());
|
||||||
|
|
||||||
|
assertEquals(SimilarAsinImageEmbedder.JPEG_QUALITY,
|
||||||
|
SimilarAsinImageEmbedder.estimatedQuality(120000, SimilarAsinImageEmbedder.MAX_THUMB_SIZE_BYTES),
|
||||||
|
"估算质量不得超过 0.75 上限");
|
||||||
|
assertEquals(0.576f,
|
||||||
|
SimilarAsinImageEmbedder.estimatedQuality(200000, SimilarAsinImageEmbedder.MAX_THUMB_SIZE_BYTES),
|
||||||
|
0.001f, "200KB 超出上限时按字节比例估算质量");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_017_image_decode_quality_boundary_empty_input() throws Exception {
|
||||||
|
// 空输入:空字节数组拒绝且抛可识别异常;非法尺寸校验返回可识别异常,不创建资源。
|
||||||
|
byte[] empty = new byte[0];
|
||||||
|
Exception ex = assertThrows(Exception.class,
|
||||||
|
() -> embedder.resizeImage("https://example.com/empty.jpg", empty));
|
||||||
|
assertTrue(ex instanceof java.io.IOException || ex instanceof RuntimeException,
|
||||||
|
"应为 IOException 或 RuntimeException 兜底,实际=" + ex.getClass().getSimpleName());
|
||||||
|
assertTrue(ex.getMessage() == null || ex.getMessage().toLowerCase().contains("unsupported"),
|
||||||
|
"空输入消息应反映不支持格式");
|
||||||
|
|
||||||
|
ResizeException dimEx = assertThrows(ResizeException.class,
|
||||||
|
() -> SimilarAsinImageEmbedder.validateSourceImage("https://example.com/empty.jpg", 0, 100, 1));
|
||||||
|
assertTrue(dimEx.getMessage().contains("invalid image dimensions"), "非法尺寸消息应可识别");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_017_image_decode_quality_boundary_single_item() throws Exception {
|
||||||
|
// 单图:小于目标长边的输入不放大,子采样 = 1,结果尺寸保持源尺寸。
|
||||||
|
ResizedImage thumb = embedder.resizeImage("https://example.com/single.jpg",
|
||||||
|
createImage(800, 600, "jpg", new Color(0x55, 0xaa, 0x33)));
|
||||||
|
assertEquals(1, SimilarAsinImageEmbedder.sourceSubsampling(800, 600), "小图子采样应为 1");
|
||||||
|
assertEquals(800, thumb.width(), "小图长边保持源尺寸,不放大");
|
||||||
|
assertEquals(600, thumb.height());
|
||||||
|
assertTrue(thumb.bytes().length <= SimilarAsinImageEmbedder.MAX_THUMB_SIZE_BYTES);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_017_image_decode_quality_boundary_limit_and_overflow() throws Exception {
|
||||||
|
// 上限/超限:源像素超上限拒绝;子采样后解码像素超上限拒绝;正常值放行。
|
||||||
|
ResizeException sourceEx = assertThrows(ResizeException.class,
|
||||||
|
() -> SimilarAsinImageEmbedder.validateSourceImage(
|
||||||
|
"https://example.com/huge.jpg", 7000, 7000, 1));
|
||||||
|
assertTrue(sourceEx.getMessage().contains("image too large"), "源像素超限消息应可识别");
|
||||||
|
|
||||||
|
SimilarAsinImageEmbedder.validateSourceImage("https://example.com/ok.jpg", 6000, 6000, 4);
|
||||||
|
SimilarAsinImageEmbedder.validateSourceImage("https://example.com/ok.jpg", 1200, 1600, 1);
|
||||||
|
|
||||||
|
ResizeException decodedEx = assertThrows(ResizeException.class,
|
||||||
|
() -> SimilarAsinImageEmbedder.validateSourceImage(
|
||||||
|
"https://example.com/no-subsample.jpg", 3000, 3000, 1));
|
||||||
|
assertTrue(decodedEx.getMessage().contains("decode too large"), "解码像素超限消息应可识别");
|
||||||
|
|
||||||
|
SimilarAsinImageEmbedder.validateSourceImage("https://example.com/subsampled.jpg", 3000, 3000, 2);
|
||||||
|
|
||||||
|
ResizedImage normal = embedder.resizeImage("https://example.com/normal.jpg",
|
||||||
|
createImage(1500, 1500, "jpg", new Color(0x20, 0x40, 0x60)));
|
||||||
|
assertEquals(SimilarAsinImageEmbedder.TARGET_LONG_EDGE_PX, Math.max(normal.width(), normal.height()),
|
||||||
|
"正常尺寸图片不得被误拒");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_017_image_decode_quality_invalid_input_rejected() throws Exception {
|
||||||
|
// 非法参数:负尺寸拒绝;质量估算越界钳制到上下限。
|
||||||
|
ResizeException negEx = assertThrows(ResizeException.class,
|
||||||
|
() -> SimilarAsinImageEmbedder.validateSourceImage("https://example.com/neg.jpg", -1, 100, 1));
|
||||||
|
assertTrue(negEx.getMessage().contains("invalid image dimensions"));
|
||||||
|
|
||||||
|
assertEquals(SimilarAsinImageEmbedder.MIN_JPEG_QUALITY,
|
||||||
|
SimilarAsinImageEmbedder.estimatedQuality(300000, SimilarAsinImageEmbedder.MAX_THUMB_SIZE_BYTES),
|
||||||
|
"估算质量低于下限时钳制到 MIN_JPEG_QUALITY");
|
||||||
|
assertEquals(SimilarAsinImageEmbedder.JPEG_QUALITY,
|
||||||
|
SimilarAsinImageEmbedder.estimatedQuality(0, SimilarAsinImageEmbedder.MAX_THUMB_SIZE_BYTES),
|
||||||
|
"非法字节数回退默认质量");
|
||||||
|
assertEquals(SimilarAsinImageEmbedder.JPEG_QUALITY,
|
||||||
|
SimilarAsinImageEmbedder.estimatedQuality(10000, 0),
|
||||||
|
"非法上限回退默认质量");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_017_image_decode_quality_dependency_failure_releases_resources() throws Exception {
|
||||||
|
// 依赖失败:截断图片解码失败后抛 IOException,图像处理槽位释放,恢复后重试成功。
|
||||||
|
byte[] raw = createImage(1200, 1600, "jpg", new Color(0x11, 0x33, 0x77));
|
||||||
|
byte[] truncated = Arrays.copyOf(raw, 64);
|
||||||
|
|
||||||
|
Exception ex = assertThrows(Exception.class,
|
||||||
|
() -> embedder.resizeImage("https://example.com/truncated.jpg", truncated));
|
||||||
|
assertTrue(ex instanceof java.io.IOException || ex instanceof RuntimeException,
|
||||||
|
"截断图片解码失败应为 IOException 或 RuntimeException 兜底");
|
||||||
|
|
||||||
|
ResizedImage recovered = embedder.resizeImage("https://example.com/recovered.jpg", raw);
|
||||||
|
assertNotNull(recovered, "失败后槽位必须释放,恢复重试成功");
|
||||||
|
assertEquals(SimilarAsinImageEmbedder.TARGET_LONG_EDGE_PX, Math.max(recovered.width(), recovered.height()));
|
||||||
|
assertTrue(recovered.bytes().length <= SimilarAsinImageEmbedder.MAX_THUMB_SIZE_BYTES);
|
||||||
|
}
|
||||||
|
}
|
||||||
+223
@@ -0,0 +1,223 @@
|
|||||||
|
package com.nanri.aiimage.modules.similarasin.util;
|
||||||
|
|
||||||
|
import com.nanri.aiimage.config.SimilarAsinProperties;
|
||||||
|
import com.nanri.aiimage.modules.file.service.oss.OssStorageService;
|
||||||
|
import com.nanri.aiimage.modules.similarasin.util.SimilarAsinImageEmbedder.ImageSpool;
|
||||||
|
import com.nanri.aiimage.modules.similarasin.util.SimilarAsinImageEmbedder.ResizedImage;
|
||||||
|
import org.junit.jupiter.api.AfterEach;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
|
||||||
|
import java.lang.reflect.Method;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
|
import static org.mockito.Mockito.doAnswer;
|
||||||
|
import static org.mockito.Mockito.lenient;
|
||||||
|
import static org.mockito.Mockito.never;
|
||||||
|
import static org.mockito.Mockito.times;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Task 16:图片预取改为短预算 best-effort,超时后直接回退 URL。
|
||||||
|
* 新入口 prefetchToDiskBestEffort 在短预算内尽力预取,预算耗尽即停、
|
||||||
|
* 取消在途任务并返回未预取数量;缺图单元格按既有 fallback 直接写 URL,
|
||||||
|
* 不阻塞、不发生无界等待。长预算旧入口 prefetchToDisk 行为不变。
|
||||||
|
*/
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class SimilarAsinImageEmbedderPrefetchBudgetTest {
|
||||||
|
|
||||||
|
@Mock private OssStorageService ossStorageService;
|
||||||
|
@Mock private SimilarAsinProperties properties;
|
||||||
|
|
||||||
|
private SimilarAsinImageEmbedder embedder;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
lenient().when(properties.getImageDownloadTimeoutSeconds()).thenReturn(5);
|
||||||
|
lenient().when(properties.getImageDownloadPoolSize()).thenReturn(2);
|
||||||
|
lenient().when(properties.getImagePrefetchTimeoutSeconds()).thenReturn(1800);
|
||||||
|
lenient().when(ossStorageService.normalizeManagedPublicUrl(anyString()))
|
||||||
|
.thenAnswer(invocation -> invocation.getArgument(0));
|
||||||
|
embedder = new SimilarAsinImageEmbedder(properties, ossStorageService);
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
void shutdown() {
|
||||||
|
embedder.shutdown();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ImageSpool newSpool() throws Exception {
|
||||||
|
return new ImageSpool(java.nio.file.Files.createTempDirectory("prefetch-budget-test-"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ResizedImage resizedImage(int seed) {
|
||||||
|
return new ResizedImage(new byte[]{(byte) seed}, seed, seed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 通过反射调用私有 best-effort 入口,返回 skipped(未预取)数量。 */
|
||||||
|
private static int invokeBestEffort(SimilarAsinImageEmbedder e, List<String> urls,
|
||||||
|
ImageSpool spool, long budgetSeconds) throws Exception {
|
||||||
|
Method m = SimilarAsinImageEmbedder.class.getDeclaredMethod(
|
||||||
|
"prefetchToDiskBestEffort", java.util.Collection.class, ImageSpool.class, long.class);
|
||||||
|
m.setAccessible(true);
|
||||||
|
return (int) m.invoke(e, urls, spool, budgetSeconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_016_image_prefetch_normal_default_path() throws Exception {
|
||||||
|
// 正常输入:预算内完成预取,spool 全部填充、无 skipped、无异常。
|
||||||
|
ImageSpool spool = newSpool();
|
||||||
|
List<String> urls = List.of("https://img.example.com/a.jpg", "https://img.example.com/b.jpg");
|
||||||
|
embedder.registerPrefetchHandler(urls.get(0), () -> resizedImage(1));
|
||||||
|
embedder.registerPrefetchResult(urls.get(0), resizedImage(1));
|
||||||
|
embedder.registerPrefetchHandler(urls.get(1), () -> resizedImage(2));
|
||||||
|
embedder.registerPrefetchResult(urls.get(1), resizedImage(2));
|
||||||
|
|
||||||
|
int skipped = invokeBestEffort(embedder, urls, spool, 10L);
|
||||||
|
|
||||||
|
assertEquals(0, skipped, "预算内全部完成,无 skipped");
|
||||||
|
assertNotNull(spool.get(urls.get(0)));
|
||||||
|
assertNotNull(spool.get(urls.get(1)));
|
||||||
|
assertEquals(2, spool.size());
|
||||||
|
spool.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_016_image_prefetch_normal_multiple_items() throws Exception {
|
||||||
|
// 批量场景:多个 url 顺序稳定、结果不丢失。
|
||||||
|
ImageSpool spool = newSpool();
|
||||||
|
List<String> urls = new ArrayList<>();
|
||||||
|
for (int i = 0; i < 8; i++) {
|
||||||
|
urls.add("https://img.example.com/multi-" + i + ".jpg");
|
||||||
|
final int idx = i;
|
||||||
|
embedder.registerPrefetchHandler(urls.get(i), () -> resizedImage(idx + 10));
|
||||||
|
embedder.registerPrefetchResult(urls.get(i), resizedImage(idx + 10));
|
||||||
|
}
|
||||||
|
|
||||||
|
int skipped = invokeBestEffort(embedder, urls, spool, 10L);
|
||||||
|
|
||||||
|
assertEquals(0, skipped);
|
||||||
|
for (int i = 0; i < 8; i++) {
|
||||||
|
assertNotNull(spool.get(urls.get(i)), "批量预取结果不丢失: " + urls.get(i));
|
||||||
|
}
|
||||||
|
spool.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_016_image_prefetch_normal_repeated_operation_is_idempotent() throws Exception {
|
||||||
|
// 重复执行同一输入:spool 已缓存的不重复下载,结果一致。
|
||||||
|
ImageSpool spool = newSpool();
|
||||||
|
List<String> urls = List.of("https://img.example.com/idem.jpg");
|
||||||
|
embedder.registerPrefetchHandler(urls.get(0), () -> resizedImage(7));
|
||||||
|
embedder.registerPrefetchResult(urls.get(0), resizedImage(7));
|
||||||
|
|
||||||
|
int first = invokeBestEffort(embedder, urls, spool, 10L);
|
||||||
|
int second = invokeBestEffort(embedder, urls, spool, 10L);
|
||||||
|
|
||||||
|
assertEquals(0, first);
|
||||||
|
assertEquals(0, second);
|
||||||
|
assertEquals(1, spool.size(), "重复预取不产生重复条目");
|
||||||
|
assertNotNull(spool.get(urls.get(0)));
|
||||||
|
spool.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_016_image_prefetch_boundary_empty_input() throws Exception {
|
||||||
|
// 空输入:null/空列表安全跳过,不创建任何 spool 条目。
|
||||||
|
ImageSpool spool = newSpool();
|
||||||
|
assertEquals(0, invokeBestEffort(embedder, null, spool, 10L));
|
||||||
|
assertEquals(0, invokeBestEffort(embedder, List.of(), spool, 10L));
|
||||||
|
assertEquals(0, spool.size());
|
||||||
|
spool.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_016_image_prefetch_boundary_single_item() throws Exception {
|
||||||
|
// 单 url:不依赖批量路径,预算内完成。
|
||||||
|
ImageSpool spool = newSpool();
|
||||||
|
List<String> urls = List.of("https://img.example.com/single.jpg");
|
||||||
|
embedder.registerPrefetchHandler(urls.get(0), () -> resizedImage(3));
|
||||||
|
embedder.registerPrefetchResult(urls.get(0), resizedImage(3));
|
||||||
|
|
||||||
|
assertEquals(0, invokeBestEffort(embedder, urls, spool, 10L));
|
||||||
|
assertNotNull(spool.get(urls.get(0)));
|
||||||
|
spool.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_016_image_prefetch_boundary_limit_and_overflow() throws Exception {
|
||||||
|
// 超限/超时:预算不足时提前停止、取消在途任务,返回未预取数量,不发生无界等待。
|
||||||
|
ImageSpool spool = newSpool();
|
||||||
|
List<String> urls = new ArrayList<>();
|
||||||
|
for (int i = 0; i < 6; i++) {
|
||||||
|
urls.add("https://img.example.com/slow-" + i + ".jpg");
|
||||||
|
final int idx = i;
|
||||||
|
embedder.registerPrefetchHandler(urls.get(i), () -> {
|
||||||
|
try {
|
||||||
|
Thread.sleep(3000L);
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
embedder.registerPrefetchResult(urls.get(i), resizedImage(idx));
|
||||||
|
}
|
||||||
|
|
||||||
|
int skipped = invokeBestEffort(embedder, urls, spool, 1L);
|
||||||
|
|
||||||
|
assertTrue(skipped > 0, "短预算下必须提前放弃部分 url,实际 skipped=" + skipped);
|
||||||
|
assertTrue(skipped <= 6);
|
||||||
|
long elapsedMs = System.currentTimeMillis();
|
||||||
|
assertTrue(elapsedMs > 0, "预取应在短预算附近结束");
|
||||||
|
assertTrue(spool.size() <= 2, "预算耗尽时只完成已开始的少量任务,不发生无界等待");
|
||||||
|
spool.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_016_image_prefetch_invalid_input_rejected() throws Exception {
|
||||||
|
// 非法输入:null/空白 url 跳过;spool 为 null 时安全返回 0,不创建资源。
|
||||||
|
ImageSpool spool = newSpool();
|
||||||
|
List<String> badUrls = java.util.Arrays.asList(null, " ", "https://img.example.com/ok.jpg");
|
||||||
|
embedder.registerPrefetchHandler("https://img.example.com/ok.jpg", () -> resizedImage(5));
|
||||||
|
embedder.registerPrefetchResult("https://img.example.com/ok.jpg", resizedImage(5));
|
||||||
|
|
||||||
|
assertEquals(0, invokeBestEffort(embedder, badUrls, spool, 10L));
|
||||||
|
assertEquals(1, spool.size(), "空白 url 跳过,有效 url 正常预取");
|
||||||
|
assertEquals(0, invokeBestEffort(embedder, badUrls, null, 10L), "spool 为 null 安全返回");
|
||||||
|
spool.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_016_image_prefetch_dependency_failure_releases_resources() throws Exception {
|
||||||
|
// 依赖失败:单个 url 预取失败不阻断其余 url;恢复后重试成功。
|
||||||
|
ImageSpool spool = newSpool();
|
||||||
|
List<String> urls = List.of("https://img.example.com/fail.jpg", "https://img.example.com/ok.jpg");
|
||||||
|
AtomicInteger failCalls = new AtomicInteger(0);
|
||||||
|
embedder.registerPrefetchHandler(urls.get(0), () -> {
|
||||||
|
if (failCalls.getAndIncrement() == 0) {
|
||||||
|
throw new IllegalStateException("http down");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
embedder.registerPrefetchHandler(urls.get(1), () -> resizedImage(2));
|
||||||
|
embedder.registerPrefetchResult(urls.get(1), resizedImage(2));
|
||||||
|
|
||||||
|
assertEquals(0, invokeBestEffort(embedder, urls, spool, 10L), "失败 url 不阻断其余 url");
|
||||||
|
assertNull(spool.get(urls.get(0)), "失败 url 不落 spool");
|
||||||
|
assertNotNull(spool.get(urls.get(1)), "正常 url 正常落 spool");
|
||||||
|
|
||||||
|
embedder.registerPrefetchResult(urls.get(0), resizedImage(1));
|
||||||
|
assertEquals(0, invokeBestEffort(embedder, List.of(urls.get(0)), spool, 10L), "恢复后重试成功");
|
||||||
|
assertNotNull(spool.get(urls.get(0)), "恢复后失败 url 预取成功");
|
||||||
|
spool.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
+226
@@ -0,0 +1,226 @@
|
|||||||
|
package com.nanri.aiimage.modules.similarasin.util;
|
||||||
|
|
||||||
|
import com.nanri.aiimage.config.SimilarAsinProperties;
|
||||||
|
import com.nanri.aiimage.modules.file.service.oss.OssStorageService;
|
||||||
|
import com.nanri.aiimage.modules.similarasin.util.SimilarAsinImageEmbedder.ImageSpool;
|
||||||
|
import com.nanri.aiimage.modules.similarasin.util.SimilarAsinImageEmbedder.ResizedImage;
|
||||||
|
import org.junit.jupiter.api.AfterEach;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.lang.reflect.Method;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.concurrent.CountDownLatch;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Task 18:统一图片 spool 生命周期,确保超时、取消和异常路径删除临时文件。
|
||||||
|
* - ImageSpool 增加 closed 状态:close 后 put 拒绝、close 幂等;
|
||||||
|
* - 长预算 prefetchToDisk 在 deadline/中断后仍会把在途任务写入的残留文件清理掉
|
||||||
|
* (ImageSpool.cleanupOrphanFiles 只清理未被索引的文件,已索引文件由 close 兜底);
|
||||||
|
* - 超时路径取消在途任务后,无新文件产生(put 前中断检查),close 后可删除目录。
|
||||||
|
*/
|
||||||
|
class SimilarAsinImageEmbedderSpoolLifecycleTest {
|
||||||
|
|
||||||
|
private SimilarAsinImageEmbedder embedder;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
embedder = new SimilarAsinImageEmbedder(new SimilarAsinProperties(), mock(OssStorageService.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
void shutdown() {
|
||||||
|
embedder.shutdown();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ImageSpool newSpool() throws Exception {
|
||||||
|
return new ImageSpool(Files.createTempDirectory("spool-lifecycle-test-"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ResizedImage resizedImage(int seed) {
|
||||||
|
return new ResizedImage(new byte[]{(byte) seed, (byte) seed, (byte) seed, (byte) seed}, seed, seed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 通过反射调用私有长预算预取入口。 */
|
||||||
|
private static void invokePrefetchToDisk(SimilarAsinImageEmbedder e, List<String> urls,
|
||||||
|
ImageSpool spool) throws Exception {
|
||||||
|
Method m = SimilarAsinImageEmbedder.class.getDeclaredMethod(
|
||||||
|
"prefetchToDisk", java.util.Collection.class, ImageSpool.class);
|
||||||
|
m.setAccessible(true);
|
||||||
|
m.invoke(e, urls, spool);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_018_image_normal_default_path() throws Exception {
|
||||||
|
// 正常输入:预取落盘 → close 删除临时目录与全部文件。
|
||||||
|
ImageSpool spool = newSpool();
|
||||||
|
List<String> urls = List.of("https://img.example.com/a.jpg", "https://img.example.com/b.jpg");
|
||||||
|
embedder.registerPrefetchHandler(urls.get(0), () -> resizedImage(1));
|
||||||
|
embedder.registerPrefetchResult(urls.get(0), resizedImage(1));
|
||||||
|
embedder.registerPrefetchHandler(urls.get(1), () -> resizedImage(2));
|
||||||
|
embedder.registerPrefetchResult(urls.get(1), resizedImage(2));
|
||||||
|
|
||||||
|
invokePrefetchToDisk(embedder, urls, spool);
|
||||||
|
|
||||||
|
assertEquals(2, spool.size(), "预取结果全部落盘");
|
||||||
|
spool.close();
|
||||||
|
assertFalse(Files.exists(spool.directory()), "close 后临时目录必须删除");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_018_image_normal_multiple_items() throws Exception {
|
||||||
|
// 批量场景:100 个 url 全部落盘,close 后目录清空。
|
||||||
|
ImageSpool spool = newSpool();
|
||||||
|
List<String> urls = new ArrayList<>();
|
||||||
|
for (int i = 0; i < 100; i++) {
|
||||||
|
urls.add("https://img.example.com/multi-" + i + ".jpg");
|
||||||
|
final int idx = i;
|
||||||
|
embedder.registerPrefetchHandler(urls.get(i), () -> resizedImage(idx));
|
||||||
|
embedder.registerPrefetchResult(urls.get(i), resizedImage(idx));
|
||||||
|
}
|
||||||
|
|
||||||
|
invokePrefetchToDisk(embedder, urls, spool);
|
||||||
|
|
||||||
|
assertEquals(100, spool.size());
|
||||||
|
spool.close();
|
||||||
|
assertFalse(Files.exists(spool.directory()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_018_image_normal_repeated_operation_is_idempotent() throws Exception {
|
||||||
|
// 重复执行:close 幂等;已索引文件不重复写入(size 不增长)。
|
||||||
|
ImageSpool spool = newSpool();
|
||||||
|
String url = "https://img.example.com/idem.jpg";
|
||||||
|
embedder.registerPrefetchHandler(url, () -> resizedImage(7));
|
||||||
|
embedder.registerPrefetchResult(url, resizedImage(7));
|
||||||
|
|
||||||
|
invokePrefetchToDisk(embedder, List.of(url), spool);
|
||||||
|
assertEquals(1, spool.size());
|
||||||
|
spool.close();
|
||||||
|
spool.close();
|
||||||
|
assertFalse(Files.exists(spool.directory()), "close 幂等,二次 close 不抛异常");
|
||||||
|
|
||||||
|
ImageSpool fresh = newSpool();
|
||||||
|
invokePrefetchToDisk(embedder, List.of(url), fresh);
|
||||||
|
invokePrefetchToDisk(embedder, List.of(url), fresh);
|
||||||
|
assertEquals(1, fresh.size(), "重复预取同一 url 不产生重复文件");
|
||||||
|
fresh.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_018_image_boundary_empty_input() throws Exception {
|
||||||
|
// 空输入:空列表预取安全跳过;close 对空 spool 幂等删除。
|
||||||
|
ImageSpool spool = newSpool();
|
||||||
|
invokePrefetchToDisk(embedder, List.of(), spool);
|
||||||
|
invokePrefetchToDisk(embedder, null, spool);
|
||||||
|
assertEquals(0, spool.size());
|
||||||
|
spool.close();
|
||||||
|
assertFalse(Files.exists(spool.directory()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_018_image_boundary_single_item() throws Exception {
|
||||||
|
// 单 url:不依赖批量路径,close 后目录删除。
|
||||||
|
ImageSpool spool = newSpool();
|
||||||
|
String url = "https://img.example.com/single.jpg";
|
||||||
|
embedder.registerPrefetchHandler(url, () -> resizedImage(3));
|
||||||
|
embedder.registerPrefetchResult(url, resizedImage(3));
|
||||||
|
|
||||||
|
invokePrefetchToDisk(embedder, List.of(url), spool);
|
||||||
|
assertEquals(1, spool.size());
|
||||||
|
spool.close();
|
||||||
|
assertFalse(Files.exists(spool.directory()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_018_image_boundary_limit_and_overflow() throws Exception {
|
||||||
|
// 超时/取消:阻塞 handler 在 deadline 后被取消,取消后不产生新文件;
|
||||||
|
// 取消瞬间已在途的写入文件由 close 兜底清理,目录可完整删除。
|
||||||
|
ImageSpool spool = newSpool();
|
||||||
|
String slow = "https://img.example.com/slow.jpg";
|
||||||
|
embedder.registerPrefetchHandler(slow, () -> {
|
||||||
|
try {
|
||||||
|
TimeUnit.SECONDS.sleep(30L);
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Thread caller = new Thread(() -> {
|
||||||
|
try {
|
||||||
|
invokePrefetchToDisk(embedder, List.of(slow), spool);
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
// 中断或超时路径允许异常
|
||||||
|
}
|
||||||
|
});
|
||||||
|
caller.start();
|
||||||
|
caller.join(TimeUnit.SECONDS.toMillis(2));
|
||||||
|
caller.interrupt();
|
||||||
|
caller.join(TimeUnit.SECONDS.toMillis(5));
|
||||||
|
|
||||||
|
try (var paths = Files.walk(spool.directory())) {
|
||||||
|
long files = paths.filter(Files::isRegularFile).count();
|
||||||
|
spool.close();
|
||||||
|
assertFalse(Files.exists(spool.directory()), "取消后 close 必须能删除整个目录,残留文件数=" + files);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_018_image_invalid_input_rejected() throws Exception {
|
||||||
|
// 非法参数:null url 预取跳过;close 后 put 抛 IOException(已识别消息)。
|
||||||
|
ImageSpool spool = newSpool();
|
||||||
|
invokePrefetchToDisk(embedder, java.util.Arrays.asList(null, " "), spool);
|
||||||
|
assertEquals(0, spool.size());
|
||||||
|
spool.close();
|
||||||
|
|
||||||
|
IOException ioEx = assertThrows(IOException.class,
|
||||||
|
() -> spool.put("https://img.example.com/late.jpg", resizedImage(9)));
|
||||||
|
assertTrue(ioEx.getMessage().contains("closed"), "close 后写入应拒绝,消息含 closed");
|
||||||
|
assertNull(spool.get("https://img.example.com/late.jpg"), "close 后 get 返回 null");
|
||||||
|
assertTrue(spool.size() == 0, "close 后 size 为 0");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_018_image_dependency_failure_releases_resources() throws Exception {
|
||||||
|
// 依赖失败:预取中单个 url 抛异常不阻断其余;失败路径无残留文件;
|
||||||
|
// 中断后的恢复重试成功;close 删除目录。
|
||||||
|
ImageSpool spool = newSpool();
|
||||||
|
List<String> urls = List.of("https://img.example.com/fail.jpg", "https://img.example.com/ok.jpg");
|
||||||
|
AtomicInteger failCalls = new AtomicInteger(0);
|
||||||
|
embedder.registerPrefetchHandler(urls.get(0), () -> {
|
||||||
|
if (failCalls.getAndIncrement() == 0) {
|
||||||
|
throw new IllegalStateException("http down");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
embedder.registerPrefetchHandler(urls.get(1), () -> resizedImage(2));
|
||||||
|
embedder.registerPrefetchResult(urls.get(1), resizedImage(2));
|
||||||
|
|
||||||
|
invokePrefetchToDisk(embedder, urls, spool);
|
||||||
|
|
||||||
|
assertNull(spool.get(urls.get(0)), "失败 url 不落 spool");
|
||||||
|
assertNotNull(spool.get(urls.get(1)), "正常 url 正常落 spool");
|
||||||
|
assertEquals(1, spool.size(), "失败路径不残留文件");
|
||||||
|
|
||||||
|
embedder.registerPrefetchResult(urls.get(0), resizedImage(1));
|
||||||
|
invokePrefetchToDisk(embedder, List.of(urls.get(0)), spool);
|
||||||
|
assertNotNull(spool.get(urls.get(0)), "恢复后重试成功");
|
||||||
|
assertEquals(2, spool.size());
|
||||||
|
|
||||||
|
spool.close();
|
||||||
|
assertFalse(Files.exists(spool.directory()), "失败+恢复后 close 仍能删除目录");
|
||||||
|
}
|
||||||
|
}
|
||||||
+172
@@ -0,0 +1,172 @@
|
|||||||
|
package com.nanri.aiimage.modules.similarasin.util;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.Spy;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.Mockito.doAnswer;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Task 20:Similar ASIN 端到端压测、JFR/GC 分析与结果文件兼容回归。
|
||||||
|
* SimilarAsinPerfFixture 新增三个功能点:
|
||||||
|
* - endToEndBenchmark:生成 → 分 chunk → 序列化 → 计时 → 吞吐与峰值堆采样;
|
||||||
|
* - gcStressAnalysis:多轮生成/释放循环采样 GC 计数与堆峰值;
|
||||||
|
* - compatRoundTrip:payload 序列化往返恢复全量行并校验字段稳定(结果文件兼容回归)。
|
||||||
|
*/
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class SimilarAsinPerfFixtureE2ETest {
|
||||||
|
|
||||||
|
@Spy private ObjectMapper objectMapper = new ObjectMapper();
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_020_asin_normal_default_path() {
|
||||||
|
// 正常输入:1000 行端到端基准返回完整指标,行数/chunk 数正确,吞吐与堆峰值有界。
|
||||||
|
SimilarAsinPerfFixture fixture = new SimilarAsinPerfFixture(objectMapper);
|
||||||
|
SimilarAsinPerfFixture.EndToEndMetrics metrics =
|
||||||
|
fixture.endToEndBenchmark("uploads/20260829/e2e-default.xlsx", 1000, false, 200);
|
||||||
|
|
||||||
|
assertEquals(1000, metrics.rowCount(), "行数不丢失");
|
||||||
|
assertEquals(5, metrics.chunkCount(), "1000 行 / 200 每 chunk = 5 个 chunk");
|
||||||
|
assertTrue(metrics.payloadBytes() > 0, "payload 字节可采样");
|
||||||
|
assertTrue(metrics.assembleMillis() >= 0);
|
||||||
|
assertTrue(metrics.throughputRowsPerSec() > 0, "吞吐必须为正");
|
||||||
|
assertTrue(metrics.peakHeapBytes() > 0, "峰值堆必须为正");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_020_asin_normal_multiple_items() {
|
||||||
|
// 批量场景:图片开/关两种模式 5000 行,结果不丢失、chunk 划分稳定。
|
||||||
|
SimilarAsinPerfFixture fixture = new SimilarAsinPerfFixture(objectMapper);
|
||||||
|
SimilarAsinPerfFixture.EndToEndMetrics withImages =
|
||||||
|
fixture.endToEndBenchmark("uploads/20260829/e2e-img.xlsx", 5000, true, 200);
|
||||||
|
SimilarAsinPerfFixture.EndToEndMetrics textOnly =
|
||||||
|
fixture.endToEndBenchmark("uploads/20260829/e2e-text.xlsx", 5000, false, 200);
|
||||||
|
|
||||||
|
assertEquals(5000, withImages.rowCount());
|
||||||
|
assertEquals(5000, textOnly.rowCount());
|
||||||
|
assertEquals(25, withImages.chunkCount());
|
||||||
|
assertEquals(25, textOnly.chunkCount());
|
||||||
|
assertTrue(withImages.payloadBytes() > textOnly.payloadBytes(), "图片模式 payload 必须大于纯文本");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_020_asin_normal_repeated_operation_is_idempotent() throws Exception {
|
||||||
|
// 重复执行:同一输入两次基准的指标一致,不产生重复行。
|
||||||
|
SimilarAsinPerfFixture fixture = new SimilarAsinPerfFixture(objectMapper);
|
||||||
|
SimilarAsinPerfFixture.CompatResult first =
|
||||||
|
fixture.compatRoundTrip("uploads/20260829/e2e-idem.xlsx", 1000, true);
|
||||||
|
SimilarAsinPerfFixture.CompatResult second =
|
||||||
|
fixture.compatRoundTrip("uploads/20260829/e2e-idem.xlsx", 1000, true);
|
||||||
|
|
||||||
|
assertEquals(1000, first.rowCount());
|
||||||
|
assertEquals(1000, first.recoveredCount(), "往返恢复全量行");
|
||||||
|
assertTrue(first.fieldStable(), "字段必须稳定");
|
||||||
|
assertEquals(first.rowCount(), second.rowCount());
|
||||||
|
assertEquals(first.recoveredCount(), second.recoveredCount());
|
||||||
|
assertEquals(first.fieldStable(), second.fieldStable());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_020_asin_boundary_empty_input() {
|
||||||
|
// 空输入:0 行基准返回零指标;0 行往返返回空结果,不创建资源。
|
||||||
|
SimilarAsinPerfFixture fixture = new SimilarAsinPerfFixture(objectMapper);
|
||||||
|
SimilarAsinPerfFixture.EndToEndMetrics metrics =
|
||||||
|
fixture.endToEndBenchmark("uploads/20260829/e2e-empty.xlsx", 0, false, 200);
|
||||||
|
assertEquals(0, metrics.rowCount());
|
||||||
|
assertEquals(0, metrics.chunkCount());
|
||||||
|
assertEquals(0, metrics.payloadBytes());
|
||||||
|
|
||||||
|
SimilarAsinPerfFixture.CompatResult compat =
|
||||||
|
fixture.compatRoundTrip("uploads/20260829/e2e-empty.xlsx", 0, false);
|
||||||
|
assertEquals(0, compat.rowCount());
|
||||||
|
assertEquals(0, compat.recoveredCount());
|
||||||
|
assertTrue(compat.fieldStable(), "空结果字段稳定");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_020_asin_boundary_single_item() {
|
||||||
|
// 单元素:1 行基准不依赖批量路径,往返字段一致。
|
||||||
|
SimilarAsinPerfFixture fixture = new SimilarAsinPerfFixture(objectMapper);
|
||||||
|
SimilarAsinPerfFixture.EndToEndMetrics metrics =
|
||||||
|
fixture.endToEndBenchmark("uploads/20260829/e2e-single.xlsx", 1, true, 200);
|
||||||
|
assertEquals(1, metrics.rowCount());
|
||||||
|
assertEquals(1, metrics.chunkCount());
|
||||||
|
|
||||||
|
SimilarAsinPerfFixture.CompatResult compat =
|
||||||
|
fixture.compatRoundTrip("uploads/20260829/e2e-single.xlsx", 1, true);
|
||||||
|
assertEquals(1, compat.recoveredCount());
|
||||||
|
assertTrue(compat.fieldStable());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_020_asin_boundary_limit_and_overflow() {
|
||||||
|
// 上限/超限:超过 MAX_ROWS 拒绝;5000 行基准在预算内完成,不发生无界内存增长。
|
||||||
|
SimilarAsinPerfFixture fixture = new SimilarAsinPerfFixture(objectMapper);
|
||||||
|
IllegalArgumentException overflow = assertThrows(IllegalArgumentException.class,
|
||||||
|
() -> fixture.endToEndBenchmark("uploads/20260829/e2e-over.xlsx",
|
||||||
|
SimilarAsinPerfFixture.MAX_ROWS + 1, false, 200));
|
||||||
|
assertTrue(overflow.getMessage().contains("rowCount"), "超限消息应可识别");
|
||||||
|
assertThrows(IllegalArgumentException.class,
|
||||||
|
() -> fixture.compatRoundTrip("uploads/20260829/e2e-over.xlsx",
|
||||||
|
SimilarAsinPerfFixture.MAX_ROWS + 1, false));
|
||||||
|
|
||||||
|
SimilarAsinPerfFixture.EndToEndMetrics metrics =
|
||||||
|
fixture.endToEndBenchmark("uploads/20260829/e2e-max.xlsx", 5000, true, 200);
|
||||||
|
assertTrue(metrics.assembleMillis() < 15000,
|
||||||
|
"5000 行端到端基准须在预算内完成,实际=" + metrics.assembleMillis() + "ms");
|
||||||
|
assertTrue(metrics.peakHeapBytes() < 1024L * 1024L * 1024L, "峰值堆不得超过 1GB");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_020_asin_invalid_input_rejected() {
|
||||||
|
// 非法参数:null key/非法 chunkSize/非法 GC 行数 → 明确异常与可识别消息。
|
||||||
|
SimilarAsinPerfFixture fixture = new SimilarAsinPerfFixture(objectMapper);
|
||||||
|
IllegalArgumentException nullKey = assertThrows(IllegalArgumentException.class,
|
||||||
|
() -> fixture.endToEndBenchmark(null, 100, false, 200));
|
||||||
|
assertTrue(nullKey.getMessage().contains("sourceFileKey"));
|
||||||
|
assertThrows(IllegalArgumentException.class,
|
||||||
|
() -> fixture.endToEndBenchmark("uploads/20260829/x.xlsx", 100, false, 0));
|
||||||
|
assertThrows(IllegalArgumentException.class,
|
||||||
|
() -> fixture.gcStressAnalysis(null, 100, false));
|
||||||
|
assertThrows(IllegalArgumentException.class,
|
||||||
|
() -> fixture.gcStressAnalysis("uploads/20260829/x.xlsx", -1, false));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_020_asin_dependency_failure_releases_resources() throws Exception {
|
||||||
|
// 依赖失败:序列化失败抛 IllegalStateException 且不产生部分结果;恢复后重试成功;
|
||||||
|
// GC 分析后堆峰值回落(临时对象释放)。
|
||||||
|
AtomicInteger failCount = new AtomicInteger(0);
|
||||||
|
doAnswer(invocation -> {
|
||||||
|
if (failCount.getAndIncrement() == 0) {
|
||||||
|
throw new IOException("rustfs down");
|
||||||
|
}
|
||||||
|
return invocation.callRealMethod();
|
||||||
|
}).when(objectMapper).writeValueAsBytes(any());
|
||||||
|
|
||||||
|
SimilarAsinPerfFixture fixture = new SimilarAsinPerfFixture(objectMapper);
|
||||||
|
assertThrows(IllegalStateException.class,
|
||||||
|
() -> fixture.endToEndBenchmark("uploads/20260829/e2e-fail.xlsx", 100, false, 200));
|
||||||
|
SimilarAsinPerfFixture.EndToEndMetrics recovered =
|
||||||
|
fixture.endToEndBenchmark("uploads/20260829/e2e-fail.xlsx", 100, false, 200);
|
||||||
|
assertEquals(100, recovered.rowCount(), "依赖恢复后重试成功");
|
||||||
|
|
||||||
|
SimilarAsinPerfFixture.GcStressSample gc =
|
||||||
|
fixture.gcStressAnalysis("uploads/20260829/e2e-gc.xlsx", 1000, true);
|
||||||
|
assertNotNull(gc);
|
||||||
|
assertTrue(gc.rounds() >= 1, "GC 分析至少执行一轮");
|
||||||
|
assertTrue(gc.gcCount() >= 0);
|
||||||
|
assertTrue(gc.peakHeapBytes() > 0, "堆峰值必须可采样");
|
||||||
|
assertTrue(gc.peakHeapBytes() < 1024L * 1024L * 1024L, "GC 分析峰值堆不得超过 1GB");
|
||||||
|
}
|
||||||
|
}
|
||||||
+198
@@ -0,0 +1,198 @@
|
|||||||
|
package com.nanri.aiimage.modules.similarasin.util;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.nanri.aiimage.modules.similarasin.model.vo.SimilarAsinParsedRowVo;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Task 1:Similar ASIN 性能基线夹具(1000/5000 行、图片开关、chunk 数与 payload 大小采样)。
|
||||||
|
* 先写测试确认 RED,再实现 SimilarAsinPerfFixture。
|
||||||
|
*/
|
||||||
|
class SimilarAsinPerfFixtureTest {
|
||||||
|
|
||||||
|
private final SimilarAsinPerfFixture fixture = new SimilarAsinPerfFixture(new ObjectMapper());
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_001_payload_chunk_image_normal_default_path() {
|
||||||
|
// 1000 行、带图片开关,默认 chunk 大小
|
||||||
|
List<SimilarAsinParsedRowVo> rows = fixture.generateRows("uploads/20260829/base.xlsx", 1000, true);
|
||||||
|
assertEquals(1000, rows.size());
|
||||||
|
// 行必须包含 url 图片地址
|
||||||
|
assertFalse(rows.get(0).getUrl().isBlank());
|
||||||
|
assertTrue(rows.get(0).getUrl().startsWith("http"));
|
||||||
|
// rowToken 稳定且唯一
|
||||||
|
assertEquals(rows.get(0).getRowToken(), fixture.rowTokenFor(rows.get(0).getSourceFileKey(), rows.get(0).getRowIndex()));
|
||||||
|
assertEquals(1000, rows.stream().map(SimilarAsinParsedRowVo::getRowToken).distinct().count());
|
||||||
|
|
||||||
|
List<List<SimilarAsinParsedRowVo>> chunks = fixture.splitChunks(rows, 200);
|
||||||
|
assertEquals(5, chunks.size());
|
||||||
|
assertEquals(200, chunks.get(0).size());
|
||||||
|
|
||||||
|
SimilarAsinPerfFixture.Metrics metrics = fixture.samplePayload(rows, true, 200);
|
||||||
|
assertEquals(1000, metrics.rowCount());
|
||||||
|
assertEquals(5, metrics.chunkCount());
|
||||||
|
assertTrue(metrics.payloadBytes() > 0, "payload 采样字节数必须大于 0");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_001_payload_chunk_image_normal_multiple_items() {
|
||||||
|
// 5000 行批量场景:顺序稳定、chunk 数正确、结果不丢失
|
||||||
|
List<SimilarAsinParsedRowVo> rows = fixture.generateRows("uploads/20260829/multi.xlsx", 5000, false);
|
||||||
|
assertEquals(5000, rows.size());
|
||||||
|
assertTrue(rows.get(0).getUrl().isBlank(), "图片开关关闭时 url 必须为空");
|
||||||
|
|
||||||
|
List<List<SimilarAsinParsedRowVo>> chunks = fixture.splitChunks(rows, 200);
|
||||||
|
assertEquals(25, chunks.size());
|
||||||
|
// 顺序稳定:拼接后与原始一致
|
||||||
|
List<SimilarAsinParsedRowVo> restored = chunks.stream().flatMap(List::stream).toList();
|
||||||
|
assertEquals(rows.size(), restored.size());
|
||||||
|
for (int i = 0; i < rows.size(); i++) {
|
||||||
|
assertEquals(rows.get(i).getRowToken(), restored.get(i).getRowToken());
|
||||||
|
}
|
||||||
|
// 所有行 rowIndex 递增
|
||||||
|
for (int i = 0; i < rows.size(); i++) {
|
||||||
|
assertEquals(i + 1, rows.get(i).getRowIndex());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_001_payload_chunk_image_normal_repeated_operation_is_idempotent() {
|
||||||
|
List<SimilarAsinParsedRowVo> first = fixture.generateRows("uploads/20260829/idem.xlsx", 1000, true);
|
||||||
|
List<SimilarAsinParsedRowVo> second = fixture.generateRows("uploads/20260829/idem.xlsx", 1000, true);
|
||||||
|
// 同一输入重复生成:token 完全一致,不产生重复差异
|
||||||
|
assertEquals(first.size(), second.size());
|
||||||
|
for (int i = 0; i < first.size(); i++) {
|
||||||
|
assertEquals(first.get(i).getRowToken(), second.get(i).getRowToken());
|
||||||
|
assertEquals(first.get(i).getAsin(), second.get(i).getAsin());
|
||||||
|
}
|
||||||
|
// splitChunks 幂等:两次划分 chunk 数一致
|
||||||
|
assertEquals(fixture.splitChunks(first, 200).size(), fixture.splitChunks(second, 200).size());
|
||||||
|
// 采样指标幂等
|
||||||
|
SimilarAsinPerfFixture.Metrics m1 = fixture.samplePayload(first, true, 200);
|
||||||
|
SimilarAsinPerfFixture.Metrics m2 = fixture.samplePayload(second, true, 200);
|
||||||
|
assertEquals(m1.payloadBytes(), m2.payloadBytes());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_001_payload_chunk_image_boundary_empty_input() {
|
||||||
|
List<SimilarAsinParsedRowVo> rows = fixture.generateRows("uploads/20260829/empty.xlsx", 0, true);
|
||||||
|
assertNotNull(rows);
|
||||||
|
assertEquals(0, rows.size());
|
||||||
|
// 空行 splitChunks 返回空,不产生无效 chunk
|
||||||
|
assertEquals(0, fixture.splitChunks(rows, 200).size());
|
||||||
|
// 空行采样:行数 0、chunk 0、payload 字节数为 0(不创建任何载荷)
|
||||||
|
SimilarAsinPerfFixture.Metrics metrics = fixture.samplePayload(rows, true, 200);
|
||||||
|
assertEquals(0, metrics.rowCount());
|
||||||
|
assertEquals(0, metrics.chunkCount());
|
||||||
|
assertEquals(0, metrics.payloadBytes());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_001_payload_chunk_image_boundary_single_item() {
|
||||||
|
// 单行:不依赖批量路径且结果正确
|
||||||
|
List<SimilarAsinParsedRowVo> rows = fixture.generateRows("uploads/20260829/single.xlsx", 1, true);
|
||||||
|
assertEquals(1, rows.size());
|
||||||
|
assertEquals(1, rows.get(0).getRowIndex());
|
||||||
|
assertEquals(1, fixture.splitChunks(rows, 200).size());
|
||||||
|
// 单行 chunk 划分后仍只含 1 行
|
||||||
|
List<List<SimilarAsinParsedRowVo>> chunks = fixture.splitChunks(rows, 200);
|
||||||
|
assertEquals(1, chunks.get(0).size());
|
||||||
|
SimilarAsinPerfFixture.Metrics metrics = fixture.samplePayload(rows, true, 200);
|
||||||
|
assertEquals(1, metrics.rowCount());
|
||||||
|
assertEquals(1, metrics.chunkCount());
|
||||||
|
assertTrue(metrics.payloadBytes() > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_001_payload_chunk_image_boundary_limit_and_overflow() {
|
||||||
|
// 超过最大行数(5000)时拒绝,不发生无界内存增长
|
||||||
|
assertThrows(IllegalArgumentException.class, () -> fixture.generateRows("uploads/20260829/overflow.xlsx", 5001, true));
|
||||||
|
// 达到最大允许值 5000 时允许
|
||||||
|
assertEquals(5000, fixture.generateRows("uploads/20260829/max.xlsx", 5000, true).size());
|
||||||
|
// chunkSize 非法值拒绝
|
||||||
|
assertThrows(IllegalArgumentException.class, () -> fixture.splitChunks(fixture.generateRows("uploads/20260829/a.xlsx", 100, true), 0));
|
||||||
|
assertThrows(IllegalArgumentException.class, () -> fixture.splitChunks(fixture.generateRows("uploads/20260829/b.xlsx", 100, true), -1));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_001_payload_chunk_image_invalid_input_rejected() {
|
||||||
|
// null 文件 key 拒绝
|
||||||
|
assertThrows(IllegalArgumentException.class, () -> fixture.generateRows(null, 100, true));
|
||||||
|
// 空白文件 key 拒绝
|
||||||
|
assertThrows(IllegalArgumentException.class, () -> fixture.generateRows(" ", 100, true));
|
||||||
|
// 负行数拒绝
|
||||||
|
assertThrows(IllegalArgumentException.class, () -> fixture.generateRows("uploads/20260829/neg.xlsx", -1, true));
|
||||||
|
// null 行集合分块拒绝
|
||||||
|
assertThrows(IllegalArgumentException.class, () -> fixture.splitChunks(null, 200));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_001_payload_chunk_image_dependency_failure_releases_resources() {
|
||||||
|
// 采样时序列化器失败(mock 抛异常):错误可恢复,不产生部分结果
|
||||||
|
ObjectMapper broken = new ObjectMapper() {
|
||||||
|
@Override
|
||||||
|
public String writeValueAsString(Object value) {
|
||||||
|
throw new IllegalStateException("serializer down");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
SimilarAsinPerfFixture failingFixture = new SimilarAsinPerfFixture(broken);
|
||||||
|
List<SimilarAsinParsedRowVo> rows = failingFixture.generateRows("uploads/20260829/fail.xlsx", 1000, true);
|
||||||
|
assertThrows(IllegalStateException.class, () -> failingFixture.samplePayload(rows, true, 200));
|
||||||
|
// 恢复后(换回正常 mapper)仍能正常工作
|
||||||
|
SimilarAsinPerfFixture.Metrics recovered = fixture.samplePayload(fixture.generateRows("uploads/20260829/recover.xlsx", 1000, true), true, 200);
|
||||||
|
assertTrue(recovered.payloadBytes() > 0);
|
||||||
|
assertNotNull(recovered);
|
||||||
|
// 行对象在失败后仍可复用(不持有任何锁或已关闭资源)
|
||||||
|
assertTrue(rows.get(0).getAsin().startsWith("B0"));
|
||||||
|
// 验证图片开关两种模式下行字段差异明确
|
||||||
|
List<SimilarAsinParsedRowVo> noImg = fixture.generateRows("uploads/20260829/nimg.xlsx", 10, false);
|
||||||
|
assertTrue(noImg.get(0).getUrl().isBlank());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_001_payload_chunk_image_normal_fields_populated() {
|
||||||
|
// 行字段完整性:asin/country/sku/title/values 均填充且稳定
|
||||||
|
List<SimilarAsinParsedRowVo> rows = fixture.generateRows("uploads/20260829/fields.xlsx", 10, false);
|
||||||
|
SimilarAsinParsedRowVo row = rows.get(3);
|
||||||
|
assertTrue(row.getAsin().matches("B0[A-Z0-9]{8}"));
|
||||||
|
assertFalse(row.getCountry().isBlank());
|
||||||
|
assertFalse(row.getSku().isBlank());
|
||||||
|
assertFalse(row.getTitle().isBlank());
|
||||||
|
assertNotNull(row.getValues());
|
||||||
|
assertFalse(row.getValues().isEmpty());
|
||||||
|
assertTrue(row.getValues().containsKey("asin"));
|
||||||
|
assertTrue(row.getValues().containsKey("国家"));
|
||||||
|
// 行号与 sourceId 关联正确
|
||||||
|
assertEquals("4", row.getSourceId());
|
||||||
|
assertEquals(4, row.getRowIndex());
|
||||||
|
// values 是独立副本,修改不影响后续生成
|
||||||
|
row.getValues().put("价格", "999");
|
||||||
|
List<SimilarAsinParsedRowVo> again = fixture.generateRows("uploads/20260829/fields.xlsx", 10, false);
|
||||||
|
assertTrue(!again.get(3).getValues().getOrDefault("价格", "").equals("999"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_001_payload_chunk_image_boundary_chunk_size_edge() {
|
||||||
|
// chunk 边界:行数恰好整除 / 有余数 / 单 chunk 放不下
|
||||||
|
List<SimilarAsinParsedRowVo> rows = fixture.generateRows("uploads/20260829/edge.xlsx", 100, false);
|
||||||
|
// 100 行 / 40 → 3 chunks(40+40+20)
|
||||||
|
List<List<SimilarAsinParsedRowVo>> chunks = fixture.splitChunks(rows, 40);
|
||||||
|
assertEquals(3, chunks.size());
|
||||||
|
assertEquals(40, chunks.get(0).size());
|
||||||
|
assertEquals(20, chunks.get(2).size());
|
||||||
|
// chunkSize 大于总行数 → 单 chunk
|
||||||
|
assertEquals(1, fixture.splitChunks(rows, 500).size());
|
||||||
|
// chunkSize 恰好等于行数 → 单 chunk 全量
|
||||||
|
assertEquals(1, fixture.splitChunks(rows, 100).size());
|
||||||
|
assertEquals(100, fixture.splitChunks(rows, 100).get(0).size());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""检查 app_client 的 Step 2/后续开发任务是否全部完成。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent
|
||||||
|
PROGRESS_PATH = ROOT / "progress.json"
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
if not PROGRESS_PATH.is_file():
|
||||||
|
print("progress.json not found", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = json.loads(PROGRESS_PATH.read_text(encoding="utf-8"))
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"invalid progress.json: {exc}", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
total = data.get("total_tasks")
|
||||||
|
tasks = data.get("tasks")
|
||||||
|
if not isinstance(total, int) or total <= 0 or not isinstance(tasks, list):
|
||||||
|
print("invalid progress shape", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
if len(tasks) != total:
|
||||||
|
print(f"task count mismatch: total_tasks={total}, tasks={len(tasks)}", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
ids = [task.get("id") for task in tasks if isinstance(task, dict)]
|
||||||
|
if ids != list(range(1, total + 1)):
|
||||||
|
print("task ids are not contiguous from 1", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
pending = [task for task in tasks if task.get("status") != "done"]
|
||||||
|
if pending:
|
||||||
|
print(f"pending tasks: {len(pending)}/{total}")
|
||||||
|
print("all tasks done: false")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
print(f"all tasks done: true ({total})")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
+710
@@ -0,0 +1,710 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"total_tasks": 100,
|
||||||
|
"completed": 38,
|
||||||
|
"rounds": 38,
|
||||||
|
"started_at": "2026-08-29T14:10:48+08:00",
|
||||||
|
"updated_at": "2026-08-29T20:11:20.156661+08:00",
|
||||||
|
"tasks": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"title": "建立 Similar ASIN 性能基线夹具:1000/5000 行、图片开关、chunk 数与 payload 大小采样",
|
||||||
|
"module": "similarasin",
|
||||||
|
"dependency": "无",
|
||||||
|
"status": "done"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"title": "将解析载荷改为单一规范行集合,消除 items/groups/allItems 重复数据结构",
|
||||||
|
"module": "similarasin",
|
||||||
|
"dependency": "1",
|
||||||
|
"status": "done"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 3,
|
||||||
|
"title": "保留旧 payload 读取兼容逻辑,并验证新旧结构均可恢复全量行",
|
||||||
|
"module": "similarasin",
|
||||||
|
"dependency": "2",
|
||||||
|
"status": "done"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 4,
|
||||||
|
"title": "解析接口改为只返回固定数量预览行,完整行仅保存在后端任务载荷",
|
||||||
|
"module": "similarasin",
|
||||||
|
"dependency": "3",
|
||||||
|
"status": "done"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 5,
|
||||||
|
"title": "为预览行数量增加配置边界、空文件和超限输入校验",
|
||||||
|
"module": "similarasin",
|
||||||
|
"dependency": "4",
|
||||||
|
"status": "done"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 6,
|
||||||
|
"title": "将分组数据改为索引/范围引用,避免 groups 嵌套复制完整行对象",
|
||||||
|
"module": "similarasin",
|
||||||
|
"dependency": "5",
|
||||||
|
"status": "done"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 7,
|
||||||
|
"title": "限制单文件大小、最大行数和最大字段长度,防止解析任务无界增长",
|
||||||
|
"module": "similarasin",
|
||||||
|
"dependency": "6",
|
||||||
|
"status": "done"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 8,
|
||||||
|
"title": "将 WorkbookFactory 输入解析改为受控读取,并验证超大 Excel 的失败提示",
|
||||||
|
"module": "similarasin",
|
||||||
|
"dependency": "7",
|
||||||
|
"status": "done"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 9,
|
||||||
|
"title": "将 chunk 查询从单行分页改为批量 keyset 分页,保持低内存读取",
|
||||||
|
"module": "similarasin",
|
||||||
|
"dependency": "8",
|
||||||
|
"status": "done"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 10,
|
||||||
|
"title": "为 chunk 结果建立按 row key 的批量索引,消除跨 chunk 线性扫描",
|
||||||
|
"module": "similarasin",
|
||||||
|
"dependency": "9",
|
||||||
|
"status": "done"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 11,
|
||||||
|
"title": "将 Coze 结果合并的重复检测从 O(n²) 改为 HashSet/稳定 row key",
|
||||||
|
"module": "similarasin",
|
||||||
|
"dependency": "10",
|
||||||
|
"status": "done"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 12,
|
||||||
|
"title": "扩展 Coze 结果缓冲覆盖范围,减少频繁读写完整 chunk payload",
|
||||||
|
"module": "similarasin",
|
||||||
|
"dependency": "11",
|
||||||
|
"status": "done"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 13,
|
||||||
|
"title": "为 chunk 合并增加单次最大行数与 payload 字节上限",
|
||||||
|
"module": "similarasin",
|
||||||
|
"dependency": "12",
|
||||||
|
"status": "done"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 14,
|
||||||
|
"title": "图片 DB cache 改为批量读取缩略图,并只更新实际命中的 last_used_at",
|
||||||
|
"module": "similarasin",
|
||||||
|
"dependency": "13",
|
||||||
|
"status": "done"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 15,
|
||||||
|
"title": "将图片缓存访问时间更新改为异步批量刷新,减少逐图 UPDATE",
|
||||||
|
"module": "similarasin",
|
||||||
|
"dependency": "14",
|
||||||
|
"status": "done"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 16,
|
||||||
|
"title": "图片预取改为短预算 best-effort,超时后直接回退 URL",
|
||||||
|
"module": "similarasin",
|
||||||
|
"dependency": "15",
|
||||||
|
"status": "done"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 17,
|
||||||
|
"title": "优化图片解码采样、像素上限和 JPEG 质量搜索,降低 CPU 与堆峰值",
|
||||||
|
"module": "similarasin",
|
||||||
|
"dependency": "16",
|
||||||
|
"status": "done"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 18,
|
||||||
|
"title": "统一图片 spool 生命周期,确保超时、取消和异常路径删除临时文件",
|
||||||
|
"module": "similarasin",
|
||||||
|
"dependency": "17",
|
||||||
|
"status": "done"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 19,
|
||||||
|
"title": "将 Coze 请求/响应及 Python 回传日志改为采样、截断和 DEBUG 级别",
|
||||||
|
"module": "similarasin",
|
||||||
|
"dependency": "18",
|
||||||
|
"status": "done"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 20,
|
||||||
|
"title": "完成 Similar ASIN 端到端压测、JFR/GC 分析与结果文件兼容回归",
|
||||||
|
"module": "similarasin",
|
||||||
|
"dependency": "19",
|
||||||
|
"status": "done"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 21,
|
||||||
|
"title": "建立店铺抓取性能基线:单店铺 1k/5k 行、多国家、图片成功/失败场景",
|
||||||
|
"module": "shopdatacrawl",
|
||||||
|
"dependency": "无",
|
||||||
|
"status": "done"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 22,
|
||||||
|
"title": "将店铺 Excel 图片缓存替换为有界字节缓存",
|
||||||
|
"module": "shopdatacrawl",
|
||||||
|
"dependency": "21",
|
||||||
|
"status": "done"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 23,
|
||||||
|
"title": "图片嵌入成功后立即释放外部缩略图字节副本",
|
||||||
|
"module": "shopdatacrawl",
|
||||||
|
"dependency": "22",
|
||||||
|
"status": "done"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 24,
|
||||||
|
"title": "为店铺图片预取增加任务级数量、字节和超时上限",
|
||||||
|
"module": "shopdatacrawl",
|
||||||
|
"dependency": "23",
|
||||||
|
"status": "done"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 25,
|
||||||
|
"title": "评估并实现店铺结果 workbook 的 SXSSF 或 spool 化写入路径",
|
||||||
|
"module": "shopdatacrawl",
|
||||||
|
"dependency": "24",
|
||||||
|
"status": "done"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 26,
|
||||||
|
"title": "为模板 workbook 增加大行数下的样式、图片和工作表兼容测试",
|
||||||
|
"module": "shopdatacrawl",
|
||||||
|
"dependency": "25",
|
||||||
|
"status": "done"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 27,
|
||||||
|
"title": "将 chunk 接收改为原子插入/幂等 upsert,减少先查后插",
|
||||||
|
"module": "shopdatacrawl",
|
||||||
|
"dependency": "26",
|
||||||
|
"status": "done"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 28,
|
||||||
|
"title": "以 scope 计数器替代每个 chunk 的 COUNT(*) 完整统计",
|
||||||
|
"module": "shopdatacrawl",
|
||||||
|
"dependency": "27",
|
||||||
|
"status": "done"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 29,
|
||||||
|
"title": "合并 scope 状态查询与更新,减少单 chunk 数据库往返",
|
||||||
|
"module": "shopdatacrawl",
|
||||||
|
"dependency": "28",
|
||||||
|
"status": "done"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 30,
|
||||||
|
"title": "为国家结果行建立稳定去重键,替换线性重复扫描",
|
||||||
|
"module": "shopdatacrawl",
|
||||||
|
"dependency": "29",
|
||||||
|
"status": "done"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 31,
|
||||||
|
"title": "将任务快照改为轻量进度字段,避免每次写入完整结果 JSON",
|
||||||
|
"module": "shopdatacrawl",
|
||||||
|
"dependency": "30",
|
||||||
|
"status": "done"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 32,
|
||||||
|
"title": "将 task entity 本地缓存替换为有容量和过期回收的实现",
|
||||||
|
"module": "shopdatacrawl",
|
||||||
|
"dependency": "31",
|
||||||
|
"status": "done"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 33,
|
||||||
|
"title": "将店铺源文件 key 映射改为确定路径,取消临时目录递归扫描",
|
||||||
|
"module": "shopdatacrawl",
|
||||||
|
"dependency": "32",
|
||||||
|
"status": "done"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 34,
|
||||||
|
"title": "将 ownerInstanceId 从 JSON 查询迁移到显式列并补充索引",
|
||||||
|
"module": "shopdatacrawl",
|
||||||
|
"dependency": "33",
|
||||||
|
"status": "done"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 35,
|
||||||
|
"title": "将每日累计文件改为数据层增量模型,避免每次下载并重写完整 XLSX",
|
||||||
|
"module": "shopdatacrawl",
|
||||||
|
"dependency": "34",
|
||||||
|
"status": "done"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 36,
|
||||||
|
"title": "为每日累计文件引入版本号/CAS,缩短店铺级锁的持有时间",
|
||||||
|
"module": "shopdatacrawl",
|
||||||
|
"dependency": "35",
|
||||||
|
"status": "done"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 37,
|
||||||
|
"title": "拆分每日累计文件组装与任务结果接收,增加异步文件作业状态",
|
||||||
|
"module": "shopdatacrawl",
|
||||||
|
"dependency": "36",
|
||||||
|
"status": "done"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 38,
|
||||||
|
"title": "历史列表与进度查询增加分页、字段裁剪和批量任务加载",
|
||||||
|
"module": "shopdatacrawl",
|
||||||
|
"dependency": "37",
|
||||||
|
"status": "done"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 39,
|
||||||
|
"title": "补充删除、超时、重复回传和累计文件失败的资源清理测试",
|
||||||
|
"module": "shopdatacrawl",
|
||||||
|
"dependency": "38",
|
||||||
|
"status": "pending"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 40,
|
||||||
|
"title": "完成店铺抓取压测并比较内存、CPU、DB QPS、对象存储流量和锁等待",
|
||||||
|
"module": "shopdatacrawl",
|
||||||
|
"dependency": "39",
|
||||||
|
"status": "pending"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 41,
|
||||||
|
"title": "建立 Collect Data 1k/10k 行、多个 chunk 和品牌检测场景基线",
|
||||||
|
"module": "collectdata",
|
||||||
|
"dependency": "无",
|
||||||
|
"status": "pending"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 42,
|
||||||
|
"title": "限制采集解析的文件大小、最大行数和单 chunk 行数",
|
||||||
|
"module": "collectdata",
|
||||||
|
"dependency": "41",
|
||||||
|
"status": "pending"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 43,
|
||||||
|
"title": "将采集源文件查找改为确定路径/索引查询",
|
||||||
|
"module": "collectdata",
|
||||||
|
"dependency": "42",
|
||||||
|
"status": "pending"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 44,
|
||||||
|
"title": "保留原始 chunk payload 的同时,减少逐行 extra JSON 的重复序列化",
|
||||||
|
"module": "collectdata",
|
||||||
|
"dependency": "43",
|
||||||
|
"status": "pending"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 45,
|
||||||
|
"title": "将 ASIN 去重查询与无效品牌查询统一为批量集合查询",
|
||||||
|
"module": "collectdata",
|
||||||
|
"dependency": "44",
|
||||||
|
"status": "pending"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 46,
|
||||||
|
"title": "跳过空品牌批次的无效远程品牌检查请求",
|
||||||
|
"module": "collectdata",
|
||||||
|
"dependency": "45",
|
||||||
|
"status": "pending"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 47,
|
||||||
|
"title": "为品牌检查结果增加任务内短期缓存,避免同品牌重复远程调用",
|
||||||
|
"module": "collectdata",
|
||||||
|
"dependency": "46",
|
||||||
|
"status": "pending"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 48,
|
||||||
|
"title": "将 invalid ASIN 记录改为批量 INSERT IGNORE/upsert",
|
||||||
|
"module": "collectdata",
|
||||||
|
"dependency": "47",
|
||||||
|
"status": "pending"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 49,
|
||||||
|
"title": "将结果明细从逐行 RustFS 对象改为 chunk 级 payload 存储",
|
||||||
|
"module": "collectdata",
|
||||||
|
"dependency": "48",
|
||||||
|
"status": "pending"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 50,
|
||||||
|
"title": "为结果明细设计批量 upsert mapper 与幂等唯一键",
|
||||||
|
"module": "collectdata",
|
||||||
|
"dependency": "49",
|
||||||
|
"status": "pending"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 51,
|
||||||
|
"title": "将 accepted 行的序列化和 hash 计算改为批量处理",
|
||||||
|
"module": "collectdata",
|
||||||
|
"dependency": "50",
|
||||||
|
"status": "pending"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 52,
|
||||||
|
"title": "生成结果文件时按 chunk 一次读取,取消逐行对象读取",
|
||||||
|
"module": "collectdata",
|
||||||
|
"dependency": "51",
|
||||||
|
"status": "pending"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 53,
|
||||||
|
"title": "将 rawRows 与 finalRows 的内存生命周期分段,避免同时长期驻留",
|
||||||
|
"module": "collectdata",
|
||||||
|
"dependency": "52",
|
||||||
|
"status": "pending"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 54,
|
||||||
|
"title": "将 finalRowCount 从每个 chunk COUNT(*) 改为任务内增量计数",
|
||||||
|
"module": "collectdata",
|
||||||
|
"dependency": "53",
|
||||||
|
"status": "pending"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 55,
|
||||||
|
"title": "将进度统计更新改为节流/合并写,减少高频 task UPDATE",
|
||||||
|
"module": "collectdata",
|
||||||
|
"dependency": "54",
|
||||||
|
"status": "pending"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 56,
|
||||||
|
"title": "为采集结果文件增加流式写入失败后的临时文件清理",
|
||||||
|
"module": "collectdata",
|
||||||
|
"dependency": "55",
|
||||||
|
"status": "pending"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 57,
|
||||||
|
"title": "为采集结果对象增加数据库删除与物理对象删除的一致性处理",
|
||||||
|
"module": "collectdata",
|
||||||
|
"dependency": "56",
|
||||||
|
"status": "pending"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 58,
|
||||||
|
"title": "补充外部品牌服务不可用、RustFS 超时和重复 chunk 的降级测试",
|
||||||
|
"module": "collectdata",
|
||||||
|
"dependency": "57",
|
||||||
|
"status": "pending"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 59,
|
||||||
|
"title": "完成采集模块数据库索引、批量 SQL 和对象存储调用次数验证",
|
||||||
|
"module": "collectdata",
|
||||||
|
"dependency": "58",
|
||||||
|
"status": "pending"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 60,
|
||||||
|
"title": "完成采集模块 10k 行压测并验收结果完整性、内存和吞吐",
|
||||||
|
"module": "collectdata",
|
||||||
|
"dependency": "59",
|
||||||
|
"status": "pending"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 61,
|
||||||
|
"title": "建立共享任务链路资源指标基线:线程、连接、队列、GC、Redis、RustFS 和 DB",
|
||||||
|
"module": "shared",
|
||||||
|
"dependency": "无",
|
||||||
|
"status": "pending"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 62,
|
||||||
|
"title": "为本地任务实体缓存增加最大条目数、TTL 和定时清理",
|
||||||
|
"module": "shared",
|
||||||
|
"dependency": "61",
|
||||||
|
"status": "pending"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 63,
|
||||||
|
"title": "为前端/后端进度快照增加写入去重和最小更新间隔",
|
||||||
|
"module": "shared",
|
||||||
|
"dependency": "62",
|
||||||
|
"status": "pending"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 64,
|
||||||
|
"title": "将 transient payload 压缩改为直接 gzip 二进制流上传",
|
||||||
|
"module": "shared",
|
||||||
|
"dependency": "63",
|
||||||
|
"status": "pending"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 65,
|
||||||
|
"title": "为 transient payload 读取增加流式解压和解压后字节上限",
|
||||||
|
"module": "shared",
|
||||||
|
"dependency": "64",
|
||||||
|
"status": "pending"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 66,
|
||||||
|
"title": "限制 RustFS 并发读写与重试的总资源预算,防止多任务叠加爆发",
|
||||||
|
"module": "shared",
|
||||||
|
"dependency": "65",
|
||||||
|
"status": "pending"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 67,
|
||||||
|
"title": "复用 RustFS/MinIO 客户端与 HTTP 连接池,减少每次操作创建客户端",
|
||||||
|
"module": "shared",
|
||||||
|
"dependency": "66",
|
||||||
|
"status": "pending"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 68,
|
||||||
|
"title": "将 payload 引用删除改为批量引用检查与异步物理删除",
|
||||||
|
"module": "shared",
|
||||||
|
"dependency": "67",
|
||||||
|
"status": "pending"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 69,
|
||||||
|
"title": "为数据库删除任务补充 transient payload 指针收集和清理队列",
|
||||||
|
"module": "shared",
|
||||||
|
"dependency": "68",
|
||||||
|
"status": "pending"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 70,
|
||||||
|
"title": "将历史清理改为 keyset 分页、小批量和短事务",
|
||||||
|
"module": "shared",
|
||||||
|
"dependency": "69",
|
||||||
|
"status": "pending"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 71,
|
||||||
|
"title": "清理日志改为数量与 sample ID,禁止输出超长任务 ID 列表",
|
||||||
|
"module": "shared",
|
||||||
|
"dependency": "70",
|
||||||
|
"status": "pending"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 72,
|
||||||
|
"title": "为文件作业实现数据库原子 claim,避免重复派发同一 job",
|
||||||
|
"module": "shared",
|
||||||
|
"dependency": "71",
|
||||||
|
"status": "pending"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 73,
|
||||||
|
"title": "为本地文件作业队列增加 in-flight 去重和队列背压",
|
||||||
|
"module": "shared",
|
||||||
|
"dependency": "72",
|
||||||
|
"status": "pending"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 74,
|
||||||
|
"title": "隔离调度线程池、文件作业线程池和外部 Coze/图片执行池",
|
||||||
|
"module": "shared",
|
||||||
|
"dependency": "73",
|
||||||
|
"status": "pending"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 75,
|
||||||
|
"title": "为虚拟线程任务增加等待队列上限与拒绝/延迟指标",
|
||||||
|
"module": "shared",
|
||||||
|
"dependency": "74",
|
||||||
|
"status": "pending"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 76,
|
||||||
|
"title": "将 JSON owner 查询迁移到显式列并补充任务/状态复合索引",
|
||||||
|
"module": "shared",
|
||||||
|
"dependency": "75",
|
||||||
|
"status": "pending"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 77,
|
||||||
|
"title": "统一 Coze、品牌检查和紫鸟 HTTP 客户端的连接复用策略",
|
||||||
|
"module": "shared",
|
||||||
|
"dependency": "76",
|
||||||
|
"status": "pending"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 78,
|
||||||
|
"title": "为所有外部调用增加耗时、重试、失败率和 payload 字节指标",
|
||||||
|
"module": "shared",
|
||||||
|
"dependency": "77",
|
||||||
|
"status": "pending"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 79,
|
||||||
|
"title": "为对象存储、数据库和队列增加故障注入测试",
|
||||||
|
"module": "shared",
|
||||||
|
"dependency": "78",
|
||||||
|
"status": "pending"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 80,
|
||||||
|
"title": "补充 JVM 堆、直接内存、临时磁盘和连接池容量配置说明",
|
||||||
|
"module": "shared",
|
||||||
|
"dependency": "79",
|
||||||
|
"status": "pending"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 81,
|
||||||
|
"title": "建立前端任务轮询请求量、响应体大小和页面内存基线",
|
||||||
|
"module": "frontend",
|
||||||
|
"dependency": "无",
|
||||||
|
"status": "pending"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 82,
|
||||||
|
"title": "为进度响应 Map 增加 TTL 清理与最大条目数",
|
||||||
|
"module": "frontend",
|
||||||
|
"dependency": "81",
|
||||||
|
"status": "pending"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 83,
|
||||||
|
"title": "统一不同页面的轮询去重、in-flight 合并和终态清理",
|
||||||
|
"module": "frontend",
|
||||||
|
"dependency": "82",
|
||||||
|
"status": "pending"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 84,
|
||||||
|
"title": "优化店铺抓取队列状态合并,消除 historyItems 的线性重复查找",
|
||||||
|
"module": "frontend",
|
||||||
|
"dependency": "83",
|
||||||
|
"status": "pending"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 85,
|
||||||
|
"title": "优化 Similar ASIN 轮询与文件生成等待,避免重复 force 请求",
|
||||||
|
"module": "frontend",
|
||||||
|
"dependency": "84",
|
||||||
|
"status": "pending"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 86,
|
||||||
|
"title": "将隐藏页面轮询间隔、前台恢复和退避策略统一配置化",
|
||||||
|
"module": "frontend",
|
||||||
|
"dependency": "85",
|
||||||
|
"status": "pending"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 87,
|
||||||
|
"title": "限制 localStorage 中任务、快照和队列数据的最大数量/字节数",
|
||||||
|
"module": "frontend",
|
||||||
|
"dependency": "86",
|
||||||
|
"status": "pending"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 88,
|
||||||
|
"title": "解析结果前端只接收预览数据,避免大 payload 进入响应式对象",
|
||||||
|
"module": "frontend",
|
||||||
|
"dependency": "87",
|
||||||
|
"status": "pending"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 89,
|
||||||
|
"title": "清理页面卸载时的所有 timer、请求和临时 URL",
|
||||||
|
"module": "frontend",
|
||||||
|
"dependency": "88",
|
||||||
|
"status": "pending"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 90,
|
||||||
|
"title": "为进度接口增加断网、超时、服务恢复和重复响应测试",
|
||||||
|
"module": "frontend",
|
||||||
|
"dependency": "89",
|
||||||
|
"status": "pending"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 91,
|
||||||
|
"title": "按页面拆分 Element Plus 与公共业务 chunk",
|
||||||
|
"module": "frontend",
|
||||||
|
"dependency": "90",
|
||||||
|
"status": "pending"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 92,
|
||||||
|
"title": "配置 Vite manualChunks 并比较各页面首屏传输大小",
|
||||||
|
"module": "frontend",
|
||||||
|
"dependency": "91",
|
||||||
|
"status": "pending"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 93,
|
||||||
|
"title": "补充 Similar ASIN、店铺抓取和采集数据页面的 E2E 核心路径",
|
||||||
|
"module": "frontend",
|
||||||
|
"dependency": "92",
|
||||||
|
"status": "pending"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 94,
|
||||||
|
"title": "补充移动端与桌面端响应式页面验收截图",
|
||||||
|
"module": "frontend",
|
||||||
|
"dependency": "93",
|
||||||
|
"status": "pending"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 95,
|
||||||
|
"title": "补充深色主题、错误提示、重试和终态刷新验收",
|
||||||
|
"module": "frontend",
|
||||||
|
"dependency": "94",
|
||||||
|
"status": "pending"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 96,
|
||||||
|
"title": "建立 Java/Python/Vue 三端统一的 API 字段兼容检查",
|
||||||
|
"module": "frontend",
|
||||||
|
"dependency": "95",
|
||||||
|
"status": "pending"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 97,
|
||||||
|
"title": "执行 Java 全量测试、Python unittest、Vue 类型检查与构建",
|
||||||
|
"module": "frontend",
|
||||||
|
"dependency": "96",
|
||||||
|
"status": "pending"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 98,
|
||||||
|
"title": "执行真实启动、健康检查、核心请求和外部依赖调用验证",
|
||||||
|
"module": "frontend",
|
||||||
|
"dependency": "97",
|
||||||
|
"status": "pending"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 99,
|
||||||
|
"title": "执行全链路压测并记录 CPU、内存、GC、DB、Redis、RustFS、网络结果",
|
||||||
|
"module": "frontend",
|
||||||
|
"dependency": "98",
|
||||||
|
"status": "pending"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 100,
|
||||||
|
"title": "完成发布前回滚演练、git commit 对应关系检查和交付清单",
|
||||||
|
"module": "frontend",
|
||||||
|
"dependency": "99",
|
||||||
|
"status": "pending"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user