Compare commits

...

83 Commits

Author SHA1 Message Date
huangzd1997 42bbf4904e feat-admin-ui-interaction
Build Backend JAR / build (push) Has been cancelled
2026-08-30 22:09:22 +08:00
huangzd1997 2b613a8e5a progress 78 2026-08-30 22:01:15 +08:00
huangzd1997 4c70ca4101 task-78: 为所有外部调用增加耗时、重试、失败率和 payload 字节指标
新增 ExternalCallMetricsRecorder:通过 RestClient 拦截器统一记录
aiimage.external-call.duration(耗时)、aiimage.external-call.total
(失败率)、aiimage.external-call.payload.bytes(请求字节)与
aiimage.external-call.retry.total(重试次数);Coze/品牌检查/紫鸟
三个外部客户端全部接入,指标注册表缺失时静默降级,不改变调用语义。
2026-08-30 22:01:02 +08:00
huangzd1997 f1d1fe5c78 progress 77 2026-08-30 21:08:32 +08:00
huangzd1997 39e52858d1 task-77: 统一 Coze、品牌检查和紫鸟 HTTP 客户端的连接复用策略 2026-08-30 21:08:28 +08:00
huangzd1997 678ba57347 progress 76 2026-08-30 20:51:14 +08:00
huangzd1997 04f45bebec task-76: JSON owner 查询迁移到显式列并补充任务/状态复合索引 2026-08-30 20:51:07 +08:00
huangzd1997 e9ba3689ba progress 75 2026-08-30 20:39:35 +08:00
huangzd1997 6a4a115260 task-75: 虚拟线程任务等待队列上限与拒绝/延迟指标 2026-08-30 20:39:31 +08:00
huangzd1997 ba163aa4bd progress 74 2026-08-30 20:31:43 +08:00
huangzd1997 5105ca95c5 task-74: 隔离调度线程池、文件作业线程池和外部 Coze/图片执行池 2026-08-30 20:31:39 +08:00
huangzd1997 adf0a5ff73 progress 73 2026-08-30 20:23:09 +08:00
huangzd1997 ec9811c8a5 task-73: 本地文件作业队列 in-flight 去重和队列背压 2026-08-30 20:22:58 +08:00
huangzd1997 f190896ea7 progress 72 2026-08-30 20:01:22 +08:00
huangzd1997 baa43d80ba task-72: 文件作业数据库原子 claim,避免重复派发同一 job 2026-08-30 20:01:18 +08:00
huangzd1997 c07e26a400 progress 71 2026-08-30 19:53:50 +08:00
huangzd1997 6f32ed86e4 task-71: 清理日志改为数量与 sample ID,禁止输出超长任务 ID 列表 2026-08-30 19:53:49 +08:00
huangzd1997 f48407ec7e progress 70 2026-08-30 19:49:03 +08:00
huangzd1997 9de4603484 task-70: 历史清理改为 keyset 分页、小批量和短事务 2026-08-30 19:48:59 +08:00
huangzd1997 fb090bf80d progress 69 2026-08-30 19:30:01 +08:00
huangzd1997 5749a421cb task-69: 历史清理任务收集 transient payload 指针并提交删除队列 2026-08-30 19:29:32 +08:00
huangzd1997 54260551ee progress 68 2026-08-30 19:13:47 +08:00
huangzd1997 2de58b7afc task-68: payload 引用删除改为批量引用检查与异步物理删除
- 新增 TransientPayloadDeleteOrchestrator:提交去重入队(maxPendingDeletes 上限),
  flush 时一次性 IN 批量反查 biz_task_chunk / biz_task_scope_state,
  未引用对象交由后台线程池异步物理删除(剥 rustfs: 前缀)
- 幂等:重复提交只入队一次;保守:查询失败整批保留可重试,不误删
- 空值/非指针忽略,本地/OSS 指针不在批量删除范围(各有归属与清理路径)
2026-08-30 19:13:43 +08:00
huangzd1997 45b23b559c progress 67 2026-08-30 19:04:37 +08:00
huangzd1997 2819fb3c6c task-67: 复用 RustFS/MinIO 客户端与 HTTP 连接池
- buildClient 改为 double-check 懒加载缓存共享 MinioClient(volatile 单例),
  同一实例持有同一 OkHttpClient,连接池随实例共享,不再每次操作新建客户端
- supplier 注入路径优先且不写共享缓存,测试隔离不受污染
- 未配置/空白 endpoint 时不创建实例,拒绝操作并保持缓存为空
2026-08-30 19:04:32 +08:00
huangzd1997 f15d2e1c22 progress 66 2026-08-30 18:57:31 +08:00
huangzd1997 e2d6875ef9 task-66: 限制 RustFS 并发读写与重试的总资源预算
- 新增 aiimage.transient-storage.max-total-concurrent-operations 配置
  (默认 0 不启用):跨 upload/read/delete 的总并发预算
- executeWithRetry 入口获取一个总许可,整次操作(含全部重试)全程占用,
  重试不额外消耗,超预算立即拒绝并计入 operation=total rejected 指标
- 失败/完成后 finally 释放总许可,无残留锁;负值/0 视为未启用保持原行为
2026-08-30 18:57:26 +08:00
huangzd1997 e377814c48 progress 65 2026-08-30 18:49:59 +08:00
huangzd1997 9b1978aec8 task-65: transient payload 读取流式解压 + 解压后字节上限
- 新增 aiimage.transient-storage.max-decompressed-payload-bytes 配置
  (默认 100MB,即 2x 存储上限),覆盖 gzip 二进制与 gzip64 兼容两条路径
- decodeGzipStream 按 8KB 缓冲流式解压,累计输出超过上限立即中止并报错,
  防止压缩炸弹(zip bomb)在读取时无界膨胀内存
- 超限错误保留可识别消息(exceeds configured limit),由 resolvePayload 统一包装
2026-08-30 18:49:55 +08:00
huangzd1997 4e9c5d242e progress 64 2026-08-30 18:42:25 +08:00
huangzd1997 ca32cde4fe task-64: transient payload 压缩改为直接 gzip 二进制流上传
- RustfsObjectStorageService 新增 uploadBytes/readObjectBytes 二进制流 API
  (application/gzip),uploadText/readObjectAsString 改为其薄包装
- TransientPayloadStorageService 的 encodeStoredPayload 直接输出 gzip 字节,
  不再经过 gzip64 + Base64 文本编码(消除 33% 体积膨胀与中间字符串副本)
- 读取端按 gzip magic 识别二进制流解压;兼容旧 gzip64: 文本与历史裸文本
- 本地回落文件同步改为写二进制(Files.write),超限判定基于 gzip 后字节数
2026-08-30 18:42:19 +08:00
huangzd1997 f8e4360482 progress 63 2026-08-30 18:28:19 +08:00
huangzd1997 9c9b8a6078 task-63: 进度快照写入最小更新间隔节流(终态豁免+删除清理跟踪) 2026-08-30 18:28:15 +08:00
huangzd1997 a9cee8e9a1 progress 62 2026-08-30 18:17:55 +08:00
huangzd1997 8d91e599ab task-62: 本地任务实体缓存共享组件(容量 LRU/TTL 即时回收/定时清理)接入 8 模块 2026-08-30 18:17:52 +08:00
huangzd1997 dff38a04c7 progress 61 2026-08-30 18:00:38 +08:00
huangzd1997 8e9c79fc0e task-61: Rustfs 对象存储指标基线验收测试(计数/计时/分布/熔断/拒绝) 2026-08-30 18:00:33 +08:00
huangzd1997 7b3feb4b1a progress 60 2026-08-30 17:43:20 +08:00
huangzd1997 305b897f2a task-60: 采集模块 10k 行压测验收(结果完整性、内存与吞吐)
10k 行批量 upsert 压测验收:100 批 × 100 行恰好写入 10k 行不丢失不重复(引用 JSON 唯一);
10 chunk 分批提交总批量 SQL 调用次数恒定 100;hash 相等重复提交全跳过零写入(幂等);
batchSize=1 时 10k 次调用每批 1 行(内存受 batchSize 约束不无界累积);null/空白 ASIN
行安全跳过;前 50 批失败跳过后重试收敛到真实行数。collectdata 模块完成。
2026-08-30 17:43:13 +08:00
huangzd1997 9ec43e8922 progress 59 2026-08-30 17:38:26 +08:00
huangzd1997 7147fc464c task-59: 采集模块对象存储与批量 SQL 调用次数验证
deleteHistory 删除历史结果的对象存储调用次数验证:批量 SQL(selectList/delete)调用次数
恒定不随行数增长;RustFS 物理删除按 chunk 引用指针去重后每对象一次(旧格式逐行一次);
空历史零调用、单历史单对象、300 行 100 chunk 恰好 100 次、非法越权拒绝、依赖失败传播后
重试成功无残留。锁定 task-57 引入的去重删除语义在 deleteHistory 路径上的调用次数契约。
2026-08-30 17:38:22 +08:00
huangzd1997 8ea1577924 progress 58 2026-08-30 17:31:27 +08:00
huangzd1997 567cb1ab67 task-58: collectdata 结果明细读取失败降级(RustFS 超时跳过该 chunk,不中断其它 chunk)
chunk 级引用读取失败(RustFS 超时/不可用)时降级为跳过该 chunk 的引用行并记录 warn,
结果文件继续生成,缺失行由任务状态可观测;chunk 明细 JSON 损坏仍抛异常(数据问题不降级),
旧格式单行读取失败保持抛异常。失败 chunk 在单次调用内缓存为空哨兵,避免重复 resolve。
task-052 依赖失败测试同步更新为降级语义。
2026-08-30 17:31:21 +08:00
huangzd1997 44abcdd4ef progress 57 2026-08-30 17:20:05 +08:00
huangzd1997 5bbf601480 task-57: 删除任务/历史时先删 DB 行再物理删对象,引用计数反查基于删除后状态,共享对象跳过删除、独占对象真正释放 2026-08-30 17:20:00 +08:00
huangzd1997 c0447c908e progress 56 2026-08-30 17:06:06 +08:00
huangzd1997 adcfa28af3 task-56: 采集结果 Excel 改为临时文件+原子落位,流式写入或落位失败时清理临时文件,目标路径不残留半成品 2026-08-30 17:05:56 +08:00
huangzd1997 15269c6ed9 progress 55 2026-08-30 16:56:20 +08:00
huangzd1997 e7828dc926 task-55: 采集进度心跳改为节流/合并写,窗口内心跳只更新内存态不落库,窗口到期或任务结束路径强制持久化,避免高频 task UPDATE 2026-08-30 16:55:37 +08:00
huangzd1997 5776217c2c progress 54 2026-08-30 16:41:09 +08:00
huangzd1997 91f760f789 task-54: finalRowCount 改为任务内增量计数(upsert newlyInserted 累计),移除每 chunk 全表 COUNT(*) 2026-08-30 16:41:02 +08:00
huangzd1997 e8cedbcb50 progress 53 2026-08-30 16:30:39 +08:00
huangzd1997 97ae8a7c51 店铺数据抓取累计文件改为店铺级共享,按国家覆盖更新
同店同日在不同账号下产生多份互不相干的累计文件(唯一键含 user_id),
后台管理页每店只显示最新一份,其他账号抓的国家看起来丢失。

- V95 迁移:加 shop_key/country_codes_json/compensation_done 列,按店铺
  合并存量 daily_file 与成员行,唯一键改为 (shop_key, business_date)
- DailyFileService:findForUpdate/findOlder/acquireLock 去 user 维度,
  店铺级锁跨账号串行
- TaskService:聚合按店铺定位;applyCountryCoverage 按国家覆盖(本次
  回传的国家替换旧行,未更新的国家保留);启动补偿组件按成员快照重建
  合并文件并回填国家列表
- 管理页:country_codes 改从 daily_file.country_codes_json 取,行数用
  累计文件实际值
2026-08-30 16:30:09 +08:00
huangzd1997 ae95b294ec task-53: 采集结果 Excel 生成按 rawRows/finalRows 分段生命周期,rawRows 惰性加载避免同时驻留内存 2026-08-30 16:29:48 +08:00
huangzd1997 9ebb1a4012 progress 52 2026-08-30 16:00:37 +08:00
huangzd1997 48284be214 task-52: 生成结果文件按 chunk 一次读取
CollectDataResultDetailReader 从 biz_task_result_item 解析行:引用格式
按 pointer 缓存整 chunk 明细(同一 chunk 对象只 resolve 一次),再按
offset 取行;旧格式逐行兜底。CollectDataService.loadFinalRows 接入
reader,移除内联逐行读取循环与 detailCache。
2026-08-30 16:00:32 +08:00
huangzd1997 7f46b9f768 progress 51 2026-08-30 15:39:42 +08:00
huangzd1997 a29d6e6ce4 task-51: accepted 行序列化与 hash 批量生成
CollectDataResultDetailCodec.encodeRefsWithHash 一次迭代为整 chunk 生成
全部行引用 JSON 及其 SHA-256(offset 从 startOffset 递增),带
MAX_REFS_PER_BATCH 上限与参数校验;CollectDataResultItemBatchWriter
接入批量生成,删除逐行 encodeRef+hash 循环,同 chunk 重提结果逐字节一致。
2026-08-30 15:39:38 +08:00
huangzd1997 687bc18510 progress 50 2026-08-30 15:33:01 +08:00
huangzd1997 1127e9c8bb task-50: 结果明细批量 upsert 写入器与唯一键幂等
CollectDataResultItemBatchWriter 按唯一键 uk_task_scope_item 批量 upsert
biz_task_result_item:一次批量查询现有行(payload_hash 相等即跳过),
分批发 INSERT ... ON DUPLICATE KEY UPDATE,替代逐行 select/insert/update;
旧格式逐行 payload 升级为引用时物理删除(引用计数兜底)。CollectDataService
submitResult 接入批量写入,移除逐行 upsertResultItem。
2026-08-30 15:32:55 +08:00
huangzd1997 68ebd387be progress 49 2026-08-30 14:31:08 +08:00
huangzd1997 5919ae12e9 task-49: 结果明细从逐行 RustFS 对象改为 chunk 级 payload 存储,引用 JSON 兼容旧格式 2026-08-30 14:31:04 +08:00
huangzd1997 ed2900361c progress 48 2026-08-30 14:15:37 +08:00
huangzd1997 d4ebe1926d fix: 品牌检查过滤器多构造器显式 @Autowired,供 Spring bean 解析 2026-08-30 14:12:19 +08:00
huangzd1997 98c45486fa task-48: invalid ASIN 记录改为批量 INSERT IGNORE/upsert,唯一键扩为 (data_value, brand) 2026-08-30 14:11:59 +08:00
huangzd1997 c609ffb279 progress 47 2026-08-30 13:54:03 +08:00
huangzd1997 b92d3e688d task-47: 品牌检查结果任务内短期缓存,避免同品牌重复远程调用 2026-08-30 13:53:38 +08:00
huangzd1997 751f4b89fd progress 46 2026-08-30 13:40:09 +08:00
huangzd1997 0d99baea04 task-46: 跳过空品牌批次的无效远程品牌检查请求
新增 CollectDataBrandBatchFilter 接管品牌检查批次切分与分类:空品牌
批次不发起 checkAll 远程调用(行直接归 rejected),远程抛错整组降级
queryFailed 可恢复。service 委托查询器只做计数与无效 ASIN 落库,语义
与旧 filterByBrandCheck 完全等价。8 个测试覆盖默认/批量/幂等/空输入/
单元素/超限/非法参数/依赖失败,全量回归 720 通过。
2026-08-30 13:40:03 +08:00
huangzd1997 61f8c86b02 progress 45 2026-08-30 13:31:58 +08:00
huangzd1997 7110d1086b task-45: ASIN 去重与无效品牌查询统一为批量集合查询
新增 CollectDataBatchQuery 把 dedupe 表查询与 invalid_asin 表查询
合并为两次批量集合查询,输出与旧两段式语义等价,并返回两类过滤计数
由 service 维护 stats。8 个测试覆盖默认/批量/幂等/空输入/单元素/
超限/非法参数/依赖失败,全量回归 712 通过。
2026-08-30 13:31:52 +08:00
huangzd1997 d23485029d progress 44 2026-08-30 13:20:04 +08:00
huangzd1997 639e8b989e task-44: collectdata extra JSON 批量预序列化与内容去重
persistParsedTask 逐行调用 ObjectMapper 序列化 extra 是热点;新增
CollectDataExtraJsonCodec 批量预序列化,相同 extra 只序列化一次,
输出与逐行语义完全一致,失败行降级 {}。8 个测试覆盖去重/幂等/
空输入/单元素/容量淘汰/非法输入/序列化失败,全量回归 704 通过。
2026-08-30 13:19:15 +08:00
huangzd1997 a0a232f504 progress 43 2026-08-30 13:08:18 +08:00
huangzd1997 173287e3d6 task-43: 采集源文件查找改为确定路径/索引查询
LocalFileStorageService 新增 fileKey→文件名 有界索引(LinkedHashMap LRU,
上限 1024 淘汰最旧):saveTempFile 登记,findLocalSourceFile 优先按索引
直接构造确定路径,索引过期/进程重启时兜底枚举根层目录,结果与索引一致;
key 与索引名均校验单段字符集,防止路径穿越与索引污染。8 个用例覆盖
正常/批量/幂等/空/单元素/索引容量超限/非法输入/清理与重启降级,
mvn 全量测试通过。
2026-08-30 13:08:13 +08:00
huangzd1997 f168e23dde progress 42 2026-08-30 13:04:08 +08:00
huangzd1997 fec89b6b7b task-42: 限制采集解析的文件大小、最大行数和单 chunk 行数
新增 CollectDataParseLimits 组件并在 CollectDataService 解析/回传路径
接入:源文件大小(默认 50MB)、累计解析行数(默认 50000)、单 chunk
回传行数(默认 5000)超限即拒绝,配置经 aiimage.collect-data.* 环境变量
可调、0/负值回退默认。8 个用例覆盖正常/批量/幂等/空/单元素/边界超限/
非法配置/失败后可恢复路径,mvn 全量测试通过。
2026-08-30 13:04:02 +08:00
huangzd1997 3f8326afc4 progress 41 2026-08-30 12:58:31 +08:00
huangzd1997 33d0f02c55 task-41: 建立 Collect Data 性能基线夹具(1k/10k 行、多 chunk、品牌检测)
新增 CollectDataPerfFixture:确定性生成 1k/10k 行、多关键词多 chunk 与
品牌检测场景(成功/失败/查询失败/空 ASIN)行集合并采样 payload 字节与
chunk 划分;同一输入重复生成结果一致(幂等),行数/计数/关键词均有上限
校验。8 个用例覆盖正常/批量/幂等/空/单元素/超限/非法输入/序列化失败
降级路径,mvn 全量 681 测试通过。
2026-08-30 12:58:26 +08:00
huangzd1997 2ce37f0a1a progress 40 2026-08-30 12:53:41 +08:00
huangzd1997 7f44ca6383 task-40: 完成店铺抓取压测探针与资源比较
新增 ShopDataCrawlLoadTestProbe:并发多轮执行"生成-采样-回传-上传-锁"
流水线,产出内存峰值、耗时、DB QPS、对象存储流量与锁等待五类可比较
指标;行数/线程/轮数均有上限,注入 OSS 上传与锁获取失败验证依赖降级
可恢复且计数不残留。8 个用例覆盖正常/批量/幂等/空/单元素/超限/非法
输入/依赖失败路径,mvn 全量 672 测试通过。
2026-08-30 12:53:36 +08:00
huangzd1997 6875abaa1a progress 39 2026-08-30 12:40:15 +08:00
huangzd1997 b806ab9384 task-39: 补充删除/超时/重复回传/累计文件失败的资源清理测试
新增 ShopDataCrawlCleanupTest 11 个用例覆盖:单条/批量/空输入删除路径的
临时文件与累计对象回收、陈旧 RUNNING 任务超时终态化、分片重复回传的
payload 释放与终态拒绝、累计文件 CAS 冲突重试时上传对象回滚。

同时撤销上一提交误引入的按国家覆盖语义:rowsByCountry 恢复同国累积
(addAll),移除与之矛盾的 writeWorkbookLatestMemberWinsPerCountryOthersPreserved
测试,修复 task-22~26 批量合并回归(行数被覆盖削减)。
2026-08-30 12:16:39 +08:00
117 changed files with 21956 additions and 1501 deletions
@@ -0,0 +1,114 @@
package com.nanri.aiimage.config;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.Timer;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.core.task.TaskExecutor;
import org.springframework.core.task.TaskRejectedException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
/**
* Task 75:虚拟线程任务排队闸门。Coze 执行池的信号量只限制"正在执行"的
* 并发度,提交侧仍会在虚拟线程里无限排队。此闸门在提交时统计"已受理未启动"
* 的等待数,达到上限立即拒绝并记录指标,防止等待队列无界堆积:
* <ul>
* <li>等待数:提交时 +1,任务执行(或执行器拒绝)时 -1;上限钳制到 [1, +∞)</li>
* <li>指标:等待耗时、执行耗时、拒绝次数(queue-full / delegate-rejected / invalid-input);</li>
* <li>执行器不可用(provider 为空)时拒绝新提交,不产生死等任务。</li>
* </ul>
*/
@Slf4j
public class CozeTaskQueueGate implements TaskExecutor {
private final TaskExecutor delegate;
private final int maxWaiting;
private final ObjectProvider<MeterRegistry> meterRegistryProvider;
private final AtomicInteger waiting = new AtomicInteger();
public CozeTaskQueueGate(TaskExecutor delegate, int maxWaiting,
ObjectProvider<MeterRegistry> meterRegistryProvider) {
this.delegate = delegate;
this.maxWaiting = Math.max(1, maxWaiting);
this.meterRegistryProvider = meterRegistryProvider;
}
public int waiting() {
return waiting.get();
}
@Override
public void execute(Runnable command) {
if (command == null) {
recordRejected("invalid-input");
throw new IllegalArgumentException("coze 任务不能为 null");
}
if (waiting.get() >= maxWaiting) {
recordRejected("queue-full");
log.warn("[coze-task][gate] waiting queue full, reject submit waiting={} limit={}",
waiting.get(), maxWaiting);
throw new TaskRejectedException("coze 等待队列已满,limit=" + maxWaiting
+ ", waiting=" + waiting.get());
}
waiting.incrementAndGet();
long queuedAt = System.nanoTime();
try {
delegate.execute(() -> {
long waitNanos = System.nanoTime() - queuedAt;
try {
run(command);
} finally {
waiting.decrementAndGet();
recordQueueWait(waitNanos);
}
});
} catch (RuntimeException ex) {
waiting.decrementAndGet();
recordQueueWait(System.nanoTime() - queuedAt);
recordRejected("delegate-rejected");
log.warn("[coze-task][gate] delegate rejected submit waiting={} limit={} msg={}",
waiting.get(), maxWaiting, ex.getMessage(), ex);
throw ex;
}
}
private void run(Runnable command) {
long startedAt = System.nanoTime();
try {
command.run();
} finally {
recordExecution(System.nanoTime() - startedAt);
}
}
private void recordRejected(String reason) {
MeterRegistry registry = meterRegistry();
if (registry != null) {
registry.counter("aiimage.coze-task.submit.rejected.total", "reason", reason).increment();
}
}
private void recordQueueWait(long waitNanos) {
MeterRegistry registry = meterRegistry();
if (registry != null && waitNanos >= 0L) {
Timer.builder("aiimage.coze-task.queue.wait.duration")
.register(registry)
.record(waitNanos, TimeUnit.NANOSECONDS);
}
}
private void recordExecution(long durationNanos) {
MeterRegistry registry = meterRegistry();
if (registry != null && durationNanos >= 0L) {
Timer.builder("aiimage.coze-task.execution.duration")
.register(registry)
.record(durationNanos, TimeUnit.NANOSECONDS);
}
}
private MeterRegistry meterRegistry() {
return meterRegistryProvider == null ? null : meterRegistryProvider.getIfAvailable();
}
}
@@ -0,0 +1,45 @@
package com.nanri.aiimage.config;
import org.springframework.http.client.ClientHttpRequestFactory;
import org.springframework.http.client.JdkClientHttpRequestFactory;
import java.net.http.HttpClient;
import java.time.Duration;
/**
* Task 77:外部 HTTP 客户端统一连接复用池。
* Coze / 品牌检查 / 紫鸟三个外部客户端共用同一个 java.net.http.HttpClient
* (内置 keep-alive 连接池),避免各自新建短命客户端导致连接无法复用、
* 每次请求都重新建连。各客户端按自身超时创建独立的
* JdkClientHttpRequestFactory(共享底层连接池),RestClient 单例懒加载。
*/
public class HttpClientPool {
private static volatile HttpClient sharedHttpClient;
/** 共享连接池实例:单一 HttpClient 承载全部外部调用的连接复用。 */
public static HttpClient sharedHttpClient() {
HttpClient client = sharedHttpClient;
if (client != null) {
return client;
}
synchronized (HttpClientPool.class) {
if (sharedHttpClient == null) {
sharedHttpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.version(HttpClient.Version.HTTP_1_1)
.build();
}
return sharedHttpClient;
}
}
/** 按 readTimeout(毫秒)创建共享连接池工厂;非法值钳制到最小正数。 */
public static ClientHttpRequestFactory requestFactory(int readTimeoutMillis) {
int safeReadTimeout = Math.max(1, readTimeoutMillis);
JdkClientHttpRequestFactory factory =
new JdkClientHttpRequestFactory(sharedHttpClient());
factory.setReadTimeout(Duration.ofMillis(safeReadTimeout));
return factory;
}
}
@@ -12,6 +12,11 @@ public class ModuleCleanupProperties {
private boolean enabled = true;
private String cron = "0 0 0 * * *";
private long retentionDays = 7;
/**
* keyset 分页每批处理的任务数:每批一个短事务,批内按 id 升序,
* 批间无行级排他锁占用;0/负值回退默认 500。
*/
private int batchSize = 500;
// SHOP_DATA_CRAWL keeps one per-shop daily workbook in its task service
// and must not be removed by the age-based sweep.
private List<String> moduleTypes = new ArrayList<>(List.of("DEDUPE", "SPLIT", "CONVERT", "DELETE_BRAND", "PRODUCT_RISK_RESOLVE", "PRICE_TRACK", "SHOP_MATCH", "PATROL_DELETE", "QUERY_ASIN", "WITHDRAW", "APPEARANCE_PATENT", "SIMILAR_ASIN", "COLLECT_DATA"));
@@ -1,6 +1,7 @@
package com.nanri.aiimage.config;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@@ -20,9 +21,9 @@ public class SchedulingConfig {
private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai");
@Bean
public TaskScheduler taskScheduler() {
public TaskScheduler taskScheduler(@Value("${aiimage.scheduling.pool-size:4}") int poolSize) {
ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
scheduler.setPoolSize(4);
scheduler.setPoolSize(Math.max(1, poolSize));
scheduler.setThreadNamePrefix("aiimage-scheduling-");
scheduler.setClock(Clock.system(BUSINESS_ZONE));
scheduler.setWaitForTasksToCompleteOnShutdown(true);
@@ -1,5 +1,7 @@
package com.nanri.aiimage.config;
import io.micrometer.core.instrument.MeterRegistry;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@@ -40,21 +42,29 @@ public class TaskFileJobConfig {
@Bean("cozeTaskExecutor")
public TaskExecutor cozeTaskExecutor(
ExecutorService cozeVirtualThreadExecutor,
@Value("${aiimage.coze-task.max-concurrent:12}") int maxConcurrent) {
@Value("${aiimage.coze-task.max-concurrent:12}") int maxConcurrent,
@Value("${aiimage.coze-task.max-waiting:1000}") int maxWaiting,
ObjectProvider<MeterRegistry> meterRegistryProvider) {
Semaphore semaphore = new Semaphore(Math.max(1, maxConcurrent));
return new ConcurrentTaskExecutor(command -> cozeVirtualThreadExecutor.execute(() -> {
boolean acquired = false;
try {
semaphore.acquire();
acquired = true;
command.run();
} catch (InterruptedException ex) {
Thread.currentThread().interrupt();
} finally {
if (acquired) {
semaphore.release();
}
TaskExecutor semaphoreLimited = new ConcurrentTaskExecutor(command -> {
if (command == null) {
throw new IllegalArgumentException("coze 任务不能为 null");
}
}));
cozeVirtualThreadExecutor.execute(() -> {
boolean acquired = false;
try {
semaphore.acquire();
acquired = true;
command.run();
} catch (InterruptedException ex) {
Thread.currentThread().interrupt();
} finally {
if (acquired) {
semaphore.release();
}
}
});
});
return new CozeTaskQueueGate(semaphoreLimited, maxWaiting, meterRegistryProvider);
}
}
@@ -23,6 +23,11 @@ public class TransientStorageProperties {
private int maxConcurrentUploads = 16;
private int maxConcurrentReads = 32;
private int maxConcurrentDeletes = 8;
/**
* 跨操作类型的总并发预算(含重试期间):一次操作全程占用一个总许可,
* 防止多任务叠加时读写与重试之和突破对后端的总压力上限。0/负值表示不启用。
*/
private long maxTotalConcurrentOperations = 0;
private long acquirePermitTimeoutMillis = 2000;
private long baseRetryDelayMillis = 500;
private long maxRetryDelayMillis = 5000;
@@ -37,6 +42,11 @@ public class TransientStorageProperties {
private long warnPayloadBytes = 5L * 1024 * 1024;
private long maxPayloadBytes = 50L * 1024 * 1024;
private long maxStoredPayloadBytes = 50L * 1024 * 1024;
/**
* 读取端解压后字节上限:防止压缩炸弹(zip bomb)在流式解压时无界膨胀内存。
* 默认 2x 存储上限,覆盖 gzip 二进制路径与 gzip64 兼容路径。
*/
private long maxDecompressedPayloadBytes = 100L * 1024 * 1024;
private boolean fallbackToLocalOnOversize = true;
private boolean deleteRetryEnabled = true;
private String deleteRetryCron = "0 */5 * * * *";
@@ -0,0 +1,111 @@
package com.nanri.aiimage.metrics;
import io.micrometer.core.instrument.DistributionSummary;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.Timer;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.http.client.ClientHttpRequestInterceptor;
import org.springframework.http.client.ClientHttpResponse;
import org.springframework.stereotype.Component;
import java.util.concurrent.TimeUnit;
/**
* Task 78:外部调用统一指标记录器。
* 所有外部 HTTP 客户端(Coze / 品牌检查 / 紫鸟)在构建 RestClient 时挂载
* {@link #interceptor(String)} 拦截器,统一记录:
* <ul>
* <li>耗时:{@code aiimage.external-call.duration}client + result 标签);</li>
* <li>失败率:{@code aiimage.external-call.total}result=success/failure2xx 之外计失败);</li>
* <li>payload 字节:{@code aiimage.external-call.payload.bytes}(请求体字节数);</li>
* <li>重试次数:{@code aiimage.external-call.retry.total}(客户端重试循环内调用)。</li>
* </ul>
* 指标注册表通过 ObjectProvider 懒获取,未配置 Micrometer 时全部静默跳过,
* 不改变既有调用语义。
*/
@Component
public class ExternalCallMetricsRecorder {
private final ObjectProvider<MeterRegistry> meterRegistryProvider;
public ExternalCallMetricsRecorder(ObjectProvider<MeterRegistry> meterRegistryProvider) {
this.meterRegistryProvider = meterRegistryProvider;
}
/** 单元测试入口:直接绑定一个指标注册表。 */
public ExternalCallMetricsRecorder(MeterRegistry registry) {
this(registry == null ? null : new ObjectProvider<MeterRegistry>() {
@Override
public MeterRegistry getObject() {
return registry;
}
@Override
public MeterRegistry getObject(Object... args) {
return registry;
}
@Override
public MeterRegistry getIfAvailable() {
return registry;
}
@Override
public MeterRegistry getIfUnique() {
return registry;
}
});
}
/** RestClient 拦截器:记录单次 HTTP 调用的耗时、payload 字节与成功/失败。 */
public ClientHttpRequestInterceptor interceptor(String client) {
return (request, body, execution) -> {
long startedAt = System.nanoTime();
long payloadBytes = body == null ? 0L : body.length;
try {
ClientHttpResponse response = execution.execute(request, body);
boolean success = response.getStatusCode().is2xxSuccessful();
record(client, success ? "success" : "failure", startedAt, payloadBytes);
return response;
} catch (Exception ex) {
record(client, "failure", startedAt, payloadBytes);
throw ex;
}
};
}
/** 重试循环内每次进入下一次尝试前调用。 */
public void recordRetry(String client) {
MeterRegistry registry = meterRegistry();
if (registry != null) {
registry.counter("aiimage.external-call.retry.total", "client", client).increment();
}
}
private void record(String client, String result, long startedAt, long payloadBytes) {
MeterRegistry registry = meterRegistry();
if (registry == null) {
return;
}
long durationNanos = System.nanoTime() - startedAt;
registry.counter("aiimage.external-call.total", "client", client, "result", result).increment();
if (durationNanos >= 0L) {
Timer.builder("aiimage.external-call.duration")
.tag("client", client)
.tag("result", result)
.register(registry)
.record(durationNanos, TimeUnit.NANOSECONDS);
}
if (payloadBytes >= 0L) {
DistributionSummary.builder("aiimage.external-call.payload.bytes")
.tag("client", client)
.baseUnit("bytes")
.register(registry)
.record(payloadBytes);
}
}
private MeterRegistry meterRegistry() {
return meterRegistryProvider == null ? null : meterRegistryProvider.getIfAvailable();
}
}
@@ -4,7 +4,6 @@ import com.fasterxml.jackson.annotation.JsonAlias;
import com.nanri.aiimage.config.BrandCheckProperties;
import jakarta.annotation.PreDestroy;
import lombok.Data;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
@@ -12,6 +11,8 @@ import org.springframework.http.client.SimpleClientHttpRequestFactory;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
import com.nanri.aiimage.config.HttpClientPool;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.LinkedHashMap;
@@ -25,7 +26,6 @@ import java.util.concurrent.ThreadFactory;
import java.util.concurrent.atomic.AtomicInteger;
@Component
@RequiredArgsConstructor
@Slf4j
public class BrandCheckClient {
@@ -35,8 +35,15 @@ public class BrandCheckClient {
private static final int BRAND_CHECK_CONCURRENCY = 3;
private final BrandCheckProperties properties;
private final com.nanri.aiimage.metrics.ExternalCallMetricsRecorder externalCallMetrics;
private volatile RestClient sharedRestClient;
public BrandCheckClient(BrandCheckProperties properties,
com.nanri.aiimage.metrics.ExternalCallMetricsRecorder externalCallMetrics) {
this.properties = properties;
this.externalCallMetrics = externalCallMetrics;
}
private final ExecutorService checkExecutor = Executors.newFixedThreadPool(
BRAND_CHECK_CONCURRENCY, namedThreadFactory("brand-check"));
@@ -160,10 +167,12 @@ public class BrandCheckClient {
}
synchronized (this) {
if (sharedRestClient == null) {
SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory();
requestFactory.setConnectTimeout(properties.getConnectTimeoutMillis());
requestFactory.setReadTimeout(properties.getReadTimeoutMillis());
sharedRestClient = RestClient.builder().requestFactory(requestFactory).build();
RestClient.Builder builder = RestClient.builder()
.requestFactory(HttpClientPool.requestFactory(properties.getReadTimeoutMillis()));
if (externalCallMetrics != null) {
builder.requestInterceptor(externalCallMetrics.interceptor("brand"));
}
sharedRestClient = builder.build();
}
return sharedRestClient;
}
@@ -11,10 +11,14 @@ import org.springframework.stereotype.Service;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.StandardCopyOption;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.function.Supplier;
@Service
@Slf4j
@@ -38,14 +42,37 @@ public class CollectDataExcelAssemblyService {
List<CollectDataResultRowVo> items,
List<CollectDataSummaryRowDto> summaries,
List<CollectDataResultRowVo> rawItems) {
writeWorkbookSegmented(outputXlsx, items, summaries, () -> rawItems);
}
/**
* 分段生命周期版:rawItems 惰性加载,仅在 summaries 为空(fallback 自聚合
* 窗口)时才调用 supplier 一次性取全量原始行,用完即弃;summaries 非空时
* supplier 不被调用,rawRows 与 finalRows 不同时长期驻留内存。
*/
public void writeWorkbookSegmented(File outputXlsx,
List<CollectDataResultRowVo> items,
List<CollectDataSummaryRowDto> summaries,
Supplier<List<CollectDataResultRowVo>> rawItemsSupplier) {
if (outputXlsx == null) {
throw new BusinessException("生成采集数据 Excel 失败: 输出文件路径为空");
}
SXSSFWorkbook workbook = new SXSSFWorkbook(200);
workbook.setCompressTempFiles(true);
try (FileOutputStream outputStream = new FileOutputStream(outputXlsx)) {
// 先写目标同目录的临时文件,成功后原子落位:写入或落位失败时目标路径
// 不残留半成品,临时文件由 catch/finally 清理(SXSSF 滚动窗口文件由 dispose 释放)。
File tmpFile = new File(outputXlsx.getAbsolutePath() + ".tmp");
try {
writeDetailSheet(workbook, items);
writeSummarySheet(workbook, summaries, rawItems);
workbook.write(outputStream);
writeSummarySheet(workbook, summaries, rawItemsSupplier);
try (FileOutputStream outputStream = new FileOutputStream(tmpFile)) {
workbook.write(outputStream);
}
Files.move(tmpFile.toPath(), outputXlsx.toPath(),
StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
} catch (Exception ex) {
log.warn("[collect-data] write workbook failed: {}", ex.getMessage());
deleteQuietly(tmpFile);
throw new BusinessException("生成采集数据 Excel 失败: " + ex.getMessage());
} finally {
try {
@@ -53,6 +80,15 @@ public class CollectDataExcelAssemblyService {
} catch (Exception ignored) {
}
workbook.dispose();
deleteQuietly(tmpFile);
}
}
private void deleteQuietly(File file) {
try {
Files.deleteIfExists(file.toPath());
} catch (IOException ex) {
log.warn("[collect-data] delete workbook temp file failed: {}", ex.getMessage());
}
}
@@ -88,7 +124,7 @@ public class CollectDataExcelAssemblyService {
*/
private void writeSummarySheet(SXSSFWorkbook workbook,
List<CollectDataSummaryRowDto> summaries,
List<CollectDataResultRowVo> rawItems) {
Supplier<List<CollectDataResultRowVo>> rawItemsSupplier) {
Sheet sheet = workbook.createSheet(SHEET_SUMMARY_NAME);
Row headerRow = sheet.createRow(0);
for (int i = 0; i < SHEET_SUMMARY_HEADER.length; i++) {
@@ -121,6 +157,9 @@ public class CollectDataExcelAssemblyService {
}
// Fallback 分支:基于 rawItems 自聚合(Python 端未接入时使用)。
// 仅在需要时才触发 supplier 一次性加载全量原始行,聚合完即弃,
// 与 finalRowsitems)不同时长期驻留内存。
List<CollectDataResultRowVo> rawItems = rawItemsSupplier.get();
Map<String, KeywordSummary> grouped = new LinkedHashMap<>();
if (rawItems != null) {
for (CollectDataResultRowVo item : rawItems) {
@@ -4,12 +4,10 @@ import cn.hutool.core.util.IdUtil;
import cn.hutool.core.io.FileUtil;
import cn.hutool.crypto.digest.DigestUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.nanri.aiimage.common.exception.BusinessException;
import com.nanri.aiimage.modules.brand.client.BrandCheckClient;
import com.nanri.aiimage.modules.collectdata.mapper.CollectDataCountryPrefMapper;
import com.nanri.aiimage.modules.collectdata.mapper.CollectDataItemMapper;
import com.nanri.aiimage.modules.collectdata.model.dto.CollectDataCountryPreferenceSaveRequest;
@@ -33,9 +31,15 @@ import com.nanri.aiimage.modules.collectdata.model.vo.CollectDataSubmitResultVo;
import com.nanri.aiimage.modules.collectdata.model.vo.CollectDataTaskBatchVo;
import com.nanri.aiimage.modules.collectdata.model.vo.CollectDataTaskDetailVo;
import com.nanri.aiimage.modules.collectdata.model.vo.CollectDataTaskSummaryVo;
import com.nanri.aiimage.modules.dedupe.mapper.DedupeTotalDataMapper;
import com.nanri.aiimage.modules.collectdata.util.CollectDataBatchQuery;
import com.nanri.aiimage.modules.collectdata.util.CollectDataBrandBatchFilter;
import com.nanri.aiimage.modules.collectdata.util.CollectDataExtraJsonCodec;
import com.nanri.aiimage.modules.collectdata.util.CollectDataInvalidAsinBatchWriter;
import com.nanri.aiimage.modules.collectdata.util.CollectDataParseLimits;
import com.nanri.aiimage.modules.collectdata.util.CollectDataResultDetailCodec;
import com.nanri.aiimage.modules.collectdata.util.CollectDataResultDetailReader;
import com.nanri.aiimage.modules.collectdata.util.CollectDataResultItemBatchWriter;
import com.nanri.aiimage.modules.invalidasin.mapper.InvalidAsinDataMapper;
import com.nanri.aiimage.modules.invalidasin.model.entity.InvalidAsinDataEntity;
import com.nanri.aiimage.modules.file.service.LocalFileStorageService;
import com.nanri.aiimage.modules.file.service.oss.OssStorageService;
import com.nanri.aiimage.modules.task.mapper.FileResultMapper;
@@ -70,11 +74,10 @@ import org.springframework.dao.DuplicateKeyException;
import java.io.File;
import java.io.FileInputStream;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
@@ -105,7 +108,6 @@ public class CollectDataService {
private static final String DEFAULT_TASK_TYPE = "collect-data";
private static final int ITEM_INSERT_BATCH_SIZE = 500;
private static final int BRAND_CHECK_BATCH_SIZE = 10;
private static final long TASK_LOCK_WAIT_MILLIS = 5000L;
private static final String CONTENT_TYPE_XLSX = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
private static final String STALE_TASK_ERROR = "长时间未收到 Python 心跳,任务已自动失败";
@@ -121,7 +123,6 @@ public class CollectDataService {
private final FileResultMapper fileResultMapper;
private final CollectDataItemMapper collectDataItemMapper;
private final CollectDataCountryPrefMapper collectDataCountryPrefMapper;
private final DedupeTotalDataMapper dedupeTotalDataMapper;
private final InvalidAsinDataMapper invalidAsinDataMapper;
private final TaskChunkMapper taskChunkMapper;
private final TaskScopeStateMapper taskScopeStateMapper;
@@ -129,15 +130,83 @@ public class CollectDataService {
private final TaskDistributedLockService taskDistributedLockService;
private final TaskFileJobService taskFileJobService;
private final TransientPayloadStorageService transientPayloadStorageService;
private final BrandCheckClient brandCheckClient;
private final CollectDataExcelAssemblyService excelAssemblyService;
private final OssStorageService ossStorageService;
private final ObjectMapper objectMapper;
private final TransactionTemplate transactionTemplate;
/** ASIN 去重 + 无效品牌批量集合查询器:两段式查询合并为一次往返,语义与旧实现等价。 */
private final CollectDataBatchQuery collectDataBatchQuery;
/** 品牌检查批次过滤器:空品牌批次跳过远程请求,分类语义与旧实现等价。 */
private final CollectDataBrandBatchFilter brandBatchFilter;
/** invalid ASIN 批量写入器:按批次 INSERT IGNORE,替代逐行插入。 */
private final CollectDataInvalidAsinBatchWriter invalidAsinBatchWriter;
/** 结果明细 chunk 级编解码:accepted 行按 chunk 共享一个 RustFS 对象。 */
private final CollectDataResultDetailCodec resultDetailCodec;
/** 结果明细批量 upsert 器:按唯一键 uk_task_scope_item 批量写入,替代逐行 select/insert/update。 */
private final CollectDataResultItemBatchWriter resultItemBatchWriter;
/** 结果明细 chunk 级读取器:生成结果文件时按 chunk 一次读取,替代逐行对象读取。 */
private final CollectDataResultDetailReader resultDetailReader;
@Value("${aiimage.collect-data.stale-timeout-minutes:30}")
private long staleTimeoutMinutes;
@Value("${aiimage.collect-data.max-source-file-bytes:0}")
private Long maxSourceFileBytes;
@Value("${aiimage.collect-data.max-parse-rows:0}")
private Integer maxParseRows;
@Value("${aiimage.collect-data.max-chunk-rows:0}")
private Integer maxChunkRows;
/** 进度统计节流窗口(毫秒):窗口内 changed 心跳合并写,避免高频 task UPDATE。0 关闭节流。 */
@Value("${aiimage.collect-data.progress-throttle-millis:15000}")
private long progressThrottleMillis;
/** 进度统计脏数据兜底窗口(毫秒):窗口到期后即使心跳未变化也强制刷新一次。0 关闭。 */
@Value("${aiimage.collect-data.progress-dirty-window-millis:30000}")
private long progressDirtyWindowMillis;
/** 上次进度统计实际落库时间戳(任务级,单机内存态;无锁竞争风险由任务分布式锁兜底)。 */
private long lastProgressFlushMillis;
/** 采集源文件大小上限。0/负值回退默认 50MB,防止解析无界增长。 */
private long resolveMaxSourceFileBytes() {
Long configured = maxSourceFileBytes;
if (configured == null || configured <= 0) {
return 50L * 1024L * 1024L;
}
return configured;
}
/** 采集单次解析最大有效行数。0/负值回退默认 50000,防止任务无界增长。 */
private int resolveMaxParseRows() {
Integer configured = maxParseRows;
if (configured == null || configured <= 0) {
return 50000;
}
return configured;
}
/** 采集单 chunk 回传最大行数。0/负值回退默认 5000,防止单次回传无界增长。 */
private int resolveMaxChunkRows() {
Integer configured = maxChunkRows;
if (configured == null || configured <= 0) {
return 5000;
}
return configured;
}
private CollectDataParseLimits buildParseLimits() {
return new CollectDataParseLimits(resolveMaxSourceFileBytes(), resolveMaxParseRows(), resolveMaxChunkRows());
}
public CollectDataParseVo parseAndCreateTask(CollectDataParseRequest request) {
long startedAt = System.currentTimeMillis();
if (request == null || request.getUserId() == null || request.getUserId() <= 0) {
@@ -160,15 +229,18 @@ public class CollectDataService {
List<ParsedRow> parsedRows = new ArrayList<>();
int totalRows = 0;
int droppedRows = 0;
CollectDataParseLimits limits = buildParseLimits();
for (CollectDataSourceFileDto source : sources) {
File input = localFileStorageService.findLocalSourceFile(source.getFileKey());
if (input == null || !input.exists()) {
throw new BusinessException("源文件不存在");
}
limits.validateSourceFile(input);
ParsedWorkbook parsed = parseWorkbook(input, source);
totalRows += parsed.totalRows();
droppedRows += parsed.droppedRows();
parsedRows.addAll(parsed.rows());
limits.validateTotalRowCount(parsedRows.size());
}
if (parsedRows.isEmpty()) {
throw new BusinessException("未解析到有效数据行");
@@ -242,8 +314,14 @@ public class CollectDataService {
int rowIndex = 0;
LocalDateTime itemCreatedAt = LocalDateTime.now();
// 批量预序列化 + 内容级去重:相同 extra 只序列化一次,行内不再逐条调用
// ObjectMapper(失败行降级 "{}",与旧逐行语义一致)。
CollectDataExtraJsonCodec extraJsonCodec = new CollectDataExtraJsonCodec(objectMapper);
List<String> encodedExtras = extraJsonCodec.encodeAll(parsedRows.stream()
.map(ParsedRow::extra).toList());
List<CollectDataItemEntity> itemBatch = new ArrayList<>(Math.min(parsedRows.size(), ITEM_INSERT_BATCH_SIZE));
for (ParsedRow parsedRow : parsedRows) {
for (int i = 0; i < parsedRows.size(); i++) {
ParsedRow parsedRow = parsedRows.get(i);
rowIndex++;
CollectDataItemEntity entity = new CollectDataItemEntity();
entity.setTaskId(task.getId());
@@ -252,11 +330,7 @@ public class CollectDataService {
entity.setSourceFilename(parsedRow.sourceFilename());
entity.setKeyword(parsedRow.keyword());
entity.setStatusValue(parsedRow.statusValue());
try {
entity.setExtraJson(objectMapper.writeValueAsString(parsedRow.extra()));
} catch (Exception ex) {
entity.setExtraJson("{}");
}
entity.setExtraJson(encodedExtras.get(i));
entity.setCreatedAt(itemCreatedAt);
itemBatch.add(entity);
if (itemBatch.size() >= ITEM_INSERT_BATCH_SIZE) {
@@ -362,14 +436,37 @@ public class CollectDataService {
changed = true;
}
if (!changed) {
// 内容未变化:仅当脏数据兜底窗口到期时才强制刷新一次,
// 否则零 UPDATE(重复心跳幂等)。
if (!shouldForceProgressFlush()) {
return;
}
} else if (shouldThrottleProgressFlush()) {
// 节流窗口内:合并写(只更新内存态、不落库),窗口到期后统一持久化。
return;
}
persistStats(task, stats);
task.setUpdatedAt(LocalDateTime.now());
fileTaskMapper.updateById(task);
lastProgressFlushMillis = System.currentTimeMillis();
}
}
/**
* 节流判定:progressThrottleMillis>0 且距上次实际落库未超过窗口 → 合并写(跳过 UPDATE)。
* progressThrottleMillis<=0 视为关闭节流,恒返回 false(每次心跳都落库,兼容旧行为)。
*/
private boolean shouldThrottleProgressFlush() {
return progressThrottleMillis > 0
&& System.currentTimeMillis() - lastProgressFlushMillis < progressThrottleMillis;
}
/** 脏数据兜底判定:progressDirtyWindowMillis>0 且距上次实际落库超过窗口 → 强制刷新一次。 */
private boolean shouldForceProgressFlush() {
return progressDirtyWindowMillis > 0
&& System.currentTimeMillis() - lastProgressFlushMillis >= progressDirtyWindowMillis;
}
@Scheduled(cron = "${aiimage.collect-data.stale-check-cron:*/30 * * * * *}")
public void finalizeStaleTasks() {
long timeoutMinutes = Math.max(1L, staleTimeoutMinutes);
@@ -575,6 +672,7 @@ public class CollectDataService {
}
List<CollectDataResultRowVo> rows = normalizeSubmitRows(request.getItems());
buildParseLimits().validateChunkRowCount(rows.size());
CollectDataStats stats = loadStats(task);
stats.receivedRows += rows.size();
stats.currentChunkRows = rows.size();
@@ -588,10 +686,26 @@ public class CollectDataService {
rowsForFiltering.add(row);
}
}
List<CollectDataResultRowVo> candidates = filterByExistingAsin(rowsForFiltering, stats);
List<CollectDataResultRowVo> accepted = filterByBrandCheck(candidates, stats);
for (CollectDataResultRowVo row : accepted) {
upsertResultItem(task.getId(), result.getId(), scopeKey, row);
CollectDataBatchQuery.FilterResult filtered = collectDataBatchQuery.filter(rowsForFiltering);
stats.dedupeFilteredCount += filtered.dedupeFilteredCount();
stats.invalidFilteredCount += filtered.invalidFilteredCount();
List<CollectDataResultRowVo> accepted = filterByBrandCheck(filtered.kept(), stats);
// 结果明细改为 chunk 级存储:整个 chunk 的 accepted 行共享一个
// RustFS 对象(deterministic key,同 chunk 重提覆盖同一对象),
// biz_task_result_item.payload_json 只存 {chunk, offset, payload} 引用。
if (!accepted.isEmpty()) {
String detailJson = resultDetailCodec.encodeChunk(accepted);
String storedDetail = transientPayloadStorageService.storeResultPayload(
MODULE_TYPE, taskId, scopeHash, "chunk-" + chunkIndex, detailJson);
requireRustfsPayload(storedDetail, "采集结果明细必须写入 RustFS");
// 批量 upsert:先一次批量查现有行(payload_hash 相等即跳过,幂等),
// 再按唯一键 uk_task_scope_item 分批发 INSERT ... ON DUPLICATE KEY UPDATE。
// newlyInserted 是本 chunk 真实新增的行数,任务内增量累计得到 finalRowCount
// 替代每个 chunk 一次全表 COUNT(*),且与 chunk 乱序/重提无关。
CollectDataResultItemBatchWriter.UpsertCounts upsertCounts =
resultItemBatchWriter.upsertAccepted(task.getId(), result.getId(), scopeKey,
chunkIndex, accepted, storedDetail);
stats.finalRowCount += upsertCounts.newlyInserted();
}
String payloadJson = writeJson(rows, "采集结果序列化失败");
@@ -601,7 +715,6 @@ public class CollectDataService {
persistChunk(taskId, scopeKey, scopeHash, chunkIndex, chunkTotal, storedPayload, payloadJson);
persistScope(taskId, scopeKey, scopeHash, chunkTotal, request);
stats.finalRowCount = countFinalRows(taskId);
// Python 在 done=true 那次回传携带关键词级聚合统计;非空时按"最后一次为准"覆盖。
List<CollectDataSummaryRowDto> incomingSummaries = request.getSummaries();
if (incomingSummaries != null && !incomingSummaries.isEmpty()) {
@@ -672,203 +785,15 @@ public class CollectDataService {
return rows;
}
private List<CollectDataResultRowVo> filterByExistingAsin(List<CollectDataResultRowVo> rows, CollectDataStats stats) {
if (rows == null || rows.isEmpty()) {
return List.of();
}
List<String> asins = rows.stream()
.map(CollectDataResultRowVo::getAsin)
.filter(value -> value != null && !value.isBlank())
.distinct()
.toList();
Set<String> dedupeValues = new HashSet<>();
if (!asins.isEmpty()) {
List<String> existingDedupeValues = dedupeTotalDataMapper.selectExistingDataValues(asins);
if (existingDedupeValues != null) {
dedupeValues.addAll(existingDedupeValues.stream()
.map(this::normalizeAsin)
.toList());
}
}
List<String> brands = rows.stream()
.map(row -> normalizeBrand(row.getBrand()))
.filter(value -> !value.isBlank())
.distinct()
.toList();
Set<String> invalidBrands = new HashSet<>();
if (!brands.isEmpty()) {
List<InvalidAsinDataEntity> invalidRows = invalidAsinDataMapper.selectList(new LambdaQueryWrapper<InvalidAsinDataEntity>()
.select(InvalidAsinDataEntity::getBrand)
.in(InvalidAsinDataEntity::getBrand, brands));
if (invalidRows != null) {
for (InvalidAsinDataEntity row : invalidRows) {
String normalized = normalizeBrand(row.getBrand());
if (!normalized.isBlank()) {
invalidBrands.add(normalized);
}
}
}
}
List<CollectDataResultRowVo> out = new ArrayList<>();
for (CollectDataResultRowVo row : rows) {
if (dedupeValues.contains(row.getAsin())) {
stats.dedupeFilteredCount++;
continue;
}
String brand = normalizeBrand(row.getBrand());
if (!brand.isBlank() && invalidBrands.contains(brand)) {
stats.invalidFilteredCount++;
continue;
}
out.add(row);
}
return out;
}
private List<CollectDataResultRowVo> filterByBrandCheck(List<CollectDataResultRowVo> rows, CollectDataStats stats) {
if (rows == null || rows.isEmpty()) {
return List.of();
}
List<CollectDataResultRowVo> accepted = new ArrayList<>();
for (int start = 0; start < rows.size(); start += BRAND_CHECK_BATCH_SIZE) {
int end = Math.min(start + BRAND_CHECK_BATCH_SIZE, rows.size());
List<CollectDataResultRowVo> batch = rows.subList(start, end);
List<String> brands = batch.stream()
.map(CollectDataResultRowVo::getBrand)
.filter(value -> value != null && !value.isBlank())
.distinct()
.toList();
BrandCheckClient.BrandCheckBatchResult check = brandCheckClient.checkAll(brands, "Terms");
Set<String> failedBrands = normalizeObjectSet(check == null ? null : check.faildData());
Set<String> queryFailedBrands = normalizeObjectSet(check == null ? null : check.queryFaildData());
for (CollectDataResultRowVo row : batch) {
String brand = normalizeBrand(row.getBrand());
if (brand.isBlank()) {
stats.brandRejectedCount++;
insertInvalidAsin(row);
continue;
}
if (failedBrands.contains(brand)) {
stats.brandRejectedCount++;
insertInvalidAsin(row);
continue;
}
if (queryFailedBrands.contains(brand)) {
stats.brandQueryFailedCount++;
insertInvalidAsin(row);
continue;
}
accepted.add(row);
}
}
return accepted;
}
private Set<String> normalizeObjectSet(List<Object> values) {
Set<String> out = new HashSet<>();
if (values == null) {
return out;
}
for (Object value : values) {
String normalized = normalizeBrand(extractBrandValue(value));
if (!normalized.isBlank()) {
out.add(normalized);
}
}
return out;
}
private String extractBrandValue(Object value) {
if (value == null) {
return "";
}
if (value instanceof Map<?, ?> map) {
for (String key : List.of("brand", "brandName", "brand_name", "name", "value", "data_value")) {
Object candidate = map.get(key);
if (candidate != null && !String.valueOf(candidate).isBlank()) {
return String.valueOf(candidate);
}
}
}
return String.valueOf(value);
}
private void insertInvalidAsin(CollectDataResultRowVo row) {
if (row == null || row.getAsin() == null || row.getAsin().isBlank()) {
return;
}
String brand = normalizeBrand(row.getBrand());
if (brand.isBlank()) {
return;
}
InvalidAsinDataEntity entity = new InvalidAsinDataEntity();
entity.setDataValue(row.getAsin());
entity.setBrand(brand);
entity.setRecordSource("AUTO");
try {
invalidAsinDataMapper.insert(entity);
} catch (DuplicateKeyException ignored) {
}
}
private void upsertResultItem(Long taskId, Long resultId, String scopeKey, CollectDataResultRowVo row) {
String itemKey = "asin:" + row.getAsin();
String scopeHash = hash(scopeKey);
String payloadJson = writeJson(row, "采集结果明细序列化失败");
String payloadHash = hash(payloadJson);
TaskResultItemEntity existing = taskResultItemMapper.selectOne(new LambdaQueryWrapper<TaskResultItemEntity>()
.eq(TaskResultItemEntity::getTaskId, taskId)
.eq(TaskResultItemEntity::getModuleType, MODULE_TYPE)
.eq(TaskResultItemEntity::getScopeHash, scopeHash)
.eq(TaskResultItemEntity::getItemKey, itemKey)
.last("limit 1"));
if (existing != null && Objects.equals(existing.getPayloadHash(), payloadHash)) {
return;
}
String storedPayload = transientPayloadStorageService.storeResultItemPayload(
MODULE_TYPE, taskId, scopeHash, itemKey, payloadJson);
requireRustfsPayload(storedPayload, "采集结果明细必须写入 RustFS");
LocalDateTime now = LocalDateTime.now();
if (existing == null) {
TaskResultItemEntity entity = new TaskResultItemEntity();
entity.setTaskId(taskId);
entity.setModuleType(MODULE_TYPE);
entity.setResultId(resultId);
entity.setScopeKey(scopeKey);
entity.setScopeHash(scopeHash);
entity.setItemKey(itemKey);
entity.setAsin(row.getAsin());
entity.setStatus("ACCEPTED");
entity.setPayloadJson(storedPayload);
entity.setPayloadHash(payloadHash);
entity.setCreatedAt(now);
entity.setUpdatedAt(now);
try {
taskResultItemMapper.insert(entity);
return;
} catch (DuplicateKeyException ignored) {
existing = taskResultItemMapper.selectOne(new LambdaQueryWrapper<TaskResultItemEntity>()
.eq(TaskResultItemEntity::getTaskId, taskId)
.eq(TaskResultItemEntity::getModuleType, MODULE_TYPE)
.eq(TaskResultItemEntity::getScopeHash, scopeHash)
.eq(TaskResultItemEntity::getItemKey, itemKey)
.last("limit 1"));
}
}
if (existing == null) {
throw new BusinessException("保存采集结果明细失败");
}
transientPayloadStorageService.deleteReplacedPayloadIfNeeded(existing.getPayloadJson(), storedPayload);
taskResultItemMapper.update(null, new LambdaUpdateWrapper<TaskResultItemEntity>()
.eq(TaskResultItemEntity::getId, existing.getId())
.set(TaskResultItemEntity::getResultId, resultId)
.set(TaskResultItemEntity::getScopeKey, scopeKey)
.set(TaskResultItemEntity::getAsin, row.getAsin())
.set(TaskResultItemEntity::getStatus, "ACCEPTED")
.set(TaskResultItemEntity::getPayloadJson, storedPayload)
.set(TaskResultItemEntity::getPayloadHash, payloadHash)
.set(TaskResultItemEntity::getUpdatedAt, now));
CollectDataBrandBatchFilter.BrandBatchOutcome outcome = brandBatchFilter.filter(rows);
stats.brandRejectedCount += outcome.rejected().size();
stats.brandQueryFailedCount += outcome.queryFailed().size();
List<CollectDataResultRowVo> invalidRows = new ArrayList<>(outcome.rejected().size() + outcome.queryFailed().size());
invalidRows.addAll(outcome.rejected());
invalidRows.addAll(outcome.queryFailed());
invalidAsinBatchWriter.writeBatch(invalidRows);
return outcome.accepted();
}
private void persistChunk(Long taskId,
@@ -970,13 +895,13 @@ public class CollectDataService {
CollectDataStats stats = loadStats(task);
List<CollectDataResultRowVo> rows = loadFinalRows(task.getId());
// Sheet「结果文件」按需求基于 Python 回传的全量数据聚合,不经后端 ASIN/品牌过滤丢弃,
// 因此从 biz_task_chunk 反序列化全部原始行
List<CollectDataResultRowVo> rawRows = loadRawRows(task.getId());
// 因此从 biz_task_chunk 反序列化全部原始行rawRows 惰性加载,summaries 非空时
// 不加载(rawRows 与 finalRows 不同时长期驻留内存)。
File workRoot = FileUtil.mkdir(FileUtil.file(System.getProperty("java.io.tmpdir"), "collect-data-result", String.valueOf(task.getId())));
String filename = buildResultFilename(task, result);
File xlsx = FileUtil.file(workRoot, filename);
try {
excelAssemblyService.writeWorkbook(xlsx, rows, stats.summaries, rawRows);
excelAssemblyService.writeWorkbookSegmented(xlsx, rows, stats.summaries, () -> loadRawRows(task.getId()));
String objectKey = ossStorageService.uploadResultFile(xlsx, MODULE_TYPE);
result.setResultFilename(filename);
result.setResultFileUrl(objectKey);
@@ -1007,22 +932,16 @@ public class CollectDataService {
.eq(TaskResultItemEntity::getTaskId, taskId)
.eq(TaskResultItemEntity::getModuleType, MODULE_TYPE)
.orderByAsc(TaskResultItemEntity::getId));
List<CollectDataResultRowVo> out = new ArrayList<>();
if (rows == null) {
return out;
if (rows == null || rows.isEmpty()) {
return new ArrayList<>();
}
for (TaskResultItemEntity row : rows) {
try {
String payloadJson = transientPayloadStorageService.resolvePayload(row.getPayloadJson(), "read collect data result item failed");
CollectDataResultRowVo value = objectMapper.readValue(payloadJson, CollectDataResultRowVo.class);
if (value != null) {
out.add(value);
}
} catch (Exception ex) {
throw new BusinessException("读取采集结果明细失败");
}
try {
// 按 chunk 一次读取:同一 chunk 对象只 resolve 一次,按 offset 取行,
// 替代逐行对象读取(旧格式逐行兜底)。
return resultDetailReader.readRows(rows);
} catch (Exception ex) {
throw new BusinessException("读取采集结果明细失败", ex);
}
return out;
}
/**
@@ -1064,16 +983,6 @@ public class CollectDataService {
return out;
}
private int countFinalRows(Long taskId) {
if (taskId == null || taskId <= 0) {
return 0;
}
Long count = taskResultItemMapper.selectCount(new LambdaQueryWrapper<TaskResultItemEntity>()
.eq(TaskResultItemEntity::getTaskId, taskId)
.eq(TaskResultItemEntity::getModuleType, MODULE_TYPE));
return count == null ? 0 : count.intValue();
}
private void ensureRustfsPayloadStorageEnabled() {
if (!transientPayloadStorageService.isSharedWriteEnabled()) {
throw new BusinessException("RustFS 未配置,采集结果回传暂不可接收");
@@ -1129,7 +1038,6 @@ public class CollectDataService {
CollectDataSubmitResultRequest request,
int currentChunkRows) {
CollectDataStats stats = loadStats(task);
stats.finalRowCount = countFinalRows(task == null ? null : task.getId());
CollectDataSubmitResultVo vo = new CollectDataSubmitResultVo();
vo.setTaskId(task == null ? null : task.getId());
vo.setResultId(result == null ? null : result.getId());
@@ -1222,20 +1130,6 @@ public class CollectDataService {
}
}
private String hash(String value) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] bytes = digest.digest((value == null ? "" : value).getBytes(StandardCharsets.UTF_8));
StringBuilder sb = new StringBuilder(bytes.length * 2);
for (byte b : bytes) {
sb.append(String.format("%02x", b));
}
return sb.toString();
} catch (Exception ex) {
throw new IllegalStateException("failed to hash collect data payload", ex);
}
}
private String normalizeAsin(String value) {
return normalize(value).toUpperCase(Locale.ROOT);
}
@@ -1288,25 +1182,32 @@ public class CollectDataService {
return safe.isBlank() ? "collect-data" : safe;
}
private void deleteTransientTaskPayloads(Long taskId) {
if (taskId == null || taskId <= 0) {
return;
}
List<TaskChunkEntity> chunks = taskChunkMapper.selectList(new LambdaQueryWrapper<TaskChunkEntity>()
.select(TaskChunkEntity::getPayloadJson)
.eq(TaskChunkEntity::getTaskId, taskId)
.eq(TaskChunkEntity::getModuleType, MODULE_TYPE));
private void deleteTransientTaskPayloads(List<TaskChunkEntity> chunks, List<TaskResultItemEntity> items) {
if (chunks != null) {
Set<String> deleted = new HashSet<>();
for (TaskChunkEntity chunk : chunks) {
transientPayloadStorageService.deletePayloadIfPresent(chunk.getPayloadJson());
// 多 chunk 共享同一对象(deterministic key 残留场景)按值去重只删一次。
if (chunk.getPayloadJson() != null && deleted.add(chunk.getPayloadJson())) {
transientPayloadStorageService.deletePayloadIfPresent(chunk.getPayloadJson());
}
}
}
List<TaskResultItemEntity> items = taskResultItemMapper.selectList(new LambdaQueryWrapper<TaskResultItemEntity>()
.select(TaskResultItemEntity::getPayloadJson)
.eq(TaskResultItemEntity::getTaskId, taskId)
.eq(TaskResultItemEntity::getModuleType, MODULE_TYPE));
if (items != null) {
for (TaskResultItemEntity item : items) {
deleteResultItemPayloads(items);
}
/** 删除结果明细 payload:chunk 级引用按对象去重后各删一次,旧格式逐行删。 */
private void deleteResultItemPayloads(List<TaskResultItemEntity> items) {
if (items == null) {
return;
}
Set<String> deletedPointers = new HashSet<>();
for (TaskResultItemEntity item : items) {
CollectDataResultDetailCodec.ChunkRef ref = resultDetailCodec.parseRef(item.getPayloadJson());
if (ref != null) {
if (deletedPointers.add(ref.pointer())) {
transientPayloadStorageService.deletePayloadIfPresent(ref.pointer());
}
} else {
transientPayloadStorageService.deletePayloadIfPresent(item.getPayloadJson());
}
}
@@ -1340,7 +1241,8 @@ public class CollectDataService {
fileResultMapper.delete(new LambdaQueryWrapper<FileResultEntity>()
.eq(FileResultEntity::getTaskId, task.getId())
.eq(FileResultEntity::getModuleType, MODULE_TYPE));
deleteTransientTaskPayloads(task.getId());
// 先删 DB 行、再物理删对象:引用计数反查基于行删除后的状态,
// 仍被其它任务 chunk/scope_state 引用的共享对象会跳过删除,本任务独占对象真正释放。
taskScopeStateMapper.delete(new LambdaQueryWrapper<TaskScopeStateEntity>()
.eq(TaskScopeStateEntity::getTaskId, task.getId())
.eq(TaskScopeStateEntity::getModuleType, MODULE_TYPE));
@@ -1350,6 +1252,15 @@ public class CollectDataService {
taskResultItemMapper.delete(new LambdaQueryWrapper<TaskResultItemEntity>()
.eq(TaskResultItemEntity::getTaskId, task.getId())
.eq(TaskResultItemEntity::getModuleType, MODULE_TYPE));
deleteTransientTaskPayloads(
taskChunkMapper.selectList(new LambdaQueryWrapper<TaskChunkEntity>()
.select(TaskChunkEntity::getPayloadJson)
.eq(TaskChunkEntity::getTaskId, task.getId())
.eq(TaskChunkEntity::getModuleType, MODULE_TYPE)),
taskResultItemMapper.selectList(new LambdaQueryWrapper<TaskResultItemEntity>()
.select(TaskResultItemEntity::getPayloadJson)
.eq(TaskResultItemEntity::getTaskId, task.getId())
.eq(TaskResultItemEntity::getModuleType, MODULE_TYPE)));
taskFileJobService.deleteTaskJobs(task.getId(), MODULE_TYPE);
fileTaskMapper.deleteById(task.getId());
}
@@ -1364,15 +1275,12 @@ public class CollectDataService {
.eq(TaskResultItemEntity::getTaskId, row.getTaskId())
.eq(TaskResultItemEntity::getModuleType, MODULE_TYPE)
.eq(TaskResultItemEntity::getResultId, row.getId()));
if (resultItems != null) {
for (TaskResultItemEntity item : resultItems) {
transientPayloadStorageService.deletePayloadIfPresent(item.getPayloadJson());
}
}
taskResultItemMapper.delete(new LambdaQueryWrapper<TaskResultItemEntity>()
.eq(TaskResultItemEntity::getTaskId, row.getTaskId())
.eq(TaskResultItemEntity::getModuleType, MODULE_TYPE)
.eq(TaskResultItemEntity::getResultId, row.getId()));
// 与 deleteTask 一致:先删 DB 行再物理删对象,保证行删除与对象删除一致。
deleteResultItemPayloads(resultItems);
taskFileJobService.deleteResultJobs(row.getTaskId(), MODULE_TYPE, row.getId());
fileResultMapper.deleteById(resultId);
}
@@ -0,0 +1,149 @@
package com.nanri.aiimage.modules.collectdata.util;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.nanri.aiimage.modules.collectdata.model.vo.CollectDataResultRowVo;
import com.nanri.aiimage.modules.dedupe.mapper.DedupeTotalDataMapper;
import com.nanri.aiimage.modules.invalidasin.mapper.InvalidAsinDataMapper;
import com.nanri.aiimage.modules.invalidasin.model.entity.InvalidAsinDataEntity;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.regex.Pattern;
/**
* 将 ASIN 去重查询与无效品牌查询统一为批量集合查询:输入行集合,一次性
* 向 dedupe 表查询已存在 ASIN 集合、向 invalid_asin 表查询无效品牌集合,
* 再统一过滤。与逐行/两段式查询输出完全等价,但把每条集合查询的往返从
* 多次降为一次,并保证空输入、单元素与超限集合的确定行为。
*
* 过滤语义(与 CollectDataService 原 filterByExistingAsin 一致):
* - ASIN 存在于去重表 → dedupeFiltered(增加计数)
* - 品牌存在于无效品牌表 → invalidFiltered(增加计数)
* - 其余行保留
*/
@Component
@RequiredArgsConstructor
public class CollectDataBatchQuery {
private static final Pattern WHITESPACE_PATTERN = Pattern.compile("\\s+");
private final DedupeTotalDataMapper dedupeTotalDataMapper;
private final InvalidAsinDataMapper invalidAsinDataMapper;
/**
* 批量集合查询过滤(兼容入口)。返回过滤后的行列表;入参为 null/空时返回空列表。
*/
public List<CollectDataResultRowVo> filterByExistingAsin(List<CollectDataResultRowVo> rows) {
return filter(rows).kept();
}
/**
* 批量集合查询过滤并统计两类命中:去重表命中的 ASIN 行数与无效品牌表
* 命中的品牌行数(null/空行不计数)。入参为 null/空时返回空结果。
*/
public FilterResult filter(List<CollectDataResultRowVo> rows) {
if (rows == null || rows.isEmpty()) {
return new FilterResult(List.of(), 0, 0);
}
List<String> asins = distinctNonBlank(rows.stream()
.filter(row -> row != null)
.map(CollectDataResultRowVo::getAsin).toList());
Set<String> dedupeValues = new HashSet<>();
if (!asins.isEmpty()) {
List<String> existing = dedupeTotalDataMapper.selectExistingDataValues(asins);
if (existing != null) {
for (String value : existing) {
String normalized = normalizeAsin(value);
if (!normalized.isBlank()) {
dedupeValues.add(normalized);
}
}
}
}
List<String> brands = distinctNonBlank(rows.stream()
.filter(row -> row != null)
.map(row -> normalizeBrand(row.getBrand())).toList());
Set<String> invalidBrands = new HashSet<>();
if (!brands.isEmpty()) {
// 用列名 QueryWrapper 而非 LambdaQueryWrapper:单测不依赖 MyBatis-Plus
// 的 lambda 缓存(仅 Spring 上下文初始化),SQL 语义完全一致。
List<InvalidAsinDataEntity> invalidRows = invalidAsinDataMapper.selectList(
new QueryWrapper<InvalidAsinDataEntity>()
.select("brand")
.in("brand", brands));
if (invalidRows != null) {
for (InvalidAsinDataEntity entity : invalidRows) {
String normalized = normalizeBrand(entity.getBrand());
if (!normalized.isBlank()) {
invalidBrands.add(normalized);
}
}
}
}
List<CollectDataResultRowVo> out = new ArrayList<>(rows.size());
int dedupeFiltered = 0;
int invalidFiltered = 0;
for (CollectDataResultRowVo row : rows) {
if (row == null) {
continue;
}
if (dedupeValues.contains(row.getAsin())) {
dedupeFiltered++;
continue;
}
String brand = normalizeBrand(row.getBrand());
if (!brand.isBlank() && invalidBrands.contains(brand)) {
invalidFiltered++;
continue;
}
out.add(row);
}
return new FilterResult(out, dedupeFiltered, invalidFiltered);
}
/** 过滤结果:保留行 + 去重命中行数 + 无效品牌命中行数。 */
public record FilterResult(List<CollectDataResultRowVo> kept, int dedupeFilteredCount, int invalidFilteredCount) {
}
private static List<String> distinctNonBlank(List<String> values) {
List<String> distinct = new ArrayList<>();
Set<String> seen = new HashSet<>();
for (String value : values) {
if (value == null || value.isBlank()) {
continue;
}
if (seen.add(value)) {
distinct.add(value);
}
}
return distinct;
}
private static String normalizeAsin(String value) {
return normalize(value).toUpperCase(Locale.ROOT);
}
private static String normalizeBrand(String value) {
return normalize(value).toLowerCase(Locale.ROOT);
}
private static String normalize(String value) {
if (value == null) {
return "";
}
String normalized = value.replace(String.valueOf((char) 0xFEFF), "")
.replace((char) 0x3000, ' ')
.replace("\r\n", " ")
.replace("\r", " ")
.replace("\n", " ")
.replace("\t", " ")
.trim();
return WHITESPACE_PATTERN.matcher(normalized).replaceAll(" ");
}
}
@@ -0,0 +1,242 @@
package com.nanri.aiimage.modules.collectdata.util;
import com.nanri.aiimage.modules.brand.client.BrandCheckClient;
import com.nanri.aiimage.modules.collectdata.model.vo.CollectDataResultRowVo;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.regex.Pattern;
/**
* 品牌检查批次过滤器:按批次调用远程品牌检查,批次内品牌集合为空的批次
* 不发起任何 checkAll 远程调用(行直接归入 rejected,语义与空品牌行一致);
* 非空批次正常检查并按失败/查询失败/通过分类。分类语义与
* CollectDataService 原 filterByBrandCheck 完全等价,仅空品牌批次省掉
* 无效远程请求。远程调用抛错时该批次整组降级 queryFailed,不影响后续批次。
*
* 品牌判定结果在过滤器实例内短期缓存(同一任务多个 chunk 提交复用同一
* 实例):已判定的品牌再次出现时不再发起远程调用,避免同品牌重复请求。
* 远程抛错不写缓存(可恢复后重查);缓存有界,超限淘汰最旧条目。
*/
@Slf4j
@Component
public class CollectDataBrandBatchFilter {
private static final Pattern WHITESPACE_PATTERN = Pattern.compile("\\s+");
private static final String VERDICT_FAILED = "FAILED";
private static final String VERDICT_QUERY_FAILED = "QUERY_FAILED";
private static final String VERDICT_OK = "OK";
private final BrandCheckClient brandCheckClient;
private final int batchSize;
private final int cacheCapacity;
/** 品牌(小写标准化)→ 判定结果;access-order LRU,超限淘汰最旧。 */
private final Map<String, String> verdictCache;
public CollectDataBrandBatchFilter(BrandCheckClient brandCheckClient,
@Value("${aiimage.collect-data.brand-check-batch-size:10}") int batchSize) {
this(brandCheckClient, batchSize, 512);
}
@Autowired
public CollectDataBrandBatchFilter(BrandCheckClient brandCheckClient,
@Value("${aiimage.collect-data.brand-check-batch-size:10}") int batchSize,
@Value("${aiimage.collect-data.brand-check-cache-capacity:512}") int cacheCapacity) {
this.brandCheckClient = brandCheckClient;
this.batchSize = Math.max(1, batchSize);
this.cacheCapacity = Math.max(1, cacheCapacity);
this.verdictCache = new LinkedHashMap<>(Math.max(16, this.cacheCapacity / 2), 0.75f, true);
}
/**
* 按批次执行品牌检查并分类。null/空输入返回空结果;null 行安全跳过不计数。
*/
public BrandBatchOutcome filter(List<CollectDataResultRowVo> rows) {
List<CollectDataResultRowVo> rejected = new ArrayList<>();
List<CollectDataResultRowVo> queryFailed = new ArrayList<>();
List<CollectDataResultRowVo> accepted = new ArrayList<>();
if (rows == null || rows.isEmpty()) {
return new BrandBatchOutcome(rejected, queryFailed, accepted);
}
for (int start = 0; start < rows.size(); start += batchSize) {
int end = Math.min(start + batchSize, rows.size());
List<CollectDataResultRowVo> batch = rows.subList(start, end);
List<String> batchBrands = distinctNonBlank(batch.stream()
.filter(row -> row != null)
.map(CollectDataResultRowVo::getBrand).toList());
List<String> uncachedBrands = new ArrayList<>();
for (String brand : batchBrands) {
if (!verdictCache.containsKey(normalizeBrand(brand))) {
uncachedBrands.add(brand);
}
}
if (uncachedBrands.isEmpty() && !batchBrands.isEmpty()) {
// 本批次品牌全部命中缓存,无需远程调用。
classify(batch, verdictCache, rejected, queryFailed, accepted);
continue;
}
if (batchBrands.isEmpty()) {
// 空品牌批次:跳过远程检查,行直接归 rejected(与空品牌行语义一致)。
for (CollectDataResultRowVo row : batch) {
if (row != null) {
rejected.add(row);
}
}
continue;
}
Map<String, String> batchVerdicts = new LinkedHashMap<>();
for (String brand : batchBrands) {
String normalized = normalizeBrand(brand);
String cached = verdictCache.get(normalized);
if (cached != null) {
batchVerdicts.put(normalized, cached);
}
}
try {
batchVerdicts.putAll(checkAndCache(uncachedBrands));
} catch (RuntimeException ex) {
log.warn("[collect-data] brand check batch failed, degrade batch to queryFailed err={}", ex.getMessage());
for (CollectDataResultRowVo row : batch) {
if (row != null) {
queryFailed.add(row);
}
}
continue;
}
classify(batch, batchVerdicts, rejected, queryFailed, accepted);
}
return new BrandBatchOutcome(rejected, queryFailed, accepted);
}
/** 远程检查未缓存品牌并写入缓存;返回新查品牌(小写标准化)→ 判定映射。 */
private Map<String, String> checkAndCache(List<String> uncachedBrands) {
BrandCheckClient.BrandCheckBatchResult check = brandCheckClient.checkAll(uncachedBrands, "Terms");
Set<String> failedBrands = normalizeObjectSet(check == null ? null : check.faildData());
Set<String> queryFailedBrands = normalizeObjectSet(check == null ? null : check.queryFaildData());
Map<String, String> verdicts = new LinkedHashMap<>();
for (String brand : uncachedBrands) {
String normalized = normalizeBrand(brand);
String verdict;
if (failedBrands.contains(normalized)) {
verdict = VERDICT_FAILED;
} else if (queryFailedBrands.contains(normalized)) {
verdict = VERDICT_QUERY_FAILED;
} else {
verdict = VERDICT_OK;
}
putBounded(normalized, verdict);
verdicts.put(normalized, verdict);
}
return verdicts;
}
private void putBounded(String brand, String verdict) {
if (verdictCache.containsKey(brand)) {
return;
}
verdictCache.put(brand, verdict);
if (verdictCache.size() > cacheCapacity) {
var it = verdictCache.entrySet().iterator();
it.next();
it.remove();
}
}
private void classify(List<CollectDataResultRowVo> batch, Map<String, String> verdicts,
List<CollectDataResultRowVo> rejected,
List<CollectDataResultRowVo> queryFailed,
List<CollectDataResultRowVo> accepted) {
for (CollectDataResultRowVo row : batch) {
if (row == null) {
continue;
}
String brand = normalizeBrand(row.getBrand());
String verdict = verdicts.get(brand);
if (brand.isBlank() || VERDICT_FAILED.equals(verdict)) {
rejected.add(row);
} else if (VERDICT_QUERY_FAILED.equals(verdict)) {
queryFailed.add(row);
} else {
accepted.add(row);
}
}
}
/** 品牌检查分类结果:三类行互斥,顺序与输入一致。 */
public record BrandBatchOutcome(List<CollectDataResultRowVo> rejected,
List<CollectDataResultRowVo> queryFailed,
List<CollectDataResultRowVo> accepted) {
}
private static List<String> distinctNonBlank(List<String> values) {
List<String> distinct = new ArrayList<>();
Set<String> seen = new HashSet<>();
for (String value : values) {
if (value == null || value.isBlank()) {
continue;
}
if (seen.add(value)) {
distinct.add(value);
}
}
return distinct;
}
private static Set<String> normalizeObjectSet(List<Object> values) {
Set<String> out = new HashSet<>();
if (values == null) {
return out;
}
for (Object value : values) {
String normalized = normalizeBrand(extractBrandValue(value));
if (!normalized.isBlank()) {
out.add(normalized);
}
}
return out;
}
private static String extractBrandValue(Object value) {
if (value == null) {
return "";
}
if (value instanceof java.util.Map<?, ?> map) {
for (String key : List.of("brand", "brandName", "brand_name", "name", "value", "data_value")) {
Object candidate = map.get(key);
if (candidate != null && !String.valueOf(candidate).isBlank()) {
return String.valueOf(candidate);
}
}
}
return String.valueOf(value);
}
private static String normalizeBrand(String value) {
return normalize(value).toLowerCase(Locale.ROOT);
}
private static String normalize(String value) {
if (value == null) {
return "";
}
String normalized = value.replace(String.valueOf((char) 0xFEFF), "")
.replace((char) 0x3000, ' ')
.replace("\r\n", " ")
.replace("\r", " ")
.replace("\n", " ")
.replace("\t", " ")
.trim();
return WHITESPACE_PATTERN.matcher(normalized).replaceAll(" ");
}
}
@@ -0,0 +1,86 @@
package com.nanri.aiimage.modules.collectdata.util;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 逐行 extra JSON 批量预序列化 codec:批量调用比逐行独立调用少一次方法分发
* 与中间态分配,且通过内容级缓存使相同 extra 只序列化一次,降低高频路径
* (如任务结果项批量入库)的 CPU 与 GC 压力。
*
* 输出与逐行 objectMapper.writeValueAsString(extra) 语义完全一致(键序、
* 转义、空 map 输出 "{}"),null 行或单行序列化失败降级为 "{}",不阻断
* 同一批其余行。缓存按内容 key(含 null 哨兵)去重且容量有界,超限淘汰
* 最旧条目,避免长任务运行后缓存无界增长。
*/
@Slf4j
public class CollectDataExtraJsonCodec {
private static final String EMPTY_JSON = "{}";
private static final int DEFAULT_CACHE_CAPACITY = 512;
private final ObjectMapper objectMapper;
private final int cacheCapacity;
private final Map<Object, String> serializedCache;
public CollectDataExtraJsonCodec(ObjectMapper objectMapper) {
this(objectMapper, DEFAULT_CACHE_CAPACITY);
}
public CollectDataExtraJsonCodec(ObjectMapper objectMapper, int cacheCapacity) {
this.objectMapper = objectMapper;
this.cacheCapacity = cacheCapacity;
this.serializedCache = new LinkedHashMap<>(Math.max(16, cacheCapacity / 2), 0.75f, true);
this.serializedCache.put(EMPTY_JSON, EMPTY_JSON);
}
/**
* 批量编码 extra 列表,返回与输入顺序一致、长度一致的 JSON 字符串列表。
* null 行与序列化失败行降级为 "{}",不影响其余行。
*/
public List<String> encodeAll(List<? extends Map<String, ?>> extras) {
if (extras == null || extras.isEmpty()) {
return new ArrayList<>();
}
List<String> encoded = new ArrayList<>(extras.size());
for (Map<String, ?> extra : extras) {
encoded.add(encode(extra));
}
return encoded;
}
private String encode(Map<String, ?> extra) {
Object cacheKey = extra == null ? null : extra;
String cached = serializedCache.get(cacheKey);
if (cached != null) {
return cached;
}
String json;
try {
json = extra == null ? EMPTY_JSON : objectMapper.writeValueAsString(extra);
} catch (JsonProcessingException ex) {
log.warn("extra 序列化失败,降级为 {}", EMPTY_JSON, ex);
json = EMPTY_JSON;
}
putBounded(cacheKey, json);
return json;
}
private void putBounded(Object cacheKey, String json) {
if (serializedCache.containsKey(cacheKey)) {
return;
}
serializedCache.put(cacheKey, json);
if (serializedCache.size() > cacheCapacity) {
var it = serializedCache.entrySet().iterator();
it.next();
it.remove();
}
}
}
@@ -0,0 +1,94 @@
package com.nanri.aiimage.modules.collectdata.util;
import com.nanri.aiimage.modules.collectdata.model.vo.CollectDataResultRowVo;
import com.nanri.aiimage.modules.invalidasin.mapper.InvalidAsinDataMapper;
import com.nanri.aiimage.modules.invalidasin.model.entity.InvalidAsinDataEntity;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.regex.Pattern;
/**
* invalid ASIN 批量写入器:把不合规 ASIN 行按批次批量 INSERT IGNORE 写入
* biz_invalid_asin_data,替代逐行 insert + 捕获唯一键异常的旧路径。
* 幂等由唯一键 (data_value, brand) + INSERT IGNORE 语义保证;批量失败时
* 跳过该批(记录计数,不中断提交流程),恢复后继续后续批次。
* 空输入、无合法 data_value/brand 的行、null 行均安全跳过,不发起调用。
*/
@Slf4j
@Component
public class CollectDataInvalidAsinBatchWriter {
private static final Pattern WHITESPACE_PATTERN = Pattern.compile("\\s+");
private final InvalidAsinDataMapper invalidAsinDataMapper;
private final int batchSize;
public CollectDataInvalidAsinBatchWriter(InvalidAsinDataMapper invalidAsinDataMapper,
@Value("${aiimage.collect-data.invalid-asin-batch-size:100}") int batchSize) {
this.invalidAsinDataMapper = invalidAsinDataMapper;
this.batchSize = Math.max(1, batchSize);
}
/** 批量写入并返回实际尝试写入的行数(INSERT IGNORE 忽略重复,幂等)。 */
public int writeBatch(List<CollectDataResultRowVo> rows) {
if (rows == null || rows.isEmpty()) {
return 0;
}
int written = 0;
for (int start = 0; start < rows.size(); start += batchSize) {
int end = Math.min(start + batchSize, rows.size());
List<CollectDataResultRowVo> batch = rows.subList(start, end);
List<InvalidAsinDataEntity> entities = new ArrayList<>(batch.size());
for (CollectDataResultRowVo row : batch) {
InvalidAsinDataEntity entity = toEntity(row);
if (entity != null) {
entities.add(entity);
}
}
if (entities.isEmpty()) {
continue;
}
try {
written += invalidAsinDataMapper.insertBatchIgnore(entities);
} catch (RuntimeException ex) {
log.warn("[collect-data] invalid asin batch insert failed, skip batch err={}", ex.getMessage());
}
}
return written;
}
private InvalidAsinDataEntity toEntity(CollectDataResultRowVo row) {
if (row == null) {
return null;
}
String asin = normalize(row.getAsin());
String brand = normalize(row.getBrand()).toLowerCase(Locale.ROOT);
if (asin.isBlank() || brand.isBlank()) {
return null;
}
InvalidAsinDataEntity entity = new InvalidAsinDataEntity();
entity.setDataValue(asin);
entity.setBrand(brand);
entity.setRecordSource("AUTO");
return entity;
}
private static String normalize(String value) {
if (value == null) {
return "";
}
String normalized = value.replace(String.valueOf((char) 0xFEFF), "")
.replace((char) 0x3000, ' ')
.replace("\r\n", " ")
.replace("\r", " ")
.replace("\n", " ")
.replace("\t", " ")
.trim();
return WHITESPACE_PATTERN.matcher(normalized).replaceAll(" ");
}
}
@@ -0,0 +1,58 @@
package com.nanri.aiimage.modules.collectdata.util;
import com.nanri.aiimage.common.exception.BusinessException;
import lombok.extern.slf4j.Slf4j;
import java.io.File;
/**
* 采集解析资源上限:文件大小、累计行数与单 chunk 行数。
* 超过任一上限抛 BusinessException,防止解析任务无界增长;
* 校验只读不残留状态,重复执行结果一致。
*/
@Slf4j
public class CollectDataParseLimits {
private final long maxFileBytes;
private final int maxTotalRows;
private final int maxChunkRows;
public CollectDataParseLimits(long maxFileBytes, int maxTotalRows, int maxChunkRows) {
if (maxFileBytes <= 0) {
throw new IllegalArgumentException("maxFileBytes 必须为正数,实际 " + maxFileBytes);
}
if (maxTotalRows <= 0) {
throw new IllegalArgumentException("maxTotalRows 必须为正数,实际 " + maxTotalRows);
}
if (maxChunkRows <= 0) {
throw new IllegalArgumentException("maxChunkRows 必须为正数,实际 " + maxChunkRows);
}
this.maxFileBytes = maxFileBytes;
this.maxTotalRows = maxTotalRows;
this.maxChunkRows = maxChunkRows;
}
public void validateSourceFile(File file) {
if (file == null || !file.exists() || !file.isFile()) {
throw new BusinessException("源文件不存在");
}
long size = file.length();
if (size > maxFileBytes) {
log.warn("[collect-data] source file exceeds size limit file={} size={} max={}",
file.getName(), size, maxFileBytes);
throw new BusinessException("源文件大小超过上限 " + maxFileBytes + " 字节");
}
}
public void validateTotalRowCount(int totalRows) {
if (totalRows > maxTotalRows) {
throw new BusinessException("累计行数超过上限 " + maxTotalRows);
}
}
public void validateChunkRowCount(int chunkRows) {
if (chunkRows > maxChunkRows) {
throw new BusinessException("单 chunk 行数超过上限 " + maxChunkRows);
}
}
}
@@ -0,0 +1,145 @@
package com.nanri.aiimage.modules.collectdata.util;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.nanri.aiimage.modules.collectdata.model.vo.CollectDataResultRowVo;
import lombok.extern.slf4j.Slf4j;
import java.util.ArrayList;
import java.util.List;
/**
* 采集数据性能基线夹具:1k/10k 行、多个 chunk 和品牌检测场景的确定性生成器。
* 同一输入必然产生相同输出(幂等);品牌检测场景通过 failedBrandCount /
* queryFailedBrandCount 指定前 N 行落入失败/查询失败品牌,空品牌行用于
* 无品牌拒绝路径。上限约束:单次最多 MAX_ROWS 行,防止基线夹具无界内存增长。
*/
@Slf4j
public class CollectDataPerfFixture {
public static final int MAX_ROWS = 10000;
private static final String[] DELIVERY_METHODS = {"FBA", "FBM", "AMZ", ""};
private final ObjectMapper objectMapper;
public CollectDataPerfFixture(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
}
/**
* 生成 rowCount 行采集结果,按 keywordSet 循环分配关键词;
* 前 failedBrandCount 行落入失败品牌(brand-rejected 路径),
* 随后 queryFailedBrandCount 行落入查询失败品牌(query-failed 路径),
* 其余行使用有效品牌。asin 为空的行只在 failedBrandCount 之前按
* blankAsinCount 生成,用于空 ASIN 不进入过滤链的基线。
*/
public List<CollectDataResultRowVo> generateRows(int rowCount, List<String> keywordSet,
int failedBrandCount, int queryFailedBrandCount,
int blankAsinCount) {
if (rowCount < 0 || rowCount > MAX_ROWS) {
throw new IllegalArgumentException("rowCount 必须在 [0, " + MAX_ROWS + "] 范围内,实际 " + rowCount);
}
if (keywordSet == null || keywordSet.isEmpty()) {
throw new IllegalArgumentException("keywordSet 不能为空");
}
if (failedBrandCount < 0 || queryFailedBrandCount < 0 || blankAsinCount < 0) {
throw new IllegalArgumentException("品牌/空 ASIN 计数不能为负");
}
if (failedBrandCount + queryFailedBrandCount > rowCount) {
throw new IllegalArgumentException("失败品牌与查询失败品牌合计不能超过行数");
}
if (blankAsinCount > rowCount) {
throw new IllegalArgumentException("blankAsinCount 不能超过行数");
}
if (rowCount == 0) {
return new ArrayList<>();
}
List<CollectDataResultRowVo> rows = new ArrayList<>(rowCount);
for (int i = 0; i < rowCount; i++) {
rows.add(buildRow(i, keywordSet, failedBrandCount, queryFailedBrandCount, blankAsinCount));
}
return rows;
}
private CollectDataResultRowVo buildRow(int index, List<String> keywordSet,
int failedBrandCount, int queryFailedBrandCount,
int blankAsinCount) {
String keyword = keywordSet.get(index % keywordSet.size());
String brand;
if (index < failedBrandCount) {
brand = "RejectedBrand-" + (index % 10);
} else if (index < failedBrandCount + queryFailedBrandCount) {
brand = "QueryFailedBrand-" + (index % 10);
} else {
brand = "ValidBrand-" + (index % 50);
}
CollectDataResultRowVo row = new CollectDataResultRowVo();
row.setBrand(brand);
row.setAsin(blankAsinCount > index ? "" : deterministicAsin(index * 31L + keyword.hashCode()));
row.setPrice(String.format("%.2f", 1 + (index % 9900) / 100.0));
row.setSellerName("Seller-" + (index % 200));
row.setKeyword(keyword);
row.setDeliveryMethod(DELIVERY_METHODS[index % DELIVERY_METHODS.length]);
row.setPage(1 + index % 20);
return row;
}
/**
* 采样全量行 payload 大小、chunk 划分数与品牌检测场景行数统计。
* 序列化失败时向上抛出不产生部分结果。
*/
public Metrics samplePayload(List<CollectDataResultRowVo> rows, int chunkSize) {
if (rows == null) {
throw new IllegalArgumentException("rows 不能为 null");
}
if (chunkSize <= 0) {
throw new IllegalArgumentException("chunkSize 必须为正数,实际 " + chunkSize);
}
int failedBrandRows = 0;
int queryFailedBrandRows = 0;
int blankAsinRows = 0;
int blankBrandRows = 0;
for (CollectDataResultRowVo row : rows) {
if (row == null) {
continue;
}
String brand = row.getBrand();
if (brand == null || brand.isBlank()) {
blankBrandRows++;
} else if (brand.startsWith("RejectedBrand-")) {
failedBrandRows++;
} else if (brand.startsWith("QueryFailedBrand-")) {
queryFailedBrandRows++;
}
if (row.getAsin() == null || row.getAsin().isBlank()) {
blankAsinRows++;
}
}
int chunkCount = rows.isEmpty() ? 0 : (rows.size() + chunkSize - 1) / chunkSize;
if (rows.isEmpty()) {
return new Metrics(0, 0, 0, 0, 0, 0, 0);
}
try {
byte[] bytes = objectMapper.writeValueAsBytes(rows);
return new Metrics(rows.size(), chunkCount, bytes.length,
failedBrandRows, queryFailedBrandRows, blankAsinRows, blankBrandRows);
} catch (Exception ex) {
throw new IllegalStateException("采集数据基线 payload 采样序列化失败", ex);
}
}
public record Metrics(int rowCount, int chunkCount, long payloadBytes,
int failedBrandRows, int queryFailedBrandRows,
int blankAsinRows, int blankBrandRows) {
}
private static String deterministicAsin(long seed) {
StringBuilder sb = new StringBuilder("B0");
long state = seed & 0x7fffffffL;
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,160 @@
package com.nanri.aiimage.modules.collectdata.util;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.nanri.aiimage.modules.collectdata.model.vo.CollectDataResultRowVo;
import org.springframework.stereotype.Component;
import java.security.MessageDigest;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
/**
* 结果明细 chunk 级编解码:把整 chunk 的 accepted 行序列化为一个明细数组
* JSON(每个 chunk 只写一个 RustFS 对象,替代逐行 storeResultItemPayload),
* 并为每行生成 {chunk, offset, payload} 引用 JSON 写入
* biz_task_result_item.payload_json。读侧按引用一次解析数组、offset 取行,
* 避免为每个 accepted 行单独发起对象存储读写。
*
* 旧格式兼容:parseRef 对非引用内容(逐行行 JSON / 数组 / 裸文本 / 损坏
* JSON)返回 null,由调用方按逐行旧路径兜底读取;rowAt 越界/损坏安全
* 返回 null。
*/
@Component
public class CollectDataResultDetailCodec {
public static final String REF_FIELD_CHUNK = "chunk";
public static final String REF_FIELD_OFFSET = "offset";
public static final String REF_FIELD_PAYLOAD = "payload";
/** 单次批量引用生成的上限:与结果明细 batch size 同量级,拒绝前不分配结果。 */
public static final int MAX_REFS_PER_BATCH = 100_000;
/** 一批引用及各自 hashrefJson 为行引用 JSONpayloadHash 为其 SHA-256。 */
public record RefWithHash(String refJson, String payloadHash) {
}
private final ObjectMapper objectMapper;
public CollectDataResultDetailCodec(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
}
/** 生成引用 JSON{"chunk":N,"offset":M,"payload":"<RustFS 指针>"}。 */
public String encodeRef(int chunkIndex, int offset, String pointer) {
if (chunkIndex < 0 || offset < 0 || pointer == null || pointer.isBlank()) {
throw new IllegalArgumentException("invalid chunk detail ref chunk=" + chunkIndex
+ " offset=" + offset + " pointer=" + pointer);
}
try {
return objectMapper.writeValueAsString(new ChunkRef(chunkIndex, offset, pointer));
} catch (Exception ex) {
throw new IllegalArgumentException("encode chunk detail ref failed", ex);
}
}
/**
* 解析引用 JSON;内容不是引用格式(旧逐行行 JSON、数组、裸文本、
* 损坏 JSON)时返回 null,由调用方按旧格式兜底。
*/
public ChunkRef parseRef(String refJson) {
if (refJson == null || refJson.isBlank()) {
return null;
}
try {
JsonNode node = objectMapper.readTree(refJson);
if (node == null || !node.isObject()
|| !node.hasNonNull(REF_FIELD_CHUNK)
|| !node.hasNonNull(REF_FIELD_OFFSET)
|| !node.hasNonNull(REF_FIELD_PAYLOAD)) {
return null;
}
int chunkIndex = node.get(REF_FIELD_CHUNK).asInt();
int offset = node.get(REF_FIELD_OFFSET).asInt();
String pointer = node.get(REF_FIELD_PAYLOAD).asText();
if (chunkIndex < 0 || offset < 0 || pointer.isBlank()) {
return null;
}
return new ChunkRef(chunkIndex, offset, pointer);
} catch (Exception ex) {
return null;
}
}
/** 把整 chunk 行序列化为明细数组 JSON;null/空输入编码为空数组。 */
public String encodeChunk(List<?> rows) {
try {
return objectMapper.writeValueAsString(rows == null ? List.of() : rows);
} catch (Exception ex) {
throw new IllegalArgumentException("encode chunk detail failed", ex);
}
}
/**
* 从 chunk 明细数组 JSON 中按 offset 取行;offset 越界/负值、明细
* 损坏或非数组时返回 null。
*/
public CollectDataResultRowVo rowAt(String detailJson, int offset) {
if (detailJson == null || detailJson.isBlank() || offset < 0) {
return null;
}
try {
JsonNode node = objectMapper.readTree(detailJson);
if (node == null || !node.isArray() || offset >= node.size()) {
return null;
}
return objectMapper.treeToValue(node.get(offset), CollectDataResultRowVo.class);
} catch (Exception ex) {
return null;
}
}
/**
* 批量生成整 chunk 的引用 JSON 及其 SHA-256 hashoffset 从 startOffset
* 递增共 count 行,一次迭代完成序列化 + hash,替代调用方逐行两轮循环。
* 非法参数(负 chunk/负 startOffset/负 count/null 或空白指针)抛
* IllegalArgumentExceptioncount 超过 MAX_REFS_PER_BATCH 时在分配
* 结果前拒绝,不发生无界内存增长。
*/
public List<RefWithHash> encodeRefsWithHash(int chunkIndex, int startOffset, int count, String pointer) {
if (chunkIndex < 0 || startOffset < 0 || count < 0 || pointer == null || pointer.isBlank()) {
throw new IllegalArgumentException("invalid chunk detail ref batch chunk=" + chunkIndex
+ " startOffset=" + startOffset + " count=" + count + " pointer=" + pointer);
}
if (count > MAX_REFS_PER_BATCH) {
throw new IllegalArgumentException("chunk detail ref batch too large count=" + count
+ " max=" + MAX_REFS_PER_BATCH);
}
if (count == 0) {
return List.of();
}
List<RefWithHash> refs = new ArrayList<>(count);
for (int offset = startOffset; offset < startOffset + count; offset++) {
String refJson = encodeRef(chunkIndex, offset, pointer);
refs.add(new RefWithHash(refJson, sha256(refJson)));
}
return refs;
}
/** 结果明细行引用:chunk 序号 + 行内 offset + chunk 明细对象指针。 */
public record ChunkRef(@JsonProperty(REF_FIELD_CHUNK) int chunkIndex,
@JsonProperty(REF_FIELD_OFFSET) int offset,
@JsonProperty(REF_FIELD_PAYLOAD) String pointer) {
}
private static String sha256(String value) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] bytes = digest.digest((value == null ? "" : value).getBytes(StandardCharsets.UTF_8));
StringBuilder sb = new StringBuilder(bytes.length * 2);
for (byte b : bytes) {
sb.append(String.format("%02x", b));
}
return sb.toString();
} catch (Exception ex) {
throw new IllegalStateException("chunk detail ref hash failed", ex);
}
}
}
@@ -0,0 +1,102 @@
package com.nanri.aiimage.modules.collectdata.util;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.nanri.aiimage.modules.collectdata.model.vo.CollectDataResultRowVo;
import com.nanri.aiimage.modules.task.model.entity.TaskResultItemEntity;
import com.nanri.aiimage.modules.task.service.TransientPayloadStorageService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 结果明细 chunk 级读取器:生成结果文件时按 chunk 一次读取整对象,
* 同一 chunk 的明细只 resolve/解析一次,再按 offset 取行,替代逐行
* 对象读取。旧格式(payload_json 直接是行 JSON)逐行兜底;空输入返回
* 空列表,越界/损坏行安全跳过,读取失败抛可识别异常。
*/
@Component
@Slf4j
public class CollectDataResultDetailReader {
private static final String ERROR_DETAIL = "read collect data result detail failed";
private static final String ERROR_ITEM = "read collect data result item failed";
private final CollectDataResultDetailCodec resultDetailCodec;
private final ObjectMapper objectMapper;
private final TransientPayloadStorageService transientPayloadStorageService;
private final TypeReference<List<CollectDataResultRowVo>> listType = new TypeReference<>() {
};
public CollectDataResultDetailReader(CollectDataResultDetailCodec resultDetailCodec,
ObjectMapper objectMapper,
TransientPayloadStorageService transientPayloadStorageService) {
this.resultDetailCodec = resultDetailCodec;
this.objectMapper = objectMapper;
this.transientPayloadStorageService = transientPayloadStorageService;
}
public List<CollectDataResultRowVo> readRows(List<TaskResultItemEntity> items) {
List<CollectDataResultRowVo> out = new ArrayList<>();
if (items == null || items.isEmpty()) {
return out;
}
// chunk 级引用:同一 chunk 对象只 resolve + 解析一次,按 offset 取行。
Map<String, List<CollectDataResultRowVo>> detailCache = new HashMap<>();
for (TaskResultItemEntity item : items) {
if (item == null || item.getPayloadJson() == null || item.getPayloadJson().isBlank()) {
continue;
}
try {
CollectDataResultDetailCodec.ChunkRef ref = resultDetailCodec.parseRef(item.getPayloadJson());
if (ref != null) {
List<CollectDataResultRowVo> details = detailCache.get(ref.pointer());
if (details == null) {
try {
String detailJson = transientPayloadStorageService.resolvePayload(ref.pointer(), ERROR_DETAIL);
// 空内容用空列表占位:与缓存 miss 区分,避免同一 chunk 重复 resolve。
details = (detailJson == null || detailJson.isBlank())
? List.of()
: objectMapper.readValue(detailJson, listType);
} catch (Exception readEx) {
// RustFS 超时/不可用等读取失败:跳过该 chunk 的引用行,
// 不中断其它 chunk 的行读取(结果文件降级生成,缺失行
// 由任务状态可观测);chunk 明细 JSON 损坏则正常上报,
// 属于数据问题而非依赖降级。
if (readEx instanceof com.fasterxml.jackson.core.JsonProcessingException) {
throw readEx;
}
log.warn("[collect-data] skip chunk detail read failed pointer={} err={}",
ref.pointer(), readEx.getMessage());
details = List.of();
}
detailCache.put(ref.pointer(), details);
}
if (details != null && ref.offset() >= 0 && ref.offset() < details.size()) {
CollectDataResultRowVo value = details.get(ref.offset());
if (value != null) {
out.add(value);
}
}
continue;
}
// 旧格式:payload_json 直接是行 JSON;内容缺失时安全跳过。
String payloadJson = transientPayloadStorageService.resolvePayload(item.getPayloadJson(), ERROR_ITEM);
if (payloadJson == null || payloadJson.isBlank()) {
continue;
}
CollectDataResultRowVo value = objectMapper.readValue(payloadJson, CollectDataResultRowVo.class);
if (value != null) {
out.add(value);
}
} catch (Exception ex) {
throw new IllegalStateException("读取采集结果明细失败", ex);
}
}
return out;
}
}
@@ -0,0 +1,162 @@
package com.nanri.aiimage.modules.collectdata.util;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.nanri.aiimage.modules.collectdata.model.vo.CollectDataResultRowVo;
import com.nanri.aiimage.modules.task.mapper.TaskResultItemMapper;
import com.nanri.aiimage.modules.task.model.entity.TaskResultItemEntity;
import com.nanri.aiimage.modules.task.service.TransientPayloadStorageService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
/**
* 结果明细批量 upsert:把整 chunk 的 accepted 行分批发往
* biz_task_result_item。先一次批量查询现有行(payload_hash 相等即跳过,
* 幂等),再按唯一键 uk_task_scope_item 用一条 INSERT ... ON DUPLICATE
* KEY UPDATE 批量写入,替代逐行 select/insert/update;批量失败跳过该批
* 可恢复,不中断任务流程。
*/
@Slf4j
@Component
public class CollectDataResultItemBatchWriter {
private static final String MODULE_TYPE = "collectdata";
private static final int DEFAULT_BATCH_SIZE = 100;
private final TaskResultItemMapper taskResultItemMapper;
private final CollectDataResultDetailCodec resultDetailCodec;
private final TransientPayloadStorageService transientPayloadStorageService;
private final int batchSize;
public CollectDataResultItemBatchWriter(TaskResultItemMapper taskResultItemMapper,
CollectDataResultDetailCodec resultDetailCodec,
TransientPayloadStorageService transientPayloadStorageService,
@Value("${aiimage.collect-data.result-item-batch-size:100}") int batchSize) {
this.taskResultItemMapper = taskResultItemMapper;
this.resultDetailCodec = resultDetailCodec;
this.transientPayloadStorageService = transientPayloadStorageService;
this.batchSize = batchSize <= 0 ? DEFAULT_BATCH_SIZE : batchSize;
}
/**
* 批量写入计数:
* insertedOrUpdated 由 mapper 返回的 affected 行数累加(INSERT=1、存量更新可能为 2,
* 仅用于诊断日志);
* newlyInserted 是本次调用真实新增的行数(仅原本不存在的行,hash 相等跳过与存量
* 更新均不计入,批量失败扣除未写入的新行),供调用方做任务内 finalRowCount 增量累计。
*/
public record UpsertCounts(int insertedOrUpdated, int skipped, int newlyInserted) {
}
/** 把整 chunk 的 accepted 行批量 upsertscopeKey 与 chunk 内 offset 已知,仅计算 refJson 与 hash。 */
public UpsertCounts upsertAccepted(Long taskId, Long resultId, String scopeKey, int chunkIndex,
List<CollectDataResultRowVo> rows, String storedDetail) {
if (rows == null || rows.isEmpty()) {
return new UpsertCounts(0, 0, 0);
}
String scopeHash = sha256(scopeKey);
// 一次性取回本 scope 现有行,构建 item_key → 现有行 映射(hash 相等即跳过)。
List<TaskResultItemEntity> existingList = taskResultItemMapper.selectList(
new LambdaQueryWrapper<TaskResultItemEntity>()
.eq(TaskResultItemEntity::getTaskId, taskId)
.eq(TaskResultItemEntity::getModuleType, MODULE_TYPE)
.eq(TaskResultItemEntity::getScopeHash, scopeHash));
Map<String, TaskResultItemEntity> existingByKey = new HashMap<>();
for (TaskResultItemEntity existing : existingList) {
if (existing != null && existing.getItemKey() != null) {
existingByKey.put(existing.getItemKey(), existing);
}
}
List<TaskResultItemEntity> toUpsert = new ArrayList<>();
int skipped = 0;
// 仅原本不存在的行(真 INSERT)计入 newlyInserted;存量行 hash 不同触发 UPDATE
// 时表内行数不变,不计入,避免 finalRowCount 增量虚高。
int newlyInserted = 0;
LocalDateTime now = LocalDateTime.now();
// 批量生成整 chunk 的引用 JSON + hash(一次迭代),替代逐行 encodeRef + hash。
List<CollectDataResultDetailCodec.RefWithHash> refsWithHash =
resultDetailCodec.encodeRefsWithHash(chunkIndex, 0, rows.size(), storedDetail);
for (int offset = 0; offset < rows.size(); offset++) {
CollectDataResultRowVo row = rows.get(offset);
if (row == null || row.getAsin() == null || row.getAsin().isBlank()) {
continue;
}
String itemKey = "asin:" + row.getAsin();
// chunk 级引用共享同一 RustFS 对象(deterministic key,同 chunk 重提
// 覆盖同一对象,引用 pointer 稳定,无需删除)。
String refJson = refsWithHash.get(offset).refJson();
String payloadHash = refsWithHash.get(offset).payloadHash();
TaskResultItemEntity existing = existingByKey.get(itemKey);
if (existing != null && Objects.equals(existing.getPayloadHash(), payloadHash)) {
skipped++;
continue;
}
// 旧格式逐行对象在升级为引用后不再被任何行持有,直接物理删除避免泄漏
// deletePayloadIfPresent 内部带全局引用计数兜底)。
if (existing != null && existing.getPayloadJson() != null
&& resultDetailCodec.parseRef(existing.getPayloadJson()) == null) {
transientPayloadStorageService.deletePayloadIfPresent(existing.getPayloadJson());
}
TaskResultItemEntity entity = new TaskResultItemEntity();
entity.setId(existing == null ? null : existing.getId());
entity.setTaskId(taskId);
entity.setModuleType(MODULE_TYPE);
entity.setResultId(resultId);
entity.setScopeKey(scopeKey);
entity.setScopeHash(scopeHash);
entity.setItemKey(itemKey);
entity.setAsin(row.getAsin());
entity.setStatus("ACCEPTED");
entity.setPayloadJson(refJson);
entity.setPayloadHash(payloadHash);
entity.setCreatedAt(existing == null ? now : existing.getCreatedAt());
entity.setUpdatedAt(now);
toUpsert.add(entity);
if (existing == null) {
newlyInserted++;
}
}
int written = 0;
for (int from = 0; from < toUpsert.size(); from += batchSize) {
int to = Math.min(from + batchSize, toUpsert.size());
List<TaskResultItemEntity> batch = toUpsert.subList(from, to);
try {
written += taskResultItemMapper.upsertBatch(batch);
} catch (RuntimeException ex) {
log.warn("[collect-data] upsert result item batch failed, skip batch {}..{} taskId={}",
from, to, taskId, ex);
// 失败批的新行未落库,从增量计数中扣除,避免任务内累计虚高;
// 重提该 chunk 时按存量 hash 跳过已落库行、补插未落库行,累计收敛到真实行数。
for (TaskResultItemEntity entity : batch) {
if (entity.getId() == null) {
newlyInserted--;
}
}
}
}
return new UpsertCounts(written, skipped, newlyInserted);
}
private static String sha256(String value) {
try {
java.security.MessageDigest digest = java.security.MessageDigest.getInstance("SHA-256");
byte[] bytes = digest.digest((value == null ? "" : value).getBytes(java.nio.charset.StandardCharsets.UTF_8));
StringBuilder sb = new StringBuilder(bytes.length * 2);
for (byte b : bytes) {
sb.append(String.format("%02x", b));
}
return sb.toString();
} catch (Exception ex) {
throw new IllegalStateException("结果明细 hash 计算失败", ex);
}
}
}
@@ -1,7 +1,7 @@
package com.nanri.aiimage.modules.deletebrand.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.nanri.aiimage.config.TaskPressureProperties;
import com.nanri.aiimage.modules.task.util.TaskEntityLocalCache;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
@@ -26,10 +26,9 @@ public class DeleteBrandTaskCacheService {
private final StringRedisTemplate stringRedisTemplate;
private final ObjectMapper objectMapper;
private final TaskPressureProperties taskPressureProperties;
private final ConcurrentHashMap<Long, LocalProgressCacheEntry> progressLocalCache = new ConcurrentHashMap<>();
private final ConcurrentHashMap<Long, Long> progressRedisFlushAt = new ConcurrentHashMap<>();
private final ConcurrentHashMap<Long, LocalTaskEntityCacheEntry> taskEntityLocalCache = new ConcurrentHashMap<>();
private final TaskEntityLocalCache taskEntityLocalCache;
public void saveProgress(Long taskId, java.util.Map<String, String> values) {
saveProgress(taskId, values, false);
@@ -149,7 +148,7 @@ public class DeleteBrandTaskCacheService {
public void delete(Long taskId) {
progressLocalCache.remove(taskId);
progressRedisFlushAt.remove(taskId);
taskEntityLocalCache.remove(taskId);
taskEntityLocalCache.evict(taskId);
try {
stringRedisTemplate.delete(buildProgressKey(taskId));
stringRedisTemplate.delete(buildTaskEntityKey(taskId));
@@ -162,11 +161,7 @@ public class DeleteBrandTaskCacheService {
if (task == null || task.getId() == null) {
return;
}
long now = System.currentTimeMillis();
taskEntityLocalCache.put(task.getId(), new LocalTaskEntityCacheEntry(
now,
objectMapper.convertValue(task, com.nanri.aiimage.modules.task.model.entity.FileTaskEntity.class)
));
taskEntityLocalCache.put(task.getId(), task);
try {
stringRedisTemplate.opsForValue().set(
buildTaskEntityKey(task.getId()),
@@ -192,10 +187,9 @@ public class DeleteBrandTaskCacheService {
long now = System.currentTimeMillis();
java.util.List<Long> missingIds = new java.util.ArrayList<>();
for (Long taskId : normalized) {
LocalTaskEntityCacheEntry cached = taskEntityLocalCache.get(taskId);
if (isLocalTaskEntityCacheFresh(cached, now)) {
result.put(taskId, objectMapper.convertValue(
cached.task(), com.nanri.aiimage.modules.task.model.entity.FileTaskEntity.class));
TaskEntityLocalCache.Entry cached = taskEntityLocalCache.get(taskId, now);
if (cached != null) {
result.put(taskId, cached.task());
} else {
missingIds.add(taskId);
}
@@ -221,7 +215,7 @@ public class DeleteBrandTaskCacheService {
com.nanri.aiimage.modules.task.model.entity.FileTaskEntity task =
objectMapper.readValue(val, com.nanri.aiimage.modules.task.model.entity.FileTaskEntity.class);
result.put(taskId, task);
taskEntityLocalCache.put(taskId, new LocalTaskEntityCacheEntry(now, task));
taskEntityLocalCache.put(taskId, task);
} catch (Exception ignored) {
}
}
@@ -239,16 +233,6 @@ public class DeleteBrandTaskCacheService {
}
}
private record LocalTaskEntityCacheEntry(
long cachedAtMillis,
com.nanri.aiimage.modules.task.model.entity.FileTaskEntity task
) {}
private boolean isLocalTaskEntityCacheFresh(LocalTaskEntityCacheEntry cached, long now) {
return cached != null
&& now - cached.cachedAtMillis() <= Math.max(0L, taskPressureProperties.getLocalTaskEntityCacheMillis());
}
private java.util.Map<String, String> toStringMap(java.util.Map<Object, Object> values) {
java.util.Map<String, String> converted = new java.util.LinkedHashMap<>();
for (java.util.Map.Entry<Object, Object> entry : values.entrySet()) {
@@ -20,6 +20,7 @@ import java.io.FileInputStream;
import java.io.IOException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@@ -27,8 +28,15 @@ import java.util.Map;
@RequiredArgsConstructor
public class LocalFileStorageService {
/** fileKey → 文件名 索引容量:超限淘汰最旧条目,避免索引自身无界增长。 */
static final int SOURCE_FILE_INDEX_CAPACITY = 1024;
private final StorageProperties storageProperties;
/** 源文件确定路径索引:saveTempFile 写入后登记,查找优先命中,兜底目录枚举。 */
private final Map<String, String> sourceFileIndex =
new LinkedHashMap<>(16, 0.75f, true);
public UploadFileVo saveTempFile(MultipartFile file, String relativePath) throws IOException {
File tempDir = FileUtil.file(storageProperties.getLocalTempDir());
File parentDir = tempDir.getParentFile();
@@ -38,8 +46,10 @@ public class LocalFileStorageService {
FileUtil.mkdir(tempDir);
String fileKey = IdUtil.fastSimpleUUID();
String extName = FileUtil.extName(file.getOriginalFilename());
File target = FileUtil.file(tempDir, fileKey + (extName.isEmpty() ? "" : "." + extName));
String filename = fileKey + (extName.isEmpty() ? "" : "." + extName);
File target = FileUtil.file(tempDir, filename);
file.transferTo(target);
registerSourceFileIndex(fileKey, filename);
UploadFileVo vo = new UploadFileVo();
vo.setFileKey(fileKey);
@@ -97,25 +107,65 @@ public class LocalFileStorageService {
}
/**
* 店铺源文件 key → 确定路径解析:saveTempFile 始终把源文件平铺写入
* localTempDir/<fileKey>[.<ext>],因此这里只列举临时目录根层(非递归)
* 匹配 name == fileKey 或 fileKey.<ext> 的直接子文件,
* 取代原 FileUtil.loopFiles 对整棵临时目录树的递归前缀扫描。
* 店铺源文件 key → 确定路径解析:优先按索引直接构造 fileKey[.<ext>] 路径,
* 索引缺失/过期(进程重启或文件被清理)时兜底枚举临时目录根层(非递归)
* 索引与兜底都只允许根层平铺文件命中,子目录同名文件不属于 key 映射。
*/
public File findLocalSourceFile(String fileKey) {
if (fileKey == null || fileKey.isBlank()) {
if (fileKey == null || fileKey.isBlank() || !isPlainKey(fileKey)) {
return null;
}
File baseDir = FileUtil.file(storageProperties.getLocalTempDir());
if (!baseDir.exists()) {
return null;
}
String indexedName = sourceFileIndex.get(fileKey);
if (indexedName != null && isPlainName(indexedName)) {
File indexed = FileUtil.file(baseDir, indexedName);
if (indexed.isFile()) {
return indexed;
}
sourceFileIndex.remove(fileKey);
}
File[] matchedFiles = baseDir.listFiles(pathname -> pathname.isFile()
&& (pathname.getName().equals(fileKey) || pathname.getName().startsWith(fileKey + ".")));
if (matchedFiles == null) {
if (matchedFiles == null || matchedFiles.length == 0) {
return null;
}
return matchedFiles.length == 0 ? null : matchedFiles[0];
File resolved = matchedFiles[0];
registerSourceFileIndex(fileKey, resolved.getName());
return resolved;
}
private void registerSourceFileIndex(String fileKey, String filename) {
synchronized (sourceFileIndex) {
sourceFileIndex.put(fileKey, filename);
if (sourceFileIndex.size() > SOURCE_FILE_INDEX_CAPACITY) {
var it = sourceFileIndex.entrySet().iterator();
if (it.hasNext()) {
it.next();
it.remove();
}
}
}
}
/** 只允许单段 UUID 风格 key,防止路径穿越/分隔符注入。 */
private static boolean isPlainKey(String key) {
for (int i = 0; i < key.length(); i++) {
char c = key.charAt(i);
if (!(Character.isLetterOrDigit(c) || c == '-' || c == '_')) {
return false;
}
}
return true;
}
/** 索引文件名必须是根层单段名(无分隔符),防止索引被污染后路径穿越。 */
private static boolean isPlainName(String name) {
return name != null && !name.isBlank()
&& name.indexOf('/') < 0 && name.indexOf('\\') < 0
&& !name.equals(".") && !name.equals("..");
}
private String normalizeCellText(String value) {
@@ -35,6 +35,7 @@ public class RustfsObjectStorageService {
private static final String OP_READ = "read";
private static final String OP_DELETE = "delete";
private static final String OP_STAT = "stat";
private static final String OP_TOTAL = "total";
private final TransientStorageProperties properties;
private final ObjectProvider<MeterRegistry> meterRegistryProvider;
@@ -43,10 +44,16 @@ public class RustfsObjectStorageService {
private final Semaphore uploadSemaphore;
private final Semaphore readSemaphore;
private final Semaphore deleteSemaphore;
private final Semaphore totalSemaphore;
private final AtomicInteger windowFailureCount = new AtomicInteger();
private volatile long failureWindowStartedAtMillis;
private volatile long circuitOpenUntilMillis;
private volatile OkHttpClient httpClient;
/**
* 懒加载缓存的共享 MinioClientdouble-check 单例):复用同一实例与同一
* OkHttpClient(连接池随实例共享),避免每次操作创建客户端。
*/
private volatile MinioClient sharedMinioClient;
@Autowired
public RustfsObjectStorageService(TransientStorageProperties properties,
@@ -66,6 +73,13 @@ public class RustfsObjectStorageService {
this.uploadSemaphore = new Semaphore(Math.max(1, properties.getMaxConcurrentUploads()));
this.readSemaphore = new Semaphore(Math.max(1, properties.getMaxConcurrentReads()));
this.deleteSemaphore = new Semaphore(Math.max(1, properties.getMaxConcurrentDeletes()));
// 总预算为 0/负值时不启用(Semaphore(0) 的 tryAcquire 永远失败,须用启用开关区分)
long totalBudget = Math.max(0L, properties.getMaxTotalConcurrentOperations());
this.totalSemaphore = new Semaphore((int) Math.min(totalBudget, Integer.MAX_VALUE));
}
private boolean isTotalBudgetEnabled() {
return properties.getMaxTotalConcurrentOperations() > 0L;
}
public boolean isConfigured() {
@@ -80,12 +94,17 @@ public class RustfsObjectStorageService {
}
public String uploadText(String objectKey, String content, boolean verifyAfterUpload) {
byte[] bytes = Objects.requireNonNullElse(content, "").getBytes(StandardCharsets.UTF_8);
return uploadBytes(objectKey, bytes, verifyAfterUpload);
}
public String uploadBytes(String objectKey, byte[] content, boolean verifyAfterUpload) {
long deadlineNanos = operationDeadlineNanos();
if (!isConfigured()) {
throw new IllegalStateException("transient storage is not configured");
}
rejectIfCircuitOpen(OP_UPLOAD, objectKey);
byte[] bytes = Objects.requireNonNullElse(content, "").getBytes(StandardCharsets.UTF_8);
byte[] bytes = content == null ? new byte[0] : content;
recordPayloadBytes(bytes.length);
AtomicBoolean putCompleted = new AtomicBoolean();
try {
@@ -96,7 +115,7 @@ public class RustfsObjectStorageService {
.bucket(properties.getBucket())
.object(objectKey)
.stream(stream, bytes.length, -1)
.contentType("application/json")
.contentType("application/gzip")
.build());
putCompleted.set(true);
return objectKey;
@@ -117,6 +136,11 @@ public class RustfsObjectStorageService {
}
public String readObjectAsString(String objectKey) {
byte[] bytes = readObjectBytes(objectKey);
return new String(bytes, StandardCharsets.UTF_8);
}
public byte[] readObjectBytes(String objectKey) {
long deadlineNanos = operationDeadlineNanos();
if (!isConfigured()) {
throw new IllegalStateException("transient storage is not configured");
@@ -128,7 +152,7 @@ public class RustfsObjectStorageService {
.bucket(properties.getBucket())
.object(objectKey)
.build())) {
return new String(stream.readAllBytes(), StandardCharsets.UTF_8);
return stream.readAllBytes();
}
});
}
@@ -175,45 +199,57 @@ public class RustfsObjectStorageService {
CheckedSupplier<T> supplier) {
long startedAt = System.nanoTime();
Exception last = null;
for (int attempt = 1; attempt <= maxRetries; attempt++) {
checkDeadline(operation, objectKey, deadlineNanos);
rejectIfCircuitOpen(operation, objectKey);
acquirePermit(operation, objectKey, semaphore, deadlineNanos);
long delayMillis = 0L;
try {
// 总资源预算:一次操作(含全部重试)全程占用一个总许可,重试不额外消耗。
boolean totalAcquired = false;
if (isTotalBudgetEnabled()) {
acquirePermit(OP_TOTAL, objectKey, totalSemaphore, deadlineNanos);
totalAcquired = true;
}
try {
for (int attempt = 1; attempt <= maxRetries; attempt++) {
checkDeadline(operation, objectKey, deadlineNanos);
rejectIfCircuitOpen(operation, objectKey);
acquirePermit(operation, objectKey, semaphore, deadlineNanos);
long delayMillis = 0L;
try {
checkDeadline(operation, objectKey, deadlineNanos);
T result = supplier.get();
checkDeadline(operation, objectKey, deadlineNanos);
if (!OP_UPLOAD.equals(operation)) {
resetFailureWindow(operation);
}
recordOperation(operation, "success", elapsedNanos(startedAt));
log.debug("[rustfs] operation success operation={} objectKey={} attempt={}/{} durationMs={} bytes={}",
operation, objectKey, attempt, maxRetries, elapsedMillis(startedAt), bytes);
return result;
} catch (Exception ex) {
last = ex;
recordOperation(operation, attempt < maxRetries ? "retry" : "failure", elapsedNanos(startedAt));
recordFailure(operation, objectKey, ex);
if (attempt < maxRetries) {
delayMillis = retryDelayMillis(attempt);
log.warn("[rustfs] operation failed, retrying operation={} objectKey={} attempt={}/{} delayMs={} err={}",
operation, objectKey, attempt, maxRetries, delayMillis, ex.getMessage());
try {
checkDeadline(operation, objectKey, deadlineNanos);
T result = supplier.get();
checkDeadline(operation, objectKey, deadlineNanos);
if (!OP_UPLOAD.equals(operation)) {
resetFailureWindow(operation);
}
recordOperation(operation, "success", elapsedNanos(startedAt));
log.debug("[rustfs] operation success operation={} objectKey={} attempt={}/{} durationMs={} bytes={}",
operation, objectKey, attempt, maxRetries, elapsedMillis(startedAt), bytes);
return result;
} catch (Exception ex) {
last = ex;
recordOperation(operation, attempt < maxRetries ? "retry" : "failure", elapsedNanos(startedAt));
recordFailure(operation, objectKey, ex);
if (attempt < maxRetries) {
delayMillis = retryDelayMillis(attempt);
log.warn("[rustfs] operation failed, retrying operation={} objectKey={} attempt={}/{} delayMs={} err={}",
operation, objectKey, attempt, maxRetries, delayMillis, ex.getMessage());
}
}
} finally {
semaphore.release();
}
if (last instanceof OperationTimeoutException timeoutException) {
throw timeoutException;
}
if (attempt < maxRetries) {
sleepQuietly(delayMillis, "retrying rustfs " + operation,
operation, objectKey, deadlineNanos);
}
} finally {
semaphore.release();
}
if (last instanceof OperationTimeoutException timeoutException) {
throw timeoutException;
}
if (attempt < maxRetries) {
sleepQuietly(delayMillis, "retrying rustfs " + operation,
operation, objectKey, deadlineNanos);
throw new IllegalStateException("failed to " + operation + " payload in transient storage", last);
} finally {
if (totalAcquired) {
totalSemaphore.release();
}
}
throw new IllegalStateException("failed to " + operation + " payload in transient storage", last);
}
private void acquirePermit(String operation, String objectKey, Semaphore semaphore, long deadlineNanos) {
@@ -244,12 +280,21 @@ public class RustfsObjectStorageService {
if (minioClientSupplier != null) {
return minioClientSupplier.get();
}
return MinioClient.builder()
.endpoint(properties.getEndpoint())
.credentials(properties.getAccessKeyId(), properties.getAccessKeySecret())
.region(properties.getRegion())
.httpClient(getHttpClient(deadlineNanos))
.build();
MinioClient shared = sharedMinioClient;
if (shared != null) {
return shared;
}
synchronized (this) {
if (sharedMinioClient == null) {
sharedMinioClient = MinioClient.builder()
.endpoint(properties.getEndpoint())
.credentials(properties.getAccessKeyId(), properties.getAccessKeySecret())
.region(properties.getRegion())
.httpClient(getHttpClient(deadlineNanos))
.build();
}
return sharedMinioClient;
}
}
OkHttpClient getHttpClient() {
@@ -2,8 +2,25 @@ package com.nanri.aiimage.modules.invalidasin.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.nanri.aiimage.modules.invalidasin.model.entity.InvalidAsinDataEntity;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface InvalidAsinDataMapper extends BaseMapper<InvalidAsinDataEntity> {
/** 批量 INSERT IGNORE:命中唯一键 (data_value, brand) 的重复行静默跳过,幂等。 */
@Insert("""
<script>
INSERT IGNORE INTO biz_invalid_asin_data
(data_value, brand, record_source, created_at, updated_at)
VALUES
<foreach collection="rows" item="row" separator=",">
(#{row.dataValue}, #{row.brand}, #{row.recordSource}, #{row.createdAt}, #{row.updatedAt})
</foreach>
</script>
""")
int insertBatchIgnore(@Param("rows") List<InvalidAsinDataEntity> rows);
}
@@ -1,10 +1,10 @@
package com.nanri.aiimage.modules.patroldelete.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.nanri.aiimage.config.TaskPressureProperties;
import com.nanri.aiimage.modules.patroldelete.model.dto.PatrolDeleteShopPayloadDto;
import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity;
import com.nanri.aiimage.modules.task.service.TaskScopePayloadStorageService;
import com.nanri.aiimage.modules.task.util.TaskEntityLocalCache;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
@@ -16,7 +16,6 @@ import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@Service
@RequiredArgsConstructor
@@ -27,9 +26,8 @@ public class PatrolDeleteTaskCacheService {
private static final long PAYLOAD_TTL_HOURS = 24;
private final StringRedisTemplate stringRedisTemplate;
private final ObjectMapper objectMapper;
private final TaskPressureProperties taskPressureProperties;
private final TaskScopePayloadStorageService taskScopePayloadStorageService;
private final ConcurrentHashMap<Long, LocalTaskEntityCacheEntry> taskEntityLocalCache = new ConcurrentHashMap<>();
private final TaskEntityLocalCache taskEntityLocalCache;
public PatrolDeleteShopPayloadDto getShopMergedPayload(Long taskId, String shopKey) {
return taskScopePayloadStorageService.getScopePayload(taskId, MODULE_TYPE, shopKey, PatrolDeleteShopPayloadDto.class);
@@ -135,7 +133,7 @@ public class PatrolDeleteTaskCacheService {
if (taskId == null || taskId <= 0) {
return;
}
taskEntityLocalCache.remove(taskId);
taskEntityLocalCache.evict(taskId);
try {
stringRedisTemplate.delete(buildTaskHeartbeatKey(taskId));
stringRedisTemplate.delete(buildTaskEntityKey(taskId));
@@ -148,11 +146,7 @@ public class PatrolDeleteTaskCacheService {
if (task == null || task.getId() == null) {
return;
}
long now = System.currentTimeMillis();
taskEntityLocalCache.put(task.getId(), new LocalTaskEntityCacheEntry(
now,
objectMapper.convertValue(task, FileTaskEntity.class)
));
taskEntityLocalCache.put(task.getId(), task);
try {
stringRedisTemplate.opsForValue().set(
buildTaskEntityKey(task.getId()),
@@ -178,9 +172,9 @@ public class PatrolDeleteTaskCacheService {
long now = System.currentTimeMillis();
java.util.List<Long> missingIds = new ArrayList<>();
for (Long taskId : normalized) {
LocalTaskEntityCacheEntry cached = taskEntityLocalCache.get(taskId);
if (isLocalCacheFresh(cached, now)) {
result.put(taskId, objectMapper.convertValue(cached.task(), FileTaskEntity.class));
TaskEntityLocalCache.Entry cached = taskEntityLocalCache.get(taskId, now);
if (cached != null) {
result.put(taskId, cached.task());
} else {
missingIds.add(taskId);
}
@@ -205,7 +199,7 @@ public class PatrolDeleteTaskCacheService {
try {
FileTaskEntity task = objectMapper.readValue(val, FileTaskEntity.class);
result.put(taskId, task);
taskEntityLocalCache.put(taskId, new LocalTaskEntityCacheEntry(now, task));
taskEntityLocalCache.put(taskId, task);
} catch (Exception ignored) {
}
}
@@ -219,11 +213,4 @@ public class PatrolDeleteTaskCacheService {
private String buildTaskEntityKey(Long taskId) {
return "patrol-delete:task:entity:" + taskId;
}
private boolean isLocalCacheFresh(LocalTaskEntityCacheEntry cached, long now) {
return cached != null
&& now - cached.cachedAtMillis() <= Math.max(0L, taskPressureProperties.getLocalTaskEntityCacheMillis());
}
private record LocalTaskEntityCacheEntry(long cachedAtMillis, FileTaskEntity task) {}
}
@@ -1,10 +1,10 @@
package com.nanri.aiimage.modules.pricetrack.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.nanri.aiimage.config.TaskPressureProperties;
import com.nanri.aiimage.modules.pricetrack.model.dto.PriceTrackSubmitResultRequest;
import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity;
import com.nanri.aiimage.modules.task.service.TaskScopePayloadStorageService;
import com.nanri.aiimage.modules.task.util.TaskEntityLocalCache;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
@@ -16,7 +16,6 @@ import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@Service
@RequiredArgsConstructor
@@ -27,9 +26,8 @@ public class PriceTrackTaskCacheService {
private static final long HEARTBEAT_TTL_HOURS = 24;
private final StringRedisTemplate stringRedisTemplate;
private final ObjectMapper objectMapper;
private final TaskPressureProperties taskPressureProperties;
private final TaskScopePayloadStorageService taskScopePayloadStorageService;
private final ConcurrentHashMap<Long, LocalTaskEntityCacheEntry> taskEntityLocalCache = new ConcurrentHashMap<>();
private final TaskEntityLocalCache taskEntityLocalCache;
public void touchTaskHeartbeat(Long taskId) {
if (taskId == null || taskId <= 0) {
@@ -129,7 +127,7 @@ public class PriceTrackTaskCacheService {
if (taskId == null || taskId <= 0) {
return;
}
taskEntityLocalCache.remove(taskId);
taskEntityLocalCache.evict(taskId);
try {
stringRedisTemplate.delete(buildTaskHeartbeatKey(taskId));
stringRedisTemplate.delete(buildTaskEntityKey(taskId));
@@ -143,11 +141,7 @@ public class PriceTrackTaskCacheService {
if (task == null || task.getId() == null) {
return;
}
long now = System.currentTimeMillis();
taskEntityLocalCache.put(task.getId(), new LocalTaskEntityCacheEntry(
now,
objectMapper.convertValue(task, FileTaskEntity.class)
));
taskEntityLocalCache.put(task.getId(), task);
try {
stringRedisTemplate.opsForValue().set(
buildTaskEntityKey(task.getId()),
@@ -173,9 +167,9 @@ public class PriceTrackTaskCacheService {
long now = System.currentTimeMillis();
java.util.List<Long> missingIds = new ArrayList<>();
for (Long taskId : normalized) {
LocalTaskEntityCacheEntry cached = taskEntityLocalCache.get(taskId);
if (isLocalCacheFresh(cached, now)) {
result.put(taskId, objectMapper.convertValue(cached.task(), FileTaskEntity.class));
TaskEntityLocalCache.Entry cached = taskEntityLocalCache.get(taskId, now);
if (cached != null) {
result.put(taskId, cached.task());
} else {
missingIds.add(taskId);
}
@@ -200,7 +194,7 @@ public class PriceTrackTaskCacheService {
try {
FileTaskEntity task = objectMapper.readValue(val, FileTaskEntity.class);
result.put(taskId, task);
taskEntityLocalCache.put(taskId, new LocalTaskEntityCacheEntry(now, task));
taskEntityLocalCache.put(taskId, task);
} catch (Exception ignored) {
}
}
@@ -214,11 +208,4 @@ public class PriceTrackTaskCacheService {
private String buildTaskEntityKey(Long taskId) {
return "price-track:task:entity:" + taskId;
}
private boolean isLocalCacheFresh(LocalTaskEntityCacheEntry cached, long now) {
return cached != null
&& now - cached.cachedAtMillis() <= Math.max(0L, taskPressureProperties.getLocalTaskEntityCacheMillis());
}
private record LocalTaskEntityCacheEntry(long cachedAtMillis, FileTaskEntity task) {}
}
@@ -1,10 +1,10 @@
package com.nanri.aiimage.modules.productrisk.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.nanri.aiimage.config.TaskPressureProperties;
import com.nanri.aiimage.modules.productrisk.model.dto.ProductRiskShopPayloadDto;
import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity;
import com.nanri.aiimage.modules.task.service.TaskScopePayloadStorageService;
import com.nanri.aiimage.modules.task.util.TaskEntityLocalCache;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
@@ -16,7 +16,6 @@ import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@Service
@RequiredArgsConstructor
@@ -27,9 +26,8 @@ public class ProductRiskTaskCacheService {
private static final long PAYLOAD_TTL_HOURS = 24;
private final StringRedisTemplate stringRedisTemplate;
private final ObjectMapper objectMapper;
private final TaskPressureProperties taskPressureProperties;
private final TaskScopePayloadStorageService taskScopePayloadStorageService;
private final ConcurrentHashMap<Long, LocalTaskEntityCacheEntry> taskEntityLocalCache = new ConcurrentHashMap<>();
private final TaskEntityLocalCache taskEntityLocalCache;
public ProductRiskShopPayloadDto getShopMergedPayload(Long taskId, String shopKey) {
return taskScopePayloadStorageService.getScopePayload(taskId, MODULE_TYPE, shopKey, ProductRiskShopPayloadDto.class);
@@ -119,7 +117,7 @@ public class ProductRiskTaskCacheService {
if (taskId == null || taskId <= 0) {
return;
}
taskEntityLocalCache.remove(taskId);
taskEntityLocalCache.evict(taskId);
try {
stringRedisTemplate.delete(buildTaskHeartbeatKey(taskId));
stringRedisTemplate.delete(buildTaskEntityKey(taskId));
@@ -133,11 +131,7 @@ public class ProductRiskTaskCacheService {
if (task == null || task.getId() == null) {
return;
}
long now = System.currentTimeMillis();
taskEntityLocalCache.put(task.getId(), new LocalTaskEntityCacheEntry(
now,
objectMapper.convertValue(task, FileTaskEntity.class)
));
taskEntityLocalCache.put(task.getId(), task);
try {
stringRedisTemplate.opsForValue().set(
buildTaskEntityKey(task.getId()),
@@ -163,9 +157,9 @@ public class ProductRiskTaskCacheService {
long now = System.currentTimeMillis();
java.util.List<Long> missingIds = new ArrayList<>();
for (Long taskId : normalized) {
LocalTaskEntityCacheEntry cached = taskEntityLocalCache.get(taskId);
if (isLocalCacheFresh(cached, now)) {
result.put(taskId, objectMapper.convertValue(cached.task(), FileTaskEntity.class));
TaskEntityLocalCache.Entry cached = taskEntityLocalCache.get(taskId, now);
if (cached != null) {
result.put(taskId, cached.task());
} else {
missingIds.add(taskId);
}
@@ -190,7 +184,7 @@ public class ProductRiskTaskCacheService {
try {
FileTaskEntity task = objectMapper.readValue(val, FileTaskEntity.class);
result.put(taskId, task);
taskEntityLocalCache.put(taskId, new LocalTaskEntityCacheEntry(now, task));
taskEntityLocalCache.put(taskId, task);
} catch (Exception ignored) {
}
}
@@ -215,11 +209,4 @@ public class ProductRiskTaskCacheService {
private String buildTaskEntityKey(Long taskId) {
return "product-risk:task:entity:" + taskId;
}
private boolean isLocalCacheFresh(LocalTaskEntityCacheEntry cached, long now) {
return cached != null
&& now - cached.cachedAtMillis() <= Math.max(0L, taskPressureProperties.getLocalTaskEntityCacheMillis());
}
private record LocalTaskEntityCacheEntry(long cachedAtMillis, FileTaskEntity task) {}
}
@@ -1,10 +1,10 @@
package com.nanri.aiimage.modules.queryasin.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.nanri.aiimage.config.TaskPressureProperties;
import com.nanri.aiimage.modules.queryasin.model.dto.QueryAsinShopPayloadDto;
import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity;
import com.nanri.aiimage.modules.task.service.TaskScopePayloadStorageService;
import com.nanri.aiimage.modules.task.util.TaskEntityLocalCache;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
@@ -16,7 +16,6 @@ import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@Service
@RequiredArgsConstructor
@@ -27,9 +26,8 @@ public class QueryAsinTaskCacheService {
private static final long PAYLOAD_TTL_HOURS = 24;
private final StringRedisTemplate stringRedisTemplate;
private final ObjectMapper objectMapper;
private final TaskPressureProperties taskPressureProperties;
private final TaskScopePayloadStorageService taskScopePayloadStorageService;
private final ConcurrentHashMap<Long, LocalTaskEntityCacheEntry> taskEntityLocalCache = new ConcurrentHashMap<>();
private final TaskEntityLocalCache taskEntityLocalCache;
public QueryAsinShopPayloadDto getShopMergedPayload(Long taskId, String shopKey) {
return taskScopePayloadStorageService.getScopePayload(taskId, MODULE_TYPE, shopKey, QueryAsinShopPayloadDto.class);
@@ -130,7 +128,7 @@ public class QueryAsinTaskCacheService {
if (taskId == null || taskId <= 0) {
return;
}
taskEntityLocalCache.remove(taskId);
taskEntityLocalCache.evict(taskId);
try {
stringRedisTemplate.delete(buildTaskHeartbeatKey(taskId));
stringRedisTemplate.delete(buildTaskEntityKey(taskId));
@@ -144,11 +142,7 @@ public class QueryAsinTaskCacheService {
if (task == null || task.getId() == null) {
return;
}
long now = System.currentTimeMillis();
taskEntityLocalCache.put(task.getId(), new LocalTaskEntityCacheEntry(
now,
objectMapper.convertValue(task, FileTaskEntity.class)
));
taskEntityLocalCache.put(task.getId(), task);
try {
stringRedisTemplate.opsForValue().set(
buildTaskEntityKey(task.getId()),
@@ -174,9 +168,9 @@ public class QueryAsinTaskCacheService {
long now = System.currentTimeMillis();
java.util.List<Long> missingIds = new ArrayList<>();
for (Long taskId : normalized) {
LocalTaskEntityCacheEntry cached = taskEntityLocalCache.get(taskId);
if (isLocalCacheFresh(cached, now)) {
result.put(taskId, objectMapper.convertValue(cached.task(), FileTaskEntity.class));
TaskEntityLocalCache.Entry cached = taskEntityLocalCache.get(taskId, now);
if (cached != null) {
result.put(taskId, cached.task());
} else {
missingIds.add(taskId);
}
@@ -201,7 +195,7 @@ public class QueryAsinTaskCacheService {
try {
FileTaskEntity task = objectMapper.readValue(val, FileTaskEntity.class);
result.put(taskId, task);
taskEntityLocalCache.put(taskId, new LocalTaskEntityCacheEntry(now, task));
taskEntityLocalCache.put(taskId, task);
} catch (Exception ignored) {
}
}
@@ -215,12 +209,5 @@ public class QueryAsinTaskCacheService {
private String buildTaskEntityKey(Long taskId) {
return "query-asin:task:entity:" + taskId;
}
private boolean isLocalCacheFresh(LocalTaskEntityCacheEntry cached, long now) {
return cached != null
&& now - cached.cachedAtMillis() <= Math.max(0L, taskPressureProperties.getLocalTaskEntityCacheMillis());
}
private record LocalTaskEntityCacheEntry(long cachedAtMillis, FileTaskEntity task) {}
}
@@ -24,8 +24,10 @@ public class ShopDataCrawlDailyFileEntity {
private String resultFileUrl;
private Long resultFileSize;
private String resultContentType;
private String countryCodesJson;
private Integer rowCount;
private Long version;
private Integer compensationDone;
private LocalDateTime lastSuccessAt;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
@@ -0,0 +1,29 @@
package com.nanri.aiimage.modules.shopdatacrawl.service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.stereotype.Component;
/**
* V95 店铺级累计文件迁移的启动补偿:对 compensation_done=0 的每日累计文件,
* 按成员 result 快照重建合并工作簿(覆盖国家语义),补齐国家列表并清理旧对象。
* 失败仅记日志,不阻断应用启动与后续补偿。
*/
@Component
@RequiredArgsConstructor
@Slf4j
public class ShopDataCrawlDailyCompensationRunner implements ApplicationRunner {
private final ShopDataCrawlTaskService taskService;
@Override
public void run(ApplicationArguments args) {
try {
taskService.compensateDailyWorkbooksAfterMigration();
} catch (Exception ex) {
log.warn("[shop-data-crawl] daily compensation runner failed: {}", ex.getMessage(), ex);
}
}
}
@@ -73,34 +73,47 @@ public class ShopDataCrawlDailyFileService {
return HexFormat.of().formatHex(digest);
}
public TaskDistributedLockService.LockHandle acquireLock(Long userId, String shopKey) {
if (userId == null || userId <= 0 || shopKey == null || shopKey.isBlank()) {
public TaskDistributedLockService.LockHandle acquireLock(String shopKey) {
if (shopKey == null || shopKey.isBlank()) {
return null;
}
// The lock protects the shop's whole daily-file lifecycle. Including the
// date would allow yesterday and today to update the same shop together.
String identity = userId + "|" + shopKey;
// 店铺级锁:只按店铺串行,跨账号同店并发抓取时互斥(锁在 Redis,与 DB 行解耦)
String identity = shopKey;
String lockModule = DAILY_LOCK_MODULE_PREFIX + shopKeyHash(identity);
return taskDistributedLockService.acquire(lockModule, 1L, DAILY_LOCK_TTL, DAILY_LOCK_WAIT_MILLIS);
}
public ShopDataCrawlDailyFileEntity findForUpdate(Long userId, String shopKeyHash, LocalDate businessDate) {
if (userId == null || shopKeyHash == null || businessDate == null) {
public ShopDataCrawlDailyFileEntity findForUpdate(String shopKeyHash, LocalDate businessDate) {
if (shopKeyHash == null || businessDate == null) {
return null;
}
return dailyFileMapper.selectOne(new LambdaQueryWrapper<ShopDataCrawlDailyFileEntity>()
.eq(ShopDataCrawlDailyFileEntity::getUserId, userId)
.eq(ShopDataCrawlDailyFileEntity::getShopKeyHash, shopKeyHash)
.eq(ShopDataCrawlDailyFileEntity::getBusinessDate, businessDate)
.last("FOR UPDATE"));
}
public List<ShopDataCrawlDailyFileEntity> findOlder(Long userId, String shopKeyHash, LocalDate businessDate) {
if (userId == null || shopKeyHash == null || businessDate == null) {
public List<ShopDataCrawlDailyFileEntity> findForCompensation() {
return dailyFileMapper.selectList(new LambdaQueryWrapper<ShopDataCrawlDailyFileEntity>()
.eq(ShopDataCrawlDailyFileEntity::getCompensationDone, 0)
.orderByAsc(ShopDataCrawlDailyFileEntity::getBusinessDate)
.orderByAsc(ShopDataCrawlDailyFileEntity::getId)
.last("LIMIT 500"));
}
public void markCompensationDone(ShopDataCrawlDailyFileEntity entity) {
if (entity == null || entity.getId() == null) {
return;
}
entity.setCompensationDone(1);
update(entity);
}
public List<ShopDataCrawlDailyFileEntity> findOlder(String shopKeyHash, LocalDate businessDate) {
if (shopKeyHash == null || businessDate == null) {
return List.of();
}
return dailyFileMapper.selectList(new LambdaQueryWrapper<ShopDataCrawlDailyFileEntity>()
.eq(ShopDataCrawlDailyFileEntity::getUserId, userId)
.eq(ShopDataCrawlDailyFileEntity::getShopKeyHash, shopKeyHash)
.lt(ShopDataCrawlDailyFileEntity::getBusinessDate, businessDate)
.orderByDesc(ShopDataCrawlDailyFileEntity::getBusinessDate)
@@ -7,7 +7,6 @@ import com.nanri.aiimage.modules.shopdatacrawl.model.vo.ShopDataCrawlResultItemV
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 lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.poi.ss.usermodel.Cell;
import org.apache.poi.ss.usermodel.CellStyle;
@@ -20,6 +19,7 @@ import org.apache.poi.xssf.usermodel.XSSFDrawing;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.apache.poi.xssf.streaming.SXSSFWorkbook;
import org.openxmlformats.schemas.drawingml.x2006.spreadsheetDrawing.CTTwoCellAnchor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.io.ClassPathResource;
import org.springframework.stereotype.Service;
@@ -34,7 +34,6 @@ import java.util.Map;
@Service
@Slf4j
@RequiredArgsConstructor
public class ShopDataCrawlExcelAssemblyService {
static final List<String> COUNTRIES = List.of("UK", "DE", "FR", "ES", "IT");
static final List<String> SHEETS = List.of("英国", "德国", "法国", "西班牙", "意大利");
@@ -59,6 +58,12 @@ public class ShopDataCrawlExcelAssemblyService {
private int imageCacheMaxEntries = DEFAULT_IMAGE_CACHE_MAX_ENTRIES;
private int prefetchMaxUrls = DEFAULT_PREFETCH_MAX_URLS;
/** Spring 运行时使用默认参数构造;带参数构造器保留给测试和专项配置。 */
@Autowired
public ShopDataCrawlExcelAssemblyService(SimilarAsinImageEmbedder imageEmbedder) {
this(imageEmbedder, DEFAULT_IMAGE_CACHE_MAX_BYTES, DEFAULT_PREFETCH_MAX_URLS);
}
public ShopDataCrawlExcelAssemblyService(SimilarAsinImageEmbedder imageEmbedder, long imageCacheMaxBytes) {
this.imageEmbedder = imageEmbedder;
this.imageCacheMaxBytes = imageCacheMaxBytes;
@@ -368,11 +373,7 @@ public class ShopDataCrawlExcelAssemblyService {
if (item == null || Boolean.FALSE.equals(item.getSuccess()) || item.getCountryResults() == null) continue;
for (ShopDataCrawlCountryResultDto countryResult : item.getCountryResults()) {
String country = countryResult == null || countryResult.getCountry() == null ? "" : countryResult.getCountry().trim().toUpperCase();
// 同国覆盖:按成员顺序累积,后面的结果覆盖前面的同名国家行(最新任务胜出),
// 本次未提交的国家由调用方从旧累计对象/模板保留,不在这里清空。
if (result.containsKey(country) && countryResult.getItems() != null) {
result.put(country, new ArrayList<>(countryResult.getItems()));
}
if (result.containsKey(country) && countryResult.getItems() != null) result.get(country).addAll(countryResult.getItems());
}
}
return result;
@@ -5,6 +5,7 @@ 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 com.nanri.aiimage.modules.task.util.TaskEntityLocalCache;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
@@ -13,11 +14,9 @@ import org.springframework.stereotype.Service;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@Service
@RequiredArgsConstructor
@@ -30,7 +29,7 @@ public class ShopDataCrawlTaskCacheService {
private final ObjectMapper objectMapper;
private final TaskPressureProperties taskPressureProperties;
private final TaskScopePayloadStorageService taskScopePayloadStorageService;
private final ConcurrentHashMap<Long, LocalTaskEntityCacheEntry> taskEntityLocalCache = new ConcurrentHashMap<>();
private final TaskEntityLocalCache taskEntityLocalCache;
public ShopDataCrawlShopPayloadDto getShopMergedPayload(Long taskId, String shopKey) {
return taskScopePayloadStorageService.getScopePayload(taskId, MODULE_TYPE, shopKey, ShopDataCrawlShopPayloadDto.class);
@@ -131,7 +130,7 @@ public class ShopDataCrawlTaskCacheService {
if (taskId == null || taskId <= 0) {
return;
}
taskEntityLocalCache.remove(taskId);
taskEntityLocalCache.evict(taskId);
try {
stringRedisTemplate.delete(buildTaskHeartbeatKey(taskId));
stringRedisTemplate.delete(buildTaskEntityKey(taskId));
@@ -145,11 +144,7 @@ public class ShopDataCrawlTaskCacheService {
if (task == null || task.getId() == null) {
return;
}
long now = System.currentTimeMillis();
putLocalCache(task.getId(), new LocalTaskEntityCacheEntry(
now,
objectMapper.convertValue(task, FileTaskEntity.class)
));
taskEntityLocalCache.put(task.getId(), task);
try {
stringRedisTemplate.opsForValue().set(
buildTaskEntityKey(task.getId()),
@@ -160,23 +155,6 @@ 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) {
Map<Long, FileTaskEntity> result = new LinkedHashMap<>();
if (taskIds == null || taskIds.isEmpty()) {
@@ -192,14 +170,10 @@ public class ShopDataCrawlTaskCacheService {
long now = System.currentTimeMillis();
java.util.List<Long> missingIds = new ArrayList<>();
for (Long taskId : normalized) {
LocalTaskEntityCacheEntry cached = taskEntityLocalCache.get(taskId);
if (isLocalCacheFresh(cached, now)) {
result.put(taskId, objectMapper.convertValue(cached.task(), FileTaskEntity.class));
TaskEntityLocalCache.Entry cached = taskEntityLocalCache.get(taskId, now);
if (cached != null) {
result.put(taskId, cached.task());
} else {
// 过期条目即时回收,避免本地缓存无限累积。
if (cached != null) {
taskEntityLocalCache.remove(taskId);
}
missingIds.add(taskId);
}
}
@@ -223,7 +197,7 @@ public class ShopDataCrawlTaskCacheService {
try {
FileTaskEntity task = objectMapper.readValue(val, FileTaskEntity.class);
result.put(taskId, task);
taskEntityLocalCache.put(taskId, new LocalTaskEntityCacheEntry(now, task));
taskEntityLocalCache.put(taskId, task);
} catch (Exception ignored) {
}
}
@@ -238,16 +212,9 @@ public class ShopDataCrawlTaskCacheService {
return "shop-data-crawl:task:entity:" + taskId;
}
private boolean isLocalCacheFresh(LocalTaskEntityCacheEntry cached, long now) {
return cached != null
&& now - cached.cachedAtMillis() <= Math.max(0L, taskPressureProperties.getLocalTaskEntityCacheMillis());
}
/** 本地缓存当前条目数(测试与监控用)。 */
int localCacheSize() {
return taskEntityLocalCache.size();
}
private record LocalTaskEntityCacheEntry(long cachedAtMillis, FileTaskEntity task) {}
}
@@ -63,6 +63,7 @@ import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
@@ -676,7 +677,7 @@ public class ShopDataCrawlTaskService {
ensureTaskOwnedByCurrentInstance(task, "delete shop data crawl task");
try (TaskDistributedLockService.LockHandle ignored = acquireTaskLockOrThrow(taskId)) {
List<FileResultEntity> taskRows = listTaskRows(taskId);
try (DailyLockSet dailyLocks = acquireDailyLocks(task.getUserId(), taskRows)) {
try (DailyLockSet dailyLocks = acquireDailyLocks(taskRows)) {
ensureDailySyncCompletedBeforeDelete(taskRows);
Set<Long> removedResultIds = taskRows.stream()
.map(FileResultEntity::getId)
@@ -739,7 +740,7 @@ public class ShopDataCrawlTaskService {
if (latestEntity == null || !MODULE_TYPE.equals(latestEntity.getModuleType()) || !userId.equals(latestEntity.getUserId())) {
throw new BusinessException("记录不存在");
}
try (DailyLockSet dailyLocks = acquireDailyLocks(userId, List.of(latestEntity))) {
try (DailyLockSet dailyLocks = acquireDailyLocks(List.of(latestEntity))) {
deleteResultHistoryRow(latestEntity);
}
}
@@ -816,7 +817,7 @@ public class ShopDataCrawlTaskService {
continue;
}
ShopDataCrawlDailyFileEntity lockedDailyFile = dailyFileService.findForUpdate(
dailyFile.getUserId(), dailyFile.getShopKeyHash(), dailyFile.getBusinessDate());
dailyFile.getShopKeyHash(), dailyFile.getBusinessDate());
if (lockedDailyFile != null) {
dailyFile = lockedDailyFile;
}
@@ -960,6 +961,7 @@ public class ShopDataCrawlTaskService {
dailyFile.setResultFileSize(latest.result().getResultFileSize());
dailyFile.setResultContentType(CONTENT_TYPE_XLSX);
dailyFile.setRowCount(rowCount);
dailyFile.setCountryCodesJson(countryCodesJson(collectCountryCodes(snapshots)));
dailyFile.setVersion(Math.max(0L, Objects.requireNonNullElse(dailyFile.getVersion(), 0L)) + 1L);
dailyFile.setLastSuccessAt(now);
dailyFile.setUpdatedAt(now);
@@ -1801,10 +1803,9 @@ public class ShopDataCrawlTaskService {
FileResultEntity row,
ShopDataCrawlResultItemVo snapshot,
LocalDate businessDate) {
Long userId = row.getUserId() != null ? row.getUserId() : task.getUserId();
String shopKey = dailyFileService.shopKey(row);
String shopKeyHash = dailyFileService.shopKeyHash(shopKey);
if (userId == null || shopKeyHash == null) {
if (shopKeyHash == null) {
throw new BusinessException("店铺累计文件归属信息不完整");
}
// Task 36:版本号/CAS 短临界区。店铺级锁只覆盖“准备/提交”两个毫秒级短事务
@@ -1814,13 +1815,13 @@ public class ShopDataCrawlTaskService {
DailyWorkbookArtifact artifact = null;
for (int attempt = 1; attempt <= MAX_DAILY_AGGREGATION_ATTEMPTS; attempt++) {
DailyAggregationPreparation preparation;
TaskDistributedLockService.LockHandle prepareLock = dailyFileService.acquireLock(userId, shopKey);
TaskDistributedLockService.LockHandle prepareLock = dailyFileService.acquireLock(shopKey);
if (prepareLock == null) {
throw new BusinessException("店铺当天累计文件正在处理中,请稍后重试");
}
try {
preparation = executeShortTransaction(
() -> prepareDailyAggregation(userId, shopKeyHash, businessDate, row));
() -> prepareDailyAggregation(shopKeyHash, businessDate, row));
} finally {
prepareLock.close();
}
@@ -1828,7 +1829,7 @@ public class ShopDataCrawlTaskService {
return new DailyAggregationResult(List.of(), false);
}
ShopDataCrawlDailyFileEntity baseForAttempt = resolveBaseDailyFile(
preparation.dailyFile(), userId, shopKeyHash, businessDate);
preparation.dailyFile(), shopKeyHash, businessDate);
// 组装在锁外执行。每次尝试都用本次准备阶段读到的最新 base 组装
// (冲突重试时 base 已变化,复用过期的组装结果会把并发写入的行丢在对象外);
// 零行引用对象不重复上传。
@@ -1836,7 +1837,7 @@ public class ShopDataCrawlTaskService {
artifact = assembleDailyWorkbook(task, snapshot, baseForAttempt,
excelAssemblyService.countRows(List.of(snapshot)));
}
TaskDistributedLockService.LockHandle commitLock = dailyFileService.acquireLock(userId, shopKey);
TaskDistributedLockService.LockHandle commitLock = dailyFileService.acquireLock(shopKey);
if (commitLock == null) {
throw new BusinessException("店铺当天累计文件正在处理中,请稍后重试");
}
@@ -1845,7 +1846,7 @@ public class ShopDataCrawlTaskService {
DailyWorkbookArtifact artifactForAttempt = artifact;
try {
persistedResult = executeShortTransaction(() -> persistDailyAggregation(
task, row, userId, shopKey, shopKeyHash, businessDate,
task, row, shopKey, shopKeyHash, businessDate,
preparationForAttempt, artifactForAttempt, snapshot,
preparationForAttempt.dailyFile()));
if (persistedResult.discardUploadedObject() && artifactForAttempt.uploaded()) {
@@ -1876,12 +1877,11 @@ public class ShopDataCrawlTaskService {
throw new BusinessException("店铺累计文件并发更新冲突,请稍后重试");
}
private DailyAggregationPreparation prepareDailyAggregation(Long userId,
String shopKeyHash,
private DailyAggregationPreparation prepareDailyAggregation(String shopKeyHash,
LocalDate businessDate,
FileResultEntity row) {
ShopDataCrawlDailyFileEntity dailyFile = dailyFileService.findForUpdate(
userId, shopKeyHash, businessDate);
shopKeyHash, businessDate);
if (handleExistingDailyMembership(row, dailyFile)) {
return new DailyAggregationPreparation(dailyFile, true);
}
@@ -1889,13 +1889,12 @@ public class ShopDataCrawlTaskService {
}
private ShopDataCrawlDailyFileEntity resolveBaseDailyFile(ShopDataCrawlDailyFileEntity currentDailyFile,
Long userId,
String shopKeyHash,
LocalDate businessDate) {
if (currentDailyFile != null) {
return currentDailyFile;
}
List<ShopDataCrawlDailyFileEntity> older = dailyFileService.findOlder(userId, shopKeyHash, businessDate);
List<ShopDataCrawlDailyFileEntity> older = dailyFileService.findOlder(shopKeyHash, businessDate);
return older == null || older.isEmpty() ? null : older.get(0);
}
@@ -1916,7 +1915,8 @@ public class ShopDataCrawlTaskService {
Math.max(0L, Objects.requireNonNullElse(baseDailyFile.getResultFileSize(), 0L)),
false,
filename,
Math.max(0, Objects.requireNonNullElse(baseDailyFile.getRowCount(), 0)));
Math.max(0, Objects.requireNonNullElse(baseDailyFile.getRowCount(), 0)),
parseCountryCodesJson(baseDailyFile.getCountryCodesJson()));
}
File workRoot = FileUtil.mkdir(FileUtil.file(
@@ -1935,7 +1935,8 @@ public class ShopDataCrawlTaskService {
if (blank(objectKey)) {
throw new BusinessException("累计文件上传后未返回文件地址");
}
return new DailyWorkbookArtifact(objectKey, outputXlsx.length(), true, filename, rowCount);
List<String> countryCodes = collectCountryCodes(accumulatedItems);
return new DailyWorkbookArtifact(objectKey, outputXlsx.length(), true, filename, rowCount, countryCodes);
} finally {
FileUtil.del(outputXlsx);
FileUtil.del(workRoot);
@@ -1943,8 +1944,10 @@ public class ShopDataCrawlTaskService {
}
/**
* 数据层累积重建每日累计文件的快照列表:既有成员行按 (createdAt, id) 升序读取,
* 优先用行级 payloadpayload 缺失(历史数据)时按结果快照兜底;新结果追加在末尾。
* 数据层增量模型:既有成员行按 (createdAt, id) 升序读取,优先用行级 payload
* payload 缺失(历史数据)时按结果快照兜底;新结果追加在末尾。
* 随后按国家覆盖:同一国家以最后一次出现的成员结果为准(本次回传的国家替换旧行,
* 未回传的国家保留),item 结构与顺序保持稳定。
*/
private List<ShopDataCrawlResultItemVo> buildDailyFileFromData(ShopDataCrawlDailyFileEntity baseDailyFile,
List<ShopDataCrawlResultItemVo> appended) {
@@ -1967,7 +1970,93 @@ public class ShopDataCrawlTaskService {
accumulated.add(item);
}
}
return accumulated;
return applyCountryCoverage(accumulated);
}
/**
* 按国家覆盖:从后往前记录每个国家最后一次出现的成员下标;每个成员只保留
* "该国最后一次出现" 的国家结果(非该次出现的行被剔除)。空结果国家同样覆盖旧行。
* item 结构(resultId/shopName 等元数据)与顺序保持稳定。
*/
private List<ShopDataCrawlResultItemVo> applyCountryCoverage(List<ShopDataCrawlResultItemVo> items) {
if (items == null || items.isEmpty()) {
return items == null ? List.of() : items;
}
Map<String, Integer> lastCountryItemIndex = new HashMap<>();
for (int i = 0; i < items.size(); i++) {
ShopDataCrawlResultItemVo item = items.get(i);
if (item == null) {
continue;
}
for (ShopDataCrawlCountryResultDto countryResult : item.getCountryResults() == null
? List.<ShopDataCrawlCountryResultDto>of() : item.getCountryResults()) {
if (countryResult != null && !blank(countryResult.getCountry())) {
lastCountryItemIndex.put(countryResult.getCountry().trim().toUpperCase(), i);
}
}
}
if (lastCountryItemIndex.isEmpty()) {
return items;
}
for (int i = 0; i < items.size(); i++) {
ShopDataCrawlResultItemVo item = items.get(i);
if (item == null || item.getCountryResults() == null || item.getCountryResults().isEmpty()) {
continue;
}
List<ShopDataCrawlCountryResultDto> kept = new ArrayList<>();
for (ShopDataCrawlCountryResultDto countryResult : item.getCountryResults()) {
if (countryResult == null || blank(countryResult.getCountry())) {
continue;
}
if (Objects.equals(lastCountryItemIndex.get(countryResult.getCountry().trim().toUpperCase()), i)) {
kept.add(countryResult);
}
}
item.setCountryResults(kept);
}
return items;
}
private List<String> collectCountryCodes(List<ShopDataCrawlResultItemVo> items) {
LinkedHashSet<String> codes = new LinkedHashSet<>();
if (items != null) {
for (ShopDataCrawlResultItemVo item : items) {
if (item == null) {
continue;
}
for (ShopDataCrawlCountryResultDto countryResult : item.getCountryResults() == null
? List.<ShopDataCrawlCountryResultDto>of() : item.getCountryResults()) {
if (countryResult != null && !blank(countryResult.getCountry())) {
codes.add(countryResult.getCountry().trim().toUpperCase());
}
}
}
}
return new ArrayList<>(codes);
}
private String countryCodesJson(List<String> countryCodes) {
if (countryCodes == null || countryCodes.isEmpty()) {
return "[]";
}
try {
return objectMapper.writeValueAsString(countryCodes);
} catch (Exception ex) {
return "[]";
}
}
private List<String> parseCountryCodesJson(String json) {
if (blank(json)) {
return List.of();
}
try {
List<String> parsed = objectMapper.readValue(json, new TypeReference<List<String>>() {
});
return parsed == null ? List.of() : parsed;
} catch (Exception ex) {
return List.of();
}
}
private ShopDataCrawlResultItemVo snapshotFromPayload(ShopDataCrawlDailyMemberEntity member) {
@@ -2023,7 +2112,6 @@ public class ShopDataCrawlTaskService {
private DailyAggregationResult persistDailyAggregation(FileTaskEntity task,
FileResultEntity row,
Long userId,
String shopKey,
String shopKeyHash,
LocalDate businessDate,
@@ -2032,7 +2120,7 @@ public class ShopDataCrawlTaskService {
ShopDataCrawlResultItemVo snapshot,
ShopDataCrawlDailyFileEntity expectedBase) {
ShopDataCrawlDailyFileEntity dailyFile = dailyFileService.findForUpdate(
userId, shopKeyHash, businessDate);
shopKeyHash, businessDate);
if (handleExistingDailyMembership(row, dailyFile)) {
return new DailyAggregationResult(List.of(), true);
}
@@ -2044,7 +2132,7 @@ public class ShopDataCrawlTaskService {
}
List<ShopDataCrawlDailyFileEntity> olderFiles = dailyFileService.findOlder(
userId, shopKeyHash, businessDate);
shopKeyHash, businessDate);
Set<String> obsoleteObjectKeys = new HashSet<>();
collectObjectKey(obsoleteObjectKeys, dailyFile == null ? null : dailyFile.getResultFileUrl());
for (ShopDataCrawlDailyFileEntity older : olderFiles) {
@@ -2053,8 +2141,6 @@ public class ShopDataCrawlTaskService {
String objectKey = artifact.objectKey();
String filename = artifact.filename();
List<FileResultEntity> shopRows = findShopResultRows(userId, row);
clearShopResultPointers(shopRows, row.getId());
row.setResultFilename(filename);
row.setResultFileUrl(objectKey);
row.setResultFileSize(artifact.fileSize());
@@ -2065,7 +2151,6 @@ public class ShopDataCrawlTaskService {
LocalDateTime now = dailyFileService.currentBusinessDateTime();
if (dailyFile == null) {
dailyFile = new ShopDataCrawlDailyFileEntity();
dailyFile.setUserId(userId);
dailyFile.setShopKeyHash(shopKeyHash);
dailyFile.setShopKey(shopKey);
dailyFile.setBusinessDate(businessDate);
@@ -2081,6 +2166,8 @@ public class ShopDataCrawlTaskService {
dailyFile.setResultFileSize(row.getResultFileSize());
dailyFile.setResultContentType(CONTENT_TYPE_XLSX);
dailyFile.setRowCount(row.getRowCount());
dailyFile.setCountryCodesJson(countryCodesJson(artifact.countryCodes()));
dailyFile.setCompensationDone(1);
dailyFile.setLastSuccessAt(now);
dailyFile.setUpdatedAt(now);
if (dailyFile.getId() == null) {
@@ -2179,60 +2266,6 @@ public class ShopDataCrawlTaskService {
return template.execute(status -> action.get());
}
private List<FileResultEntity> findShopResultRows(Long userId, FileResultEntity sourceRow) {
if (userId == null || sourceRow == null) {
return List.of();
}
String shopId = trimToNull(sourceRow.getSourceFileUrl());
String shopName = trimToNull(sourceRow.getSourceFilename());
LambdaQueryWrapper<FileResultEntity> wrapper = new LambdaQueryWrapper<FileResultEntity>()
.eq(FileResultEntity::getModuleType, MODULE_TYPE)
.and(owner -> owner.eq(FileResultEntity::getUserId, userId)
.or().isNull(FileResultEntity::getUserId));
if (shopId != null) {
wrapper.eq(FileResultEntity::getSourceFileUrl, shopId);
} else if (shopName != null) {
wrapper.eq(FileResultEntity::getSourceFilename, shopName)
.apply("TRIM(COALESCE(source_file_url, '')) = ''");
} else {
return List.of();
}
List<FileResultEntity> candidates = fileResultMapper.selectList(wrapper);
if (candidates == null || candidates.isEmpty()) {
return List.of();
}
Map<Long, FileTaskEntity> legacyTaskOwners = loadTaskMapByIds(candidates.stream()
.filter(candidate -> candidate != null && candidate.getUserId() == null)
.map(FileResultEntity::getTaskId)
.filter(Objects::nonNull)
.distinct()
.toList());
return candidates.stream()
.filter(Objects::nonNull)
.filter(candidate -> Objects.equals(userId, candidate.getUserId())
|| Objects.equals(userId,
legacyTaskOwners.get(candidate.getTaskId()) == null
? null
: legacyTaskOwners.get(candidate.getTaskId()).getUserId()))
.toList();
}
private void clearShopResultPointers(List<FileResultEntity> rows, Long keepResultId) {
if (rows == null) {
return;
}
for (FileResultEntity candidate : rows) {
if (candidate == null || Objects.equals(candidate.getId(), keepResultId)
|| blank(candidate.getResultFileUrl())) {
continue;
}
candidate.setResultFileUrl(null);
candidate.setResultFileSize(null);
candidate.setResultContentType(null);
fileResultMapper.updateById(candidate);
}
}
private void collectObjectKey(Set<String> target, String value) {
if (target == null || blank(value)) {
return;
@@ -2286,19 +2319,18 @@ public class ShopDataCrawlTaskService {
}
}
private DailyLockSet acquireDailyLocks(Long fallbackUserId, List<FileResultEntity> rows) {
private DailyLockSet acquireDailyLocks(List<FileResultEntity> rows) {
Map<String, DailyLockRequest> requests = new TreeMap<>();
if (rows != null) {
for (FileResultEntity row : rows) {
if (row == null) {
continue;
}
Long userId = row.getUserId() != null ? row.getUserId() : fallbackUserId;
String shopKey = dailyFileService.shopKey(row);
if (userId == null || userId <= 0 || blank(shopKey)) {
if (blank(shopKey)) {
continue;
}
requests.putIfAbsent(userId + "|" + shopKey, new DailyLockRequest(userId, shopKey));
requests.putIfAbsent(shopKey, new DailyLockRequest(shopKey));
}
}
if (requests.isEmpty()) {
@@ -2307,8 +2339,7 @@ public class ShopDataCrawlTaskService {
List<TaskDistributedLockService.LockHandle> handles = new ArrayList<>();
try {
for (DailyLockRequest request : requests.values()) {
TaskDistributedLockService.LockHandle handle = dailyFileService.acquireLock(
request.userId(), request.shopKey());
TaskDistributedLockService.LockHandle handle = dailyFileService.acquireLock(request.shopKey());
if (handle == null) {
throw new BusinessException("店铺累计文件正在处理中,请稍后重试");
}
@@ -2321,7 +2352,7 @@ public class ShopDataCrawlTaskService {
}
}
private record DailyLockRequest(Long userId, String shopKey) {
private record DailyLockRequest(String shopKey) {
}
private record DailyMemberData(ShopDataCrawlDailyMemberEntity member,
@@ -2374,10 +2405,11 @@ public class ShopDataCrawlTaskService {
long fileSize,
boolean uploaded,
String filename,
int rowCount) {
int rowCount,
List<String> countryCodes) {
DailyWorkbookArtifact(String objectKey, long fileSize, boolean uploaded, String filename) {
this(objectKey, fileSize, uploaded, filename, 0);
DailyWorkbookArtifact(String objectKey, long fileSize, boolean uploaded, String filename, int rowCount) {
this(objectKey, fileSize, uploaded, filename, rowCount, List.of());
}
}
@@ -2443,6 +2475,133 @@ public class ShopDataCrawlTaskService {
}
}
/**
* V95 迁移后启动补偿:对 compensation_done=0 的每日累计文件,
* 按成员 result 快照重建一份合并工作簿(覆盖国家语义),上传新对象、
* 更新 daily_file 与最新 result 行指针、清理不再被引用的旧对象。
* 单文件失败仅记日志,不阻断其余文件与启动。
*/
public void compensateDailyWorkbooksAfterMigration() {
List<ShopDataCrawlDailyFileEntity> pending = dailyFileService.findForCompensation();
if (pending.isEmpty()) {
return;
}
log.info("[shop-data-crawl] daily compensation start, pending={}", pending.size());
int processed = 0;
for (ShopDataCrawlDailyFileEntity dailyFile : pending) {
if (dailyFile == null || dailyFile.getId() == null) {
continue;
}
try {
compensateOneDailyWorkbook(dailyFile);
processed++;
} catch (Exception ex) {
log.warn("[shop-data-crawl] daily compensation failed dailyFileId={} shopKey={} date={} msg={}",
dailyFile.getId(), dailyFile.getShopKey(), dailyFile.getBusinessDate(), safeMessage(ex));
}
}
log.info("[shop-data-crawl] daily compensation done, processed={}/{}", processed, pending.size());
}
private void compensateOneDailyWorkbook(ShopDataCrawlDailyFileEntity dailyFile) {
List<ShopDataCrawlDailyMemberEntity> members = dailyFileService.listMembers(dailyFile.getId());
List<ShopDataCrawlDailyMemberEntity> sorted = sortedDailyMembers(members);
if (sorted.isEmpty()) {
// 无成员行:标记完成,避免每次启动重试
dailyFileService.markCompensationDone(dailyFile);
return;
}
List<ShopDataCrawlResultItemVo> snapshots = new ArrayList<>();
for (ShopDataCrawlDailyMemberEntity member : sorted) {
FileResultEntity result = fileResultMapper.selectById(member.getResultId());
if (result == null) {
log.warn("[shop-data-crawl] compensation member result missing dailyFileId={} resultId={}",
dailyFile.getId(), member.getResultId());
continue;
}
ShopDataCrawlResultItemVo snapshot = loadSnapshotForDailyMember(result);
if (snapshot == null) {
log.warn("[shop-data-crawl] compensation snapshot missing dailyFileId={} resultId={} skip",
dailyFile.getId(), member.getResultId());
continue;
}
snapshots.add(snapshot);
}
if (snapshots.isEmpty()) {
dailyFileService.markCompensationDone(dailyFile);
return;
}
String filename = blank(dailyFile.getResultFilename())
? buildTaskWorkbookFilename(loadCompensationTask(dailyFile))
: dailyFile.getResultFilename();
String oldObjectKey = dailyFile.getResultFileUrl();
File workRoot = FileUtil.mkdir(FileUtil.file(
System.getProperty("java.io.tmpdir"),
"shop-data-crawl-result",
"compensation-" + dailyFile.getId()));
File outputXlsx = FileUtil.file(workRoot, filename);
try {
int rowCount = excelAssemblyService.writeWorkbook(outputXlsx, snapshots);
String newObjectKey = ossStorageService.uploadResultFile(outputXlsx, MODULE_TYPE);
if (blank(newObjectKey)) {
throw new BusinessException("累计文件补偿上传结果为空");
}
LocalDateTime now = dailyFileService.currentBusinessDateTime();
dailyFile.setLatestTaskId(dailyFile.getLatestTaskId());
dailyFile.setResultFilename(filename);
dailyFile.setResultFileUrl(newObjectKey);
dailyFile.setResultFileSize(outputXlsx.length());
dailyFile.setResultContentType(CONTENT_TYPE_XLSX);
dailyFile.setRowCount(rowCount);
dailyFile.setCountryCodesJson(countryCodesJson(collectCountryCodes(snapshots)));
dailyFile.setVersion(Math.max(0L, Objects.requireNonNullElse(dailyFile.getVersion(), 0L)) + 1L);
dailyFile.setCompensationDone(1);
dailyFile.setLastSuccessAt(now);
dailyFile.setUpdatedAt(now);
dailyFileService.update(dailyFile);
FileResultEntity latestResult = fileResultMapper.selectById(dailyFile.getLatestResultId());
if (latestResult != null) {
latestResult.setResultFilename(filename);
latestResult.setResultFileUrl(newObjectKey);
latestResult.setResultFileSize(outputXlsx.length());
latestResult.setResultContentType(CONTENT_TYPE_XLSX);
latestResult.setRowCount(rowCount);
fileResultMapper.updateById(latestResult);
}
// 清理旧对象(引用计数:其他 result/daily_file 仍引用则保留)
if (!blank(oldObjectKey) && !Objects.equals(oldObjectKey, newObjectKey)) {
deleteResultObjectNowIfUnreferenced(oldObjectKey);
}
log.info("[shop-data-crawl] compensation rebuilt dailyFileId={} shopKey={} rows={} object={}",
dailyFile.getId(), dailyFile.getShopKey(), rowCount, newObjectKey);
} finally {
FileUtil.del(outputXlsx);
FileUtil.del(workRoot);
}
}
private FileTaskEntity loadCompensationTask(ShopDataCrawlDailyFileEntity dailyFile) {
if (dailyFile.getLatestTaskId() != null) {
FileTaskEntity task = fileTaskMapper.selectById(dailyFile.getLatestTaskId());
if (task != null) {
return task;
}
}
List<ShopDataCrawlDailyMemberEntity> members = dailyFileService.listMembers(dailyFile.getId());
for (ShopDataCrawlDailyMemberEntity member : sortedDailyMembers(members)) {
FileTaskEntity task = fileTaskMapper.selectById(member.getTaskId());
if (task != null) {
return task;
}
}
FileTaskEntity fallback = new FileTaskEntity();
fallback.setId(0L);
fallback.setTaskNo(MODULE_TYPE + "-" + IdUtil.getSnowflakeNextIdStr());
return fallback;
}
private boolean isTaskWorkbookPending(FileTaskEntity task, List<FileResultEntity> rows) {
if (task == null || task.getId() == null || rows == null || rows.isEmpty()) {
return false;
@@ -0,0 +1,173 @@
package com.nanri.aiimage.modules.shopdatacrawl.util;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.nanri.aiimage.modules.shopdatacrawl.model.vo.ShopDataCrawlResultItemVo;
import lombok.extern.slf4j.Slf4j;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicLong;
/**
* 店铺抓取压测探针:并发执行多轮"生成-采样-回传-上传-锁"流水线,
* 产出内存峰值、总耗时、DB QPS、对象存储流量和锁等待五类可比较指标。
* 同一输入必然产生相同计数(幂等);行数、线程数和轮数均有上限,防止无界资源增长。
*/
@Slf4j
public class ShopDataCrawlLoadTestProbe {
public static final int MAX_THREADS = 8;
public static final int MAX_ROUNDS = 50;
private static final int CHUNK_SIZE = 200;
private final ObjectMapper objectMapper;
private final ShopDataCrawlPerfFixture fixture;
private final AtomicBoolean failNextOssUpload = new AtomicBoolean(false);
private final AtomicBoolean failNextLockAcquire = new AtomicBoolean(false);
public ShopDataCrawlLoadTestProbe(ObjectMapper objectMapper, ShopDataCrawlPerfFixture fixture) {
this.objectMapper = objectMapper;
this.fixture = fixture;
}
/** 注入下一次对象上传失败(只生效一次),用于依赖失败可恢复验证。 */
public void failNextOssUpload() {
failNextOssUpload.set(true);
}
/** 注入下一次任务锁获取失败(只生效一次),用于锁等待重试验证。 */
public void failNextLockAcquire() {
failNextLockAcquire.set(true);
}
public Report runComparison(String shopName, int rowCount, int countryCount, boolean withImages,
int threads, int rounds) {
validate(shopName, rowCount, countryCount, threads, rounds);
long startedNanos = System.nanoTime();
ExecutorService pool = Executors.newFixedThreadPool(threads);
List<Future<RoundResult>> futures = new ArrayList<>();
try {
for (int round = 0; round < rounds; round++) {
final int roundIndex = round;
futures.add(pool.submit(() -> runRound(shopName, rowCount, countryCount, withImages, roundIndex)));
}
RoundResult total = new RoundResult();
for (Future<RoundResult> future : futures) {
total.merge(future.get());
}
long elapsedMillis = Math.max(1L, (System.nanoTime() - startedNanos) / 1_000_000L);
long peakHeap = Math.max(total.peakHeapBytes, usedHeapBytes());
long dbOps = total.dbReads + total.dbWrites;
long dbQps = dbOps * 1000L / elapsedMillis;
return new Report(rounds, total.rows, peakHeap, elapsedMillis,
total.dbReads, total.dbWrites, total.ossUploads, total.ossUploadBytes,
total.ossDeletes, total.lockAcquires, total.lockRetries,
dbQps, total.ossUploadBytes);
} catch (ExecutionException ex) {
Throwable cause = ex.getCause();
if (cause instanceof IllegalArgumentException iae) {
throw iae;
}
if (cause instanceof IllegalStateException ise) {
throw ise;
}
throw new IllegalStateException("店铺抓取压测执行失败: " + safeMessage(cause), cause);
} catch (InterruptedException ex) {
Thread.currentThread().interrupt();
throw new IllegalStateException("店铺抓取压测被中断", ex);
} finally {
pool.shutdownNow();
}
}
private void validate(String shopName, int rowCount, int countryCount, int threads, int rounds) {
if (shopName == null || shopName.isBlank()) {
throw new IllegalArgumentException("shopName 不能为空");
}
if (rowCount < 0 || rowCount > ShopDataCrawlPerfFixture.MAX_ROWS) {
throw new IllegalArgumentException("rowCount 必须在 [0, " + ShopDataCrawlPerfFixture.MAX_ROWS + "] 范围内,实际 " + rowCount);
}
if (countryCount < 1 || countryCount > ShopDataCrawlPerfFixture.COUNTRIES.size()) {
throw new IllegalArgumentException("countryCount 必须在 [1, " + ShopDataCrawlPerfFixture.COUNTRIES.size() + "] 范围内,实际 " + countryCount);
}
if (threads < 1 || threads > MAX_THREADS) {
throw new IllegalArgumentException("threads 必须在 [1, " + MAX_THREADS + "] 范围内,实际 " + threads);
}
if (rounds < 1 || rounds > MAX_ROUNDS) {
throw new IllegalArgumentException("rounds 必须在 [1, " + MAX_ROUNDS + "] 范围内,实际 " + rounds);
}
}
private RoundResult runRound(String shopName, int rowCount, int countryCount, boolean withImages,
int roundIndex) {
RoundResult result = new RoundResult();
List<ShopDataCrawlResultItemVo> items =
fixture.generateItems(shopName, rowCount, countryCount, withImages, 0);
ShopDataCrawlPerfFixture.Metrics metrics = fixture.samplePayload(items, withImages, CHUNK_SIZE);
// 任务锁:模拟任务锁获取,注入失败时重试一次。
if (failNextLockAcquire.compareAndSet(true, false)) {
result.lockRetries++;
}
result.lockAcquires++;
if (rowCount > 0) {
result.dbReads += countryCount;
result.dbWrites += 2;
if (failNextOssUpload.compareAndSet(true, false)) {
throw new IllegalStateException("对象存储上传失败: 注入依赖失败 shop=" + shopName + " round=" + roundIndex);
}
result.ossUploads++;
result.ossUploadBytes += metrics.payloadBytes();
}
result.rows += metrics.rowCount();
result.peakHeapBytes = Math.max(result.peakHeapBytes, usedHeapBytes());
return result;
}
private static long usedHeapBytes() {
Runtime runtime = Runtime.getRuntime();
return runtime.totalMemory() - runtime.freeMemory();
}
private static String safeMessage(Throwable throwable) {
return throwable == null ? "unknown" : String.valueOf(throwable.getMessage());
}
/** 单轮执行结果,跨线程聚合后产出最终报告。 */
private static final class RoundResult {
long rows;
long dbReads;
long dbWrites;
long ossUploads;
long ossUploadBytes;
long ossDeletes;
long lockAcquires;
long lockRetries;
long peakHeapBytes;
void merge(RoundResult other) {
rows += other.rows;
dbReads += other.dbReads;
dbWrites += other.dbWrites;
ossUploads += other.ossUploads;
ossUploadBytes += other.ossUploadBytes;
ossDeletes += other.ossDeletes;
lockAcquires += other.lockAcquires;
lockRetries += other.lockRetries;
peakHeapBytes = Math.max(peakHeapBytes, other.peakHeapBytes);
}
}
/** 压测比较报告:内存、耗时、DB QPS、对象存储流量与锁等待五类指标。 */
public record Report(int rounds, long totalRows, long peakHeapBytes, long totalElapsedMillis,
long dbReads, long dbWrites, long ossUploads, long ossUploadBytes,
long ossDeletes, long lockAcquires, long lockRetries,
long dbQps, long ossTrafficBytes) {
}
}
@@ -4,6 +4,7 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import com.nanri.aiimage.modules.shopmatch.model.dto.ShopMatchShopPayloadDto;
import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity;
import com.nanri.aiimage.modules.task.service.TaskScopePayloadStorageService;
import com.nanri.aiimage.modules.task.util.TaskEntityLocalCache;
import com.nanri.aiimage.config.TaskPressureProperties;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -20,7 +21,6 @@ import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@Service
@RequiredArgsConstructor
@@ -33,7 +33,7 @@ public class ShopMatchTaskCacheService {
private final ObjectMapper objectMapper;
private final TaskPressureProperties taskPressureProperties;
private final TaskScopePayloadStorageService taskScopePayloadStorageService;
private final ConcurrentHashMap<Long, LocalTaskEntityCacheEntry> taskEntityLocalCache = new ConcurrentHashMap<>();
private final TaskEntityLocalCache taskEntityLocalCache;
public void touchTaskHeartbeat(Long taskId) {
if (taskId == null || taskId <= 0) {
@@ -133,7 +133,7 @@ public class ShopMatchTaskCacheService {
if (taskId == null || taskId <= 0) {
return;
}
taskEntityLocalCache.remove(taskId);
taskEntityLocalCache.evict(taskId);
try {
stringRedisTemplate.delete(buildTaskHeartbeatKey(taskId));
} catch (Exception ex) {
@@ -169,11 +169,7 @@ public class ShopMatchTaskCacheService {
if (task == null || task.getId() == null) {
return;
}
long now = System.currentTimeMillis();
taskEntityLocalCache.put(task.getId(), new LocalTaskEntityCacheEntry(
now,
objectMapper.convertValue(task, FileTaskEntity.class)
));
taskEntityLocalCache.put(task.getId(), task);
try {
Files.createDirectories(buildTaskDir(task.getId()));
Files.writeString(
@@ -202,9 +198,9 @@ public class ShopMatchTaskCacheService {
long now = System.currentTimeMillis();
java.util.List<Long> missingIds = new ArrayList<>();
for (Long taskId : normalized) {
LocalTaskEntityCacheEntry cached = taskEntityLocalCache.get(taskId);
if (isLocalCacheFresh(cached, now)) {
result.put(taskId, objectMapper.convertValue(cached.task(), FileTaskEntity.class));
TaskEntityLocalCache.Entry cached = taskEntityLocalCache.get(taskId, now);
if (cached != null) {
result.put(taskId, cached.task());
} else {
missingIds.add(taskId);
}
@@ -225,7 +221,7 @@ public class ShopMatchTaskCacheService {
try {
FileTaskEntity task = objectMapper.readValue(Files.readString(file), FileTaskEntity.class);
result.put(taskId, task);
taskEntityLocalCache.put(taskId, new LocalTaskEntityCacheEntry(now, task));
taskEntityLocalCache.put(taskId, task);
} catch (Exception ignored) {
}
}
@@ -248,11 +244,6 @@ public class ShopMatchTaskCacheService {
return "shop-match:task:heartbeat:" + taskId;
}
private boolean isLocalCacheFresh(LocalTaskEntityCacheEntry cached, long now) {
return cached != null
&& now - cached.cachedAtMillis() <= Math.max(0L, taskPressureProperties.getLocalTaskEntityCacheMillis());
}
/**
* 文件缓存新鲜度判断:超过 {@code localTaskEntityFileCacheMillis} 视为过期。
* 通过文件 mtime 判断,避免在 finalize 与 poll 线程的竞态下把陈旧的 RUNNING 写回后被永久信任。
@@ -269,6 +260,4 @@ public class ShopMatchTaskCacheService {
return false;
}
}
private record LocalTaskEntityCacheEntry(long cachedAtMillis, FileTaskEntity task) {}
}
@@ -6,7 +6,6 @@ import com.nanri.aiimage.config.SimilarAsinProperties;
import com.nanri.aiimage.modules.coze.service.CozeCredentialPoolService;
import com.nanri.aiimage.modules.similarasin.model.dto.SimilarAsinResultRowDto;
import com.nanri.aiimage.modules.similarasin.util.SimilarAsinLogSupport;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
@@ -15,6 +14,8 @@ import org.springframework.stereotype.Component;
import org.springframework.util.StreamUtils;
import org.springframework.web.client.RestClient;
import com.nanri.aiimage.config.HttpClientPool;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
@@ -25,7 +26,6 @@ import java.util.Map;
import java.util.concurrent.atomic.AtomicLong;
@Component
@RequiredArgsConstructor
@Slf4j
public class SimilarAsinCozeClient {
@@ -37,6 +37,7 @@ public class SimilarAsinCozeClient {
private final SimilarAsinProperties properties;
private final ObjectMapper objectMapper;
private final CozeCredentialPoolService cozeCredentialPoolService;
private final com.nanri.aiimage.metrics.ExternalCallMetricsRecorder externalCallMetrics;
private final AtomicLong credentialCursor = new AtomicLong();
private final AtomicLong historyResponseLogCounter = new AtomicLong();
/**
@@ -204,6 +205,9 @@ public class SimilarAsinCozeClient {
failureMessage(ex));
}
if (attemptIndex < 3) {
if (externalCallMetrics != null) {
externalCallMetrics.recordRetry("coze");
}
sleepBeforeRetry(attemptIndex);
}
}
@@ -749,10 +753,12 @@ public class SimilarAsinCozeClient {
}
synchronized (this) {
if (sharedRestClient == null) {
SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory();
requestFactory.setConnectTimeout(properties.getCozeConnectTimeoutMillis());
requestFactory.setReadTimeout(properties.getCozeReadTimeoutMillis());
sharedRestClient = RestClient.builder().requestFactory(requestFactory).build();
RestClient.Builder builder = RestClient.builder()
.requestFactory(HttpClientPool.requestFactory(properties.getCozeReadTimeoutMillis()));
if (externalCallMetrics != null) {
builder.requestInterceptor(externalCallMetrics.interceptor("coze"));
}
sharedRestClient = builder.build();
}
return sharedRestClient;
}
@@ -871,6 +877,16 @@ public class SimilarAsinCozeClient {
|| !normalize(row.getPuzzleImg2()).isBlank();
}
public SimilarAsinCozeClient(SimilarAsinProperties properties,
ObjectMapper objectMapper,
CozeCredentialPoolService cozeCredentialPoolService,
com.nanri.aiimage.metrics.ExternalCallMetricsRecorder externalCallMetrics) {
this.properties = properties;
this.objectMapper = objectMapper;
this.cozeCredentialPoolService = cozeCredentialPoolService;
this.externalCallMetrics = externalCallMetrics;
}
private void ensureSuccess(JsonNode root) {
if (root.path("code").asInt(-1) != 0) {
throw new IllegalStateException(root.path("msg").asText("Coze response code is not 0"));
@@ -2,8 +2,39 @@ package com.nanri.aiimage.modules.task.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.nanri.aiimage.modules.task.model.entity.TaskResultItemEntity;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface TaskResultItemMapper extends BaseMapper<TaskResultItemEntity> {
/**
* 按唯一键 uk_task_scope_item (task_id, module_type, scope_hash, item_key)
* 批量 upsert:命中唯一键时更新行内容(含 id 保留),否则插入。
*/
@Insert("""
<script>
INSERT INTO biz_task_result_item
(task_id, module_type, result_id, scope_key, scope_hash, item_key, asin, status,
payload_json, payload_hash, created_at, updated_at)
VALUES
<foreach collection="rows" item="row" separator=",">
(#{row.taskId}, #{row.moduleType}, #{row.resultId}, #{row.scopeKey}, #{row.scopeHash},
#{row.itemKey}, #{row.asin}, #{row.status}, #{row.payloadJson}, #{row.payloadHash},
#{row.createdAt}, #{row.updatedAt})
</foreach>
ON DUPLICATE KEY UPDATE
result_id = VALUES(result_id),
scope_key = VALUES(scope_key),
asin = VALUES(asin),
status = VALUES(status),
payload_json = VALUES(payload_json),
payload_hash = VALUES(payload_hash),
updated_at = VALUES(updated_at)
</script>
""")
int upsertBatch(@Param("rows") List<TaskResultItemEntity> rows);
}
@@ -17,6 +17,8 @@ public class TaskFileJobEntity {
private String moduleType;
private Long resultId;
private String scopeKey;
/** 从 scope_key 提取的显式 ownertask:1:owner:instance-a → instance-a),支持等值索引查询。 */
private String owner;
private String jobType;
private String status;
private Integer retryCount;
@@ -22,11 +22,12 @@ import com.nanri.aiimage.modules.task.model.entity.TaskProgressSnapshotEntity;
import com.nanri.aiimage.modules.task.model.entity.TaskResultItemEntity;
import com.nanri.aiimage.modules.task.model.entity.TaskResultPayloadEntity;
import com.nanri.aiimage.modules.task.model.entity.TaskScopeStateEntity;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.support.TransactionTemplate;
import java.time.Duration;
import java.time.LocalDateTime;
@@ -37,12 +38,13 @@ import java.util.Set;
@Slf4j
@Service
@RequiredArgsConstructor
public class ModuleHistoryCleanupService {
private static final Set<String> TERMINAL_STATUSES = Set.of("SUCCESS", "FAILED", "CANCELLED", "CANCELED");
private static final Duration CLEANUP_LOCK_TTL = Duration.ofHours(2);
private static final String COLLECT_DATA_MODULE_TYPE = "COLLECT_DATA";
private static final int DEFAULT_BATCH_SIZE = 500;
private static final int LOG_SAMPLE_IDS = 5;
private final ModuleCleanupProperties moduleCleanupProperties;
private final FileTaskMapper fileTaskMapper;
@@ -55,8 +57,48 @@ public class ModuleHistoryCleanupService {
private final TaskChunkMapper taskChunkMapper;
private final CollectDataItemMapper collectDataItemMapper;
private final DistributedJobLockService distributedJobLockService;
private final TransientPayloadDeleteOrchestrator transientPayloadDeleteOrchestrator;
private final TransactionTemplate transactionTemplate;
@Transactional
/**
* 单批最多收集的 payload 指针数:超过即截断(保底可重试),
* 防止单任务行数异常巨大时无界收集造成内存增长。
*/
@Value("${aiimage.module-cleanup.max-collect-payloads:10000}")
private int maxCollectPayloadsPerRun = 10000;
public ModuleHistoryCleanupService(ModuleCleanupProperties moduleCleanupProperties,
FileTaskMapper fileTaskMapper,
FileResultMapper fileResultMapper,
TaskFileJobMapper taskFileJobMapper,
TaskResultItemMapper taskResultItemMapper,
TaskProgressSnapshotMapper taskProgressSnapshotMapper,
TaskResultPayloadMapper taskResultPayloadMapper,
TaskScopeStateMapper taskScopeStateMapper,
TaskChunkMapper taskChunkMapper,
CollectDataItemMapper collectDataItemMapper,
DistributedJobLockService distributedJobLockService,
TransientPayloadDeleteOrchestrator transientPayloadDeleteOrchestrator,
PlatformTransactionManager platformTransactionManager) {
this.moduleCleanupProperties = moduleCleanupProperties;
this.fileTaskMapper = fileTaskMapper;
this.fileResultMapper = fileResultMapper;
this.taskFileJobMapper = taskFileJobMapper;
this.taskResultItemMapper = taskResultItemMapper;
this.taskProgressSnapshotMapper = taskProgressSnapshotMapper;
this.taskResultPayloadMapper = taskResultPayloadMapper;
this.taskScopeStateMapper = taskScopeStateMapper;
this.taskChunkMapper = taskChunkMapper;
this.collectDataItemMapper = collectDataItemMapper;
this.distributedJobLockService = distributedJobLockService;
this.transientPayloadDeleteOrchestrator = transientPayloadDeleteOrchestrator;
this.transactionTemplate = new TransactionTemplate(platformTransactionManager);
}
/**
* 按 id 升序 keyset 分页拉取过期终态任务,每页一个小批量、一个短事务;
* 页间不持有事务与行锁,失败回滚当页并中止,未处理页可下次重试。
*/
@Scheduled(cron = "${aiimage.module-cleanup.cron:0 0 0 * * *}")
public void cleanupConfiguredModules() {
DistributedJobLockService.LockHandle lockHandle =
@@ -74,91 +116,130 @@ public class ModuleHistoryCleanupService {
return;
}
LocalDateTime cutoff = LocalDateTime.now().minusDays(Math.max(0, moduleCleanupProperties.getRetentionDays()));
int batchSize = moduleCleanupProperties.getBatchSize();
if (batchSize <= 0) {
batchSize = DEFAULT_BATCH_SIZE;
}
List<FileTaskEntity> moduleTasks = fileTaskMapper.selectList(new LambdaQueryWrapper<FileTaskEntity>()
.in(FileTaskEntity::getModuleType, moduleTypes)
.select(FileTaskEntity::getId, FileTaskEntity::getModuleType, FileTaskEntity::getStatus,
FileTaskEntity::getUpdatedAt, FileTaskEntity::getFinishedAt));
List<Long> cleanupTaskIds = new ArrayList<>();
List<Long> cleanupCollectDataTaskIds = new ArrayList<>();
long cursor = 0L;
int totalBatches = 0;
int totalDeletedTasks = 0;
int totalCollectedPointers = 0;
List<Long> skippedActiveTaskIds = new ArrayList<>();
List<Long> skippedRetainedTaskIds = new ArrayList<>();
for (FileTaskEntity task : moduleTasks) {
if (task == null || task.getId() == null) {
continue;
while (true) {
List<FileTaskEntity> page = fileTaskMapper.selectList(new LambdaQueryWrapper<FileTaskEntity>()
.in(FileTaskEntity::getModuleType, moduleTypes)
.gt(FileTaskEntity::getId, cursor)
.orderByAsc(FileTaskEntity::getId)
.last("LIMIT " + batchSize));
if (page.isEmpty()) {
break;
}
if (!isTerminalStatus(task.getStatus())) {
skippedActiveTaskIds.add(task.getId());
continue;
}
if (isExpired(task, cutoff)) {
cleanupTaskIds.add(task.getId());
if (COLLECT_DATA_MODULE_TYPE.equals(task.getModuleType())) {
cleanupCollectDataTaskIds.add(task.getId());
long pageMaxId = cursor;
List<Long> batchTaskIds = new ArrayList<>();
List<Long> batchCollectDataTaskIds = new ArrayList<>();
for (FileTaskEntity task : page) {
if (task == null || task.getId() == null) {
continue;
}
pageMaxId = Math.max(pageMaxId, task.getId());
if (!isTerminalStatus(task.getStatus())) {
skippedActiveTaskIds.add(task.getId());
continue;
}
if (isExpired(task, cutoff)) {
batchTaskIds.add(task.getId());
if (COLLECT_DATA_MODULE_TYPE.equals(task.getModuleType())) {
batchCollectDataTaskIds.add(task.getId());
}
} else {
skippedRetainedTaskIds.add(task.getId());
}
} else {
skippedRetainedTaskIds.add(task.getId());
}
if (!batchTaskIds.isEmpty()) {
final List<Long> taskIds = batchTaskIds;
final List<Long> collectDataTaskIds = batchCollectDataTaskIds;
final List<String> types = moduleTypes;
final List<String> collected = new ArrayList<>();
transactionTemplate.executeWithoutResult(status -> {
collected.addAll(collectPayloadPointers(types, taskIds, maxCollectPayloadsPerRun));
int deletedRows = deleteRows(types, taskIds, collectDataTaskIds);
submitAndFlush(collected);
log.info("[module-cleanup] batch: taskIds={}, deletedRows={}, collectedPointers={}",
formatIdSample(taskIds, LOG_SAMPLE_IDS), deletedRows, collected.size());
});
totalBatches++;
totalDeletedTasks += taskIds.size();
totalCollectedPointers += collected.size();
}
if (pageMaxId <= cursor) {
log.warn("[module-cleanup] keyset cursor did not advance, abort loop cursor={}", cursor);
break;
}
cursor = pageMaxId;
}
if (cleanupTaskIds.isEmpty()) {
log.info("[module-cleanup] skipped: moduleTypes={}, retentionDays={}, cutoff={}, activeTaskIds={}, retainedTaskIds={}, reason=no-expired-terminal-tasks",
moduleTypes, moduleCleanupProperties.getRetentionDays(), cutoff, skippedActiveTaskIds, skippedRetainedTaskIds);
return;
}
int deletedFileJobs = taskFileJobMapper.delete(new LambdaQueryWrapper<TaskFileJobEntity>()
.in(TaskFileJobEntity::getModuleType, moduleTypes)
.in(TaskFileJobEntity::getTaskId, cleanupTaskIds));
int deletedResultItems = taskResultItemMapper.delete(new LambdaQueryWrapper<TaskResultItemEntity>()
.in(TaskResultItemEntity::getModuleType, moduleTypes)
.in(TaskResultItemEntity::getTaskId, cleanupTaskIds));
int deletedProgressSnapshots = taskProgressSnapshotMapper.delete(new LambdaQueryWrapper<TaskProgressSnapshotEntity>()
.in(TaskProgressSnapshotEntity::getModuleType, moduleTypes)
.in(TaskProgressSnapshotEntity::getTaskId, cleanupTaskIds));
int deletedResultPayloads = taskResultPayloadMapper.delete(new LambdaQueryWrapper<TaskResultPayloadEntity>()
.in(TaskResultPayloadEntity::getModuleType, moduleTypes)
.in(TaskResultPayloadEntity::getTaskId, cleanupTaskIds));
int deletedScopeStates = taskScopeStateMapper.delete(new LambdaQueryWrapper<TaskScopeStateEntity>()
.in(TaskScopeStateEntity::getModuleType, moduleTypes)
.in(TaskScopeStateEntity::getTaskId, cleanupTaskIds));
int deletedChunks = taskChunkMapper.delete(new LambdaQueryWrapper<TaskChunkEntity>()
.in(TaskChunkEntity::getModuleType, moduleTypes)
.in(TaskChunkEntity::getTaskId, cleanupTaskIds));
int deletedCollectDataItems = 0;
if (!cleanupCollectDataTaskIds.isEmpty()) {
deletedCollectDataItems = collectDataItemMapper.delete(new LambdaQueryWrapper<CollectDataItemEntity>()
.in(CollectDataItemEntity::getTaskId, cleanupCollectDataTaskIds));
}
int deletedResults = fileResultMapper.delete(new LambdaQueryWrapper<FileResultEntity>()
.in(FileResultEntity::getModuleType, moduleTypes)
.in(FileResultEntity::getTaskId, cleanupTaskIds));
int resetTasks = fileTaskMapper.update(null, new LambdaUpdateWrapper<FileTaskEntity>()
.in(FileTaskEntity::getId, cleanupTaskIds)
.set(FileTaskEntity::getResultJson, null)
.set(FileTaskEntity::getRequestJson, null)
.set(FileTaskEntity::getErrorMessage, null));
int deletedTasks = fileTaskMapper.delete(new LambdaQueryWrapper<FileTaskEntity>()
.in(FileTaskEntity::getId, cleanupTaskIds));
log.info("[module-cleanup] completed: moduleTypes={}, retentionDays={}, cutoff={}, deletedFileJobs={}, deletedResultItems={}, deletedProgressSnapshots={}, deletedResultPayloads={}, deletedScopeStates={}, deletedChunks={}, deletedCollectDataItems={}, deletedResults={}, resetTasks={}, deletedTasks={}, skippedActiveTaskIds={}, retainedTaskIds={}",
log.info("[module-cleanup] completed: moduleTypes={}, retentionDays={}, cutoff={}, batches={}, deletedTasks={}, collectedPayloadPointers={}, skippedActiveTaskIds={}, retainedTaskIds={}",
moduleTypes, moduleCleanupProperties.getRetentionDays(), cutoff,
deletedFileJobs, deletedResultItems, deletedProgressSnapshots, deletedResultPayloads,
deletedScopeStates, deletedChunks, deletedCollectDataItems, deletedResults, resetTasks, deletedTasks,
skippedActiveTaskIds, skippedRetainedTaskIds);
totalBatches, totalDeletedTasks, totalCollectedPointers,
formatIdSample(skippedActiveTaskIds, LOG_SAMPLE_IDS),
formatIdSample(skippedRetainedTaskIds, LOG_SAMPLE_IDS));
}
}
/** 一个事务内完成:收集指针 → 删除本批行 → 行删完后提交清理队列并 flush。 */
private int deleteRows(List<String> moduleTypes, List<Long> cleanupTaskIds, List<Long> collectDataTaskIds) {
taskFileJobMapper.delete(new LambdaQueryWrapper<TaskFileJobEntity>()
.in(TaskFileJobEntity::getModuleType, moduleTypes)
.in(TaskFileJobEntity::getTaskId, cleanupTaskIds));
taskResultItemMapper.delete(new LambdaQueryWrapper<TaskResultItemEntity>()
.in(TaskResultItemEntity::getModuleType, moduleTypes)
.in(TaskResultItemEntity::getTaskId, cleanupTaskIds));
taskProgressSnapshotMapper.delete(new LambdaQueryWrapper<TaskProgressSnapshotEntity>()
.in(TaskProgressSnapshotEntity::getModuleType, moduleTypes)
.in(TaskProgressSnapshotEntity::getTaskId, cleanupTaskIds));
taskResultPayloadMapper.delete(new LambdaQueryWrapper<TaskResultPayloadEntity>()
.in(TaskResultPayloadEntity::getModuleType, moduleTypes)
.in(TaskResultPayloadEntity::getTaskId, cleanupTaskIds));
taskScopeStateMapper.delete(new LambdaQueryWrapper<TaskScopeStateEntity>()
.in(TaskScopeStateEntity::getModuleType, moduleTypes)
.in(TaskScopeStateEntity::getTaskId, cleanupTaskIds));
taskChunkMapper.delete(new LambdaQueryWrapper<TaskChunkEntity>()
.in(TaskChunkEntity::getModuleType, moduleTypes)
.in(TaskChunkEntity::getTaskId, cleanupTaskIds));
if (!collectDataTaskIds.isEmpty()) {
collectDataItemMapper.delete(new LambdaQueryWrapper<CollectDataItemEntity>()
.in(CollectDataItemEntity::getTaskId, collectDataTaskIds));
}
fileResultMapper.delete(new LambdaQueryWrapper<FileResultEntity>()
.in(FileResultEntity::getModuleType, moduleTypes)
.in(FileResultEntity::getTaskId, cleanupTaskIds));
fileTaskMapper.update(null, new LambdaUpdateWrapper<FileTaskEntity>()
.in(FileTaskEntity::getId, cleanupTaskIds)
.set(FileTaskEntity::getResultJson, null)
.set(FileTaskEntity::getRequestJson, null)
.set(FileTaskEntity::getErrorMessage, null));
return fileTaskMapper.delete(new LambdaQueryWrapper<FileTaskEntity>()
.in(FileTaskEntity::getId, cleanupTaskIds));
}
private void submitAndFlush(List<String> collectedPointers) {
if (collectedPointers.isEmpty()) {
return;
}
transientPayloadDeleteOrchestrator.submitDeletes(collectedPointers);
transientPayloadDeleteOrchestrator.flushPendingDeletes();
}
private boolean isExpired(FileTaskEntity task, LocalDateTime cutoff) {
LocalDateTime completedAt = task.getFinishedAt() != null ? task.getFinishedAt() : task.getUpdatedAt();
return completedAt != null && !completedAt.isAfter(cutoff);
@@ -170,4 +251,63 @@ public class ModuleHistoryCleanupService {
}
return TERMINAL_STATUSES.contains(status.trim().toUpperCase(Locale.ROOT));
}
/**
* 删除前批量收集将随行删除的 payload 指针(chunk.payloadJson、
* scope_state.parsedPayloadJson / stateJson),去重并保持稳定顺序;
* 达到 {@code max} 上限即截断,防止异常巨大的任务行数引发无界收集。
*/
private List<String> collectPayloadPointers(List<String> moduleTypes, List<Long> cleanupTaskIds, int max) {
java.util.Set<String> pointers = new java.util.LinkedHashSet<>();
List<TaskChunkEntity> chunks = taskChunkMapper.selectList(new LambdaQueryWrapper<TaskChunkEntity>()
.in(TaskChunkEntity::getModuleType, moduleTypes)
.in(TaskChunkEntity::getTaskId, cleanupTaskIds));
for (TaskChunkEntity chunk : chunks) {
collectPointer(pointers, chunk.getPayloadJson(), max);
}
List<TaskScopeStateEntity> scopeStates = taskScopeStateMapper.selectList(new LambdaQueryWrapper<TaskScopeStateEntity>()
.in(TaskScopeStateEntity::getModuleType, moduleTypes)
.in(TaskScopeStateEntity::getTaskId, cleanupTaskIds));
for (TaskScopeStateEntity scopeState : scopeStates) {
collectPointer(pointers, scopeState.getParsedPayloadJson(), max);
collectPointer(pointers, scopeState.getStateJson(), max);
}
if (pointers.size() >= max) {
log.warn("[module-cleanup] payload pointer collection truncated at max={}", max);
}
return new ArrayList<>(pointers);
}
private void collectPointer(java.util.Set<String> pointers, String value, int max) {
if (pointers.size() >= max || value == null || value.isBlank()) {
return;
}
String pointer = transientPayloadDeleteOrchestrator.extractPointer(value);
if (pointer != null) {
pointers.add(pointer);
}
}
/**
* 日志用 ID 摘要:只输出数量与最多 {@code sampleLimit} 个样本,
* 禁止把超长任务 ID 列表写进日志。非法上限回退到 1,null/空输出 count=0。
*/
static String formatIdSample(List<Long> ids, int sampleLimit) {
int count = ids == null ? 0 : ids.size();
if (count == 0) {
return "count=0";
}
int limit = Math.max(1, sampleLimit);
StringBuilder sb = new StringBuilder("count=").append(count).append(", sample=[");
for (int i = 0; i < Math.min(count, limit); i++) {
if (i > 0) {
sb.append(',');
}
sb.append(ids.get(i));
}
if (count > limit) {
sb.append(",...");
}
return sb.append(']').toString();
}
}
@@ -11,6 +11,9 @@ import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.task.TaskExecutor;
import org.springframework.stereotype.Service;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
@Slf4j
@Service
@RequiredArgsConstructor
@@ -26,6 +29,15 @@ public class TaskFileJobLocalDispatcher {
@Value("${aiimage.result-file-job.local-dispatch-enabled:true}")
private boolean localDispatchEnabled;
/**
* 已受理未完成的 jobId 集合:同 job 重复 dispatch 幂等返回;
* 数量达到上限时拒绝新提交(背压),防止执行队列无界堆积。
*/
@Value("${aiimage.result-file-job.max-inflight-dispatch:64}")
private int maxInflightDispatch = 64;
private final Set<Long> inflightJobIds = ConcurrentHashMap.newKeySet();
public boolean dispatch(Long jobId, Long taskId, String moduleType) {
return dispatch(jobId, taskId, moduleType, false);
}
@@ -39,15 +51,31 @@ public class TaskFileJobLocalDispatcher {
jobId, taskId, moduleType);
return false;
}
int inflightLimit = Math.max(1, maxInflightDispatch);
if (!inflightJobIds.contains(jobId) && inflightJobIds.size() >= inflightLimit) {
log.warn("[task-file-job] local dispatch backpressure, inflight limit reached jobId={} taskId={} moduleType={} inflight={} limit={}",
jobId, taskId, moduleType, inflightJobIds.size(), inflightLimit);
return false;
}
if (!inflightJobIds.add(jobId)) {
log.info("[task-file-job] local dispatch skipped, job already inflight jobId={} taskId={} moduleType={}",
jobId, taskId, moduleType);
return true;
}
try {
taskFileJobDispatchExecutor.execute(() -> processLocally(jobId, taskId, moduleType));
return true;
} catch (RuntimeException ex) {
inflightJobIds.remove(jobId);
log.warn("[task-file-job] local dispatch executor rejected jobId={} taskId={} moduleType={} msg={}",
jobId, taskId, moduleType, ex.getMessage(), ex);
if (force) {
processLocally(jobId, taskId, moduleType);
return true;
try {
processLocally(jobId, taskId, moduleType);
return true;
} finally {
inflightJobIds.remove(jobId);
}
}
return false;
}
@@ -71,6 +99,8 @@ public class TaskFileJobLocalDispatcher {
} catch (Exception ex) {
log.warn("[task-file-job] local dispatch failed jobId={} taskId={} moduleType={} msg={}",
jobId, taskId, moduleType, ex.getMessage(), ex);
} finally {
inflightJobIds.remove(jobId);
}
}
}
@@ -40,6 +40,7 @@ public class TaskFileJobService {
entity.setModuleType(moduleType);
entity.setResultId(resultId);
entity.setScopeKey(scopeKey);
entity.setOwner(ownerFromScopeKey(scopeKey));
entity.setJobType(JOB_TYPE_ASSEMBLE_RESULT);
entity.setStatus("PENDING");
entity.setRetryCount(0);
@@ -68,6 +69,7 @@ public class TaskFileJobService {
taskFileJobMapper.update(null, new LambdaUpdateWrapper<TaskFileJobEntity>()
.eq(TaskFileJobEntity::getId, existing.getId())
.set(TaskFileJobEntity::getScopeKey, scopeKey)
.set(TaskFileJobEntity::getOwner, ownerFromScopeKey(scopeKey))
.set(TaskFileJobEntity::getStatus, "PENDING")
.set(TaskFileJobEntity::getErrorMessage, null)
.set(TaskFileJobEntity::getUpdatedAt, now)
@@ -86,19 +88,81 @@ public class TaskFileJobService {
.last("limit " + Math.max(1, Math.min(limit, 100))));
}
public List<TaskFileJobEntity> listRunnableJobsForOwner(int limit, String owner) {
/**
* 原子 claim:先查候选(PENDING/FAILED 且未达重试上限),再逐个以条件
* UPDATE(状态+retryCount 匹配 → RUNNING)翻转;只有翻转成功的行才返回。
* 多个 worker/实例并发取数时,同一 job 只会被一个消费者 claim 到,
* 从源头避免重复派发同一 job。
*/
public List<TaskFileJobEntity> claimRunnableJobs(int limit) {
List<TaskFileJobEntity> candidates = listRunnableJobs(limit);
return claimCandidates(candidates);
}
/** 带 owner 过滤的原子 claim:owner 任务优先,其余补足;同 {@link #claimRunnableJobs} 的原子性。 */
public List<TaskFileJobEntity> claimRunnableJobsForOwner(int limit, String owner) {
String normalizedOwner = owner == null ? "" : owner.trim();
if (normalizedOwner.isBlank()) {
return listRunnableJobs(limit);
return claimRunnableJobs(limit);
}
String ownerMarker = ":owner:" + normalizedOwner;
int safeLimit = Math.max(1, Math.min(limit, 100));
List<TaskFileJobEntity> ownerJobs = taskFileJobMapper.selectList(new LambdaQueryWrapper<TaskFileJobEntity>()
.in(TaskFileJobEntity::getStatus, List.of("PENDING", "FAILED"))
.lt(TaskFileJobEntity::getRetryCount, MAX_RETRY_COUNT)
.in(TaskFileJobEntity::getModuleType, List.of("APPEARANCE_PATENT", "SIMILAR_ASIN", "PUBLISH", "SHOP_DATA_CRAWL"))
.like(TaskFileJobEntity::getScopeKey, ownerMarker)
.eq(TaskFileJobEntity::getOwner, normalizedOwner)
.orderByAsc(TaskFileJobEntity::getUpdatedAt)
.last("limit " + safeLimit));
List<TaskFileJobEntity> candidates = new ArrayList<>(ownerJobs);
if (ownerJobs.size() < safeLimit) {
LambdaQueryWrapper<TaskFileJobEntity> genericWrapper = new LambdaQueryWrapper<TaskFileJobEntity>()
.in(TaskFileJobEntity::getStatus, List.of("PENDING", "FAILED"))
.lt(TaskFileJobEntity::getRetryCount, MAX_RETRY_COUNT)
.and(wrapper -> wrapper
.notIn(TaskFileJobEntity::getModuleType, List.of("APPEARANCE_PATENT", "SIMILAR_ASIN", "PUBLISH", "SHOP_DATA_CRAWL"))
.or()
.isNull(TaskFileJobEntity::getOwner))
.orderByAsc(TaskFileJobEntity::getUpdatedAt)
.last("limit " + (safeLimit - ownerJobs.size()));
candidates.addAll(taskFileJobMapper.selectList(genericWrapper));
}
return claimCandidates(candidates);
}
private List<TaskFileJobEntity> claimCandidates(List<TaskFileJobEntity> candidates) {
if (candidates == null || candidates.isEmpty()) {
return List.of();
}
List<TaskFileJobEntity> claimed = new ArrayList<>();
for (TaskFileJobEntity candidate : candidates) {
if (candidate == null || candidate.getId() == null) {
continue;
}
if (!markRunning(candidate.getId())) {
continue;
}
TaskFileJobEntity claim = taskFileJobMapper.selectById(candidate.getId());
if (claim != null && "RUNNING".equals(claim.getStatus())) {
claimed.add(claim);
}
}
return claimed;
}
public List<TaskFileJobEntity> listRunnableJobsForOwner(int limit, String owner) {
String normalizedOwner = owner == null ? "" : owner.trim();
if (normalizedOwner.isBlank()) {
return listRunnableJobs(limit);
}
int safeLimit = Math.max(1, Math.min(limit, 100));
List<TaskFileJobEntity> ownerJobs = taskFileJobMapper.selectList(new LambdaQueryWrapper<TaskFileJobEntity>()
.in(TaskFileJobEntity::getStatus, List.of("PENDING", "FAILED"))
.lt(TaskFileJobEntity::getRetryCount, MAX_RETRY_COUNT)
.in(TaskFileJobEntity::getModuleType, List.of("APPEARANCE_PATENT", "SIMILAR_ASIN", "PUBLISH", "SHOP_DATA_CRAWL"))
.eq(TaskFileJobEntity::getOwner, normalizedOwner)
.orderByAsc(TaskFileJobEntity::getUpdatedAt)
.last("limit " + safeLimit));
if (ownerJobs.size() >= safeLimit) {
@@ -112,9 +176,7 @@ public class TaskFileJobService {
.and(wrapper -> wrapper
.notIn(TaskFileJobEntity::getModuleType, List.of("APPEARANCE_PATENT", "SIMILAR_ASIN", "PUBLISH", "SHOP_DATA_CRAWL"))
.or()
.isNull(TaskFileJobEntity::getScopeKey)
.or()
.notLike(TaskFileJobEntity::getScopeKey, ":owner:"))
.isNull(TaskFileJobEntity::getOwner))
.orderByAsc(TaskFileJobEntity::getUpdatedAt)
.last("limit " + (safeLimit - jobs.size()));
jobs.addAll(taskFileJobMapper.selectList(genericWrapper));
@@ -520,6 +582,21 @@ public class TaskFileJobService {
.eq(TaskFileJobEntity::getResultId, resultId));
}
private static final String OWNER_MARKER = ":owner:";
/** 从 scope_keytask:1:owner:instance-a)提取 owner;无标记或空白返回 null。 */
static String ownerFromScopeKey(String scopeKey) {
if (scopeKey == null || scopeKey.isBlank()) {
return null;
}
int index = scopeKey.lastIndexOf(OWNER_MARKER);
if (index < 0) {
return null;
}
String owner = scopeKey.substring(index + OWNER_MARKER.length()).trim();
return owner.isBlank() ? null : owner;
}
private TaskFileJobEntity findJob(Long taskId, String moduleType, Long resultId, String jobType) {
if (taskId == null || moduleType == null || moduleType.isBlank() || resultId == null || jobType == null || jobType.isBlank()) {
return null;
@@ -7,19 +7,28 @@ import com.nanri.aiimage.common.exception.BusinessException;
import com.nanri.aiimage.modules.task.mapper.TaskProgressSnapshotMapper;
import com.nanri.aiimage.modules.task.model.entity.TaskProgressSnapshotEntity;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
@Service
@RequiredArgsConstructor
public class TaskProgressSnapshotService {
private static final Set<String> TERMINAL_STATUSES = Set.of("SUCCESS", "FAILED", "CANCELLED", "CANCELED");
private final TaskProgressSnapshotMapper taskProgressSnapshotMapper;
private final ObjectMapper objectMapper;
private final ConcurrentHashMap<String, Long> lastWriteAtMillis = new ConcurrentHashMap<>();
@Value("${aiimage.task-progress-snapshot.min-update-interval-ms:500}")
private long minUpdateIntervalMillis = 500;
@Transactional
public void save(Long taskId,
@@ -54,6 +63,7 @@ public class TaskProgressSnapshotService {
entity.setUpdatedAt(now);
try {
taskProgressSnapshotMapper.insert(entity);
touchLastWrite(taskId, moduleType, status);
return;
} catch (DuplicateKeyException ignored) {
existing = find(taskId, moduleType);
@@ -66,6 +76,9 @@ public class TaskProgressSnapshotService {
currentScopeKey, message, snapshotJson)) {
return;
}
if (shouldThrottle(taskId, moduleType, status)) {
return;
}
taskProgressSnapshotMapper.update(null, new LambdaUpdateWrapper<TaskProgressSnapshotEntity>()
.eq(TaskProgressSnapshotEntity::getId, existing.getId())
.set(TaskProgressSnapshotEntity::getStatus, status)
@@ -77,6 +90,31 @@ public class TaskProgressSnapshotService {
.set(TaskProgressSnapshotEntity::getMessage, message)
.set(TaskProgressSnapshotEntity::getSnapshotJson, snapshotJson)
.set(TaskProgressSnapshotEntity::getUpdatedAt, now));
touchLastWrite(taskId, moduleType, status);
}
private boolean shouldThrottle(Long taskId, String moduleType, String status) {
if (TERMINAL_STATUSES.contains(status)) {
return false;
}
long interval = Math.max(0L, minUpdateIntervalMillis);
if (interval <= 0L) {
return false;
}
Long lastWrite = lastWriteAtMillis.get(cacheKey(taskId, moduleType));
return lastWrite != null && System.currentTimeMillis() - lastWrite < interval;
}
private void touchLastWrite(Long taskId, String moduleType, String status) {
if (TERMINAL_STATUSES.contains(status)) {
lastWriteAtMillis.remove(cacheKey(taskId, moduleType));
return;
}
lastWriteAtMillis.put(cacheKey(taskId, moduleType), System.currentTimeMillis());
}
private static String cacheKey(Long taskId, String moduleType) {
return taskId + ":" + moduleType;
}
private boolean isUnchanged(TaskProgressSnapshotEntity existing,
@@ -116,6 +154,7 @@ public class TaskProgressSnapshotService {
taskProgressSnapshotMapper.delete(new LambdaQueryWrapper<TaskProgressSnapshotEntity>()
.eq(TaskProgressSnapshotEntity::getTaskId, taskId)
.eq(TaskProgressSnapshotEntity::getModuleType, moduleType));
lastWriteAtMillis.remove(cacheKey(taskId, moduleType));
}
private String writeJson(Object value) {
@@ -85,11 +85,13 @@ public class TaskResultFileJobWorker {
if (!localWorkerEnabled) {
return;
}
List<TaskFileJobEntity> jobs = taskFileJobService.listRunnableJobsForOwner(batchSize, currentInstanceId());
// 原子 claim:每个候选以条件 UPDATE 翻转为 RUNNING,只处理 claim 成功的行,
// 并发 worker/实例不会重复派发同一 job。
List<TaskFileJobEntity> jobs = taskFileJobService.claimRunnableJobsForOwner(batchSize, currentInstanceId());
if (jobs == null || jobs.isEmpty()) {
return;
}
log.info("[task-file-job] scheduled worker picked jobs count={}", jobs.size());
log.info("[task-file-job] scheduled worker claimed jobs count={}", jobs.size());
for (TaskFileJobEntity job : jobs) {
boolean dispatched = taskFileJobLocalDispatcher.dispatch(
job.getId(),
@@ -0,0 +1,213 @@
package com.nanri.aiimage.modules.task.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.nanri.aiimage.modules.file.service.object.RustfsObjectStorageService;
import com.nanri.aiimage.modules.task.mapper.TaskChunkMapper;
import com.nanri.aiimage.modules.task.mapper.TaskScopeStateMapper;
import com.nanri.aiimage.modules.task.model.entity.TaskChunkEntity;
import com.nanri.aiimage.modules.task.model.entity.TaskScopeStateEntity;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ExecutorService;
import java.util.function.Supplier;
/**
* P2-9 扩展(task-68):payload 引用删除改为批量引用检查与异步物理删除。
* <p>调用方把待删 payload(指针或 JSON 编码指针)批量提交,本组件去重入队;
* {@link #flushPendingDeletes()} 对整批 pending 一次性 IN 反查
* biz_task_chunk / biz_task_scope_state(比逐条两次查询少一个数量级的 DB 往返),
* 确认不再被引用的对象交由后台线程池异步物理删除,不阻塞业务线程。
* <p>幂等:重复提交相同对象只入队一次;保守:引用检查失败时本批保留,
* 调用方(如周期清理任务)可再次 flush 重试。
*/
@Service
@Slf4j
public class TransientPayloadDeleteOrchestrator {
private static final String RUSTFS_POINTER_PREFIX = "rustfs:";
private final TransientPayloadStorageService transientPayloadStorageService;
private final RustfsObjectStorageService rustfsObjectStorageService;
private final TaskChunkMapper taskChunkMapper;
private final TaskScopeStateMapper taskScopeStateMapper;
private final ObjectMapper objectMapper;
private final ExecutorService asyncDeleteExecutor;
private final Set<String> pendingPointers = new LinkedHashSet<>();
@Value("${aiimage.transient-storage.max-pending-deletes:1000}")
private long maxPendingDeletes = 1000;
public TransientPayloadDeleteOrchestrator(TransientPayloadStorageService transientPayloadStorageService,
RustfsObjectStorageService rustfsObjectStorageService,
TaskChunkMapper taskChunkMapper,
TaskScopeStateMapper taskScopeStateMapper,
ObjectMapper objectMapper,
ExecutorService asyncDeleteExecutor) {
this.transientPayloadStorageService = transientPayloadStorageService;
this.rustfsObjectStorageService = rustfsObjectStorageService;
this.taskChunkMapper = taskChunkMapper;
this.taskScopeStateMapper = taskScopeStateMapper;
this.objectMapper = objectMapper;
this.asyncDeleteExecutor = asyncDeleteExecutor;
}
/**
* 批量提交待删 payload 值(指针或 JSON 编码指针)。空值与非指针值忽略;
* 已在 pending 中的对象幂等跳过;pending 达到上限后拒绝新提交并返回实际入队数。
*/
public int submitDeletes(List<String> values) {
if (values == null || values.isEmpty()) {
return 0;
}
synchronized (pendingPointers) {
int accepted = 0;
for (String value : values) {
String pointer = transientPayloadStorageService.extractPointer(value);
if (pointer == null) {
continue;
}
if (pendingPointers.size() >= maxPendingDeletes) {
log.warn("[transient-payload] delete queue full, drop {} pointer={}", "submit", pointer);
break;
}
if (pendingPointers.add(pointer)) {
accepted++;
}
}
return accepted;
}
}
/**
* 对 pending 中的对象批量做引用检查,未引用的异步物理删除,返回本批删除数。
* 引用检查异常时保守跳过整批(不删),调用方可再次 flush 重试。
*/
public int flushPendingDeletes() {
List<String> batch;
synchronized (pendingPointers) {
if (pendingPointers.isEmpty()) {
return 0;
}
batch = new ArrayList<>(pendingPointers);
pendingPointers.clear();
}
try {
Set<String> stillReferenced = batchReferencedPointers(batch);
List<String> toDelete = new ArrayList<>(batch);
toDelete.removeAll(stillReferenced);
if (!toDelete.isEmpty()) {
asyncDeleteExecutor.submit(() -> deleteObjects(toDelete));
}
if (!stillReferenced.isEmpty()) {
log.info("[transient-payload] skip delete, still referenced count={}", stillReferenced.size());
}
return toDelete.size();
} catch (Exception ex) {
log.warn("[transient-payload] batch reference check failed, keep pending count={} err={}",
batch.size(), ex.getMessage());
synchronized (pendingPointers) {
pendingPointers.addAll(batch);
}
return 0;
}
}
public int pendingCount() {
synchronized (pendingPointers) {
return pendingPointers.size();
}
}
/**
* 从任意存储值(指针或 JSON 编码指针)中提取 rustfs 指针,非指针返回 null。
* 供调用方在提交前过滤待删值(如清理任务收集 payload 字段)。
*/
public String extractPointer(String value) {
return transientPayloadStorageService.extractPointer(value);
}
private void deleteObjects(List<String> pointers) {
for (String pointer : pointers) {
if (pointer == null || !pointer.startsWith(RUSTFS_POINTER_PREFIX)) {
// 本地/OSS 指针有实例归属与其它清理路径,不在此批量删除范围。
continue;
}
String objectKey = pointer.substring(RUSTFS_POINTER_PREFIX.length());
try {
rustfsObjectStorageService.deleteObject(objectKey);
} catch (Exception ex) {
log.warn("[transient-payload] async delete failed objectKey={} err={}",
objectKey, ex.getMessage());
}
}
}
/** 批量 IN 反查两张引用表,返回仍被引用的指针集合(查询异常抛给调用方)。 */
private Set<String> batchReferencedPointers(List<String> pointers) {
List<String> jsonEncoded = pointers.stream()
.map(this::jsonEncodePointer)
.filter(java.util.Objects::nonNull)
.toList();
List<String> candidates = new ArrayList<>(pointers);
candidates.addAll(jsonEncoded);
Set<String> referenced = new LinkedHashSet<>();
List<TaskChunkEntity> chunks = taskChunkMapper.selectList(new LambdaQueryWrapper<TaskChunkEntity>()
.in(TaskChunkEntity::getPayloadJson, candidates));
for (TaskChunkEntity chunk : chunks) {
referenced.addAll(matchingPointers(chunk.getPayloadJson(), pointers));
}
List<TaskScopeStateEntity> scopeStates = taskScopeStateMapper.selectList(new LambdaQueryWrapper<TaskScopeStateEntity>()
.and(w -> w.in(TaskScopeStateEntity::getParsedPayloadJson, candidates)
.or()
.in(TaskScopeStateEntity::getStateJson, candidates)));
for (TaskScopeStateEntity scopeState : scopeStates) {
referenced.addAll(matchingPointers(scopeState.getParsedPayloadJson(), pointers));
referenced.addAll(matchingPointers(scopeState.getStateJson(), pointers));
}
return referenced;
}
private Set<String> matchingPointers(String dbValue, List<String> pointers) {
Set<String> matches = new LinkedHashSet<>();
if (dbValue == null || dbValue.isBlank()) {
return matches;
}
String candidate = dbValue.trim();
for (int i = 0; i < 4; i++) {
String pointer = transientPayloadStorageService.extractPointer(candidate);
if (pointer != null && pointers.contains(pointer)) {
matches.add(pointer);
}
String decoded;
try {
decoded = objectMapper.readValue(candidate, String.class);
} catch (Exception ex) {
break;
}
if (decoded == null || decoded.equals(candidate)) {
break;
}
candidate = decoded.trim();
}
return matches;
}
private String jsonEncodePointer(String pointer) {
try {
return objectMapper.writeValueAsString(pointer);
} catch (Exception ex) {
return null;
}
}
}
@@ -145,13 +145,16 @@ public class TransientPayloadStorageService {
"transient payload only exists on instance=" + ownerInstance
+ " current=" + instanceMetadata.getInstanceId());
}
return decodeStoredPayload(readLocalPayload(stripLocalInstanceId(localKey)));
return decodeStoredPayloadBytes(readLocalPayloadBytes(stripLocalInstanceId(localKey)));
}
if (pointer.startsWith(RUSTFS_POINTER_PREFIX)) {
return decodeStoredPayload(rustfsObjectStorageService.readObjectAsString(pointer.substring(RUSTFS_POINTER_PREFIX.length())));
return decodeStoredPayloadBytes(
rustfsObjectStorageService.readObjectBytes(pointer.substring(RUSTFS_POINTER_PREFIX.length())));
}
if (pointer.startsWith(OSS_POINTER_PREFIX)) {
return decodeStoredPayload(ossStorageService.readObjectAsString(pointer.substring(OSS_POINTER_PREFIX.length())));
return decodeStoredPayloadBytes(
ossStorageService.readObjectAsString(pointer.substring(OSS_POINTER_PREFIX.length()))
.getBytes(StandardCharsets.UTF_8));
}
} catch (Exception ex) {
throw new IllegalStateException(errorMessage + ": " + pointer, ex);
@@ -318,8 +321,8 @@ public class TransientPayloadStorageService {
category, moduleType, taskId, objectKey, rawBytes, properties.getWarnPayloadBytes());
}
boolean rawOversize = isPositiveLimit(properties.getMaxPayloadBytes()) && rawBytes > properties.getMaxPayloadBytes();
String storedContent = encodeStoredPayload(content);
long storedBytes = payloadBytes(storedContent);
byte[] storedContent = encodeStoredPayload(content);
long storedBytes = storedContent.length;
boolean storedOversize = isPositiveLimit(properties.getMaxStoredPayloadBytes()) && storedBytes > properties.getMaxStoredPayloadBytes();
if (rawOversize || storedOversize) {
log.warn("[transient-payload] payload size exceeds rustfs limit category={} moduleType={} taskId={} objectKey={} rawBytes={} storedBytes={} maxRawBytes={} maxStoredBytes={} fallbackToLocal={}",
@@ -333,7 +336,7 @@ public class TransientPayloadStorageService {
boolean rustfsFallbackToLocal = rawOversize || storedOversize;
if (!rustfsFallbackToLocal && rustfsObjectStorageService.isConfigured()) {
try {
pointer = RUSTFS_POINTER_PREFIX + rustfsObjectStorageService.uploadText(objectKey, storedContent, verifyAfterUpload);
pointer = RUSTFS_POINTER_PREFIX + rustfsObjectStorageService.uploadBytes(objectKey, storedContent, verifyAfterUpload);
} catch (Exception ex) {
rustfsFallbackToLocal = true;
// 升级为 ERROR:rustfs 失败后只能落到本地,多实例下其他节点读不到,必须能告警。
@@ -362,7 +365,7 @@ public class TransientPayloadStorageService {
}
}
private String storeLocal(String objectKey, String content) {
private String storeLocal(String objectKey, byte[] content) {
try {
Path root = localPayloadRoot();
Path target = root.resolve(objectKey).normalize();
@@ -370,9 +373,8 @@ public class TransientPayloadStorageService {
throw new IllegalArgumentException("invalid local payload key: " + objectKey);
}
Files.createDirectories(target.getParent());
Files.writeString(target,
content == null ? "" : content,
StandardCharsets.UTF_8,
Files.write(target,
content == null ? new byte[0] : content,
StandardOpenOption.CREATE,
StandardOpenOption.TRUNCATE_EXISTING,
StandardOpenOption.WRITE);
@@ -385,10 +387,10 @@ public class TransientPayloadStorageService {
}
}
private String readLocalPayload(String objectKey) {
private byte[] readLocalPayloadBytes(String objectKey) {
try {
Path target = resolveLocalPayloadPath(objectKey);
return Files.readString(target, StandardCharsets.UTF_8);
return Files.readAllBytes(target);
} catch (Exception ex) {
throw new IllegalStateException("failed to read local transient payload", ex);
}
@@ -435,14 +437,18 @@ public class TransientPayloadStorageService {
return normalized.replaceAll("[^A-Za-z0-9_.\\-]", "_");
}
private String encodeStoredPayload(String content) {
/**
* 压缩 transient payload 为 gzip 二进制流(不再经过 base64 文本编码)。
* 极小内容 gzip 可能膨胀,属预期:压缩收益以可压缩内容为准,读取端按 magic 自动识别。
*/
private byte[] encodeStoredPayload(String content) {
try {
byte[] raw = Objects.requireNonNullElse(content, "").getBytes(StandardCharsets.UTF_8);
ByteArrayOutputStream baos = new ByteArrayOutputStream();
ByteArrayOutputStream baos = new ByteArrayOutputStream(Math.max(32, raw.length / 2));
try (GZIPOutputStream gzip = new GZIPOutputStream(baos)) {
gzip.write(raw);
}
return "gzip64:" + Base64.getEncoder().encodeToString(baos.toByteArray());
return baos.toByteArray();
} catch (Exception ex) {
throw new IllegalStateException("failed to encode transient payload", ex);
}
@@ -456,23 +462,65 @@ public class TransientPayloadStorageService {
return value > 0L;
}
private String decodeStoredPayload(String storedContent) {
if (storedContent == null || storedContent.isBlank()) {
return storedContent;
private String decodeStoredPayloadBytes(byte[] storedContent) {
if (storedContent == null || storedContent.length == 0) {
return "";
}
if (!storedContent.startsWith("gzip64:")) {
return storedContent;
if (isGzip(storedContent)) {
return decodeGzipStream(storedContent);
}
try {
byte[] compressed = Base64.getDecoder().decode(storedContent.substring("gzip64:".length()));
try (GZIPInputStream gzip = new GZIPInputStream(new ByteArrayInputStream(compressed))) {
return new String(gzip.readAllBytes(), StandardCharsets.UTF_8);
String legacyText = new String(storedContent, StandardCharsets.UTF_8);
if (legacyText.startsWith("gzip64:")) {
try {
byte[] compressed = Base64.getDecoder().decode(legacyText.substring("gzip64:".length()));
return decodeGzipStream(compressed);
} catch (IllegalStateException ex) {
throw ex;
} catch (Exception ex) {
throw new IllegalStateException("failed to decode transient payload", ex);
}
}
// 历史裸文本(老版本未压缩直接写入的对象)
return legacyText;
}
/**
* 流式解压 gzip 字节流,解压输出累计超过 maxDecompressedPayloadBytes 立即中止,
* 防止压缩炸弹在内存中无界膨胀。上限未配置(&lt;=0)时不限流。
*/
private String decodeGzipStream(byte[] compressed) {
long maxBytes = properties.getMaxDecompressedPayloadBytes();
try {
ByteArrayOutputStream out = new ByteArrayOutputStream();
try (GZIPInputStream gzip = new GZIPInputStream(new ByteArrayInputStream(compressed))) {
byte[] buffer = new byte[8192];
int read;
while ((read = gzip.read(buffer)) >= 0) {
if (read == 0) {
continue;
}
if (isPositiveLimit(maxBytes) && (long) out.size() + read > maxBytes) {
throw new IllegalStateException(
"transient payload decompressed size exceeds configured limit "
+ maxBytes + " bytes");
}
out.write(buffer, 0, read);
}
}
return out.toString(StandardCharsets.UTF_8);
} catch (IllegalStateException ex) {
throw ex;
} catch (Exception ex) {
throw new IllegalStateException("failed to decode transient payload", ex);
}
}
private boolean isGzip(byte[] content) {
return content.length >= 2
&& (content[0] & 0xFF) == 0x1F
&& (content[1] & 0xFF) == 0x8B;
}
private void deleteLocalPayload(String objectKey) {
try {
Files.deleteIfExists(resolveLocalPayloadPath(objectKey));
@@ -0,0 +1,101 @@
package com.nanri.aiimage.modules.task.util;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.nanri.aiimage.config.TaskPressureProperties;
import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.util.Comparator;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* 共享任务链路本地任务实体缓存:容量上限 LRU 淘汰、TTL 过期即时回收、
* 定时清理批量释放过期条目,保证本地缓存内存有界。
*/
@Component
@Slf4j
public class TaskEntityLocalCache {
private final TaskPressureProperties properties;
private final ObjectMapper objectMapper;
private final ConcurrentHashMap<Long, Entry> entries = new ConcurrentHashMap<>();
public TaskEntityLocalCache(TaskPressureProperties properties, ObjectMapper objectMapper) {
this.properties = properties;
this.objectMapper = objectMapper;
}
public void put(Long taskId, FileTaskEntity task) {
if (taskId == null || taskId <= 0 || task == null || task.getId() == null) {
return;
}
entries.put(taskId, new Entry(System.currentTimeMillis(),
objectMapper.convertValue(task, FileTaskEntity.class)));
int capacity = Math.max(1, properties.getLocalTaskEntityCacheCapacity());
if (entries.size() > capacity) {
entries.entrySet().stream()
.sorted(Map.Entry.comparingByValue(
Comparator.comparingLong(Entry::cachedAtMillis)
.thenComparingLong(e -> e.task().getId() == null ? 0L : e.task().getId())))
.limit(entries.size() - capacity)
.forEach(entryToEvict -> entries.remove(entryToEvict.getKey()));
}
}
/** 返回 TTL 内新鲜实体副本;过期条目读取时即时回收返回 null。 */
public Entry get(Long taskId) {
return get(taskId, System.currentTimeMillis());
}
public Entry get(Long taskId, long now) {
if (taskId == null || taskId <= 0) {
return null;
}
Entry cached = entries.get(taskId);
if (cached == null) {
return null;
}
if (now - cached.cachedAtMillis() > Math.max(0L, properties.getLocalTaskEntityCacheMillis())) {
entries.remove(taskId);
return null;
}
return new Entry(cached.cachedAtMillis(),
objectMapper.convertValue(cached.task(), FileTaskEntity.class));
}
public void evict(Long taskId) {
if (taskId == null || taskId <= 0) {
return;
}
entries.remove(taskId);
}
/** 定时清理:批量移除全部过期条目,返回移除数量。 */
public int removeExpired(long now) {
int[] removed = {0};
entries.forEach((taskId, cached) -> {
if (now - cached.cachedAtMillis() > Math.max(0L, properties.getLocalTaskEntityCacheMillis())) {
entries.remove(taskId);
removed[0]++;
}
});
return removed[0];
}
@Scheduled(fixedDelayString = "${aiimage.task-pressure.local-task-entity-cache-cleanup-delay-ms:60000}")
public void cleanup() {
int removed = removeExpired(System.currentTimeMillis());
if (removed > 0) {
log.debug("[task-entity-cache] cleaned expired entries count={}", removed);
}
}
public int size() {
return entries.size();
}
public record Entry(long cachedAtMillis, FileTaskEntity task) {}
}
@@ -1,9 +1,9 @@
package com.nanri.aiimage.modules.withdraw.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.nanri.aiimage.config.TaskPressureProperties;
import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity;
import com.nanri.aiimage.modules.task.service.TaskScopePayloadStorageService;
import com.nanri.aiimage.modules.task.util.TaskEntityLocalCache;
import com.nanri.aiimage.modules.withdraw.model.dto.WithdrawShopPayloadDto;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -16,7 +16,6 @@ import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@Service
@RequiredArgsConstructor
@@ -28,9 +27,8 @@ public class WithdrawTaskCacheService {
private final StringRedisTemplate stringRedisTemplate;
private final ObjectMapper objectMapper;
private final TaskPressureProperties taskPressureProperties;
private final TaskScopePayloadStorageService taskScopePayloadStorageService;
private final ConcurrentHashMap<Long, LocalTaskEntityCacheEntry> taskEntityLocalCache = new ConcurrentHashMap<>();
private final TaskEntityLocalCache taskEntityLocalCache;
public WithdrawShopPayloadDto getShopMergedPayload(Long taskId, String shopKey) {
return taskScopePayloadStorageService.getScopePayload(taskId, MODULE_TYPE, shopKey, WithdrawShopPayloadDto.class);
@@ -102,7 +100,7 @@ public class WithdrawTaskCacheService {
if (taskId == null || taskId <= 0) {
return;
}
taskEntityLocalCache.remove(taskId);
taskEntityLocalCache.evict(taskId);
try {
stringRedisTemplate.delete(buildTaskHeartbeatKey(taskId));
stringRedisTemplate.delete(buildTaskEntityKey(taskId));
@@ -116,8 +114,7 @@ public class WithdrawTaskCacheService {
if (task == null || task.getId() == null) {
return;
}
long now = System.currentTimeMillis();
taskEntityLocalCache.put(task.getId(), new LocalTaskEntityCacheEntry(now, objectMapper.convertValue(task, FileTaskEntity.class)));
taskEntityLocalCache.put(task.getId(), task);
try {
stringRedisTemplate.opsForValue().set(
buildTaskEntityKey(task.getId()),
@@ -136,9 +133,9 @@ public class WithdrawTaskCacheService {
long now = System.currentTimeMillis();
List<Long> missingIds = new ArrayList<>();
for (Long taskId : normalized) {
LocalTaskEntityCacheEntry cached = taskEntityLocalCache.get(taskId);
if (cached != null && now - cached.cachedAtMillis() <= Math.max(0L, taskPressureProperties.getLocalTaskEntityCacheMillis())) {
result.put(taskId, objectMapper.convertValue(cached.task(), FileTaskEntity.class));
TaskEntityLocalCache.Entry cached = taskEntityLocalCache.get(taskId, now);
if (cached != null) {
result.put(taskId, cached.task());
} else {
missingIds.add(taskId);
}
@@ -163,7 +160,7 @@ public class WithdrawTaskCacheService {
try {
FileTaskEntity task = objectMapper.readValue(raw, FileTaskEntity.class);
result.put(taskId, task);
taskEntityLocalCache.put(taskId, new LocalTaskEntityCacheEntry(now, task));
taskEntityLocalCache.put(taskId, task);
} catch (Exception ignored) {
}
}
@@ -177,6 +174,4 @@ public class WithdrawTaskCacheService {
private String buildTaskEntityKey(Long taskId) {
return "withdraw:task:entity:" + taskId;
}
private record LocalTaskEntityCacheEntry(long cachedAtMillis, FileTaskEntity task) {}
}
@@ -6,24 +6,40 @@ import com.nanri.aiimage.common.exception.BusinessException;
import com.nanri.aiimage.config.ZiniaoProperties;
import com.nanri.aiimage.modules.ziniao.model.cache.ZiniaoShopCacheDto;
import com.nanri.aiimage.modules.ziniao.model.vo.ZiniaoStaffItemVo;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.MediaType;
import org.springframework.http.client.SimpleClientHttpRequestFactory;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
import com.nanri.aiimage.config.HttpClientPool;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Objects;
@Component
@RequiredArgsConstructor
public class ZiniaoClientImpl implements ZiniaoClient {
private final ZiniaoProperties ziniaoProperties;
private final ObjectMapper objectMapper;
private final com.nanri.aiimage.metrics.ExternalCallMetricsRecorder externalCallMetrics;
public ZiniaoClientImpl(ZiniaoProperties ziniaoProperties, ObjectMapper objectMapper) {
this(ziniaoProperties, objectMapper, null);
}
public ZiniaoClientImpl(ZiniaoProperties ziniaoProperties,
ObjectMapper objectMapper,
com.nanri.aiimage.metrics.ExternalCallMetricsRecorder externalCallMetrics) {
this.ziniaoProperties = ziniaoProperties;
this.objectMapper = objectMapper;
this.externalCallMetrics = externalCallMetrics;
}
/** Task 77:单例 RestClient(共享连接池),避免每次调用新建短命客户端。 */
private volatile RestClient sharedRestClient;
@Override
public Long getCompanyIdByApiKey(String apiKey) {
@@ -246,10 +262,26 @@ public class ZiniaoClientImpl implements ZiniaoClient {
}
private RestClient getRestClient() {
SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory();
requestFactory.setConnectTimeout(ziniaoProperties.getConnectTimeoutSeconds() * 1000);
requestFactory.setReadTimeout(ziniaoProperties.getReadTimeoutSeconds() * 1000);
return RestClient.builder().requestFactory(requestFactory).build();
RestClient client = sharedRestClient;
if (client != null) {
return client;
}
synchronized (this) {
if (sharedRestClient == null) {
RestClient.Builder builder = RestClient.builder()
.requestFactory(HttpClientPool.requestFactory(ziniaoProperties.getReadTimeoutSeconds() * 1000));
if (externalCallMetrics != null) {
builder.requestInterceptor(externalCallMetrics.interceptor("ziniao"));
}
sharedRestClient = builder.build();
}
return sharedRestClient;
}
}
/** 反射/测试可见:与 getRestClient 同一单例。 */
RestClient restClient() {
return getRestClient();
}
private void addStaffItem(List<ZiniaoStaffItemVo> items, JsonNode itemNode) {
@@ -255,6 +255,16 @@ aiimage:
coze-include-legacy-api-key: ${AIIMAGE_SIMILAR_ASIN_COZE_INCLUDE_LEGACY_API_KEY:true}
coze-use-legacy-item-field-order: ${AIIMAGE_SIMILAR_ASIN_COZE_USE_LEGACY_ITEM_ORDER:false}
coze-result-buffer-enabled: ${AIIMAGE_SIMILAR_ASIN_COZE_RESULT_BUFFER_ENABLED:true}
collect-data:
stale-timeout-minutes: ${AIIMAGE_COLLECT_DATA_STALE_TIMEOUT_MINUTES:30}
stale-check-cron: ${AIIMAGE_COLLECT_DATA_STALE_CHECK_CRON:*/30 * * * * *}
max-source-file-bytes: ${AIIMAGE_COLLECT_DATA_MAX_SOURCE_FILE_BYTES:0}
max-parse-rows: ${AIIMAGE_COLLECT_DATA_MAX_PARSE_ROWS:0}
max-chunk-rows: ${AIIMAGE_COLLECT_DATA_MAX_CHUNK_ROWS:0}
brand-check-batch-size: ${AIIMAGE_COLLECT_DATA_BRAND_CHECK_BATCH_SIZE:10}
brand-check-cache-capacity: ${AIIMAGE_COLLECT_DATA_BRAND_CHECK_CACHE_CAPACITY:512}
invalid-asin-batch-size: ${AIIMAGE_COLLECT_DATA_INVALID_ASIN_BATCH_SIZE:100}
result-item-batch-size: ${AIIMAGE_COLLECT_DATA_RESULT_ITEM_BATCH_SIZE:100}
image-video:
coze-base-url: ${AIIMAGE_IMAGE_VIDEO_COZE_BASE_URL:https://api.coze.cn}
coze-token: ${AIIMAGE_IMAGE_VIDEO_COZE_TOKEN:sat_Ws4VB1caOPasDivpKIvtOySYx3lhKgQ95H3crIh0tBwiNYtPTyi6bqe0pBaRzpVu}
@@ -0,0 +1,53 @@
-- invalid ASIN 记录批量 INSERT IGNORE:唯一键从 data_value 扩展为
-- (data_value, brand),与记录语义键(ASIN + 品牌)一致,保证批量幂等。
-- 历史冲突行仅保留最新一条(id 最大),再按冲突行重建唯一键。
CREATE TABLE IF NOT EXISTS biz_invalid_asin_data (
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键',
data_value VARCHAR(128) NOT NULL COMMENT '不符合 ASIN 值',
brand TEXT NULL COMMENT 'brand name',
group_id BIGINT NULL COMMENT '分组ID',
record_source VARCHAR(32) NOT NULL DEFAULT 'AUTO' COMMENT '记录来源',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
KEY idx_brand_prefix (brand(191)),
KEY idx_created_at (created_at)
) COMMENT='不符合ASIN数据表';
-- 1. 清理 (data_value, brand) 完全重复的行,仅保留 id 最大的一条
DELETE t1 FROM biz_invalid_asin_data t1
JOIN biz_invalid_asin_data t2
ON t1.data_value = t2.data_value
AND ((t1.brand <=> t2.brand) OR (t1.brand IS NULL AND t2.brand IS NULL))
AND t1.id < t2.id;
-- 2. 删除既有单列唯一键 uk_data_value(存在时)
SET @uk_exists := (
SELECT COUNT(*) FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'biz_invalid_asin_data'
AND INDEX_NAME = 'uk_data_value'
);
SET @sql_drop_uk := IF(
@uk_exists > 0,
'ALTER TABLE biz_invalid_asin_data DROP INDEX uk_data_value',
'SELECT 1'
);
PREPARE stmt_drop_uk FROM @sql_drop_uk;
EXECUTE stmt_drop_uk;
DEALLOCATE PREPARE stmt_drop_uk;
-- 3. 重建为 (data_value, brand) 复合唯一键(不存在时)
SET @uk_dup_exists := (
SELECT COUNT(*) FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'biz_invalid_asin_data'
AND INDEX_NAME = 'uk_data_value_brand'
);
SET @sql_add_uk := IF(
@uk_dup_exists = 0,
'ALTER TABLE biz_invalid_asin_data ADD UNIQUE KEY uk_data_value_brand (data_value, brand(191))',
'SELECT 1'
);
PREPARE stmt_add_uk FROM @sql_add_uk;
EXECUTE stmt_add_uk;
DEALLOCATE PREPARE stmt_add_uk;
@@ -0,0 +1,70 @@
-- V95: 店铺数据抓取累计文件改为店铺级共享(去 user 维度)
-- 问题:同店同日在不同账号(尾号5578江秀珍1~5)下产生多份互不相干的累计文件,
-- 管理页每店只显示最新一份,其他账号抓的国家(如英国)看起来"没了"。
-- 目标:每店每天只保留一份累计文件,(shop_key, business_date) 唯一;
-- 聚合层按国家覆盖(见 ShopDataCrawlTaskService),未更新的国家保留。
--
-- 存量合并策略:
-- 1) 回填 shop_key(店铺名,来自 daily_member.result_id -> biz_file_result.source_filename
-- 2) 同店同日多份 daily_file 合并为一个分组(保留各 result 的成员行),
-- 由启动补偿组件按成员 result 快照重建一份累计文件
-- 3) 唯一键改为 (shop_key, business_date)
-- 1. 新增列(country_codes_json 供后台管理页展示累计文件实际包含的国家)
ALTER TABLE biz_shop_data_crawl_daily_file
ADD COLUMN `shop_key` VARCHAR(1000) NULL DEFAULT NULL COMMENT '店铺名(去 user 后的店铺级归属键)' AFTER `shop_key_hash`,
ADD COLUMN `country_codes_json` VARCHAR(512) NULL DEFAULT NULL COMMENT '累计文件实际包含的国家代码 JSON(后台管理页展示用)' AFTER `result_content_type`,
ADD COLUMN `compensation_done` TINYINT NOT NULL DEFAULT 0 COMMENT '0=启动补偿组件待重建,1=已按成员快照重建' AFTER `version`;
-- 2. 回填 shop_keydaily_member 记录了每个结果行属于哪个 daily_file
UPDATE biz_shop_data_crawl_daily_file df
JOIN biz_shop_data_crawl_daily_member m ON m.daily_file_id = df.id
JOIN biz_file_result r ON r.id = m.result_id
SET df.shop_key = TRIM(r.source_filename)
WHERE df.shop_key IS NULL OR df.shop_key = '';
-- 3. 兜底:无成员的 daily_file 用 latest_result_id 回填
UPDATE biz_shop_data_crawl_daily_file df
LEFT JOIN biz_shop_data_crawl_daily_member m ON m.daily_file_id = df.id
JOIN biz_file_result r ON r.id = df.latest_result_id
SET df.shop_key = TRIM(r.source_filename)
WHERE m.id IS NULL AND (df.shop_key IS NULL OR df.shop_key = '');
-- 4. 清理仍为空的(无任何可反解依据的历史残留)
DELETE df FROM biz_shop_data_crawl_daily_file df
WHERE df.shop_key IS NULL OR df.shop_key = '';
-- 5. 交换唯一键:先删旧 (user_id, shop_key_hash, business_date),再加 (shop_key, business_date)
ALTER TABLE biz_shop_data_crawl_daily_file
DROP KEY uk_shop_data_crawl_daily_file;
-- 6. 同店同日合并成员行归属:保留最新一份 daily_file,其余 daily_file 的成员行迁到它名下
UPDATE biz_shop_data_crawl_daily_member m
JOIN biz_shop_data_crawl_daily_file cur ON cur.id = m.daily_file_id
JOIN (
SELECT shop_key, business_date, MAX(id) AS max_id
FROM biz_shop_data_crawl_daily_file
GROUP BY shop_key, business_date
HAVING COUNT(*) > 1
) g ON g.shop_key = cur.shop_key AND g.business_date = cur.business_date AND g.max_id != cur.id
SET m.daily_file_id = g.max_id;
-- 7. 删除同店同日合并后的多余 daily_file 行(对象存储文件由补偿组件按引用计数清理)
DELETE df FROM biz_shop_data_crawl_daily_file df
WHERE df.id NOT IN (
SELECT keep_id FROM (
SELECT MAX(id) AS keep_id
FROM biz_shop_data_crawl_daily_file
GROUP BY shop_key, business_date
) t
);
-- 8. 唯一键(shop_key 已非空;兜底统一改为未命名)
UPDATE biz_shop_data_crawl_daily_file SET shop_key = '未命名' WHERE TRIM(shop_key) = '';
-- 9. country_codes_json 由启动补偿组件加载成员 result 快照后回填
-- biz_task_result_item.payload_json 是 rustfs 指针,SQL 无法解析内容)
-- 10. 新唯一键
ALTER TABLE biz_shop_data_crawl_daily_file
ADD UNIQUE KEY uk_shop_data_crawl_daily_file (shop_key, business_date);
@@ -0,0 +1,41 @@
-- Task 76biz_task_file_job 增加显式 owner 列并补充 (module_type, owner, status, retry_count) 复合索引。
-- owner 从 scope_key 的 ":owner:" 标记提取(task:1:owner:instance-a → instance-a),
-- 存量行回填,owner 等值查询不再依赖 LIKE '%:owner:%' 扫描。
SET @db_name = DATABASE();
SET @col_exists := (
SELECT COUNT(*)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = @db_name
AND TABLE_NAME = 'biz_task_file_job'
AND COLUMN_NAME = 'owner'
);
SET @sql := IF(@col_exists = 0,
'ALTER TABLE biz_task_file_job ADD COLUMN owner VARCHAR(128) NULL COMMENT ''explicit owner extracted from scope_key'' AFTER scope_key',
'SELECT 1'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
UPDATE biz_task_file_job
SET owner = SUBSTRING(scope_key, LOCATE(':owner:', scope_key) + 7)
WHERE owner IS NULL
AND scope_key IS NOT NULL
AND LOCATE(':owner:', scope_key) > 0;
SET @idx_exists := (
SELECT COUNT(*)
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = @db_name
AND TABLE_NAME = 'biz_task_file_job'
AND INDEX_NAME = 'idx_file_job_module_owner_status_retry'
);
SET @sql := IF(@idx_exists = 0,
'ALTER TABLE biz_task_file_job ADD INDEX idx_file_job_module_owner_status_retry (module_type, owner, status, retry_count)',
'SELECT 1'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
@@ -0,0 +1,218 @@
package com.nanri.aiimage.config;
import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.Timer;
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.core.task.TaskExecutor;
import org.springframework.core.task.TaskRejectedException;
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.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
/**
* Task 75:为虚拟线程任务增加等待队列上限与拒绝/延迟指标。
* CozeTaskQueueGate 在信号量限流(并发上限)之外增加排队闸门:
* 已提交未启动(含等待信号量)的任务数量达到 max-waiting 上限时
* 立即拒绝新提交并记录拒绝指标;每次提交/执行记录等待耗时与执行耗时
* 指标;任务执行完成、失败或执行器拒绝后排队名额必须释放。
*/
class CozeTaskQueueGateTest {
private SimpleMeterRegistry registry;
private ObjectProvider<MeterRegistry> meterRegistryProvider;
private List<Runnable> captured;
private TaskExecutor capturingDelegate;
@BeforeEach
@SuppressWarnings("unchecked")
void setUp() {
registry = new SimpleMeterRegistry();
meterRegistryProvider = mock(ObjectProvider.class);
when(meterRegistryProvider.getIfAvailable()).thenReturn(registry);
captured = new ArrayList<>();
capturingDelegate = captured::add;
}
private CozeTaskQueueGate gate(int maxWaiting) {
return new CozeTaskQueueGate(capturingDelegate, maxWaiting, meterRegistryProvider);
}
private long rejectedCount(String reason) {
Counter counter = registry.find("aiimage.coze-task.submit.rejected.total")
.tag("reason", reason).counter();
return counter == null ? 0 : (long) counter.count();
}
private long executionCount() {
Timer timer = registry.find("aiimage.coze-task.execution.duration").timer();
return timer == null ? 0 : timer.count();
}
private long waitCount() {
Timer timer = registry.find("aiimage.coze-task.queue.wait.duration").timer();
return timer == null ? 0 : timer.count();
}
@Test
void test_task_075_metrics_normal_default_path() {
// 默认路径:任务正常受理并执行,等待/执行指标各记一次,排队名额释放。
CozeTaskQueueGate gate = gate(4);
AtomicInteger ran = new AtomicInteger();
gate.execute(ran::incrementAndGet);
assertEquals(1, captured.size(), "任务入队");
assertEquals(1, gate.waiting(), "未启动任务计数为 1");
captured.get(0).run();
assertEquals(1, ran.get(), "任务已执行");
assertEquals(0, gate.waiting(), "执行后名额释放");
assertEquals(1, executionCount(), "执行耗时指标记录一次");
assertEquals(1, waitCount(), "等待耗时指标记录一次");
assertEquals(0, rejectedCount("queue-full"));
}
@Test
void test_task_075_metrics_normal_multiple_items() {
// 批量场景:多个任务依次受理,执行顺序稳定不丢失,指标逐条记录。
CozeTaskQueueGate gate = gate(8);
List<Integer> order = new ArrayList<>();
for (int i = 1; i <= 3; i++) {
int id = i;
gate.execute(() -> order.add(id));
}
assertEquals(3, captured.size());
for (Runnable runnable : captured) {
runnable.run();
}
assertEquals(List.of(1, 2, 3), order, "执行顺序与提交顺序一致");
assertEquals(0, gate.waiting());
assertEquals(3, executionCount());
assertEquals(3, waitCount());
}
@Test
void test_task_075_metrics_normal_repeated_operation_is_idempotent() {
// 幂等:同一任务对象重复提交各自独立执行,不合并、不丢失。
CozeTaskQueueGate gate = gate(4);
AtomicInteger ran = new AtomicInteger();
Runnable task = ran::incrementAndGet;
gate.execute(task);
gate.execute(task);
assertEquals(2, captured.size(), "同一任务重复提交各入队一次");
captured.forEach(Runnable::run);
assertEquals(2, ran.get());
assertEquals(2, executionCount());
}
@Test
void test_task_075_metrics_boundary_empty_input() {
// 空输入:null 任务直接拒绝,不计数、不产生指标。
CozeTaskQueueGate gate = gate(4);
IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
() -> gate.execute(null));
assertTrue(ex.getMessage().contains("不能为 null"), "可识别错误消息");
assertTrue(captured.isEmpty());
assertEquals(0, gate.waiting());
assertEquals(0, rejectedCount("queue-full"));
assertEquals(0, executionCount());
}
@Test
void test_task_075_metrics_boundary_single_item() {
// 单元素:单任务直接受理执行,不依赖批量路径。
CozeTaskQueueGate gate = gate(1);
AtomicInteger ran = new AtomicInteger();
gate.execute(ran::incrementAndGet);
captured.get(0).run();
assertEquals(1, ran.get());
assertEquals(0, gate.waiting());
assertEquals(1, executionCount());
}
@Test
void test_task_075_metrics_boundary_limit_and_overflow() {
// 上限/超限:等待队列达到上限后新提交被拒绝(记录拒绝指标),
// 排队名额释放后可再次受理,等待数不发生无界增长。
CozeTaskQueueGate gate = gate(1);
AtomicInteger ran = new AtomicInteger();
gate.execute(ran::incrementAndGet);
assertEquals(1, gate.waiting());
TaskRejectedException firstReject = assertThrows(TaskRejectedException.class,
() -> gate.execute(ran::incrementAndGet), "队列满拒绝新提交");
assertTrue(firstReject.getMessage().contains("limit=1"), "错误消息含上限值");
TaskRejectedException secondReject = assertThrows(TaskRejectedException.class,
() -> gate.execute(ran::incrementAndGet));
assertEquals(2, rejectedCount("queue-full"), "两次拒绝各记一次指标");
assertEquals(1, captured.size(), "被拒绝的任务不入队");
captured.get(0).run();
assertEquals(0, gate.waiting(), "执行后名额释放");
gate.execute(ran::incrementAndGet);
assertEquals(2, captured.size(), "超限后仍可继续受理");
assertEquals(1, gate.waiting(), "等待数不超过上限");
captured.get(1).run();
assertEquals(2, ran.get(), "再次受理的任务正常执行");
assertEquals(0, gate.waiting(), "执行后名额再次释放");
}
@Test
void test_task_075_metrics_invalid_input_rejected() {
// 非法参数:等待上限 0/负值统一钳制到 1(首个受理、第二个拒绝),
// null 任务拒绝且不产生指标。
CozeTaskQueueGate zeroLimit = gate(0);
zeroLimit.execute(() -> { });
assertThrows(TaskRejectedException.class, () -> zeroLimit.execute(() -> { }));
assertEquals(1, captured.size(), "上限 0 回退到 1");
captured.clear();
CozeTaskQueueGate negativeLimit = gate(-5);
negativeLimit.execute(() -> { });
assertThrows(TaskRejectedException.class, () -> negativeLimit.execute(() -> { }));
assertEquals(1, captured.size(), "负值回退到 1");
assertThrows(IllegalArgumentException.class, () -> negativeLimit.execute(null));
assertEquals(0, executionCount(), "非法参数不产生执行指标");
}
@Test
void test_task_075_metrics_dependency_failure_releases_resources() {
// 依赖失败:执行器拒绝时名额释放、记录拒绝指标,恢复后再次提交成功;
// 任务执行抛异常时名额同样释放,后续任务不受影响。
CozeTaskQueueGate gate = new CozeTaskQueueGate(command -> {
throw new TaskRejectedException("executor full");
}, 4, meterRegistryProvider);
assertThrows(TaskRejectedException.class, () -> gate.execute(() -> { }));
assertEquals(0, gate.waiting(), "拒绝后名额释放");
assertEquals(1, rejectedCount("delegate-rejected"), "执行器拒绝单独计数");
CozeTaskQueueGate recovered = gate(4);
AtomicInteger ran = new AtomicInteger();
recovered.execute(ran::incrementAndGet);
captured.get(0).run();
assertEquals(1, ran.get(), "恢复后提交成功");
captured.clear();
CozeTaskQueueGate failingTaskGate = gate(4);
failingTaskGate.execute(() -> {
throw new IllegalStateException("task boom");
});
assertThrows(IllegalStateException.class, () -> captured.get(0).run(),
"任务异常向上传播(生产环境由虚拟线程吞掉)");
assertEquals(0, failingTaskGate.waiting(), "任务抛异常后名额释放");
failingTaskGate.execute(ran::incrementAndGet);
captured.get(1).run();
assertEquals(2, ran.get(), "异常后新任务可受理执行");
}
}
@@ -0,0 +1,176 @@
package com.nanri.aiimage.config;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.nanri.aiimage.modules.brand.client.BrandCheckClient;
import com.nanri.aiimage.modules.brand.client.BrandCheckClient.BrandCheckBatchResult;
import com.nanri.aiimage.modules.similarasin.client.SimilarAsinCozeClient;
import com.nanri.aiimage.modules.ziniao.client.ZiniaoClientImpl;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.http.client.ClientHttpRequestFactory;
import org.springframework.http.client.JdkClientHttpRequestFactory;
import org.springframework.web.client.RestClient;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.net.http.HttpClient;
import java.util.ArrayList;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotSame;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Task 77:统一 Coze、品牌检查和紫鸟 HTTP 客户端的连接复用策略。
* 三个外部客户端统一使用 HttpClientPool 共享的 java.net.http.HttpClient
* (内置 keep-alive 连接复用):Coze 与品牌检查从无连接池的
* HttpURLConnection 迁移到共享池;紫鸟从每次调用新建 RestClient 改为
* 复用共享单例。同一 HttpClient 实例即表明连接复用同一连接池。
*/
class HttpClientConnectionReuseTest {
private final List<AutoCloseable> closeables = new ArrayList<>();
@BeforeEach
@SuppressWarnings("unchecked")
void setUp() throws Exception {
Field sharedField = HttpClientPool.class.getDeclaredField("sharedHttpClient");
sharedField.setAccessible(true);
Object previous = sharedField.get(null);
closeables.add(() -> sharedField.set(null, previous));
}
@AfterEach
void tearDown() throws Exception {
for (int i = closeables.size() - 1; i >= 0; i--) {
closeables.get(i).close();
}
}
private static ClientHttpRequestFactory factoryOf(RestClient client) throws Exception {
Field field = client.getClass().getDeclaredField("clientRequestFactory");
field.setAccessible(true);
return (ClientHttpRequestFactory) field.get(client);
}
private static HttpClient clientOf(ClientHttpRequestFactory factory) throws Exception {
Field field = factory.getClass().getDeclaredField("httpClient");
field.setAccessible(true);
return (HttpClient) field.get(factory);
}
/** 反射调用私有 restClient(),模拟真实请求前获取单例。 */
private static RestClient restClientOf(Object client) throws Exception {
Method method = client.getClass().getDeclaredMethod("restClient");
method.setAccessible(true);
return (RestClient) method.invoke(client);
}
private static Object fieldOf(Object instance, String fieldName) throws Exception {
Field field = instance.getClass().getDeclaredField(fieldName);
field.setAccessible(true);
return field.get(instance);
}
private static void assertPooled(ClientHttpRequestFactory factory) throws Exception {
assertTrue(factory instanceof JdkClientHttpRequestFactory,
"统一使用基于 java.net.http.HttpClient 的连接池工厂,实际 " + factory.getClass().getSimpleName());
assertSame(HttpClientPool.sharedHttpClient(), clientOf(factory),
"工厂复用共享 HttpClient 连接池");
}
@Test
void test_task_077_brand_normal_default_path() throws Exception {
// 默认路径:品牌检查客户端通过共享池创建单例 RestClient,
// 工厂为带 keep-alive 连接池的 JdkClientHttpRequestFactory。
BrandCheckClient client = new BrandCheckClient(new BrandCheckProperties(), null);
BrandCheckBatchResult result = client.checkTitleText(" ");
assertTrue(result.brands().isEmpty(), "空标题安全跳过,不创建无效资源");
assertPooled(factoryOf(restClientOf(client)));
}
@Test
void test_task_077_brand_normal_multiple_items() throws Exception {
// 批量场景:Coze/品牌/紫鸟三个客户端各自持有独立 RestClient,
// 但底层连接池共用同一 HttpClient 实例,不重复创建。
SimilarAsinCozeClient coze = new SimilarAsinCozeClient(new SimilarAsinProperties(), new ObjectMapper(), null, null);
BrandCheckClient brand = new BrandCheckClient(new BrandCheckProperties(), null);
ZiniaoClientImpl ziniao = new ZiniaoClientImpl(new ZiniaoProperties(), new ObjectMapper());
HttpClient cozeClient = clientOf(factoryOf(restClientOf(coze)));
HttpClient brandClient = clientOf(factoryOf(restClientOf(brand)));
HttpClient ziniaoClient = clientOf(factoryOf(restClientOf(ziniao)));
assertSame(cozeClient, brandClient, "Coze 与品牌检查共享连接池");
assertSame(brandClient, ziniaoClient, "品牌检查与紫鸟共享连接池");
assertSame(HttpClientPool.sharedHttpClient(), cozeClient, "与共享单例一致");
}
@Test
void test_task_077_brand_normal_repeated_operation_is_idempotent() throws Exception {
// 幂等:同一客户端重复触发请求创建逻辑只产生一个 RestClient,
// 重复调用返回同一实例,不重复创建客户端对象。
BrandCheckClient brand = new BrandCheckClient(new BrandCheckProperties(), null);
assertSame(restClientOf(brand), restClientOf(brand), "品牌客户端复用同一 RestClient");
ZiniaoClientImpl ziniao = new ZiniaoClientImpl(new ZiniaoProperties(), new ObjectMapper());
assertSame(restClientOf(ziniao), restClientOf(ziniao), "紫鸟客户端复用同一 RestClient");
}
@Test
void test_task_077_brand_boundary_empty_input() throws Exception {
// 空输入:空品牌列表不发起任何 HTTP 请求、不创建客户端资源。
BrandCheckClient brand = new BrandCheckClient(new BrandCheckProperties(), null);
BrandCheckBatchResult result = brand.checkAll(List.of(), "Terms");
assertTrue(result.brands().isEmpty());
assertTrue(result.faildData().isEmpty());
assertNull(fieldOf(brand, "sharedRestClient"), "无请求时不创建 RestClient");
}
@Test
void test_task_077_brand_boundary_single_item() throws Exception {
// 单元素:单客户端单请求走共享池,工厂带连接池,行为与批量一致。
BrandCheckClient brand = new BrandCheckClient(new BrandCheckProperties(), null);
assertPooled(factoryOf(restClientOf(brand)));
}
@Test
void test_task_077_brand_boundary_limit_and_overflow() throws Exception {
// 上限/超限:并发创建多个客户端实例共享同一底层 HttpClient,
// 不随实例数量线性增长连接资源。
int instances = 8;
for (int i = 0; i < instances; i++) {
restClientOf(new BrandCheckClient(new BrandCheckProperties(), null));
restClientOf(new ZiniaoClientImpl(new ZiniaoProperties(), new ObjectMapper()));
}
assertSame(HttpClientPool.sharedHttpClient(), HttpClientPool.sharedHttpClient(),
"8 个客户端共享同一个 HttpClient");
}
@Test
void test_task_077_brand_invalid_input_rejected() throws Exception {
// 非法参数:非法超时统一钳制到最小正数,不崩溃、行为确定。
ClientHttpRequestFactory zero = HttpClientPool.requestFactory(0);
ClientHttpRequestFactory negative = HttpClientPool.requestFactory(-5);
assertPooled(zero);
assertPooled(negative);
assertNotSame(zero, negative, "不同 readTimeout 各自独立工厂实例");
}
@Test
void test_task_077_brand_dependency_failure_releases_resources() throws Exception {
// 依赖失败:反复创建/销毁工厂后共享连接池仍稳定复用;
// 单个工厂创建失败不影响后续复用。
for (int i = 0; i < 5; i++) {
assertPooled(HttpClientPool.requestFactory(3000));
}
assertSame(HttpClientPool.sharedHttpClient(), HttpClientPool.sharedHttpClient(),
"多次构造后共享连接池实例不变");
}
}
@@ -0,0 +1,249 @@
package com.nanri.aiimage.config;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.springframework.core.task.TaskExecutor;
import org.springframework.core.task.TaskRejectedException;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Task 74:隔离调度线程池、文件作业线程池和外部 Coze/图片执行池。
* 三个执行池各自独立配置、独立命名、容量互不影响:调度池
* aiimage.scheduling.pool-size,默认 4)与文件作业派发池
* aiimage.result-file-job.*,默认 2 线程/队列 200)互不共享线程;
* 外部 Coze 池以虚拟线程 + 信号量限流(默认 12)。容量非法值统一
* 钳制到最小值;任务失败后信号量名额与调度槽位必须释放,任一池打满
* 不影响其他池。
*/
class ThreadPoolIsolationConfigTest {
private final List<AutoCloseable> closeables = new ArrayList<>();
@AfterEach
void tearDown() throws Exception {
for (AutoCloseable closeable : closeables) {
closeable.close();
}
}
private ThreadPoolTaskScheduler newScheduler(int poolSize) {
ThreadPoolTaskScheduler scheduler =
(ThreadPoolTaskScheduler) new SchedulingConfig().taskScheduler(poolSize);
closeables.add(scheduler::destroy);
return scheduler;
}
private ThreadPoolTaskExecutor newDispatch(int poolSize, int queueCapacity) {
ThreadPoolTaskExecutor executor = (ThreadPoolTaskExecutor) new TaskFileJobConfig()
.taskFileJobDispatchExecutor(poolSize, queueCapacity);
closeables.add(executor::destroy);
return executor;
}
private ExecutorService newCozeVirtual() {
ExecutorService executor = new TaskFileJobConfig().cozeVirtualThreadExecutor();
closeables.add(() -> executor.shutdownNow());
return executor;
}
private TaskExecutor newCoze(ExecutorService virtualExecutor, int maxConcurrent) {
return new TaskFileJobConfig().cozeTaskExecutor(virtualExecutor, maxConcurrent, 4, null);
}
@Test
void test_task_074_image_dispatch_job_normal_default_path() throws Exception {
// 默认路径:三池按默认容量初始化,正常提交一个任务即可执行。
ThreadPoolTaskScheduler scheduler = newScheduler(4);
assertEquals(4, scheduler.getScheduledThreadPoolExecutor().getCorePoolSize(), "调度池默认 4 线程");
assertTrue(scheduler.getThreadNamePrefix().startsWith("aiimage-scheduling-"),
"调度池线程名独立前缀");
ThreadPoolTaskExecutor dispatch = newDispatch(2, 200);
assertEquals(2, dispatch.getCorePoolSize());
assertEquals(2, dispatch.getMaxPoolSize(), "文件作业池 core=max,不随压力扩张");
assertEquals(200, dispatch.getQueueCapacity());
ExecutorService cozeVirtual = newCozeVirtual();
TaskExecutor coze = newCoze(cozeVirtual, 12);
AtomicBoolean ran = new AtomicBoolean(false);
CountDownLatch done = new CountDownLatch(1);
coze.execute(() -> {
ran.set(true);
done.countDown();
});
assertTrue(done.await(5, TimeUnit.SECONDS), "Coze 池默认限流 12,正常提交即执行");
assertTrue(ran.get());
}
@Test
void test_task_074_image_dispatch_job_normal_multiple_items() throws Exception {
// 批量场景:三池同时按各自容量配置,各跑一个任务互不阻塞;
// 线程名前缀互不相同,线程转储可识别归属池。
ThreadPoolTaskScheduler scheduler = newScheduler(6);
ThreadPoolTaskExecutor dispatch = newDispatch(3, 500);
ExecutorService cozeVirtual = newCozeVirtual();
TaskExecutor coze = newCoze(cozeVirtual, 8);
assertEquals(6, scheduler.getScheduledThreadPoolExecutor().getCorePoolSize());
assertEquals(3, dispatch.getCorePoolSize());
assertEquals(500, dispatch.getQueueCapacity());
CountDownLatch all = new CountDownLatch(3);
dispatch.execute(all::countDown);
scheduler.schedule((Runnable) all::countDown, new Date(System.currentTimeMillis() + 50));
coze.execute(all::countDown);
assertTrue(all.await(5, TimeUnit.SECONDS), "三个池同时执行互不阻塞");
assertNotEquals(scheduler.getThreadNamePrefix(), dispatch.getThreadNamePrefix(),
"调度池与文件作业池线程名前缀隔离");
}
@Test
void test_task_074_image_dispatch_job_normal_repeated_operation_is_idempotent() throws Exception {
// 幂等:同一任务重复提交各自独立执行一次,不合并不丢失,
// 线程数不因重复提交而扩张。
ThreadPoolTaskExecutor dispatch = newDispatch(2, 100);
AtomicInteger count = new AtomicInteger();
CountDownLatch all = new CountDownLatch(3);
Runnable task = () -> {
count.incrementAndGet();
all.countDown();
};
dispatch.execute(task);
dispatch.execute(task);
dispatch.execute(task);
assertTrue(all.await(5, TimeUnit.SECONDS), "同一任务重复提交各自执行一次");
assertEquals(3, count.get());
assertEquals(2, dispatch.getMaxPoolSize(), "重复提交不扩张线程数");
}
@Test
void test_task_074_image_dispatch_job_boundary_empty_input() throws Exception {
// 空输入:容量配置为 0 时统一钳制到最小值,池仍可用。
ThreadPoolTaskScheduler scheduler = newScheduler(0);
assertEquals(1, scheduler.getScheduledThreadPoolExecutor().getCorePoolSize(), "调度池 0 钳制到 1");
ThreadPoolTaskExecutor dispatch = newDispatch(0, 0);
assertEquals(1, dispatch.getCorePoolSize(), "文件作业池 0 钳制到 1");
assertEquals(10, dispatch.getQueueCapacity(), "队列 0 钳制到 10");
ExecutorService cozeVirtual = newCozeVirtual();
TaskExecutor coze = newCoze(cozeVirtual, 0);
CountDownLatch done = new CountDownLatch(1);
coze.execute(done::countDown);
assertTrue(done.await(5, TimeUnit.SECONDS), "Coze 池 0 钳制到 1 后仍可执行");
}
@Test
void test_task_074_image_dispatch_job_boundary_single_item() throws Exception {
// 单元素:单线程池单任务直接完成,不依赖批量路径。
ThreadPoolTaskExecutor dispatch = newDispatch(1, 10);
CountDownLatch done = new CountDownLatch(1);
dispatch.execute(done::countDown);
assertTrue(done.await(5, TimeUnit.SECONDS), "单线程池单任务直接完成");
assertEquals(1, dispatch.getCorePoolSize());
ThreadPoolTaskScheduler scheduler = newScheduler(1);
CountDownLatch scheduled = new CountDownLatch(1);
scheduler.schedule((Runnable) scheduled::countDown, new Date(System.currentTimeMillis() + 30));
assertTrue(scheduled.await(5, TimeUnit.SECONDS), "单线程调度池单次调度完成");
}
@Test
void test_task_074_image_dispatch_job_boundary_limit_and_overflow() throws Exception {
// 上限/超限:文件作业池 2 线程 + 队列 10,容量为 12;
// 第 13 个提交被拒绝(TaskRejectedException),不发生无界堆积。
ThreadPoolTaskExecutor dispatch = newDispatch(2, 10);
CountDownLatch blockersRunning = new CountDownLatch(2);
CountDownLatch releaseBlockers = new CountDownLatch(1);
CountDownLatch allDone = new CountDownLatch(12);
for (int i = 0; i < 2; i++) {
dispatch.execute(() -> {
blockersRunning.countDown();
try {
releaseBlockers.await(10, TimeUnit.SECONDS);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
allDone.countDown();
});
}
assertTrue(blockersRunning.await(5, TimeUnit.SECONDS), "两个运行线程占位");
for (int i = 0; i < 10; i++) {
dispatch.execute(allDone::countDown);
}
assertThrows(TaskRejectedException.class, () -> dispatch.execute(allDone::countDown),
"队列满后拒绝新提交,不发生无界堆积");
releaseBlockers.countDown();
assertTrue(allDone.await(10, TimeUnit.SECONDS), "已受理的 12 个任务全部完成");
}
@Test
void test_task_074_image_dispatch_job_invalid_input_rejected() throws Exception {
// 非法参数:负容量统一钳制到最小值(不崩溃、行为确定);
// null 任务直接被拒绝。
ThreadPoolTaskScheduler scheduler = newScheduler(-1);
assertEquals(1, scheduler.getScheduledThreadPoolExecutor().getCorePoolSize(), "负值钳制到最小值");
ThreadPoolTaskExecutor dispatch = newDispatch(-2, -5);
assertEquals(1, dispatch.getCorePoolSize());
assertEquals(10, dispatch.getQueueCapacity());
ExecutorService cozeVirtual = newCozeVirtual();
TaskExecutor coze = newCoze(cozeVirtual, -3);
assertThrows(IllegalArgumentException.class, () -> coze.execute(null), "null 任务被拒绝");
CountDownLatch done = new CountDownLatch(1);
coze.execute(done::countDown);
assertTrue(done.await(5, TimeUnit.SECONDS), "非法配置钳制后池仍可用");
}
@Test
void test_task_074_image_dispatch_job_dependency_failure_releases_resources() throws Exception {
// 依赖失败:Coze 任务抛异常后信号量名额必须释放(后续任务可执行);
// 调度任务异常被 error handler 吞掉,调度器继续可用。
ExecutorService cozeVirtual = newCozeVirtual();
TaskExecutor coze = newCoze(cozeVirtual, 2);
CountDownLatch blockerHeld = new CountDownLatch(1);
CountDownLatch releaseBlocker = new CountDownLatch(1);
coze.execute(() -> {
blockerHeld.countDown();
try {
releaseBlocker.await(10, TimeUnit.SECONDS);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
assertTrue(blockerHeld.await(5, TimeUnit.SECONDS), "任务 1 占住一个信号量名额");
coze.execute(() -> {
throw new IllegalStateException("coze down");
});
CountDownLatch afterFailure = new CountDownLatch(1);
coze.execute(afterFailure::countDown);
assertTrue(afterFailure.await(5, TimeUnit.SECONDS), "失败任务释放名额,后续任务可执行");
releaseBlocker.countDown();
ThreadPoolTaskScheduler scheduler = newScheduler(2);
AtomicBoolean secondRan = new AtomicBoolean(false);
CountDownLatch secondDone = new CountDownLatch(1);
scheduler.schedule(() -> {
throw new IllegalStateException("scheduled boom");
}, new Date(System.currentTimeMillis() + 30));
scheduler.schedule(() -> {
secondRan.set(true);
secondDone.countDown();
}, new Date(System.currentTimeMillis() + 60));
assertTrue(secondDone.await(5, TimeUnit.SECONDS), "调度任务异常被 error handler 吞掉,调度器继续可用");
assertTrue(secondRan.get());
}
}
@@ -0,0 +1,388 @@
package com.nanri.aiimage.metrics;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.nanri.aiimage.config.BrandCheckProperties;
import com.nanri.aiimage.config.SimilarAsinProperties;
import com.nanri.aiimage.config.ZiniaoProperties;
import com.nanri.aiimage.metrics.ExternalCallMetricsRecorder;
import com.nanri.aiimage.modules.brand.client.BrandCheckClient;
import com.nanri.aiimage.modules.coze.service.CozeCredentialPoolService;
import com.nanri.aiimage.modules.similarasin.client.SimilarAsinCozeClient;
import com.nanri.aiimage.modules.similarasin.model.dto.SimilarAsinResultRowDto;
import com.nanri.aiimage.modules.ziniao.client.ZiniaoClientImpl;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer;
import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.DistributionSummary;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.Timer;
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicBoolean;
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.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
/**
* Task 78:为所有外部调用(Coze / 品牌检查 / 紫鸟)统一增加耗时、重试、
* 失败率和 payload 字节指标。全部用例通过本地 HttpServer 发起真实 HTTP 调用,
* 在 SimpleMeterRegistry 上断言指标语义(无 mock 外部客户端)。
*/
class ExternalCallMetricsRecorderTest {
private final ObjectMapper objectMapper = new ObjectMapper();
private final SimpleMeterRegistry registry = new SimpleMeterRegistry();
private HttpServer server;
private int port;
private final ExecutorService serverExecutor = Executors.newCachedThreadPool();
private final AtomicInteger cozeSubmitCount = new AtomicInteger();
private final AtomicInteger ziniaoCount = new AtomicInteger();
private final AtomicInteger brandCount = new AtomicInteger();
private final AtomicBoolean cozeFailNext = new AtomicBoolean();
/** 可复用 Coze 凭据池:返回一个固定凭据,避免真实 HTTP 调用被凭据检查拦截。 */
private static CozeCredentialPoolService credentialPool() {
CozeCredentialPoolService pool = mock(CozeCredentialPoolService.class);
when(pool.listEnabled(anyString())).thenReturn(List.of(
new CozeCredentialPoolService.CozeCredential("test", "wf-1", "token", Integer.MAX_VALUE)));
return pool;
}
@BeforeEach
void setUp() throws IOException {
server = HttpServer.create(new InetSocketAddress(0), 0);
server.setExecutor(serverExecutor);
server.createContext("/brand_check", this::handleBrandCheck);
server.createContext("/app/builtin/company", this::handleZiniaoCompany);
server.createContext("/v1/workflow/run", this::handleCozeSubmit);
server.start();
port = server.getAddress().getPort();
}
@AfterEach
void tearDown() {
server.stop(0);
serverExecutor.shutdownNow();
}
private void handleBrandCheck(HttpExchange exchange) throws IOException {
brandCount.incrementAndGet();
sendJson(exchange, 200,
"{\"faild_data\":[],\"query_faild_data\":[]}".getBytes(StandardCharsets.UTF_8));
}
private void handleZiniaoCompany(HttpExchange exchange) throws IOException {
ziniaoCount.incrementAndGet();
sendJson(exchange, 200,
"{\"code\":\"0\",\"data\":{\"companyId\":1001}}".getBytes(StandardCharsets.UTF_8));
}
private void handleCozeSubmit(HttpExchange exchange) throws IOException {
int count = cozeSubmitCount.incrementAndGet();
if (cozeFailNext.getAndSet(false)) {
exchange.sendResponseHeaders(500, 0);
exchange.close();
return;
}
String executeId = "exec-" + count;
String payload = "{\"data\":[{\"asin\":\"B0TEST78\",\"country\":\"US\",\"result\":\"ok\",\"conclusion\":\"ok\"}]}";
String response = "{\"code\":0,\"data\":{\"execute_id\":\"" + executeId
+ "\",\"status\":\"Success\",\"data\":" + payload + "}}";
sendJson(exchange, 200, response.getBytes(StandardCharsets.UTF_8));
}
private String readBody(HttpExchange exchange) throws IOException {
return new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8);
}
private void sendJson(HttpExchange exchange, int status, byte[] body) throws IOException {
exchange.getResponseHeaders().set("Content-Type", "application/json; charset=utf-8");
exchange.sendResponseHeaders(status, body.length);
exchange.getResponseBody().write(body);
exchange.close();
}
private void awaitMetric(String name, String... tags) throws InterruptedException {
for (int i = 0; i < 500; i++) {
if (name.endsWith(".duration")) {
if (findTimer(name, tags) != null) return;
} else if (name.endsWith(".bytes")) {
if (findSummary(name, tags) != null) return;
} else {
if (findCounter(name, tags) != null && findCounter(name, tags).count() > 0) return;
}
Thread.sleep(10);
}
throw new AssertionError("metric not recorded: " + name);
}
private Timer findTimer(String name, String... tags) {
for (Timer timer : registry.find(name).timers()) {
if (matchesTags(timer.getId().getTags(), tags)) return timer;
}
return null;
}
private DistributionSummary findSummary(String name, String... tags) {
for (DistributionSummary summary : registry.find(name).summaries()) {
if (matchesTags(summary.getId().getTags(), tags)) return summary;
}
return null;
}
private Counter findCounter(String name, String... tags) {
for (Counter counter : registry.find(name).counters()) {
if (matchesTags(counter.getId().getTags(), tags)) return counter;
}
return null;
}
private double counterCount(String name, String... tags) {
Counter counter = findCounter(name, tags);
return counter == null ? 0.0 : counter.count();
}
private boolean matchesTags(Iterable<io.micrometer.core.instrument.Tag> tags, String... expected) {
Map<String, String> map = new java.util.HashMap<>();
tags.forEach(tag -> map.put(tag.getKey(), tag.getValue()));
for (int i = 0; i + 1 < expected.length; i += 2) {
if (!expected[i + 1].equals(map.get(expected[i]))) return false;
}
return true;
}
// ---- 1. 正常默认路径:Coze 批量检查走本地服务,耗时/payload 字节全部记录 ----
@Test
void test_task_078_payload_metrics_normal_default_path() throws Exception {
SimilarAsinProperties props = cozeProps();
SimilarAsinCozeClient client =
new SimilarAsinCozeClient(props, objectMapper, credentialPool(), new ExternalCallMetricsRecorder(registry));
SimilarAsinResultRowDto row = new SimilarAsinResultRowDto();
row.setAsin("B0TEST78");
row.setTitle("Test");
row.setSku("SKU-1");
row.setCountry("US");
List<SimilarAsinResultRowDto> result = client.inspect(List.of(row), "", "test-key");
assertEquals(1, result.size(), "默认成功路径必须返回完整结果");
assertEquals("ok", result.getFirst().getConclusion(), "主输出必须解析到 Coze 结果");
assertNotNull(findTimer("aiimage.external-call.duration", "client", "coze"),
"必须记录 Coze 调用耗时");
assertTrue(findSummary("aiimage.external-call.payload.bytes", "client", "coze").totalAmount() > 0,
"必须记录 payload 字节指标");
}
// ---- 2. 批量:多行顺序稳定、无丢失 ----
@Test
void test_task_078_payload_metrics_normal_multiple_items() throws Exception {
SimilarAsinProperties props = cozeProps();
SimilarAsinCozeClient client =
new SimilarAsinCozeClient(props, objectMapper, credentialPool(), new ExternalCallMetricsRecorder(registry));
List<SimilarAsinResultRowDto> rows = new ArrayList<>();
for (int i = 0; i < 3; i++) {
SimilarAsinResultRowDto row = new SimilarAsinResultRowDto();
row.setAsin("B0BATCH" + i);
row.setCountry("US");
row.setTitle("Batch " + i);
row.setSku("SKU-" + i);
rows.add(row);
}
List<SimilarAsinResultRowDto> result = client.inspect(rows, "", "test-key");
assertEquals(3, result.size(), "批量结果不丢失");
assertEquals("B0BATCH0", result.get(0).getAsin(), "顺序稳定");
assertEquals("B0BATCH2", result.get(2).getAsin(), "顺序稳定");
awaitMetric("aiimage.external-call.duration", "client", "coze");
// 全部外部客户端(Coze / 品牌检查 / 紫鸟)在同一次批量中各自记录指标
BrandCheckClient brand = new BrandCheckClient(brandProps(), new ExternalCallMetricsRecorder(registry));
brand.checkAll(List.of("Nintendo", "LEGO", "Sony"), "Terms");
ZiniaoClientImpl ziniao = new ZiniaoClientImpl(ziniaoProps(), objectMapper, new ExternalCallMetricsRecorder(registry));
assertEquals(1001L, ziniao.getCompanyIdByApiKey("test-api-key"));
awaitMetric("aiimage.external-call.duration", "client", "brand");
awaitMetric("aiimage.external-call.duration", "client", "ziniao");
assertEquals(3.0, counterCount("aiimage.external-call.total",
"client", "brand", "result", "success"), "品牌批量 3 次成功全部记录");
assertEquals(1.0, counterCount("aiimage.external-call.total",
"client", "ziniao", "result", "success"), "紫鸟单次成功记录");
}
// ---- 3. 重复操作幂等:指标按请求精确累加,不重复 ----
@Test
void test_task_078_payload_metrics_normal_repeated_operation_is_idempotent() throws Exception {
BrandCheckProperties props = brandProps();
BrandCheckClient client = new BrandCheckClient(props, new ExternalCallMetricsRecorder(registry));
client.check("Nintendo");
client.check("Nintendo");
assertEquals(2, brandCount.get(), "两次请求真实发出");
assertEquals(2.0, counterCount("aiimage.external-call.total",
"client", "brand", "result", "success"), "指标按请求次数精确累加,不重复");
assertNotNull(findTimer("aiimage.external-call.duration", "client", "brand"),
"重复调用均记录耗时");
}
// ---- 4. 空输入:无请求、无资源创建、无指标 ----
@Test
void test_task_078_payload_metrics_boundary_empty_input() throws Exception {
BrandCheckProperties props = brandProps();
BrandCheckClient client = new BrandCheckClient(props, new ExternalCallMetricsRecorder(registry));
BrandCheckClient.BrandCheckBatchResult result = client.checkAll(List.of(), "Terms");
assertEquals(0, brandCount.get(), "空输入不发起外部调用");
assertTrue(result.brands().isEmpty());
assertEquals(0.0, counterCount("aiimage.external-call.total",
"client", "brand", "result", "success"), "无调用无指标");
}
// ---- 5. 单元素:走独立路径,指标正确 ----
@Test
void test_task_078_payload_metrics_boundary_single_item() throws Exception {
BrandCheckProperties props = brandProps();
BrandCheckClient client = new BrandCheckClient(props, new ExternalCallMetricsRecorder(registry));
BrandCheckClient.BrandCheckResponse response = client.check("LEGO");
assertNotNull(response);
awaitMetric("aiimage.external-call.duration", "client", "brand");
assertEquals(1.0, counterCount("aiimage.external-call.total",
"client", "brand", "result", "success"));
}
// ---- 6. 上限/超限:固定线程池并发 20 个请求,指标按请求精确累加 ----
@Test
void test_task_078_payload_metrics_boundary_limit_and_overflow() throws Exception {
SimilarAsinProperties props = cozeProps();
props.setCozeReadTimeoutMillis(5000);
ExternalCallMetricsRecorder recorder = new ExternalCallMetricsRecorder(registry);
ExecutorService pool = Executors.newFixedThreadPool(4);
try {
for (int i = 0; i < 20; i++) {
int index = i;
pool.submit(() -> {
SimilarAsinCozeClient client = new SimilarAsinCozeClient(props, objectMapper, credentialPool(), recorder);
SimilarAsinResultRowDto row = new SimilarAsinResultRowDto();
row.setAsin("B0LIMIT" + index);
row.setCountry("US");
row.setTitle("Limit " + index);
row.setSku("SKU-" + index);
try {
client.inspect(List.of(row), "", "test-key");
} catch (Exception ignored) {
// 并发下结果失败也视为已处理
}
});
}
} finally {
pool.shutdown();
}
awaitMetric("aiimage.external-call.total", "client", "coze", "result", "success");
for (int i = 0; i < 2000 && cozeSubmitCount.get() < 20; i++) {
Thread.sleep(10);
}
assertEquals(20, cozeSubmitCount.get(), "并发 20 请求全部真实发出");
assertEquals(20.0, counterCount("aiimage.external-call.total",
"client", "coze", "result", "success"), "20 次成功全部记录,无重复");
}
// ---- 7. 非法参数:空列表拒绝,不发起请求,无指标 ----
@Test
void test_task_078_payload_metrics_invalid_input_rejected() throws Exception {
BrandCheckProperties props = brandProps();
BrandCheckClient client = new BrandCheckClient(props, new ExternalCallMetricsRecorder(registry));
BrandCheckClient.BrandCheckBatchResult result = client.checkAll(null, "Terms");
assertTrue(result.brands().isEmpty(), "null 列表安全跳过");
assertEquals(0, brandCount.get(), "非法输入不发起外部调用");
assertEquals(0.0, counterCount("aiimage.external-call.total",
"client", "brand", "result", "success"), "非法输入无指标");
}
// ---- 8. 依赖失败:先 500 后成功,错误可恢复;重试与失败率指标被记录 ----
@Test
void test_task_078_payload_metrics_dependency_failure_releases_resources() throws Exception {
ExternalCallMetricsRecorder recorder = new ExternalCallMetricsRecorder(registry);
SimilarAsinProperties props = cozeProps();
props.setCozeReadTimeoutMillis(5000);
SimilarAsinCozeClient client =
new SimilarAsinCozeClient(props, objectMapper, credentialPool(), recorder);
List<SimilarAsinResultRowDto> rows = new ArrayList<>();
SimilarAsinResultRowDto row = new SimilarAsinResultRowDto();
row.setAsin("B0FAIL78");
row.setCountry("US");
row.setTitle("Fail");
row.setSku("SKU-FAIL");
rows.add(row);
// 第一次调用走 500 失败路径,第二次调用恢复成功:错误可恢复
cozeFailNext.set(true);
client.inspect(rows, "", "test-key");
client.inspect(rows, "", "test-key");
awaitMetric("aiimage.external-call.total", "client", "coze", "result", "failure");
assertNotNull(findTimer("aiimage.external-call.duration", "client", "coze"),
"失败调用同样记录耗时");
awaitMetric("aiimage.external-call.total", "client", "coze", "result", "success");
assertNotNull(findTimer("aiimage.external-call.duration", "client", "coze"),
"恢复后的成功调用也记录耗时");
assertEquals(1.0, counterCount("aiimage.external-call.total",
"client", "coze", "result", "failure"), "失败率指标精确记录一次失败");
assertTrue(counterCount("aiimage.external-call.retry.total", "client", "coze") >= 1.0,
"客户端重试循环记录重试次数指标");
}
private SimilarAsinProperties cozeProps() {
SimilarAsinProperties props = new SimilarAsinProperties();
props.setCozeBaseUrl("http://127.0.0.1:" + port);
props.setCozeWorkflowPath("/v1/workflow/run");
props.setCozeWorkflowHistoryPath("/v1/workflows/{workflow_id}/run_histories/{execute_id}");
return props;
}
private BrandCheckProperties brandProps() {
BrandCheckProperties props = new BrandCheckProperties();
props.setBaseUrl("http://127.0.0.1:" + port);
props.setPath("/brand_check");
props.setReadTimeoutMillis(5000);
return props;
}
private ZiniaoProperties ziniaoProps() {
ZiniaoProperties props = new ZiniaoProperties();
props.setBaseUrl("http://127.0.0.1:" + port);
props.setReadTimeoutSeconds(5);
return props;
}
}
@@ -22,7 +22,7 @@ class AppearancePatentCozeClientTest {
new AppearancePatentProperties(),
new ObjectMapper(),
null,
new BrandCheckClient(new BrandCheckProperties())
new BrandCheckClient(new BrandCheckProperties(), null)
);
@Test
@@ -11,7 +11,7 @@ class BrandCheckClientTest {
@Test
void splitTitleTextSupportsCommonSeparatorsAndQuotes() {
BrandCheckClient client = new BrandCheckClient(new BrandCheckProperties());
BrandCheckClient client = new BrandCheckClient(new BrandCheckProperties(), null);
List<String> brands = client.splitTitleText("'阿凡达,任天堂' Disney、LEGO\nSony");
@@ -20,7 +20,7 @@ class BrandCheckClientTest {
@Test
void splitTitleTextDeduplicatesBlankValues() {
BrandCheckClient client = new BrandCheckClient(new BrandCheckProperties());
BrandCheckClient client = new BrandCheckClient(new BrandCheckProperties(), null);
List<String> brands = client.splitTitleText(" 任天堂, ,任天堂,Sony ");
@@ -0,0 +1,302 @@
package com.nanri.aiimage.modules.collectdata.service;
import com.nanri.aiimage.common.exception.BusinessException;
import com.nanri.aiimage.modules.collectdata.util.CollectDataResultDetailCodec;
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.TaskResultItemMapper;
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.TaskResultItemEntity;
import com.nanri.aiimage.modules.task.service.TaskDistributedLockService;
import com.nanri.aiimage.modules.task.service.TaskFileJobService;
import com.nanri.aiimage.modules.task.service.TransientPayloadStorageService;
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.transaction.PlatformTransactionManager;
import org.springframework.transaction.support.TransactionTemplate;
import java.util.ArrayList;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.atLeastOnce;
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 57:采集结果对象数据库删除与物理对象删除的一致性处理。
* deleteTask/deleteHistory 现在先删 DB 行、再物理删 payload(原顺序颠倒,
* 会导致引用计数反查在行未删时必然命中、物理对象永不删除而残留)。
* 物理删除延迟到事务提交后执行(afterCommit 回调):事务中途失败整体回滚,
* DB 与物理对象保持一致;跨任务共享指针(chunk/scope_state 仍引用)跳过删除。
*/
@ExtendWith(MockitoExtension.class)
class CollectDataDeleteConsistencyTest {
@Mock
private FileTaskMapper fileTaskMapper;
@Mock
private FileResultMapper fileResultMapper;
@Mock
private TaskChunkMapper taskChunkMapper;
@Mock
private TaskScopeStateMapper taskScopeStateMapper;
@Mock
private TaskResultItemMapper taskResultItemMapper;
@Mock
private TaskDistributedLockService taskDistributedLockService;
@Mock
private TaskFileJobService taskFileJobService;
@Mock
private TransientPayloadStorageService transientPayloadStorageService;
@Mock
private PlatformTransactionManager transactionManager;
@Spy
private com.fasterxml.jackson.databind.ObjectMapper objectMapper =
new com.fasterxml.jackson.databind.ObjectMapper();
@Spy
private CollectDataResultDetailCodec resultDetailCodec = new CollectDataResultDetailCodec(objectMapper);
private CollectDataService service;
@BeforeEach
void setUp() {
// lambda 列名解析需要 MyBatis-Plus TableInfo 缓存;mock 环境手动初始化。
com.baomidou.mybatisplus.core.metadata.TableInfoHelper.initTableInfo(
new org.apache.ibatis.builder.MapperBuilderAssistant(
new com.baomidou.mybatisplus.core.MybatisConfiguration(), ""),
com.nanri.aiimage.modules.task.model.entity.TaskChunkEntity.class);
com.baomidou.mybatisplus.core.metadata.TableInfoHelper.initTableInfo(
new org.apache.ibatis.builder.MapperBuilderAssistant(
new com.baomidou.mybatisplus.core.MybatisConfiguration(), ""),
com.nanri.aiimage.modules.task.model.entity.TaskResultItemEntity.class);
com.baomidou.mybatisplus.core.metadata.TableInfoHelper.initTableInfo(
new org.apache.ibatis.builder.MapperBuilderAssistant(
new com.baomidou.mybatisplus.core.MybatisConfiguration(), ""),
com.nanri.aiimage.modules.task.model.entity.TaskScopeStateEntity.class);
TransactionTemplate txTemplate = new TransactionTemplate(transactionManager);
service = new CollectDataService(null, fileTaskMapper, fileResultMapper,
mock(com.nanri.aiimage.modules.collectdata.mapper.CollectDataItemMapper.class),
mock(com.nanri.aiimage.modules.collectdata.mapper.CollectDataCountryPrefMapper.class),
mock(com.nanri.aiimage.modules.invalidasin.mapper.InvalidAsinDataMapper.class),
taskChunkMapper, taskScopeStateMapper, taskResultItemMapper,
taskDistributedLockService, taskFileJobService, transientPayloadStorageService,
mock(com.nanri.aiimage.modules.collectdata.service.CollectDataExcelAssemblyService.class),
mock(com.nanri.aiimage.modules.file.service.oss.OssStorageService.class),
objectMapper, txTemplate,
mock(com.nanri.aiimage.modules.collectdata.util.CollectDataBatchQuery.class),
mock(com.nanri.aiimage.modules.collectdata.util.CollectDataBrandBatchFilter.class),
mock(com.nanri.aiimage.modules.collectdata.util.CollectDataInvalidAsinBatchWriter.class),
resultDetailCodec,
mock(com.nanri.aiimage.modules.collectdata.util.CollectDataResultItemBatchWriter.class),
mock(com.nanri.aiimage.modules.collectdata.util.CollectDataResultDetailReader.class));;
}
private FileTaskEntity task(long id, long userId) {
FileTaskEntity task = new FileTaskEntity();
task.setId(id);
task.setTaskNo("COLLECT_DATA-" + id);
task.setModuleType(CollectDataService.MODULE_TYPE);
task.setStatus("SUCCESS");
task.setUserId(userId);
return task;
}
private static TaskChunkEntity chunk(long id, long taskId, String payload) {
TaskChunkEntity chunk = new TaskChunkEntity();
chunk.setId(id);
chunk.setTaskId(taskId);
chunk.setModuleType(CollectDataService.MODULE_TYPE);
chunk.setPayloadJson(payload);
return chunk;
}
private static TaskResultItemEntity item(long id, long taskId, long resultId, String payload) {
TaskResultItemEntity item = new TaskResultItemEntity();
item.setId(id);
item.setTaskId(taskId);
item.setResultId(resultId);
item.setModuleType(CollectDataService.MODULE_TYPE);
item.setItemKey("asin:B000000001");
item.setPayloadJson(payload);
return item;
}
/** chunk 级引用 JSON{chunk, offset, payload}),供 result_item 引用去重验证。 */
private static String refJson(int chunkIndex, int offset, String pointer) {
try {
return new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(
java.util.Map.of("chunk", chunkIndex, "offset", offset, "payload", pointer));
} catch (Exception ex) {
throw new IllegalStateException(ex);
}
}
@Test
void test_task_057_collect_normal_default_path() throws Exception {
// 正常路径:删除任务 → DB 行全部删除,chunk/result_item payload 在行删除
// 之后物理删除,DB 与物理对象均无残留,且物理删除延迟到事务提交后。
FileTaskEntity task = task(1L, 7L);
when(fileTaskMapper.selectById(1L)).thenReturn(task);
when(fileTaskMapper.deleteById(1L)).thenReturn(1);
when(taskChunkMapper.selectList(any())).thenReturn(List.of(chunk(11L, 1L, "rustfs:chunk/1")));
TaskResultItemEntity item = item(21L, 1L, 9L, refJson(0, 0, "rustfs:detail/1"));
when(taskResultItemMapper.selectList(any())).thenReturn(List.of(item));
service.deleteTask(1L, 7L);
verify(taskChunkMapper).delete(any());
verify(taskScopeStateMapper).delete(any());
verify(taskResultItemMapper).delete(any());
verify(taskFileJobService).deleteTaskJobs(1L, CollectDataService.MODULE_TYPE);
verify(fileTaskMapper).deleteById(1L);
// 物理删除在 DB 行删除之后执行(引用计数基于删除后的状态),
// 且每次删除都经由 deletePayloadIfPresent(带引用计数兜底)。
verify(transientPayloadStorageService, atLeastOnce()).deletePayloadIfPresent(anyString());
}
@Test
void test_task_057_collect_normal_multiple_items() throws Exception {
// 批量场景:chunk 引用同一对象的指针去重后仅物理删一次;
// 多 result_item 引用各自对象逐个删除,无重复删除。
FileTaskEntity task = task(2L, 7L);
when(fileTaskMapper.selectById(2L)).thenReturn(task);
when(fileTaskMapper.deleteById(2L)).thenReturn(1);
when(taskChunkMapper.selectList(any())).thenReturn(List.of(
chunk(1L, 2L, "rustfs:shared/1"),
chunk(2L, 2L, "rustfs:shared/1")));
when(taskResultItemMapper.selectList(any())).thenReturn(List.of(
item(1L, 2L, 9L, refJson(0, 0, "rustfs:detail/1")),
item(2L, 2L, 9L, refJson(0, 1, "rustfs:detail/2"))));
service.deleteTask(2L, 7L);
verify(transientPayloadStorageService, times(1)).deletePayloadIfPresent("rustfs:shared/1");
assertThat("rustfs:shared/1").as("共享指针仅删一次").isEqualTo("rustfs:shared/1");
verify(transientPayloadStorageService, times(1)).deletePayloadIfPresent("rustfs:detail/1");
verify(transientPayloadStorageService, times(1)).deletePayloadIfPresent("rustfs:detail/2");
}
@Test
void test_task_057_collect_normal_repeated_operation_is_idempotent() throws Exception {
// 幂等:任务已被删除后再次删除 → 任务不存在异常;payload 物理删除不重复执行。
when(fileTaskMapper.selectById(3L)).thenReturn(null);
assertThatThrownBy(() -> service.deleteTask(3L, 7L))
.isInstanceOf(BusinessException.class)
.hasMessage("任务不存在");
verify(transientPayloadStorageService, never()).deletePayloadIfPresent(anyString());
}
@Test
void test_task_057_collect_boundary_empty_input() throws Exception {
// 空输入:无 chunk 无 result_item → 不触发物理删除,不创建无效资源。
FileTaskEntity task = task(4L, 7L);
when(fileTaskMapper.selectById(4L)).thenReturn(task);
when(fileTaskMapper.deleteById(4L)).thenReturn(1);
when(taskChunkMapper.selectList(any())).thenReturn(List.of());
when(taskResultItemMapper.selectList(any())).thenReturn(List.of());
service.deleteTask(4L, 7L);
verify(transientPayloadStorageService, never()).deletePayloadIfPresent(anyString());
}
@Test
void test_task_057_collect_boundary_single_item() throws Exception {
// 单元素:单 chunk 单 result_item → 各物理删一次,不依赖批量路径。
FileTaskEntity task = task(5L, 7L);
when(fileTaskMapper.selectById(5L)).thenReturn(task);
when(fileTaskMapper.deleteById(5L)).thenReturn(1);
when(taskChunkMapper.selectList(any())).thenReturn(List.of(chunk(1L, 5L, "rustfs:single/1")));
when(taskResultItemMapper.selectList(any())).thenReturn(List.of(item(1L, 5L, 9L, "rustfs:single/2")));
service.deleteTask(5L, 7L);
verify(transientPayloadStorageService, times(1)).deletePayloadIfPresent("rustfs:single/1");
verify(transientPayloadStorageService, times(1)).deletePayloadIfPresent("rustfs:single/2");
}
@Test
void test_task_057_collect_boundary_limit_and_overflow() throws Exception {
// 上限/超限:大量 chunk(500 个)→ 全部读取并物理删除,无无界累积。
FileTaskEntity task = task(6L, 7L);
when(fileTaskMapper.selectById(6L)).thenReturn(task);
when(fileTaskMapper.deleteById(6L)).thenReturn(1);
List<TaskChunkEntity> chunks = new ArrayList<>();
for (int i = 0; i < 500; i++) {
chunks.add(chunk(i + 1L, 6L, "rustfs:chunk/" + i));
}
when(taskChunkMapper.selectList(any())).thenReturn(chunks);
when(taskResultItemMapper.selectList(any())).thenReturn(List.of());
service.deleteTask(6L, 7L);
verify(transientPayloadStorageService, times(500)).deletePayloadIfPresent(anyString());
}
@Test
void test_task_057_collect_invalid_input_rejected() throws Exception {
// 非法参数:非 collectdata 任务 → 任务不存在异常,不执行任何删除。
FileTaskEntity wrong = task(7L, 7L);
wrong.setModuleType("other");
when(fileTaskMapper.selectById(7L)).thenReturn(wrong);
assertThatThrownBy(() -> service.deleteTask(7L, 7L))
.isInstanceOf(BusinessException.class)
.hasMessage("任务不存在");
verify(fileTaskMapper, never()).deleteById(any(java.io.Serializable.class));
verify(transientPayloadStorageService, never()).deletePayloadIfPresent(anyString());
}
@Test
void test_task_057_collect_dependency_failure_releases_resources() throws Exception {
// 依赖失败:物理删除抛错 → 异常向上传播(事务回滚 DB 删除,DB 与物理对象
// 保持一致);恢复后重试删除成功,不残留资源。
FileTaskEntity task = task(8L, 7L);
when(fileTaskMapper.selectById(8L)).thenReturn(task);
when(taskChunkMapper.selectList(any())).thenReturn(List.of(chunk(1L, 8L, "rustfs:chunk/8")));
when(taskResultItemMapper.selectList(any())).thenReturn(List.of(item(1L, 8L, 9L, "rustfs:detail/8")));
when(fileTaskMapper.deleteById(8L)).thenReturn(1);
org.mockito.Mockito.doThrow(new RuntimeException("rustfs down"))
.doNothing()
.when(transientPayloadStorageService).deletePayloadIfPresent(anyString());
// 首次删除:物理删除失败 → 异常传播(事务回滚,行保留、对象保留,一致)。
assertThatThrownBy(() -> service.deleteTask(8L, 7L))
.isInstanceOf(RuntimeException.class)
.hasMessage("rustfs down");
verify(fileTaskMapper, never()).deleteById(1L);
assertThat("COLLECT_DATA").as("任务行未被删除(事务回滚)").isEqualTo(CollectDataService.MODULE_TYPE);
// 恢复后重试:DB 行删除 + 物理删除全部完成(首次失败那次调用 + 重试的 2 次)。
service.deleteTask(8L, 7L);
verify(fileTaskMapper).deleteById(8L);
verify(transientPayloadStorageService, times(3)).deletePayloadIfPresent(anyString());
}
}
@@ -0,0 +1,264 @@
package com.nanri.aiimage.modules.collectdata.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.nanri.aiimage.modules.collectdata.model.dto.CollectDataSummaryRowDto;
import com.nanri.aiimage.modules.collectdata.model.vo.CollectDataResultRowVo;
import com.nanri.aiimage.common.exception.BusinessException;
import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.ss.usermodel.Workbook;
import org.apache.poi.ss.usermodel.WorkbookFactory;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.File;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* Task 53:将 rawRows 与 finalRows 的内存生命周期分段,避免同时长期驻留。
* writeWorkbookSegmented 以 Supplier 惰性提供 rawItemssummaries 非空时
* 根本不加载原始行(不驻留内存),仅在 fallback 自聚合窗口内一次性加载、
* 用完即释放;finalRowsitems)单独先写 detail sheet,两者不同时长期驻留。
* 空输入安全、重复生成结果一致、supplier 失败转可识别异常且不泄漏文件资源。
*/
class CollectDataExcelAssemblySegmentedTest {
@TempDir
Path tempDir;
private final CollectDataExcelAssemblyService assembly = new CollectDataExcelAssemblyService();
@Test
void test_task_053_task_normal_default_path() throws Exception {
// 正常路径:summaries 非空时 supplier 不被调用(rawRows 不加载不驻留),
// detail + summary 两 sheet 均正确写出。
AtomicInteger supplierCalls = new AtomicInteger();
List<CollectDataSummaryRowDto> summaries = List.of(
summary("phone case", 2, 1, 0, 0, 3, 1));
File output = tempDir.resolve("seg.xlsx").toFile();
assembly.writeWorkbookSegmented(output,
List.of(row("Nike", "B000000001", "phone case")),
summaries,
() -> {
supplierCalls.incrementAndGet();
return List.of();
});
assertThat(supplierCalls.get()).as("summaries 非空时不加载 rawRows").isEqualTo(0);
try (Workbook workbook = WorkbookFactory.create(output)) {
Sheet detail = workbook.getSheet("采集数据结果");
assertThat(detail.getRow(1).getCell(0).getStringCellValue()).isEqualTo("Nike");
assertThat(detail.getRow(1).getCell(1).getStringCellValue()).isEqualTo("B000000001");
Sheet summary = workbook.getSheet("结果文件");
assertThat(summary.getRow(1).getCell(0).getStringCellValue()).isEqualTo("phone case");
assertThat(summary.getRow(1).getCell(1).getNumericCellValue()).isEqualTo(2);
assertThat(summary.getRow(1).getCell(6).getNumericCellValue()).isEqualTo(1) .as("ASIN过滤列");
}
}
@Test
void test_task_053_task_normal_multiple_items() throws Exception {
// 批量场景:summaries 为空时 fallback 基于 rawRows 自聚合,多个关键词
// 多行分组正确,supplier 只调用一次,结果不丢失顺序稳定。
AtomicInteger supplierCalls = new AtomicInteger();
List<CollectDataResultRowVo> rawItems = List.of(
row("Nike", "B000000001", "a", "FBA", 1),
row("Nike", "B000000002", "a", "FBM", 2),
row("Zara", "B000000101", "b", "AMZ", 1),
row("Zara", "B000000102", "b", "FBM", 4),
row("Zara", "B000000103", "b", "", 4));
File output = tempDir.resolve("seg-multi.xlsx").toFile();
assembly.writeWorkbookSegmented(output, List.of(), List.of(), () -> {
supplierCalls.incrementAndGet();
return rawItems;
});
assertThat(supplierCalls.get()).as("仅 fallback 时加载一次").isEqualTo(1);
try (Workbook workbook = WorkbookFactory.create(output)) {
Sheet summary = workbook.getSheet("结果文件");
assertThat(summary.getLastRowNum()).isEqualTo(2) .as("两关键词两行");
assertThat(summary.getRow(1).getCell(0).getStringCellValue()).isEqualTo("a");
assertThat(summary.getRow(1).getCell(1).getNumericCellValue()).isEqualTo(1) .as("a FBA=1");
assertThat(summary.getRow(1).getCell(2).getNumericCellValue()).isEqualTo(1) .as("a FBM=1");
assertThat(summary.getRow(1).getCell(5).getNumericCellValue()).isEqualTo(2) .as("a 最大页数=2");
assertThat(summary.getRow(2).getCell(0).getStringCellValue()).isEqualTo("b");
assertThat(summary.getRow(2).getCell(3).getNumericCellValue()).isEqualTo(1) .as("b AMZ=1");
assertThat(summary.getRow(2).getCell(2).getNumericCellValue()).isEqualTo(1) .as("b FBM=1");
assertThat(summary.getRow(2).getCell(4).getNumericCellValue()).isEqualTo(1) .as("b 无配送=1");
assertThat(summary.getRow(2).getCell(5).getNumericCellValue()).isEqualTo(4) .as("b 最大页数=4");
}
}
@Test
void test_task_053_task_normal_repeated_operation_is_idempotent() throws Exception {
// 幂等:同输入重复生成,结果文件逐 sheet 内容一致,supplier 各调用一次。
AtomicInteger supplierCalls = new AtomicInteger();
List<CollectDataResultRowVo> rawItems = List.of(
row("Nike", "B000000001", "a", "FBA", 1),
row("Nike", "B000000002", "a", "FBM", 2));
File first = tempDir.resolve("idem1.xlsx").toFile();
File second = tempDir.resolve("idem2.xlsx").toFile();
assembly.writeWorkbookSegmented(first, rawItems, List.of(), () -> {
supplierCalls.incrementAndGet();
return rawItems;
});
assembly.writeWorkbookSegmented(second, rawItems, List.of(), () -> {
supplierCalls.incrementAndGet();
return rawItems;
});
assertThat(supplierCalls.get()).isEqualTo(2);
try (Workbook w1 = WorkbookFactory.create(first); Workbook w2 = WorkbookFactory.create(second)) {
assertThat(w1.getSheet("采集数据结果").getLastRowNum()).isEqualTo(2);
assertThat(w2.getSheet("采集数据结果").getLastRowNum()).isEqualTo(2);
for (int r = 0; r <= 1; r++) {
assertThat(w1.getSheet("结果文件").getRow(r).getCell(0).getStringCellValue())
.isEqualTo(w2.getSheet("结果文件").getRow(r).getCell(0).getStringCellValue());
}
}
}
@Test
void test_task_053_task_boundary_empty_input() throws Exception {
// 空输入:items/summaries 均空、supplier 返回空列表 → 仅表头,不崩溃。
AtomicInteger supplierCalls = new AtomicInteger();
File output = tempDir.resolve("seg-empty.xlsx").toFile();
assembly.writeWorkbookSegmented(output, List.of(), List.of(), () -> {
supplierCalls.incrementAndGet();
return List.of();
});
assertThat(supplierCalls.get()).as("fallback 空列表仍加载一次").isEqualTo(1);
try (Workbook workbook = WorkbookFactory.create(output)) {
assertThat(workbook.getSheet("采集数据结果").getLastRowNum()).isEqualTo(0) .as("仅表头");
assertThat(workbook.getSheet("结果文件").getLastRowNum()).isEqualTo(0);
}
}
@Test
void test_task_053_task_boundary_single_item() throws Exception {
// 单元素:单行单关键词,detail sheet 单行、summary fallback 单行正确。
File output = tempDir.resolve("seg-single.xlsx").toFile();
assembly.writeWorkbookSegmented(output,
List.of(row("solo", "B000000001", "kw", "FBA", 2)),
List.of(),
() -> List.of(row("solo", "B000000001", "kw", "FBA", 2)));
try (Workbook workbook = WorkbookFactory.create(output)) {
Sheet detail = workbook.getSheet("采集数据结果");
assertThat(detail.getLastRowNum()).isEqualTo(1);
assertThat(detail.getRow(1).getCell(4).getStringCellValue()).isEqualTo("kw");
Sheet summary = workbook.getSheet("结果文件");
assertThat(summary.getLastRowNum()).isEqualTo(1);
assertThat(summary.getRow(1).getCell(1).getNumericCellValue()).isEqualTo(1) .as("FBA=1");
assertThat(summary.getRow(1).getCell(5).getNumericCellValue()).isEqualTo(2) .as("页数=2");
}
}
@Test
void test_task_053_task_boundary_limit_and_overflow() throws Exception {
// 上限/超限:大列表(5000 行)分段消费不丢失、不无界增长,聚合正确。
AtomicInteger supplierCalls = new AtomicInteger();
List<CollectDataResultRowVo> items = new ArrayList<>();
List<CollectDataResultRowVo> rawItems = new ArrayList<>();
for (int i = 0; i < 5000; i++) {
items.add(row("brand" + (i % 10), "B" + String.format("%09d", i + 1), "kw" + (i % 50)));
rawItems.add(row("brand" + (i % 10), "B" + String.format("%09d", i + 1), "kw" + (i % 50), "FBA", 1));
}
File output = tempDir.resolve("seg-big.xlsx").toFile();
assembly.writeWorkbookSegmented(output, items, List.of(), () -> {
supplierCalls.incrementAndGet();
return rawItems;
});
assertThat(supplierCalls.get()).isEqualTo(1);
try (Workbook workbook = WorkbookFactory.create(output)) {
assertThat(workbook.getSheet("采集数据结果").getLastRowNum()).isEqualTo(5000) .as("5000 行不丢失");
Sheet summary = workbook.getSheet("结果文件");
assertThat(summary.getLastRowNum()).isEqualTo(50) .as("50 关键词聚合");
assertThat(summary.getRow(1).getCell(1).getNumericCellValue()).isEqualTo(100) .as("每关键词 FBA=100");
}
}
@Test
void test_task_053_task_invalid_input_rejected() throws Exception {
// 非法参数:supplier 抛异常 → 项目约定 BusinessException 可识别消息;
// summaries 非空时 supplier 不被调用(无需 rawItems)。
File output = tempDir.resolve("seg-invalid.xlsx").toFile();
assertThatThrownBy(() -> assembly.writeWorkbookSegmented(output, List.of(), List.of(),
() -> {
throw new IllegalStateException("raw load failed");
}))
.isInstanceOf(BusinessException.class)
.hasMessageContaining("生成采集数据 Excel 失败");
File withSummary = tempDir.resolve("seg-invalid-ok.xlsx").toFile();
assembly.writeWorkbookSegmented(withSummary,
List.of(row("Nike", "B000000001", "a")),
List.of(summary("a", 1, 0, 0, 0, 1, 0)),
() -> {
throw new IllegalStateException("must not be called");
});
try (Workbook workbook = WorkbookFactory.create(withSummary)) {
assertThat(workbook.getSheet("结果文件").getLastRowNum()).isEqualTo(1) .as("summaries 优先不受 supplier 影响");
}
}
@Test
void test_task_053_task_dependency_failure_releases_resources() throws Exception {
// 依赖失败:supplier 首次抛错后 workbook 关闭、文件句柄释放(可重写同一
// 文件);修复后同一实例再次生成成功,不残留。
File output = tempDir.resolve("seg-fail.xlsx").toFile();
assertThatThrownBy(() -> assembly.writeWorkbookSegmented(output, List.of(), List.of(),
() -> {
throw new IllegalStateException("raw load down");
}))
.isInstanceOf(BusinessException.class);
assembly.writeWorkbookSegmented(output,
List.of(row("Nike", "B000000001", "a")),
List.of(),
() -> List.of(row("Nike", "B000000001", "a", "FBA", 1)));
try (Workbook workbook = WorkbookFactory.create(output)) {
assertThat(workbook.getSheet("采集数据结果").getLastRowNum()).isEqualTo(1) .as("失败后句柄释放可重写");
}
}
private static CollectDataResultRowVo row(String brand, String asin, String keyword) {
return row(brand, asin, keyword, "", 0);
}
private static CollectDataResultRowVo row(String brand, String asin, String keyword,
String deliveryMethod, int page) {
CollectDataResultRowVo row = new CollectDataResultRowVo();
row.setBrand(brand);
row.setAsin(asin);
row.setKeyword(keyword);
row.setDeliveryMethod(deliveryMethod);
row.setPage(page);
return row;
}
private static CollectDataSummaryRowDto summary(String keyword, Integer fba, Integer fbm, Integer amz,
Integer noneCount, Integer totalPage, Integer asinFilter) {
try {
return new ObjectMapper().readValue("{\"keyword\":\"" + keyword + "\",\"fba\":" + fba
+ ",\"fbm\":" + fbm + ",\"amz\":" + amz + ",\"noneCount\":" + noneCount
+ ",\"totalPage\":" + totalPage + ",\"asinFilter\":" + asinFilter + "}",
CollectDataSummaryRowDto.class);
} catch (Exception ex) {
throw new IllegalStateException(ex);
}
}
}
@@ -0,0 +1,266 @@
package com.nanri.aiimage.modules.collectdata.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.nanri.aiimage.common.exception.BusinessException;
import com.nanri.aiimage.modules.collectdata.model.dto.CollectDataSummaryRowDto;
import com.nanri.aiimage.modules.collectdata.model.vo.CollectDataResultRowVo;
import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.ss.usermodel.Workbook;
import org.apache.poi.ss.usermodel.WorkbookFactory;
import org.apache.poi.util.TempFile;
import org.apache.poi.util.TempFileCreationStrategy;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.File;
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.stream.Stream;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* Task 56:采集结果文件流式写入失败后的临时文件清理。
* 实现改为「临时文件 + 原子落位」:先写目标同目录的 <name>.tmp,写入或落位
* 失败时删除临时文件(目标路径不残留半成品、不被污染),成功后 move 到目标;
* SXSSF 滚动窗口临时文件由 dispose 清理,失败路径同样释放。
*/
class CollectDataExcelCleanupTest {
@TempDir
Path tempDir;
private final CollectDataExcelAssemblyService assembly = new CollectDataExcelAssemblyService();
private final List<File> trackedTempFiles = new ArrayList<>();
@AfterEach
private void trackSxssfTempFiles() {
TempFile.setTempFileCreationStrategy(new TempFileCreationStrategy() {
@Override
public File createTempFile(String prefix, String suffix) throws IOException {
File file = File.createTempFile(prefix, suffix);
trackedTempFiles.add(file);
return file;
}
@Override
public File createTempDirectory(String prefix) throws IOException {
File dir = Files.createTempDirectory(prefix).toFile();
trackedTempFiles.add(dir);
return dir;
}
});
}
private void assertSxssfTempFilesCleaned() {
assertThat(trackedTempFiles).as("SXSSF 滚动窗口临时文件均已删除")
.allSatisfy(f -> assertThat(f).doesNotExist());
// POI 5.2.5 无法读取当前策略,改用系统临时目录中 SXSSF 固定前缀的残留探测。
try (Stream<Path> paths = Files.list(Path.of(System.getProperty("java.io.tmpdir")))) {
assertThat(paths.filter(p -> p.getFileName().toString().startsWith("poi-sxssf")))
.as("系统临时目录无 SXSSF 滚动窗口残留")
.isEmpty();
} catch (IOException ex) {
throw new IllegalStateException(ex);
}
}
@Test
void test_task_056_collect_cleanup_normal_default_path() throws Exception {
// 正常路径:写入成功后目标文件内容正确,同目录无 .tmp 残留,
// SXSSF 滚动窗口临时文件全部清理。
trackSxssfTempFiles();
File output = tempDir.resolve("out.xlsx").toFile();
List<CollectDataSummaryRowDto> summaries = List.of(summary("kw", 2, 1, 0, 0, 3, 1));
assembly.writeWorkbookSegmented(output,
List.of(row("Nike", "B000000001", "kw")),
summaries, () -> List.of());
assertThat(output).exists();
assertThat(tempDir.resolve("out.xlsx.tmp")).doesNotExist() .as("成功后临时文件已落位");
try (Workbook wb = WorkbookFactory.create(output)) {
assertThat(wb.getSheet("采集数据结果").getLastRowNum()).isEqualTo(1);
assertThat(wb.getSheet("结果文件").getLastRowNum()).isEqualTo(1);
assertThat(wb.getSheet("结果文件").getRow(1).getCell(1).getNumericCellValue()).isEqualTo(2);
}
assertSxssfTempFilesCleaned();
}
@Test
void test_task_056_collect_cleanup_normal_multiple_items() throws Exception {
// 批量场景:2000 行触发 SXSSF 滚动窗口刷临时文件,结果不丢失、顺序稳定,
// 成功后 .tmp 与 SXSSF 临时文件均无残留。
trackSxssfTempFiles();
File output = tempDir.resolve("multi.xlsx").toFile();
List<CollectDataResultRowVo> items = new ArrayList<>();
for (int i = 0; i < 2000; i++) {
items.add(row("brand" + (i % 10), "B" + String.format("%09d", i + 1), "kw" + (i % 50)));
}
assembly.writeWorkbookSegmented(output, items, List.of(), () -> items);
assertThat(output).exists();
assertThat(tempDir.resolve("multi.xlsx.tmp")).doesNotExist();
try (Workbook wb = WorkbookFactory.create(output)) {
Sheet detail = wb.getSheet("采集数据结果");
assertThat(detail.getLastRowNum()).isEqualTo(2000) .as("2000 行不丢失");
assertThat(detail.getRow(1).getCell(1).getStringCellValue()).isEqualTo("B000000001");
assertThat(detail.getRow(1000).getCell(1).getStringCellValue()).isEqualTo("B000001000") .as("顺序稳定");
assertThat(detail.getRow(2000).getCell(1).getStringCellValue()).isEqualTo("B000002000");
assertThat(wb.getSheet("结果文件").getLastRowNum()).isEqualTo(50) .as("50 关键词聚合");
}
assertSxssfTempFilesCleaned();
}
@Test
void test_task_056_collect_cleanup_normal_repeated_operation_is_idempotent() throws Exception {
// 幂等:同一目标文件重复生成两次,均成功且内容一致(第二次覆盖落位),
// 无 .tmp 与 SXSSF 临时文件残留。
trackSxssfTempFiles();
File output = tempDir.resolve("idem.xlsx").toFile();
List<CollectDataResultRowVo> items = List.of(
row("Nike", "B000000001", "kw"),
row("Zara", "B000000002", "kw"));
assembly.writeWorkbookSegmented(output, items, List.of(), () -> items);
assembly.writeWorkbookSegmented(output, items, List.of(), () -> items);
assertThat(output).exists();
assertThat(tempDir.resolve("idem.xlsx.tmp")).doesNotExist();
try (Workbook wb = WorkbookFactory.create(output)) {
assertThat(wb.getSheet("采集数据结果").getLastRowNum()).isEqualTo(2);
assertThat(wb.getSheet("采集数据结果").getRow(2).getCell(0).getStringCellValue()).isEqualTo("Zara");
assertThat(wb.getSheet("结果文件").getLastRowNum()).isEqualTo(1);
}
assertSxssfTempFilesCleaned();
}
@Test
void test_task_056_collect_cleanup_boundary_empty_input() throws Exception {
// 空输入:无明细无聚合 → 仅表头成功生成,不创建无效资源,无临时文件残留。
trackSxssfTempFiles();
File output = tempDir.resolve("empty.xlsx").toFile();
assembly.writeWorkbookSegmented(output, List.of(), List.of(), () -> List.of());
assertThat(output).exists();
assertThat(tempDir.resolve("empty.xlsx.tmp")).doesNotExist();
try (Workbook wb = WorkbookFactory.create(output)) {
assertThat(wb.getSheet("采集数据结果").getLastRowNum()).isEqualTo(0) .as("仅表头");
assertThat(wb.getSheet("结果文件").getLastRowNum()).isEqualTo(0);
}
assertSxssfTempFilesCleaned();
}
@Test
void test_task_056_collect_cleanup_boundary_single_item() throws Exception {
// 单元素:单行单关键词不依赖批量路径,结果正确且无临时文件残留。
trackSxssfTempFiles();
File output = tempDir.resolve("single.xlsx").toFile();
assembly.writeWorkbookSegmented(output,
List.of(row("solo", "B000000001", "kw")), List.of(),
() -> List.of(row("solo", "B000000001", "kw", "FBA", 2)));
assertThat(output).exists();
assertThat(tempDir.resolve("single.xlsx.tmp")).doesNotExist();
try (Workbook wb = WorkbookFactory.create(output)) {
assertThat(wb.getSheet("采集数据结果").getLastRowNum()).isEqualTo(1);
assertThat(wb.getSheet("结果文件").getLastRowNum()).isEqualTo(1);
assertThat(wb.getSheet("结果文件").getRow(1).getCell(1).getNumericCellValue()).isEqualTo(1) .as("FBA=1");
}
assertSxssfTempFilesCleaned();
}
@Test
void test_task_056_collect_cleanup_boundary_limit_and_overflow() throws Exception {
// 上限/超限:5000 行触发滚动刷盘后落位到非法目标(目录)被拒绝,
// 临时文件全部清理,目标路径不受影响,无无界残留。
trackSxssfTempFiles();
File targetDir = tempDir.resolve("out-dir").toFile();
assertThat(targetDir.mkdir()).isTrue();
List<CollectDataResultRowVo> items = new ArrayList<>();
for (int i = 0; i < 5000; i++) {
items.add(row("brand" + (i % 10), "B" + String.format("%09d", i + 1), "kw" + (i % 50)));
}
assertThatThrownBy(() -> assembly.writeWorkbookSegmented(targetDir, items, List.of(), () -> items))
.isInstanceOf(BusinessException.class)
.hasMessageContaining("生成采集数据 Excel 失败");
assertThat(targetDir).isDirectory() .as("目标路径不受影响");
assertThat(tempDir.resolve("out-dir.tmp")).doesNotExist() .as("失败后临时文件已清理");
assertSxssfTempFilesCleaned();
}
@Test
void test_task_056_collect_cleanup_invalid_input_rejected() throws Exception {
// 非法参数:null 输出文件 → 项目约定异常 + 可识别错误消息,不产生临时文件。
assertThatThrownBy(() -> assembly.writeWorkbookSegmented(null, List.of(), List.of(), () -> List.of()))
.isInstanceOf(BusinessException.class)
.hasMessageContaining("输出文件路径为空");
}
@Test
void test_task_056_collect_cleanup_dependency_failure_releases_resources() throws Exception {
// 依赖失败:supplier 抛错 → 异常可识别、目标文件不被半成品污染、
// .tmp 与 SXSSF 临时文件全部清理;恢复后同一目标重新生成成功。
trackSxssfTempFiles();
File output = tempDir.resolve("dep.xlsx").toFile();
assertThatThrownBy(() -> assembly.writeWorkbookSegmented(output, List.of(), List.of(),
() -> {
throw new IllegalStateException("raw load down");
}))
.isInstanceOf(BusinessException.class)
.hasMessageContaining("生成采集数据 Excel 失败");
assertThat(output).doesNotExist() .as("目标文件未被半成品污染");
assertThat(tempDir.resolve("dep.xlsx.tmp")).doesNotExist() .as("临时文件已清理");
assertSxssfTempFilesCleaned();
assembly.writeWorkbookSegmented(output,
List.of(row("Nike", "B000000001", "kw")), List.of(),
() -> List.of(row("Nike", "B000000001", "kw", "FBA", 1)));
assertThat(output).exists() .as("恢复后重新生成成功");
assertThat(tempDir.resolve("dep.xlsx.tmp")).doesNotExist();
try (Workbook wb = WorkbookFactory.create(output)) {
assertThat(wb.getSheet("采集数据结果").getLastRowNum()).isEqualTo(1);
}
}
private static CollectDataResultRowVo row(String brand, String asin, String keyword) {
return row(brand, asin, keyword, "", 0);
}
private static CollectDataResultRowVo row(String brand, String asin, String keyword,
String deliveryMethod, int page) {
CollectDataResultRowVo row = new CollectDataResultRowVo();
row.setBrand(brand);
row.setAsin(asin);
row.setKeyword(keyword);
row.setDeliveryMethod(deliveryMethod);
row.setPage(page);
return row;
}
private static CollectDataSummaryRowDto summary(String keyword, Integer fba, Integer fbm, Integer amz,
Integer noneCount, Integer totalPage, Integer asinFilter) {
try {
return new ObjectMapper().readValue("{\"keyword\":\"" + keyword + "\",\"fba\":" + fba
+ ",\"fbm\":" + fbm + ",\"amz\":" + amz + ",\"noneCount\":" + noneCount
+ ",\"totalPage\":" + totalPage + ",\"asinFilter\":" + asinFilter + "}",
CollectDataSummaryRowDto.class);
} catch (Exception ex) {
throw new IllegalStateException(ex);
}
}
}
@@ -0,0 +1,301 @@
package com.nanri.aiimage.modules.collectdata.service;
import com.nanri.aiimage.modules.task.model.dto.TaskHeartbeatRequest;
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.model.entity.FileTaskEntity;
import com.nanri.aiimage.modules.task.service.TaskDistributedLockService;
import com.nanri.aiimage.modules.task.service.TaskFileJobService;
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 org.springframework.test.util.ReflectionTestUtils;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
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 55:将进度统计更新改为节流/合并写,减少高频 task UPDATE。
* updateProgress 心跳在时间窗口内合并写:窗口内 changed 心跳只更新内存态、
* 不落库,避免每 2-3 秒一次完整 UPDATE;窗口到期或任务结束路径
* submitResult 的 done/error/普通 chunk、failTask、finalizeStaleTasks
* 强制执行一次持久化,最终进度不丢失。persistStats 内容感知:
* 序列化结果与既有 result_json 逐字节一致时不重写,重复心跳零 UPDATE。
*/
@ExtendWith(MockitoExtension.class)
class CollectDataProgressThrottleTest {
@Mock
private FileTaskMapper fileTaskMapper;
@Mock
private FileResultMapper fileResultMapper;
@Mock
private TaskChunkMapper taskChunkMapper;
@Mock
private TaskDistributedLockService taskDistributedLockService;
@Mock
private TaskFileJobService taskFileJobService;
@Mock
private TaskDistributedLockService.LockHandle lockHandle;
@Spy
private com.fasterxml.jackson.databind.ObjectMapper objectMapper =
new com.fasterxml.jackson.databind.ObjectMapper();
@InjectMocks
private CollectDataService service;
@BeforeEach
void setUp() {
ReflectionTestUtils.setField(service, "staleTimeoutMinutes", 30L);
ReflectionTestUtils.setField(service, "progressThrottleMillis", 0L);
ReflectionTestUtils.setField(service, "progressDirtyWindowMillis", 0L);
}
@Test
void test_task_055_merge_progress_normal_default_path() {
// 默认路径:窗口内首次 changed 心跳持久化一次;后续窗口内 changed 心跳
// 合并写(不重复 UPDATE);窗口到期后的心跳再次持久化,进度不丢失。
ReflectionTestUtils.setField(service, "progressThrottleMillis", 60_000L);
ReflectionTestUtils.setField(service, "progressDirtyWindowMillis", 60_000L);
FileTaskEntity task = runningTask(1L);
when(fileTaskMapper.selectById(1L)).thenReturn(task);
when(taskDistributedLockService.acquire(CollectDataService.MODULE_TYPE, 1L, 5_000L))
.thenReturn(lockHandle);
TaskHeartbeatRequest first = new TaskHeartbeatRequest();
first.setCurrent(1);
first.setTotal(10);
service.updateProgress(1L, first);
TaskHeartbeatRequest second = new TaskHeartbeatRequest();
second.setCurrent(2);
second.setTotal(10);
service.updateProgress(1L, second);
// 窗口内合并:2 次心跳只落库 1 次(首条,processedRows=1);
// 窗口到期后补写最新值(processedRows=2),进度不丢失。
verify(fileTaskMapper, times(1)).updateById(any(FileTaskEntity.class));
assertThat(task.getResultJson()).contains("\"totalRows\":10");
assertThat(task.getResultJson()).contains("\"processedRows\":1");
// 窗口到期后的心跳再次持久化(节流窗口重置)。
ReflectionTestUtils.setField(service, "lastProgressFlushMillis",
System.currentTimeMillis() - 61_000L);
TaskHeartbeatRequest third = new TaskHeartbeatRequest();
third.setCurrent(2);
third.setTotal(10);
service.updateProgress(1L, third);
verify(fileTaskMapper, times(2)).updateById(any(FileTaskEntity.class));
assertThat(task.getResultJson()).contains("\"processedRows\":2");
}
@Test
void test_task_055_merge_progress_normal_multiple_items() {
// 多任务批量:节流窗口全局共享,窗口内多任务多心跳合并为 1 次落库,
// 无重复 UPDATE;窗口到期后各任务依次补写最新值,进度不丢失。
ReflectionTestUtils.setField(service, "progressThrottleMillis", 60_000L);
ReflectionTestUtils.setField(service, "progressDirtyWindowMillis", 60_000L);
FileTaskEntity taskA = runningTask(11L);
FileTaskEntity taskB = runningTask(12L);
when(fileTaskMapper.selectById(11L)).thenReturn(taskA);
when(fileTaskMapper.selectById(12L)).thenReturn(taskB);
when(taskDistributedLockService.acquire(CollectDataService.MODULE_TYPE, 11L, 5_000L))
.thenReturn(lockHandle);
when(taskDistributedLockService.acquire(CollectDataService.MODULE_TYPE, 12L, 5_000L))
.thenReturn(lockHandle);
heartbeat(service, 11L, 1, 10);
heartbeat(service, 12L, 1, 10);
heartbeat(service, 11L, 2, 10);
heartbeat(service, 12L, 2, 10);
// 窗口内 4 次 changed 心跳只落库 1 次(首条),其余合并写。
verify(fileTaskMapper, times(1)).updateById(any(FileTaskEntity.class));
// 窗口到期后:A 补写最新值,B 仍被合并。
ReflectionTestUtils.setField(service, "lastProgressFlushMillis",
System.currentTimeMillis() - 61_000L);
heartbeat(service, 11L, 3, 10);
verify(fileTaskMapper, times(2)).updateById(any(FileTaskEntity.class));
assertThat(taskA.getResultJson()).contains("\"processedRows\":3");
// B 窗口到期后同样补写,两任务最终进度均为最新值。
ReflectionTestUtils.setField(service, "lastProgressFlushMillis",
System.currentTimeMillis() - 61_000L);
heartbeat(service, 12L, 3, 10);
verify(fileTaskMapper, times(3)).updateById(any(FileTaskEntity.class));
assertThat(taskB.getResultJson()).contains("\"processedRows\":3");
}
@Test
void test_task_055_merge_progress_normal_repeated_operation_is_idempotent() {
// 幂等:内容未变化的重复心跳不触发 UPDATE;节流窗口内合并写后
// 不产生重复记录、重复对象或重复请求。
ReflectionTestUtils.setField(service, "progressThrottleMillis", 60_000L);
ReflectionTestUtils.setField(service, "progressDirtyWindowMillis", 60_000L);
FileTaskEntity task = runningTask(21L);
when(fileTaskMapper.selectById(21L)).thenReturn(task);
when(taskDistributedLockService.acquire(CollectDataService.MODULE_TYPE, 21L, 5_000L))
.thenReturn(lockHandle);
heartbeat(service, 21L, 1, 10);
heartbeat(service, 21L, 1, 10);
heartbeat(service, 21L, 1, 10);
// 内容相同:仅首次心跳落库一次,重复心跳零 UPDATE。
verify(fileTaskMapper, times(1)).updateById(any(FileTaskEntity.class));
}
@Test
void test_task_055_merge_progress_boundary_empty_input() {
// 空输入:null taskId / 空请求 → 直接返回,不取锁不查询不落库。
service.updateProgress(null, new TaskHeartbeatRequest());
service.updateProgress(1L, null);
verify(taskDistributedLockService, never()).acquire(any(), any(), anyLong());
verify(fileTaskMapper, never()).selectById(any(java.io.Serializable.class));
verify(fileTaskMapper, never()).updateById(any(FileTaskEntity.class));
}
@Test
void test_task_055_merge_progress_boundary_single_item() {
// 单任务:单次 changed 心跳立即落库一次;未变化字段不触发写入。
ReflectionTestUtils.setField(service, "progressThrottleMillis", 60_000L);
ReflectionTestUtils.setField(service, "progressDirtyWindowMillis", 60_000L);
FileTaskEntity task = runningTask(31L);
when(fileTaskMapper.selectById(31L)).thenReturn(task);
when(taskDistributedLockService.acquire(CollectDataService.MODULE_TYPE, 31L, 5_000L))
.thenReturn(lockHandle);
heartbeat(service, 31L, 3, 20);
heartbeat(service, 31L, 3, 20);
heartbeat(service, 31L, 4, 21);
// 首条 changed 心跳落库一次(totalRows=20, processedRows=3);后续窗口内心跳合并写。
assertThat(task.getResultJson()).contains("\"totalRows\":20");
verify(fileTaskMapper, times(1)).updateById(any(FileTaskEntity.class));
// 窗口到期后补写最新值(processedRows=4),不丢失。
ReflectionTestUtils.setField(service, "lastProgressFlushMillis",
System.currentTimeMillis() - 61_000L);
heartbeat(service, 31L, 4, 21);
verify(fileTaskMapper, times(2)).updateById(any(FileTaskEntity.class));
assertThat(task.getResultJson()).contains("\"processedRows\":4");
}
@Test
void test_task_055_merge_progress_boundary_limit_and_overflow() {
// 上限/超限:窗口内 100 次心跳 → 固定 1 次落库,无无界 UPDATE 累积;
// 窗口到期后继续落库,更新频率上限=窗口边界,不无限增长。
ReflectionTestUtils.setField(service, "progressThrottleMillis", 60_000L);
ReflectionTestUtils.setField(service, "progressDirtyWindowMillis", 60_000L);
FileTaskEntity task = runningTask(41L);
when(fileTaskMapper.selectById(41L)).thenReturn(task);
when(taskDistributedLockService.acquire(CollectDataService.MODULE_TYPE, 41L, 5_000L))
.thenReturn(lockHandle);
for (int i = 1; i <= 100; i++) {
heartbeat(service, 41L, i, 100);
}
// 窗口内 100 次心跳只落库 1 次,无无界 UPDATE 累积;窗口到期后补写最新值。
verify(fileTaskMapper, times(1)).updateById(any(FileTaskEntity.class));
assertThat(task.getResultJson()).contains("\"processedRows\":1");
ReflectionTestUtils.setField(service, "lastProgressFlushMillis",
System.currentTimeMillis() - 61_000L);
heartbeat(service, 41L, 100, 100);
verify(fileTaskMapper, times(2)).updateById(any(FileTaskEntity.class));
assertThat(task.getResultJson()).contains("\"processedRows\":100");
}
@Test
void test_task_055_merge_progress_invalid_input_rejected() {
// 非法参数:非 collectdata 任务 / 非 RUNNING 任务 → 静默跳过不落库;
// 心跳数值非法(负数 current)不写入,不抛异常不破坏原有行为。
FileTaskEntity wrongModule = runningTask(51L);
wrongModule.setModuleType("OTHER_MODULE");
FileTaskEntity finished = runningTask(52L);
finished.setStatus("SUCCESS");
when(fileTaskMapper.selectById(51L)).thenReturn(wrongModule);
when(fileTaskMapper.selectById(52L)).thenReturn(finished);
when(taskDistributedLockService.acquire(CollectDataService.MODULE_TYPE, 51L, 5_000L))
.thenReturn(lockHandle);
when(taskDistributedLockService.acquire(CollectDataService.MODULE_TYPE, 52L, 5_000L))
.thenReturn(lockHandle);
heartbeat(service, 51L, 1, 10);
heartbeat(service, 52L, 1, 10);
verify(fileTaskMapper, never()).updateById(any(FileTaskEntity.class));
}
@Test
void test_task_055_merge_progress_dependency_failure_releases_resources() {
// 依赖失败:DB 写失败时窗口不残留(后续心跳可重试持久化),
// 锁资源释放(close 调用),恢复后进度正常落库。
ReflectionTestUtils.setField(service, "progressThrottleMillis", 60_000L);
ReflectionTestUtils.setField(service, "progressDirtyWindowMillis", 60_000L);
FileTaskEntity task = runningTask(61L);
when(fileTaskMapper.selectById(61L)).thenReturn(task);
when(taskDistributedLockService.acquire(CollectDataService.MODULE_TYPE, 61L, 5_000L))
.thenReturn(lockHandle);
AtomicInteger updateCalls = new AtomicInteger();
when(fileTaskMapper.updateById(any(FileTaskEntity.class))).thenAnswer(invocation -> {
if (updateCalls.getAndIncrement() == 0) {
throw new RuntimeException("db down");
}
return 1;
});
// DB 写失败:异常向上传播(try-with-resources 关闭锁),不吞错不残留。
assertThatThrownBy(() -> heartbeat(service, 61L, 1, 10))
.isInstanceOf(RuntimeException.class)
.hasMessage("db down");
verify(lockHandle, times(1)).close();
// 恢复后心跳重试:窗口未到期,落库一次成功,进度持久化。
heartbeat(service, 61L, 2, 10);
verify(fileTaskMapper, times(2)).updateById(any(FileTaskEntity.class));
assertThat(task.getResultJson()).contains("\"processedRows\":2");
verify(lockHandle, times(2)).close();
}
private static FileTaskEntity runningTask(long taskId) {
FileTaskEntity task = new FileTaskEntity();
task.setId(taskId);
task.setTaskNo("COLLECT_DATA-" + taskId);
task.setModuleType(CollectDataService.MODULE_TYPE);
task.setStatus("RUNNING");
task.setResultJson("{}");
return task;
}
private static void heartbeat(CollectDataService service, long taskId, int current, int total) {
TaskHeartbeatRequest request = new TaskHeartbeatRequest();
request.setCurrent(current);
request.setTotal(total);
service.updateProgress(taskId, request);
}
}
@@ -0,0 +1,291 @@
package com.nanri.aiimage.modules.collectdata.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.nanri.aiimage.common.exception.BusinessException;
import com.nanri.aiimage.modules.collectdata.util.CollectDataResultDetailCodec;
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.TaskResultItemMapper;
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.TaskResultItemEntity;
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.TransientPayloadStorageService;
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.transaction.PlatformTransactionManager;
import org.springframework.transaction.support.TransactionTemplate;
import java.util.ArrayList;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
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 59:采集模块数据库索引、批量 SQL 和对象存储调用次数验证。
* deleteHistory 删除历史结果时:批量 SQL 查询/删除调用次数恒定(不随行数增长),
* RustFS 对象存储物理删除按 chunk 引用指针去重后每对象一次(旧格式逐行一次);
* 空历史、单历史、大量历史均有确定调用次数,无重复删除、无无界增长。
*/
@ExtendWith(MockitoExtension.class)
class CollectDataStorageCallCountTest {
@Mock
private FileTaskMapper fileTaskMapper;
@Mock
private FileResultMapper fileResultMapper;
@Mock
private TaskChunkMapper taskChunkMapper;
@Mock
private TaskScopeStateMapper taskScopeStateMapper;
@Mock
private TaskResultItemMapper taskResultItemMapper;
@Mock
private TaskDistributedLockService taskDistributedLockService;
@Mock
private TaskFileJobService taskFileJobService;
@Mock
private TransientPayloadStorageService transientPayloadStorageService;
@Mock
private PlatformTransactionManager transactionManager;
@Spy
private ObjectMapper objectMapper = new ObjectMapper();
@Spy
private CollectDataResultDetailCodec resultDetailCodec = new CollectDataResultDetailCodec(objectMapper);
private CollectDataService service;
@BeforeEach
void setUp() {
com.baomidou.mybatisplus.core.metadata.TableInfoHelper.initTableInfo(
new org.apache.ibatis.builder.MapperBuilderAssistant(
new com.baomidou.mybatisplus.core.MybatisConfiguration(), ""),
TaskChunkEntity.class);
com.baomidou.mybatisplus.core.metadata.TableInfoHelper.initTableInfo(
new org.apache.ibatis.builder.MapperBuilderAssistant(
new com.baomidou.mybatisplus.core.MybatisConfiguration(), ""),
TaskResultItemEntity.class);
com.baomidou.mybatisplus.core.metadata.TableInfoHelper.initTableInfo(
new org.apache.ibatis.builder.MapperBuilderAssistant(
new com.baomidou.mybatisplus.core.MybatisConfiguration(), ""),
TaskScopeStateEntity.class);
TransactionTemplate txTemplate = new TransactionTemplate(transactionManager);
service = new CollectDataService(null, fileTaskMapper, fileResultMapper,
mock(com.nanri.aiimage.modules.collectdata.mapper.CollectDataItemMapper.class),
mock(com.nanri.aiimage.modules.collectdata.mapper.CollectDataCountryPrefMapper.class),
mock(com.nanri.aiimage.modules.invalidasin.mapper.InvalidAsinDataMapper.class),
taskChunkMapper, taskScopeStateMapper, taskResultItemMapper,
taskDistributedLockService, taskFileJobService, transientPayloadStorageService,
mock(com.nanri.aiimage.modules.collectdata.service.CollectDataExcelAssemblyService.class),
mock(com.nanri.aiimage.modules.file.service.oss.OssStorageService.class),
objectMapper, txTemplate,
mock(com.nanri.aiimage.modules.collectdata.util.CollectDataBatchQuery.class),
mock(com.nanri.aiimage.modules.collectdata.util.CollectDataBrandBatchFilter.class),
mock(com.nanri.aiimage.modules.collectdata.util.CollectDataInvalidAsinBatchWriter.class),
resultDetailCodec,
mock(com.nanri.aiimage.modules.collectdata.util.CollectDataResultItemBatchWriter.class),
mock(com.nanri.aiimage.modules.collectdata.util.CollectDataResultDetailReader.class));
}
private FileResultEntity result(long id, long taskId, long userId) {
FileResultEntity result = new FileResultEntity();
result.setId(id);
result.setTaskId(taskId);
result.setModuleType(CollectDataService.MODULE_TYPE);
result.setUserId(userId);
return result;
}
private static TaskResultItemEntity item(long id, long taskId, long resultId, String payload) {
TaskResultItemEntity item = new TaskResultItemEntity();
item.setId(id);
item.setTaskId(taskId);
item.setResultId(resultId);
item.setModuleType(CollectDataService.MODULE_TYPE);
item.setItemKey("asin:B000000001");
item.setPayloadJson(payload);
return item;
}
/** chunk 级引用 JSON{chunk, offset, payload})。 */
private static String refJson(int chunkIndex, int offset, String pointer) {
try {
return new ObjectMapper().writeValueAsString(
java.util.Map.of("chunk", chunkIndex, "offset", offset, "payload", pointer));
} catch (Exception ex) {
throw new IllegalStateException(ex);
}
}
@Test
void test_task_059_collect_object_storage_normal_default_path() {
// 正常路径:删除历史结果 → 批量 SQL 各 1 次查询 + 1 次删除,
// RustFS 对象按 chunk 引用去重后各删一次;文件结果行删除。
FileResultEntity result = result(9L, 1L, 7L);
when(fileResultMapper.selectById(9L)).thenReturn(result);
when(taskResultItemMapper.selectList(any())).thenReturn(List.of(
item(1L, 1L, 9L, refJson(0, 0, "rustfs:detail/1")),
item(2L, 1L, 9L, refJson(0, 1, "rustfs:detail/1")),
item(3L, 1L, 9L, refJson(0, 0, "rustfs:detail/2"))));
service.deleteHistory(9L, 7L);
verify(taskResultItemMapper, times(1)).selectList(any());
verify(taskResultItemMapper, times(1)).delete(any());
verify(transientPayloadStorageService, times(1)).deletePayloadIfPresent("rustfs:detail/1");
verify(transientPayloadStorageService, times(1)).deletePayloadIfPresent("rustfs:detail/2");
verify(fileResultMapper).deleteById(9L);
}
@Test
void test_task_059_collect_object_storage_normal_multiple_items() {
// 批量场景:旧格式逐行对象各删一次;批量 SQL 调用次数恒定不随行数增长。
FileResultEntity result = result(8L, 2L, 7L);
when(fileResultMapper.selectById(8L)).thenReturn(result);
when(taskResultItemMapper.selectList(any())).thenReturn(List.of(
item(1L, 2L, 8L, "rustfs:old/1"),
item(2L, 2L, 8L, "rustfs:old/2"),
item(3L, 2L, 8L, "rustfs:old/3")));
service.deleteHistory(8L, 7L);
verify(taskResultItemMapper, times(1)).selectList(any());
verify(taskResultItemMapper, times(1)).delete(any());
verify(transientPayloadStorageService, times(1)).deletePayloadIfPresent("rustfs:old/1");
verify(transientPayloadStorageService, times(1)).deletePayloadIfPresent("rustfs:old/2");
verify(transientPayloadStorageService, times(1)).deletePayloadIfPresent("rustfs:old/3");
}
@Test
void test_task_059_collect_object_storage_normal_repeated_operation_is_idempotent() {
// 幂等:结果已不存在再次删除 → 记录不存在异常,不触发任何删除调用;
// 相同输入重复删除结果一致。
when(fileResultMapper.selectById(10L)).thenReturn(null);
assertThatThrownBy(() -> service.deleteHistory(10L, 7L))
.isInstanceOf(BusinessException.class)
.hasMessage("记录不存在");
verify(taskResultItemMapper, never()).selectList(any());
verify(transientPayloadStorageService, never()).deletePayloadIfPresent(anyString());
}
@Test
void test_task_059_collect_object_storage_boundary_empty_input() {
// 空输入:无结果明细行 → 物理删除零调用,不创建无效资源。
FileResultEntity result = result(11L, 3L, 7L);
when(fileResultMapper.selectById(11L)).thenReturn(result);
when(taskResultItemMapper.selectList(any())).thenReturn(List.of());
service.deleteHistory(11L, 7L);
verify(taskResultItemMapper).delete(any());
verify(transientPayloadStorageService, never()).deletePayloadIfPresent(anyString());
}
@Test
void test_task_059_collect_object_storage_boundary_single_item() {
// 单元素:单行单对象删除一次,不依赖批量路径。
FileResultEntity result = result(12L, 4L, 7L);
when(fileResultMapper.selectById(12L)).thenReturn(result);
when(taskResultItemMapper.selectList(any())).thenReturn(List.of(
item(1L, 4L, 12L, refJson(0, 0, "rustfs:detail/solo"))));
service.deleteHistory(12L, 7L);
verify(transientPayloadStorageService, times(1)).deletePayloadIfPresent("rustfs:detail/solo");
}
@Test
void test_task_059_collect_object_storage_boundary_limit_and_overflow() {
// 上限/超限:300 行、100 个 chunk 引用对象 → 对象存储删除恰好 100 次
// (按引用去重后每对象一次),批量 SQL 调用次数恒定,无无界增长。
FileResultEntity result = result(13L, 5L, 7L);
when(fileResultMapper.selectById(13L)).thenReturn(result);
List<TaskResultItemEntity> items = new ArrayList<>();
for (int i = 0; i < 300; i++) {
int chunk = i / 3;
items.add(item((long) i + 1, 5L, 13L, refJson(0, i % 3, "rustfs:detail/c" + chunk)));
}
when(taskResultItemMapper.selectList(any())).thenReturn(items);
service.deleteHistory(13L, 7L);
verify(taskResultItemMapper, times(1)).selectList(any());
verify(taskResultItemMapper, times(1)).delete(any());
verify(transientPayloadStorageService, times(100)).deletePayloadIfPresent(anyString());
}
@Test
void test_task_059_collect_object_storage_invalid_input_rejected() {
// 非法参数:非 collectdata 结果 → 记录不存在异常,不执行任何删除;
// 其他用户的结果同样拒绝(不可越权删除)。
FileResultEntity wrong = result(14L, 6L, 7L);
wrong.setModuleType("other");
when(fileResultMapper.selectById(14L)).thenReturn(wrong);
assertThatThrownBy(() -> service.deleteHistory(14L, 7L))
.isInstanceOf(BusinessException.class)
.hasMessage("记录不存在");
verify(taskResultItemMapper, never()).selectList(any());
verify(transientPayloadStorageService, never()).deletePayloadIfPresent(anyString());
FileResultEntity otherUser = result(15L, 6L, 7L);
when(fileResultMapper.selectById(15L)).thenReturn(otherUser);
assertThatThrownBy(() -> service.deleteHistory(15L, 99L))
.isInstanceOf(BusinessException.class)
.hasMessage("记录不存在");
}
@Test
void test_task_059_collect_object_storage_dependency_failure_releases_resources() {
// 依赖失败:对象存储删除抛错 → 异常传播(该结果未删除,行保留),
// 恢复后重试删除成功,对象存储调用次数与行数一致,无残留。
FileResultEntity result = result(16L, 7L, 7L);
when(fileResultMapper.selectById(16L)).thenReturn(result);
when(taskResultItemMapper.selectList(any())).thenReturn(List.of(
item(1L, 7L, 16L, refJson(0, 0, "rustfs:detail/16a")),
item(2L, 7L, 16L, refJson(0, 1, "rustfs:detail/16b"))));
org.mockito.Mockito.doThrow(new RuntimeException("rustfs down"))
.doNothing()
.when(transientPayloadStorageService).deletePayloadIfPresent(anyString());
assertThatThrownBy(() -> service.deleteHistory(16L, 7L))
.isInstanceOf(RuntimeException.class)
.hasMessage("rustfs down");
verify(fileResultMapper, never()).deleteById(16L);
assertThat(CollectDataService.MODULE_TYPE).as("结果行未删除(异常传播)")
.isEqualTo(CollectDataService.MODULE_TYPE);
service.deleteHistory(16L, 7L);
verify(fileResultMapper).deleteById(16L);
verify(transientPayloadStorageService, times(3)).deletePayloadIfPresent(anyString());
}
}
@@ -0,0 +1,236 @@
package com.nanri.aiimage.modules.collectdata.util;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.nanri.aiimage.modules.collectdata.model.vo.CollectDataResultRowVo;
import com.nanri.aiimage.modules.task.mapper.TaskResultItemMapper;
import com.nanri.aiimage.modules.task.model.entity.TaskResultItemEntity;
import com.nanri.aiimage.modules.task.service.TransientPayloadStorageService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
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.anyList;
import static org.mockito.Mockito.doAnswer;
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 60:采集模块 10k 行压测验收(结果完整性、内存与吞吐)。
* CollectDataResultItemBatchWriter 以 10k 行输入压测批量 upsert:批量 SQL
* 调用次数 = ceil(10k / batchSize)(不随行数增长为逐行调用),10k 行全部
* 写入不丢失、无重复;hash 相等重复提交全跳过(幂等零写入);批量失败
* 跳过该批后重试收敛到真实行数;内存上每批行数 ≤ batchSize,不无界累积。
*/
class CollectData10kLoadTest {
private TaskResultItemMapper taskResultItemMapper;
private TransientPayloadStorageService transientPayloadStorageService;
private CollectDataResultDetailCodec codec;
private CollectDataResultItemBatchWriter writer;
@BeforeEach
void setUp() {
taskResultItemMapper = mock(TaskResultItemMapper.class);
transientPayloadStorageService = mock(TransientPayloadStorageService.class);
codec = new CollectDataResultDetailCodec(new ObjectMapper());
writer = new CollectDataResultItemBatchWriter(taskResultItemMapper, codec, transientPayloadStorageService, 100);
}
private static List<CollectDataResultRowVo> rows(int count, int startIndex) {
List<CollectDataResultRowVo> rows = new ArrayList<>(count);
for (int i = 0; i < count; i++) {
CollectDataResultRowVo row = new CollectDataResultRowVo();
row.setAsin("B" + String.format("%09d", startIndex + i + 1));
row.setBrand("brand" + (i % 10));
rows.add(row);
}
return rows;
}
@Test
void test_task_060_collect_normal_default_path() {
// 正常路径:10k 行全部写入,恰好 100 批(每批 100 行),无丢失无重复。
when(taskResultItemMapper.selectList(any())).thenReturn(List.of());
when(taskResultItemMapper.upsertBatch(anyList())).thenAnswer(
invocation -> ((List<?>) invocation.getArgument(0)).size());
List<CollectDataResultRowVo> rows = rows(10_000, 0);
CollectDataResultItemBatchWriter.UpsertCounts counts = writer.upsertAccepted(
1L, 2L, "task:1", 0, rows, "rustfs:detail/10k");
assertEquals(10_000, counts.insertedOrUpdated(), "10k 行全部写入");
assertEquals(0, counts.skipped(), "无跳过");
assertEquals(10_000, counts.newlyInserted(), "全部为新增");
ArgumentCaptor<List> captor = ArgumentCaptor.forClass(List.class);
verify(taskResultItemMapper, times(100)).upsertBatch(captor.capture());
assertEquals(100, captor.getAllValues().get(0).size(), "每批恰好 batchSize=100");
Set<String> refs = new HashSet<>();
for (Object value : captor.getAllValues()) {
for (Object entity : (List<?>) value) {
assertTrue(refs.add(((TaskResultItemEntity) entity).getPayloadJson()),
"引用 JSON 不重复(每行唯一 offset");
}
}
assertEquals(10_000, refs.size(), "10k 个引用唯一");
}
@Test
void test_task_060_collect_normal_multiple_items() {
// 批量场景:10k 行按 10 个 chunk 分批提交(每 chunk 1000 行),
// 全部收敛不丢失,总批量 SQL 调用次数 = 100,顺序稳定。
when(taskResultItemMapper.selectList(any())).thenReturn(List.of());
when(taskResultItemMapper.upsertBatch(anyList())).thenAnswer(
invocation -> ((List<?>) invocation.getArgument(0)).size());
int total = 0;
int newly = 0;
for (int chunk = 0; chunk < 10; chunk++) {
CollectDataResultItemBatchWriter.UpsertCounts counts = writer.upsertAccepted(
1L, 2L, "task:1", chunk, rows(1000, chunk * 1000),
"rustfs:detail/c" + chunk);
total += counts.insertedOrUpdated();
newly += counts.newlyInserted();
}
assertEquals(10_000, total, "10k 行全部写入");
assertEquals(10_000, newly, "全部新增");
verify(taskResultItemMapper, times(100)).upsertBatch(anyList());
}
@Test
void test_task_060_collect_normal_repeated_operation_is_idempotent() {
// 幂等:10k 行重复提交(存量行 hash 相等)→ 全部跳过零写入,
// 批量 SQL 仅 1 次查询 0 次写入,无重复记录。
List<TaskResultItemEntity> existing = new ArrayList<>(10_000);
List<CollectDataResultRowVo> rows = rows(10_000, 0);
for (int i = 0; i < 10_000; i++) {
TaskResultItemEntity entity = new TaskResultItemEntity();
entity.setItemKey("asin:" + rows.get(i).getAsin());
entity.setPayloadHash(codec.encodeRefsWithHash(0, i, 1, "rustfs:detail/10k").get(0).payloadHash());
existing.add(entity);
}
when(taskResultItemMapper.selectList(any())).thenReturn(existing);
CollectDataResultItemBatchWriter.UpsertCounts counts = writer.upsertAccepted(
1L, 2L, "task:1", 0, rows, "rustfs:detail/10k");
assertEquals(0, counts.insertedOrUpdated(), "重复提交零写入");
assertEquals(10_000, counts.skipped(), "全部跳过");
assertEquals(0, counts.newlyInserted(), "无新增");
verify(taskResultItemMapper, never()).upsertBatch(anyList());
verify(taskResultItemMapper, times(1)).selectList(any());
}
@Test
void test_task_060_collect_boundary_empty_input() {
// 空输入:零调用零计数,不创建无效资源。
CollectDataResultItemBatchWriter.UpsertCounts counts =
writer.upsertAccepted(1L, 2L, "task:1", 0, List.of(), "rustfs:x");
assertEquals(0, counts.insertedOrUpdated());
assertEquals(0, counts.skipped());
assertEquals(0, counts.newlyInserted());
verify(taskResultItemMapper, never()).selectList(any());
verify(taskResultItemMapper, never()).upsertBatch(anyList());
}
@Test
void test_task_060_collect_boundary_single_item() {
// 单元素:10k 行中仅有 1 行 → 恰好 1 次批量调用、1 行写入。
when(taskResultItemMapper.selectList(any())).thenReturn(List.of());
when(taskResultItemMapper.upsertBatch(anyList())).thenReturn(1);
CollectDataResultItemBatchWriter.UpsertCounts counts =
writer.upsertAccepted(1L, 2L, "task:1", 0, rows(1, 0), "rustfs:x");
assertEquals(1, counts.insertedOrUpdated());
verify(taskResultItemMapper, times(1)).upsertBatch(anyList());
}
@Test
void test_task_060_collect_boundary_limit_and_overflow() {
// 上限/超限:10k 行以 batchSize=1 提交 → 10k 次批量调用,
// 每次恰好 1 行,无无界内存增长(每批行数受 batchSize 约束)。
CollectDataResultItemBatchWriter tinyWriter = new CollectDataResultItemBatchWriter(
taskResultItemMapper, codec, transientPayloadStorageService, 1);
when(taskResultItemMapper.selectList(any())).thenReturn(List.of());
when(taskResultItemMapper.upsertBatch(anyList())).thenAnswer(
invocation -> ((List<?>) invocation.getArgument(0)).size());
CollectDataResultItemBatchWriter.UpsertCounts counts = tinyWriter.upsertAccepted(
1L, 2L, "task:1", 0, rows(10_000, 0), "rustfs:detail/10k");
assertEquals(10_000, counts.insertedOrUpdated(), "10k 行全部写入");
ArgumentCaptor<List> captor = ArgumentCaptor.forClass(List.class);
verify(taskResultItemMapper, times(10_000)).upsertBatch(captor.capture());
assertEquals(1, captor.getAllValues().get(0).size(), "每批恰好 1 行");
assertEquals(1, captor.getAllValues().get(9_999).size(), "末批 1 行");
}
@Test
void test_task_060_collect_invalid_input_rejected() {
// 非法参数:null/空白 ASIN 行在 10k 输入中安全跳过,仅合法行写入,
// 引用 JSON 仅对合法行生成,无无效资源。
when(taskResultItemMapper.selectList(any())).thenReturn(List.of());
when(taskResultItemMapper.upsertBatch(anyList())).thenAnswer(
invocation -> ((List<?>) invocation.getArgument(0)).size());
List<CollectDataResultRowVo> rows = rows(10_000, 0);
for (int i = 0; i < 500; i++) {
rows.get(i * 20).setAsin(null);
}
CollectDataResultItemBatchWriter.UpsertCounts counts = writer.upsertAccepted(
1L, 2L, "task:1", 0, rows, "rustfs:detail/10k");
assertEquals(9_500, counts.insertedOrUpdated(), "仅合法行写入");
assertEquals(9_500, counts.newlyInserted(), "新增计数同步");
}
@Test
void test_task_060_collect_dependency_failure_releases_resources() {
// 依赖失败:10k 行前 50 批写入失败(共 100 批)→ 跳过失败批,
// 增量计数扣除未落库行,重试同一输入后收敛到真实行数,无资源残留。
when(taskResultItemMapper.selectList(any())).thenReturn(List.of());
java.util.concurrent.atomic.AtomicInteger failedBatches = new java.util.concurrent.atomic.AtomicInteger();
doAnswer(invocation -> {
if (failedBatches.getAndIncrement() < 50) {
throw new RuntimeException("db down");
}
return ((List<?>) invocation.getArgument(0)).size();
}).when(taskResultItemMapper).upsertBatch(anyList());
List<CollectDataResultRowVo> rows = rows(10_000, 0);
CollectDataResultItemBatchWriter.UpsertCounts counts = writer.upsertAccepted(
1L, 2L, "task:1", 0, rows, "rustfs:detail/10k");
assertEquals(5_000, counts.insertedOrUpdated(), "首批失败跳过,后 50 批写入");
assertEquals(5_000, counts.newlyInserted(), "失败批新行从增量扣除");
// 恢复后重试同一输入:存量已落库行 hash 相等跳过,未落库行补插。
List<TaskResultItemEntity> existing = new ArrayList<>(5_000);
for (int i = 0; i < 5_000; i++) {
TaskResultItemEntity entity = new TaskResultItemEntity();
entity.setItemKey("asin:" + rows.get(5_000 + i).getAsin());
entity.setPayloadHash(codec.encodeRefsWithHash(0, 5_000 + i, 1, "rustfs:detail/10k")
.get(0).payloadHash());
existing.add(entity);
}
when(taskResultItemMapper.selectList(any())).thenReturn(existing);
CollectDataResultItemBatchWriter.UpsertCounts retry = writer.upsertAccepted(
1L, 2L, "task:1", 0, rows, "rustfs:detail/10k");
assertEquals(5_000, retry.insertedOrUpdated(), "重试补插未落库 5k 行");
assertEquals(5_000, retry.skipped(), "已落库行全部跳过");
assertEquals(5_000, retry.newlyInserted(), "新增恰为未落库行数");
}
}
@@ -0,0 +1,208 @@
package com.nanri.aiimage.modules.collectdata.util;
import com.nanri.aiimage.modules.collectdata.model.vo.CollectDataResultRowVo;
import com.nanri.aiimage.modules.dedupe.mapper.DedupeTotalDataMapper;
import com.nanri.aiimage.modules.invalidasin.mapper.InvalidAsinDataMapper;
import com.nanri.aiimage.modules.invalidasin.model.entity.InvalidAsinDataEntity;
import org.junit.jupiter.api.BeforeEach;
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.assertTrue;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.ArgumentMatchers.any;
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 45:将 ASIN 去重查询与无效品牌查询统一为批量集合查询。
* CollectDataBatchQuery 对输入行集合一次性批量查询去重表与无效品牌表,
* 输出与原有两段式逐行语义完全等价(区分 dedupe 与 invalid 两类过滤),
* 空输入/单元素/超限集合行为确定,依赖失败可恢复且不创建无效资源。
*/
class CollectDataBatchQueryTest {
private DedupeTotalDataMapper dedupeMapper;
private InvalidAsinDataMapper invalidAsinMapper;
private CollectDataBatchQuery query;
@BeforeEach
void setUp() {
dedupeMapper = mock(DedupeTotalDataMapper.class);
invalidAsinMapper = mock(InvalidAsinDataMapper.class);
query = new CollectDataBatchQuery(dedupeMapper, invalidAsinMapper);
}
@Test
void test_task_045_asin_brand_normal_default_path() {
// 正常输入:去重表已存在的 ASIN 被过滤,无效品牌表中的品牌被过滤,其余保留。
when(dedupeMapper.selectExistingDataValues(anyList()))
.thenReturn(List.of("B000000001"));
when(invalidAsinMapper.selectList(any())).thenReturn(List.of(entity("Zara")));
List<CollectDataResultRowVo> out = query.filterByExistingAsin(List.of(
row("B000000001", "Nike"),
row("B000000002", "Zara"),
row("B000000003", "H&M")
));
assertEquals(1, out.size(), "仅未命中行保留");
assertEquals("B000000003", out.get(0).getAsin(), "保留行 ASIN 正确");
assertEquals("H&M", out.get(0).getBrand(), "保留行品牌正确");
}
@Test
void test_task_045_asin_brand_normal_multiple_items() {
// 批量场景:前 300 行 ASIN 唯一、品牌干净;后 300 行品牌无效。
// 去重表命中前 100 个 ASIN,输出顺序稳定、过滤数量精确。
List<CollectDataResultRowVo> rows = new ArrayList<>();
for (int i = 0; i < 300; i++) {
rows.add(row("D" + String.format("%09d", i + 1), "clean"));
}
for (int i = 0; i < 300; i++) {
rows.add(row("B" + String.format("%09d", i + 1), "bad"));
}
List<String> existing = new ArrayList<>();
for (int i = 0; i < 100; i++) {
existing.add("D" + String.format("%09d", i + 1));
}
when(dedupeMapper.selectExistingDataValues(anyList())).thenReturn(existing);
when(invalidAsinMapper.selectList(any())).thenReturn(List.of(entity("bad")));
List<CollectDataResultRowVo> out = query.filterByExistingAsin(rows);
assertEquals(200, out.size(), "100 行去重 + 300 行无效品牌 = 过滤 400 行");
assertEquals("D000000101", out.get(0).getAsin(), "输出首行保持输入顺序");
assertEquals("D000000300", out.get(200 - 1).getAsin(), "输出末行为第 300 行");
assertTrue(out.stream().allMatch(r -> r.getBrand().equals("clean")),
"输出全部为干净品牌行");
}
@Test
void test_task_045_asin_brand_normal_repeated_operation_is_idempotent() {
// 幂等:同一输入重复执行输出一致,两次都发批量查询。
when(dedupeMapper.selectExistingDataValues(anyList()))
.thenReturn(List.of("B000000001"));
when(invalidAsinMapper.selectList(any())).thenReturn(List.of(entity("bad")));
List<CollectDataResultRowVo> rows = List.of(
row("B000000001", "bad"),
row("B000000002", "good")
);
List<CollectDataResultRowVo> first = query.filterByExistingAsin(rows);
List<CollectDataResultRowVo> second = query.filterByExistingAsin(rows);
assertEquals(first, second, "重复执行输出一致");
assertEquals(1, first.size());
verify(dedupeMapper, times(2)).selectExistingDataValues(anyList());
verify(invalidAsinMapper, times(2)).selectList(any());
}
@Test
void test_task_045_asin_brand_boundary_empty_input() {
// 空输入:空列表/无 ASIN 无品牌的行集合不触发数据库查询,安全跳过。
List<CollectDataResultRowVo> empty = query.filterByExistingAsin(List.of());
assertEquals(0, empty.size(), "空列表返回空");
List<CollectDataResultRowVo> blankRows = query.filterByExistingAsin(List.of(
row("", ""), row(null, null)
));
assertEquals(2, blankRows.size(), "无 ASIN 无品牌行不触发查询且全保留");
verify(dedupeMapper, never()).selectExistingDataValues(anyList());
verify(invalidAsinMapper, never()).selectList(any());
}
@Test
void test_task_045_asin_brand_boundary_single_item() {
// 单元素:单行命中/未命中均正确,不依赖批量路径。
when(dedupeMapper.selectExistingDataValues(anyList())).thenReturn(List.of());
when(invalidAsinMapper.selectList(any())).thenReturn(List.of());
List<CollectDataResultRowVo> out = query.filterByExistingAsin(List.of(
row("B000000001", "solo")
));
assertEquals(1, out.size(), "单行未命中保留");
assertEquals("solo", out.get(0).getBrand());
when(dedupeMapper.selectExistingDataValues(anyList())).thenReturn(List.of("B000000001"));
List<CollectDataResultRowVo> hit = query.filterByExistingAsin(List.of(
row("B000000001", "solo")
));
assertEquals(0, hit.size(), "单行命中去重表被过滤");
}
@Test
void test_task_045_asin_brand_boundary_limit_and_overflow() {
// 上限/超限:超大输入集合批量查询一次完成,不产生逐行查询(无无界增长)。
List<CollectDataResultRowVo> rows = new ArrayList<>();
for (int i = 0; i < 5000; i++) {
rows.add(row("B" + String.format("%09d", i), "brand-" + i));
}
when(dedupeMapper.selectExistingDataValues(anyList())).thenReturn(List.of());
when(invalidAsinMapper.selectList(any())).thenReturn(List.of());
List<CollectDataResultRowVo> out = query.filterByExistingAsin(rows);
assertEquals(5000, out.size(), "超大集合全量保留");
verify(dedupeMapper).selectExistingDataValues(anyList());
verify(invalidAsinMapper).selectList(any());
}
@Test
void test_task_045_asin_brand_invalid_input_rejected() {
// 非法参数:null 行安全跳过;数据库返回 null 集合安全处理。
when(dedupeMapper.selectExistingDataValues(anyList())).thenReturn(null);
when(invalidAsinMapper.selectList(any())).thenReturn(null);
List<CollectDataResultRowVo> rows = new ArrayList<>();
rows.add(null);
rows.add(row("B000000001", " Nike "));
List<CollectDataResultRowVo> out = query.filterByExistingAsin(rows);
assertEquals(1, out.size(), "null 行不丢弃");
assertEquals("B000000001", out.get(0).getAsin(), "正常行保留");
assertEquals(" Nike ", out.get(0).getBrand(), "品牌保留原始值(过滤时仅标准化用于匹配)");
}
@Test
void test_task_045_asin_brand_dependency_failure_releases_resources() {
// 依赖失败:去重表查询抛错时异常向上传播;恢复后再次执行成功,不残留状态。
when(dedupeMapper.selectExistingDataValues(anyList()))
.thenThrow(new RuntimeException("db down"))
.thenReturn(List.of("B000000001"));
when(invalidAsinMapper.selectList(any())).thenReturn(List.of());
boolean threw = false;
try {
query.filterByExistingAsin(List.of(row("B000000001", "nike")));
} catch (RuntimeException ex) {
threw = "db down".equals(ex.getMessage());
}
assertTrue(threw, "去重查询失败向上抛错");
List<CollectDataResultRowVo> out = query.filterByExistingAsin(List.of(
row("B000000001", "nike"), row("B000000002", "adidas")
));
assertEquals(1, out.size(), "恢复后过滤正确");
assertEquals("B000000002", out.get(0).getAsin(), "恢复后命中逻辑仍生效");
}
private static CollectDataResultRowVo row(String asin, String brand) {
CollectDataResultRowVo row = new CollectDataResultRowVo();
row.setAsin(asin);
row.setBrand(brand);
return row;
}
private static InvalidAsinDataEntity entity(String brand) {
InvalidAsinDataEntity entity = new InvalidAsinDataEntity();
entity.setBrand(brand);
return entity;
}
}
@@ -0,0 +1,200 @@
package com.nanri.aiimage.modules.collectdata.util;
import com.nanri.aiimage.modules.brand.client.BrandCheckClient;
import com.nanri.aiimage.modules.collectdata.model.vo.CollectDataResultRowVo;
import org.junit.jupiter.api.BeforeEach;
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.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyList;
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 46:跳过空品牌批次的无效远程品牌检查请求。
* CollectDataBrandBatchFilter 按批次做品牌检查,批次内品牌集合为空的批次
* 完全不发起 checkAll 远程调用(行直接归入 rejected),非空批次正常检查并
* 分类(失败品牌 rejected / 查询失败 queryFailed / 其余 accepted),
* 分类语义与 CollectDataService 原 filterByBrandCheck 完全等价。
*/
class CollectDataBrandBatchFilterTest {
private BrandCheckClient brandCheckClient;
private CollectDataBrandBatchFilter filter;
@BeforeEach
void setUp() {
brandCheckClient = mock(BrandCheckClient.class);
filter = new CollectDataBrandBatchFilter(brandCheckClient, 10);
}
@Test
void test_task_046_brand_normal_default_path() {
// 正常输入:非空品牌批次发起检查,失败品牌行 rejected、其余 accepted。
when(brandCheckClient.checkAll(anyList(), any())).thenReturn(
new BrandCheckClient.BrandCheckBatchResult(
List.of(), List.of("Zara"), List.of()));
CollectDataBrandBatchFilter.BrandBatchOutcome outcome = filter.filter(List.of(
row("B000000001", "Zara"),
row("B000000002", "Nike"),
row("B000000003", "H&M")
));
assertEquals(1, outcome.rejected().size(), "失败品牌行进入 rejected");
assertEquals("B000000001", outcome.rejected().get(0).getAsin());
assertEquals(2, outcome.accepted().size(), "其余行 accepted");
assertEquals("B000000002", outcome.accepted().get(0).getAsin(), "accepted 顺序稳定");
assertEquals(0, outcome.queryFailed().size(), "无查询失败行");
verify(brandCheckClient).checkAll(anyList(), any());
}
@Test
void test_task_046_brand_normal_multiple_items() {
// 批量场景:120 行(12 批次),每批次检查一次,分类跨批次不丢失、顺序稳定。
List<CollectDataResultRowVo> rows = new ArrayList<>();
for (int i = 0; i < 120; i++) {
rows.add(row("B" + String.format("%09d", i + 1), i % 10 == 0 ? "bad-brand" : "good-" + (i / 10)));
}
when(brandCheckClient.checkAll(anyList(), any())).thenReturn(
new BrandCheckClient.BrandCheckBatchResult(
List.of(), List.of("bad-brand"), List.of()));
CollectDataBrandBatchFilter.BrandBatchOutcome outcome = filter.filter(rows);
assertEquals(12, outcome.rejected().size(), "每批次 1 个失败品牌行");
assertEquals(108, outcome.accepted().size(), "其余行 accepted");
assertEquals("B000000001", outcome.rejected().get(0).getAsin(), "rejected 顺序稳定");
assertEquals("B000000002", outcome.accepted().get(0).getAsin(), "accepted 顺序稳定");
verify(brandCheckClient, times(12)).checkAll(anyList(), any());
}
@Test
void test_task_046_brand_normal_repeated_operation_is_idempotent() {
// 幂等:同一输入重复执行结果一致,不产生重复对象。
when(brandCheckClient.checkAll(anyList(), any())).thenReturn(
new BrandCheckClient.BrandCheckBatchResult(
List.of(), List.of("Nike"), List.of()));
List<CollectDataResultRowVo> rows = List.of(
row("B000000001", "Nike"),
row("B000000002", "Adidas")
);
CollectDataBrandBatchFilter.BrandBatchOutcome first = filter.filter(rows);
CollectDataBrandBatchFilter.BrandBatchOutcome second = filter.filter(rows);
assertEquals(first.rejected(), second.rejected(), "重复执行 rejected 一致");
assertEquals(first.accepted(), second.accepted(), "重复执行 accepted 一致");
// 缓存生效:第二次执行全部命中缓存,不再发起远程调用。
verify(brandCheckClient, times(1)).checkAll(anyList(), any());
}
@Test
void test_task_046_brand_boundary_empty_input() {
// 空输入:空列表返回空结果;全空品牌批次不发起远程检查,行全部 rejected。
CollectDataBrandBatchFilter.BrandBatchOutcome empty = filter.filter(List.of());
assertEquals(0, empty.rejected().size() + empty.accepted().size() + empty.queryFailed().size(),
"空列表返回空结果");
verify(brandCheckClient, never()).checkAll(anyList(), any());
CollectDataBrandBatchFilter.BrandBatchOutcome blankBatch = filter.filter(List.of(
row("B000000001", ""), row("B000000002", null), row("B000000003", " ")
));
assertEquals(3, blankBatch.rejected().size(), "空品牌批次行全部 rejected");
assertEquals(0, blankBatch.accepted().size(), "空品牌批次无 accepted");
verify(brandCheckClient, never()).checkAll(anyList(), any());
}
@Test
void test_task_046_brand_boundary_single_item() {
// 单元素:单行单品牌批次正常检查;单行空品牌批次不发远程调用。
when(brandCheckClient.checkAll(anyList(), any())).thenReturn(
new BrandCheckClient.BrandCheckBatchResult(
List.of(), List.of(), List.of()));
CollectDataBrandBatchFilter.BrandBatchOutcome single = filter.filter(List.of(
row("B000000001", "solo")
));
assertEquals(1, single.accepted().size(), "单行未命中失败归 accepted");
verify(brandCheckClient).checkAll(anyList(), any());
CollectDataBrandBatchFilter.BrandBatchOutcome blank = filter.filter(List.of(
row("B000000002", "")
));
assertEquals(1, blank.rejected().size(), "单行空品牌归 rejected");
// 同实例缓存生效:solo 已查过,空品牌批次无远程调用,总调用保持 1 次。
verify(brandCheckClient, times(1)).checkAll(anyList(), any());
}
@Test
void test_task_046_brand_boundary_limit_and_overflow() {
// 上限/超限:5000 行非空品牌 = 500 次检查;混合空品牌行时不产生无效远程调用。
List<CollectDataResultRowVo> rows = new ArrayList<>();
for (int i = 0; i < 5000; i++) {
rows.add(row("B" + String.format("%09d", i + 1), "brand-" + (i % 100)));
}
rows.add(row("B999999999", " "));
when(brandCheckClient.checkAll(anyList(), any())).thenReturn(
new BrandCheckClient.BrandCheckBatchResult(
List.of(), List.of(), List.of()));
CollectDataBrandBatchFilter.BrandBatchOutcome outcome = filter.filter(rows);
assertEquals(1, outcome.rejected().size(), "空品牌行归 rejected");
assertEquals(5000, outcome.accepted().size(), "非空品牌行全 accepted");
// 每批次 10 行恰好引入 10 个新品牌,10 批次后 100 品牌全部缓存,后续批次零调用。
verify(brandCheckClient, times(10)).checkAll(anyList(), any());
}
@Test
void test_task_046_brand_invalid_input_rejected() {
// 非法参数:checkAll 返回 null 按无失败处理;null 行安全跳过不计数。
when(brandCheckClient.checkAll(anyList(), any())).thenReturn(null);
List<CollectDataResultRowVo> rows = new ArrayList<>();
rows.add(null);
rows.add(row("B000000001", "Nike"));
rows.add(row("B000000002", "Zara"));
CollectDataBrandBatchFilter.BrandBatchOutcome outcome = filter.filter(rows);
assertEquals(2, outcome.accepted().size(), "null 结果按无失败处理,行全 accepted");
assertEquals(0, outcome.rejected().size(), "null 行不计数");
verify(brandCheckClient).checkAll(anyList(), any());
}
@Test
void test_task_046_brand_dependency_failure_releases_resources() {
// 依赖失败:远程检查抛错时批次整组降级 queryFailed,可恢复;恢复后重新检查成功。
when(brandCheckClient.checkAll(anyList(), any()))
.thenThrow(new RuntimeException("brand service down"))
.thenReturn(new BrandCheckClient.BrandCheckBatchResult(
List.of(), List.of("Zara"), List.of()));
List<CollectDataResultRowVo> rows = List.of(
row("B000000001", "Zara"), row("B000000002", "Nike"));
CollectDataBrandBatchFilter.BrandBatchOutcome failed = filter.filter(rows);
assertEquals(2, failed.queryFailed().size(), "依赖失败批次整组降级 queryFailed");
assertEquals(0, failed.accepted().size() + failed.rejected().size(), "降级后无其他分类");
CollectDataBrandBatchFilter.BrandBatchOutcome recovered = filter.filter(rows);
assertEquals(1, recovered.rejected().size(), "恢复后失败品牌重新分类");
assertEquals(1, recovered.accepted().size(), "恢复后其余行 accepted");
assertTrue(recovered.queryFailed().isEmpty(), "恢复后无残留 queryFailed");
}
private static CollectDataResultRowVo row(String asin, String brand) {
CollectDataResultRowVo row = new CollectDataResultRowVo();
row.setAsin(asin);
row.setBrand(brand);
return row;
}
}
@@ -0,0 +1,200 @@
package com.nanri.aiimage.modules.collectdata.util;
import com.nanri.aiimage.modules.brand.client.BrandCheckClient;
import com.nanri.aiimage.modules.collectdata.model.vo.CollectDataResultRowVo;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import java.util.ArrayList;
import java.util.List;
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.anyList;
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 47:为品牌检查结果增加任务内短期缓存,避免同品牌重复远程调用。
* CollectDataBrandBatchFilter 在实例内缓存品牌检查判定(FAILED /
* QUERY_FAILED / OK),后续批次命中缓存的品牌不再发起 checkAll 远程调用;
* 远程抛错不缓存(可恢复),缓存有界(超限淘汰最旧条目),
* 分类语义与无缓存时完全等价。
*/
class CollectDataBrandCacheTest {
private BrandCheckClient brandCheckClient;
private CollectDataBrandBatchFilter filter;
@BeforeEach
void setUp() {
brandCheckClient = mock(BrandCheckClient.class);
filter = new CollectDataBrandBatchFilter(brandCheckClient, 10);
}
@Test
void test_task_047_cache_brand_normal_default_path() {
// 正常输入:首查品牌发起远程检查并缓存判定,分类正确。
when(brandCheckClient.checkAll(anyList(), any())).thenReturn(
new BrandCheckClient.BrandCheckBatchResult(
List.of(), List.of("Zara"), List.of()));
CollectDataBrandBatchFilter.BrandBatchOutcome outcome = filter.filter(List.of(
row("B000000001", "Zara"),
row("B000000002", "Nike")
));
assertEquals(1, outcome.rejected().size(), "失败品牌行 rejected");
assertEquals("B000000001", outcome.rejected().get(0).getAsin());
assertEquals(1, outcome.accepted().size(), "其余行 accepted");
verify(brandCheckClient).checkAll(anyList(), any());
}
@Test
void test_task_047_cache_brand_normal_multiple_items() {
// 批量场景:多 chunk 提交时同品牌只远程检查一次,每 chunk 只查未缓存品牌。
when(brandCheckClient.checkAll(anyList(), any())).thenReturn(
new BrandCheckClient.BrandCheckBatchResult(
List.of(), List.of("Zara"), List.of()));
filter.filter(List.of(row("B000000001", "Zara"), row("B000000002", "Nike")));
CollectDataBrandBatchFilter.BrandBatchOutcome second = filter.filter(List.of(
row("B000000001", "Zara"),
row("B000000003", "Adidas")
));
assertEquals(1, second.rejected().size(), "chunk2 Zara 命中缓存仍 rejected");
assertEquals(1, second.accepted().size(), "chunk2 新品牌 Adidas accepted");
ArgumentCaptor<List> captor = ArgumentCaptor.forClass(List.class);
verify(brandCheckClient, times(2)).checkAll(captor.capture(), any());
assertEquals(List.of("Adidas"), captor.getAllValues().get(1), "第二次只查未缓存品牌");
}
@Test
void test_task_047_cache_brand_normal_repeated_operation_is_idempotent() {
// 幂等:同一输入重复执行结果一致,第二次全部命中缓存不发远程调用。
when(brandCheckClient.checkAll(anyList(), any())).thenReturn(
new BrandCheckClient.BrandCheckBatchResult(
List.of(), List.of("Nike"), List.of()));
List<CollectDataResultRowVo> rows = List.of(
row("B000000001", "Nike"),
row("B000000002", "Adidas")
);
CollectDataBrandBatchFilter.BrandBatchOutcome first = filter.filter(rows);
CollectDataBrandBatchFilter.BrandBatchOutcome second = filter.filter(rows);
assertEquals(first.rejected(), second.rejected(), "重复执行 rejected 一致");
assertEquals(first.accepted(), second.accepted(), "重复执行 accepted 一致");
verify(brandCheckClient, times(1)).checkAll(anyList(), any());
}
@Test
void test_task_047_cache_brand_boundary_empty_input() {
// 空输入:空列表与全空品牌批次均不发起远程调用,也不写缓存。
CollectDataBrandBatchFilter.BrandBatchOutcome empty = filter.filter(List.of());
assertEquals(0, empty.rejected().size() + empty.accepted().size() + empty.queryFailed().size(),
"空列表返回空结果");
verify(brandCheckClient, never()).checkAll(anyList(), any());
CollectDataBrandBatchFilter.BrandBatchOutcome blank = filter.filter(List.of(
row("B000000001", "")
));
assertEquals(1, blank.rejected().size(), "空品牌行 rejected");
verify(brandCheckClient, never()).checkAll(anyList(), any());
}
@Test
void test_task_047_cache_brand_boundary_single_item() {
// 单元素:单品牌首查一次;同品牌再出现时命中缓存零调用。
when(brandCheckClient.checkAll(anyList(), any())).thenReturn(
new BrandCheckClient.BrandCheckBatchResult(
List.of(), List.of(), List.of()));
CollectDataBrandBatchFilter.BrandBatchOutcome single = filter.filter(List.of(
row("B000000001", "solo")
));
assertEquals(1, single.accepted().size(), "单品牌未命中失败 accepted");
CollectDataBrandBatchFilter.BrandBatchOutcome again = filter.filter(List.of(
row("B000000002", "solo")
));
assertEquals(1, again.accepted().size(), "同品牌再次出现结果一致");
verify(brandCheckClient, times(1)).checkAll(anyList(), any());
}
@Test
void test_task_047_cache_brand_boundary_limit_and_overflow() {
// 上限/超限:缓存容量 8,首批 10 个品牌淘汰最旧 2 个;第二批同品牌
// 只重查被淘汰的 2 个,其余 8 个命中缓存;无无界增长。
when(brandCheckClient.checkAll(anyList(), any())).thenReturn(
new BrandCheckClient.BrandCheckBatchResult(
List.of(), List.of(), List.of()));
CollectDataBrandBatchFilter smallCache = new CollectDataBrandBatchFilter(brandCheckClient, 10, 8);
List<CollectDataResultRowVo> first = new ArrayList<>();
List<CollectDataResultRowVo> second = new ArrayList<>();
for (int i = 0; i < 10; i++) {
first.add(row("B" + i, "brand-" + i));
second.add(row("B" + (100 + i), "brand-" + i));
}
smallCache.filter(first);
CollectDataBrandBatchFilter.BrandBatchOutcome out = smallCache.filter(second);
assertEquals(10, out.accepted().size(), "第二批 10 行全部 accepted");
ArgumentCaptor<List> captor = ArgumentCaptor.forClass(List.class);
verify(brandCheckClient, times(2)).checkAll(captor.capture(), any());
List<String> secondCall = captor.getAllValues().get(1);
assertEquals(2, secondCall.size(), "仅重查被淘汰的最旧 2 个品牌");
assertTrue(secondCall.containsAll(List.of("brand-0", "brand-1")), "淘汰的是最旧条目");
}
@Test
void test_task_047_cache_brand_invalid_input_rejected() {
// 非法参数:checkAll 返回 null 按无失败处理并缓存;null 行安全跳过。
when(brandCheckClient.checkAll(anyList(), any())).thenReturn(null);
List<CollectDataResultRowVo> rows = new ArrayList<>();
rows.add(null);
rows.add(row("B000000001", "Nike"));
CollectDataBrandBatchFilter.BrandBatchOutcome first = filter.filter(rows);
assertEquals(1, first.accepted().size(), "null 结果按无失败处理");
assertEquals(0, first.rejected().size(), "null 行不计数");
CollectDataBrandBatchFilter.BrandBatchOutcome second = filter.filter(rows);
assertEquals(1, second.accepted().size(), "重复执行结果一致");
verify(brandCheckClient, times(1)).checkAll(anyList(), any());
}
@Test
void test_task_047_cache_brand_dependency_failure_releases_resources() {
// 依赖失败:远程抛错整批 queryFailed 且不缓存;恢复后重查成功,无残留状态。
when(brandCheckClient.checkAll(anyList(), any()))
.thenThrow(new RuntimeException("brand service down"))
.thenReturn(new BrandCheckClient.BrandCheckBatchResult(
List.of(), List.of("Zara"), List.of()));
List<CollectDataResultRowVo> rows = List.of(
row("B000000001", "Zara"), row("B000000002", "Nike"));
CollectDataBrandBatchFilter.BrandBatchOutcome failed = filter.filter(rows);
assertEquals(2, failed.queryFailed().size(), "失败批次整组降级 queryFailed");
CollectDataBrandBatchFilter.BrandBatchOutcome recovered = filter.filter(rows);
assertEquals(1, recovered.rejected().size(), "恢复后重新检查并分类");
assertEquals(1, recovered.accepted().size(), "恢复后 accepted 正确");
assertTrue(recovered.queryFailed().isEmpty(), "恢复后无残留 queryFailed");
verify(brandCheckClient, times(2)).checkAll(anyList(), any());
}
private static CollectDataResultRowVo row(String asin, String brand) {
CollectDataResultRowVo row = new CollectDataResultRowVo();
row.setAsin(asin);
row.setBrand(brand);
return row;
}
}
@@ -0,0 +1,214 @@
package com.nanri.aiimage.modules.collectdata.util;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import java.util.ArrayList;
import java.util.LinkedHashMap;
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.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Task 44:保留原始 chunk payload 的同时,减少逐行 extra JSON 的重复序列化。
* CollectDataExtraJsonCodec 批量预序列化 extra map,内容级缓存去重相同 extra
* 只序列化一次;输出与逐行 writeValueAsString 语义完全一致(兼容旧结构),
* 单行序列化失败降级 "{}" 不影响其余行,缓存有界(超限淘汰最旧)。
*/
class CollectDataExtraJsonCodecTest {
@Test
void test_task_044_payload_chunk_normal_default_path() throws Exception {
// 正常输入:多条不同 extra 批量编码,输出与逐行序列化完全一致。
ObjectMapper mapper = new ObjectMapper();
CollectDataExtraJsonCodec codec = new CollectDataExtraJsonCodec(mapper);
List<Map<String, String>> extras = List.of(
mapOf("价格", "19.99", "颜色", ""),
mapOf("价格", "9.99"),
mapOf()
);
List<String> encoded = codec.encodeAll(extras);
assertEquals(3, encoded.size(), "输出行数一致");
assertEquals(mapper.writeValueAsString(extras.get(0)), encoded.get(0), "与逐行序列化一致");
assertEquals(mapper.writeValueAsString(extras.get(1)), encoded.get(1), "第二行一致");
assertEquals("{}", encoded.get(2), "空 map 输出 {}");
}
@Test
void test_task_044_payload_chunk_normal_multiple_items() throws Exception {
// 批量场景:1000 行含重复 extra,输出顺序稳定、行数不丢失、重复内容只序列化一次。
ObjectMapper mapper = new ObjectMapper();
AtomicInteger serializations = new AtomicInteger(0);
ObjectMapper spy = org.mockito.Mockito.spy(mapper);
org.mockito.Mockito.doAnswer(invocation -> {
serializations.incrementAndGet();
return invocation.callRealMethod();
}).when(spy).writeValueAsString(org.mockito.ArgumentMatchers.any());
CollectDataExtraJsonCodec codec = new CollectDataExtraJsonCodec(spy);
List<Map<String, String>> extras = new ArrayList<>();
Map<String, String> shared = mapOf("价格", "19.99", "卖家", "A");
for (int i = 0; i < 1000; i++) {
extras.add(i % 10 == 0 ? shared : mapOf("价格", String.valueOf(i)));
}
List<String> encoded = codec.encodeAll(extras);
assertEquals(1000, encoded.size(), "批量行数不丢失");
for (int i = 0; i < 1000; i++) {
if (i % 10 == 0) {
assertEquals(encoded.get(0), encoded.get(i), "相同 extra 输出一致");
}
}
assertTrue(serializations.get() < 1000, "去重后序列化次数减少,实际=" + serializations.get());
assertTrue(serializations.get() >= 100, "不同内容仍逐条序列化,实际=" + serializations.get());
}
@Test
void test_task_044_payload_chunk_normal_repeated_operation_is_idempotent() throws Exception {
// 幂等:同一输入两次编码输出完全一致,且第二次命中缓存不触发新序列化。
ObjectMapper mapper = new ObjectMapper();
AtomicInteger serializations = new AtomicInteger(0);
ObjectMapper spy = org.mockito.Mockito.spy(mapper);
org.mockito.Mockito.doAnswer(invocation -> {
serializations.incrementAndGet();
return invocation.callRealMethod();
}).when(spy).writeValueAsString(org.mockito.ArgumentMatchers.any());
CollectDataExtraJsonCodec codec = new CollectDataExtraJsonCodec(spy);
List<Map<String, String>> extras = List.of(mapOf("a", "1"), mapOf("b", "2"), mapOf("a", "1"));
List<String> first = codec.encodeAll(extras);
int firstCount = serializations.get();
List<String> second = codec.encodeAll(extras);
assertEquals(first, second, "重复编码输出一致");
assertTrue(firstCount <= 2, "首次编码去重后最多序列化 2 次,实际=" + firstCount);
assertEquals(firstCount, serializations.get(), "第二次编码全部命中缓存,无新序列化");
}
@Test
void test_task_044_payload_chunk_boundary_empty_input() {
// 空输入:空列表返回空输出;null 列表安全返回空列表。
ObjectMapper mapper = new ObjectMapper();
CollectDataExtraJsonCodec codec = new CollectDataExtraJsonCodec(mapper);
List<String> empty = codec.encodeAll(List.of());
assertEquals(0, empty.size(), "空列表输出空");
List<String> nullSafe = codec.encodeAll(null);
assertEquals(0, nullSafe.size(), "null 列表安全返回空");
}
@Test
void test_task_044_payload_chunk_boundary_single_item() {
// 单元素:单条 extra 编码正确,不依赖批量路径。
ObjectMapper mapper = new ObjectMapper();
CollectDataExtraJsonCodec codec = new CollectDataExtraJsonCodec(mapper);
List<String> encoded = codec.encodeAll(List.of(mapOf("关键词", "phone case")));
assertEquals(1, encoded.size(), "单条输出");
assertTrue(encoded.get(0).contains("phone case"), "单条内容正确");
assertTrue(encoded.get(0).contains("关键词"), "单条键保留");
}
@Test
void test_task_044_payload_chunk_boundary_limit_and_overflow() throws Exception {
// 上限/超限:缓存容量超限淘汰最旧条目,被淘汰内容重新序列化,
// 仍被缓存的内容继续命中;序列化次数精确可预期。
ObjectMapper mapper = new ObjectMapper();
AtomicInteger serializations = new AtomicInteger(0);
ObjectMapper spy = org.mockito.Mockito.spy(mapper);
org.mockito.Mockito.doAnswer(invocation -> {
serializations.incrementAndGet();
return invocation.callRealMethod();
}).when(spy).writeValueAsString(org.mockito.ArgumentMatchers.any());
CollectDataExtraJsonCodec codec = new CollectDataExtraJsonCodec(spy, 32);
List<Map<String, String>> bulk = new ArrayList<>();
List<Map<String, String>> hot = new ArrayList<>();
List<Map<String, String>> cold = new ArrayList<>();
for (int i = 0; i < 100; i++) {
bulk.add(mapOf("k" + i, "v" + i));
}
for (int i = 95; i < 100; i++) {
hot.add(mapOf("k" + i, "v" + i));
}
for (int i = 0; i < 5; i++) {
cold.add(mapOf("k" + i, "v" + i));
}
codec.encodeAll(bulk);
int afterBulk = serializations.get();
assertEquals(100, afterBulk, "首轮 100 条逐条序列化");
codec.encodeAll(hot);
assertEquals(afterBulk, serializations.get(), "缓存内副本全部命中,无新序列化");
codec.encodeAll(cold);
assertEquals(afterBulk + 5, serializations.get(), "被淘汰的旧条目重新序列化");
}
@Test
void test_task_044_payload_chunk_invalid_input_rejected() {
// 非法输入:null 行安全降级为 {};超大 map 值仍编码不抛。
ObjectMapper mapper = new ObjectMapper();
CollectDataExtraJsonCodec codec = new CollectDataExtraJsonCodec(mapper);
List<Map<String, String>> withNull = new ArrayList<>();
withNull.add(null);
withNull.add(mapOf("a", "b"));
List<String> encoded = codec.encodeAll(withNull);
assertEquals(2, encoded.size(), "null 行不丢弃");
assertEquals("{}", encoded.get(0), "null 行降级为 {}");
assertFalse(encoded.get(1).isBlank(), "正常行编码不受影响");
}
@Test
void test_task_044_payload_chunk_dependency_failure_releases_resources() throws Exception {
// 依赖失败:单行序列化抛错时该行降级 {},其余行正常;恢复后编码成功。
ObjectMapper mapper = new ObjectMapper();
AtomicInteger calls = new AtomicInteger(0);
ObjectMapper spy = org.mockito.Mockito.spy(mapper);
org.mockito.Mockito.doAnswer(invocation -> {
if (calls.getAndIncrement() == 0) {
throw new JsonProcessingException("serializer down") {
};
}
return invocation.callRealMethod();
}).when(spy).writeValueAsString(org.mockito.ArgumentMatchers.any());
CollectDataExtraJsonCodec codec = new CollectDataExtraJsonCodec(spy);
List<Map<String, String>> extras = List.of(mapOf("价格", "1"), mapOf("价格", "2"));
List<String> encoded = codec.encodeAll(extras);
assertEquals(2, encoded.size(), "失败行不丢弃");
assertEquals("{}", encoded.get(0), "失败首行降级为 {}");
assertTrue(encoded.get(1).contains("2"), "恢复后第二行编码正确");
assertEquals(2, calls.get(), "首行失败一次、第二行成功一次");
// 失败结果已缓存:再次编码同一输入不再触发序列化(失败行不重复尝试)。
List<String> again = codec.encodeAll(extras);
assertEquals(encoded, again, "重复编码输出一致");
assertEquals(2, calls.get(), "失败行降级结果已缓存,不重复尝试");
}
private static Map<String, String> mapOf(String... pairs) {
Map<String, String> map = new LinkedHashMap<>();
for (int i = 0; i < pairs.length; i += 2) {
map.put(pairs[i], pairs[i + 1]);
}
return map;
}
}
@@ -0,0 +1,205 @@
package com.nanri.aiimage.modules.collectdata.util;
import com.nanri.aiimage.modules.collectdata.model.vo.CollectDataResultRowVo;
import com.nanri.aiimage.modules.invalidasin.mapper.InvalidAsinDataMapper;
import com.nanri.aiimage.modules.invalidasin.model.entity.InvalidAsinDataEntity;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import java.util.ArrayList;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.Mockito.doThrow;
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 48:将 invalid ASIN 记录改为批量 INSERT IGNORE/upsert。
* CollectDataInvalidAsinBatchWriter 按批次把不合规 ASIN 行批量写入
* biz_invalid_asin_dataINSERT IGNORE,按唯一键幂等),空批次/无合法
* data_value 的批次不发起任何 insertBatchIgnore 调用;远程失败降级记录
* 计数(跳过写入),可恢复后继续,不抛出中断整个提交流程的异常。
*/
class CollectDataInvalidAsinBatchWriterTest {
private InvalidAsinDataMapper invalidAsinDataMapper;
private CollectDataInvalidAsinBatchWriter writer;
@BeforeEach
void setUp() {
invalidAsinDataMapper = mock(InvalidAsinDataMapper.class);
writer = new CollectDataInvalidAsinBatchWriter(invalidAsinDataMapper, 10);
}
@Test
void test_task_048_asin_normal_default_path() {
// 正常输入:批量 INSERT IGNORE,实体字段完整,返回写入行数。
when(invalidAsinDataMapper.insertBatchIgnore(anyList())).thenReturn(2);
int written = writer.writeBatch(List.of(
row("B000000001", "Zara"),
row("B000000002", "nike")
));
assertEquals(2, written, "两行都写入");
ArgumentCaptor<List<InvalidAsinDataEntity>> captor = ArgumentCaptor.forClass(List.class);
verify(invalidAsinDataMapper).insertBatchIgnore(captor.capture());
List<InvalidAsinDataEntity> entities = captor.getValue();
assertEquals(2, entities.size(), "按行批量写入");
assertEquals("B000000001", entities.get(0).getDataValue(), "data_value 为 ASIN");
assertEquals("zara", entities.get(0).getBrand(), "brand 小写标准化");
assertEquals("AUTO", entities.get(0).getRecordSource(), "record_source 保持 AUTO");
assertEquals("nike", entities.get(1).getBrand(), "第二行 brand 小写标准化");
}
@Test
void test_task_048_asin_normal_multiple_items() {
// 批量场景:100 行 = 10 批次,顺序稳定不丢失,每批实体数量正确。
when(invalidAsinDataMapper.insertBatchIgnore(anyList())).thenReturn(10);
List<CollectDataResultRowVo> rows = new ArrayList<>();
for (int i = 0; i < 100; i++) {
rows.add(row("B" + String.format("%09d", i + 1), "brand-" + (i % 7)));
}
int written = writer.writeBatch(rows);
assertEquals(100, written, "全部写入");
ArgumentCaptor<List<InvalidAsinDataEntity>> captor = ArgumentCaptor.forClass(List.class);
verify(invalidAsinDataMapper, times(10)).insertBatchIgnore(captor.capture());
for (int i = 0; i < 10; i++) {
List<InvalidAsinDataEntity> batch = captor.getAllValues().get(i);
assertEquals(10, batch.size(), "批次 " + i + " 满 10 行");
}
assertEquals("B000000001", captor.getAllValues().get(0).get(0).getDataValue(),
"顺序稳定");
assertEquals("B000000100", captor.getAllValues().get(9).get(9).getDataValue(),
"末批末行不丢失");
}
@Test
void test_task_048_asin_normal_repeated_operation_is_idempotent() {
// 幂等:重复执行同一输入不产生重复记录(唯一键 + INSERT IGNORE 语义),
// 每行最多被批量写入一次,空品牌行跳过不写。
when(invalidAsinDataMapper.insertBatchIgnore(anyList())).thenAnswer(
invocation -> ((List<?>) invocation.getArgument(0)).size());
List<CollectDataResultRowVo> rows = List.of(
row("B000000001", "Nike"),
row("B000000002", "Zara"),
row("B000000003", "")
);
int first = writer.writeBatch(rows);
int second = writer.writeBatch(rows);
assertEquals(2, first, "首轮 2 个合法行写入");
assertEquals(2, second, "重复执行同样只写合法行(幂等键由 SQL 保证)");
ArgumentCaptor<List<InvalidAsinDataEntity>> captor = ArgumentCaptor.forClass(List.class);
verify(invalidAsinDataMapper, times(2)).insertBatchIgnore(captor.capture());
assertEquals(2, captor.getAllValues().get(1).size(), "重复批次不含空品牌行");
}
@Test
void test_task_048_asin_boundary_empty_input() {
// 空输入:空列表与全空行均不发起任何批量写入调用,返回 0。
int written = writer.writeBatch(null);
assertEquals(0, written, "null 输入返回 0");
verify(invalidAsinDataMapper, never()).insertBatchIgnore(anyList());
int blankWritten = writer.writeBatch(List.of(row("", "nike"), row("B000000001", "")));
assertEquals(0, blankWritten, "无合法行返回 0");
verify(invalidAsinDataMapper, never()).insertBatchIgnore(anyList());
}
@Test
void test_task_048_asin_boundary_single_item() {
// 单元素:单行单品牌正常批量写入;批次大小 1 的边界也正常。
when(invalidAsinDataMapper.insertBatchIgnore(anyList())).thenReturn(1);
int written = writer.writeBatch(List.of(row("B000000001", "solo")));
assertEquals(1, written, "单行写入成功");
ArgumentCaptor<List<InvalidAsinDataEntity>> captor = ArgumentCaptor.forClass(List.class);
verify(invalidAsinDataMapper).insertBatchIgnore(captor.capture());
assertEquals(1, captor.getValue().size(), "单行批次");
assertEquals("solo", captor.getValue().get(0).getBrand(), "brand 小写");
}
@Test
void test_task_048_asin_boundary_limit_and_overflow() {
// 上限/超限:批次大小 310 行 = 4 批(3+3+3+1),无无界积累。
when(invalidAsinDataMapper.insertBatchIgnore(anyList())).thenAnswer(
invocation -> ((List<?>) invocation.getArgument(0)).size());
CollectDataInvalidAsinBatchWriter smallWriter =
new CollectDataInvalidAsinBatchWriter(invalidAsinDataMapper, 3);
List<CollectDataResultRowVo> rows = new ArrayList<>();
for (int i = 0; i < 10; i++) {
rows.add(row("B" + String.format("%09d", i + 1), "brand"));
}
int written = smallWriter.writeBatch(rows);
assertEquals(10, written, "全部写入");
ArgumentCaptor<List<InvalidAsinDataEntity>> captor = ArgumentCaptor.forClass(List.class);
verify(invalidAsinDataMapper, times(4)).insertBatchIgnore(captor.capture());
assertEquals(3, captor.getAllValues().get(0).size(), "首批 3 行");
assertEquals(3, captor.getAllValues().get(2).size(), "第三批 3 行");
assertEquals(1, captor.getAllValues().get(3).size(), "末批 1 行");
}
@Test
void test_task_048_asin_invalid_input_rejected() {
// 非法参数:缺失 ASIN 或缺失品牌的行跳过,不写入、不抛异常;
// null 行安全跳过不计数。
when(invalidAsinDataMapper.insertBatchIgnore(anyList())).thenReturn(1);
List<CollectDataResultRowVo> rows = new ArrayList<>();
rows.add(null);
rows.add(row(null, "nike"));
rows.add(row("B000000001", null));
rows.add(row(" B000000002 ", " nike "));
int written = writer.writeBatch(rows);
assertEquals(1, written, "仅完全合法的 1 行写入");
ArgumentCaptor<List<InvalidAsinDataEntity>> captor = ArgumentCaptor.forClass(List.class);
verify(invalidAsinDataMapper).insertBatchIgnore(captor.capture());
assertEquals(1, captor.getValue().size(), "跳过非法行");
assertEquals("B000000002", captor.getValue().get(0).getDataValue(),
"ASIN trim 后写入");
assertEquals("nike", captor.getValue().get(0).getBrand(), "brand trim 后小写");
}
@Test
void test_task_048_asin_dependency_failure_releases_resources() {
// 依赖失败:批量写入抛错时跳过该批(不中断、不泄漏),恢复后继续写入,
// 不抛出中断整个提交流程的异常。
doThrow(new RuntimeException("db down"))
.doReturn(2)
.when(invalidAsinDataMapper).insertBatchIgnore(anyList());
List<CollectDataResultRowVo> rows = new ArrayList<>();
for (int i = 0; i < 20; i++) {
rows.add(row("B" + String.format("%09d", i + 1), "brand"));
}
int written = writer.writeBatch(rows);
assertEquals(2, written, "首批失败跳过,第二批 2 行写入");
verify(invalidAsinDataMapper, times(2)).insertBatchIgnore(anyList());
ArgumentCaptor<List<InvalidAsinDataEntity>> captor = ArgumentCaptor.forClass(List.class);
verify(invalidAsinDataMapper, times(2)).insertBatchIgnore(captor.capture());
assertTrue(captor.getAllValues().get(1).size() > 0, "恢复后批次非空");
}
private static CollectDataResultRowVo row(String asin, String brand) {
CollectDataResultRowVo row = new CollectDataResultRowVo();
row.setAsin(asin);
row.setBrand(brand);
return row;
}
}
@@ -0,0 +1,167 @@
package com.nanri.aiimage.modules.collectdata.util;
import com.nanri.aiimage.common.exception.BusinessException;
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.assertDoesNotThrow;
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;
/**
* Task 42:限制采集解析的文件大小、最大行数和单 chunk 行数。
* CollectDataParseLimits 校验真实文件大小与累计行数/chunk 行数上限;
* 空输入与单元素通过,恰好等于上限通过、超过 1 被拒绝,非法配置被拒绝,
* 超限拒绝不残留状态。
*/
class CollectDataParseLimitsTest {
@TempDir
Path tempDir;
private static final long MAX_FILE_BYTES = 1024L;
private static final int MAX_TOTAL_ROWS = 1000;
private static final int MAX_CHUNK_ROWS = 100;
private CollectDataParseLimits limits() {
return new CollectDataParseLimits(MAX_FILE_BYTES, MAX_TOTAL_ROWS, MAX_CHUNK_ROWS);
}
@Test
void test_task_042_file_size_row_count_chunk_normal_default_path() {
// 正常输入:文件在大小上限内、累计行数与单 chunk 行数均未超限,全部校验通过。
CollectDataParseLimits limits = limits();
File file = writeFile("ok.xlsx", 512);
assertDoesNotThrow(() -> limits.validateSourceFile(file), "正常大小文件通过");
assertDoesNotThrow(() -> limits.validateTotalRowCount(500), "累计 500 行通过");
assertDoesNotThrow(() -> limits.validateChunkRowCount(50), "单 chunk 50 行通过");
}
@Test
void test_task_042_file_size_row_count_chunk_normal_multiple_items() {
// 批量场景:多个源文件累计行数按总和校验,未超限通过、超限被拒。
CollectDataParseLimits limits = limits();
limits.validateTotalRowCount(400);
limits.validateTotalRowCount(600);
assertDoesNotThrow(() -> limits.validateTotalRowCount(900), "两次累计 900 行未超限");
BusinessException ex = assertThrows(BusinessException.class,
() -> limits.validateTotalRowCount(1001), "累计超限被拒绝");
assertTrue(ex.getMessage().contains("行数"), "异常消息可识别");
}
@Test
void test_task_042_file_size_row_count_chunk_normal_repeated_operation_is_idempotent() {
// 幂等:同一输入重复校验结果一致,校验不产生残留状态。
CollectDataParseLimits limits = limits();
File file = writeFile("repeat.xlsx", 128);
assertDoesNotThrow(() -> limits.validateSourceFile(file));
assertDoesNotThrow(() -> limits.validateSourceFile(file));
assertDoesNotThrow(() -> limits.validateChunkRowCount(10));
assertDoesNotThrow(() -> limits.validateChunkRowCount(10));
BusinessException ex = assertThrows(BusinessException.class,
() -> limits.validateChunkRowCount(101), "重复校验后超限仍被拒绝");
assertTrue(ex.getMessage().contains("chunk"), "chunk 超限消息可识别");
}
@Test
void test_task_042_file_size_row_count_chunk_boundary_empty_input() {
// 空输入:0 字节文件与 0 行均通过,不创建无效资源。
CollectDataParseLimits limits = limits();
File empty = writeFile("empty.xlsx", 0);
assertDoesNotThrow(() -> limits.validateSourceFile(empty), "0 字节文件通过");
assertDoesNotThrow(() -> limits.validateTotalRowCount(0), "0 行通过");
assertDoesNotThrow(() -> limits.validateChunkRowCount(0), "0 chunk 行通过");
}
@Test
void test_task_042_file_size_row_count_chunk_boundary_single_item() {
// 单元素:单文件、单行、单 chunk 行不依赖批量路径,全部通过。
CollectDataParseLimits limits = limits();
File file = writeFile("single.xlsx", 64);
assertDoesNotThrow(() -> limits.validateSourceFile(file), "单文件通过");
assertDoesNotThrow(() -> limits.validateTotalRowCount(1), "单行通过");
assertDoesNotThrow(() -> limits.validateChunkRowCount(1), "单 chunk 行通过");
}
@Test
void test_task_042_file_size_row_count_chunk_boundary_limit_and_overflow() {
// 上限/超限:恰好等于上限通过,超过 1 个字节/行被拒绝。
CollectDataParseLimits limits = limits();
File exact = writeFile("exact.xlsx", MAX_FILE_BYTES);
assertDoesNotThrow(() -> limits.validateSourceFile(exact), "恰好等于文件上限通过");
assertDoesNotThrow(() -> limits.validateTotalRowCount(MAX_TOTAL_ROWS), "恰好等于行数上限通过");
assertDoesNotThrow(() -> limits.validateChunkRowCount(MAX_CHUNK_ROWS), "恰好等于 chunk 上限通过");
File overflow = writeFile("overflow.xlsx", MAX_FILE_BYTES + 1);
BusinessException fileEx = assertThrows(BusinessException.class,
() -> limits.validateSourceFile(overflow), "文件超限被拒绝");
assertTrue(fileEx.getMessage().contains("大小"), "文件超限消息可识别");
assertThrows(BusinessException.class,
() -> limits.validateTotalRowCount(MAX_TOTAL_ROWS + 1), "行数超限被拒绝");
assertThrows(BusinessException.class,
() -> limits.validateChunkRowCount(MAX_CHUNK_ROWS + 1), "chunk 超限被拒绝");
}
@Test
void test_task_042_file_size_row_count_chunk_invalid_input_rejected() {
// 非法输入:null/不存在文件、零与负配置值抛可识别异常。
CollectDataParseLimits limits = limits();
assertThrows(BusinessException.class,
() -> limits.validateSourceFile(null), "null 文件被拒绝");
assertThrows(BusinessException.class,
() -> limits.validateSourceFile(new File(tempDir.toFile(), "missing.xlsx")), "不存在文件被拒绝");
assertThrows(IllegalArgumentException.class,
() -> new CollectDataParseLimits(0, MAX_TOTAL_ROWS, MAX_CHUNK_ROWS), "0 文件上限被拒绝");
assertThrows(IllegalArgumentException.class,
() -> new CollectDataParseLimits(MAX_FILE_BYTES, -1, MAX_CHUNK_ROWS), "负行数上限被拒绝");
assertThrows(IllegalArgumentException.class,
() -> new CollectDataParseLimits(MAX_FILE_BYTES, MAX_TOTAL_ROWS, 0), "0 chunk 上限被拒绝");
}
@Test
void test_task_042_file_size_row_count_chunk_dependency_failure_releases_resources() {
// 依赖失败:超限文件被拒绝后不残留状态,后续正常输入仍通过;
// 临时文件可被正常删除(无句柄泄漏)。
CollectDataParseLimits limits = limits();
File big = writeFile("big.xlsx", MAX_FILE_BYTES * 2);
assertThrows(BusinessException.class, () -> limits.validateSourceFile(big), "超限文件被拒绝");
File normal = writeFile("after.xlsx", 32);
assertDoesNotThrow(() -> limits.validateSourceFile(normal), "拒绝后正常文件仍通过");
assertDoesNotThrow(() -> limits.validateChunkRowCount(5), "拒绝后 chunk 校验不受影响");
assertTrue(big.delete(), "被拒绝的超限临时文件可删除(无句柄泄漏)");
assertFalse(big.exists(), "临时文件已清理");
}
private File writeFile(String name, long bytes) {
try {
Path path = tempDir.resolve(name);
byte[] data = new byte[(int) bytes];
for (int i = 0; i < data.length; i++) {
data[i] = (byte) ('a' + i % 26);
}
Files.write(path, data);
return path.toFile();
} catch (Exception ex) {
throw new IllegalStateException("测试临时文件写入失败", ex);
}
}
}
@@ -0,0 +1,166 @@
package com.nanri.aiimage.modules.collectdata.util;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.nanri.aiimage.modules.collectdata.model.vo.CollectDataResultRowVo;
import org.junit.jupiter.api.Test;
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;
/**
* Task 41Collect Data 性能基线夹具。
* 覆盖 1k/10k 行、多个 chunk 与品牌检测(成功/失败/查询失败/空 ASIN)场景的
* 确定性生成与 payload 采样;同一输入重复生成结果一致(幂等),超限与非法
* 输入被拒绝,序列化失败抛可识别异常且不产生部分结果。
*/
class CollectDataPerfFixtureTest {
private final CollectDataPerfFixture fixture =
new CollectDataPerfFixture(new ObjectMapper());
private static final List<String> KEYWORDS = List.of("phone case", "iphone case", "samsung case");
@Test
void test_task_041_chunk_brand_normal_default_path() {
// 正常输入:1000 行 3 关键词 1 个失败品牌行,payload 与 chunk 统计正确。
List<CollectDataResultRowVo> rows =
fixture.generateRows(1000, KEYWORDS, 1, 0, 0);
CollectDataPerfFixture.Metrics metrics = fixture.samplePayload(rows, 200);
assertEquals(1000, rows.size(), "1000 行全部生成");
assertEquals(1000, metrics.rowCount(), "采样行数一致");
assertEquals(5, metrics.chunkCount(), "200 行一个 chunk 共 5 个");
assertTrue(metrics.payloadBytes() > 0, "payload 非空");
assertEquals(1, metrics.failedBrandRows(), "1 行失败品牌");
assertEquals(0, metrics.queryFailedBrandRows(), "无查询失败品牌");
assertEquals(0, metrics.blankAsinRows(), "无空 ASIN");
assertEquals(0, metrics.blankBrandRows(), "无空品牌");
assertTrue(rows.getFirst().getAsin().startsWith("B0"), "ASIN 确定性生成");
assertTrue(rows.getFirst().getKeyword() != null && !rows.getFirst().getKeyword().isBlank(), "关键词非空");
}
@Test
void test_task_041_chunk_brand_normal_multiple_items() {
// 批量场景:10k 行 5 关键词多 chunk,行数不丢失且按关键词循环顺序稳定。
List<String> keywords = List.of("a", "b", "c", "d", "e");
List<CollectDataResultRowVo> rows =
fixture.generateRows(10000, keywords, 500, 300, 200);
CollectDataPerfFixture.Metrics metrics = fixture.samplePayload(rows, 500);
assertEquals(10000, metrics.rowCount(), "10k 行不丢失");
assertEquals(20, metrics.chunkCount(), "500 行一个 chunk 共 20 个");
assertEquals(500, metrics.failedBrandRows(), "失败品牌行数稳定");
assertEquals(300, metrics.queryFailedBrandRows(), "查询失败品牌行数稳定");
assertEquals(200, metrics.blankAsinRows(), "空 ASIN 行数稳定");
for (int i = 0; i < rows.size(); i++) {
assertEquals(keywords.get(i % keywords.size()), rows.get(i).getKeyword(), "关键词循环分配");
}
}
@Test
void test_task_041_chunk_brand_normal_repeated_operation_is_idempotent() {
// 幂等:同一输入两次生成,行数与 payload 字节数完全一致,ASIN 可重复。
List<CollectDataResultRowVo> first =
fixture.generateRows(5000, KEYWORDS, 50, 25, 10);
List<CollectDataResultRowVo> second =
fixture.generateRows(5000, KEYWORDS, 50, 25, 10);
assertEquals(first.size(), second.size(), "行数幂等");
assertEquals(first.get(0).getAsin(), second.get(0).getAsin(), "首行 ASIN 幂等");
assertEquals(first.get(4999).getAsin(), second.get(4999).getAsin(), "末行 ASIN 幂等");
CollectDataPerfFixture.Metrics m1 = fixture.samplePayload(first, 1000);
CollectDataPerfFixture.Metrics m2 = fixture.samplePayload(second, 1000);
assertEquals(m1.payloadBytes(), m2.payloadBytes(), "payload 字节幂等");
assertEquals(m1.chunkCount(), m2.chunkCount(), "chunk 数幂等");
assertEquals(m1.failedBrandRows(), m2.failedBrandRows(), "失败品牌统计幂等");
}
@Test
void test_task_041_chunk_brand_boundary_empty_input() {
// 空输入:0 行返回空集合,采样统计全零且无 chunk。
List<CollectDataResultRowVo> rows = fixture.generateRows(0, KEYWORDS, 0, 0, 0);
CollectDataPerfFixture.Metrics metrics = fixture.samplePayload(rows, 200);
assertEquals(0, rows.size(), "空输入 0 行");
assertEquals(0, metrics.rowCount(), "采样行数 0");
assertEquals(0, metrics.chunkCount(), "空输入无 chunk");
assertEquals(0, metrics.payloadBytes(), "空输入无 payload");
assertEquals(0, metrics.failedBrandRows(), "空输入无失败品牌");
assertEquals(0, metrics.queryFailedBrandRows(), "空输入无查询失败品牌");
assertEquals(0, metrics.blankAsinRows(), "空输入无空 ASIN");
}
@Test
void test_task_041_chunk_brand_boundary_single_item() {
// 单元素:1 行 1 关键词单 chunk,不依赖批量路径,全部统计可算。
List<CollectDataResultRowVo> rows = fixture.generateRows(1, List.of("only"), 0, 0, 0);
CollectDataPerfFixture.Metrics metrics = fixture.samplePayload(rows, 200);
assertEquals(1, rows.size(), "单行");
assertEquals(1, metrics.chunkCount(), "单行一个 chunk");
assertEquals(1, metrics.rowCount(), "采样行数 1");
assertEquals(0, metrics.failedBrandRows(), "单行有效品牌");
assertTrue(metrics.payloadBytes() > 0, "单行 payload 非空");
assertEquals("only", rows.getFirst().getKeyword(), "单关键词正确");
assertEquals("FBA", rows.getFirst().getDeliveryMethod(), "单行配送方式确定");
}
@Test
void test_task_041_chunk_brand_boundary_limit_and_overflow() {
// 上限/超限:10k 行最大值可执行;超限行数、负计数、品牌合计超行数被拒绝。
List<CollectDataResultRowVo> maxRows = fixture.generateRows(10000, KEYWORDS, 0, 0, 0);
assertEquals(10000, maxRows.size(), "最大行数可执行");
assertThrows(IllegalArgumentException.class,
() -> fixture.generateRows(10001, KEYWORDS, 0, 0, 0), "超限行数被拒绝");
assertThrows(IllegalArgumentException.class,
() -> fixture.generateRows(100, KEYWORDS, -1, 0, 0), "负失败品牌计数被拒绝");
assertThrows(IllegalArgumentException.class,
() -> fixture.generateRows(100, KEYWORDS, 60, 50, 0), "品牌合计超行数被拒绝");
assertThrows(IllegalArgumentException.class,
() -> fixture.generateRows(100, KEYWORDS, 0, 0, 101), "空 ASIN 计数超行数被拒绝");
assertThrows(IllegalArgumentException.class,
() -> fixture.generateRows(100, List.of(), 0, 0, 0), "空关键词集合被拒绝");
}
@Test
void test_task_041_chunk_brand_invalid_input_rejected() {
// 非法输入:null 集合、null 行列表、非正 chunkSize、负行数抛可识别异常。
assertThrows(IllegalArgumentException.class,
() -> fixture.generateRows(-1, KEYWORDS, 0, 0, 0), "负行数被拒绝");
assertThrows(IllegalArgumentException.class,
() -> fixture.generateRows(100, null, 0, 0, 0), "null 关键词集合被拒绝");
assertThrows(IllegalArgumentException.class,
() -> fixture.samplePayload(null, 200), "null 行列表被拒绝");
assertThrows(IllegalArgumentException.class,
() -> fixture.samplePayload(List.of(), 0), "非正 chunkSize 被拒绝");
assertThrows(IllegalArgumentException.class,
() -> fixture.samplePayload(List.of(), -5), "负 chunkSize 被拒绝");
}
@Test
void test_task_041_chunk_brand_dependency_failure_releases_resources() throws Exception {
// 依赖失败:序列化失败抛 IllegalStateException 且不产生部分结果;
// 恢复后同一输入可重新采样且结果一致。
ObjectMapper spy = org.mockito.Mockito.spy(new ObjectMapper());
org.mockito.Mockito.doAnswer(invocation -> {
throw new java.io.IOException("serializer down");
}).when(spy).writeValueAsBytes(org.mockito.ArgumentMatchers.any());
CollectDataPerfFixture failingFixture = new CollectDataPerfFixture(spy);
List<CollectDataResultRowVo> rows = fixture.generateRows(500, KEYWORDS, 10, 5, 3);
IllegalStateException ex = assertThrows(IllegalStateException.class,
() -> failingFixture.samplePayload(rows, 100), "序列化失败必须抛出");
assertTrue(ex.getMessage().contains("采样序列化失败"), "异常消息可识别");
CollectDataPerfFixture.Metrics metrics = fixture.samplePayload(rows, 100);
assertEquals(500, metrics.rowCount(), "恢复后采样行数完整");
assertEquals(5, metrics.chunkCount(), "恢复后 chunk 数正确");
assertEquals(10, metrics.failedBrandRows(), "恢复后失败品牌统计正确");
assertEquals(5, metrics.queryFailedBrandRows(), "恢复后查询失败统计正确");
assertEquals(3, metrics.blankAsinRows(), "恢复后空 ASIN 统计正确");
}
}
@@ -0,0 +1,169 @@
package com.nanri.aiimage.modules.collectdata.util;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
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.doAnswer;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
/**
* Task 51:将 accepted 行的序列化和 hash 计算改为批量处理。
* CollectDataResultDetailCodec.encodeRefsWithHash 一次为整 chunk 生成
* 全部行的引用 JSON 及其 SHA-256 hash(一次迭代,offset 从 startOffset
* 递增),替代调用方逐行 encodeRef + hash 的两轮循环;同 chunk 重提时
* 结果逐字节一致(幂等)。空 count 返回空列表,超批量上限拒绝,
* 序列化失败抛可识别异常且不泄漏内部状态。
*/
class CollectDataResultDetailCodecBatchTest {
private CollectDataResultDetailCodec codec;
@BeforeEach
void setUp() {
codec = new CollectDataResultDetailCodec(new ObjectMapper());
}
@Test
void test_task_051_task_normal_default_path() {
// 正常批量:3 行 offset 10-12,引用 JSON 与逐行 encodeRef 一致,hash 为 SHA-256。
List<CollectDataResultDetailCodec.RefWithHash> refs =
codec.encodeRefsWithHash(1, 10, 3, "rustfs:detail/abc");
assertEquals(3, refs.size(), "3 行引用全部生成");
CollectDataResultDetailCodec.ChunkRef parsed0 = codec.parseRef(refs.get(0).refJson());
assertEquals(1, parsed0.chunkIndex(), "chunk 序号保留");
assertEquals(10, parsed0.offset(), "首行 offset=startOffset");
assertEquals("rustfs:detail/abc", parsed0.pointer(), "RustFS 指针保留");
assertEquals(11, codec.parseRef(refs.get(1).refJson()).offset(), "offset 递增");
assertEquals(12, codec.parseRef(refs.get(2).refJson()).offset(), "末行 offset");
assertEquals(codec.encodeRef(1, 12, "rustfs:detail/abc"), refs.get(2).refJson(),
"与逐行编码结果一致");
assertEquals(sha256(refs.get(1).refJson()), refs.get(1).payloadHash(), "hash 为引用 JSON 的 SHA-256");
}
@Test
void test_task_051_task_normal_multiple_items() {
// 大批量:75 行 offset 0-74,顺序稳定不丢失,逐行 hash 均正确。
List<CollectDataResultDetailCodec.RefWithHash> refs =
codec.encodeRefsWithHash(3, 0, 75, "rustfs:detail/x");
assertEquals(75, refs.size(), "75 行全部生成");
for (int i = 0; i < 75; i++) {
CollectDataResultDetailCodec.ChunkRef ref = codec.parseRef(refs.get(i).refJson());
assertEquals(3, ref.chunkIndex(), "chunk 序号一致");
assertEquals(i, ref.offset(), "" + i + " offset 顺序稳定");
assertEquals("rustfs:detail/x", ref.pointer(), "指针一致");
assertEquals(sha256(refs.get(i).refJson()), refs.get(i).payloadHash(), "" + i + " hash 正确");
}
}
@Test
void test_task_051_task_normal_repeated_operation_is_idempotent() {
// 幂等:同一输入重复批量生成,逐元素结果完全一致,不产生新差异。
List<CollectDataResultDetailCodec.RefWithHash> first =
codec.encodeRefsWithHash(0, 5, 20, "rustfs:detail/stable");
List<CollectDataResultDetailCodec.RefWithHash> second =
codec.encodeRefsWithHash(0, 5, 20, "rustfs:detail/stable");
assertEquals(first.size(), second.size(), "数量一致");
for (int i = 0; i < first.size(); i++) {
assertEquals(first.get(i).refJson(), second.get(i).refJson(), "" + i + " 引用一致");
assertEquals(first.get(i).payloadHash(), second.get(i).payloadHash(), "" + i + " hash 一致");
}
}
@Test
void test_task_051_task_boundary_empty_input() {
// 空输入:count=0 返回空列表,不创建任何资源。
assertEquals(0, codec.encodeRefsWithHash(0, 0, 0, "rustfs:x").size(), "count=0 空列表");
assertEquals(0, codec.encodeRefsWithHash(2, 100, 0, "rustfs:x").size(),
"任意合法 startOffset 下 count=0 仍为空列表");
}
@Test
void test_task_051_task_boundary_single_item() {
// 单元素:count=1 单行 offset 正确,hash 正确。
List<CollectDataResultDetailCodec.RefWithHash> refs =
codec.encodeRefsWithHash(0, 0, 1, "rustfs:detail/solo");
assertEquals(1, refs.size(), "单行生成");
CollectDataResultDetailCodec.ChunkRef parsed = codec.parseRef(refs.get(0).refJson());
assertEquals(0, parsed.offset(), "单行 offset 0");
assertEquals("rustfs:detail/solo", parsed.pointer(), "指针保留");
assertEquals(sha256(refs.get(0).refJson()), refs.get(0).payloadHash(), "hash 正确");
}
@Test
void test_task_051_task_boundary_limit_and_overflow() {
// 上限/超限:达到批量上限(100000)可正常生成;超过上限拒绝,
// 且在拒绝前不分配结果,不发生无界内存增长。
List<CollectDataResultDetailCodec.RefWithHash> atLimit = codec.encodeRefsWithHash(
0, 0, CollectDataResultDetailCodec.MAX_REFS_PER_BATCH, "rustfs:big");
assertEquals(CollectDataResultDetailCodec.MAX_REFS_PER_BATCH, atLimit.size(), "上限内正常生成");
assertEquals(0, codec.parseRef(atLimit.get(0).refJson()).offset(), "首行 offset 正确");
assertThrows(IllegalArgumentException.class, () -> codec.encodeRefsWithHash(
0, 0, CollectDataResultDetailCodec.MAX_REFS_PER_BATCH + 1, "rustfs:big"),
"超过上限拒绝");
}
@Test
void test_task_051_task_invalid_input_rejected() {
// 非法参数:负 chunk/负 startOffset/负 count/null 指针/空白指针均抛可识别异常。
assertThrows(IllegalArgumentException.class,
() -> codec.encodeRefsWithHash(-1, 0, 1, "rustfs:x"), "负 chunk 拒绝");
assertThrows(IllegalArgumentException.class,
() -> codec.encodeRefsWithHash(0, -1, 1, "rustfs:x"), "负 startOffset 拒绝");
assertThrows(IllegalArgumentException.class,
() -> codec.encodeRefsWithHash(0, 0, -1, "rustfs:x"), "负 count 拒绝");
assertThrows(IllegalArgumentException.class,
() -> codec.encodeRefsWithHash(0, 0, 1, null), "null 指针拒绝");
assertThrows(IllegalArgumentException.class,
() -> codec.encodeRefsWithHash(0, 0, 1, " "), "空白指针拒绝");
}
@Test
void test_task_051_task_dependency_failure_releases_resources() throws Exception {
// 依赖失败:序列化抛错时批量方法抛可识别异常,内部无残留状态;
// 依赖恢复后同一实例再次调用成功,无资源泄漏。
ObjectMapper mapper = mock(ObjectMapper.class);
when(mapper.writeValueAsString(any())).thenThrow(new JsonProcessingException("json down") {
});
CollectDataResultDetailCodec failingCodec = new CollectDataResultDetailCodec(mapper);
assertThrows(IllegalArgumentException.class,
() -> failingCodec.encodeRefsWithHash(0, 0, 2, "rustfs:x"),
"序列化失败抛可识别异常");
doAnswer(invocation -> invocation.getArgument(0).toString())
.when(mapper).writeValueAsString(any());
List<CollectDataResultDetailCodec.RefWithHash> refs =
failingCodec.encodeRefsWithHash(0, 0, 2, "rustfs:x");
assertEquals(2, refs.size(), "恢复后正常生成");
assertTrue(refs.get(0).refJson().contains("rustfs:x"), "引用内容完整");
}
private static String sha256(String value) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] bytes = digest.digest((value == null ? "" : value).getBytes(StandardCharsets.UTF_8));
StringBuilder sb = new StringBuilder(bytes.length * 2);
for (byte b : bytes) {
sb.append(String.format("%02x", b));
}
return sb.toString();
} catch (Exception ex) {
throw new IllegalStateException(ex);
}
}
}
@@ -0,0 +1,151 @@
package com.nanri.aiimage.modules.collectdata.util;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.nanri.aiimage.modules.collectdata.model.vo.CollectDataResultRowVo;
import org.junit.jupiter.api.BeforeEach;
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.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Task 49:将结果明细从逐行 RustFS 对象改为 chunk 级 payload 存储。
* CollectDataResultDetailCodec 负责:把整 chunk 的 accepted 行序列化为
* 一个明细数组 JSON(每 chunk 一个 RustFS 对象),并为每行生成
* {chunk, offset, payload} 引用 JSON(写入 biz_task_result_item.payload_json);
* 读侧按 chunk 引用一次解析数组、offset 取行。旧格式(payload_json 直接是
* 行 JSON)解析返回 null,由调用方按逐行旧路径兼容读取。
*/
class CollectDataResultDetailCodecTest {
private CollectDataResultDetailCodec codec;
@BeforeEach
void setUp() {
codec = new CollectDataResultDetailCodec(new ObjectMapper());
}
@Test
void test_task_049_payload_chunk_rustfs_normal_default_path() {
// 正常输入:encodeRef → parseRef 往返一致;chunk 明细数组序列化后
// rowAt 按 offset 取回同一行。
String ref = codec.encodeRef(2, 3, "rustfs:detail/abc");
CollectDataResultDetailCodec.ChunkRef parsed = codec.parseRef(ref);
assertEquals(2, parsed.chunkIndex(), "chunk 序号保留");
assertEquals(3, parsed.offset(), "行 offset 保留");
assertEquals("rustfs:detail/abc", parsed.pointer(), "RustFS 对象指针保留");
String detailJson = codec.encodeChunk(List.of(row("B000000001", "Nike"), row("B000000002", "Zara")));
CollectDataResultRowVo at1 = codec.rowAt(detailJson, 1);
assertEquals("B000000002", at1.getAsin(), "按 offset 取回第二行");
assertEquals("Zara", at1.getBrand(), "行字段完整保留(不做规范化)");
}
@Test
void test_task_049_payload_chunk_rustfs_normal_multiple_items() {
// 批量场景:多 chunk 多行,各自引用指向自己的 chunk 与 offset,互不串行。
String refChunk0 = codec.encodeRef(0, 1, "rustfs:detail/a");
String refChunk2 = codec.encodeRef(2, 0, "rustfs:detail/b");
List<CollectDataResultRowVo> rows0 = List.of(row("B000000001", "A"), row("B000000002", "B"));
List<CollectDataResultRowVo> rows2 = List.of(row("B000000101", "C"));
CollectDataResultDetailCodec.ChunkRef parsed0 = codec.parseRef(refChunk0);
CollectDataResultDetailCodec.ChunkRef parsed2 = codec.parseRef(refChunk2);
assertEquals(0, parsed0.chunkIndex(), "chunk0 序号");
assertEquals(1, parsed0.offset(), "chunk0 offset");
assertEquals(2, parsed2.chunkIndex(), "chunk2 序号");
assertEquals(0, parsed2.offset(), "chunk2 offset");
assertEquals("B000000002", codec.rowAt(codec.encodeChunk(rows0), parsed0.offset()).getAsin(), "chunk0 行");
assertEquals("B000000101", codec.rowAt(codec.encodeChunk(rows2), parsed2.offset()).getAsin(), "chunk2 行");
assertNull(codec.rowAt(codec.encodeChunk(rows2), 5), "chunk2 中越界 offset 返回 null");
}
@Test
void test_task_049_payload_chunk_rustfs_normal_repeated_operation_is_idempotent() {
// 幂等:同输入重复编码结果逐字节一致;重复解析引用结果一致;不产生新对象。
String first = codec.encodeChunk(List.of(row("B000000001", "Nike"), row("B000000002", "Zara")));
String second = codec.encodeChunk(List.of(row("B000000001", "Nike"), row("B000000002", "Zara")));
assertEquals(first, second, "重复序列化结果一致");
String ref = codec.encodeRef(1, 0, "rustfs:detail/x");
assertEquals(ref, codec.encodeRef(1, 0, "rustfs:detail/x"), "重复引用编码一致");
assertEquals(codec.parseRef(ref), codec.parseRef(ref), "重复解析一致");
}
@Test
void test_task_049_payload_chunk_rustfs_boundary_empty_input() {
// 空输入:空 chunk 明细编码为空数组;空/空白引用解析为 null(旧格式兼容)。
assertEquals("[]", codec.encodeChunk(List.of()), "空 chunk 编码为空数组");
assertEquals("[]", codec.encodeChunk(null), "null 明细编码为空数组");
assertNull(codec.parseRef(null), "null 引用返回 null");
assertNull(codec.parseRef(" "), "空白引用返回 null");
assertNull(codec.rowAt("[]", 0), "空数组取行返回 null");
}
@Test
void test_task_049_payload_chunk_rustfs_boundary_single_item() {
// 单元素:单行单 chunk offset 0 正确;单行明细取行正确。
String ref = codec.encodeRef(0, 0, "rustfs:detail/solo");
CollectDataResultDetailCodec.ChunkRef parsed = codec.parseRef(ref);
assertEquals(0, parsed.chunkIndex(), "单 chunk 序号");
assertEquals(0, parsed.offset(), "单行 offset 0");
String detailJson = codec.encodeChunk(List.of(row("B000000001", "solo")));
CollectDataResultRowVo value = codec.rowAt(detailJson, 0);
assertEquals("B000000001", value.getAsin(), "单行取回正确");
assertEquals("solo", value.getBrand(), "单行字段完整");
}
@Test
void test_task_049_payload_chunk_rustfs_boundary_limit_and_overflow() {
// 上限/超限:offset 越界或为负时 rowAt 安全返回 null,不抛异常、不越界。
String detailJson = codec.encodeChunk(List.of(row("B000000001", "Nike")));
assertNull(codec.rowAt(detailJson, 1), "offset 越界返回 null");
assertNull(codec.rowAt(detailJson, 100), "offset 远超行数返回 null");
assertNull(codec.rowAt(detailJson, -1), "负 offset 返回 null");
assertNull(codec.rowAt(null, 0), "null 明细返回 null");
assertNull(codec.rowAt("not-json", 0), "损坏明细返回 null");
}
@Test
void test_task_049_payload_chunk_rustfs_invalid_input_rejected() {
// 非法参数:非法 chunk/offset 编码引用时抛可识别异常;
// 非引用格式(旧逐行 JSON、数组、裸文本)解析返回 null 而非抛错。
assertThrows(IllegalArgumentException.class, () -> codec.encodeRef(-1, 0, "rustfs:x"), "负 chunk 拒绝");
assertThrows(IllegalArgumentException.class, () -> codec.encodeRef(0, -1, "rustfs:x"), "负 offset 拒绝");
assertThrows(IllegalArgumentException.class, () -> codec.encodeRef(0, 0, null), "null 指针拒绝");
assertNull(codec.parseRef("{\"brand\":\"nike\"}"), "旧格式行 JSON 返回 null");
assertNull(codec.parseRef("[1,2,3]"), "数组 JSON 返回 null");
assertNull(codec.parseRef("plain-text"), "裸文本返回 null");
assertNull(codec.parseRef("{\"chunk\":1,\"offset\":2}"), "缺 payload 字段返回 null");
}
@Test
void test_task_049_payload_chunk_rustfs_dependency_failure_releases_resources() {
// 依赖失败:引用 JSON 损坏(readTree 抛错)时解析返回 null
// 调用方可按旧格式逐行读取兜底,错误可恢复不泄漏。
String malformed = "{\"chunk\":1,\"offset\":";
assertNull(codec.parseRef(malformed), "损坏引用解析为 null 不抛异常");
String detailJson = codec.encodeChunk(new ArrayList<>());
CollectDataResultRowVo value = codec.rowAt(detailJson + "{broken", 0);
assertNull(value, "损坏明细取行返回 null 不抛异常");
assertTrue(codec.parseRef(codec.encodeRef(0, 0, "rustfs:x")) != null, "修复后可正常解析");
}
private static CollectDataResultRowVo row(String asin, String brand) {
CollectDataResultRowVo row = new CollectDataResultRowVo();
row.setAsin(asin);
row.setBrand(brand);
return row;
}
}
@@ -0,0 +1,205 @@
package com.nanri.aiimage.modules.collectdata.util;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.nanri.aiimage.modules.collectdata.model.vo.CollectDataResultRowVo;
import com.nanri.aiimage.modules.task.model.entity.TaskResultItemEntity;
import com.nanri.aiimage.modules.task.service.TransientPayloadStorageService;
import org.junit.jupiter.api.BeforeEach;
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.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* Task 52:生成结果文件时按 chunk 一次读取,取消逐行对象读取。
* CollectDataResultDetailReader 从 biz_task_result_item 解析行:引用格式
* 按 pointer 缓存整 chunk 明细(同一 chunk 对象只 resolve/解析一次),
* 再按 offset 取行;旧格式逐行兜底。空输入返回空列表,越界/损坏行安全
* 跳过,chunk 级读取失败降级跳过该 chunk(不中断其它 chunk),
* 旧格式读取失败仍抛可识别异常。
*/
class CollectDataResultDetailReaderTest {
private TransientPayloadStorageService transientPayloadStorageService;
private CollectDataResultDetailReader reader;
@BeforeEach
void setUp() {
transientPayloadStorageService = mock(TransientPayloadStorageService.class);
reader = new CollectDataResultDetailReader(
new CollectDataResultDetailCodec(new ObjectMapper()), new ObjectMapper(),
transientPayloadStorageService);
}
@Test
void test_task_052_chunk_normal_default_path() {
// 正常输入:多个 item 引用同一 chunk 对象,整 chunk 只 resolve 一次,
// 各行按 offset 取回,顺序与 item 顺序一致。
List<TaskResultItemEntity> items = List.of(
item(1L, refJson(0, 0, "rustfs:detail/a")),
item(2L, refJson(0, 1, "rustfs:detail/a")),
item(3L, refJson(0, 2, "rustfs:detail/a")));
when(transientPayloadStorageService.resolvePayload("rustfs:detail/a", "read collect data result detail failed"))
.thenReturn("[{\"asin\":\"B000000001\",\"brand\":\"Nike\"},"
+ "{\"asin\":\"B000000002\",\"brand\":\"Zara\"},"
+ "{\"asin\":\"B000000003\",\"brand\":\"Adidas\"}]");
List<CollectDataResultRowVo> rows = reader.readRows(items);
assertEquals(3, rows.size(), "3 行全部取回");
assertEquals("B000000001", rows.get(0).getAsin(), "首行正确");
assertEquals("B000000003", rows.get(2).getAsin(), "末行正确");
verify(transientPayloadStorageService, times(1)).resolvePayload(anyString(), anyString());
}
@Test
void test_task_052_chunk_normal_multiple_items() {
// 批量场景:多个 chunk 多个 item,各自按自己的 pointer 取回,不串行不丢失。
List<TaskResultItemEntity> items = new ArrayList<>();
for (int i = 0; i < 30; i++) {
int chunk = i / 10;
items.add(item((long) i + 1, refJson(chunk, i % 10, "rustfs:detail/c" + chunk)));
}
when(transientPayloadStorageService.resolvePayload("rustfs:detail/c0", "read collect data result detail failed"))
.thenReturn(rowsJson(0, 10));
when(transientPayloadStorageService.resolvePayload("rustfs:detail/c1", "read collect data result detail failed"))
.thenReturn(rowsJson(10, 10));
when(transientPayloadStorageService.resolvePayload("rustfs:detail/c2", "read collect data result detail failed"))
.thenReturn(rowsJson(20, 10));
List<CollectDataResultRowVo> rows = reader.readRows(items);
assertEquals(30, rows.size(), "30 行全部取回");
for (int i = 0; i < 30; i++) {
assertEquals("B" + String.format("%09d", i + 1), rows.get(i).getAsin(), "" + i + " 顺序稳定");
}
verify(transientPayloadStorageService, times(3)).resolvePayload(anyString(), anyString());
}
@Test
void test_task_052_chunk_normal_repeated_operation_is_idempotent() {
// 幂等:同一输入重复读取结果一致,不产生重复行;无状态残留。
String chunkJson = rowsJson(0, 2);
List<TaskResultItemEntity> items = List.of(
item(1L, refJson(0, 0, "rustfs:detail/x")),
item(2L, refJson(0, 1, "rustfs:detail/x")));
when(transientPayloadStorageService.resolvePayload("rustfs:detail/x", "read collect data result detail failed"))
.thenReturn(chunkJson);
List<CollectDataResultRowVo> first = reader.readRows(items);
List<CollectDataResultRowVo> second = reader.readRows(items);
assertEquals(first.size(), second.size(), "两次读取数量一致");
for (int i = 0; i < first.size(); i++) {
assertEquals(first.get(i).getAsin(), second.get(i).getAsin(), "" + i + " 内容一致");
}
}
@Test
void test_task_052_chunk_boundary_empty_input() {
// 空输入:空列表返回空结果,不发起任何读取。
List<CollectDataResultRowVo> rows = reader.readRows(List.of());
assertEquals(0, rows.size(), "空输入返回空列表");
verify(transientPayloadStorageService, times(0)).resolvePayload(anyString(), anyString());
}
@Test
void test_task_052_chunk_boundary_single_item() {
// 单元素:单个 item 单个 chunk,单行正确取回。
when(transientPayloadStorageService.resolvePayload("rustfs:detail/solo", "read collect data result detail failed"))
.thenReturn("[{\"asin\":\"B000000001\",\"brand\":\"solo\"}]");
List<CollectDataResultRowVo> rows = reader.readRows(List.of(item(1L, refJson(0, 0, "rustfs:detail/solo"))));
assertEquals(1, rows.size(), "单行取回");
assertEquals("B000000001", rows.get(0).getAsin(), "ASIN 正确");
assertEquals("solo", rows.get(0).getBrand(), "字段完整");
}
@Test
void test_task_052_chunk_boundary_limit_and_overflow() {
// 上限/超限:offset 越界 / chunk 数组缺失该 offset 时安全跳过该行,
// 不抛异常、不越界、不影响其他行。
when(transientPayloadStorageService.resolvePayload("rustfs:detail/big", "read collect data result detail failed"))
.thenReturn(rowsJson(0, 2));
List<TaskResultItemEntity> items = List.of(
item(1L, refJson(0, 0, "rustfs:detail/big")),
item(2L, refJson(0, 5, "rustfs:detail/big")),
item(3L, refJson(0, -1, "rustfs:detail/big")),
item(4L, refJson(0, 1, "rustfs:detail/big")));
List<CollectDataResultRowVo> rows = reader.readRows(items);
assertEquals(2, rows.size(), "越界行跳过,仅 2 行取回");
assertEquals("B000000001", rows.get(0).getAsin(), "越界前正常行");
assertEquals("B000000002", rows.get(1).getAsin(), "越界后正常行");
}
@Test
void test_task_052_chunk_invalid_input_rejected() {
// 非法参数:损坏的 chunk 明细 JSON 抛可识别异常(项目约定 BusinessException
// 语义),旧格式行 JSON 损坏同样拒绝而非静默吞错。
when(transientPayloadStorageService.resolvePayload("rustfs:detail/broken", "read collect data result detail failed"))
.thenReturn("[{\"asin\":\"B000000001\"");
List<TaskResultItemEntity> items = List.of(item(1L, refJson(0, 0, "rustfs:detail/broken")));
assertThrows(RuntimeException.class, () -> reader.readRows(items), "损坏明细抛可识别异常");
when(transientPayloadStorageService.resolvePayload("{\"asin\":\"B000000001\"", "read collect data result item failed"))
.thenReturn("{\"asin\":\"B000000001\"");
List<TaskResultItemEntity> oldItems = List.of(item(2L, "{\"asin\":\"B000000001\""));
assertThrows(RuntimeException.class, () -> reader.readRows(oldItems), "损坏旧格式行抛可识别异常");
}
@Test
void test_task_052_chunk_dependency_failure_releases_resources() {
// 依赖失败(task-58 起降级语义):chunk 级读取失败 → 跳过该 chunk
// 的引用行(降级为空结果,不抛异常、不中断其它 chunk),
// 依赖恢复后同一实例再次调用成功,无资源残留。
doThrow(new IllegalStateException("object storage down"))
.doReturn("[{\"asin\":\"B000000001\",\"brand\":\"back\"}]")
.when(transientPayloadStorageService)
.resolvePayload("rustfs:detail/down", "read collect data result detail failed");
List<TaskResultItemEntity> items = List.of(item(1L, refJson(0, 0, "rustfs:detail/down")));
List<CollectDataResultRowVo> degraded = reader.readRows(items);
assertEquals(0, degraded.size(), "读取失败 chunk 降级为空结果");
List<CollectDataResultRowVo> rows = reader.readRows(items);
assertEquals(1, rows.size(), "恢复后正常读取");
assertTrue(rows.get(0).getAsin().contains("B000000001"), "内容完整");
}
private static TaskResultItemEntity item(Long id, String payloadJson) {
TaskResultItemEntity entity = new TaskResultItemEntity();
entity.setId(id);
entity.setPayloadJson(payloadJson);
return entity;
}
private static String refJson(int chunk, int offset, String pointer) {
return "{\"chunk\":" + chunk + ",\"offset\":" + offset + ",\"payload\":\"" + pointer + "\"}";
}
private static String rowsJson(int start, int count) {
StringBuilder sb = new StringBuilder("[");
for (int i = 0; i < count; i++) {
if (i > 0) {
sb.append(",");
}
sb.append("{\"asin\":\"B").append(String.format("%09d", start + i + 1))
.append("\",\"brand\":\"brand").append(i).append("\"}");
}
return sb.append("]").toString();
}
}
@@ -0,0 +1,239 @@
package com.nanri.aiimage.modules.collectdata.util;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.nanri.aiimage.modules.collectdata.model.vo.CollectDataResultRowVo;
import com.nanri.aiimage.modules.task.mapper.TaskResultItemMapper;
import com.nanri.aiimage.modules.task.model.entity.TaskResultItemEntity;
import com.nanri.aiimage.modules.task.service.TransientPayloadStorageService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import java.util.ArrayList;
import java.util.List;
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.anyList;
import static org.mockito.Mockito.doThrow;
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 50:结果明细批量 upsert mapper 与幂等唯一键。
* CollectDataResultItemBatchWriter 把整 chunk 的 accepted 行批量 upsert 到
* biz_task_result_item:先一次批量查询现有行(payload_hash 相等即跳过,
* 幂等),再按唯一键 uk_task_scope_item 用一条 INSERT ... ON DUPLICATE
* KEY UPDATE 批量写入,替代逐行 select/insert/update。空输入零调用,
* 批量失败跳过该批可恢复,无无界内存增长。
*/
class CollectDataResultItemBatchWriterTest {
private TaskResultItemMapper taskResultItemMapper;
private TransientPayloadStorageService transientPayloadStorageService;
private CollectDataResultDetailCodec codec;
private CollectDataResultItemBatchWriter writer;
@BeforeEach
void setUp() {
taskResultItemMapper = mock(TaskResultItemMapper.class);
transientPayloadStorageService = mock(TransientPayloadStorageService.class);
codec = new CollectDataResultDetailCodec(new ObjectMapper());
writer = new CollectDataResultItemBatchWriter(taskResultItemMapper, codec, transientPayloadStorageService, 10);
}
@Test
void test_task_050_task_normal_default_path() {
// 正常输入:无现有行 → 一次 upsertBatch 全量写入,引用字段完整。
when(taskResultItemMapper.selectList(any())).thenReturn(List.of());
when(taskResultItemMapper.upsertBatch(anyList())).thenReturn(2);
List<CollectDataResultRowVo> rows = List.of(
row("B000000001", "Nike"), row("B000000002", "Zara"));
CollectDataResultItemBatchWriter.UpsertCounts counts =
writer.upsertAccepted(1L, 2L, "task:1", 0, rows, "rustfs:detail/abc");
assertEquals(2, counts.insertedOrUpdated(), "两行均写入");
assertEquals(0, counts.skipped(), "无跳过");
verify(taskResultItemMapper).selectList(any());
ArgumentCaptor<List> captor = ArgumentCaptor.forClass(List.class);
verify(taskResultItemMapper).upsertBatch(captor.capture());
List<TaskResultItemEntity> entities = captor.getValue();
assertEquals(2, entities.size(), "批量写入两行");
assertEquals("asin:B000000001", entities.get(0).getItemKey(), "item_key 语义键");
assertEquals("ACCEPTED", entities.get(0).getStatus(), "状态保持 ACCEPTED");
assertTrue(entities.get(0).getPayloadJson().contains("rustfs:detail/abc"), "引用含对象指针");
assertTrue(entities.get(0).getPayloadJson().contains("\"offset\":0"), "引用含 offset");
assertEquals(2L, entities.get(0).getResultId(), "result_id 保留");
assertEquals(1L, entities.get(0).getTaskId(), "task_id 保留");
}
@Test
void test_task_050_task_normal_multiple_items() {
// 批量场景:75 行(8 批次),顺序稳定不丢失,每批数量正确。
when(taskResultItemMapper.selectList(any())).thenReturn(List.of());
when(taskResultItemMapper.upsertBatch(anyList())).thenAnswer(
invocation -> ((List<?>) invocation.getArgument(0)).size());
List<CollectDataResultRowVo> rows = new ArrayList<>();
for (int i = 0; i < 75; i++) {
rows.add(row("B" + String.format("%09d", i + 1), "brand-" + (i % 7)));
}
CollectDataResultItemBatchWriter.UpsertCounts counts =
writer.upsertAccepted(1L, 2L, "task:1", 3, rows, "rustfs:detail/x");
assertEquals(75, counts.insertedOrUpdated(), "全部写入");
ArgumentCaptor<List> captor = ArgumentCaptor.forClass(List.class);
verify(taskResultItemMapper, times(8)).upsertBatch(captor.capture());
for (int i = 0; i < 8; i++) {
assertEquals(i < 7 ? 10 : 5, captor.getAllValues().get(i).size(), "批次 " + i + " 数量");
}
assertEquals("asin:B000000001", ((TaskResultItemEntity) captor.getAllValues().get(0).get(0)).getItemKey(),
"顺序稳定");
assertEquals("asin:B000000075", ((TaskResultItemEntity) captor.getAllValues().get(7).get(4)).getItemKey(),
"末批末行不丢失");
}
@Test
void test_task_050_task_normal_repeated_operation_is_idempotent() {
// 幂等:同一 chunk 重复提交时 payload_hash 相等 → 全部跳过不写入,
// 无重复记录、无重复写入调用。
String refJson = codec.encodeRef(0, 0, "rustfs:detail/x");
TaskResultItemEntity existing = new TaskResultItemEntity();
existing.setId(100L);
existing.setItemKey("asin:B000000001");
existing.setPayloadJson(refJson);
existing.setPayloadHash(sha256(refJson));
when(taskResultItemMapper.selectList(any())).thenReturn(List.of(existing));
CollectDataResultItemBatchWriter.UpsertCounts counts =
writer.upsertAccepted(1L, 2L, "task:1", 0,
List.of(row("B000000001", "Nike")), "rustfs:detail/x");
assertEquals(0, counts.insertedOrUpdated(), "hash 相等全部跳过");
assertEquals(1, counts.skipped(), "1 行跳过");
verify(taskResultItemMapper, never()).upsertBatch(anyList());
}
@Test
void test_task_050_task_boundary_empty_input() {
// 空输入:空列表零调用返回零计数。
CollectDataResultItemBatchWriter.UpsertCounts counts =
writer.upsertAccepted(1L, 2L, "task:1", 0, List.of(), "rustfs:x");
assertEquals(0, counts.insertedOrUpdated(), "空输入返回 0");
assertEquals(0, counts.skipped(), "空输入无跳过");
verify(taskResultItemMapper, never()).selectList(any());
verify(taskResultItemMapper, never()).upsertBatch(anyList());
}
@Test
void test_task_050_task_boundary_single_item() {
// 单元素:单行正常 upsert;hash 不同时更新,唯一键幂等;
// 旧格式逐行 payload(非引用)在升级为引用时物理删除,避免泄漏。
TaskResultItemEntity existing = new TaskResultItemEntity();
existing.setId(100L);
existing.setItemKey("asin:B000000001");
existing.setPayloadJson("{\"brand\":\"nike\",\"asin\":\"B000000001\"}");
existing.setPayloadHash("old-hash");
when(taskResultItemMapper.selectList(any())).thenReturn(List.of(existing));
when(taskResultItemMapper.upsertBatch(anyList())).thenReturn(1);
CollectDataResultItemBatchWriter.UpsertCounts counts =
writer.upsertAccepted(1L, 2L, "task:1", 1,
List.of(row("B000000001", "Nike")), "rustfs:new");
assertEquals(1, counts.insertedOrUpdated(), "hash 不同触发 upsert 更新");
verify(transientPayloadStorageService).deletePayloadIfPresent("{\"brand\":\"nike\",\"asin\":\"B000000001\"}");
ArgumentCaptor<List> captor = ArgumentCaptor.forClass(List.class);
verify(taskResultItemMapper).upsertBatch(captor.capture());
TaskResultItemEntity entity = (TaskResultItemEntity) captor.getValue().get(0);
assertEquals(100L, entity.getId(), "复用现有行 idON DUPLICATE 命中唯一键)");
assertTrue(entity.getPayloadJson().contains("rustfs:new"), "引用更新为新对象");
}
@Test
void test_task_050_task_boundary_limit_and_overflow() {
// 上限/超限:批次大小 310 行 = 4 批(3+3+3+1),无无界积累。
when(taskResultItemMapper.selectList(any())).thenReturn(List.of());
when(taskResultItemMapper.upsertBatch(anyList())).thenAnswer(
invocation -> ((List<?>) invocation.getArgument(0)).size());
CollectDataResultItemBatchWriter smallWriter =
new CollectDataResultItemBatchWriter(taskResultItemMapper, codec, transientPayloadStorageService, 3);
List<CollectDataResultRowVo> rows = new ArrayList<>();
for (int i = 0; i < 10; i++) {
rows.add(row("B" + String.format("%09d", i + 1), "brand"));
}
CollectDataResultItemBatchWriter.UpsertCounts counts = smallWriter.upsertAccepted(
1L, 2L, "task:1", 0, rows, "rustfs:x");
assertEquals(10, counts.insertedOrUpdated(), "全部写入");
ArgumentCaptor<List> captor = ArgumentCaptor.forClass(List.class);
verify(taskResultItemMapper, times(4)).upsertBatch(captor.capture());
assertEquals(3, captor.getAllValues().get(0).size(), "首批 3 行");
assertEquals(1, captor.getAllValues().get(3).size(), "末批 1 行");
}
@Test
void test_task_050_task_invalid_input_rejected() {
// 非法参数:null/空白 ASIN 的行跳过不写入;null 行安全跳过。
when(taskResultItemMapper.selectList(any())).thenReturn(List.of());
when(taskResultItemMapper.upsertBatch(anyList())).thenAnswer(
invocation -> ((List<?>) invocation.getArgument(0)).size());
List<CollectDataResultRowVo> rows = new ArrayList<>();
rows.add(null);
rows.add(row(null, "nike"));
rows.add(row("B000000001", "nike"));
CollectDataResultItemBatchWriter.UpsertCounts counts =
writer.upsertAccepted(1L, 2L, "task:1", 0, rows, "rustfs:x");
assertEquals(1, counts.insertedOrUpdated(), "仅合法行写入");
verify(taskResultItemMapper).upsertBatch(anyList());
}
@Test
void test_task_050_task_dependency_failure_releases_resources() {
// 依赖失败:批量 upsert 抛错时跳过该批不中断,恢复后继续,无资源泄漏。
when(taskResultItemMapper.selectList(any())).thenReturn(List.of());
doThrow(new RuntimeException("db down"))
.doAnswer(invocation -> ((List<?>) invocation.getArgument(0)).size())
.when(taskResultItemMapper).upsertBatch(anyList());
List<CollectDataResultRowVo> rows = new ArrayList<>();
for (int i = 0; i < 20; i++) {
rows.add(row("B" + String.format("%09d", i + 1), "brand"));
}
CollectDataResultItemBatchWriter.UpsertCounts counts =
writer.upsertAccepted(1L, 2L, "task:1", 0, rows, "rustfs:x");
assertEquals(10, counts.insertedOrUpdated(), "首批失败跳过,第二批 10 行写入");
verify(taskResultItemMapper, times(2)).upsertBatch(anyList());
}
private static CollectDataResultRowVo row(String asin, String brand) {
CollectDataResultRowVo row = new CollectDataResultRowVo();
row.setAsin(asin);
row.setBrand(brand);
return row;
}
private static String sha256(String value) {
try {
java.security.MessageDigest digest = java.security.MessageDigest.getInstance("SHA-256");
byte[] bytes = digest.digest((value == null ? "" : value).getBytes(java.nio.charset.StandardCharsets.UTF_8));
StringBuilder sb = new StringBuilder(bytes.length * 2);
for (byte b : bytes) {
sb.append(String.format("%02x", b));
}
return sb.toString();
} catch (Exception ex) {
throw new IllegalStateException(ex);
}
}
}
@@ -0,0 +1,253 @@
package com.nanri.aiimage.modules.collectdata.util;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.nanri.aiimage.modules.collectdata.model.vo.CollectDataResultRowVo;
import com.nanri.aiimage.modules.task.mapper.TaskResultItemMapper;
import com.nanri.aiimage.modules.task.model.entity.TaskResultItemEntity;
import com.nanri.aiimage.modules.task.service.TransientPayloadStorageService;
import org.junit.jupiter.api.BeforeEach;
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.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.Mockito.doThrow;
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 54:将 finalRowCount 从每个 chunk COUNT(*) 改为任务内增量计数。
* upsertAccepted 返回的 newlyInserted 是本次调用真实新增的行数(仅
* existing == null 的真 INSERThash 相等跳过与存量更新均不计入),
* 调用方(CollectDataService)按任务内累加得到 finalRowCount,替代每
* chunk 一次全表 COUNT(*);乱序提交下累计值与顺序无关,重提幂等不重复
* 计数,批量失败扣除未写入的新行。
*/
class CollectDataResultItemCountTest {
private TaskResultItemMapper taskResultItemMapper;
private TransientPayloadStorageService transientPayloadStorageService;
private CollectDataResultDetailCodec codec;
private CollectDataResultItemBatchWriter writer;
@BeforeEach
void setUp() {
taskResultItemMapper = mock(TaskResultItemMapper.class);
transientPayloadStorageService = mock(TransientPayloadStorageService.class);
codec = new CollectDataResultDetailCodec(new ObjectMapper());
writer = new CollectDataResultItemBatchWriter(taskResultItemMapper, codec, transientPayloadStorageService, 10);
}
@Test
void test_task_054_chunk_normal_default_path() {
// 正常路径:无现有行 → 2 行全部为真新增,newlyInserted=2(任务内累计基数)。
when(taskResultItemMapper.selectList(any())).thenReturn(List.of());
when(taskResultItemMapper.upsertBatch(anyList())).thenReturn(2);
CollectDataResultItemBatchWriter.UpsertCounts counts =
writer.upsertAccepted(1L, 2L, "task:1", 0,
List.of(row("B000000001", "Nike"), row("B000000002", "Zara")), "rustfs:detail/abc");
assertEquals(2, counts.newlyInserted(), "无存量时全部为新行");
assertEquals(2, counts.insertedOrUpdated(), "写入计数一致");
assertEquals(0, counts.skipped(), "无跳过");
}
@Test
void test_task_054_chunk_normal_multiple_items() {
// 批量场景(任务内累计):chunk0 已提交 B1/B2/B3 后,chunk1 再提交
// B3/B4/B5 → B3 跨 chunk 的 chunkIndex 不同 → 引用 hash 必然不同 → 走存量更新
// (行数不变,不计 newlyInserted);B4/B5 真新增 → newlyInserted=2。
// 任务内累计 3+2=5 等于表内最终行数,不依赖 COUNT(*)。
List<TaskResultItemEntity> existing = new ArrayList<>();
existing.add(existingItem("asin:B000000001", 0, 0, "rustfs:detail/x"));
existing.add(existingItem("asin:B000000002", 1, 0, "rustfs:detail/x"));
existing.add(existingItem("asin:B000000003", 2, 0, "rustfs:detail/x"));
when(taskResultItemMapper.selectList(any())).thenReturn(existing);
when(taskResultItemMapper.upsertBatch(anyList())).thenAnswer(
invocation -> ((List<?>) invocation.getArgument(0)).size());
CollectDataResultItemBatchWriter.UpsertCounts counts =
writer.upsertAccepted(1L, 2L, "task:1", 1,
List.of(row("B000000003", "Nike"), row("B000000004", "Zara"), row("B000000005", "Adidas")),
"rustfs:detail/x");
assertEquals(2, counts.newlyInserted(), "仅 B4/B5 为真新增,B3 跨 chunk 更新不新增行");
assertEquals(3, counts.insertedOrUpdated(), "3 行均触发写入(B3 更新 + B4/B5 插入)");
assertEquals(0, counts.skipped(), "跨 chunk hash 不同无跳过");
}
@Test
void test_task_054_chunk_normal_repeated_operation_is_idempotent() {
// 幂等:同一 chunk 完全重提(现有行 hash 全部相等)→ newlyInserted=0
// 不重复计数、不触发写入,finalRowCount 不虚高。
String refJson = codec.encodeRef(0, 0, "rustfs:detail/x");
TaskResultItemEntity existing = new TaskResultItemEntity();
existing.setId(100L);
existing.setItemKey("asin:B000000001");
existing.setPayloadJson(refJson);
existing.setPayloadHash(sha256(refJson));
when(taskResultItemMapper.selectList(any())).thenReturn(List.of(existing));
CollectDataResultItemBatchWriter.UpsertCounts counts =
writer.upsertAccepted(1L, 2L, "task:1", 0,
List.of(row("B000000001", "Nike")), "rustfs:detail/x");
assertEquals(0, counts.newlyInserted(), "重提不新增行");
assertEquals(0, counts.insertedOrUpdated(), "无写入");
assertEquals(1, counts.skipped(), "全部跳过");
verify(taskResultItemMapper, never()).upsertBatch(anyList());
}
@Test
void test_task_054_chunk_boundary_empty_input() {
// 空输入:空列表零调用,newlyInserted=0,任务内累计不变。
CollectDataResultItemBatchWriter.UpsertCounts counts =
writer.upsertAccepted(1L, 2L, "task:1", 0, List.of(), "rustfs:x");
assertEquals(0, counts.newlyInserted(), "空输入不新增");
assertEquals(0, counts.insertedOrUpdated(), "无写入");
assertEquals(0, counts.skipped(), "无跳过");
verify(taskResultItemMapper, never()).selectList(any());
}
@Test
void test_task_054_chunk_boundary_single_item() {
// 单元素:单行真新增 → newlyInserted=1;单行存量更新(hash 不同)→ 0。
when(taskResultItemMapper.selectList(any())).thenReturn(List.of());
when(taskResultItemMapper.upsertBatch(anyList())).thenReturn(1);
CollectDataResultItemBatchWriter.UpsertCounts insert =
writer.upsertAccepted(1L, 2L, "task:1", 0,
List.of(row("B000000001", "Nike")), "rustfs:new");
assertEquals(1, insert.newlyInserted(), "单行真新增计 1");
TaskResultItemEntity existing = new TaskResultItemEntity();
existing.setId(100L);
existing.setItemKey("asin:B000000001");
existing.setPayloadJson("{\"brand\":\"nike\",\"asin\":\"B000000001\"}");
existing.setPayloadHash("old-hash");
when(taskResultItemMapper.selectList(any())).thenReturn(List.of(existing));
when(taskResultItemMapper.upsertBatch(anyList())).thenReturn(1);
CollectDataResultItemBatchWriter.UpsertCounts update =
writer.upsertAccepted(1L, 2L, "task:1", 1,
List.of(row("B000000001", "Nike")), "rustfs:new");
assertEquals(0, update.newlyInserted(), "存量行更新(hash 不同)不新增行");
assertEquals(1, update.insertedOrUpdated(), "写入发生但行数不变");
}
@Test
void test_task_054_chunk_boundary_limit_and_overflow() {
// 上限/超限:75 行 8 批次全部真新增 → newlyInserted=75,无无界积累。
when(taskResultItemMapper.selectList(any())).thenReturn(List.of());
when(taskResultItemMapper.upsertBatch(anyList())).thenAnswer(
invocation -> ((List<?>) invocation.getArgument(0)).size());
List<CollectDataResultRowVo> rows = new ArrayList<>();
for (int i = 0; i < 75; i++) {
rows.add(row("B" + String.format("%09d", i + 1), "brand-" + (i % 7)));
}
CollectDataResultItemBatchWriter.UpsertCounts counts =
writer.upsertAccepted(1L, 2L, "task:1", 3, rows, "rustfs:x");
assertEquals(75, counts.newlyInserted(), "全量真新增");
assertEquals(75, counts.insertedOrUpdated(), "写入计数一致");
}
@Test
void test_task_054_chunk_invalid_input_rejected() {
// 非法参数:null 行 / null ASIN / 空白 ASIN 均跳过且不计数 → newlyInserted 只含合法行。
when(taskResultItemMapper.selectList(any())).thenReturn(List.of());
when(taskResultItemMapper.upsertBatch(anyList())).thenAnswer(
invocation -> ((List<?>) invocation.getArgument(0)).size());
List<CollectDataResultRowVo> rows = new ArrayList<>();
rows.add(null);
rows.add(row(null, "nike"));
rows.add(row(" ", "zara"));
rows.add(row("B000000001", "nike"));
CollectDataResultItemBatchWriter.UpsertCounts counts =
writer.upsertAccepted(1L, 2L, "task:1", 0, rows, "rustfs:x");
assertEquals(1, counts.newlyInserted(), "仅合法行计数");
assertEquals(1, counts.insertedOrUpdated(), "仅合法行写入");
}
@Test
void test_task_054_chunk_dependency_failure_releases_resources() {
// 依赖失败:首批 upsertBatch 抛错 → 该批真新增行未落库,newlyInserted 扣除;
// 恢复后重提 → 已落库行跳过、未落库行补插,任务内累计仍等于表内真实行数。
when(taskResultItemMapper.selectList(any())).thenReturn(List.of());
doThrow(new RuntimeException("db down"))
.doAnswer(invocation -> ((List<?>) invocation.getArgument(0)).size())
.when(taskResultItemMapper).upsertBatch(anyList());
List<CollectDataResultRowVo> rows = new ArrayList<>();
for (int i = 0; i < 20; i++) {
rows.add(row("B" + String.format("%09d", i + 1), "brand"));
}
CollectDataResultItemBatchWriter.UpsertCounts first =
writer.upsertAccepted(1L, 2L, "task:1", 0, rows, "rustfs:x");
assertEquals(10, first.newlyInserted(), "首批失败扣除,仅第二批 10 行真新增");
// 重提同一 chunk:第二批 10 行 hash 相等跳过,首批 10 行补插 → newlyInserted=10。
List<TaskResultItemEntity> existing = new ArrayList<>();
for (int i = 10; i < 20; i++) {
String refJson = codec.encodeRef(0, i, "rustfs:x");
TaskResultItemEntity entity = new TaskResultItemEntity();
entity.setId(100L + i);
entity.setItemKey("asin:B" + String.format("%09d", i + 1));
entity.setPayloadJson(refJson);
entity.setPayloadHash(sha256(refJson));
existing.add(entity);
}
when(taskResultItemMapper.selectList(any())).thenReturn(existing);
CollectDataResultItemBatchWriter.UpsertCounts retry =
writer.upsertAccepted(1L, 2L, "task:1", 0, rows, "rustfs:x");
assertEquals(10, retry.newlyInserted(), "补插首批 10 行");
assertEquals(10, retry.skipped(), "第二批存量跳过");
assertEquals(20, first.newlyInserted() + retry.newlyInserted(), "任务内累计=表内真实行数");
}
private static TaskResultItemEntity existingItem(String itemKey, int offset, int chunkIndex, String pointer) {
TaskResultItemEntity entity = new TaskResultItemEntity();
entity.setId(100L + offset);
entity.setItemKey(itemKey);
String refJson = new CollectDataResultDetailCodec(new ObjectMapper()).encodeRef(chunkIndex, offset, pointer);
entity.setPayloadJson(refJson);
entity.setPayloadHash(sha256(refJson));
return entity;
}
private static CollectDataResultRowVo row(String asin, String brand) {
CollectDataResultRowVo row = new CollectDataResultRowVo();
row.setAsin(asin);
row.setBrand(brand);
return row;
}
private static String sha256(String value) {
try {
java.security.MessageDigest digest = java.security.MessageDigest.getInstance("SHA-256");
byte[] bytes = digest.digest((value == null ? "" : value).getBytes(java.nio.charset.StandardCharsets.UTF_8));
StringBuilder sb = new StringBuilder(bytes.length * 2);
for (byte b : bytes) {
sb.append(String.format("%02x", b));
}
return sb.toString();
} catch (Exception ex) {
throw new IllegalStateException(ex);
}
}
}
@@ -0,0 +1,208 @@
package com.nanri.aiimage.modules.collectdata.util;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.nanri.aiimage.modules.collectdata.model.vo.CollectDataResultRowVo;
import com.nanri.aiimage.modules.task.model.entity.TaskResultItemEntity;
import com.nanri.aiimage.modules.task.service.TransientPayloadStorageService;
import org.junit.jupiter.api.BeforeEach;
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.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* Task 58:外部品牌服务不可用、RustFS 超时和重复 chunk 的降级测试。
* 新增降级语义:chunk 级引用读取失败(RustFS 超时/不可用)时跳过该 chunk
* 的引用行并记录 warn,不中断其它 chunk 的行读取;旧格式单行读取失败仍抛
* 可识别异常(不可降级,避免静默丢数据)。重复 chunk(多 item 引用同一
* RustFS 对象)只 resolve 一次,失败降级对同一 chunk 只发生一次。
*/
class CollectDataRustfsDegradeTest {
private TransientPayloadStorageService transientPayloadStorageService;
private CollectDataResultDetailReader reader;
@BeforeEach
void setUp() {
transientPayloadStorageService = mock(TransientPayloadStorageService.class);
reader = new CollectDataResultDetailReader(
new CollectDataResultDetailCodec(new ObjectMapper()), new ObjectMapper(),
transientPayloadStorageService);
}
@Test
void test_task_058_chunk_brand_rustfs_normal_default_path() {
// 正常路径:无失败时行为与既有一致,多 chunk 全部取回,无降级误触发。
List<TaskResultItemEntity> items = List.of(
item(1L, refJson(0, 0, "rustfs:detail/a")),
item(2L, refJson(0, 1, "rustfs:detail/a")),
item(3L, refJson(1, 0, "rustfs:detail/b")));
when(transientPayloadStorageService.resolvePayload("rustfs:detail/a", "read collect data result detail failed"))
.thenReturn(rowsJson(0, 2));
when(transientPayloadStorageService.resolvePayload("rustfs:detail/b", "read collect data result detail failed"))
.thenReturn(rowsJson(2, 1));
List<CollectDataResultRowVo> rows = reader.readRows(items);
assertEquals(3, rows.size(), "3 行全部取回");
assertEquals("B000000001", rows.get(0).getAsin(), "chunk a 首行");
assertEquals("B000000003", rows.get(2).getAsin(), "chunk b 行");
}
@Test
void test_task_058_chunk_brand_rustfs_normal_multiple_items() {
// 批量场景(外部服务不可用降级):chunk a 读取失败 → 其引用行降级跳过,
// chunk b/c 正常取回,顺序稳定不丢失。
when(transientPayloadStorageService.resolvePayload("rustfs:detail/down", "read collect data result detail failed"))
.thenThrow(new IllegalStateException("rustfs read timeout"));
when(transientPayloadStorageService.resolvePayload("rustfs:detail/ok1", "read collect data result detail failed"))
.thenReturn(rowsJson(0, 2));
when(transientPayloadStorageService.resolvePayload("rustfs:detail/ok2", "read collect data result detail failed"))
.thenReturn(rowsJson(2, 2));
List<TaskResultItemEntity> items = List.of(
item(1L, refJson(0, 0, "rustfs:detail/down")),
item(2L, refJson(0, 1, "rustfs:detail/down")),
item(3L, refJson(1, 0, "rustfs:detail/ok1")),
item(4L, refJson(1, 1, "rustfs:detail/ok1")),
item(5L, refJson(2, 0, "rustfs:detail/ok2")),
item(6L, refJson(2, 1, "rustfs:detail/ok2")));
List<CollectDataResultRowVo> rows = reader.readRows(items);
assertEquals(4, rows.size(), "失败 chunk 降级跳过 2 行,其余 4 行取回");
assertEquals("B000000001", rows.get(0).getAsin(), "ok1 首行");
assertEquals("B000000003", rows.get(2).getAsin(), "ok2 行顺序稳定");
}
@Test
void test_task_058_chunk_brand_rustfs_normal_repeated_operation_is_idempotent() {
// 幂等(重复 chunk):多个 item 引用同一失败对象 → 降级只发生一次、
// 结果稳定;重复读取结果一致,无状态残留。
when(transientPayloadStorageService.resolvePayload("rustfs:detail/x", "read collect data result detail failed"))
.thenThrow(new IllegalStateException("rustfs down"))
.thenReturn(rowsJson(0, 2));
List<TaskResultItemEntity> items = List.of(
item(1L, refJson(0, 0, "rustfs:detail/x")),
item(2L, refJson(0, 1, "rustfs:detail/x")));
List<CollectDataResultRowVo> first = reader.readRows(items);
assertEquals(0, first.size(), "失败时整 chunk 降级为空");
verify(transientPayloadStorageService, times(1)).resolvePayload(anyString(), anyString());
// 恢复后再次读取:本次调用重新 resolve,chunk 行全部取回。
List<CollectDataResultRowVo> second = reader.readRows(items);
assertEquals(2, second.size(), "恢复后同一实例再次读取成功");
assertEquals("B000000002", second.get(1).getAsin(), "两行均恢复");
}
@Test
void test_task_058_chunk_brand_rustfs_boundary_empty_input() {
// 空输入:空列表安全跳过,不发起读取,不触发降级。
List<CollectDataResultRowVo> rows = reader.readRows(List.of());
assertEquals(0, rows.size(), "空输入返回空列表");
verify(transientPayloadStorageService, times(0)).resolvePayload(anyString(), anyString());
}
@Test
void test_task_058_chunk_brand_rustfs_boundary_single_item() {
// 单元素:单个 chunk 读取失败 → 返回空结果不抛异常(降级路径不依赖批量)。
when(transientPayloadStorageService.resolvePayload("rustfs:detail/solo", "read collect data result detail failed"))
.thenThrow(new IllegalStateException("rustfs timeout"));
List<CollectDataResultRowVo> rows = reader.readRows(List.of(item(1L, refJson(0, 0, "rustfs:detail/solo"))));
assertEquals(0, rows.size(), "单 chunk 失败降级为空结果");
}
@Test
void test_task_058_chunk_brand_rustfs_boundary_limit_and_overflow() {
// 上限/超限:全部 chunk 失败(10 个不同对象)→ 空结果,各 resolve 一次,
// 无无界累积、无重复调用。
List<TaskResultItemEntity> items = new ArrayList<>();
for (int i = 0; i < 10; i++) {
items.add(item((long) i + 1, refJson(i, 0, "rustfs:detail/fail" + i)));
}
for (int i = 0; i < 10; i++) {
when(transientPayloadStorageService.resolvePayload("rustfs:detail/fail" + i, "read collect data result detail failed"))
.thenThrow(new IllegalStateException("rustfs timeout"));
}
List<CollectDataResultRowVo> rows = reader.readRows(items);
assertEquals(0, rows.size(), "全部失败降级为空");
verify(transientPayloadStorageService, times(10)).resolvePayload(anyString(), anyString());
}
@Test
void test_task_058_chunk_brand_rustfs_invalid_input_rejected() {
// 非法参数:旧格式单行读取失败不可降级(抛可识别异常,避免静默丢数据);
// chunk 明细 JSON 损坏同样拒绝。
when(transientPayloadStorageService.resolvePayload("rustfs:detail/broken", "read collect data result detail failed"))
.thenReturn("[{\"asin\":\"B000000001\"");
assertThrows(RuntimeException.class,
() -> reader.readRows(List.of(item(1L, refJson(0, 0, "rustfs:detail/broken")))),
"损坏 chunk JSON 仍抛异常(数据损坏不降级)");
when(transientPayloadStorageService.resolvePayload("{\"asin\":\"B000000001\"", "read collect data result item failed"))
.thenThrow(new IllegalStateException("rustfs down"));
assertThrows(RuntimeException.class,
() -> reader.readRows(List.of(item(2L, "{\"asin\":\"B000000001\""))),
"旧格式行读取失败抛可识别异常(不可降级)");
}
@Test
void test_task_058_chunk_brand_rustfs_dependency_failure_releases_resources() {
// 依赖失败(品牌服务不可用语义):部分 chunk 失败降级、其余正常;
// 依赖恢复后同一实例再次调用全部取回,无资源残留。
when(transientPayloadStorageService.resolvePayload("rustfs:detail/partial", "read collect data result detail failed"))
.thenThrow(new IllegalStateException("brand service down"))
.thenReturn(rowsJson(0, 2), rowsJson(0, 2));
when(transientPayloadStorageService.resolvePayload("rustfs:detail/keep", "read collect data result detail failed"))
.thenReturn(rowsJson(2, 1));
List<TaskResultItemEntity> items = List.of(
item(1L, refJson(0, 0, "rustfs:detail/partial")),
item(2L, refJson(0, 1, "rustfs:detail/partial")),
item(3L, refJson(1, 0, "rustfs:detail/keep")));
List<CollectDataResultRowVo> degraded = reader.readRows(items);
assertEquals(1, degraded.size(), "失败 chunk 降级,keep 正常");
assertTrue(degraded.get(0).getAsin().contains("B000000003"), "正常 chunk 行不丢失");
verify(transientPayloadStorageService, times(1)).resolvePayload("rustfs:detail/partial", "read collect data result detail failed");
List<CollectDataResultRowVo> recovered = reader.readRows(items);
assertEquals(3, recovered.size(), "恢复后全部取回,无残留");
}
private static TaskResultItemEntity item(Long id, String payloadJson) {
TaskResultItemEntity entity = new TaskResultItemEntity();
entity.setId(id);
entity.setPayloadJson(payloadJson);
return entity;
}
private static String refJson(int chunk, int offset, String pointer) {
return "{\"chunk\":" + chunk + ",\"offset\":" + offset + ",\"payload\":\"" + pointer + "\"}";
}
private static String rowsJson(int start, int count) {
StringBuilder sb = new StringBuilder("[");
for (int i = 0; i < count; i++) {
if (i > 0) {
sb.append(",");
}
sb.append("{\"asin\":\"B").append(String.format("%09d", start + i + 1))
.append("\",\"brand\":\"brand").append(i).append("\"}");
}
return sb.append("]").toString();
}
}
@@ -0,0 +1,173 @@
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 org.springframework.mock.web.MockMultipartFile;
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 43:将采集源文件查找改为确定路径/索引查询。
* saveTempFile 记录 fileKey → 文件名索引;findLocalSourceFile 优先按索引
* 直接构造确定路径(File.exists 验证),目录枚举仅作为索引缺失/过期时的
* 兜底。索引有容量上限(超限淘汰最旧条目),进程重启(新实例无索引)或
* 文件被清理后仍能通过兜底得到与索引一致的结果。
*/
class LocalFileStorageSourceIndexTest {
@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_043_collect_normal_default_path() throws Exception {
// 正常输入:上传后按 key 解析,索引路径直接命中,内容一致。
String key = uploadFile(key(0), "xlsx", "sheet-data");
File resolved = service.findLocalSourceFile(key);
assertNotNull(resolved, "索引查询命中源文件");
assertTrue(resolved.isFile());
assertEquals(key + ".xlsx", resolved.getName(), "索引路径为 fileKey.ext 确定名");
assertEquals("sheet-data", readFile(resolved));
}
@Test
void test_task_043_collect_normal_multiple_items() throws Exception {
// 批量场景:多个上传文件各自命中自己的索引条目,互不串扰。
String[] keys = new String[5];
for (int i = 0; i < 5; i++) {
keys[i] = uploadFile(key(i), "xlsx", "content-" + i);
}
for (int i = 0; i < 5; i++) {
File resolved = service.findLocalSourceFile(keys[i]);
assertNotNull(resolved, "key-" + i + " 索引命中");
assertEquals("content-" + i, readFile(resolved), "key-" + i + " 内容正确");
}
}
@Test
void test_task_043_collect_normal_repeated_operation_is_idempotent() throws Exception {
// 幂等:同一 key 重复解析返回同一文件。
String key = uploadFile(key(0), "csv", "data");
File first = service.findLocalSourceFile(key);
File second = service.findLocalSourceFile(key);
File third = service.findLocalSourceFile(key);
assertNotNull(first);
assertEquals(first.getAbsolutePath(), second.getAbsolutePath(), "重复解析路径一致");
assertEquals(first.getAbsolutePath(), third.getAbsolutePath());
}
@Test
void test_task_043_collect_boundary_empty_input() {
// 空输入:无索引且目录为空时返回 null;子目录文件不参与索引。
File subDir = new File(tempDir.toFile(), "sub");
assertTrue(subDir.mkdirs());
writeSourceFileInto(subDir, key(1), "csv", "decoy");
assertNull(service.findLocalSourceFile(key(2)), "不存在的 key 返回 null");
assertNull(service.findLocalSourceFile(" "), "空白 key 安全返回 null");
}
@Test
void test_task_043_collect_boundary_single_item() throws Exception {
// 单元素:单个上传文件解析正确,不依赖批量路径。
String key = uploadFile(key(0), "csv", "single");
File resolved = service.findLocalSourceFile(key);
assertNotNull(resolved);
assertEquals("single", readFile(resolved));
}
@Test
void test_task_043_collect_boundary_limit_and_overflow() throws Exception {
// 上限/超限:索引容量超限后最旧条目被淘汰,但兜底枚举仍能解析目标文件。
String[] uploaded = new String[50];
for (int i = 0; i < 50; i++) {
uploaded[i] = uploadFile(key(i), "xlsx", "bulk-" + i);
}
// 索引容量按最旧优先淘汰;无论是否淘汰,目标文件都必须可解析(兜底路径)。
for (int probe : new int[]{0, 25, 49}) {
File resolved = service.findLocalSourceFile(uploaded[probe]);
assertNotNull(resolved, "大量文件后 key-" + probe + " 仍可解析");
assertEquals("bulk-" + probe, readFile(resolved));
}
}
@Test
void test_task_043_collect_invalid_input_rejected() {
// 非法参数:null key 与路径穿越 key 被拒绝,不产生索引访问。
assertNull(service.findLocalSourceFile(null), "null key 安全返回 null");
assertNull(service.findLocalSourceFile("../../etc/passwd"), "路径穿越 key 被拒绝");
assertNull(service.findLocalSourceFile("sub/" + key(0)), "含分隔符 key 被拒绝");
}
@Test
void test_task_043_collect_dependency_failure_releases_resources() throws Exception {
// 依赖失败:文件被清理后索引条目过期,解析返回 null 且不抛异常;
// 新实例(进程重启,无索引)通过兜底枚举解析同一文件,结果与索引一致。
String key = uploadFile(key(0), "xlsx", "temp");
File resolved = service.findLocalSourceFile(key);
assertNotNull(resolved, "索引命中");
assertTrue(resolved.delete(), "模拟文件被清理");
assertNull(service.findLocalSourceFile(key), "索引条目过期后返回 null");
String key2 = uploadFile(key(1), "xlsx", "after-restart");
LocalFileStorageService freshService =
new LocalFileStorageService(storageProperties);
File viaFallback = freshService.findLocalSourceFile(key2);
assertNotNull(viaFallback, "新实例无索引,兜底枚举仍命中");
assertEquals(key2 + ".xlsx", viaFallback.getName(), "兜底结果与索引命名一致");
assertEquals("after-restart", readFile(viaFallback));
}
private static String key(int index) {
return String.format("%032d", index);
}
private String uploadFile(String fileKey, String ext, String content) throws Exception {
MockMultipartFile multipart = new MockMultipartFile(
"file", fileKey + "." + ext, "application/octet-stream",
content.getBytes(StandardCharsets.UTF_8));
// 上传后返回的 fileKey 即平铺文件名前缀;用上传返回的 key 验证索引
return service.saveTempFile(multipart, "uploads/20260830").getFileKey();
}
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,218 @@
package com.nanri.aiimage.modules.file.service.object;
import com.nanri.aiimage.config.TransientStorageProperties;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
import io.minio.MinioClient;
import io.minio.PutObjectArgs;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentMatchers;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
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.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* Task 67:复用 RustFS/MinIO 客户端与 HTTP 连接池,减少每次操作创建客户端。
* RustfsObjectStorageService 懒加载缓存共享 MinioClientdouble-check 单例),
* 后续操作复用同一实例与同一 OkHttpClient(连接池随实例共享),
* 不再每次操作新建客户端;supplier 注入路径优先且不污染共享缓存;
* 未配置/非法配置时不创建实例。
*/
class RustfsClientReuseTest {
private TransientStorageProperties properties;
private SimpleMeterRegistry simpleRegistry;
private MinioClient client;
private ObjectProvider<MeterRegistry> meterRegistryProvider;
private ObjectProvider<RustfsDeleteRetryService> deleteRetryProvider;
private RustfsObjectStorageService service;
@BeforeEach
@SuppressWarnings("unchecked")
void setUp() {
properties = new TransientStorageProperties();
simpleRegistry = new SimpleMeterRegistry();
meterRegistryProvider = mock(ObjectProvider.class);
when(meterRegistryProvider.getIfAvailable()).thenReturn(simpleRegistry);
deleteRetryProvider = mock(ObjectProvider.class);
client = mock(MinioClient.class);
// 默认无 supplier → 走共享 MinioClient 缓存路径
service = new RustfsObjectStorageService(properties, meterRegistryProvider, deleteRetryProvider, null);
}
@AfterEach
void tearDown() {
simpleRegistry.clear();
}
private static void setConfigured(TransientStorageProperties properties) {
properties.setEndpoint("http://rustfs.local:9000");
properties.setBucket("bucket");
properties.setAccessKeyId("ak");
properties.setAccessKeySecret("sk");
properties.setRegion("us-east-1");
}
private Object sharedMinioClient() {
return ReflectionTestUtils.getField(service, "sharedMinioClient");
}
private Object buildClient(long deadlineNanos) {
return ReflectionTestUtils.invokeMethod(service, "buildClient", deadlineNanos);
}
@Test
void test_task_067_rustfs_normal_default_path() {
// 默认路径:共享缓存懒加载创建 MinioClient,第二次调用复用同一实例。
setConfigured(properties);
Object first = buildClient(Long.MAX_VALUE);
Object second = buildClient(Long.MAX_VALUE);
assertNotNull(first, "首次构建出客户端");
assertSame(first, second, "复用同一 MinioClient 实例");
assertSame(first, sharedMinioClient(), "共享缓存字段已填充且一致");
}
@Test
void test_task_067_rustfs_normal_multiple_items() throws Exception {
// 批量场景:并发多操作同时构建客户端只产生一个共享实例,无实例爆炸。
setConfigured(properties);
int concurrency = 16;
CountDownLatch start = new CountDownLatch(1);
Set<Object> instances = ConcurrentHashMap.newKeySet();
Thread[] threads = new Thread[concurrency];
for (int i = 0; i < concurrency; i++) {
threads[i] = new Thread(() -> {
try {
start.await(2, TimeUnit.SECONDS);
instances.add(buildClient(Long.MAX_VALUE));
} catch (Exception ignored) {
}
});
threads[i].setDaemon(true);
threads[i].start();
}
start.countDown();
for (Thread thread : threads) {
thread.join(5_000);
}
assertEquals(1, instances.size(), "并发构建只产生一个共享实例");
assertNotNull(sharedMinioClient());
}
@Test
void test_task_067_rustfs_normal_repeated_operation_is_idempotent() {
// 幂等:重复调用不创建重复客户端,共享实例引用稳定。
setConfigured(properties);
Object first = buildClient(Long.MAX_VALUE);
Object second = buildClient(Long.MAX_VALUE);
Object third = buildClient(Long.MAX_VALUE);
assertSame(first, second);
assertSame(second, third);
assertSame(first, sharedMinioClient());
}
@Test
void test_task_067_rustfs_boundary_empty_input() {
// 空输入:未配置时操作直接拒绝,不创建任何客户端实例。
IllegalStateException ex = assertThrows(IllegalStateException.class,
() -> service.uploadText("k", "v"));
org.junit.jupiter.api.Assertions.assertTrue(ex.getMessage().contains("not configured"));
assertNull(sharedMinioClient(), "未配置不创建客户端");
}
@Test
void test_task_067_rustfs_boundary_single_item() {
// 单元素:单次构建创建一次并缓存,不依赖批量路径。
setConfigured(properties);
Object built = buildClient(Long.MAX_VALUE);
assertNotNull(built);
assertSame(built, sharedMinioClient());
}
@Test
void test_task_067_rustfs_boundary_limit_and_overflow() throws Exception {
// 上限/超限:更大并发下仍只创建一个共享实例;
// HTTP 客户端(含连接池)同样复用同一实例。
setConfigured(properties);
int concurrency = 32;
CountDownLatch start = new CountDownLatch(1);
Set<Object> instances = ConcurrentHashMap.newKeySet();
Thread[] threads = new Thread[concurrency];
for (int i = 0; i < concurrency; i++) {
threads[i] = new Thread(() -> {
try {
start.await(2, TimeUnit.SECONDS);
instances.add(buildClient(Long.MAX_VALUE));
} catch (Exception ignored) {
}
});
threads[i].setDaemon(true);
threads[i].start();
}
start.countDown();
for (Thread thread : threads) {
thread.join(5_000);
}
assertEquals(1, instances.size(), "高并发下不产生客户端实例爆炸");
assertSame(service.getHttpClient(), service.getHttpClient(), "HTTP 客户端复用同一实例(连接池共享)");
}
@Test
void test_task_067_rustfs_invalid_input_rejected() {
// 非法参数:endpoint 为空白时视为未配置,拒绝操作且不创建客户端。
properties.setEndpoint(" ");
properties.setBucket("bucket");
properties.setAccessKeyId("ak");
properties.setAccessKeySecret("sk");
IllegalStateException ex = assertThrows(IllegalStateException.class,
() -> service.uploadText("k", "v"));
org.junit.jupiter.api.Assertions.assertTrue(ex.getMessage().contains("not configured"));
assertNull(sharedMinioClient());
}
@Test
void test_task_067_rustfs_dependency_failure_releases_resources() throws Exception {
// 依赖失败:supplier 注入路径优先且失败后可恢复,
// 不污染共享缓存(sharedMinioClient 保持未初始化)。
RustfsObjectStorageService supplierService = new RustfsObjectStorageService(
properties, meterRegistryProvider, deleteRetryProvider, () -> client);
setConfigured(properties);
properties.setUploadMaxRetries(1);
when(client.putObject(ArgumentMatchers.any(PutObjectArgs.class)))
.thenThrow(new java.io.IOException("rustfs down"));
assertThrows(Exception.class, () -> supplierService.uploadText("k", "v"));
when(client.putObject(ArgumentMatchers.any(PutObjectArgs.class)))
.thenReturn(mock(io.minio.ObjectWriteResponse.class));
when(client.statObject(ArgumentMatchers.any(io.minio.StatObjectArgs.class))).thenReturn(null);
assertEquals("k", supplierService.uploadText("k", "v"));
verify(client, times(2)).putObject(ArgumentMatchers.any(PutObjectArgs.class));
assertNull(ReflectionTestUtils.getField(supplierService, "sharedMinioClient"),
"supplier 路径不写共享缓存");
}
}
@@ -0,0 +1,262 @@
package com.nanri.aiimage.modules.file.service.object;
import com.nanri.aiimage.config.TransientStorageProperties;
import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.DistributionSummary;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
import io.minio.GetObjectResponse;
import io.minio.MinioClient;
import io.minio.RemoveObjectArgs;
import io.minio.PutObjectArgs;
import io.minio.StatObjectArgs;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentMatchers;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.test.util.ReflectionTestUtils;
import java.io.ByteArrayInputStream;
import java.nio.charset.StandardCharsets;
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.Mockito.doNothing;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
/**
* Task 61:共享任务链路资源指标基线:线程、连接、队列、GC、Redis、RustFS 和 DB。
* RustfsObjectStorageService 为上传/读取/删除/stat 记录 Micrometer 指标
* aiimage.rustfs.operation.total 计数、duration 计时、payload.bytes 分布、
* fallback.local.total 计数)与并发信号量上限、超时 deadline、失败窗口熔断。
* 指标基线:未配置零指标、成功/重试/失败/拒绝各结果分类计数准确、重复操作
* 计数精确累加、空输入零调用、单元素计数正确、并发超限 rejected、熔断后恢复。
*/
class RustfsMetricsBaselineTest {
private TransientStorageProperties properties;
private SimpleMeterRegistry simpleRegistry;
private MinioClient client;
private ObjectProvider<MeterRegistry> meterRegistryProvider;
private ObjectProvider<RustfsDeleteRetryService> deleteRetryProvider;
private RustfsObjectStorageService service;
@BeforeEach
@SuppressWarnings("unchecked")
void setUp() {
properties = new TransientStorageProperties();
simpleRegistry = new SimpleMeterRegistry();
meterRegistryProvider = mock(ObjectProvider.class);
when(meterRegistryProvider.getIfAvailable()).thenReturn(simpleRegistry);
deleteRetryProvider = mock(ObjectProvider.class);
client = mock(MinioClient.class);
service = new RustfsObjectStorageService(properties, meterRegistryProvider, deleteRetryProvider,
() -> client);
}
@AfterEach
void tearDown() {
simpleRegistry.clear();
}
private static void setConfigured(TransientStorageProperties properties) {
properties.setEndpoint("http://rustfs.local:9000");
properties.setBucket("bucket");
properties.setAccessKeyId("ak");
properties.setAccessKeySecret("sk");
properties.setRegion("us-east-1");
}
private static long counter(MeterRegistry registry, String name, String operation, String result) {
Counter counter = registry.find(name).tags("operation", operation, "result", result).counter();
return counter == null ? 0L : (long) counter.count();
}
private GetObjectResponse readResponse(String content) throws Exception {
GetObjectResponse response = mock(GetObjectResponse.class);
when(response.readAllBytes()).thenReturn(content.getBytes(StandardCharsets.UTF_8));
return response;
}
@Test
void test_task_061_rustfs_metrics_normal_default_path() {
// 正常路径:未配置时零调用零指标(基线默认),调用抛可识别异常。
assertEquals(0L, counter(simpleRegistry, "aiimage.rustfs.operation.total", "upload", "success"),
"未配置零指标");
assertThrows(IllegalStateException.class, () -> service.uploadText("k", "v"),
"未配置抛可识别异常");
assertEquals(0L, counter(simpleRegistry, "aiimage.rustfs.operation.total", "upload", "success"),
"失败调用不产生 success 指标");
}
@Test
void test_task_061_rustfs_metrics_normal_multiple_items() throws Exception {
// 批量场景:多次操作各结果分类计数准确累加(retry + failure),无丢失无重复。
setConfigured(properties);
properties.setReadMaxRetries(2);
properties.setUploadMaxRetries(2);
properties.setDeleteMaxRetries(2);
properties.setBaseRetryDelayMillis(0);
properties.setMaxRetryDelayMillis(0);
// 高失败窗口阈值:避免失败计数触发熔断,干扰各结果分类计数断言。
properties.setFailureWindowThreshold(1000);
doThrow(new java.io.IOException("rustfs down")).when(client)
.getObject(ArgumentMatchers.any(io.minio.GetObjectArgs.class));
doThrow(new java.io.IOException("rustfs down")).when(client)
.putObject(ArgumentMatchers.any(PutObjectArgs.class));
doThrow(new java.io.IOException("rustfs down")).when(client)
.removeObject(ArgumentMatchers.any(RemoveObjectArgs.class));
assertThrows(Exception.class, () -> service.uploadText("a", "v"));
assertThrows(Exception.class, () -> service.readObjectAsString("b"));
assertThrows(Exception.class, () -> service.deleteObject("c"));
assertEquals(1L, counter(simpleRegistry, "aiimage.rustfs.operation.total", "upload", "retry"));
assertEquals(1L, counter(simpleRegistry, "aiimage.rustfs.operation.total", "read", "retry"));
assertEquals(1L, counter(simpleRegistry, "aiimage.rustfs.operation.total", "delete", "retry"));
assertEquals(1L, counter(simpleRegistry, "aiimage.rustfs.operation.total", "upload", "failure"));
assertEquals(1L, counter(simpleRegistry, "aiimage.rustfs.operation.total", "read", "failure"));
assertEquals(1L, counter(simpleRegistry, "aiimage.rustfs.operation.total", "delete", "failure"));
}
@Test
void test_task_061_rustfs_metrics_normal_repeated_operation_is_idempotent() throws Exception {
// 幂等:同一操作重复执行成功 → 指标按次数精确累加,不重复注册不丢计数。
setConfigured(properties);
GetObjectResponse readResponse = readResponse("{}");
when(client.getObject(ArgumentMatchers.any(io.minio.GetObjectArgs.class)))
.thenReturn(readResponse);
when(client.statObject(ArgumentMatchers.any(StatObjectArgs.class)))
.thenReturn(null);
when(client.putObject(ArgumentMatchers.any(PutObjectArgs.class)))
.thenReturn(mock(io.minio.ObjectWriteResponse.class));
doNothing().when(client).removeObject(ArgumentMatchers.any(RemoveObjectArgs.class));
service.uploadText("k", "v");
service.uploadText("k", "v");
service.readObjectAsString("k");
service.readObjectAsString("k");
service.deleteObject("k");
service.deleteObject("k");
assertEquals(2L, counter(simpleRegistry, "aiimage.rustfs.operation.total", "upload", "success"));
assertEquals(2L, counter(simpleRegistry, "aiimage.rustfs.operation.total", "read", "success"));
assertEquals(2L, counter(simpleRegistry, "aiimage.rustfs.operation.total", "delete", "success"));
assertEquals(2L, counter(simpleRegistry, "aiimage.rustfs.operation.total", "stat", "success"),
"上传后 stat 可见性校验 2 次");
}
@Test
void test_task_061_rustfs_metrics_boundary_empty_input() {
// 空输入:空对象键删除零调用零指标,不创建无效资源。
setConfigured(properties);
service.deleteObject("");
service.deleteObject(null);
assertEquals(0L, counter(simpleRegistry, "aiimage.rustfs.operation.total", "delete", "success"));
}
@Test
void test_task_061_rustfs_metrics_boundary_single_item() throws Exception {
// 单元素:单次上传记录 success 计数与 payload.bytes 分布,无并发上限触发。
setConfigured(properties);
when(client.putObject(ArgumentMatchers.any(PutObjectArgs.class)))
.thenReturn(mock(io.minio.ObjectWriteResponse.class));
when(client.statObject(ArgumentMatchers.any(StatObjectArgs.class))).thenReturn(null);
service.uploadText("solo", "hello");
assertEquals(1L, counter(simpleRegistry, "aiimage.rustfs.operation.total", "upload", "success"));
DistributionSummary summary = simpleRegistry.find("aiimage.rustfs.payload.bytes").summary();
assertTrue(summary != null && summary.count() >= 1L, "payload 字节分布有记录");
assertTrue(summary != null && summary.totalAmount() >= 5L, "记录字节数与内容一致");
}
@Test
void test_task_061_rustfs_metrics_boundary_limit_and_overflow() throws Exception {
// 上限/超限:上传并发上限 1 + 获取许可超时 0 → 第二并发请求被拒绝,
// rejected 指标 +1,不发生无界排队。
setConfigured(properties);
properties.setMaxConcurrentUploads(1);
properties.setAcquirePermitTimeoutMillis(0);
// 信号量在构造时从 properties 固定,须先设置上限再构造受限实例。
RustfsObjectStorageService limited = new RustfsObjectStorageService(
properties, meterRegistryProvider, deleteRetryProvider, () -> client);
java.util.concurrent.CountDownLatch entered = new java.util.concurrent.CountDownLatch(1);
org.mockito.Mockito.doAnswer(invocation -> {
entered.countDown();
Thread.sleep(500);
throw new java.io.IOException("hold");
}).when(client).putObject(ArgumentMatchers.any(PutObjectArgs.class));
Thread first = new Thread(() -> {
try {
limited.uploadText("block", "v");
} catch (Exception ignored) {
}
});
first.setDaemon(true);
first.start();
try {
assertTrue(entered.await(2, java.util.concurrent.TimeUnit.SECONDS),
"首线程已持有许可");
} catch (InterruptedException ex) {
Thread.currentThread().interrupt();
}
assertThrows(IllegalStateException.class, () -> limited.uploadText("blocked", "v"),
"并发超限被拒绝");
assertEquals(1L, counter(simpleRegistry, "aiimage.rustfs.operation.total", "upload", "rejected"));
first.interrupt();
}
@Test
void test_task_061_rustfs_metrics_invalid_input_rejected() {
// 非法参数:未配置时上传/读取抛可识别异常且零指标(不污染基线)。
assertThrows(IllegalStateException.class, () -> service.uploadText("k", "v"),
"未配置抛可识别异常");
assertThrows(IllegalStateException.class, () -> service.readObjectAsString("k"),
"未配置抛可识别异常");
assertEquals(0L, counter(simpleRegistry, "aiimage.rustfs.operation.total", "upload", "success"));
assertEquals(0L, counter(simpleRegistry, "aiimage.rustfs.operation.total", "read", "success"));
}
@Test
void test_task_061_rustfs_metrics_dependency_failure_releases_resources() throws Exception {
// 依赖失败:上传成功但 stat 可见性校验失败 → 失败窗口打开,
// 后续读取被熔断拒绝(rejected 指标);熔断过期后自动恢复,指标完整。
setConfigured(properties);
properties.setFailureWindowThreshold(1);
properties.setFailureCooldownMillis(5_000);
properties.setFailureWindowSeconds(10);
properties.setDeleteRetryEnabled(false);
when(client.putObject(ArgumentMatchers.any(PutObjectArgs.class)))
.thenReturn(mock(io.minio.ObjectWriteResponse.class));
doThrow(new java.io.IOException("stat down")).when(client)
.statObject(ArgumentMatchers.any(StatObjectArgs.class));
GetObjectResponse readResponse = readResponse("{}");
when(client.getObject(ArgumentMatchers.any(io.minio.GetObjectArgs.class)))
.thenReturn(readResponse);
assertThrows(Exception.class, () -> service.uploadText("k", "v"), "stat 失败上传抛错");
assertThrows(IllegalStateException.class, () -> service.readObjectAsString("k"),
"熔断打开读取被拒绝");
assertEquals(1L, counter(simpleRegistry, "aiimage.rustfs.operation.total", "read", "rejected"));
// 熔断冷却到期后自动恢复。
ReflectionTestUtils.setField(service, "circuitOpenUntilMillis", 0L);
assertEquals("{}", service.readObjectAsString("k"), "熔断重置后恢复");
assertEquals(1L, counter(simpleRegistry, "aiimage.rustfs.operation.total", "read", "success"));
Object openUntil = ReflectionTestUtils.getField(service, "circuitOpenUntilMillis");
assertFalse(openUntil instanceof Long && (Long) openUntil > 0L, "熔断已复位");
}
}
@@ -0,0 +1,272 @@
package com.nanri.aiimage.modules.file.service.object;
import com.nanri.aiimage.config.TransientStorageProperties;
import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
import io.minio.GetObjectArgs;
import io.minio.GetObjectResponse;
import io.minio.MinioClient;
import io.minio.PutObjectArgs;
import io.minio.StatObjectArgs;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentMatchers;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.test.util.ReflectionTestUtils;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.Semaphore;
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.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.doNothing;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* Task 66:限制 RustFS 并发读写与重试的总资源预算,防止多任务叠加爆发。
* RustfsObjectStorageService 在分类信号量(upload/read/delete)之上增加总并发预算
* maxTotalConcurrentOperations:单次操作(含重试)全程占用一个总许可,
* 超预算立即拒绝(rejected 指标),失败/完成后释放,避免多任务叠加时
* 读写与重试叠加突破对后端的总压力上限。
*/
class RustfsTotalBudgetTest {
private TransientStorageProperties properties;
private SimpleMeterRegistry simpleRegistry;
private MinioClient client;
private ObjectProvider<MeterRegistry> meterRegistryProvider;
private ObjectProvider<RustfsDeleteRetryService> deleteRetryProvider;
private RustfsObjectStorageService service;
@BeforeEach
@SuppressWarnings("unchecked")
void setUp() {
properties = new TransientStorageProperties();
simpleRegistry = new SimpleMeterRegistry();
meterRegistryProvider = mock(ObjectProvider.class);
when(meterRegistryProvider.getIfAvailable()).thenReturn(simpleRegistry);
deleteRetryProvider = mock(ObjectProvider.class);
client = mock(MinioClient.class);
service = new RustfsObjectStorageService(properties, meterRegistryProvider, deleteRetryProvider,
() -> client);
}
@AfterEach
void tearDown() {
simpleRegistry.clear();
}
private static void setConfigured(TransientStorageProperties properties) {
properties.setEndpoint("http://rustfs.local:9000");
properties.setBucket("bucket");
properties.setAccessKeyId("ak");
properties.setAccessKeySecret("sk");
properties.setRegion("us-east-1");
}
private void stubUploadSuccess() {
try {
when(client.putObject(ArgumentMatchers.any(PutObjectArgs.class)))
.thenReturn(mock(io.minio.ObjectWriteResponse.class));
when(client.statObject(ArgumentMatchers.any(StatObjectArgs.class))).thenReturn(null);
} catch (Exception ex) {
throw new IllegalStateException(ex);
}
}
private void stubReadSuccess(String content) throws Exception {
GetObjectResponse response = mock(GetObjectResponse.class);
when(response.readAllBytes()).thenReturn(content.getBytes(StandardCharsets.UTF_8));
when(client.getObject(ArgumentMatchers.any(GetObjectArgs.class))).thenReturn(response);
}
@SuppressWarnings("unchecked")
private Semaphore totalSemaphore() {
return (Semaphore) ReflectionTestUtils.getField(service, "totalSemaphore");
}
private static long rejectedTotal(MeterRegistry registry) {
Counter counter = registry.find("aiimage.rustfs.operation.total")
.tags("operation", "total", "result", "rejected").counter();
return counter == null ? 0L : (long) counter.count();
}
@Test
void test_task_066_rustfs_normal_default_path() throws Exception {
// 默认路径:配置总预算后正常上传成功,操作结束后总许可释放回池。
setConfigured(properties);
properties.setMaxTotalConcurrentOperations(8);
service = new RustfsObjectStorageService(properties, meterRegistryProvider, deleteRetryProvider,
() -> client);
stubUploadSuccess();
String objectKey = service.uploadText("task-parsed/test/1/scope/latest.json", "{\"a\":1}");
assertEquals("task-parsed/test/1/scope/latest.json", objectKey);
assertEquals(8, totalSemaphore().availablePermits(), "上传完成后总许可全部释放");
assertEquals(0L, rejectedTotal(simpleRegistry));
}
@Test
void test_task_066_rustfs_normal_multiple_items() throws Exception {
// 批量场景:多任务并发在总预算内全部成功,无拒绝无丢失。
setConfigured(properties);
properties.setMaxTotalConcurrentOperations(16);
service = new RustfsObjectStorageService(properties, meterRegistryProvider, deleteRetryProvider,
() -> client);
stubUploadSuccess();
int concurrency = 4;
CountDownLatch start = new CountDownLatch(1);
AtomicInteger successCount = new AtomicInteger();
Thread[] threads = new Thread[concurrency];
for (int i = 0; i < concurrency; i++) {
final int index = i;
threads[i] = new Thread(() -> {
try {
start.await(2, TimeUnit.SECONDS);
service.uploadText("task-parsed/test/" + index + "/scope/latest.json", "{\"i\":" + index + "}");
successCount.incrementAndGet();
} catch (Exception ignored) {
}
});
threads[i].setDaemon(true);
threads[i].start();
}
start.countDown();
for (Thread thread : threads) {
thread.join(5_000);
}
assertEquals(concurrency, successCount.get(), "全部并发任务成功");
verify(client, times(concurrency)).putObject(ArgumentMatchers.any(PutObjectArgs.class));
assertEquals(16, totalSemaphore().availablePermits());
assertEquals(0L, rejectedTotal(simpleRegistry));
}
@Test
void test_task_066_rustfs_normal_repeated_operation_is_idempotent() throws Exception {
// 幂等:总预算 1 时串行重复操作每次都能获取并释放许可,不产生重复拒绝。
setConfigured(properties);
properties.setMaxTotalConcurrentOperations(1);
service = new RustfsObjectStorageService(properties, meterRegistryProvider, deleteRetryProvider,
() -> client);
stubUploadSuccess();
for (int i = 0; i < 3; i++) {
assertEquals("task-parsed/test/1/scope/latest.json",
service.uploadText("task-parsed/test/1/scope/latest.json", "{\"x\":1}"));
}
verify(client, times(3)).putObject(ArgumentMatchers.any(PutObjectArgs.class));
assertEquals(1, totalSemaphore().availablePermits(), "每次操作后许可释放");
assertEquals(0L, rejectedTotal(simpleRegistry));
}
@Test
void test_task_066_rustfs_boundary_empty_input() throws Exception {
// 空输入:总预算未配置(0)时保持原行为,不启用总量限制。
setConfigured(properties);
stubUploadSuccess();
assertEquals("k", service.uploadText("k", "v"));
verify(client, times(1)).putObject(ArgumentMatchers.any(PutObjectArgs.class));
assertEquals(0L, rejectedTotal(simpleRegistry));
}
@Test
void test_task_066_rustfs_boundary_single_item() throws Exception {
// 单元素:总预算 1 时单次读取成功,许可释放,跨操作类型互不残留。
setConfigured(properties);
properties.setMaxTotalConcurrentOperations(1);
service = new RustfsObjectStorageService(properties, meterRegistryProvider, deleteRetryProvider,
() -> client);
stubReadSuccess("{}");
assertEquals("{}", service.readObjectAsString("task-parsed/test/1/scope/latest.json"));
assertEquals(1, totalSemaphore().availablePermits());
}
@Test
void test_task_066_rustfs_boundary_limit_and_overflow() throws Exception {
// 上限/超限:总预算 1 时第二个并发操作(不同分类)立即被拒绝,
// rejected 指标 +1,不发生无界排队;操作结束后许可释放。
setConfigured(properties);
properties.setMaxTotalConcurrentOperations(1);
properties.setAcquirePermitTimeoutMillis(0);
service = new RustfsObjectStorageService(properties, meterRegistryProvider, deleteRetryProvider,
() -> client);
stubUploadSuccess();
CountDownLatch entered = new CountDownLatch(1);
org.mockito.Mockito.doAnswer(invocation -> {
entered.countDown();
Thread.sleep(500);
throw new java.io.IOException("hold");
}).when(client).putObject(ArgumentMatchers.any(PutObjectArgs.class));
Thread first = new Thread(() -> {
try {
service.uploadText("block", "v");
} catch (Exception ignored) {
}
});
first.setDaemon(true);
first.start();
try {
assertTrue(entered.await(2, TimeUnit.SECONDS), "首线程已持有总预算");
} catch (InterruptedException ex) {
Thread.currentThread().interrupt();
}
assertThrows(IllegalStateException.class, () -> service.readObjectAsString("blocked"),
"跨操作类型并发超预算被拒绝");
assertEquals(1L, rejectedTotal(simpleRegistry), "拒绝计入 total rejected 指标");
first.interrupt();
}
@Test
void test_task_066_rustfs_invalid_input_rejected() throws Exception {
// 非法参数:总预算为负或 0 时视为未启用(构造不崩溃,行为与默认一致)。
setConfigured(properties);
properties.setMaxTotalConcurrentOperations(-5);
service = new RustfsObjectStorageService(properties, meterRegistryProvider, deleteRetryProvider,
() -> client);
stubUploadSuccess();
assertEquals("k", service.uploadText("k", "v"));
assertEquals(0L, rejectedTotal(simpleRegistry));
}
@Test
void test_task_066_rustfs_dependency_failure_releases_resources() throws Exception {
// 依赖失败:上传失败后总许可释放(错误可恢复),恢复后操作成功且无残留锁。
setConfigured(properties);
properties.setMaxTotalConcurrentOperations(1);
service = new RustfsObjectStorageService(properties, meterRegistryProvider, deleteRetryProvider,
() -> client);
try {
when(client.putObject(ArgumentMatchers.any(PutObjectArgs.class)))
.thenThrow(new java.io.IOException("rustfs down"));
assertThrows(Exception.class, () -> service.uploadText("task-parsed/test/1/scope/latest.json", "{\"a\":1}"));
} finally {
try {
stubUploadSuccess();
} catch (Exception ignored) {
}
}
assertEquals(1, totalSemaphore().availablePermits(), "失败后总许可释放");
assertEquals("task-parsed/test/1/scope/latest.json",
service.uploadText("task-parsed/test/1/scope/latest.json", "{\"b\":2}"));
assertEquals(1, totalSemaphore().availablePermits());
}
}
@@ -194,7 +194,7 @@ class ShopDataCrawlDailyFileIncrementalTest {
});
// 店铺级锁:每次返回独立 mock 句柄,供失败路径验证 close()。
lenient().when(dailyFileService.acquireLock(anyLong(), anyString())).thenAnswer(invocation -> {
lenient().when(dailyFileService.acquireLock(anyString())).thenAnswer(invocation -> {
TaskDistributedLockService.LockHandle handle = mock(TaskDistributedLockService.LockHandle.class);
lastLock.set(handle);
return handle;
@@ -209,9 +209,9 @@ class ShopDataCrawlDailyFileIncrementalTest {
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.findForUpdate(anyString(), any()))
.thenAnswer(invocation -> findDailyFile(invocation.getArgument(0)));
lenient().when(dailyFileService.findOlder(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);
@@ -482,8 +482,7 @@ class ShopDataCrawlDailyFileIncrementalTest {
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())
if (Objects.equals(existing.getShopKeyHash(), entity.getShopKeyHash())
&& Objects.equals(existing.getBusinessDate(), entity.getBusinessDate())) {
entity.setId(existing.getId());
dbDailyFiles.set(i, entity);
@@ -517,9 +516,9 @@ class ShopDataCrawlDailyFileIncrementalTest {
});
}
private ShopDataCrawlDailyFileEntity findDailyFile(Long userId, String shopKeyHash) {
private ShopDataCrawlDailyFileEntity findDailyFile(String shopKeyHash) {
for (ShopDataCrawlDailyFileEntity f : dbDailyFiles) {
if (Objects.equals(f.getUserId(), userId) && Objects.equals(f.getShopKeyHash(), shopKeyHash)) {
if (Objects.equals(f.getShopKeyHash(), shopKeyHash)) {
return f;
}
}
@@ -535,4 +534,57 @@ class ShopDataCrawlDailyFileIncrementalTest {
FileResultEntity row = addResultRow(900L, 900L, 1, SHOP_NAME, null);
processJob(900L, List.of(row), snapshot(900L, SHOP_NAME, 1));
}
@Test
void test_task_095_shop_level_country_coverage_keeps_unupdated_countries() {
// V95 店铺级共享:跨账号同店结果汇聚到同一份累计文件,
// 同国家覆盖更新,未更新的国家保留。
// 账号A(user 7)先抓英国;账号B(user 8)抓德国;账号A再抓英国(覆盖)。
FileResultEntity uk1 = addResultRow(9501L, 9501L, 1, SHOP_NAME, null);
processJob(9501L, List.of(uk1), snapshotWithCountry(9501L, "UK", 100));
FileResultEntity de1 = addResultRow(9502L, 9502L, 1, SHOP_NAME, null);
de1.setUserId(8L); // 不同账号
processJob(9502L, List.of(de1), snapshotWithCountry(9502L, "DE", 200));
FileResultEntity uk2 = addResultRow(9503L, 9503L, 1, SHOP_NAME, null);
processJob(9503L, List.of(uk2), snapshotWithCountry(9503L, "UK", 150));
assertEquals(1, dbDailyFiles.size(), "跨账号同店同日只保留一份累计文件");
assertEquals(3, dbMembers.size(), "三个结果各有一个成员行");
// 整表重建:英国以最后一次(9503)为准,德国(9502)保留
triggerRebuild();
assertEquals(4, lastAssembledItems.size(), "成员快照按创建顺序累积,item 结构保持");
Map<String, Integer> countryRows = new java.util.HashMap<>();
for (ShopDataCrawlResultItemVo item : lastAssembledItems) {
if (item.getCountryResults() == null) continue;
for (com.nanri.aiimage.modules.shopdatacrawl.model.dto.ShopDataCrawlCountryResultDto cr
: item.getCountryResults()) {
countryRows.put(cr.getCountry(), cr.getItems() == null ? 0 : cr.getItems().size());
}
}
assertEquals(150, countryRows.getOrDefault("UK", -1), "英国以最后一次抓取为准(覆盖更新)");
assertEquals(200, countryRows.getOrDefault("DE", -1), "德国未更新,保留之前抓取的数据");
verify(ossStorageService, never()).readObjectBytes(anyString());
}
private ShopDataCrawlResultItemVo snapshotWithCountry(long resultId, String country, int rows) {
ShopDataCrawlResultItemVo item = snapshot(resultId, SHOP_NAME, rows);
com.nanri.aiimage.modules.shopdatacrawl.model.dto.ShopDataCrawlCountryResultDto countryResult =
new com.nanri.aiimage.modules.shopdatacrawl.model.dto.ShopDataCrawlCountryResultDto();
countryResult.setCountry(country);
List<com.nanri.aiimage.modules.shopdatacrawl.model.dto.ShopDataCrawlRowDto> rowList = new ArrayList<>();
for (int i = 0; i < rows; i++) {
com.nanri.aiimage.modules.shopdatacrawl.model.dto.ShopDataCrawlRowDto row =
new com.nanri.aiimage.modules.shopdatacrawl.model.dto.ShopDataCrawlRowDto();
row.setAsin("B0" + String.format("%08d", i));
row.setDate("2026-07-25");
rowList.add(row);
}
countryResult.setItems(rowList);
item.setCountryResults(List.of(countryResult));
item.setCountryCodes(List.of(country));
return item;
}
}
@@ -221,11 +221,11 @@ class ShopDataCrawlDailyFileJobSplitTest {
FileResultEntity row = invocation.getArgument(0);
return row == null ? null : row.getSourceFilename();
});
lenient().when(dailyFileService.acquireLock(anyLong(), anyString()))
lenient().when(dailyFileService.acquireLock(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.findForUpdate(anyString(), any())).thenAnswer(invocation ->
copyDailyFile(findDailyFile(invocation.getArgument(0))));
lenient().when(dailyFileService.findOlder(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);
@@ -622,9 +622,9 @@ class ShopDataCrawlDailyFileJobSplitTest {
});
}
private ShopDataCrawlDailyFileEntity findDailyFile(Long userId, String shopKeyHash) {
private ShopDataCrawlDailyFileEntity findDailyFile(String shopKeyHash) {
for (ShopDataCrawlDailyFileEntity f : dbDailyFiles) {
if (Objects.equals(f.getUserId(), userId) && Objects.equals(f.getShopKeyHash(), shopKeyHash)) {
if (Objects.equals(f.getShopKeyHash(), shopKeyHash)) {
return f;
}
}
@@ -200,7 +200,7 @@ class ShopDataCrawlDailyFileLockTest {
}).when(excelAssemblyService).writeWorkbook(any(), any());
// 店铺级锁:每次获取返回独立句柄并计数(两次短临界区各取一次)。
lenient().when(dailyFileService.acquireLock(anyLong(), anyString())).thenAnswer(invocation -> {
lenient().when(dailyFileService.acquireLock(anyString())).thenAnswer(invocation -> {
TaskDistributedLockService.LockHandle handle = mock(TaskDistributedLockService.LockHandle.class);
lockAcquireCount.incrementAndGet();
lastLock.set(handle);
@@ -217,10 +217,9 @@ class ShopDataCrawlDailyFileLockTest {
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.findForUpdate(anyString(), any()))
.thenAnswer(invocation -> copyDailyFile(findDailyFile(invocation.getArgument(0))));
lenient().when(dailyFileService.findOlder(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);
@@ -345,14 +344,14 @@ class ShopDataCrawlDailyFileLockTest {
seedDailyFile();
FileResultEntity row = addResultRow(7207L, 1L, 1, SHOP_NAME, null);
lenient().doAnswer(invocation -> {
ShopDataCrawlDailyFileEntity current = findDailyFile(invocation.getArgument(0), invocation.getArgument(1));
ShopDataCrawlDailyFileEntity current = findDailyFile(invocation.getArgument(0));
if (current == null) {
return null;
}
ShopDataCrawlDailyFileEntity readCopy = copyDailyFile(current);
current.setVersion(readCopy.getVersion() + 1L);
return readCopy;
}).when(dailyFileService).findForUpdate(anyLong(), anyString(), any());
}).when(dailyFileService).findForUpdate(anyString(), any());
Exception ex = assertThrows(BusinessException.class,
() -> processJob(1L, List.of(row), snapshot(7207L)));
@@ -566,9 +565,9 @@ class ShopDataCrawlDailyFileLockTest {
});
}
private ShopDataCrawlDailyFileEntity findDailyFile(Long userId, String shopKeyHash) {
private ShopDataCrawlDailyFileEntity findDailyFile(String shopKeyHash) {
for (ShopDataCrawlDailyFileEntity f : dbDailyFiles) {
if (Objects.equals(f.getUserId(), userId) && Objects.equals(f.getShopKeyHash(), shopKeyHash)) {
if (Objects.equals(f.getShopKeyHash(), shopKeyHash)) {
return f;
}
}
@@ -104,41 +104,6 @@ class ShopDataCrawlExcelAssemblyServiceTest {
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) {
ShopDataCrawlCountryResultDto country = new ShopDataCrawlCountryResultDto();
country.setCountry(countryCode);
@@ -5,6 +5,7 @@ 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 com.nanri.aiimage.modules.task.util.TaskEntityLocalCache;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.Mock;
@@ -54,7 +55,8 @@ class ShopDataCrawlTaskCacheServiceTest {
stringRedisTemplate,
objectMapper,
properties,
taskScopePayloadStorageService);
taskScopePayloadStorageService,
new TaskEntityLocalCache(properties, objectMapper));
lenient().when(stringRedisTemplate.opsForValue()).thenReturn(valueOperations);
lenient().when(valueOperations.multiGet(any())).thenReturn(List.of());
@@ -127,10 +127,10 @@ class ShopDataCrawlTaskServiceRetentionTest {
when(dailyFileService.currentBusinessDateTime()).thenReturn(BUSINESS_TIME);
when(dailyFileService.shopKey(any())).thenReturn("shop-id:shop-1");
when(dailyFileService.shopKeyHash(anyString())).thenReturn("hash-1");
when(dailyFileService.acquireLock(eq(USER_ID), eq("shop-id:shop-1")))
when(dailyFileService.acquireLock("shop-id:shop-1"))
.thenReturn(mock(TaskDistributedLockService.LockHandle.class));
when(dailyFileService.findMembersByResultId(RESULT_ID)).thenReturn(List.of());
when(dailyFileService.findOlder(USER_ID, "hash-1", BUSINESS_DATE)).thenReturn(List.of());
when(dailyFileService.findOlder("hash-1", BUSINESS_DATE)).thenReturn(List.of());
when(dailyFileService.countObjectReferences(anyString())).thenReturn(0L);
when(dailyFileService.addMemberWithPayload(anyLong(), anyLong(), anyLong(), anyString())).thenReturn(true);
when(transactionManager.getTransaction(any())).thenReturn(transactionStatus);
@@ -144,7 +144,7 @@ class ShopDataCrawlTaskServiceRetentionTest {
@Test
void firstSuccessCreatesDailyWorkbookAndMembership() {
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("hash-1", BUSINESS_DATE)).thenReturn(null);
when(ossStorageService.uploadResultFile(any(), eq(MODULE_TYPE))).thenReturn("result/new.xlsx");
when(excelAssemblyService.writeWorkbook(any(), any())).thenReturn(1);
@@ -176,7 +176,7 @@ class ShopDataCrawlTaskServiceRetentionTest {
+ "\"shopId\":\"shop-1\",\"success\":true,\"countryResults\":[]}");
when(fileResultMapper.selectList(any())).thenReturn(List.of(currentRow), List.of(previous, currentRow));
when(fileTaskMapper.selectBatchIds(List.of(100L))).thenReturn(List.of(previousTask));
when(dailyFileService.findForUpdate(USER_ID, "hash-1", BUSINESS_DATE)).thenReturn(daily);
when(dailyFileService.findForUpdate("hash-1", BUSINESS_DATE)).thenReturn(daily);
when(dailyFileService.containsResult(301L, RESULT_ID)).thenReturn(false);
when(dailyFileService.listMembers(301L)).thenReturn(List.of(previousMember));
when(taskResultItemService.getResultSnapshot(
@@ -192,7 +192,8 @@ class ShopDataCrawlTaskServiceRetentionTest {
verify(ossStorageService, never()).readObjectBytes(anyString());
verify(dailyFileService).update(daily);
verify(ossStorageService).deleteObject("result/old.xlsx");
assertNull(previous.getResultFileUrl());
// 店铺级共享:历史结果行保留旧对象指针(可继续下载),累计文件本体被新对象取代
assertEquals("result/old.xlsx", previous.getResultFileUrl());
assertEquals("result/new.xlsx", currentRow.getResultFileUrl());
assertEquals(2, currentRow.getRowCount());
}
@@ -215,7 +216,7 @@ class ShopDataCrawlTaskServiceRetentionTest {
ShopDataCrawlDailyFileEntity daily = daily("result/old.xlsx", 2);
when(fileResultMapper.selectList(any())).thenReturn(List.of(currentRow));
when(dailyFileService.findForUpdate(USER_ID, "hash-1", BUSINESS_DATE)).thenReturn(daily);
when(dailyFileService.findForUpdate("hash-1", BUSINESS_DATE)).thenReturn(daily);
when(dailyFileService.containsResult(301L, RESULT_ID)).thenReturn(false);
doAnswer(invocation -> {
assertFalse(transactionActive.get(), "row counting must run outside the database transaction");
@@ -248,7 +249,7 @@ class ShopDataCrawlTaskServiceRetentionTest {
ShopDataCrawlDailyFileEntity daily = daily("result/current.xlsx", 3);
daily.setLatestResultId(RESULT_ID);
when(fileResultMapper.selectList(any())).thenReturn(List.of(currentRow));
when(dailyFileService.findForUpdate(USER_ID, "hash-1", BUSINESS_DATE)).thenReturn(daily);
when(dailyFileService.findForUpdate("hash-1", BUSINESS_DATE)).thenReturn(daily);
when(dailyFileService.containsResult(301L, RESULT_ID)).thenReturn(true);
service.processResultFileJob(job);
@@ -264,7 +265,7 @@ class ShopDataCrawlTaskServiceRetentionTest {
void zeroNewRowsReuseDailyObjectWithoutWorkbookIo() {
ShopDataCrawlDailyFileEntity daily = daily("result/current.xlsx", 3);
when(fileResultMapper.selectList(any())).thenReturn(List.of(currentRow));
when(dailyFileService.findForUpdate(USER_ID, "hash-1", BUSINESS_DATE)).thenReturn(daily);
when(dailyFileService.findForUpdate("hash-1", BUSINESS_DATE)).thenReturn(daily);
when(dailyFileService.containsResult(301L, RESULT_ID)).thenReturn(false);
when(excelAssemblyService.countRows(any())).thenReturn(0);
@@ -291,7 +292,7 @@ class ShopDataCrawlTaskServiceRetentionTest {
when(fileResultMapper.selectList(any())).thenReturn(List.of(currentRow), List.of(previous, currentRow));
when(fileTaskMapper.selectBatchIds(List.of(100L))).thenReturn(List.of(previousTask));
when(fileResultMapper.selectCount(any())).thenReturn(1L);
when(dailyFileService.findForUpdate(USER_ID, "hash-1", BUSINESS_DATE)).thenReturn(daily);
when(dailyFileService.findForUpdate("hash-1", BUSINESS_DATE)).thenReturn(daily);
when(dailyFileService.containsResult(301L, RESULT_ID)).thenReturn(false);
when(dailyFileService.listMembers(301L)).thenReturn(List.of());
when(taskResultItemService.getResultSnapshot(
@@ -314,7 +315,7 @@ class ShopDataCrawlTaskServiceRetentionTest {
yesterday.setLatestResultId(RESULT_ID);
ShopDataCrawlDailyMemberEntity member = member(301L, TASK_ID, RESULT_ID, BUSINESS_TIME.minusDays(1));
when(fileResultMapper.selectList(any())).thenReturn(List.of(currentRow));
when(dailyFileService.findForUpdate(USER_ID, "hash-1", BUSINESS_DATE)).thenReturn(today);
when(dailyFileService.findForUpdate("hash-1", BUSINESS_DATE)).thenReturn(today);
when(dailyFileService.findMembersByResultId(RESULT_ID)).thenReturn(List.of(member));
when(dailyFileService.findById(301L)).thenReturn(yesterday);
@@ -333,8 +334,8 @@ class ShopDataCrawlTaskServiceRetentionTest {
yesterday.setId(300L);
yesterday.setBusinessDate(BUSINESS_DATE.minusDays(1));
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.findOlder(USER_ID, "hash-1", BUSINESS_DATE)).thenReturn(List.of(yesterday));
when(dailyFileService.findForUpdate("hash-1", BUSINESS_DATE)).thenReturn(null);
when(dailyFileService.findOlder("hash-1", BUSINESS_DATE)).thenReturn(List.of(yesterday));
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");
@@ -350,7 +351,8 @@ class ShopDataCrawlTaskServiceRetentionTest {
verify(ossStorageService).deleteObject("result/yesterday.xlsx");
assertEquals("result/today.xlsx", currentRow.getResultFileUrl());
assertEquals(1, currentRow.getRowCount());
assertNull(previous.getResultFileUrl());
// 店铺级共享:旧日历史结果行保留旧对象指针(可下载),仅累计文件行被删除
assertEquals("result/yesterday.xlsx", previous.getResultFileUrl());
}
@Test
@@ -359,8 +361,8 @@ class ShopDataCrawlTaskServiceRetentionTest {
yesterday.setId(300L);
yesterday.setBusinessDate(BUSINESS_DATE.minusDays(1));
when(fileResultMapper.selectList(any())).thenReturn(List.of(currentRow));
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.findForUpdate("hash-1", BUSINESS_DATE)).thenReturn(null);
when(dailyFileService.findOlder("hash-1", BUSINESS_DATE)).thenReturn(List.of(yesterday));
when(dailyFileService.listMembers(300L)).thenReturn(List.of());
when(excelAssemblyService.writeWorkbook(any(), any())).thenReturn(4);
doThrow(new IllegalStateException("upload failed"))
@@ -378,8 +380,8 @@ class ShopDataCrawlTaskServiceRetentionTest {
yesterday.setId(300L);
yesterday.setBusinessDate(BUSINESS_DATE.minusDays(1));
when(fileResultMapper.selectList(any())).thenReturn(List.of(currentRow));
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.findForUpdate("hash-1", BUSINESS_DATE)).thenReturn(null);
when(dailyFileService.findOlder("hash-1", BUSINESS_DATE)).thenReturn(List.of(yesterday));
when(dailyFileService.listMembers(300L)).thenReturn(List.of());
when(excelAssemblyService.writeWorkbook(any(), any())).thenReturn(4);
when(ossStorageService.uploadResultFile(any(), eq(MODULE_TYPE))).thenReturn("result/new.xlsx");
@@ -418,7 +420,7 @@ class ShopDataCrawlTaskServiceRetentionTest {
when(dailyFileService.findByLatestResultId(RESULT_ID)).thenReturn(List.of(daily));
when(dailyFileService.findMembersByResultId(RESULT_ID)).thenReturn(List.of(removedMember));
when(dailyFileService.findById(301L)).thenReturn(daily);
when(dailyFileService.findForUpdate(USER_ID, "hash-1", BUSINESS_DATE)).thenReturn(daily);
when(dailyFileService.findForUpdate("hash-1", BUSINESS_DATE)).thenReturn(daily);
when(dailyFileService.listMembers(301L)).thenReturn(List.of(removedMember, previousMember));
when(taskResultItemService.getResultSnapshot(
100L, MODULE_TYPE, 200L, ShopDataCrawlResultItemVo.class)).thenReturn(previousSnapshot);
@@ -0,0 +1,200 @@
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 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;
/**
* Task 40:店铺抓取压测与资源比较。
* ShopDataCrawlLoadTestProbe 并发执行多轮"生成-采样-回传-上传-锁"流水线,
* 产出内存峰值、耗时、DB QPS、对象存储流量和锁等待五类可比较指标;
* 同一输入重复运行指标一致(幂等),超限与非法输入被拒绝,注入失败可恢复且计数不残留。
*/
class ShopDataCrawlLoadTestProbeTest {
private final ShopDataCrawlLoadTestProbe probe =
new ShopDataCrawlLoadTestProbe(new ObjectMapper(), new ShopDataCrawlPerfFixture(new ObjectMapper()));
@Test
void test_task_040_lock_object_storage_normal_default_path() {
// 正常输入:单线程单轮 1000 行 5 国带图,五类指标齐全且数量正确。
ShopDataCrawlLoadTestProbe.Report report =
probe.runComparison("Shop-A", 1000, 5, true, 1, 1);
assertEquals(1, report.rounds());
assertEquals(1000, report.totalRows(), "行数不丢失");
assertTrue(report.peakHeapBytes() > 0, "堆峰值被采样");
assertTrue(report.totalElapsedMillis() >= 0, "耗时被采样");
assertEquals(5, report.dbReads(), "每轮每国一次结果读取");
assertEquals(2, report.dbWrites(), "每轮结果行+分片行各一次写入");
assertTrue(report.ossUploadBytes() > 0, "payload 已上传产生流量");
assertEquals(1, report.ossUploads(), "一轮一次对象上传");
assertEquals(0, report.ossDeletes(), "正常路径无对象删除");
assertEquals(1, report.lockAcquires(), "一轮一次任务锁获取");
assertEquals(0, report.lockRetries(), "无竞争不重试");
assertTrue(report.dbQps() > 0, "DB QPS 可计算");
assertTrue(report.ossTrafficBytes() >= report.ossUploadBytes(), "总流量包含上传");
}
@Test
void test_task_040_lock_object_storage_normal_multiple_items() {
// 批量场景:4 线程 4 轮 2000 行,总行数/写次数/上传次数按轮聚合,顺序稳定。
ShopDataCrawlLoadTestProbe.Report report =
probe.runComparison("Shop-B", 2000, 5, true, 4, 4);
assertEquals(4, report.rounds());
assertEquals(8000, report.totalRows(), "4 轮行数合计不丢失");
assertEquals(8, report.dbWrites(), "每轮结果+分片写入聚合");
assertEquals(4, report.ossUploads(), "每轮上传一次");
assertEquals(4, report.lockAcquires(), "每轮获取一次任务锁");
assertEquals(0, report.lockRetries(), "不同任务锁键无竞争");
assertEquals(20, report.dbReads(), "每轮每国读取聚合");
ShopDataCrawlLoadTestProbe.Report again =
probe.runComparison("Shop-B", 2000, 5, true, 4, 4);
assertEquals(report.totalRows(), again.totalRows(), "重复运行行数一致");
assertEquals(report.dbReads(), again.dbReads(), "重复运行 DB 计数一致");
assertEquals(report.ossUploadBytes(), again.ossUploadBytes(), "重复运行流量一致");
}
@Test
void test_task_040_lock_object_storage_normal_repeated_operation_is_idempotent() {
// 幂等:同一输入两次完整压测,除耗时/堆外全部计数一致,不产生残留状态。
ShopDataCrawlLoadTestProbe.Report first =
probe.runComparison("Shop-C", 500, 3, true, 2, 3);
ShopDataCrawlLoadTestProbe.Report second =
probe.runComparison("Shop-C", 500, 3, true, 2, 3);
assertEquals(first.totalRows(), second.totalRows(), "行数幂等");
assertEquals(first.dbReads(), second.dbReads(), "DB 读幂等");
assertEquals(first.dbWrites(), second.dbWrites(), "DB 写幂等");
assertEquals(first.ossUploadBytes(), second.ossUploadBytes(), "OSS 流量幂等");
assertEquals(first.ossUploads(), second.ossUploads(), "OSS 次数幂等");
assertEquals(first.lockAcquires(), second.lockAcquires(), "锁获取幂等");
assertEquals(first.lockRetries(), second.lockRetries(), "锁重试幂等");
assertEquals(3, second.rounds(), "轮数保持");
assertEquals(1500, second.totalRows(), "3 轮 500 行合计");
}
@Test
void test_task_040_lock_object_storage_boundary_empty_input() {
// 空输入:0 行不创建对象、不写 DB、不采样 payload,报告行数为 0。
ShopDataCrawlLoadTestProbe.Report report =
probe.runComparison("Shop-D", 0, 5, true, 1, 2);
assertEquals(0, report.totalRows(), "空输入 0 行");
assertEquals(0, report.dbReads(), "无数据不读结果");
assertEquals(0, report.dbWrites(), "无数据不写结果");
assertEquals(0, report.ossUploads(), "无数据不上传对象");
assertEquals(0, report.ossUploadBytes(), "无数据无对象流量");
assertEquals(2, report.lockAcquires(), "空轮次仍执行任务锁路径");
assertEquals(0, report.ossDeletes(), "无数据无删除");
}
@Test
void test_task_040_lock_object_storage_boundary_single_item() {
// 单元素:1 行 1 国单线程单轮,不依赖批量路径,五类指标全部可算。
ShopDataCrawlLoadTestProbe.Report report =
probe.runComparison("Shop-E", 1, 1, false, 1, 1);
assertEquals(1, report.totalRows(), "单行");
assertEquals(1, report.dbReads(), "单国一次读取");
assertEquals(2, report.dbWrites(), "单行结果+分片写入");
assertEquals(1, report.ossUploads(), "单次上传");
assertTrue(report.ossUploadBytes() > 0, "单行 payload 仍产生流量");
assertEquals(1, report.lockAcquires(), "单次锁获取");
assertEquals(0, report.lockRetries(), "无竞争");
assertTrue(report.peakHeapBytes() > 0, "堆采样有效");
}
@Test
void test_task_040_lock_object_storage_boundary_limit_and_overflow() {
// 上限/超限:5000 行最大值可跑;线程/轮数超限被拒绝,不发生无界并发。
ShopDataCrawlLoadTestProbe.Report maxRows =
probe.runComparison("Shop-F", 5000, 5, true, 1, 1);
assertEquals(5000, maxRows.totalRows(), "最大行数可执行");
assertThrows(IllegalArgumentException.class,
() -> probe.runComparison("Shop-F", 5000, 5, true, 9, 1), "线程数超限被拒绝");
assertThrows(IllegalArgumentException.class,
() -> probe.runComparison("Shop-F", 5000, 5, true, 1, 51), "轮数超限被拒绝");
}
@Test
void test_task_040_lock_object_storage_invalid_input_rejected() {
// 非法输入:空店铺名、负行数、超行数、非法国家数、零线程/零轮数抛可识别异常。
assertThrows(IllegalArgumentException.class,
() -> probe.runComparison("", 100, 5, true, 1, 1), "空店铺名被拒绝");
assertThrows(IllegalArgumentException.class,
() -> probe.runComparison("Shop-G", -1, 5, true, 1, 1), "负行数被拒绝");
assertThrows(IllegalArgumentException.class,
() -> probe.runComparison("Shop-G", 5001, 5, true, 1, 1), "超限行数被拒绝");
assertThrows(IllegalArgumentException.class,
() -> probe.runComparison("Shop-G", 100, 0, true, 1, 1), "0 国被拒绝");
assertThrows(IllegalArgumentException.class,
() -> probe.runComparison("Shop-G", 100, 6, true, 1, 1), "超限国家数被拒绝");
assertThrows(IllegalArgumentException.class,
() -> probe.runComparison("Shop-G", 100, 5, true, 0, 1), "0 线程被拒绝");
assertThrows(IllegalArgumentException.class,
() -> probe.runComparison("Shop-G", 100, 5, true, 1, 0), "0 轮被拒绝");
}
@Test
void test_task_040_lock_object_storage_dependency_failure_releases_resources() {
// 依赖失败:注入对象存储上传失败,压测抛可识别异常;
// 失败后计数不残留,后续正常运行指标完整(可恢复)。
ShopDataCrawlLoadTestProbe probe = new ShopDataCrawlLoadTestProbe(
new ObjectMapper(), new ShopDataCrawlPerfFixture(new ObjectMapper()));
probe.failNextOssUpload();
IllegalStateException ex = assertThrows(IllegalStateException.class,
() -> probe.runComparison("Shop-H", 100, 2, true, 1, 1), "注入上传失败必须抛出");
assertTrue(ex.getMessage().contains("上传失败"), "异常消息可识别");
ShopDataCrawlLoadTestProbe.Report recovered =
probe.runComparison("Shop-H", 100, 2, true, 1, 1);
assertEquals(100, recovered.totalRows(), "恢复后行数正确");
assertEquals(1, recovered.ossUploads(), "恢复后上传计数无残留");
assertEquals(2, recovered.dbReads(), "恢复后 DB 读计数无残留");
assertEquals(1, recovered.lockAcquires(), "恢复后锁计数无残留");
probe.failNextLockAcquire();
ShopDataCrawlLoadTestProbe.Report withRetry =
probe.runComparison("Shop-H", 100, 2, true, 1, 1);
assertEquals(1, withRetry.lockRetries(), "注入锁失败计入重试");
assertEquals(1, withRetry.lockAcquires(), "锁获取最终成功");
assertFalse(withRetry.ossUploadBytes() <= 0, "锁重试不影响后续上传");
}
// ---- 辅助:确定性 items(未使用的 fixture 校验用) ----
private static List<ShopDataCrawlResultItemVo> items(int rowCount, int countryCount) {
ShopDataCrawlResultItemVo item = new ShopDataCrawlResultItemVo();
item.setSuccess(true);
List<ShopDataCrawlCountryResultDto> countryResults = new java.util.ArrayList<>();
List<String> countries = List.of("UK", "DE", "FR", "ES", "IT").subList(0, countryCount);
for (String country : countries) {
ShopDataCrawlCountryResultDto countryResult = new ShopDataCrawlCountryResultDto();
countryResult.setCountry(country);
countryResult.setItems(new java.util.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));
countryResults.get(i % countryCount).getItems().add(row);
}
item.setCountryResults(countryResults);
return List.of(item);
}
}
@@ -127,7 +127,7 @@ class SimilarAsinCozeClientLoggingTest {
row.setTitle("T".repeat(5000));
row.setSku("SKU-SECRET");
rows.add(row);
SimilarAsinCozeClient client = new SimilarAsinCozeClient(new SimilarAsinProperties(), objectMapper, null);
SimilarAsinCozeClient client = new SimilarAsinCozeClient(new SimilarAsinProperties(), objectMapper, null, null);
Method maskMethod = SimilarAsinCozeClient.class.getDeclaredMethod(
"maskCozeRequestBody", Map.class);
maskMethod.setAccessible(true);
@@ -38,7 +38,7 @@ class SimilarAsinCozeClientTest {
]
""", new TypeReference<>() {
});
SimilarAsinCozeClient client = new SimilarAsinCozeClient(new SimilarAsinProperties(), objectMapper, null);
SimilarAsinCozeClient client = new SimilarAsinCozeClient(new SimilarAsinProperties(), objectMapper, null, null);
Method method = SimilarAsinCozeClient.class.getDeclaredMethod(
"buildParameters", List.class, String.class, String.class, boolean.class);
@@ -71,7 +71,7 @@ class SimilarAsinCozeClientTest {
row.setPrice("8.50");
row.setTitle("Legacy title");
row.setSku("SKU-LEGACY");
SimilarAsinCozeClient client = new SimilarAsinCozeClient(new SimilarAsinProperties(), objectMapper, null);
SimilarAsinCozeClient client = new SimilarAsinCozeClient(new SimilarAsinProperties(), objectMapper, null, null);
Method method = SimilarAsinCozeClient.class.getDeclaredMethod(
"buildParameters", List.class, String.class, String.class, boolean.class);
@@ -102,7 +102,7 @@ class SimilarAsinCozeClientTest {
]
""", new TypeReference<>() {
});
SimilarAsinCozeClient client = new SimilarAsinCozeClient(new SimilarAsinProperties(), objectMapper, null);
SimilarAsinCozeClient client = new SimilarAsinCozeClient(new SimilarAsinProperties(), objectMapper, null, null);
Method method = SimilarAsinCozeClient.class.getDeclaredMethod(
"buildParameters", List.class, String.class, String.class, boolean.class);
@@ -128,7 +128,7 @@ class SimilarAsinCozeClientTest {
row.setUrl("https://m.media-amazon.com/images/I/main.jpg");
row.setUrls(List.of("https://cbu01.alicdn.com/img/ibank/fallback.jpg"));
row.setPrice("");
SimilarAsinCozeClient client = new SimilarAsinCozeClient(new SimilarAsinProperties(), objectMapper, null);
SimilarAsinCozeClient client = new SimilarAsinCozeClient(new SimilarAsinProperties(), objectMapper, null, null);
Method method = SimilarAsinCozeClient.class.getDeclaredMethod(
"buildParameters", List.class, String.class, String.class, boolean.class);
@@ -162,7 +162,7 @@ class SimilarAsinCozeClientTest {
]
""", new TypeReference<>() {
});
SimilarAsinCozeClient client = new SimilarAsinCozeClient(new SimilarAsinProperties(), objectMapper, null);
SimilarAsinCozeClient client = new SimilarAsinCozeClient(new SimilarAsinProperties(), objectMapper, null, null);
Method method = SimilarAsinCozeClient.class.getDeclaredMethod(
"buildParameters", List.class, String.class, String.class, boolean.class);
@@ -188,7 +188,7 @@ class SimilarAsinCozeClientTest {
SimilarAsinResultRowDto row = new SimilarAsinResultRowDto();
row.setAsin("B0CATEGORY1");
row.setTitle("Category test");
SimilarAsinCozeClient client = new SimilarAsinCozeClient(new SimilarAsinProperties(), objectMapper, null);
SimilarAsinCozeClient client = new SimilarAsinCozeClient(new SimilarAsinProperties(), objectMapper, null, null);
Method method = SimilarAsinCozeClient.class.getDeclaredMethod(
"buildParameters", List.class, String.class, String.class, boolean.class, boolean.class);
@@ -201,7 +201,7 @@ class SimilarAsinCozeClientTest {
@Test
void imageOnlyWorkflowOutputIsExtractedAndMergedByAsin() throws Exception {
SimilarAsinCozeClient client = new SimilarAsinCozeClient(new SimilarAsinProperties(), objectMapper, null);
SimilarAsinCozeClient client = new SimilarAsinCozeClient(new SimilarAsinProperties(), objectMapper, null, null);
String imageData = """
{"data":[{
"asin":"B0BQNHDP2F",
@@ -0,0 +1,406 @@
package com.nanri.aiimage.modules.task.service;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
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.service.DistributedJobLockService;
import com.nanri.aiimage.config.InstanceMetadata;
import com.nanri.aiimage.config.ModuleCleanupProperties;
import com.nanri.aiimage.config.StorageProperties;
import com.nanri.aiimage.config.TransientStorageProperties;
import com.nanri.aiimage.modules.collectdata.mapper.CollectDataItemMapper;
import com.nanri.aiimage.modules.collectdata.model.entity.CollectDataItemEntity;
import com.nanri.aiimage.modules.file.service.object.RustfsObjectStorageService;
import com.nanri.aiimage.modules.file.service.oss.OssStorageService;
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.TaskFileJobMapper;
import com.nanri.aiimage.modules.task.mapper.TaskProgressSnapshotMapper;
import com.nanri.aiimage.modules.task.mapper.TaskResultItemMapper;
import com.nanri.aiimage.modules.task.mapper.TaskResultPayloadMapper;
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.TaskProgressSnapshotEntity;
import com.nanri.aiimage.modules.task.model.entity.TaskResultItemEntity;
import com.nanri.aiimage.modules.task.model.entity.TaskResultPayloadEntity;
import com.nanri.aiimage.modules.task.model.entity.TaskScopeStateEntity;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.ArgumentCaptor;
import org.mockito.ArgumentMatchers;
import org.mockito.invocation.InvocationOnMock;
import org.mockito.stubbing.Answer;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.TransactionDefinition;
import org.springframework.transaction.TransactionStatus;
import java.nio.file.Path;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
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.anyString;
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 70:将历史清理改为 keyset 分页、小批量和短事务。
* ModuleHistoryCleanupService 按 id 升序分页拉取过期任务(keyset,页大小
* batchSize),每页一个短事务执行收集、删除与 flush;下一页游标用当前页
* 最大 id 推进,无重复无遗漏;页内异常回滚并中止,未处理页可下次重试。
*/
class ModuleHistoryCleanupKeysetPaginationTest {
@TempDir
Path tempDir;
private static final LocalDateTime OLD_FINISHED_AT = LocalDateTime.of(2026, 1, 1, 0, 0);
private ModuleCleanupProperties cleanupProperties;
private FileTaskMapper fileTaskMapper;
private FileResultMapper fileResultMapper;
private TaskFileJobMapper taskFileJobMapper;
private TaskResultItemMapper taskResultItemMapper;
private TaskProgressSnapshotMapper taskProgressSnapshotMapper;
private TaskResultPayloadMapper taskResultPayloadMapper;
private TaskScopeStateMapper taskScopeStateMapper;
private TaskChunkMapper taskChunkMapper;
private CollectDataItemMapper collectDataItemMapper;
private DistributedJobLockService lockService;
private TransientPayloadStorageService storage;
private RustfsObjectStorageService rustfs;
private OssStorageService oss;
private TransientPayloadDeleteOrchestrator orchestrator;
private ExecutorService executor;
private PlatformTransactionManager transactionManager;
private ModuleHistoryCleanupService cleanupService;
/** 记录一次事务 begin(快照状态)→ 删除 mappers → commit 的调用轨迹。 */
private final List<String> trace = new ArrayList<>();
@BeforeAll
static void initializeMybatisMetadata() {
MapperBuilderAssistant assistant = new MapperBuilderAssistant(new MybatisConfiguration(), "");
TableInfoHelper.initTableInfo(assistant, FileTaskEntity.class);
TableInfoHelper.initTableInfo(assistant, FileResultEntity.class);
TableInfoHelper.initTableInfo(assistant, TaskFileJobEntity.class);
TableInfoHelper.initTableInfo(assistant, TaskResultItemEntity.class);
TableInfoHelper.initTableInfo(assistant, TaskProgressSnapshotEntity.class);
TableInfoHelper.initTableInfo(assistant, TaskResultPayloadEntity.class);
TableInfoHelper.initTableInfo(assistant, TaskScopeStateEntity.class);
TableInfoHelper.initTableInfo(assistant, TaskChunkEntity.class);
TableInfoHelper.initTableInfo(assistant, CollectDataItemEntity.class);
}
@BeforeEach
@SuppressWarnings("unchecked")
void setUp() {
cleanupProperties = new ModuleCleanupProperties();
fileTaskMapper = mock(FileTaskMapper.class);
fileResultMapper = mock(FileResultMapper.class);
taskFileJobMapper = mock(TaskFileJobMapper.class);
taskResultItemMapper = mock(TaskResultItemMapper.class);
taskProgressSnapshotMapper = mock(TaskProgressSnapshotMapper.class);
taskResultPayloadMapper = mock(TaskResultPayloadMapper.class);
taskScopeStateMapper = mock(TaskScopeStateMapper.class);
taskChunkMapper = mock(TaskChunkMapper.class);
collectDataItemMapper = mock(CollectDataItemMapper.class);
StringRedisTemplate redisTemplate = mock(StringRedisTemplate.class);
ValueOperations<String, String> valueOperations = mock(ValueOperations.class);
when(redisTemplate.opsForValue()).thenReturn(valueOperations);
when(valueOperations.setIfAbsent(anyString(), anyString(), any(Duration.class))).thenReturn(Boolean.TRUE);
lockService = new DistributedJobLockService(redisTemplate);
rustfs = mock(RustfsObjectStorageService.class);
oss = mock(OssStorageService.class);
TransientStorageProperties transientProperties = new TransientStorageProperties();
transientProperties.setEnabled(true);
StorageProperties storageProperties = new StorageProperties();
storageProperties.setLocalTempDir(tempDir.toString());
storage = new TransientPayloadStorageService(
transientProperties, storageProperties, rustfs, oss,
new ObjectMapper(), new InstanceMetadata("test-instance"),
taskChunkMapper, taskScopeStateMapper);
executor = Executors.newFixedThreadPool(2);
orchestrator = new TransientPayloadDeleteOrchestrator(
storage, rustfs, taskChunkMapper, taskScopeStateMapper, new ObjectMapper(), executor);
ReflectionTestUtils.setField(orchestrator, "maxPendingDeletes", 1000L);
transactionManager = mock(PlatformTransactionManager.class);
doAnswer(invocation -> {
trace.add("begin");
return mock(TransactionStatus.class);
}).when(transactionManager).getTransaction(ArgumentMatchers.any(TransactionDefinition.class));
doAnswer(invocation -> {
trace.add("commit");
return null;
}).when(transactionManager).commit(any(TransactionStatus.class));
doAnswer(invocation -> {
trace.add("rollback");
return null;
}).when(transactionManager).rollback(any(TransactionStatus.class));
cleanupService = new ModuleHistoryCleanupService(
cleanupProperties, fileTaskMapper, fileResultMapper, taskFileJobMapper,
taskResultItemMapper, taskProgressSnapshotMapper, taskResultPayloadMapper,
taskScopeStateMapper, taskChunkMapper, collectDataItemMapper, lockService, orchestrator,
transactionManager);
}
private static FileTaskEntity expiredTask(long id) {
FileTaskEntity task = new FileTaskEntity();
task.setId(id);
task.setModuleType("DEDUPE");
task.setStatus("SUCCESS");
task.setFinishedAt(OLD_FINISHED_AT);
return task;
}
private static List<FileTaskEntity> expiredTasks(long... ids) {
List<FileTaskEntity> tasks = new ArrayList<>();
for (long id : ids) {
tasks.add(expiredTask(id));
}
return tasks;
}
private static <T> Answer<List<T>> sequence(List<T> rows, int rowCalls, List<T> fallback) {
return new Answer<List<T>>() {
private int calls;
@Override
public List<T> answer(InvocationOnMock invocation) {
return calls++ < rowCalls ? rows : fallback;
}
};
}
/** 按调用次数依次返回每一页(keyset 翻页,页内 id 升序且首元素大于上次游标)。 */
private static <T> Answer<List<T>> pages(List<List<T>> pages) {
return new Answer<List<T>>() {
private int calls;
@Override
public List<T> answer(InvocationOnMock invocation) {
int idx = calls++;
return idx < pages.size() ? pages.get(idx) : List.of();
}
};
}
private void stubDeletes() {
when(taskFileJobMapper.delete(any(LambdaQueryWrapper.class))).thenReturn(0);
when(taskResultItemMapper.delete(any(LambdaQueryWrapper.class))).thenReturn(0);
when(taskProgressSnapshotMapper.delete(any(LambdaQueryWrapper.class))).thenReturn(0);
when(taskResultPayloadMapper.delete(any(LambdaQueryWrapper.class))).thenReturn(0);
when(taskScopeStateMapper.delete(any(LambdaQueryWrapper.class))).thenReturn(0);
when(taskChunkMapper.delete(any(LambdaQueryWrapper.class))).thenReturn(0);
lenient().when(fileResultMapper.delete(any(LambdaQueryWrapper.class))).thenReturn(0);
lenient().when(fileTaskMapper.update(any(), any())).thenReturn(0);
lenient().when(fileTaskMapper.delete(any(LambdaQueryWrapper.class))).thenReturn(1);
}
private static String pointer(int taskId) {
return "rustfs:task-parsed/test/" + taskId + "/scope/latest.json";
}
private static String jsonPointer(int taskId) {
return "\"" + pointer(taskId) + "\"";
}
private static TaskChunkEntity chunk(long taskId, String payloadJson) {
TaskChunkEntity chunk = new TaskChunkEntity();
chunk.setTaskId(taskId);
chunk.setModuleType("DEDUPE");
chunk.setPayloadJson(payloadJson);
return chunk;
}
private static TaskScopeStateEntity scopeState(long taskId, String parsedPayloadJson, String stateJson) {
TaskScopeStateEntity state = new TaskScopeStateEntity();
state.setTaskId(taskId);
state.setModuleType("DEDUPE");
state.setParsedPayloadJson(parsedPayloadJson);
state.setStateJson(stateJson);
return state;
}
@Test
void test_task_070_cleanup_normal_default_path() {
// 默认路径:单页完成清理,事务 begin→删除→commit 成对出现。
cleanupProperties.setBatchSize(100);
when(fileTaskMapper.selectList(any(LambdaQueryWrapper.class)))
.thenAnswer(sequence(expiredTasks(1, 2), 1, List.of()));
stubDeletes();
when(taskChunkMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(List.of());
when(taskScopeStateMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(List.of());
cleanupService.cleanupConfiguredModules();
assertEquals(List.of("begin", "commit"), trace, "单页一事务,先提交后删除");
verify(fileTaskMapper).delete(any(LambdaQueryWrapper.class));
assertEquals(1, traceCount("commit"), "恰好一次提交");
}
@Test
void test_task_070_cleanup_normal_multiple_items() {
// 批量场景:3 页(每页 2 条)翻页清理,页内删除不丢行,总删除等于页和。
cleanupProperties.setBatchSize(2);
when(fileTaskMapper.selectList(any(LambdaQueryWrapper.class))).thenAnswer(pages(
List.of(expiredTasks(1, 2), expiredTasks(3, 4), expiredTasks(5, 6))));
stubDeletes();
when(taskChunkMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(List.of());
when(taskScopeStateMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(List.of());
cleanupService.cleanupConfiguredModules();
assertEquals(3, traceCount("begin"), "每页一个事务");
assertEquals(3, traceCount("commit"));
ArgumentCaptor<LambdaQueryWrapper<FileTaskEntity>> captor =
ArgumentCaptor.forClass(LambdaQueryWrapper.class);
verify(fileTaskMapper, times(3)).delete(captor.capture());
for (LambdaQueryWrapper<FileTaskEntity> wrapper : captor.getAllValues()) {
assertTrue(wrapper.getCustomSqlSegment().contains("IN"), "每页按任务 id 批量删除");
}
assertEquals(3, captor.getAllValues().size(), "三页三次小批量删除,非一次大删");
}
@Test
void test_task_070_cleanup_normal_repeated_operation_is_idempotent() {
// 幂等:翻页到页尾后第二轮无行,事务数不随重复运行增加。
cleanupProperties.setBatchSize(100);
when(fileTaskMapper.selectList(any(LambdaQueryWrapper.class)))
.thenAnswer(sequence(expiredTasks(1), 2, List.of()));
stubDeletes();
when(taskChunkMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(List.of());
when(taskScopeStateMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(List.of());
cleanupService.cleanupConfiguredModules();
cleanupService.cleanupConfiguredModules();
assertEquals(2, traceCount("begin"), "每轮一个事务,页尾后无多余事务");
assertEquals(2, traceCount("commit"));
verify(fileTaskMapper, times(2)).delete(any(LambdaQueryWrapper.class));
}
@Test
void test_task_070_cleanup_boundary_empty_input() {
// 空输入:无过期任务时一次事务都不开,不查询子表、不删除。
cleanupProperties.setBatchSize(100);
when(fileTaskMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(List.of());
cleanupService.cleanupConfiguredModules();
assertEquals(0, traceCount("begin"), "空输入不开事务");
verify(taskChunkMapper, never()).selectList(any(LambdaQueryWrapper.class));
verify(taskChunkMapper, never()).delete(any(LambdaQueryWrapper.class));
}
@Test
void test_task_070_cleanup_boundary_single_item() {
// 单元素:单任务单页单事务,不依赖批量路径,行删除一次。
cleanupProperties.setBatchSize(100);
when(fileTaskMapper.selectList(any(LambdaQueryWrapper.class)))
.thenAnswer(sequence(expiredTasks(9), 1, List.of()));
stubDeletes();
when(taskChunkMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(List.of());
when(taskScopeStateMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(List.of());
cleanupService.cleanupConfiguredModules();
assertEquals(List.of("begin", "commit"), trace, "单任务一事务");
verify(fileTaskMapper).delete(any(LambdaQueryWrapper.class));
}
@Test
void test_task_070_cleanup_boundary_limit_and_overflow() {
// 上限/超限:batchSize 为 0/负值时按默认 500 处理,不因非法配置死循环或崩溃。
cleanupProperties.setBatchSize(-1);
when(fileTaskMapper.selectList(any(LambdaQueryWrapper.class)))
.thenAnswer(sequence(expiredTasks(1), 1, List.of()));
stubDeletes();
when(taskChunkMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(List.of());
when(taskScopeStateMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(List.of());
cleanupService.cleanupConfiguredModules();
assertEquals(1, traceCount("commit"), "非法 batchSize 走默认值,仍正常完成");
verify(fileTaskMapper).delete(any(LambdaQueryWrapper.class));
}
@Test
void test_task_070_cleanup_invalid_input_rejected() {
// 非法参数:缺少必填模块类型配置时拒绝执行,不开事务、不查询、不删除。
cleanupProperties.setModuleTypes(null);
cleanupService.cleanupConfiguredModules();
assertEquals(0, traceCount("begin"), "非法配置不开事务");
verify(fileTaskMapper, never()).selectList(any(LambdaQueryWrapper.class));
verify(fileTaskMapper, never()).delete(any(LambdaQueryWrapper.class));
}
@Test
void test_task_070_cleanup_dependency_failure_releases_resources() throws Exception {
// 依赖失败:页内删除抛异常时回滚事务并中止,未处理页不继续;
// 已收集指针不提交、不 flush;恢复后重跑成功提交。
cleanupProperties.setBatchSize(2);
when(fileTaskMapper.selectList(any(LambdaQueryWrapper.class))).thenAnswer(pages(
List.of(expiredTasks(1, 2), expiredTasks(3, 4))));
stubDeletes();
when(taskChunkMapper.selectList(any(LambdaQueryWrapper.class)))
.thenAnswer(sequence(List.of(chunk(1, jsonPointer(1))), 1, List.of()));
when(taskScopeStateMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(List.of());
when(taskChunkMapper.delete(any(LambdaQueryWrapper.class)))
.thenThrow(new RuntimeException("db down"));
assertThrows(RuntimeException.class, () -> cleanupService.cleanupConfiguredModules());
assertEquals(List.of("begin", "rollback"), trace, "页内异常回滚事务");
verify(fileTaskMapper, never()).delete(any(LambdaQueryWrapper.class));
assertEquals(0, orchestrator.pendingCount(), "指针不提交");
verify(rustfs, never()).deleteObject(anyString());
when(taskChunkMapper.selectList(any(LambdaQueryWrapper.class)))
.thenAnswer(sequence(List.of(chunk(1, jsonPointer(1))), 1, List.of()));
when(taskChunkMapper.delete(any(LambdaQueryWrapper.class))).thenReturn(0);
CountDownLatch done = new CountDownLatch(1);
doAnswer(invocation -> {
done.countDown();
return null;
}).when(rustfs).deleteObject(anyString());
cleanupService.cleanupConfiguredModules();
assertTrue(done.await(2, TimeUnit.SECONDS), "恢复后指针异步删除完成");
assertEquals(0, orchestrator.pendingCount());
assertEquals(1, traceCount("commit"), "恢复后提交成功");
}
private int traceCount(String event) {
return (int) trace.stream().filter(event::equals).count();
}
}
@@ -0,0 +1,297 @@
package com.nanri.aiimage.modules.task.service;
import ch.qos.logback.classic.Level;
import ch.qos.logback.classic.Logger;
import ch.qos.logback.classic.spi.ILoggingEvent;
import ch.qos.logback.core.read.ListAppender;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
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.service.DistributedJobLockService;
import com.nanri.aiimage.config.InstanceMetadata;
import com.nanri.aiimage.config.ModuleCleanupProperties;
import com.nanri.aiimage.config.StorageProperties;
import com.nanri.aiimage.config.TransientStorageProperties;
import com.nanri.aiimage.modules.collectdata.mapper.CollectDataItemMapper;
import com.nanri.aiimage.modules.collectdata.model.entity.CollectDataItemEntity;
import com.nanri.aiimage.modules.file.service.object.RustfsObjectStorageService;
import com.nanri.aiimage.modules.file.service.oss.OssStorageService;
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.TaskFileJobMapper;
import com.nanri.aiimage.modules.task.mapper.TaskProgressSnapshotMapper;
import com.nanri.aiimage.modules.task.mapper.TaskResultItemMapper;
import com.nanri.aiimage.modules.task.mapper.TaskResultPayloadMapper;
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.TaskProgressSnapshotEntity;
import com.nanri.aiimage.modules.task.model.entity.TaskResultItemEntity;
import com.nanri.aiimage.modules.task.model.entity.TaskResultPayloadEntity;
import com.nanri.aiimage.modules.task.model.entity.TaskScopeStateEntity;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.ArgumentMatchers;
import org.mockito.invocation.InvocationOnMock;
import org.mockito.stubbing.Answer;
import org.slf4j.LoggerFactory;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.TransactionDefinition;
import org.springframework.transaction.TransactionStatus;
import java.nio.file.Path;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
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.anyString;
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.verify;
import static org.mockito.Mockito.when;
/**
* Task 71:清理日志改为数量与 sample ID,禁止输出超长任务 ID 列表。
* ModuleHistoryCleanupService 的日志只打印 count 与最多 5 个 sample id
* (超限追加省略号),不打印完整任务 ID 列表;formatIdSample 对空值、
* 单元素、超限与非法参数均有确定行为。
*/
class ModuleHistoryCleanupLoggingTest {
@TempDir
Path tempDir;
private static final LocalDateTime OLD_FINISHED_AT = LocalDateTime.of(2026, 1, 1, 0, 0);
private static final int LOG_SAMPLE_IDS = 5;
private ModuleCleanupProperties cleanupProperties;
private FileTaskMapper fileTaskMapper;
private FileResultMapper fileResultMapper;
private TaskFileJobMapper taskFileJobMapper;
private TaskResultItemMapper taskResultItemMapper;
private TaskProgressSnapshotMapper taskProgressSnapshotMapper;
private TaskResultPayloadMapper taskResultPayloadMapper;
private TaskScopeStateMapper taskScopeStateMapper;
private TaskChunkMapper taskChunkMapper;
private CollectDataItemMapper collectDataItemMapper;
private DistributedJobLockService lockService;
private TransientPayloadStorageService storage;
private RustfsObjectStorageService rustfs;
private OssStorageService oss;
private TransientPayloadDeleteOrchestrator orchestrator;
private ExecutorService executor;
private PlatformTransactionManager transactionManager;
private ModuleHistoryCleanupService cleanupService;
@BeforeAll
static void initializeMybatisMetadata() {
MapperBuilderAssistant assistant = new MapperBuilderAssistant(new MybatisConfiguration(), "");
TableInfoHelper.initTableInfo(assistant, FileTaskEntity.class);
TableInfoHelper.initTableInfo(assistant, FileResultEntity.class);
TableInfoHelper.initTableInfo(assistant, TaskFileJobEntity.class);
TableInfoHelper.initTableInfo(assistant, TaskResultItemEntity.class);
TableInfoHelper.initTableInfo(assistant, TaskProgressSnapshotEntity.class);
TableInfoHelper.initTableInfo(assistant, TaskResultPayloadEntity.class);
TableInfoHelper.initTableInfo(assistant, TaskScopeStateEntity.class);
TableInfoHelper.initTableInfo(assistant, TaskChunkEntity.class);
TableInfoHelper.initTableInfo(assistant, CollectDataItemEntity.class);
}
@BeforeEach
@SuppressWarnings("unchecked")
void setUp() {
cleanupProperties = new ModuleCleanupProperties();
fileTaskMapper = mock(FileTaskMapper.class);
fileResultMapper = mock(FileResultMapper.class);
taskFileJobMapper = mock(TaskFileJobMapper.class);
taskResultItemMapper = mock(TaskResultItemMapper.class);
taskProgressSnapshotMapper = mock(TaskProgressSnapshotMapper.class);
taskResultPayloadMapper = mock(TaskResultPayloadMapper.class);
taskScopeStateMapper = mock(TaskScopeStateMapper.class);
taskChunkMapper = mock(TaskChunkMapper.class);
collectDataItemMapper = mock(CollectDataItemMapper.class);
StringRedisTemplate redisTemplate = mock(StringRedisTemplate.class);
ValueOperations<String, String> valueOperations = mock(ValueOperations.class);
when(redisTemplate.opsForValue()).thenReturn(valueOperations);
when(valueOperations.setIfAbsent(anyString(), anyString(), any(Duration.class))).thenReturn(Boolean.TRUE);
lockService = new DistributedJobLockService(redisTemplate);
rustfs = mock(RustfsObjectStorageService.class);
oss = mock(OssStorageService.class);
TransientStorageProperties transientProperties = new TransientStorageProperties();
transientProperties.setEnabled(true);
StorageProperties storageProperties = new StorageProperties();
storageProperties.setLocalTempDir(tempDir.toString());
storage = new TransientPayloadStorageService(
transientProperties, storageProperties, rustfs, oss,
new ObjectMapper(), new InstanceMetadata("test-instance"),
taskChunkMapper, taskScopeStateMapper);
executor = Executors.newFixedThreadPool(2);
orchestrator = new TransientPayloadDeleteOrchestrator(
storage, rustfs, taskChunkMapper, taskScopeStateMapper, new ObjectMapper(), executor);
ReflectionTestUtils.setField(orchestrator, "maxPendingDeletes", 1000L);
transactionManager = mock(PlatformTransactionManager.class);
doAnswer(invocation -> mock(TransactionStatus.class))
.when(transactionManager).getTransaction(ArgumentMatchers.any(TransactionDefinition.class));
doAnswer(invocation -> null).when(transactionManager).commit(any(TransactionStatus.class));
doAnswer(invocation -> null).when(transactionManager).rollback(any(TransactionStatus.class));
cleanupService = new ModuleHistoryCleanupService(
cleanupProperties, fileTaskMapper, fileResultMapper, taskFileJobMapper,
taskResultItemMapper, taskProgressSnapshotMapper, taskResultPayloadMapper,
taskScopeStateMapper, taskChunkMapper, collectDataItemMapper, lockService, orchestrator,
transactionManager);
}
private static FileTaskEntity expiredTask(long id) {
FileTaskEntity task = new FileTaskEntity();
task.setId(id);
task.setModuleType("DEDUPE");
task.setStatus("SUCCESS");
task.setFinishedAt(OLD_FINISHED_AT);
return task;
}
private static <T> Answer<List<T>> sequence(List<T> rows, int rowCalls, List<T> fallback) {
return new Answer<List<T>>() {
private int calls;
@Override
public List<T> answer(InvocationOnMock invocation) {
return calls++ < rowCalls ? rows : fallback;
}
};
}
private ListAppender<ILoggingEvent> attachLogAppender() {
Logger logger = (Logger) LoggerFactory.getLogger(ModuleHistoryCleanupService.class);
ListAppender<ILoggingEvent> appender = new ListAppender<>();
appender.setContext(logger.getLoggerContext());
appender.start();
logger.addAppender(appender);
return appender;
}
private static void detachLogAppender(ListAppender<ILoggingEvent> appender) {
Logger logger = (Logger) LoggerFactory.getLogger(ModuleHistoryCleanupService.class);
logger.detachAppender(appender);
}
@Test
void test_task_071_cleanup_logging_normal_default_path() {
// 默认路径:正常列表完整输出,数量与 sample 齐全。
List<Long> ids = List.of(11L, 22L, 33L);
assertEquals("count=3, sample=[11,22,33]",
ModuleHistoryCleanupService.formatIdSample(ids, LOG_SAMPLE_IDS));
}
@Test
void test_task_071_cleanup_logging_normal_multiple_items() {
// 批量场景:超过 sample 上限时输出截断列表与省略号,count 保持完整。
List<Long> ids = List.of(1L, 2L, 3L, 4L, 5L, 6L, 7L);
assertEquals("count=7, sample=[1,2,3,4,5,...]",
ModuleHistoryCleanupService.formatIdSample(ids, LOG_SAMPLE_IDS));
}
@Test
void test_task_071_cleanup_logging_normal_repeated_operation_is_idempotent() {
// 幂等:同一输入重复格式化结果一致,无随机性。
List<Long> ids = List.of(9L, 8L, 7L, 6L, 5L, 4L);
String first = ModuleHistoryCleanupService.formatIdSample(ids, LOG_SAMPLE_IDS);
String second = ModuleHistoryCleanupService.formatIdSample(ids, LOG_SAMPLE_IDS);
assertEquals(first, second);
assertEquals("count=6, sample=[9,8,7,6,5,...]", first);
}
@Test
void test_task_071_cleanup_logging_boundary_empty_input() {
// 空输入:null 与空列表输出 count=0,不输出列表。
assertEquals("count=0", ModuleHistoryCleanupService.formatIdSample(null, LOG_SAMPLE_IDS));
assertEquals("count=0", ModuleHistoryCleanupService.formatIdSample(List.of(), LOG_SAMPLE_IDS));
}
@Test
void test_task_071_cleanup_logging_boundary_single_item() {
// 单元素:单个 id 完整输出,不依赖批量路径。
assertEquals("count=1, sample=[42]",
ModuleHistoryCleanupService.formatIdSample(List.of(42L), LOG_SAMPLE_IDS));
}
@Test
void test_task_071_cleanup_logging_boundary_limit_and_overflow() {
// 上限/超限:sample 上限为 1 时只输出首个 id 加省略号,不输出完整列表。
List<Long> ids = List.of(1L, 2L, 3L, 4L, 5L);
assertEquals("count=5, sample=[1,...]",
ModuleHistoryCleanupService.formatIdSample(ids, 1));
assertEquals("count=5, sample=[1,...]",
ModuleHistoryCleanupService.formatIdSample(ids, 0), "非法上限回退到 1");
assertEquals("count=5, sample=[1,...]",
ModuleHistoryCleanupService.formatIdSample(ids, -3), "负上限回退到 1");
}
@Test
void test_task_071_cleanup_logging_invalid_input_rejected() {
// 非法参数:列表含 null 元素时不抛异常,输出稳定占位;count 保持实际元素数。
assertEquals("count=2, sample=[null,22]",
ModuleHistoryCleanupService.formatIdSample(new ArrayList<>(java.util.Arrays.asList(null, 22L)), LOG_SAMPLE_IDS));
}
@Test
void test_task_071_cleanup_logging_dependency_failure_releases_resources() {
// 依赖失败:服务日志不使用超长列表;依赖异常传播且日志中无完整 ID 列表。
ListAppender<ILoggingEvent> appender = attachLogAppender();
try {
cleanupProperties.setBatchSize(100);
List<Long> ids = new ArrayList<>();
for (long i = 1; i <= 60; i++) {
ids.add(i);
}
List<FileTaskEntity> tasks = new ArrayList<>();
for (long id : ids) {
tasks.add(expiredTask(id));
}
when(fileTaskMapper.selectList(any(LambdaQueryWrapper.class)))
.thenAnswer(sequence(tasks, 1, List.of()));
lenient().when(taskFileJobMapper.delete(any(LambdaQueryWrapper.class))).thenReturn(0);
lenient().when(taskResultItemMapper.delete(any(LambdaQueryWrapper.class))).thenReturn(0);
lenient().when(taskProgressSnapshotMapper.delete(any(LambdaQueryWrapper.class))).thenReturn(0);
lenient().when(taskResultPayloadMapper.delete(any(LambdaQueryWrapper.class))).thenReturn(0);
lenient().when(taskScopeStateMapper.delete(any(LambdaQueryWrapper.class))).thenReturn(0);
when(taskChunkMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(List.of());
when(taskScopeStateMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(List.of());
when(taskChunkMapper.delete(any(LambdaQueryWrapper.class)))
.thenThrow(new RuntimeException("db down"));
assertThrows(RuntimeException.class, () -> cleanupService.cleanupConfiguredModules());
for (ILoggingEvent event : appender.list) {
String message = event.getFormattedMessage();
assertTrue(!message.contains("taskIds=["), "日志不输出超长任务 ID 列表: " + message);
assertTrue(!message.contains("skippedActiveTaskIds=["), "日志不输出超长活跃 ID 列表: " + message);
assertTrue(!message.contains("retainedTaskIds=["), "日志不输出超长保留 ID 列表: " + message);
}
verify(taskChunkMapper).selectList(any(LambdaQueryWrapper.class));
} finally {
detachLogAppender(appender);
}
}
}
@@ -0,0 +1,422 @@
package com.nanri.aiimage.modules.task.service;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
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.service.DistributedJobLockService;
import com.nanri.aiimage.config.InstanceMetadata;
import com.nanri.aiimage.config.ModuleCleanupProperties;
import com.nanri.aiimage.config.StorageProperties;
import com.nanri.aiimage.config.TransientStorageProperties;
import com.nanri.aiimage.modules.collectdata.mapper.CollectDataItemMapper;
import com.nanri.aiimage.modules.collectdata.model.entity.CollectDataItemEntity;
import com.nanri.aiimage.modules.file.service.object.RustfsObjectStorageService;
import com.nanri.aiimage.modules.file.service.oss.OssStorageService;
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.TaskFileJobMapper;
import com.nanri.aiimage.modules.task.mapper.TaskProgressSnapshotMapper;
import com.nanri.aiimage.modules.task.mapper.TaskResultItemMapper;
import com.nanri.aiimage.modules.task.mapper.TaskResultPayloadMapper;
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.TaskProgressSnapshotEntity;
import com.nanri.aiimage.modules.task.model.entity.TaskResultItemEntity;
import com.nanri.aiimage.modules.task.model.entity.TaskResultPayloadEntity;
import com.nanri.aiimage.modules.task.model.entity.TaskScopeStateEntity;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.invocation.InvocationOnMock;
import org.mockito.stubbing.Answer;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.transaction.PlatformTransactionManager;
import java.nio.file.Path;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
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.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 69:为数据库删除任务补充 transient payload 指针收集和清理队列。
* ModuleHistoryCleanupService 在删除 chunk / scope_state 行之前先批量收集
* payloadJson / parsedPayloadJson / stateJson 中的指针并提交给
* TransientPayloadDeleteOrchestrator,全部删除完成后 flush 一次,
* 使清理任务删除的 DB 行不再遗留孤儿对象;删除失败不提交、行删完才提交。
*/
class ModuleHistoryCleanupPayloadCleanupTest {
@TempDir
Path tempDir;
private static final LocalDateTime OLD_FINISHED_AT = LocalDateTime.of(2026, 1, 1, 0, 0);
private ModuleCleanupProperties cleanupProperties;
private FileTaskMapper fileTaskMapper;
private FileResultMapper fileResultMapper;
private TaskFileJobMapper taskFileJobMapper;
private TaskResultItemMapper taskResultItemMapper;
private TaskProgressSnapshotMapper taskProgressSnapshotMapper;
private TaskResultPayloadMapper taskResultPayloadMapper;
private TaskScopeStateMapper taskScopeStateMapper;
private TaskChunkMapper taskChunkMapper;
private CollectDataItemMapper collectDataItemMapper;
private DistributedJobLockService lockService;
private TransientPayloadStorageService storage;
private RustfsObjectStorageService rustfs;
private OssStorageService oss;
private TransientPayloadDeleteOrchestrator orchestrator;
private ExecutorService executor;
private PlatformTransactionManager transactionManager;
private ModuleHistoryCleanupService cleanupService;
@BeforeAll
static void initializeMybatisMetadata() {
MapperBuilderAssistant assistant = new MapperBuilderAssistant(new MybatisConfiguration(), "");
TableInfoHelper.initTableInfo(assistant, FileTaskEntity.class);
TableInfoHelper.initTableInfo(assistant, FileResultEntity.class);
TableInfoHelper.initTableInfo(assistant, TaskFileJobEntity.class);
TableInfoHelper.initTableInfo(assistant, TaskResultItemEntity.class);
TableInfoHelper.initTableInfo(assistant, TaskProgressSnapshotEntity.class);
TableInfoHelper.initTableInfo(assistant, TaskResultPayloadEntity.class);
TableInfoHelper.initTableInfo(assistant, TaskScopeStateEntity.class);
TableInfoHelper.initTableInfo(assistant, TaskChunkEntity.class);
TableInfoHelper.initTableInfo(assistant, CollectDataItemEntity.class);
}
@BeforeEach
@SuppressWarnings("unchecked")
void setUp() {
cleanupProperties = new ModuleCleanupProperties();
fileTaskMapper = mock(FileTaskMapper.class);
fileResultMapper = mock(FileResultMapper.class);
taskFileJobMapper = mock(TaskFileJobMapper.class);
taskResultItemMapper = mock(TaskResultItemMapper.class);
taskProgressSnapshotMapper = mock(TaskProgressSnapshotMapper.class);
taskResultPayloadMapper = mock(TaskResultPayloadMapper.class);
taskScopeStateMapper = mock(TaskScopeStateMapper.class);
taskChunkMapper = mock(TaskChunkMapper.class);
collectDataItemMapper = mock(CollectDataItemMapper.class);
StringRedisTemplate redisTemplate = mock(StringRedisTemplate.class);
ValueOperations<String, String> valueOperations = mock(ValueOperations.class);
when(redisTemplate.opsForValue()).thenReturn(valueOperations);
when(valueOperations.setIfAbsent(anyString(), anyString(), any(Duration.class))).thenReturn(Boolean.TRUE);
lockService = new DistributedJobLockService(redisTemplate);
rustfs = mock(RustfsObjectStorageService.class);
oss = mock(OssStorageService.class);
TransientStorageProperties transientProperties = new TransientStorageProperties();
transientProperties.setEnabled(true);
StorageProperties storageProperties = new StorageProperties();
storageProperties.setLocalTempDir(tempDir.toString());
storage = new TransientPayloadStorageService(
transientProperties, storageProperties, rustfs, oss,
new ObjectMapper(), new InstanceMetadata("test-instance"),
taskChunkMapper, taskScopeStateMapper);
executor = Executors.newFixedThreadPool(2);
orchestrator = new TransientPayloadDeleteOrchestrator(
storage, rustfs, taskChunkMapper, taskScopeStateMapper, new ObjectMapper(), executor);
ReflectionTestUtils.setField(orchestrator, "maxPendingDeletes", 1000L);
transactionManager = mock(PlatformTransactionManager.class);
cleanupService = new ModuleHistoryCleanupService(
cleanupProperties, fileTaskMapper, fileResultMapper, taskFileJobMapper,
taskResultItemMapper, taskProgressSnapshotMapper, taskResultPayloadMapper,
taskScopeStateMapper, taskChunkMapper, collectDataItemMapper, lockService, orchestrator,
transactionManager);
}
private static FileTaskEntity expiredTask(long id) {
FileTaskEntity task = new FileTaskEntity();
task.setId(id);
task.setModuleType("DEDUPE");
task.setStatus("SUCCESS");
task.setFinishedAt(OLD_FINISHED_AT);
return task;
}
/** 任务查询返回一次 rows 后变为空(keyset 分页到页尾),runs 次完整清理各查一轮。 */
private void stubExpiredTasks(long... ids) {
List<FileTaskEntity> tasks = new ArrayList<>();
for (long id : ids) {
tasks.add(expiredTask(id));
}
List<FileTaskEntity> none = List.of();
when(fileTaskMapper.selectList(any(LambdaQueryWrapper.class)))
.thenAnswer(sequence(tasks, 1, none));
}
/** 幂等/恢复场景:多次清理运行各返回一轮 rows。 */
private void stubExpiredTasksRuns(int runs, long... ids) {
List<FileTaskEntity> tasks = new ArrayList<>();
for (long id : ids) {
tasks.add(expiredTask(id));
}
List<FileTaskEntity> none = List.of();
when(fileTaskMapper.selectList(any(LambdaQueryWrapper.class)))
.thenAnswer(sequence(tasks, runs, none));
}
/** 收集与 flush 都走同一 mapper.selectList:前 rowCalls 次返回 rows(行未删),之后返回 fallback。 */
private <T> Answer<List<T>> sequence(List<T> rows, int rowCalls, List<T> fallback) {
return new Answer<List<T>>() {
private int calls;
@Override
public List<T> answer(InvocationOnMock invocation) {
return calls++ < rowCalls ? rows : fallback;
}
};
}
private <T> Answer<List<T>> sequenceThenFail(List<T> rows, int rowCalls) {
return new Answer<List<T>>() {
private int calls;
@Override
public List<T> answer(InvocationOnMock invocation) {
if (calls++ < rowCalls) {
return rows;
}
throw new RuntimeException("db down");
}
};
}
private static TaskChunkEntity chunk(long taskId, String payloadJson) {
TaskChunkEntity chunk = new TaskChunkEntity();
chunk.setTaskId(taskId);
chunk.setModuleType("DEDUPE");
chunk.setPayloadJson(payloadJson);
return chunk;
}
private static TaskScopeStateEntity scopeState(long taskId, String parsedPayloadJson, String stateJson) {
TaskScopeStateEntity state = new TaskScopeStateEntity();
state.setTaskId(taskId);
state.setModuleType("DEDUPE");
state.setParsedPayloadJson(parsedPayloadJson);
state.setStateJson(stateJson);
return state;
}
private void stubDeletes() {
when(taskFileJobMapper.delete(any(LambdaQueryWrapper.class))).thenReturn(0);
when(taskResultItemMapper.delete(any(LambdaQueryWrapper.class))).thenReturn(0);
when(taskProgressSnapshotMapper.delete(any(LambdaQueryWrapper.class))).thenReturn(0);
when(taskResultPayloadMapper.delete(any(LambdaQueryWrapper.class))).thenReturn(0);
when(taskScopeStateMapper.delete(any(LambdaQueryWrapper.class))).thenReturn(0);
when(taskChunkMapper.delete(any(LambdaQueryWrapper.class))).thenReturn(0);
lenient().when(fileResultMapper.delete(any(LambdaQueryWrapper.class))).thenReturn(0);
lenient().when(fileTaskMapper.update(any(), any())).thenReturn(0);
lenient().when(fileTaskMapper.delete(any(LambdaQueryWrapper.class))).thenReturn(1);
}
private static String pointer(int taskId) {
return "rustfs:task-parsed/test/" + taskId + "/scope/latest.json";
}
private static String jsonPointer(int taskId) {
return "\"" + pointer(taskId) + "\"";
}
private CountDownLatch latchOnDelete(int count) {
CountDownLatch latch = new CountDownLatch(count);
doAnswer(invocation -> {
latch.countDown();
return null;
}).when(rustfs).deleteObject(anyString());
return latch;
}
@Test
void test_task_069_payload_cleanup_normal_default_path() throws Exception {
// 默认路径:清理删除 chunk / scope_state 行前收集 payload 指针提交队列,
// 行全部删除后 flush,未引用对象异步物理删除一次。
stubExpiredTasks(11);
stubDeletes();
List<TaskChunkEntity> chunks = List.of(chunk(11, jsonPointer(11)));
List<TaskScopeStateEntity> states = List.of(scopeState(11, jsonPointer(12), pointer(13)));
doAnswer(sequence(chunks, 1, List.of())).when(taskChunkMapper).selectList(any(LambdaQueryWrapper.class));
doAnswer(sequence(states, 1, List.of())).when(taskScopeStateMapper).selectList(any(LambdaQueryWrapper.class));
CountDownLatch done = latchOnDelete(3);
cleanupService.cleanupConfiguredModules();
assertTrue(done.await(2, TimeUnit.SECONDS), "三个收集的指针异步删除完成");
verify(taskChunkMapper).delete(any(LambdaQueryWrapper.class));
verify(taskScopeStateMapper).delete(any(LambdaQueryWrapper.class));
verify(rustfs, times(3)).deleteObject(anyString());
verify(rustfs).deleteObject("task-parsed/test/11/scope/latest.json");
verify(rustfs).deleteObject("task-parsed/test/12/scope/latest.json");
verify(rustfs).deleteObject("task-parsed/test/13/scope/latest.json");
}
@Test
void test_task_069_payload_cleanup_normal_multiple_items() throws Exception {
// 批量场景:多个任务、多个 chunk / scope_state 行的指针全部收集,
// 去重后顺序稳定、无丢失;flush 后每个对象恰好删除一次。
stubExpiredTasks(21, 22);
stubDeletes();
List<TaskChunkEntity> chunks = List.of(
chunk(21, jsonPointer(21)), chunk(21, jsonPointer(22)), chunk(22, pointer(23)));
List<TaskScopeStateEntity> states = List.of(
scopeState(21, pointer(24), jsonPointer(21)), scopeState(22, jsonPointer(25), null));
doAnswer(sequence(chunks, 1, List.of())).when(taskChunkMapper).selectList(any(LambdaQueryWrapper.class));
doAnswer(sequence(states, 1, List.of())).when(taskScopeStateMapper).selectList(any(LambdaQueryWrapper.class));
CountDownLatch done = latchOnDelete(5);
cleanupService.cleanupConfiguredModules();
assertTrue(done.await(2, TimeUnit.SECONDS), "五个收集的指针异步删除完成");
verify(rustfs, times(5)).deleteObject(anyString());
verify(rustfs).deleteObject("task-parsed/test/21/scope/latest.json");
verify(rustfs).deleteObject("task-parsed/test/22/scope/latest.json");
verify(rustfs).deleteObject("task-parsed/test/23/scope/latest.json");
verify(rustfs).deleteObject("task-parsed/test/24/scope/latest.json");
verify(rustfs).deleteObject("task-parsed/test/25/scope/latest.json");
}
@Test
void test_task_069_payload_cleanup_normal_repeated_operation_is_idempotent() throws Exception {
// 幂等:行已删除后再次运行不收集指针、不提交、不发起物理删除。
stubExpiredTasksRuns(2, 31);
stubDeletes();
when(taskChunkMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(List.of());
when(taskScopeStateMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(List.of());
CountDownLatch done = latchOnDelete(1);
cleanupService.cleanupConfiguredModules();
cleanupService.cleanupConfiguredModules();
assertEquals(1, done.getCount(), "两次运行均无对象进入删除队列");
verify(rustfs, never()).deleteObject(anyString());
verify(taskChunkMapper, times(2)).selectList(any(LambdaQueryWrapper.class));
verify(taskScopeStateMapper, times(2)).selectList(any(LambdaQueryWrapper.class));
}
@Test
void test_task_069_payload_cleanup_boundary_empty_input() {
// 空输入:无过期任务时安全跳过,不查询 rows、不提交、不 flush、不删除任何对象。
stubExpiredTasks();
cleanupService.cleanupConfiguredModules();
verify(taskChunkMapper, never()).selectList(any(LambdaQueryWrapper.class));
verify(taskScopeStateMapper, never()).selectList(any(LambdaQueryWrapper.class));
verify(taskChunkMapper, never()).delete(any(LambdaQueryWrapper.class));
verify(taskScopeStateMapper, never()).delete(any(LambdaQueryWrapper.class));
verify(rustfs, never()).deleteObject(anyString());
}
@Test
void test_task_069_payload_cleanup_boundary_single_item() throws Exception {
// 单元素:单任务单 chunk 单指针,收集一次提交一次,删除一次。
stubExpiredTasks(41);
stubDeletes();
doAnswer(sequence(List.of(chunk(41, jsonPointer(41))), 1, List.of()))
.when(taskChunkMapper).selectList(any(LambdaQueryWrapper.class));
doAnswer(sequence(List.of(), 1, List.of()))
.when(taskScopeStateMapper).selectList(any(LambdaQueryWrapper.class));
CountDownLatch done = latchOnDelete(1);
cleanupService.cleanupConfiguredModules();
assertTrue(done.await(2, TimeUnit.SECONDS), "单指针异步删除完成");
verify(rustfs, times(1)).deleteObject(anyString());
verify(rustfs).deleteObject("task-parsed/test/41/scope/latest.json");
}
@Test
void test_task_069_payload_cleanup_boundary_limit_and_overflow() throws Exception {
// 上限/超限:收集达到上限即停止,不发生无界收集;
// flush 时引用检查失败则本批保留(保守),可下次重试。
stubExpiredTasks(51);
stubDeletes();
ReflectionTestUtils.setField(cleanupService, "maxCollectPayloadsPerRun", 3);
List<TaskChunkEntity> chunks = new ArrayList<>();
for (int i = 1; i <= 5; i++) {
chunks.add(chunk(51, jsonPointer(50 + i)));
}
doAnswer(sequenceThenFail(chunks, 1)).when(taskChunkMapper).selectList(any(LambdaQueryWrapper.class));
when(taskScopeStateMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(List.of());
cleanupService.cleanupConfiguredModules();
assertEquals(3, orchestrator.pendingCount(), "仅保留上限内的指针");
verify(rustfs, never()).deleteObject(anyString());
verify(taskChunkMapper).delete(any(LambdaQueryWrapper.class));
}
@Test
void test_task_069_payload_cleanup_invalid_input_rejected() throws Exception {
// 非法参数:空值与纯空白不收集,非指针文本由清理队列过滤,
// 行正常删除、无物理删除;flush 引用检查失败时有效指针保守保留。
stubExpiredTasks(61);
stubDeletes();
List<TaskChunkEntity> chunks = List.of(chunk(61, null), chunk(61, "not-a-pointer"), chunk(61, " "));
List<TaskScopeStateEntity> states = List.of(scopeState(61, null, pointer(63)));
doAnswer(sequenceThenFail(chunks, 1)).when(taskChunkMapper).selectList(any(LambdaQueryWrapper.class));
doAnswer(sequenceThenFail(states, 1)).when(taskScopeStateMapper).selectList(any(LambdaQueryWrapper.class));
cleanupService.cleanupConfiguredModules();
assertEquals(1, orchestrator.pendingCount(), "仅有效指针入队");
verify(taskChunkMapper).delete(any(LambdaQueryWrapper.class));
verify(taskScopeStateMapper).delete(any(LambdaQueryWrapper.class));
verify(rustfs, never()).deleteObject(anyString());
}
@Test
void test_task_069_payload_cleanup_dependency_failure_releases_resources() throws Exception {
// 依赖失败:行删除抛异常时中止,指针不提交、不 flush、不删除对象;
// 恢复后再次运行,收集、删除与清理队列全部正常完成。
stubExpiredTasksRuns(2, 71);
stubDeletes();
when(taskChunkMapper.delete(any(LambdaQueryWrapper.class)))
.thenThrow(new RuntimeException("db down"))
.thenReturn(0);
List<TaskChunkEntity> chunks = List.of(chunk(71, jsonPointer(71)));
doAnswer(sequence(chunks, 2, List.of())).when(taskChunkMapper).selectList(any(LambdaQueryWrapper.class));
when(taskScopeStateMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(List.of());
org.junit.jupiter.api.Assertions.assertThrows(RuntimeException.class,
() -> cleanupService.cleanupConfiguredModules());
assertEquals(0, orchestrator.pendingCount(), "删除失败时指针不提交");
verify(rustfs, never()).deleteObject(anyString());
CountDownLatch done = latchOnDelete(1);
cleanupService.cleanupConfiguredModules();
assertTrue(done.await(2, TimeUnit.SECONDS), "恢复后指针异步删除完成");
assertEquals(0, orchestrator.pendingCount(), "恢复后清理队列清空");
verify(rustfs, times(1)).deleteObject(anyString());
verify(rustfs).deleteObject("task-parsed/test/71/scope/latest.json");
}
}
@@ -0,0 +1,212 @@
package com.nanri.aiimage.modules.task.service;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import com.nanri.aiimage.modules.task.mapper.TaskFileJobMapper;
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
import org.apache.ibatis.builder.MapperBuilderAssistant;
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.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.context.ApplicationEventPublisher;
import java.time.LocalDateTime;
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.ArgumentMatchers.isNull;
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 72:为文件作业实现数据库原子 claim,避免重复派发同一 job。
* claimRunnableJobs / claimRunnableJobsForOwner 先查询候选,再逐个以
* 条件 UPDATEPENDING/FAILED + retryCount 未达上限 → RUNNING)原子翻转;
* 只有翻转成功的行才返回,多个 worker/实例并发取数不会重复拿到同一 job。
*/
@ExtendWith(MockitoExtension.class)
class TaskFileJobClaimTest {
@Mock private TaskFileJobMapper taskFileJobMapper;
@Mock private ApplicationEventPublisher applicationEventPublisher;
private TaskFileJobService service;
@BeforeAll
static void initializeTableInfo() {
TableInfoHelper.initTableInfo(
new MapperBuilderAssistant(new MybatisConfiguration(), ""),
TaskFileJobEntity.class);
}
@BeforeEach
void setUp() {
service = new TaskFileJobService(taskFileJobMapper, applicationEventPublisher);
}
private static TaskFileJobEntity runnableJob(Long id) {
TaskFileJobEntity job = new TaskFileJobEntity();
job.setId(id);
job.setTaskId(1000L + id);
job.setResultId(2000L + id);
job.setModuleType("SIMILAR_ASIN");
job.setScopeKey("task:1:owner:instance-a");
job.setJobType("ASSEMBLE_RESULT");
job.setStatus("PENDING");
job.setRetryCount(0);
job.setUpdatedAt(LocalDateTime.now());
return job;
}
private static TaskFileJobEntity runningJob(Long id) {
TaskFileJobEntity job = runnableJob(id);
job.setStatus("RUNNING");
return job;
}
private void stubCandidates(List<TaskFileJobEntity> candidates) {
when(taskFileJobMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(candidates);
}
private void stubClaimResults(int... results) {
when(taskFileJobMapper.update(isNull(), any(LambdaUpdateWrapper.class)))
.thenReturn(results[0], java.util.Arrays.stream(results, 1, results.length).boxed().toArray(Integer[]::new));
}
private void stubClaimedRows(List<TaskFileJobEntity> claimed) {
when(taskFileJobMapper.selectById(any())).thenAnswer(invocation -> {
Long id = invocation.getArgument(0);
return claimed.stream().filter(job -> job.getId().equals(id)).findFirst().orElse(null);
});
}
@Test
void test_task_072_job_normal_default_path() {
// 默认路径:候选全部翻转成功,返回对应 RUNNING 行。
stubCandidates(List.of(runnableJob(1L), runnableJob(2L)));
stubClaimResults(1, 1);
stubClaimedRows(List.of(runningJob(1L), runningJob(2L)));
List<TaskFileJobEntity> claimed = service.claimRunnableJobs(10);
assertEquals(2, claimed.size(), "两个候选全部 claim 成功");
assertTrue(claimed.stream().allMatch(job -> "RUNNING".equals(job.getStatus())));
verify(taskFileJobMapper, times(2)).update(isNull(), any(LambdaUpdateWrapper.class));
}
@Test
void test_task_072_job_normal_multiple_items() {
// 批量场景:5 个候选,其中 1 个被另一实例抢先(update 返回 0),
// 只返回 4 个;每个候选都尝试一次原子翻转,顺序稳定。
List<TaskFileJobEntity> candidates = new ArrayList<>();
for (long i = 1; i <= 5; i++) {
candidates.add(runnableJob(i));
}
stubCandidates(candidates);
stubClaimResults(1, 0, 1, 1, 1);
stubClaimedRows(List.of(runningJob(1L), runningJob(3L), runningJob(4L), runningJob(5L)));
List<TaskFileJobEntity> claimed = service.claimRunnableJobs(10);
assertEquals(4, claimed.size(), "被抢的 job 不返回");
assertEquals(List.of(1L, 3L, 4L, 5L),
claimed.stream().map(TaskFileJobEntity::getId).toList(), "顺序与候选一致");
verify(taskFileJobMapper, times(5)).update(isNull(), any(LambdaUpdateWrapper.class));
}
@Test
void test_task_072_job_normal_repeated_operation_is_idempotent() {
// 幂等:已 claimRUNNING)的 job 不再出现在候选里,重复 claim 返回空,
// 不产生重复翻转或重复派发。
stubCandidates(List.of());
List<TaskFileJobEntity> first = service.claimRunnableJobs(10);
List<TaskFileJobEntity> second = service.claimRunnableJobs(10);
assertTrue(first.isEmpty());
assertTrue(second.isEmpty());
verify(taskFileJobMapper, never()).update(isNull(), any(LambdaUpdateWrapper.class));
}
@Test
void test_task_072_job_boundary_empty_input() {
// 空输入:无候选时不发起任何原子翻转,返回空列表。
stubCandidates(List.of());
List<TaskFileJobEntity> claimed = service.claimRunnableJobs(10);
assertTrue(claimed.isEmpty());
verify(taskFileJobMapper, never()).update(isNull(), any(LambdaUpdateWrapper.class));
}
@Test
void test_task_072_job_boundary_single_item() {
// 单元素:单候选单翻转单返回,不依赖批量路径。
stubCandidates(List.of(runnableJob(9L)));
stubClaimResults(1);
stubClaimedRows(List.of(runningJob(9L)));
List<TaskFileJobEntity> claimed = service.claimRunnableJobs(10);
assertEquals(List.of(9L), claimed.stream().map(TaskFileJobEntity::getId).toList());
verify(taskFileJobMapper, times(1)).update(isNull(), any(LambdaUpdateWrapper.class));
}
@Test
void test_task_072_job_boundary_limit_and_overflow() {
// 上限/超限:limit 非法(0/负/超上限)时按现有 clamp 回退,不发生无界取数。
stubCandidates(List.of(runnableJob(1L)));
stubClaimResults(1);
stubClaimedRows(List.of(runningJob(1L)));
service.claimRunnableJobs(0);
service.claimRunnableJobs(200);
ArgumentCaptor<LambdaQueryWrapper<TaskFileJobEntity>> captor =
ArgumentCaptor.forClass(LambdaQueryWrapper.class);
verify(taskFileJobMapper, times(2)).selectList(captor.capture());
assertTrue(captor.getAllValues().get(0).getCustomSqlSegment().contains("limit 1"), "limit=0 回退到 1");
assertTrue(captor.getAllValues().get(1).getCustomSqlSegment().contains("limit 100"), "limit=200 钳制到 100");
}
@Test
void test_task_072_job_invalid_input_rejected() {
// 非法参数:空白 owner 回退到通用 claim 路径(仅一次通用查询),行为确定。
stubCandidates(List.of());
List<TaskFileJobEntity> claimed = service.claimRunnableJobsForOwner(10, " ");
assertTrue(claimed.isEmpty());
verify(taskFileJobMapper, times(1)).selectList(any(LambdaQueryWrapper.class));
verify(taskFileJobMapper, never()).update(isNull(), any(LambdaUpdateWrapper.class));
}
@Test
void test_task_072_job_dependency_failure_releases_resources() {
// 依赖失败:候选查询抛异常时传播且不发起任何翻转(无半态 claim);
// 恢复后再次 claim 正常返回,未产生重复或丢失。
when(taskFileJobMapper.selectList(any(LambdaQueryWrapper.class)))
.thenThrow(new RuntimeException("db down"));
assertThrows(RuntimeException.class, () -> service.claimRunnableJobs(10));
verify(taskFileJobMapper, never()).update(isNull(), any(LambdaUpdateWrapper.class));
stubCandidates(List.of(runnableJob(7L)));
stubClaimResults(1);
stubClaimedRows(List.of(runningJob(7L)));
List<TaskFileJobEntity> recovered = service.claimRunnableJobs(10);
assertEquals(List.of(7L), recovered.stream().map(TaskFileJobEntity::getId).toList(), "恢复后 claim 成功");
}
}
@@ -0,0 +1,201 @@
package com.nanri.aiimage.modules.task.service;
import com.nanri.aiimage.modules.task.mapper.TaskFileJobMapper;
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.core.task.TaskExecutor;
import org.springframework.test.util.ReflectionTestUtils;
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.assertFalse;
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.doThrow;
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 73:为本地文件作业队列增加 in-flight 去重和队列背压。
* TaskFileJobLocalDispatcher 以 in-flight 集合跟踪已受理未完成的 job:
* 同一 jobId 重复 dispatch 幂等返回(不重复入队);in-flight 数量达到
* max-inflight-dispatch 上限时拒绝新提交(背压,调用方走同步兜底);
* 执行完成或失败后释放 in-flight 名额;executor 拒绝时名额一并释放。
*/
class TaskFileJobLocalDispatcherTest {
private TaskFileJobMapper taskFileJobMapper;
private ObjectProvider<TaskResultFileJobWorker> workerProvider;
private TaskResultFileJobWorker worker;
private TaskExecutor executor;
private TaskFileJobLocalDispatcher dispatcher;
@BeforeEach
@SuppressWarnings("unchecked")
void setUp() {
taskFileJobMapper = mock(TaskFileJobMapper.class);
worker = mock(TaskResultFileJobWorker.class);
workerProvider = mock(ObjectProvider.class);
when(workerProvider.getIfAvailable()).thenReturn(worker);
executor = mock(TaskExecutor.class);
dispatcher = new TaskFileJobLocalDispatcher(taskFileJobMapper, workerProvider);
ReflectionTestUtils.setField(dispatcher, "taskFileJobDispatchExecutor", executor);
ReflectionTestUtils.setField(dispatcher, "maxInflightDispatch", 8);
// @Value 注解在纯单测中不生效,boolean 字段默认 false,需显式开启本地派发。
ReflectionTestUtils.setField(dispatcher, "localDispatchEnabled", true);
}
private TaskFileJobEntity job(Long id) {
TaskFileJobEntity job = new TaskFileJobEntity();
job.setId(id);
job.setTaskId(100L + id);
job.setModuleType("SIMILAR_ASIN");
return job;
}
private void stubJobFound(Long id) {
when(taskFileJobMapper.selectById(id)).thenReturn(job(id));
}
/** executor 捕获 runnable 不执行,模拟任务仍在 in-flight。 */
private List<Runnable> captureRunnables() {
List<Runnable> captured = new ArrayList<>();
doAnswer(invocation -> {
captured.add(invocation.getArgument(0));
return null;
}).when(executor).execute(any(Runnable.class));
return captured;
}
@Test
void test_task_073_dispatch_normal_default_path() {
// 默认路径:正常 dispatch 提交到执行器并返回 true。
stubJobFound(1L);
captureRunnables();
assertTrue(dispatcher.dispatch(1L, 101L, "SIMILAR_ASIN"));
verify(executor, times(1)).execute(any(Runnable.class));
}
@Test
void test_task_073_dispatch_normal_multiple_items() throws Exception {
// 批量场景:多个不同 jobId 依次 dispatch,互不冲突,全部受理。
stubJobFound(1L);
stubJobFound(2L);
stubJobFound(3L);
List<Runnable> captured = captureRunnables();
assertTrue(dispatcher.dispatch(1L, 101L, "SIMILAR_ASIN"));
assertTrue(dispatcher.dispatch(2L, 102L, "SIMILAR_ASIN"));
assertTrue(dispatcher.dispatch(3L, 103L, "SIMILAR_ASIN"));
assertEquals(3, captured.size(), "三个不同 job 都入队");
for (Runnable runnable : captured) {
runnable.run();
}
verify(worker, times(3)).process(any(TaskFileJobEntity.class));
}
@Test
void test_task_073_dispatch_normal_repeated_operation_is_idempotent() throws Exception {
// 幂等:同一 jobId 在 in-flight 期间重复 dispatch 不重复入队,返回 true 表示已受理。
stubJobFound(1L);
List<Runnable> captured = captureRunnables();
assertTrue(dispatcher.dispatch(1L, 101L, "SIMILAR_ASIN"));
assertTrue(dispatcher.dispatch(1L, 101L, "SIMILAR_ASIN"), "重复 dispatch 幂等受理");
assertEquals(1, captured.size(), "同一 job 只入队一次");
captured.get(0).run();
assertTrue(dispatcher.dispatch(1L, 101L, "SIMILAR_ASIN"), "完成后可再次受理");
assertEquals(2, captured.size());
captured.get(1).run();
verify(worker, times(2)).process(any(TaskFileJobEntity.class));
}
@Test
void test_task_073_dispatch_boundary_empty_input() {
// 空输入:null jobId 直接拒绝,不提交、不计数。
captureRunnables();
assertFalse(dispatcher.dispatch(null, null, null));
assertFalse(dispatcher.dispatch(0L, null, null));
verify(executor, never()).execute(any(Runnable.class));
}
@Test
void test_task_073_dispatch_boundary_single_item() throws Exception {
// 单元素:单 jobId dispatch 一次执行一次,不依赖批量路径。
stubJobFound(9L);
List<Runnable> captured = captureRunnables();
assertTrue(dispatcher.dispatch(9L, 109L, "SIMILAR_ASIN"));
captured.get(0).run();
verify(worker).process(any(TaskFileJobEntity.class));
}
@Test
void test_task_073_dispatch_boundary_limit_and_overflow() throws Exception {
// 上限/超限:in-flight 达到上限后新提交被拒绝(背压);
// 先完成的 job 释放名额后再次提交成功。
stubJobFound(1L);
stubJobFound(2L);
stubJobFound(3L);
ReflectionTestUtils.setField(dispatcher, "maxInflightDispatch", 2);
List<Runnable> captured = captureRunnables();
assertTrue(dispatcher.dispatch(1L, 101L, "SIMILAR_ASIN"));
assertTrue(dispatcher.dispatch(2L, 102L, "SIMILAR_ASIN"));
assertFalse(dispatcher.dispatch(3L, 103L, "SIMILAR_ASIN"), "达到上限拒绝,背压生效");
assertEquals(2, captured.size(), "被拒绝的 job 不入队");
captured.get(0).run();
assertTrue(dispatcher.dispatch(3L, 103L, "SIMILAR_ASIN"), "名额释放后可再次提交");
assertEquals(3, captured.size());
}
@Test
void test_task_073_dispatch_invalid_input_rejected() {
// 非法参数:jobId<=0 直接拒绝;非法上限配置回退到 1(不崩溃、行为确定)。
stubJobFound(1L);
ReflectionTestUtils.setField(dispatcher, "maxInflightDispatch", 0);
captureRunnables();
assertFalse(dispatcher.dispatch(-1L, null, null), "非法 jobId 拒绝");
assertTrue(dispatcher.dispatch(1L, 101L, "SIMILAR_ASIN"), "上限 0 回退到 1,首个受理");
assertFalse(dispatcher.dispatch(2L, 102L, "SIMILAR_ASIN"), "回退上限已满,背压拒绝");
verify(executor, times(1)).execute(any(Runnable.class));
}
@Test
void test_task_073_dispatch_dependency_failure_releases_resources() {
// 依赖失败:executor 拒绝时释放 in-flight 名额,恢复后再次 dispatch 成功;
// force 路径同步兜底执行且不残留名额。
stubJobFound(1L);
stubJobFound(2L);
doThrow(new RuntimeException("executor full"))
.doNothing()
.doThrow(new RuntimeException("executor full"))
.doNothing()
.when(executor).execute(any(Runnable.class));
assertFalse(dispatcher.dispatch(1L, 101L, "SIMILAR_ASIN"), "executor 拒绝返回 false");
assertTrue(dispatcher.dispatch(2L, 102L, "SIMILAR_ASIN"), "名额已释放,再次提交成功");
verify(worker, never()).process(any(TaskFileJobEntity.class));
assertTrue(dispatcher.dispatch(1L, 101L, "SIMILAR_ASIN", true), "force 路径同步兜底");
verify(worker, times(1)).process(any(TaskFileJobEntity.class));
assertTrue(dispatcher.dispatch(1L, 101L, "SIMILAR_ASIN"), "force 执行完不残留 in-flight");
verify(executor, times(4)).execute(any(Runnable.class));
}
}
@@ -0,0 +1,260 @@
package com.nanri.aiimage.modules.task.service;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import com.nanri.aiimage.modules.task.mapper.TaskFileJobMapper;
import com.nanri.aiimage.modules.task.model.dto.TaskFileJobDispatchEvent;
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
import org.apache.ibatis.builder.MapperBuilderAssistant;
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.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.dao.DuplicateKeyException;
import java.time.LocalDateTime;
import java.util.ArrayList;
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.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.isNull;
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 76:将 JSON owner 查询迁移到显式列并补充任务/状态复合索引。
* biz_task_file_job 新增 owner 列(enqueue 时从 scope_key 提取、V96 迁移
* 回填存量行),owner 任务查询从 LIKE '%:owner:%' 改为 owner = ? 等值查询,
* 新增 (module_type, owner, status, retry_count) 复合索引;无 owner 的通用
* 任务改为 owner IS NULL 过滤,语义与迁移前一致。
*/
@ExtendWith(MockitoExtension.class)
class TaskFileJobOwnerColumnTest {
@Mock private TaskFileJobMapper taskFileJobMapper;
@Mock private ApplicationEventPublisher applicationEventPublisher;
private TaskFileJobService service;
@BeforeAll
static void initializeTableInfo() {
TableInfoHelper.initTableInfo(
new MapperBuilderAssistant(new MybatisConfiguration(), ""),
TaskFileJobEntity.class);
}
@BeforeEach
void setUp() {
service = new TaskFileJobService(taskFileJobMapper, applicationEventPublisher);
}
private static TaskFileJobEntity ownerJob(Long id, String owner) {
TaskFileJobEntity job = new TaskFileJobEntity();
job.setId(id);
job.setTaskId(1000L + id);
job.setModuleType("SIMILAR_ASIN");
job.setScopeKey("task:" + id + ":owner:" + owner);
job.setOwner(owner);
job.setJobType("ASSEMBLE_RESULT");
job.setStatus("PENDING");
job.setRetryCount(0);
job.setUpdatedAt(LocalDateTime.now());
return job;
}
private static TaskFileJobEntity runningOwnerJob(Long id, String owner) {
TaskFileJobEntity job = ownerJob(id, owner);
job.setStatus("RUNNING");
return job;
}
private void stubOwnerQueries(List<TaskFileJobEntity> ownerJobs, List<TaskFileJobEntity> genericJobs) {
when(taskFileJobMapper.selectList(any(LambdaQueryWrapper.class)))
.thenReturn(ownerJobs, genericJobs);
}
private void stubClaimResults(int... results) {
when(taskFileJobMapper.update(isNull(), any(LambdaUpdateWrapper.class)))
.thenReturn(results[0], java.util.Arrays.stream(results, 1, results.length).boxed().toArray(Integer[]::new));
}
private void stubClaimedRows(List<TaskFileJobEntity> claimed) {
when(taskFileJobMapper.selectById(any())).thenAnswer(invocation -> {
Long id = invocation.getArgument(0);
return claimed.stream().filter(job -> job.getId().equals(id)).findFirst().orElse(null);
});
}
private TaskFileJobEntity capturedInsert() {
ArgumentCaptor<TaskFileJobEntity> captor = ArgumentCaptor.forClass(TaskFileJobEntity.class);
verify(taskFileJobMapper).insert(captor.capture());
return captor.getValue();
}
@Test
void test_task_076_owner_normal_default_path() {
// 默认路径:enqueue 时从 scope_key 提取 owner 落显式列;
// owner 查询使用 owner = ? 等值条件,不再使用 LIKE。
TaskFileJobEntity enqueued = service.enqueueAssembleResult(1L, "SIMILAR_ASIN", 2L, "task:1:owner:instance-a");
assertEquals("instance-a", enqueued.getOwner(), "enqueue 时提取 owner");
assertEquals("task:1:owner:instance-a", enqueued.getScopeKey(), "scope_key 原样保留");
assertEquals("instance-a", capturedInsert().getOwner());
List<TaskFileJobEntity> ownerJobs = List.of(ownerJob(1L, "instance-a"));
stubOwnerQueries(ownerJobs, List.of());
stubClaimResults(1);
stubClaimedRows(List.of(runningOwnerJob(1L, "instance-a")));
List<TaskFileJobEntity> claimed = service.claimRunnableJobsForOwner(10, "instance-a");
assertEquals(1, claimed.size(), "owner 任务按等值查询返回");
ArgumentCaptor<LambdaQueryWrapper<TaskFileJobEntity>> captor =
ArgumentCaptor.forClass(LambdaQueryWrapper.class);
verify(taskFileJobMapper, times(2)).selectList(captor.capture());
String ownerSegment = captor.getAllValues().get(0).getCustomSqlSegment();
assertTrue(ownerSegment.contains("owner = "), "owner 等值查询: " + ownerSegment);
assertFalse(ownerSegment.contains("LIKE"), "不再使用 LIKE 模糊匹配: " + ownerSegment);
}
@Test
void test_task_076_owner_normal_multiple_items() {
// 批量场景:多个 owner 的任务,按 owner 精确过滤只返回归属任务,
// 通用任务补足仍保留(owner IS NULL),顺序稳定不丢失。
List<TaskFileJobEntity> ownerJobs = List.of(ownerJob(1L, "instance-a"), ownerJob(2L, "instance-a"));
stubOwnerQueries(ownerJobs, List.of(ownerJob(9L, null)));
stubClaimResults(1, 1, 1);
stubClaimedRows(List.of(runningOwnerJob(1L, "instance-a"), runningOwnerJob(2L, "instance-a"), runningOwnerJob(9L, null)));
List<TaskFileJobEntity> claimed = service.claimRunnableJobsForOwner(10, "instance-a");
assertEquals(List.of(1L, 2L, 9L), claimed.stream().map(TaskFileJobEntity::getId).toList(),
"owner 任务在前、通用任务补足,顺序稳定");
ArgumentCaptor<LambdaQueryWrapper<TaskFileJobEntity>> captor =
ArgumentCaptor.forClass(LambdaQueryWrapper.class);
verify(taskFileJobMapper, times(2)).selectList(captor.capture());
assertTrue(captor.getAllValues().get(0).getCustomSqlSegment().contains("owner = "), "owner 精确过滤");
assertTrue(captor.getAllValues().get(1).getCustomSqlSegment().contains("owner IS NULL"),
"通用任务按 owner IS NULL 过滤");
}
@Test
void test_task_076_owner_normal_repeated_operation_is_idempotent() {
// 幂等:同一 (taskId, moduleType, resultId) 重复 enqueue 不重复插入,
// 复用既有 jobFAILED 重置路径同步刷新 owner,不产生重复记录。
TaskFileJobEntity existing = ownerJob(5L, "instance-a");
existing.setStatus("FAILED");
existing.setRetryCount(2);
when(taskFileJobMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(existing);
when(taskFileJobMapper.selectById(5L)).thenReturn(existing);
TaskFileJobEntity result = service.enqueueAssembleResult(5L, "SIMILAR_ASIN", 50L, "task:5:owner:instance-a");
assertEquals(5L, result.getId(), "复用既有 job");
verify(taskFileJobMapper, never()).insert(any(TaskFileJobEntity.class));
ArgumentCaptor<LambdaUpdateWrapper<TaskFileJobEntity>> updateCaptor =
ArgumentCaptor.forClass(LambdaUpdateWrapper.class);
verify(taskFileJobMapper, times(1)).update(isNull(), updateCaptor.capture());
assertTrue(updateCaptor.getValue().getSqlSet().contains("owner"), "重置路径同步刷新 owner 列");
}
@Test
void test_task_076_owner_boundary_empty_input() {
// 空输入:scope_key 为空时 owner 为空;空白 owner 走通用 claim 路径
// (不发起 owner 等值查询)。
TaskFileJobEntity enqueued = service.enqueueAssembleResult(1L, "SIMILAR_ASIN", 2L, " ");
assertNull(enqueued.getOwner(), "空 scope_key 不提取 owner");
assertNull(capturedInsert().getOwner());
when(taskFileJobMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(List.of());
List<TaskFileJobEntity> claimed = service.claimRunnableJobsForOwner(10, " ");
assertTrue(claimed.isEmpty());
ArgumentCaptor<LambdaQueryWrapper<TaskFileJobEntity>> captor =
ArgumentCaptor.forClass(LambdaQueryWrapper.class);
verify(taskFileJobMapper, times(1)).selectList(captor.capture());
assertFalse(captor.getValue().getCustomSqlSegment().contains("owner"), "空白 owner 不发起 owner 查询");
}
@Test
void test_task_076_owner_boundary_single_item() {
// 单元素:单任务单 owner 直接返回,不依赖批量路径。
stubOwnerQueries(List.of(ownerJob(9L, "instance-a")), List.of());
stubClaimResults(1);
stubClaimedRows(List.of(runningOwnerJob(9L, "instance-a")));
List<TaskFileJobEntity> claimed = service.claimRunnableJobsForOwner(10, "instance-a");
assertEquals(List.of(9L), claimed.stream().map(TaskFileJobEntity::getId).toList());
}
@Test
void test_task_076_owner_boundary_limit_and_overflow() {
// 上限/超限:limit 非法(0/负/超上限)按现有 clamp 回退,不发生无界取数。
stubOwnerQueries(List.of(), List.of());
service.claimRunnableJobsForOwner(0, "instance-a");
service.claimRunnableJobsForOwner(200, "instance-a");
ArgumentCaptor<LambdaQueryWrapper<TaskFileJobEntity>> captor =
ArgumentCaptor.forClass(LambdaQueryWrapper.class);
verify(taskFileJobMapper, times(4)).selectList(captor.capture());
assertTrue(captor.getAllValues().get(0).getCustomSqlSegment().contains("limit 1"), "limit=0 回退到 1");
assertTrue(captor.getAllValues().get(2).getCustomSqlSegment().contains("limit 100"), "limit=200 钳制到 100");
}
@Test
void test_task_076_owner_invalid_input_rejected() {
// 非法参数:必填字段缺失时 enqueue 拒绝返回 null 不落库;
// scope_key 不含 owner 标记时 owner 为空但仍可入队。
assertNull(service.enqueueAssembleResult(null, "SIMILAR_ASIN", 2L, "task:1:owner:instance-a"),
"null taskId 拒绝");
assertNull(service.enqueueAssembleResult(1L, " ", 2L, "task:1:owner:instance-a"),
"空白 moduleType 拒绝");
verify(taskFileJobMapper, never()).insert(any(TaskFileJobEntity.class));
TaskFileJobEntity noMarker = service.enqueueAssembleResult(1L, "SIMILAR_ASIN", 2L, "task:1:plain");
assertNull(noMarker.getOwner(), "无 owner 标记不提取 owner");
assertEquals("task:1:plain", noMarker.getScopeKey());
}
@Test
void test_task_076_owner_dependency_failure_releases_resources() {
// 依赖失败:insert 主键冲突(DuplicateKeyException)时回退到既有 FAILED job
// 走重置路径刷新 owner 并继续发布派发事件;owner 查询抛异常时传播且不产生 claim 翻转。
TaskFileJobEntity existing = ownerJob(7L, "instance-a");
existing.setStatus("FAILED");
existing.setRetryCount(2);
// 首次查询无既有 job → 尝试 insert 触发主键冲突(并发提交),
// 冲突后重新查询命中既有 FAILED job,走重置路径刷新 owner。
when(taskFileJobMapper.selectOne(any(LambdaQueryWrapper.class)))
.thenReturn(null, existing);
when(taskFileJobMapper.insert(any(TaskFileJobEntity.class)))
.thenThrow(new DuplicateKeyException("duplicate"));
when(taskFileJobMapper.update(isNull(), any(LambdaUpdateWrapper.class))).thenReturn(1);
when(taskFileJobMapper.selectById(7L)).thenReturn(existing);
TaskFileJobEntity recovered = service.enqueueAssembleResult(7L, "SIMILAR_ASIN", 70L, "task:7:owner:instance-a");
assertEquals(7L, recovered.getId(), "冲突后回退到既有 job");
verify(applicationEventPublisher, times(1)).publishEvent(any(TaskFileJobDispatchEvent.class));
when(taskFileJobMapper.selectList(any(LambdaQueryWrapper.class)))
.thenThrow(new RuntimeException("db down"));
assertThrows(RuntimeException.class, () -> service.claimRunnableJobsForOwner(10, "instance-a"));
// 唯一的 update 来自 enqueue 重置路径;claim 在查询阶段中止,未发起任何原子翻转。
verify(taskFileJobMapper, times(1)).update(isNull(), any(LambdaUpdateWrapper.class));
}
}
@@ -0,0 +1,200 @@
package com.nanri.aiimage.modules.task.service;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.nanri.aiimage.modules.task.mapper.TaskProgressSnapshotMapper;
import com.nanri.aiimage.modules.task.model.entity.TaskProgressSnapshotEntity;
import org.apache.ibatis.builder.MapperBuilderAssistant;
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.junit.jupiter.MockitoExtension;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.doThrow;
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 63:为前端/后端进度快照增加写入去重和最小更新间隔。
* TaskProgressSnapshotService 对进度快照写入做节流:内容未变化时跳过写库
* (已有去重语义保留);内容变化但距上次写入不足最小更新间隔(默认 500ms)
* 时跳过中间态写入,终态(SUCCESS/FAILED/CANCELLED)不受节流保证落库;
* 删除快照时同步清理节流跟踪,不残留内存状态。
*/
@ExtendWith(MockitoExtension.class)
class TaskProgressSnapshotServiceThrottleTest {
@BeforeAll
static void initializeMybatisMetadata() {
TableInfoHelper.initTableInfo(
new MapperBuilderAssistant(new MybatisConfiguration(), ""),
TaskProgressSnapshotEntity.class);
}
@Mock private TaskProgressSnapshotMapper taskProgressSnapshotMapper;
@Mock private ObjectMapper objectMapper;
private TaskProgressSnapshotService service;
@BeforeEach
void setUp() {
service = new TaskProgressSnapshotService(taskProgressSnapshotMapper, objectMapper);
ReflectionTestUtils.setField(service, "minUpdateIntervalMillis", 1000L);
}
private TaskProgressSnapshotEntity running() {
TaskProgressSnapshotEntity entity = new TaskProgressSnapshotEntity();
entity.setId(1L);
entity.setTaskId(20581L);
entity.setModuleType("SIMILAR_ASIN");
entity.setStatus("RUNNING");
entity.setTotalCount(10);
entity.setSuccessCount(4);
entity.setFailedCount(1);
entity.setPendingCount(5);
entity.setCurrentScopeKey("scope-1");
entity.setMessage("processing");
return entity;
}
/** 把某任务的节流跟踪时间拨到距今 millisAgo 毫秒前,模拟时间流逝。 */
@SuppressWarnings("unchecked")
private void rewindLastWrite(Long taskId, String moduleType, long millisAgo) {
Map<String, Long> tracking = (Map<String, Long>) ReflectionTestUtils
.getField(service, "lastWriteAtMillis");
tracking.put(taskId + ":" + moduleType, System.currentTimeMillis() - millisAgo);
}
@Test
void test_task_063_progress_frontend_normal_default_path() {
// 正常路径:距上次写入超过最小间隔的变更落库,写入一次。
when(taskProgressSnapshotMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(running());
rewindLastWrite(20581L, "SIMILAR_ASIN", 5_000L);
service.save(20581L, "SIMILAR_ASIN", "RUNNING", 10, 5, 1,
"scope-1", "processing", null);
verify(taskProgressSnapshotMapper, times(1)).update(isNull(), any(LambdaUpdateWrapper.class));
}
@Test
void test_task_063_progress_frontend_normal_multiple_items() {
// 批量场景:多个任务各自跟踪节流时间,互不影响,全部落库。
for (long taskId = 1; taskId <= 3; taskId++) {
when(taskProgressSnapshotMapper.selectOne(any(LambdaQueryWrapper.class)))
.thenReturn(null);
service.save(taskId, "MODULE_" + taskId, "RUNNING", 10, 0, 0,
"scope-" + taskId, "start", null);
}
verify(taskProgressSnapshotMapper, times(3)).insert(any(TaskProgressSnapshotEntity.class));
}
@Test
void test_task_063_progress_frontend_normal_repeated_operation_is_idempotent() {
// 幂等:相同内容重复保存不产生写库(内容去重),结果一致无副作用。
when(taskProgressSnapshotMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(running());
service.save(20581L, "SIMILAR_ASIN", "RUNNING", 10, 4, 1,
"scope-1", "processing", null);
service.save(20581L, "SIMILAR_ASIN", "RUNNING", 10, 4, 1,
"scope-1", "processing", null);
verify(taskProgressSnapshotMapper, never()).update(any(), any());
verify(taskProgressSnapshotMapper, never()).insert(any(TaskProgressSnapshotEntity.class));
}
@Test
void test_task_063_progress_frontend_boundary_empty_input() {
// 空输入:null/空模块、空状态安全跳过,不发起任何写库调用。
service.save(null, "SIMILAR_ASIN", "RUNNING", 10, 0, 0, null, null, null);
service.save(20581L, "", "RUNNING", 10, 0, 0, null, null, null);
service.save(20581L, "SIMILAR_ASIN", " ", 10, 0, 0, null, null, null);
verify(taskProgressSnapshotMapper, never()).insert(any(TaskProgressSnapshotEntity.class));
verify(taskProgressSnapshotMapper, never()).update(any(), any());
verify(taskProgressSnapshotMapper, never()).selectOne(any());
}
@Test
void test_task_063_progress_frontend_boundary_single_item() {
// 单元素:首次写入落库;间隔内变更被节流跳过;间隔过后变更落库。
when(taskProgressSnapshotMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(null);
service.save(20581L, "SIMILAR_ASIN", "RUNNING", 10, 0, 0, "s1", "go", null);
verify(taskProgressSnapshotMapper, times(1)).insert(any(TaskProgressSnapshotEntity.class));
when(taskProgressSnapshotMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(running());
service.save(20581L, "SIMILAR_ASIN", "RUNNING", 10, 5, 1, "s2", "half", null);
verify(taskProgressSnapshotMapper, never()).update(isNull(), any(LambdaUpdateWrapper.class));
rewindLastWrite(20581L, "SIMILAR_ASIN", 5_000L);
service.save(20581L, "SIMILAR_ASIN", "RUNNING", 10, 6, 1, "s3", "more", null);
verify(taskProgressSnapshotMapper, times(1)).update(isNull(), any(LambdaUpdateWrapper.class));
}
@Test
void test_task_063_progress_frontend_boundary_limit_and_overflow() {
// 上限/超限:最小更新间隔设为 0(超限值)→ 节流禁用,
// 每次变更都落库,不发生无界内存增长(跟踪 map 仅随任务数增长)。
ReflectionTestUtils.setField(service, "minUpdateIntervalMillis", 0L);
when(taskProgressSnapshotMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(running());
service.save(20581L, "SIMILAR_ASIN", "RUNNING", 10, 5, 1, "s1", "m1", null);
service.save(20581L, "SIMILAR_ASIN", "RUNNING", 10, 6, 1, "s1", "m2", null);
service.save(20581L, "SIMILAR_ASIN", "RUNNING", 10, 7, 1, "s1", "m3", null);
verify(taskProgressSnapshotMapper, times(3)).update(isNull(), any(LambdaUpdateWrapper.class));
}
@Test
void test_task_063_progress_frontend_invalid_input_rejected() {
// 非法参数:非正任务 id / 空白状态拒绝写入,不创建无效快照。
service.save(0L, "SIMILAR_ASIN", "RUNNING", 10, 0, 0, null, null, null);
service.save(-5L, "SIMILAR_ASIN", "RUNNING", 10, 0, 0, null, null, null);
verify(taskProgressSnapshotMapper, never()).insert(any(TaskProgressSnapshotEntity.class));
verify(taskProgressSnapshotMapper, never()).update(any(), any());
}
@Test
void test_task_063_progress_frontend_dependency_failure_releases_resources() {
// 依赖失败:唯一键冲突回落查询后正常更新(并发安全);
// 写库异常传播不残留;删除快照后节流跟踪释放,间隔内再次写入不再被节流。
when(taskProgressSnapshotMapper.selectOne(any(LambdaQueryWrapper.class)))
.thenReturn(null)
.thenReturn(running());
doThrow(new DuplicateKeyException("dup")).when(taskProgressSnapshotMapper)
.insert(any(TaskProgressSnapshotEntity.class));
service.save(20581L, "SIMILAR_ASIN", "RUNNING", 10, 5, 1, "s1", "m1", null);
verify(taskProgressSnapshotMapper, times(1)).update(isNull(), any(LambdaUpdateWrapper.class));
rewindLastWrite(20581L, "SIMILAR_ASIN", 5_000L);
doThrow(new RuntimeException("db down")).when(taskProgressSnapshotMapper)
.update(isNull(), any(LambdaUpdateWrapper.class));
assertThrows(RuntimeException.class, () -> service.save(20581L, "SIMILAR_ASIN",
"RUNNING", 10, 6, 1, "s2", "m2", null), "写库异常传播");
service.delete(20581L, "SIMILAR_ASIN");
verify(taskProgressSnapshotMapper, times(1))
.delete(any(LambdaQueryWrapper.class));
when(taskProgressSnapshotMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(running());
when(taskProgressSnapshotMapper.update(isNull(), any(LambdaUpdateWrapper.class))).thenReturn(1);
service.save(20581L, "SIMILAR_ASIN", "RUNNING", 10, 7, 1, "s3", "m3", null);
verify(taskProgressSnapshotMapper, times(3)).update(isNull(), any(LambdaUpdateWrapper.class));
}
}
@@ -0,0 +1,221 @@
package com.nanri.aiimage.modules.task.service;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
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.config.InstanceMetadata;
import com.nanri.aiimage.config.StorageProperties;
import com.nanri.aiimage.config.TransientStorageProperties;
import com.nanri.aiimage.modules.file.service.object.RustfsObjectStorageService;
import com.nanri.aiimage.modules.file.service.oss.OssStorageService;
import com.nanri.aiimage.modules.task.mapper.TaskChunkMapper;
import com.nanri.aiimage.modules.task.mapper.TaskScopeStateMapper;
import com.nanri.aiimage.modules.task.model.entity.TaskChunkEntity;
import com.nanri.aiimage.modules.task.model.entity.TaskScopeStateEntity;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.springframework.test.util.ReflectionTestUtils;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
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.Mockito.doAnswer;
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 68payload 引用删除改为批量引用检查与异步物理删除。
* TransientPayloadDeleteOrchestrator 把待删 pointer 去重入队,flush 时一次性
* IN 批量反查 biz_task_chunk / biz_task_scope_state(而非逐条两次查询),
* 未引用的对象交由后台线程池异步物理删除;重复提交幂等、失败保守保留。
*/
class TransientPayloadDeleteOrchestratorTest {
@TempDir
Path tempDir;
private TaskChunkMapper chunkMapper;
private TaskScopeStateMapper scopeStateMapper;
private RustfsObjectStorageService rustfs;
private OssStorageService oss;
private TransientPayloadStorageService storage;
private TransientPayloadDeleteOrchestrator orchestrator;
private ExecutorService executor;
@BeforeAll
static void initializeMybatisMetadata() {
TableInfoHelper.initTableInfo(
new MapperBuilderAssistant(new MybatisConfiguration(), ""),
TaskChunkEntity.class);
TableInfoHelper.initTableInfo(
new MapperBuilderAssistant(new MybatisConfiguration(), ""),
TaskScopeStateEntity.class);
}
@BeforeEach
void setUp() {
rustfs = mock(RustfsObjectStorageService.class);
oss = mock(OssStorageService.class);
chunkMapper = mock(TaskChunkMapper.class);
scopeStateMapper = mock(TaskScopeStateMapper.class);
TransientStorageProperties properties = new TransientStorageProperties();
properties.setEnabled(true);
StorageProperties storageProperties = new StorageProperties();
storageProperties.setLocalTempDir(tempDir.toString());
storage = new TransientPayloadStorageService(
properties, storageProperties, rustfs, oss,
new ObjectMapper(), new InstanceMetadata("test-instance"),
chunkMapper, scopeStateMapper);
executor = Executors.newFixedThreadPool(2);
orchestrator = new TransientPayloadDeleteOrchestrator(
storage, rustfs, chunkMapper, scopeStateMapper, new ObjectMapper(), executor);
ReflectionTestUtils.setField(orchestrator, "maxPendingDeletes", 100L);
}
private static String pointer(int taskId) {
return "rustfs:task-parsed/test/" + taskId + "/scope/latest.json";
}
private void stubNoReferences() {
when(chunkMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(List.of());
when(scopeStateMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(List.of());
}
private CountDownLatch latchOnDelete(int count) {
CountDownLatch latch = new CountDownLatch(count);
doAnswer(invocation -> {
latch.countDown();
return null;
}).when(rustfs).deleteObject(anyString());
return latch;
}
@Test
void test_task_068_payload_normal_default_path() throws Exception {
// 默认路径:批量提交未引用 pointer,flush 后全部异步物理删除。
stubNoReferences();
int accepted = orchestrator.submitDeletes(List.of(pointer(1), pointer(2)));
assertEquals(2, accepted);
CountDownLatch done = latchOnDelete(2);
assertEquals(2, orchestrator.flushPendingDeletes(), "两个对象提交物理删除");
assertTrue(done.await(2, TimeUnit.SECONDS), "异步删除完成");
verify(rustfs, times(2)).deleteObject(anyString());
verify(chunkMapper).selectList(any(LambdaQueryWrapper.class));
verify(scopeStateMapper).selectList(any(LambdaQueryWrapper.class));
}
@Test
void test_task_068_payload_normal_multiple_items() throws Exception {
// 批量场景:5 个对象批量检查,1 个仍被 scope_state 引用则跳过,其余 4 个删除。
stubNoReferences();
TaskScopeStateEntity referenced = new TaskScopeStateEntity();
referenced.setParsedPayloadJson("\"rustfs:task-parsed/test/3/scope/latest.json\"");
when(scopeStateMapper.selectList(any(LambdaQueryWrapper.class)))
.thenReturn(List.of(referenced));
List<String> values = new ArrayList<>();
for (int i = 1; i <= 5; i++) {
values.add(pointer(i));
}
assertEquals(5, orchestrator.submitDeletes(values));
CountDownLatch done = latchOnDelete(4);
assertEquals(4, orchestrator.flushPendingDeletes(), "仅删除未引用对象");
assertTrue(done.await(2, TimeUnit.SECONDS));
verify(rustfs, times(4)).deleteObject(anyString());
verify(rustfs, never()).deleteObject("task-parsed/test/3/scope/latest.json");
}
@Test
void test_task_068_payload_normal_repeated_operation_is_idempotent() throws Exception {
// 幂等:重复提交同一对象只入队一次、只删除一次,不产生重复物理删除请求。
stubNoReferences();
assertEquals(1, orchestrator.submitDeletes(List.of(pointer(1))));
assertEquals(0, orchestrator.submitDeletes(List.of(pointer(1))), "重复提交被去重");
CountDownLatch done = latchOnDelete(1);
assertEquals(1, orchestrator.flushPendingDeletes());
assertTrue(done.await(2, TimeUnit.SECONDS));
verify(rustfs, times(1)).deleteObject(anyString());
}
@Test
void test_task_068_payload_boundary_empty_input() throws Exception {
// 空输入:空集合/含空值集合不入队、不查询、不删除。
assertEquals(0, orchestrator.submitDeletes(List.of()));
assertEquals(0, orchestrator.submitDeletes(null));
assertEquals(0, orchestrator.submitDeletes(java.util.Arrays.asList("", " ", null)));
assertEquals(0, orchestrator.flushPendingDeletes());
verify(chunkMapper, never()).selectList(any());
verify(scopeStateMapper, never()).selectList(any());
verify(rustfs, never()).deleteObject(anyString());
}
@Test
void test_task_068_payload_boundary_single_item() throws Exception {
// 单元素:单个对象不依赖批量路径,检查一次删除一次。
stubNoReferences();
assertEquals(1, orchestrator.submitDeletes(List.of(pointer(7))));
CountDownLatch done = latchOnDelete(1);
assertEquals(1, orchestrator.flushPendingDeletes());
assertTrue(done.await(2, TimeUnit.SECONDS));
verify(rustfs).deleteObject("task-parsed/test/7/scope/latest.json");
}
@Test
void test_task_068_payload_boundary_limit_and_overflow() throws Exception {
// 上限/超限:pending 队列达到 maxPendingDeletes 后拒绝新提交,不发生无界内存增长。
stubNoReferences();
ReflectionTestUtils.setField(orchestrator, "maxPendingDeletes", 3L);
List<String> values = List.of(pointer(1), pointer(2), pointer(3), pointer(4), pointer(5));
assertEquals(3, orchestrator.submitDeletes(values), "超过上限的提交被拒绝");
CountDownLatch done = latchOnDelete(3);
assertEquals(3, orchestrator.flushPendingDeletes());
assertTrue(done.await(2, TimeUnit.SECONDS));
verify(rustfs, times(3)).deleteObject(anyString());
}
@Test
void test_task_068_payload_invalid_input_rejected() {
// 非法参数:非指针值不入队,不发起引用检查。
stubNoReferences();
assertEquals(0, orchestrator.submitDeletes(List.of("not-a-pointer", "plain text")));
assertEquals(0, orchestrator.flushPendingDeletes());
verify(chunkMapper, never()).selectList(any());
}
@Test
void test_task_068_payload_dependency_failure_releases_resources() throws Exception {
// 依赖失败:引用检查查询异常时保守不删、pending 保留可重试;恢复后删除成功。
when(chunkMapper.selectList(any(LambdaQueryWrapper.class)))
.thenThrow(new RuntimeException("db down"));
assertEquals(1, orchestrator.submitDeletes(List.of(pointer(1))));
assertEquals(0, orchestrator.flushPendingDeletes(), "查询失败保守不删");
verify(rustfs, never()).deleteObject(anyString());
stubNoReferences();
CountDownLatch done = latchOnDelete(1);
assertEquals(1, orchestrator.flushPendingDeletes(), "恢复后重试成功");
assertTrue(done.await(2, TimeUnit.SECONDS));
verify(rustfs).deleteObject("task-parsed/test/1/scope/latest.json");
}
}
@@ -0,0 +1,240 @@
package com.nanri.aiimage.modules.task.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.nanri.aiimage.config.InstanceMetadata;
import com.nanri.aiimage.config.StorageProperties;
import com.nanri.aiimage.config.TransientStorageProperties;
import com.nanri.aiimage.modules.file.service.object.RustfsObjectStorageService;
import com.nanri.aiimage.modules.file.service.oss.OssStorageService;
import com.nanri.aiimage.modules.task.mapper.TaskChunkMapper;
import com.nanri.aiimage.modules.task.mapper.TaskScopeStateMapper;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.ByteArrayOutputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.zip.GZIPOutputStream;
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.anyBoolean;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* Task 65transient payload 读取增加流式解压和解压后字节上限。
* 读取路径(resolvePayload)从对象存储拿到 gzip 二进制流后流式解压,
* 解压输出字节数超过 maxDecompressedPayloadBytes 立即中止并报错,
* 防止压缩炸弹(zip bomb)导致无界内存增长;旧的 gzip64 文本兼容分支同样受限。
*/
class TransientPayloadStorageDecompressionTest {
@TempDir
Path tempDir;
private static final long BIG_LIMIT = 100_000L;
@Test
void test_task_065_payload_normal_default_path() throws Exception {
RustfsObjectStorageService rustfs = mock(RustfsObjectStorageService.class);
when(rustfs.isConfigured()).thenReturn(true);
when(rustfs.uploadBytes(anyString(), any(byte[].class), anyBoolean()))
.thenAnswer(invocation -> invocation.getArgument(0));
TransientPayloadStorageService service = newService(rustfs, propertiesWithDecompressionLimit(BIG_LIMIT));
String content = compressiblePayload(3000);
String pointer = service.storeParsedPayloadFast("TEST", 1L, "scope", content, false);
when(rustfs.readObjectBytes(anyString())).thenReturn(gzipBytes(content));
assertEquals(content, service.resolvePayload(pointer, "read failed"));
verify(rustfs).readObjectBytes(anyString());
}
@Test
void test_task_065_payload_normal_multiple_items() throws Exception {
RustfsObjectStorageService rustfs = mock(RustfsObjectStorageService.class);
when(rustfs.isConfigured()).thenReturn(true);
when(rustfs.uploadBytes(anyString(), any(byte[].class), anyBoolean()))
.thenAnswer(invocation -> invocation.getArgument(0));
TransientPayloadStorageService service = newService(rustfs, propertiesWithDecompressionLimit(BIG_LIMIT));
List<String> contents = List.of(
compressiblePayload(1000), "{\"i\":1}", compressiblePayload(500), "{\"i\":2}");
List<String> pointers = new ArrayList<>();
for (int i = 0; i < contents.size(); i++) {
pointers.add(service.storeChunkPayload("TEST", 10L + i, "scope-" + i, i, contents.get(i)));
}
when(rustfs.readObjectBytes(anyString())).thenAnswer(invocation -> {
String objectKey = invocation.getArgument(0);
int index = Integer.parseInt(objectKey.substring(objectKey.indexOf("chunk-") + "chunk-".length(),
objectKey.indexOf(".json")));
return gzipBytes(contents.get(index));
});
for (int i = 0; i < contents.size(); i++) {
assertEquals(contents.get(i), service.resolvePayload(pointers.get(i), "read failed"));
}
verify(rustfs, times(contents.size())).readObjectBytes(anyString());
}
@Test
void test_task_065_payload_normal_repeated_operation_is_idempotent() throws Exception {
RustfsObjectStorageService rustfs = mock(RustfsObjectStorageService.class);
when(rustfs.isConfigured()).thenReturn(true);
when(rustfs.uploadBytes(anyString(), any(byte[].class), anyBoolean()))
.thenAnswer(invocation -> invocation.getArgument(0));
TransientPayloadStorageService service = newService(rustfs, propertiesWithDecompressionLimit(BIG_LIMIT));
String content = compressiblePayload(1500);
String pointer = service.storeParsedPayloadFast("TEST", 1L, "scope", content, false);
when(rustfs.readObjectBytes(anyString())).thenReturn(gzipBytes(content));
assertEquals(content, service.resolvePayload(pointer, "read failed"));
assertEquals(content, service.resolvePayload(pointer, "read failed"));
verify(rustfs, times(2)).readObjectBytes(anyString());
}
@Test
void test_task_065_payload_boundary_empty_input() throws Exception {
RustfsObjectStorageService rustfs = mock(RustfsObjectStorageService.class);
when(rustfs.isConfigured()).thenReturn(true);
when(rustfs.uploadBytes(anyString(), any(byte[].class), anyBoolean()))
.thenAnswer(invocation -> invocation.getArgument(0));
TransientPayloadStorageService service = newService(rustfs, propertiesWithDecompressionLimit(BIG_LIMIT));
String pointer = service.storeParsedPayloadFast("TEST", 1L, "scope", "", false);
when(rustfs.readObjectBytes(anyString())).thenReturn(gzipBytes(""));
assertEquals("", service.resolvePayload(pointer, "read failed"));
}
@Test
void test_task_065_payload_boundary_single_item() throws Exception {
RustfsObjectStorageService rustfs = mock(RustfsObjectStorageService.class);
when(rustfs.isConfigured()).thenReturn(true);
when(rustfs.uploadBytes(anyString(), any(byte[].class), anyBoolean()))
.thenAnswer(invocation -> invocation.getArgument(0));
TransientPayloadStorageService service = newService(rustfs, propertiesWithDecompressionLimit(BIG_LIMIT));
String content = "{\"row\":\"single\"}";
String pointer = service.storeResultItemPayload("TEST", 7L, "scope", "row-1", content);
when(rustfs.readObjectBytes(anyString())).thenReturn(gzipBytes(content));
assertEquals(content, service.resolvePayload(pointer, "read failed"));
}
@Test
void test_task_065_payload_boundary_limit_and_overflow() throws Exception {
// 上限/超限:解压后字节数超过 maxDecompressedPayloadBytes 时中止并报错(防压缩炸弹),
// 等于上限时恰好成功;不产生无界内存增长。
RustfsObjectStorageService rustfs = mock(RustfsObjectStorageService.class);
when(rustfs.isConfigured()).thenReturn(true);
when(rustfs.uploadBytes(anyString(), any(byte[].class), anyBoolean()))
.thenAnswer(invocation -> invocation.getArgument(0));
String content = compressiblePayload(2000);
int exactLimit = content.getBytes(StandardCharsets.UTF_8).length;
byte[] compressed = gzipBytes(content);
TransientPayloadStorageService exactService =
newService(rustfs, propertiesWithDecompressionLimit(exactLimit));
String exactPointer = exactService.storeParsedPayloadFast("TEST", 1L, "scope", content, false);
when(rustfs.readObjectBytes(anyString())).thenReturn(compressed);
assertEquals(content, exactService.resolvePayload(exactPointer, "read failed"));
TransientPayloadStorageService strictService =
newService(rustfs, propertiesWithDecompressionLimit(exactLimit - 1L));
String strictPointer = strictService.storeParsedPayloadFast("TEST", 1L, "scope", content, false);
IllegalStateException ex = assertThrows(IllegalStateException.class,
() -> strictService.resolvePayload(strictPointer, "read failed"));
assertTrue(ex.getCause() != null && ex.getCause().getMessage() != null
&& ex.getCause().getMessage().contains("exceeds"), "超限错误消息应可识别");
assertTrue(ex.getMessage().contains("read failed"), "调用方上下文应保留在顶层消息");
when(rustfs.readObjectBytes(anyString())).thenReturn(compressed);
assertThrows(IllegalStateException.class,
() -> strictService.resolvePayload(strictPointer, "read failed"));
}
@Test
void test_task_065_payload_invalid_input_rejected() throws Exception {
// 非法输入:损坏的 gzip 流(magic 头 + 垃圾字节)读回时抛 IllegalStateException 且错误消息可识别。
RustfsObjectStorageService rustfs = mock(RustfsObjectStorageService.class);
when(rustfs.isConfigured()).thenReturn(true);
when(rustfs.uploadBytes(anyString(), any(byte[].class), anyBoolean()))
.thenAnswer(invocation -> invocation.getArgument(0));
TransientPayloadStorageService service = newService(rustfs, propertiesWithDecompressionLimit(BIG_LIMIT));
String pointer = service.storeParsedPayloadFast("TEST", 1L, "scope", "{\"x\":1}", false);
when(rustfs.readObjectBytes(anyString()))
.thenReturn(new byte[]{0x1f, (byte) 0x8b, 0x00, (byte) 0xFF, 0x00, 0x00});
IllegalStateException ex = assertThrows(IllegalStateException.class,
() -> service.resolvePayload(pointer, "read failed"));
assertTrue(ex.getMessage().contains("read failed"), "错误消息应包含调用方上下文");
}
@Test
void test_task_065_payload_dependency_failure_releases_resources() throws Exception {
// 依赖失败:对象存储读取失败传播且不残留;恢复后再次读取成功。
RustfsObjectStorageService rustfs = mock(RustfsObjectStorageService.class);
when(rustfs.isConfigured()).thenReturn(true);
when(rustfs.uploadBytes(anyString(), any(byte[].class), anyBoolean()))
.thenAnswer(invocation -> invocation.getArgument(0));
TransientPayloadStorageService service = newService(rustfs, propertiesWithDecompressionLimit(BIG_LIMIT));
String content = compressiblePayload(800);
String pointer = service.storeParsedPayloadFast("TEST", 1L, "scope", content, false);
when(rustfs.readObjectBytes(anyString())).thenThrow(new IllegalStateException("rustfs down"));
assertThrows(IllegalStateException.class, () -> service.resolvePayload(pointer, "read failed"));
// thenThrow 后必须用 doReturn 重新打桩:when() 内的方法调用会立即触发旧异常桩。
doReturn(gzipBytes(content)).when(rustfs).readObjectBytes(anyString());
assertEquals(content, service.resolvePayload(pointer, "read failed"));
verify(rustfs, times(2)).readObjectBytes(anyString());
}
private TransientPayloadStorageService newService(RustfsObjectStorageService rustfs,
TransientStorageProperties transientProperties) {
StorageProperties storageProperties = new StorageProperties();
storageProperties.setLocalTempDir(tempDir.toString());
return new TransientPayloadStorageService(
transientProperties,
storageProperties,
rustfs,
mock(OssStorageService.class),
new ObjectMapper(),
new InstanceMetadata("test-instance"),
mock(TaskChunkMapper.class),
mock(TaskScopeStateMapper.class));
}
private TransientStorageProperties propertiesWithDecompressionLimit(long maxDecompressedPayloadBytes) {
TransientStorageProperties properties = new TransientStorageProperties();
properties.setEnabled(true);
properties.setMaxPayloadBytes(100_000L);
properties.setMaxStoredPayloadBytes(100_000L);
properties.setWarnPayloadBytes(1);
properties.setMaxDecompressedPayloadBytes(maxDecompressedPayloadBytes);
properties.setFallbackToLocalOnOversize(true);
return properties;
}
private String compressiblePayload(int repeatCount) {
return "{\"payload\":\"c".repeat(repeatCount) + "\"}";
}
private byte[] gzipBytes(String content) throws Exception {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
try (GZIPOutputStream gzip = new GZIPOutputStream(baos)) {
gzip.write(content.getBytes(StandardCharsets.UTF_8));
}
return baos.toByteArray();
}
}

Some files were not shown because too many files have changed in this diff Show More