Compare commits

...

69 Commits

Author SHA1 Message Date
huangzd1997 6b76333159 task-39: 店铺 Excel 按国家增量更新,未提交国家保留旧数据
Build Backend JAR / build (push) Has been cancelled
rowsByCountry 由同国行累加改为覆盖语义:按成员顺序累积,
后面的结果覆盖前面同名国家的行(最新任务胜出),本次未提交的
国家从旧累计对象/成员累积中保留,不再被清空。

新增回归测试:两个成员快照(英德法 + 仅德国新行)写入 workbook 后,
英国/法国保留旧行、德国被新任务覆盖、未提交国家保持空表。
2026-08-29 23:46:22 +08:00
huangzd1997 9a8fab2ce4 progress 38 2026-08-29 23:12:06 +08:00
huangzd1997 36bed2bdc6 task-38 2026-08-29 23:11:59 +08:00
huangzd1997 a9a72c7bdf progress 37 2026-08-29 22:36:15 +08:00
huangzd1997 95e314cef4 task-37 2026-08-29 22:36:11 +08:00
huangzd1997 6f6d7be344 progress 36 2026-08-29 22:05:36 +08:00
huangzd1997 93ccb27b40 task-36 2026-08-29 22:05:30 +08:00
huangzd1997 f2546fc152 progress 35 2026-08-29 21:28:32 +08:00
huangzd1997 2f89f795f6 task-35 2026-08-29 21:28:28 +08:00
huangzd1997 add8b057d5 progress 34 2026-08-29 20:44:25 +08:00
huangzd1997 11e19ef4f1 task-34: 将 ownerInstanceId 从 JSON 查询迁移到显式列并补充索引 2026-08-29 20:44:21 +08:00
huangzd1997 790267560a progress 33 2026-08-29 20:19:17 +08:00
huangzd1997 0e4a1c1c35 task-33: 将店铺源文件 key 映射改为确定路径,取消临时目录递归扫描 2026-08-29 20:18:57 +08:00
huangzd1997 76d9558851 progress 32 2026-08-29 20:11:20 +08:00
huangzd1997 2cd5e07248 task-32: 将 task entity 本地缓存替换为有容量和过期回收的实现 2026-08-29 20:11:16 +08:00
huangzd1997 5f7457a07b progress 31 2026-08-29 20:04:26 +08:00
huangzd1997 cb4e10c87f task-31: 将任务快照改为轻量进度字段,避免每次写入完整结果 JSON 2026-08-29 20:04:22 +08:00
huangzd1997 76a51ac290 progress 30 2026-08-29 19:52:14 +08:00
huangzd1997 199345fd11 task-30: 为国家结果行建立稳定去重键,替换线性重复扫描 2026-08-29 19:52:08 +08:00
huangzd1997 6e3b43d591 progress 29 2026-08-29 19:36:34 +08:00
huangzd1997 ec28216984 task-29: 合并 scope 状态查询与更新,减少单 chunk 数据库往返
persistResultScope 复用接收前已加载的 scope,不再重复 selectOne ——
每个分片接收的 scope 查询从 2 次降为 1 次;空分片校验前移至任何
scope 查询之前,非法分片零数据库往返。任务级分布式锁保证预读 scope
在锁内不过期。
2026-08-29 19:36:30 +08:00
huangzd1997 24ea02f150 progress 28 2026-08-29 19:26:03 +08:00
huangzd1997 f1db31f813 task-28: 以 scope 计数器替代每个 chunk 的 COUNT(*) 完整统计
分片接收不再对 chunk 表执行 selectCount 全量统计:新插入分片按 scope 计数器 +1
并钳制到 chunk_total,重复提交(唯一键冲突)保持不变;分片行与 scope 计数器
作为一致性单元,状态写入失败时回滚本次插入的分片行与 payload,重试走全新插入,
避免计数器重复计数或永久欠计。任务级分布式锁串行化同一任务的接收,读改写在锁内完成。
2026-08-29 19:25:59 +08:00
huangzd1997 7f49ef596e progress 27 2026-08-29 19:11:17 +08:00
huangzd1997 23eddde770 task-27: chunk 接收改为原子插入/幂等 upsert,去掉先查后插
persistResultChunk 去除提交前的 findResultChunk 预查,直接 store payload + insert,
唯一索引 uk_task_scope_chunk 兜底幂等:重复提交命中 DuplicateKeyException 后
重查 winner 校验并清理本次重存 payload;空分片(无可处理数据)在落库前拒绝,
不创建 RustFS payload 与分片行。
2026-08-29 19:11:10 +08:00
huangzd1997 0092092dbd progress: task-26 done (completed=26) 2026-08-29 18:55:05 +08:00
huangzd1997 8ea4d2f502 task-26: 模板 workbook 大行数下的样式、图片和工作表兼容测试
writeWorkbook(模板路径)改为返回实际写入行数,与 streaming 路径一致;
新增 1000/5000 行规模兼容测试:模板表头样式保留、同 URL 图片按 pictureIndex 去重复用、
5 工作表顺序名称保持模板语义、失败行兜底 URL 文本不中断整表。
全量测试 556 通过。
2026-08-29 18:55:01 +08:00
huangzd1997 6594e1f5d7 progress: task-25 done (completed=25) 2026-08-29 18:46:44 +08:00
huangzd1997 4e4af7911a task-25: 店铺结果 workbook 的 SXSSF 流式写入路径
评估结论:SXSSF 无法访问/删除模板行,与现有模板重写流程不兼容,
故模板路径保留 XSSFWorkbook,另提供 writeWorkbookStreaming —— 空构造
SXSSFWorkbook + 自建 5 国工作表与表头,行数据按 rowAccessWindow spill 到磁盘,
写入后 dispose() 释放 spill 文件;图片嵌入复用现有链路,失败行兜底 URL 文本。
全量测试 548 通过。
2026-08-29 18:46:40 +08:00
huangzd1997 f816fed4f5 progress: task-24 done (completed=24) 2026-08-29 18:38:41 +08:00
huangzd1997 7eccbc016a task-24: 店铺图片预取增加任务级数量、字节和超时上限
ShopDataCrawlPrefetchBudget 对预取 URL 施加三重预算:数量截断(去重后按序取前 N)、
字节估算门(urlCount×avgBytes 超预算拒绝)、超时钳制(200ms/URL 估算钳到任务上限);
Excel 组装在 prefetch 前应用预算,未预取 URL 由 embed 兜底直接下载。
全量测试 540 通过。
2026-08-29 18:38:37 +08:00
huangzd1997 a9cbd18246 progress: task-23 done (completed=23) 2026-08-29 18:34:05 +08:00
huangzd1997 70d724382d task-23: 图片嵌入成功后立即释放外部缩略图字节副本
BoundedImageCache.release(url) 移除条目并扣减字节计数,byte[] 可被 GC 回收;
embedImage 在 addPicture 成功后立即释放,同一 URL 后续行复用 pictureIndex 不重复嵌入。
全量测试 532 通过。
2026-08-29 18:34:02 +08:00
huangzd1997 4a11fd612e progress: task-22 done (completed=22) 2026-08-29 18:29:29 +08:00
huangzd1997 6d488515ca task-22: 店铺 Excel 图片缓存替换为有界字节缓存
BoundedImageCache(字节预算+条目上限,FIFO 淘汰,超预算单图拒绝)替换
ShopDataCrawlExcelAssemblyService 中的无界 ConcurrentHashMap;单图超预算时
embed 走原有直接下载兜底。全量测试 524 通过。
2026-08-29 18:29:25 +08:00
huangzd1997 064e13c132 progress: task-21 done (completed=21) 2026-08-29 18:24:14 +08:00
huangzd1997 81da7a182e task-21: 建立店铺抓取性能基线夹具(1k/5k 行、多国家、图片成功/失败)
ShopDataCrawlPerfFixture 提供单店铺多国家抓取结果的确定性生成器:
- generateItems:1k/5k 行、1-5 国家、图片成功/失败(前 N 行无缩略图)场景;
- samplePayload:行数/chunk 数/payload 字节/图片成功与失败行数采样。
全量测试 516 通过。
2026-08-29 18:24:10 +08:00
huangzd1997 3d5fd42bfa progress: task-20 done (completed=20) 2026-08-29 18:18:37 +08:00
huangzd1997 29b6353d7e task-20: Similar ASIN 端到端压测、GC 分析与结果文件兼容回归
SimilarAsinPerfFixture 新增三个功能点:
- endToEndBenchmark:生成→分 chunk→序列化,记录行数/chunk 数/payload 字节/耗时/吞吐/峰值堆;
- gcStressAnalysis:多轮生成/序列化/释放循环,采样 GC 计数差与堆峰值;
- compatRoundTrip:payload 序列化往返恢复全量行并校验字段稳定(结果文件兼容回归)。
全量测试 508 通过。
2026-08-29 18:18:24 +08:00
huangzd1997 9b5873bb84 progress: task-19 done (completed=19) 2026-08-29 18:11:28 +08:00
huangzd1997 77ae8b2823 task-19: Coze 请求/响应与 Python 回传日志改采样、截断、DEBUG
- 新增 SimilarAsinLogSupport 纯函数:truncate(2000 前缀+长度后缀,代理对安全)
  与 shouldLog(每 N 次采样,计数 0 恒采样)
- Coze 请求 body 与 submit 响应正文日志降 DEBUG + 截断;
  history 轮询响应降 DEBUG + 每 20 次记一次完整正文,其余只记状态
- Python 回传逐行日志降 DEBUG + 每 20 行采样一行,大任务日志量降到 5%
2026-08-29 18:11:24 +08:00
huangzd1997 07e99a5fa8 progress: task-18 done (completed=18) 2026-08-29 18:02:54 +08:00
huangzd1997 92858acb4d task-18: 统一图片 spool 生命周期,超时/取消/异常路径删除临时文件
- ImageSpool 增加 closed 状态:close 幂等、close 后 put 拒绝且消息可识别,
  杜绝关闭后再写入导致目录删除失败
- 长预算 prefetchToDisk 任务体补 put 前中断检查:deadline/中断取消后不再
  产生新临时文件,close 可完整删除目录(与 best-effort 路径对齐)
- 长预算路径并入测试 hook 探测,与短预算路径行为一致,生命周期可测
2026-08-29 18:02:50 +08:00
huangzd1997 e1217c9c85 progress: task-17 done (completed=17) 2026-08-29 17:55:06 +08:00
huangzd1997 795dd1084c task-17: 解码采样推广全格式、子采样后像素上限、JPEG 质量估算搜索
- sourceSubsampling 通用化:PNG 等非 JPEG 格式也按源长边取 2 的幂子采样,
  reader 忽略采样参数时记录日志并按全量解码像素校验
- 新增 MAX_DECODED_PIXELS(2400^2) 子采样后解码像素上限,源像素超限先拒绝,
  子采样仍超限再升采样因子,解码像素始终受控,36MP 源不再全量解码
- 质量搜索由固定阶梯 {0.75,0.65,0.55} 改为估算(字节比例钳制 [0.45,0.75]),
  最坏编码 9 次降到 6 次,典型 1-2 次即命中,降低 CPU 峰值
2026-08-29 17:55:00 +08:00
huangzd1997 423666ef99 progress: task-16 done (completed=16) 2026-08-29 17:37:25 +08:00
huangzd1997 0e3dbe450f task-16: 图片预取改短预算 best-effort,超时取消在途并回退 URL
prefetchToDiskBestEffort 在 budgetSeconds 内尽力预取,预算耗尽即取消
在途任务并返回未预取数量;缺图单元格由既有 fallback 直接写 URL。
put 前检查中断标志,避免取消后写盘与 spool close 竞态产生残留文件。
预算通过 aiimage.similar-asin.image-prefetch-budget-seconds 配置(默认 60s),
长预算后台预热路径 prefetchToDisk 行为不变。
2026-08-29 17:37:11 +08:00
huangzd1997 f517b7585f progress: task-15 done (completed=15, rounds=15) 2026-08-29 17:24:31 +08:00
huangzd1997 dc69b002db task-15: async batched last_used_at touch refresh 2026-08-29 17:24:25 +08:00
huangzd1997 fb10077557 progress: task-14 done (completed=14, rounds=14) 2026-08-29 17:16:47 +08:00
huangzd1997 d1ea8feb6f task-14: batch read image db cache and touch only actual hits 2026-08-29 17:16:40 +08:00
huangzd1997 4030897d49 progress: task-13 done (completed=13, rounds=13) 2026-08-29 17:05:59 +08:00
huangzd1997 e201b13301 task-13: cap chunk merge row count and payload bytes with orphan fallback 2026-08-29 17:05:49 +08:00
huangzd1997 cbe0ec8bb2 task-12: mark done in progress 2026-08-29 16:46:38 +08:00
huangzd1997 4d91146256 task-12: extend coze result buffering beyond poll path
- bufferCozeRowsOrMerge: unified DONE result persist entry point; buffers to
  transient storage when toggle on, falls back to immediate chunk merge on
  failure (result never lost), no-op on empty input
- submit/retry synchronous immediate DONE results now buffer too (previously
  always merged immediately); submit path persists a DONE scope state row to
  carry the buffer pointer (persistImmediateCozeDoneState)
- poll path drops the batchTotal>1 gate: single-batch tasks also buffer
- CozeCandidate/CozeBatchContext records package-private for test access
2026-08-29 16:46:29 +08:00
huangzd1997 59ee3c8154 progress: task-11 done (completed=11, rounds=11) 2026-08-29 16:17:27 +08:00
huangzd1997 3d085c1605 task-11: Coze 结果合并前按稳定 rowKey HashSet 去重,消除 O(n²) 重复处理 2026-08-29 16:17:12 +08:00
huangzd1997 d64ba3a827 progress: task-10 done (completed=10, rounds=10) 2026-08-29 16:07:49 +08:00
huangzd1997 1ccf4f74ef task-10: chunk 结果建立 rowKey 批量索引,消除跨 chunk 线性扫描
mergeCozeRowsIntoChunk 先 indexRowsByChunkKey 建立 rowKey→chunkKey
索引,assignCozeRowsToChunks 按 O(1) 查找分配行归属,保留原有
命中/fallback/orphan 语义与顺序稳定性,每 chunk 只读一次 payload。
2026-08-29 16:07:45 +08:00
huangzd1997 17a3292c78 progress: task-9 done (completed=9, rounds=9) 2026-08-29 15:52:57 +08:00
huangzd1997 99c0b7dd75 task-9: chunk 查询改为批量 keyset 分页,保持低内存读取
loadChunksKeyset 按 id 递增分批拉取(每批 pageSize),最后按
chunkIndex 升序合并,避免超大任务一次 selectList 全量载入 chunk
元数据。surefire fork 堆显式限制 1536m 防止与主 JVM 叠加挤爆内存。
2026-08-29 15:52:46 +08:00
huangzd1997 3b051bbafe task-8: WorkbookFactory 输入解析改为受控读取,验证超大 Excel 失败提示
解析前用 ZipFile 中央目录探测 xlsx(zip) 条目数与解压总字节(不读压缩
内容),超过 maxWorkbookZipEntries(20000)/maxWorkbookUncompressedBytes
(512MB) 立即拒绝并给出可识别提示;非 zip/损坏文件跳过探测交给
WorkbookFactory 兜底转业务异常。0/负值配置回退默认。新增 8 个测试覆盖
正常路径、多文件、幂等、空输入、单行、解压超限、条目超限、损坏文件、
依赖失败恢复。
2026-08-29 15:11:53 +08:00
huangzd1997 68b7d9a4a4 task-7: 限制单文件大小、最大行数和最大字段长度,防止解析任务无界增长
新增 maxSourceFileBytes(50MB)/maxParseRows(50000)/maxFieldLength(2000)
三个配置项:文件超限在解析入口拒绝(异常消息可识别)、行数超限拒绝、
超长单元格字段截断。0/负值配置回退默认。新增 8 个测试覆盖正常路径、
多文件、幂等、空文件、单行、边界(恰好=上限通过、超限拒绝)、
非法输入(文件超限/字段截断)、依赖失败恢复。
2026-08-29 15:08:14 +08:00
huangzd1997 db6e8f0ce8 task-6: 分组数据改为索引/范围引用,避免 groups 嵌套复制完整行对象
buildParsedGroups 只写 [startIndex, endIndex) 半开区间引用(同 baseId 行在
items 中天然连续),行对象仅存在于 items 一次;resolveAllRows 优先按引用
展开并安全跳过越界/非法区间;响应组仍内嵌预览行兼容前端。旧 payload 内嵌
items 格式继续可读。新增 8 个测试覆盖引用写入、批量、幂等、空输入、单组、
越界 clamp、非法区间、依赖失败恢复。
2026-08-29 15:02:15 +08:00
huangzd1997 16bec1c0fa task-5: 预览行数量改为配置驱动并做边界校验
解析响应预览上限从硬编码常量改为 SimilarAsinProperties.parseResponsePreviewLimit,
0/负值回退默认 100,超过 1000 clamp 到 1000 防止误配导致响应体无界膨胀。
新增 8 个测试覆盖默认路径、自定义值、幂等、空文件、单行、超限 clamp、非法配置回退、依赖失败恢复。
2026-08-29 14:48:01 +08:00
huangzd1997 026aa4e0c3 task-4: 解析接口只返回固定数量预览行,完整行仅保存在后端任务载荷
parseAndCreateTask 响应 items/groups 改为 buildResponsePreviewRows/Groups 裁剪
到 PARSE_RESPONSE_PREVIEW_LIMIT(100) 行:大文件(5000行)响应体不再携带全量行,
前端/队列只依赖统计字段与 taskId,Python 通过 parsed-payload 接口拉全量。
groups 预览副本保留 itemCount 与行级字段,字段完整且顺序稳定。
8 个测试覆盖 150/5000/1/100/101 行、空文件、非法输入、存储失败恢复与临时文件清理。
2026-08-29 14:43:08 +08:00
huangzd1997 9f8bdecc9e task-3: 统一解析载荷全量行恢复逻辑,新旧结构均可恢复
新增 SimilarAsinTaskService.resolveAllRows(payload):优先 items(新规范结构)、
其次 allItems(旧结构)、最后 groups 展开(最旧结构),并收敛 rowCount/
loadAllRowsByBaseId/hydrateParsedPayloadRows/两条 assemble 路径的重复 fallback 链。
旧 payload(含 allItems 或仅 groups)仍可恢复全量行,行数、顺序不丢失。
9 个测试覆盖默认/批量/幂等/空/单行/超限/新旧 JSON 反序列化/组展开顺序。
2026-08-29 14:36:15 +08:00
huangzd1997 8c84ff3394 task-2: 解析载荷改为单一规范行集合,消除 allItems 重复序列化
SimilarAsinParsedPayloadDto.allItems 标记 @JsonInclude(NON_EMPTY) 并仅作反序列化
兼容字段(新写入不再输出);buildParsedPayloadJson 只写 items 一份全量行,
消除每任务双倍行序列化的内存与 payload 体积。读取侧 getAllItems() 消费点保留
以兼容旧载荷(task-3 再统一收敛)。9 个测试覆盖默认/批量/幂等/空/单行/超限/
旧格式吸收/依赖失败恢复。
2026-08-29 14:32:11 +08:00
huangzd1997 2039acdfe6 task-1: 建立 Similar ASIN 性能基线夹具 (1000/5000行、图片开关、chunk与payload采样)
新增 SimilarAsinPerfFixture 确定性夹具:按 sourceFileKey+rowIndex 派生字段,
支持 0/1/1000/5000 行、图片开关两种模式、chunk 划分与 payload 字节采样;
空输入返回空、超限(>5000行/非法chunk/空key)抛 IllegalArgumentException。
含 10 个测试覆盖默认路径/批量/幂等/边界/非法输入/依赖失败恢复。
2026-08-29 14:22:07 +08:00
70 changed files with 16177 additions and 286 deletions
+1 -1
View File
@@ -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 8xlsx(zip) 最大条目数。受控读取在 WorkBookFactory 打开前探测,
* 超过则拒绝,防止 zip bomb / 超大工作簿拖垮内存。
*/
private int maxWorkbookZipEntries = 20000;
/**
* Task 8xlsx(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 兜底;单行本身超过该上限时抛异常拒绝合并。
* 默认 16MB50000 行 × 平均 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;
@@ -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) {
@@ -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")
@@ -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;
} }
@@ -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;
} }
@@ -0,0 +1,13 @@
package com.nanri.aiimage.modules.shopdatacrawl.service;
/**
* Task 36:每日累计文件归档提交阶段的版本 CAS 冲突信号。
* 由提交阶段捕获并触发整次归档重试(释放店铺级锁后按最新状态重新组装),
* 区别于需要调用方直接失败的运行时异常。
*/
class DailyStateConflictException extends RuntimeException {
DailyStateConflictException() {
super("daily file state conflict");
}
}
@@ -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;
@@ -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 25SXSSF 流式写入路径。空构造 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;
@@ -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) {}
} }
@@ -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;
try {
tasks = fileTaskMapper.selectList(new LambdaQueryWrapper<FileTaskEntity>()
.eq(FileTaskEntity::getModuleType, MODULE_TYPE) .eq(FileTaskEntity::getModuleType, MODULE_TYPE)
.eq(FileTaskEntity::getStatus, "RUNNING") .eq(FileTaskEntity::getStatus, "RUNNING")
.apply("JSON_UNQUOTE(JSON_EXTRACT(request_json, '$.ownerInstanceId')) = {0}", currentInstanceId()) .eq(FileTaskEntity::getOwnerInstanceId, currentInstanceId())
.lt(FileTaskEntity::getUpdatedAt, LocalDateTime.now().minusMinutes(minutes)) .lt(FileTaskEntity::getUpdatedAt, LocalDateTime.now().minusMinutes(minutes))
.last("limit 200")); .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 必须 ≥ 1limit 收敛到 [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;
try {
rows = fileResultMapper.selectList(new LambdaQueryWrapper<FileResultEntity>()
.eq(FileResultEntity::getModuleType, MODULE_TYPE) .eq(FileResultEntity::getModuleType, MODULE_TYPE)
.in(FileResultEntity::getTaskId, normalizedTaskIds) .in(FileResultEntity::getTaskId, normalizedTaskIds)
.orderByAsc(FileResultEntity::getId)); .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)) {
if (changed || !snapshotJsonHasCountryRows(task)) {
persistSnapshotJson(task, snapshots); 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 上传在两次取锁之间于锁外执行;
// 提交阶段按 daily_file.version CAS,冲突时释放锁、清理本次上传对象并按最新状态重试(最多 3 次)。
DailyAggregationResult persistedResult = null;
DailyWorkbookArtifact artifact = null;
for (int attempt = 1; attempt <= MAX_DAILY_AGGREGATION_ATTEMPTS; attempt++) {
DailyAggregationPreparation preparation;
TaskDistributedLockService.LockHandle prepareLock = dailyFileService.acquireLock(userId, shopKey);
if (prepareLock == null) {
throw new BusinessException("店铺当天累计文件正在处理中,请稍后重试"); throw new BusinessException("店铺当天累计文件正在处理中,请稍后重试");
} }
DailyAggregationResult persistedResult = null;
try { try {
DailyAggregationPreparation preparation = executeShortTransaction( preparation = executeShortTransaction(
() -> prepareDailyAggregation(userId, shopKeyHash, businessDate, row)); () -> 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 已变化,复用过期的组装结果会把并发写入的行丢在对象外);
// 零行引用对象不重复上传。
if (artifact == null || !artifact.uploaded()) {
artifact = assembleDailyWorkbook(task, snapshot, baseForAttempt,
excelAssemblyService.countRows(List.of(snapshot)));
}
TaskDistributedLockService.LockHandle commitLock = dailyFileService.acquireLock(userId, shopKey);
if (commitLock == null) {
throw new BusinessException("店铺当天累计文件正在处理中,请稍后重试");
}
try {
DailyAggregationPreparation preparationForAttempt = preparation;
DailyWorkbookArtifact artifactForAttempt = artifact;
try { try {
persistedResult = executeShortTransaction(() -> persistDailyAggregation( persistedResult = executeShortTransaction(() -> persistDailyAggregation(
task, row, userId, shopKey, shopKeyHash, businessDate, task, row, userId, shopKey, shopKeyHash, businessDate,
preparation, artifact)); preparationForAttempt, artifactForAttempt, snapshot,
if (persistedResult.discardUploadedObject() && artifact.uploaded()) { preparationForAttempt.dailyFile()));
deleteObjectQuietly(artifact.objectKey()); if (persistedResult.discardUploadedObject() && artifactForAttempt.uploaded()) {
deleteObjectQuietly(artifactForAttempt.objectKey());
} else {
persistedResult.obsoleteObjectKeys().forEach(this::deleteObjectQuietly);
} }
return persistedResult; return persistedResult;
} catch (RuntimeException ex) { } catch (DailyStateConflictException conflictEx) {
// 冲突:本次上传对象作废并释放,按最新状态重新组装再试。
if (artifact.uploaded()) { if (artifact.uploaded()) {
deleteObjectQuietly(artifact.objectKey()); 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; throw ex;
} }
} finally { } finally {
try { commitLock.close();
lock.close();
} finally {
if (persistedResult != null) {
persistedResult.obsoleteObjectKeys().forEach(this::deleteObjectQuietly);
}
} }
} }
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); 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, rowCount); return new DailyWorkbookArtifact(objectKey, outputXlsx.length(), true, filename, rowCount);
}
excelAssemblyService.writeWorkbook(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, addedRowCount);
} 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_idTask 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("");
@@ -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++;
}
}
@@ -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();
}
}
@@ -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 数估算的 deadline200ms/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 200msclamp 到 [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;
}
}
@@ -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 19history 轮询响应正文采样频率(每 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;
}); });
} }
@@ -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<>();
} }
@@ -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<>();
} }
@@ -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 15last_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-11DB 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-11DB 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) {
@@ -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 19Python 回传逐行日志采样频率(每 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 8xlsx(zip) 最大条目数0/负值回退默认 20000
*/
private int resolveMaxWorkbookZipEntries() {
Integer configured = properties.getMaxWorkbookZipEntries();
if (configured == null || configured <= 0) {
return 20000;
}
return configured;
}
/**
* Task 8xlsx(zip) 解压后总字节数上限0/负值回退默认 512MB
*/
private long resolveMaxWorkbookUncompressedBytes() {
Long configured = properties.getMaxWorkbookUncompressedBytes();
if (configured == null || configured <= 0) {
return 512L * 1024L * 1024L;
}
return configured;
}
/**
* Task 8WorkbookFactory 打开前的受控读取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();
} }
if (payload.getItems() != null && !payload.getItems().isEmpty()) {
return payload.getItems().size(); /**
* 从解析载荷统一恢复全量行优先 items新规范结构其次 allItems旧结构
* 最后 groups 展开最旧结构新旧结构均可恢复完整行集合 rowCount
* Coze 候选加载与结果文件组装复用
*/
static List<SimilarAsinParsedRowVo> resolveAllRows(SimilarAsinParsedPayloadDto payload) {
if (payload == null) {
return List.of();
} }
if (payload.getGroups() != null && !payload.getGroups().isEmpty()) { if (containsGroupRefs(payload)) {
return payload.getGroups().stream() // 新格式分组携带索引引用信任引用展开结果越界/非法区间安全跳过
.map(SimilarAsinParsedGroupVo::getItems) return expandGroupRefs(payload);
}
List<SimilarAsinParsedRowVo> rows = payload.getItems();
if (rows == null || rows.isEmpty()) {
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 11Coze 结果合并前按稳定 rowKey 一次性去重
* 优先 rowToken缺失时回退归一化的 legacy keyid::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未命中且有有效 fallbackchunkScopeHash + 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 12DONE 结果统一走 bufferCozeRowsOrMerge P0-3 poll batchTotal>1 缓冲
// finalize 阶段一次性合并到 chunk失败 batch / batch 任务保留原立即 merge 路径 // 现单 batchsubmit/retry 同步 immediate 结果也缓冲缓冲失败回退立即 merge
boolean buffered = false; // flush finalize/assemble 前一次性合并到 chunk结果不丢失
if (failureMessage.isBlank() // 失败行markRowsFailed保持立即 merge 语义不变
&& isCozeResultBufferEnabled()
&& context.batchTotal() != null && context.batchTotal() > 1
&& cozeRows != null && !cozeRows.isEmpty()) {
CozeBatchContext bufferedContext = bufferCozeResultForFlush(state, context, cozeRows);
if (bufferedContext != null
&& bufferedContext.resultPayloadPointer() != null
&& !bufferedContext.resultPayloadPointer().isBlank()) {
context = bufferedContext;
buffered = true;
}
}
if (!buffered) {
FileTaskEntity task = taskForPoll(state.getTaskId());
if (task != null) {
Map<String, List<SimilarAsinParsedRowVo>> allRowsByBaseId = allRowsByBaseIdForPoll(task);
try {
mergeCozeRowsIntoChunk(task, context.chunkScopeHash(), context.chunkIndex(), cozeRows, allRowsByBaseId);
} catch (Exception mergeEx) {
if (failureMessage.isBlank()) { if (failureMessage.isBlank()) {
throw mergeEx; if (cozeRows != null && !cozeRows.isEmpty()) {
FileTaskEntity pollTask = taskForPoll(state.getTaskId());
bufferCozeRowsOrMerge(state, context, cozeRows, pollTask,
pollTask == null ? Map.of() : allRowsByBaseIdForPoll(pollTask));
} }
} else {
FileTaskEntity pollTask = taskForPoll(state.getTaskId());
if (pollTask != null) {
Map<String, List<SimilarAsinParsedRowVo>> allRowsByBaseId = allRowsByBaseIdForPoll(pollTask);
try {
mergeCozeRowsIntoChunk(pollTask, context.chunkScopeHash(), context.chunkIndex(), cozeRows, allRowsByBaseId);
} catch (Exception 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 12retry 同步 immediate DONE 结果也走缓冲原立即 merge
// 缓冲失败回退立即 mergeflush 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> uniqueRows = dedupeRowsByRowKey(cozeRows);
List<SimilarAsinResultRowDto> expandedAll = new ArrayList<>();
for (SimilarAsinResultRowDto resultRow : uniqueRows) {
expandedAll.addAll(expandRows(List.of(resultRow), allRowsByBaseId));
}
// Task 10先建 rowKey chunkKey 批量索引把逐 chunk 线性扫描降为 O(1) 查找
Map<String, String> rowKeyIndex = indexRowsByChunkKey(rowsByChunk);
List<SimilarAsinResultRowDto> orphanRows = new ArrayList<>(); List<SimilarAsinResultRowDto> orphanRows = new ArrayList<>();
for (SimilarAsinResultRowDto resultRow : cozeRows) { Map<String, Map<String, SimilarAsinResultRowDto>> mergeRowsByChunk =
for (SimilarAsinResultRowDto expandedRow : expandRows(List.of(resultRow), allRowsByBaseId)) { assignCozeRowsToChunks(rowsByChunk, expandedAll, rowKeyIndex, chunkScopeHash, chunkIndex, orphanRows);
String rowKey = rowKey(expandedRow);
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());
}
}
}
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 storagepointer 存进 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
* saveCozeBatchStateSUBMITTED 异步不同 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,7 +4489,7 @@ 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) {
} }
@@ -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,7 +6987,7 @@ 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,
@@ -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 17JPEG 质量估算下限。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.76MPRGB 解码 ≈ 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,
@@ -358,12 +484,16 @@ public class SimilarAsinImageEmbedder {
return completion.submit(() -> { return completion.submit(() -> {
try { try {
ResizedImage thumb = readLocalCachedThumb(url); ResizedImage thumb = readLocalCachedThumb(url);
if (thumb == null) {
thumb = fetchAndResizeDirectForTest(url);
if (thumb == null) { if (thumb == null) {
thumb = fetchAndResizeDirect(url); thumb = fetchAndResizeDirect(url);
downloaded.incrementAndGet(); 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)) {
int subsampling = jpegSourceSubsampling(sourceWidth, sourceHeight);
if (subsampling > 1) { if (subsampling > 1) {
if (isJpegReader(reader)) {
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;
@@ -0,0 +1,48 @@
package com.nanri.aiimage.modules.similarasin.util;
/**
* Task 19Coze 请求/响应及 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;
}
}
@@ -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();
}
}
@@ -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();
@@ -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`;
@@ -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);
}
}
}
@@ -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;
}
}
@@ -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
* 新结果归档时把该结果的快照 JSONrow_payloadV93 迁移新增列写入 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));
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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);
@@ -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;
}
}
@@ -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();
}
}
@@ -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 超过最大值收敛到 100page 越界返回空页批量进度查询超过 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);
}
}
@@ -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|recommendedOfferLinkedHashSet 一次遍历去重
* 保留首现顺序与语义等价性去重结果与 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;
}
}
@@ -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/22/2scope 计数器 12齐集完成任务
// 全程不执行 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/33/31/3计数器按到达分片计数 123
// 结果按 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;
}
}
@@ -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 次提交后任务仍 RUNNINGtryFinalizeTask 补偿探针再查 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/33/31/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 查询两次提交后任务均 RUNNINGfinalize 探针各 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 期间不写 resultJsonDE 在快照中永久丢失修复后 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;
}
}
@@ -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();
}
}
@@ -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());
// 被淘汰的最旧条目201202不产生本地命中
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());
// 本地条目被淘汰后读取回落 RedisRedis 异常时降级返回空而不抛错
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;
}
}
@@ -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;
@@ -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 -> {
@@ -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 大行数下的样式图片和工作表兼容测试
* writeWorkbookXSSFWorkbook 模板路径返回实际写入的数据行数 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();
}
}
@@ -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 返回 nullnull/空集合安全
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();
}
}
@@ -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 安全返回 nullcache 本身非法构造仍被拒绝
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();
}
}
@@ -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();
}
}
@@ -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();
}
}
@@ -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 19Coze 请求/响应及 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, "截断输出必须有界");
}
}
@@ -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);
}
}
@@ -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());
}
}
@@ -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 9chunk 查询从单行分页改为批量 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());
}
}
@@ -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 13chunk 合并增加单次最大行数与 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));
}
}
@@ -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 只保留非空 chunkchunk 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 {
// batchbatchTotal=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 {
// 缓冲开关关闭回退立即 mergeDONE 结果仍落 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());
// 失败分组不清 pointerbuffer 未被删除stateJson 未更新留待重试
verify(transientPayloadStorageService, never()).deletePayloadIfPresent(anyString());
verify(taskScopeStateMapper, never()).update(any(), any());
// 恢复后重试 flushchunk 合并成功一次pointer 清理
flush.invoke(service, 7104L);
assertEquals(2, storeCalls.get(), "恢复后重试应再次写 chunk");
verify(taskScopeStateMapper, atLeastOnce()).update(any(), any());
}
}
@@ -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);
}
}
@@ -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 字符");
}
}
@@ -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);
}
}
@@ -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());
}
}
@@ -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 {
// 默认配置 100150 行文件返回 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 {
// 配置 50500 行文件返回 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);
// 配置 200300 行文件返回 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 {
// 配置值超过最大上限1000clamp 到上限不发生无界内存增长
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 stubint 默认 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);
}
}
@@ -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());
}
}
@@ -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 11Coze 结果合并的重复检测从 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 keyid::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());
}
}
@@ -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 的行索引到 rowKeychunkKeycoze 行归属从
* 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;
}
/** 构造 rowsByChunkchunkStorageKey(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索引保留第一个 chunkputIfAbsent行为确定
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());
}
}
@@ -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 8WorkbookFactory 输入解析改为受控读取
* 解析前先探测 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");
}
}
@@ -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 {
// 批量/多格式 JPEGPNG同样按源尺寸子采样采样后解码像素受控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);
}
}
@@ -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();
}
}
@@ -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 仍能删除目录");
}
}
@@ -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 20Similar ASIN 端到端压测JFR/GC 分析与结果文件兼容回归
* SimilarAsinPerfFixture 新增三个功能点
* - endToEndBenchmark生成 chunk 序列化 计时 吞吐与峰值堆采样
* - gcStressAnalysis多轮生成/释放循环采样 GC 计数与堆峰值
* - compatRoundTrippayload 序列化往返恢复全量行并校验字段稳定结果文件兼容回归
*/
@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");
}
}
@@ -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 1Similar 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());
// 空行采样行数 0chunk 0payload 字节数为 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 chunks40+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());
}
}
+50
View File
@@ -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
View File
@@ -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"
}
]
}