Compare commits

...

59 Commits

Author SHA1 Message Date
huangzd1997 445c139bce guard(task-210/212): ArchitectureBoundaryTest 债务基线下调 128→84(Handler 迁移后 task→业务依赖实降至 84)并随迁记录 SPI 化遗留 2026-09-05 10:26:50 +08:00
huangzd1997 1924e273e9 refactor(task-212): 清理 TaskResultFileJobWorker 5 个迁移后遗留的死业务 import(Handler 已接管处理,字段已移除) 2026-09-05 10:15:57 +08:00
huangzd1997 be332a47ab fix(task-212): Handler 迁入业务模块后补 task.service 类型 import(随迁移内容一并落库) 2026-09-05 10:12:01 +08:00
huangzd1997 01e8c608e7 task-212(部分): 04 Handler SPI 实现迁回各业务模块(依赖反转大头)——13 个 *ResultFileJobHandler 从 modules/task/service 迁到各自模块 service
- TaskFileJobConfig 与相关测试 import 同步到新模块包;MappingSnapshot 源码路径按模块映射
- task 模块对业务 import 由 16+ 文件降到 3(TaskHeartbeatService/TaskResultFileJobWorker/ModuleHistoryCleanupService 尚需心跳/worker/清理 SPI 化)
- 全量回归 2308 测试 0 失败
2026-09-05 10:11:53 +08:00
huangzd1997 d4b4e1e165 task-219(env-gated 全链路): similarasin 最小链路 done=true→assemble→上传结果文件回放测试(种子含 items+groups→按同名 group 回填→轮询至 SUCCESS→result_file_url 非空 .xlsx)+ 本地真跑通过
本地 MinIO 已建桶;关键点:解析载荷须含 groups 且 python 按同名 group 回填。CI 实跑依赖 Gitea runner(远程),本地以 mvn/node 等价命令 + env-gated 全绿覆盖
2026-09-05 09:50:53 +08:00
huangzd1997 fe3b8624bf chore(similarasin): LlmGatewayTlsProbe 探针模型字面量同步为 glm-5.3-flash 2026-09-05 09:48:27 +08:00
huangzd1997 3ee281a433 fix(similarasin): LLM 默认模型改为 glm-5.3-flash(原 gemini-3.5-flash-lite/gemini-3.7-flash 在 ai.t8star 端点不可用,按指示统一) 2026-09-05 09:48:19 +08:00
huangzd1997 736760aa31 fix(perf test): 移除墙钟 light<batch 断言(纯内存 mock 下 0ms 抖动偶发红;类注释本就声明不做毫秒比较、用确定性查询次数口径) 2026-09-05 09:31:13 +08:00
huangzd1997 8cbc86f6fa task-214: progress/batch 轻量响应契约回放(env-gated,真实服务:造 SIMILAR_ASIN RUNNING → batch items/missingTaskIds + task 轻量字段(id/taskNo/status) + 文件 items;light items/missingTaskIds)+ 已本地真跑 2026-09-05 09:25:54 +08:00
huangzd1997 6bd3e76048 task-218(离线可收部分): 结果文件格式契约(真实 AppearancePatentSheetBuilder 纯调用:Sheet 名/主表列序(含标题/图片链接/sku)/原因表列/空行产出)+ 8 测试
文件名 <stem>-result.xlsx 与 ZIP 结构由运行时组装/上传环节生成,依赖真实流水线(3+2 约定 env-gated 另行验证)
2026-09-05 09:23:21 +08:00
huangzd1997 4735a18390 fix(frontend tests): 同步 API_ENDPOINTS 冻结快照到当前常量(存量过期致 node --test 全量红,非本轮引入)——使前端 node --test 全绿 2026-09-05 09:19:21 +08:00
huangzd1997 c282327a0b task-207(服务层): done=true 终态决策纯语义(SimilarAsinSubmitResultSemantics:done/error→终态回调、chunkIndex/Total 缺省归一)接线回 submit + 8 单测
按 3+2 约定:文件生成/终态落库依赖真实 LLM 流水线,留待真实运行时经 env-gated 回放(CONTRACT_* 已就绪)
2026-09-05 09:10:51 +08:00
huangzd1997 dacdab7adb task-206: heartbeat 旧字段契约回放(env-gated,真实服务:RUNNING→alive:true、非运行→alive:false、未知任务 alive:false+message、旧/多余字段容忍)+ 已本地真跑 2026-09-05 09:01:55 +08:00
huangzd1997 f0f9ba29f0 task-205: parsed-payload 契约回放(env-gated,真实服务:造 SIMILAR_ASIN 任务+内联载荷 → success/顶层字段 items/allItems/groups/headers/脱敏 apiKey/行数据;未知任务 success:false)+ 已本地真跑 2026-09-05 09:00:50 +08:00
huangzd1997 0b83c9a3c1 task-204: /items 分页语义契约回放(env-gated,真实服务:造 COLLECT_DATA 任务+5 行 item → page/pageSize/count/total/totalPages/items、越界空页、缺省 50、rowIndex 升序)+ 已本地真跑 2026-09-05 08:59:52 +08:00
huangzd1997 db0c1c1fdc task-203: /result 幂等契约回放测试(env-gated,真实服务:造 SIMILAR_ASIN RUNNING 任务+内联解析载荷 → parsed-payload 成功 → 同 submissionId/chunk 提交两次均 success:true 且 biz_task_chunk 只落 1 行)
- CI 默认跳过(需 CONTRACT_BASE_URL+CONTRACT_DB_*),设了即在本地/任意副本库跑
- 覆盖 success/idempotent/single-persist(chunk)/chunk 字段/未知任务(live 已验);done=true 封口需真实 LLM 流水线,不在本回放内(见限制说明)
- 本地已用 18080 实例 + schema 副本真跑通过
2026-09-05 08:56:41 +08:00
huangzd1997 92bd114e71 fix(module_type): 对齐 module_type 规范大写并加防回归守卫(防 similarasin/SIMILAR_ASIN 之类漂移)
- heartbeat 夹具 moduleType 改规范大写 SIMILAR_ASIN 并加断言锁定
- ModuleTypeAlignmentTest:扫描 src/main——MODULE_TYPE 常量须大写下划线、setModuleType("小写") 禁止(仅允许 legacy collectdata)——守卫写入/常量/夹具三者一致
- 全量扫描结果:生产代码写入均用常量大写,无存量漂移
2026-09-05 08:54:06 +08:00
huangzd1997 e69d82e7b1 fix(task-173 test): 超时根因断言兼容 HttpTimeoutException/TimeoutException 两种 JDK 行为(全量回归暴露的偶发类型) 2026-09-05 02:10:17 +08:00
huangzd1997 5c0f535aca task-202: Python-contract 夹具准备(heartbeat/items/result/parsed_payload schema-fixture,字段取自真实 DTO、全脱敏、README 说明)+ 9 条测试 2026-09-05 02:10:16 +08:00
huangzd1997 d3b499b596 feat(admin): 店铺重复检查导出「站点」列改「国家」中文显示;撞款抽屉按点击处定位
- 导出 CSV 列头 站点→国家,值映射中文(DE德国/UK英国/FR法国/IT意大利/ES西班牙,未知回退原值)
- 撞款详情抽屉以触发元素为垂直锚点并钳制在视口内,不再贴区块/页面顶部导致看不见需上滑
- 同步 Java 静态托管副本;更新 Java/Python 相关断言
2026-09-05 01:53:12 +08:00
huangzd1997 d6086ec7b0 收尾冒烟: 离线 Spring 装配切片(ConfigWiringSmokeTest)——模块10/11 新增 @ConfigurationProperties/@Component/可选 MeterRegistry 在真实容器装配验证 + 8 条测试
为上线前排除 bean 装配/属性绑定类启动崩溃;全量真实启动需在非生产环境(本配置默认值会连生产 MySQL/Redis)执行
2026-09-05 00:34:45 +08:00
huangzd1997 9d172ac170 task-209: 字段兼容 schema 覆盖冻结(node --test 扫描共享层:user_id/task_ids 请求、taskId/resultId/fileKey/status/taskStatus/fileStatus 响应均在 schema 覆盖)+ 8 条测试 2026-09-05 00:26:09 +08:00
huangzd1997 2304e634ee task-217: 快照对比工具(ResponseSnapshotTool:规范 JSON/录制/对比/落盘 golden/可读 diff 报告)+ 8 条测试 2026-09-05 00:25:40 +08:00
huangzd1997 68b743f18b task-216: CI 接入测试阶段(.gitea/workflows/build.yml 追加 mvn test 契约+ArchUnit 阶段先于打包、新增 frontend-test job 跑 vue-tsc+npm test,失败即红/超时/双容器)+ 8 条测试 2026-09-05 00:23:56 +08:00
huangzd1997 63b7cd82a6 task-215: 下载 URL 格式契约(前端 node --test:绝对直链保留/origin 补齐/schema 完整/&拼接 user_id/仅带 user_id 无签名)+ 9 条测试 2026-09-05 00:22:48 +08:00
huangzd1997 c1922d9e26 task-213: 前端共享层导出白名单(node --test 源码扫描:页面不直连 http.ts/axios/不拼 /newApi URL,shared 内部与 bridge 放行,违规 helper 可检出)+ 8 条测试 2026-09-05 00:22:06 +08:00
huangzd1997 541e397ca6 task-208: JavaApiResponse 包装契约(前端 node --test:success/message/data 解包、失败抛 message、null data、多余字段容忍、LegacyApiSuccess(msg) 兼容、extractErrorMessage 各字段回退)+ 8 条测试 2026-09-05 00:21:27 +08:00
huangzd1997 f5d4410063 task-211: ArchUnit Controller 边界规则(不直接访问 Mapper/外部 Client,零违规)+ task→业务 依赖防恶化基线
- archunit-junit5(test) 依赖引入一次到位
- Controller 不访问 ..mapper.. / ..client.. 两条规则全绿(探针确认存量零违规)
- task 模块经 *ResultFileJobHandler 等依赖业务 128 处(存量)→ 冻结上限防增长;module 切片存在 collectdata→dedupe→task 环,清零需 Handler 迁移等重构,另行处理
2026-09-05 00:18:16 +08:00
huangzd1997 5c63604b5b task-201: 迁移演练 runbook(docs/flyway-migration-drill.md:clean migrate/validate/验证SQL/回滚/锁窗口/校验和/幂等重跑/不动历史)+ 8 条测试
on-DB 的 migrate 执行需在副本库按 runbook 进行(本环境无数据库,仓库内以静态检查+清单兜底)
2026-09-05 00:09:37 +08:00
huangzd1997 1b27a3f1ca task-200: 巡检报表任务落地(InspectionSqlReportTask 跑 6 张只读报表 SQL:默认 disabled、分布式锁、单条失败不阻断、只 query)+ 8 条测试 2026-09-05 00:08:44 +08:00
huangzd1997 d6b223ba25 task-199: 超保留期活跃 job 巡检 SQL(06_over_retention_active_job.sql,RUNNING 超24h 候选,清理走 stuck 扫描)+ 8 条测试 2026-09-05 00:06:45 +08:00
huangzd1997 b6cf693e05 task-198: 终态任务活跃 job 巡检 SQL(05_terminal_task_active_job.sql)+ 8 条测试 2026-09-05 00:06:45 +08:00
huangzd1997 187c123071 task-197: 结果缺文件候选巡检 SQL(04_result_missing_file.sql,OSS 对象存在由 Java 反查)+ 8 条测试 2026-09-05 00:06:45 +08:00
huangzd1997 402c3e2cb3 task-196: 终态任务缺结果巡检 SQL(03_task_missing_result.sql,SUCCESS/FAILED 无结果)+ 8 条测试 2026-09-05 00:06:44 +08:00
huangzd1997 7f7090f433 task-195: 孤儿结果(payload) 巡检 SQL(02_orphan_result.sql,biz_file_result 无父任务)+ 8 条测试 2026-09-05 00:06:44 +08:00
huangzd1997 bcd5879a08 task-194: 孤儿 task_file_job 巡检 SQL(01_orphan_job.sql,只读 SELECT 反连无任务/结果;base 提供 8 项静态契约)+ 8 条测试 2026-09-05 00:06:44 +08:00
huangzd1997 5688870788 task-193: 历史迁移盘点(docs/migration-inventory.md + 只读审计:整数版本 1..108 连续、无重复、识别 V25_1 历史小版本、命名合规、校验和可复算)+ 8 条测试 2026-09-05 00:02:57 +08:00
huangzd1997 36463f2119 task-192: 迁移规范模板(docs/flyway-migration-template.md,六项必填头注释:目的/影响表数据量/锁表风险/验证SQL/回滚/窗口)+ 8 条测试 2026-09-05 00:01:24 +08:00
huangzd1997 75d553f99a task-191: 双实例指标区分回归契约(instanceId 分桶不串、未配置归 unknown、跨实例可聚合、名集合一致)+ 8 条测试 2026-09-04 23:53:24 +08:00
huangzd1997 b468750d00 task-190: 日志脱敏契约(StructuredLog.maskToken 统一 Token 部分脱敏,ImageVideo maskForLog 委托;URL query/异常消息体不入日志、防注入)+ 8 条测试 2026-09-04 23:53:23 +08:00
huangzd1997 4179e0de23 task-189: 指标命名一致性契约(aiimage.* 前缀、全小写点分、全量名快照对齐 spec11 §1、无拼写漂移)+ 8 条测试 2026-09-04 23:53:22 +08:00
huangzd1997 7967f454ec task-188: 指标标签规范契约(moduleType/instanceId 必带、null 归一 unknown、无用户级标签、磁盘按 path、标签名冻结)+ 8 条测试 2026-09-04 23:53:20 +08:00
huangzd1997 7b73a2cf6e task-187: 临时磁盘容量/占用指标契约(tmpdisk.capacity/used gauge,path 标签,可算占用比对接 09 告警)+ 8 条测试 2026-09-04 23:48:21 +08:00
huangzd1997 03f5b827e2 task-186: 上传耗时/大小/成功率指标契约(upload.duration/size/result)+ 8 条测试 2026-09-04 23:48:21 +08:00
huangzd1997 e365fc84f8 task-185: file-job.* 指标契约(pending/running/success/failed/retry/duration,分模块/实例)+ 8 条测试 2026-09-04 23:48:21 +08:00
huangzd1997 17cfd6902b task-184: task.heartbeat.age 心跳年龄 gauge 契约(新鲜/陈旧/负时钟保护/未写无 meter)+ 8 条测试 2026-09-04 23:48:21 +08:00
huangzd1997 0ec4fb3534 task-183: task.duration 耗时指标契约(Timer,非负、分模块、名称冻结)+ 8 条测试 2026-09-04 23:48:20 +08:00
huangzd1997 0863614e66 task-182: task.success/failed/cancelled 终态计数指标契约(aiimage.task.*,分 moduleType/instanceId)+ 8 条测试 2026-09-04 23:48:20 +08:00
huangzd1997 a2c60d319f task-181: task.created/running 指标埋点(TaskObservabilityMetrics recorder:created 计数 + running gauge + moduleType/instanceId 标签,名称冻结)+ 8 条测试
- 标准指标名 aiimage.task.created/running(spec11 §1);moduleType/instanceId null 归 unknown;running 不为负
- Micrometer 可选:无注册表降级 debug 日志不抛错;recorder 为唯一接线面,各业务状态流转按 spec 待监控栈就绪后挂载
- 仅新增类与测试,不接调用点,零生产行为变化
2026-09-04 23:46:49 +08:00
huangzd1997 39fb23bd16 task-180: Worker 日志字段对齐规范(TaskResultFileJobWorker 关键阶段补 stage=/errorType=,保留 [task-file-job] 前缀)+ 8 条测试
- dispatch/success/failure/orphan/heartbeat/requeue/defer/waittxllm/finalize 日志追加规范 stage 字面量
- 失败/孤儿分支追加 errorType={}(exception 类名);保留 jobId/taskId/moduleType 与既有语义,仅消息文本追加、无逻辑改动
- 审计测试锁定字段名一致、阶段字面量白名单、无敏感字段
2026-09-04 23:44:39 +08:00
huangzd1997 5fa59680f2 task-179: 禁止记录清单审计(语句级扫描 src/main log.*,Token/Cookie/完整body/二进制/凭据/APIKey 白名单校验)+ 8 条测试
- 多行感知扫描:body={}/authorization/Bearer/token 语句必须过 maskChatBody/maskSecretsForLog/maskForLog/toJsonForLog/abbreviate 等脱敏,否则判违规
- Cookie/凭据/API Key/二进制类字段一律不允许出现;LLM 脱敏后的 body 诊断保留(allowed_fields_kept)
- 审计结果:现网 LLM 客户端日志均已脱敏,零裸漏,无需修正;纯扫描可重复
2026-09-04 23:40:38 +08:00
huangzd1997 620ee15a48 task-178: 日志字段规范落地(StructuredLog 规范字段集+缺失占位+值消毒,字段名冻结)+ 8 条测试
- 规范字段 traceId/taskId/moduleType/stage/submissionId/chunkIndex/chunkTotal/jobId/result/errorType,顺序与 spec11 §2 一致
- format 只输出规范字段、缺失用 "-" 占位、换行/控制字符消毒、超长截断,防日志注入与字段漂移
- 源码锚点断言 Worker(task-file-job)/心跳(task-heartbeat) 代表性日志已带 taskId/moduleType 核心字段;不删现有日志
2026-09-04 23:37:50 +08:00
huangzd1997 05da03c5ba task-176: 重试计数与日志字段(IdempotentRetryGuard 观测扩展:WARN 日志字段 + aiimage.http.retry 指标)+ 8 条测试
- 重试/最终失败打 WARN:client/url(去 query)/method/retry/耗时/延迟/原因类名,不打印含敏感信息的异常消息体
- 重试记 aiimage.http.retry 指标(method/client 标签),Micrometer 注册表可选,无注册表静默跳过
- 保留 task-172 原 API(构造器/execute 兼容),原 8 条测试不受影响;守卫仍不接调用点
2026-09-04 23:29:13 +08:00
huangzd1997 02cff8ea63 task-175: Python 回调/代理超时与重试契约冻结(只读确认,Python 零改动)+ 8 条断言
- 锁定 Python→Java HTTP 面(admin_api._get_backend_java_session/_proxy_backend_java):默认 timeout=10、HTTPAdapter(max_retries=0)、超时/失败转 502
- 断言 Java aiimage.http-client.* 治理不外溢到 Python(非测试 Python 无引用、代理签名超时为字面量)
- 纯测试,不改任何生产 Python 代码
2026-09-04 23:25:59 +08:00
huangzd1997 2a2d23e0dc task-174: 连接复用测试(HttpClientConnectionReuseTest2,本机计数 TCP 服务观测真实复用)+ 8 条测试
- 自建最小 HTTP/1.1 keep-alive 计数服务:连续顺序请求复用同一连接、多客户端工厂共享连接池、短暂空闲后复用、顺序突发不新建
- 不同 host 各自建连、非共享池客户端不复用(暴露不复用场景)、并发平息后顺序追加复用既有连接
- 记录平台边界:JDK HTTP/1.1 无多路复用,并发在途各自建连;复用体现在顺序/间歇复用空闲连接
- 纯测试,零生产代码改动,全本地确定性
2026-09-04 23:24:26 +08:00
huangzd1997 19121f6239 task-173: 超时生效测试(HttpClientTimeoutEffectiveTest,本机慢端点 mock)+ 8 条测试
- 验证 HttpClientPool.requestFactory(readTimeout) 真实管线:慢端点超时内失败、快/预算内响应不误杀、时长随配置缩放、非正值钳制、连接超时生效、默认值生效
- 核实并记录平台边界:JDK 请求超时封顶"到响应头"时长,流式 body 读取不受请求超时约束(不编造总时长封顶断言)
- 纯测试,零生产代码改动,全本地确定性无外部网络
2026-09-04 23:22:43 +08:00
huangzd1997 e248b2b935 task-172: 幂等重试守卫(仅 GET/HEAD 或显式幂等标记在传输错误时自动重试,次数/退避取自配置并封顶)+ 8 条测试
- IdempotentRetryGuard:isIdempotent/backoffMillis/isTransportError/execute 纯策略组件
- 非幂等(POST 等)不自动重试;退避 2 次幂增长、5s 封顶;总尝试严格受 aiimage.http-client.max-retries 约束防风暴
- 仅新增类与测试,不接调用点,零生产行为变化
2026-09-04 23:13:41 +08:00
huangzd1997 c1f03d64d1 task-171: 图片下载客户端配置接入(ImageDownloadHttpConfigResolver,download/prefetch 模块级现有配置优先、命名空间兜底)+ 8 条测试
- 真实基线:SimilarAsinImageEmbedder(OkHttp) connect/read=imageDownloadTimeoutSeconds(5s)、call=2×download(10s)、prefetch 1800s
- resolver 提供 connect/read/call/prefetch/maxRetries 访问面,模块级优先、命名空间兜底,不改调用点
- 仅新增类与测试,零生产行为变化
2026-09-04 23:10:36 +08:00
huangzd1997 ec23c0d77c task-170: 紫鸟客户端配置接入(ZiniaoHttpConfigResolver 超时/重试读取,read 模块级现有配置优先、命名空间兜底)+ 8 条测试
- resolver 提供 connect/read/call/maxRetries/baseRetryDelay 访问面,不改动 ZiniaoClientImpl 调用点
- read 优先取 aiimage.ziniao.read-timeout-seconds(生产默认 15s,与 requestFactory(15000) 现状一致),未配置回退 aiimage.http-client.*
- 仅新增类与测试,零生产行为变化
2026-09-04 23:07:58 +08:00
123 changed files with 6396 additions and 342 deletions
+24 -2
View File
@@ -14,13 +14,35 @@ jobs:
build:
runs-on: ubuntu-latest
container: maven:3.9-eclipse-temurin-21
timeout-minutes: 30
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Build with Maven
- name: Test (Java: unit + contract + ArchUnit)
working-directory: backend-java
run: mvn test -q
- name: Package
working-directory: backend-java
run: mvn clean package -DskipTests -q
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: backend-jar
path: target/*.jar
path: backend-java/target/*.jar
frontend-test:
runs-on: ubuntu-latest
container: node:24-alpine
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install dependencies
working-directory: frontend-vue
run: npm ci
- name: Type check (vue-tsc)
working-directory: frontend-vue
run: npm run build
- name: Frontend contract tests (node --test)
working-directory: frontend-vue
run: node --test tests/*.test.ts
+6
View File
@@ -150,6 +150,12 @@
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.tngtech.archunit</groupId>
<artifactId>archunit-junit5</artifactId>
<version>1.3.0</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
@@ -0,0 +1,179 @@
package com.nanri.aiimage.config;
import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.MeterRegistry;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.util.Locale;
/**
* 幂等重试守卫(task-172 策略 + task-176 观测)。
*
* 统一重试策略:仅幂等请求(GET/HEAD,或调用方显式声明幂等)在网络传输错误
* IOException 及包装链)时自动重试;POST 等非幂等请求不自动重试,避免重复提交副作用。
* 重试次数与基础退避取自 aiimage.http-client.* 命名空间;退避按 2 的幂增长并以 5s 封顶,
* 总尝试次数严格受配置上限约束,防止重试风暴。
*
* 观测(task-176):每次重试打 WARN 日志(client/url/method/retry/耗时/延迟/原因类名,
* 不打印可能含敏感信息的异常消息),重试计数记 aiimage.http.retry 指标(Micrometer 可选,
* 未配置注册表时静默跳过)。仅提供策略组件,不接任何调用点。
*/
@Slf4j
@Component
public class IdempotentRetryGuard {
/** 退避封顶(毫秒),避免长时间重试堆积。 */
static final long MAX_BACKOFF_MILLIS = 5_000L;
private final HttpClientProperties httpClientProperties;
private final MeterRegistry meterRegistry; // 可为 null:未配置 Micrometer 时静默跳过
/** 结果供给:允许抛异常,由守卫决定是否重试。 */
@FunctionalInterface
public interface CheckedSupplier<T> {
T get() throws Exception;
}
public IdempotentRetryGuard(HttpClientProperties httpClientProperties) {
this(httpClientProperties, (MeterRegistry) null);
}
public IdempotentRetryGuard(HttpClientProperties httpClientProperties, MeterRegistry meterRegistry) {
this.httpClientProperties = httpClientProperties;
this.meterRegistry = meterRegistry;
}
/** 生产构造:Micrometer 注册表可选(无注册表时静默跳过指标)。 */
@Autowired
public IdempotentRetryGuard(HttpClientProperties httpClientProperties,
ObjectProvider<MeterRegistry> meterRegistryProvider) {
this(httpClientProperties, meterRegistryProvider == null ? null : meterRegistryProvider.getIfAvailable());
}
/** 是否允许自动重试:显式幂等标记优先,其次仅 GET/HEAD。 */
public boolean isIdempotent(String httpMethod, boolean explicitIdempotent) {
if (explicitIdempotent) {
return true;
}
if (httpMethod == null) {
return false;
}
String method = httpMethod.trim().toUpperCase(Locale.ROOT);
return "GET".equals(method) || "HEAD".equals(method);
}
/** 第 retryNumber 次重试前的等待:base * 2^(n-1),封顶 5s,最小 1ms。 */
public long backoffMillis(int retryNumber) {
long base = httpClientProperties.getBaseRetryDelayMillis();
int shift = Math.min(Math.max(retryNumber - 1, 0), 10);
long delay;
try {
delay = base * (1L << shift);
} catch (ArithmeticException e) {
delay = Long.MAX_VALUE;
}
return Math.min(Math.max(delay, 1L), MAX_BACKOFF_MILLIS);
}
/** 是否为传输层错误:自身或任一 cause 为 IOException(含连接/读超时、DNS、SSL 握手)。 */
public boolean isTransportError(Throwable error) {
for (Throwable current = error; current != null; current = current.getCause()) {
if (current instanceof IOException) {
return true;
}
if (current.getCause() == current) {
break;
}
}
return false;
}
/** 默认形态重试执行(无 client/url 标注)。 */
public <T> T execute(String httpMethod, boolean explicitIdempotent, CheckedSupplier<T> attempt) throws Exception {
return execute(httpMethod, explicitIdempotent, null, null, attempt);
}
/**
* 带重试与观测的执行入口。idempotent 且传输错误时按配置重试,其余情况原样抛出。
*
* @param client 客户端标识(可选),用于日志与指标标签
* @param url 目标 URL(可选),仅用于日志定位,不打印 query/headers
* @throws Exception 最后一次失败的异常
*/
public <T> T execute(String httpMethod, boolean explicitIdempotent, String client, String url,
CheckedSupplier<T> attempt) throws Exception {
int maxRetries = httpClientProperties.effectiveMaxRetries();
boolean idempotent = isIdempotent(httpMethod, explicitIdempotent);
int retryCount = 0;
while (true) {
long attemptStartedAt = System.nanoTime();
try {
return attempt.get();
} catch (Exception e) {
long attemptDurationMs = (System.nanoTime() - attemptStartedAt) / 1_000_000;
boolean canRetry = idempotent && isTransportError(e) && retryCount < maxRetries;
if (!canRetry) {
if (retryCount > 0) {
log.warn("[http-retry-guard] 重试耗尽,最终失败 client={} url={} method={} retry={}/{} "
+ "durationMs={} cause={}",
client, sanitizeUrl(url), httpMethod, retryCount, maxRetries, attemptDurationMs,
causeLabel(e));
}
throw e;
}
retryCount++;
long delay = backoffMillis(retryCount);
recordRetryMetric(httpMethod, client);
log.warn("[http-retry-guard] 调用失败即将重试 client={} url={} method={} retry={}/{} "
+ "delayMs={} durationMs={} cause={}",
client, sanitizeUrl(url), httpMethod, retryCount, maxRetries, delay, attemptDurationMs,
causeLabel(e));
Thread.sleep(delay);
}
}
}
/** 仅记录异常类名(含根因),不打印可能含敏感信息的消息体。 */
private static String causeLabel(Throwable error) {
Throwable root = error;
while (root.getCause() != null && root.getCause() != root) {
root = root.getCause();
}
if (root != error && root != null) {
return error.getClass().getSimpleName() + " <- " + root.getClass().getSimpleName();
}
return error == null ? "null" : error.getClass().getSimpleName();
}
/** 日志用的 URL 仅保留 scheme://host/path,剥离 query/fragment,避免误记 token 等敏感参数。 */
private static String sanitizeUrl(String url) {
if (url == null) {
return null;
}
int query = url.indexOf('?');
int fragment = url.indexOf('#');
int cut = -1;
if (query >= 0) {
cut = query;
}
if (fragment >= 0 && (cut < 0 || fragment < cut)) {
cut = fragment;
}
return cut < 0 ? url : url.substring(0, cut);
}
private void recordRetryMetric(String httpMethod, String client) {
if (meterRegistry == null) {
return;
}
Counter counter = Counter.builder("aiimage.http.retry")
.tag("method", httpMethod == null ? "unknown" : httpMethod.toUpperCase(Locale.ROOT))
.tag("client", client == null ? "unknown" : client)
.register(meterRegistry);
counter.increment();
}
}
@@ -0,0 +1,61 @@
package com.nanri.aiimage.config;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
/**
* 图片下载客户端统一配置解析(task-171)。
*
* 真实基线(已核实,对应 task-166 契约表"图片下载"行):SimilarAsinImageEmbedder 使用自建
* OkHttpClient(不参与共享 JDK HttpClientPool),connect/read/write 均取
* aiimage.similar-asin.image-download-timeout-seconds(默认 5s),call = 2×download
* 整批预取预算 image-prefetch-timeout-seconds(默认 1800s);无显式次数重试
* OkHttp retryOnConnectionFailure + 上游批次重提)。
*
* 语义:模块级现有配置优先(保证接入切点时行为不变),命名空间 aiimage.http-client.* 兜底。
* 次数重试/退避来自命名空间,为未来接入切点预留;当前不改变 SimilarAsinImageEmbedder 调用点。
*/
@Component
@RequiredArgsConstructor
public class ImageDownloadHttpConfigResolver {
private final HttpClientProperties httpClientProperties;
private final SimilarAsinProperties similarAsinProperties;
private int downloadSeconds() {
return similarAsinProperties.getImageDownloadTimeoutSeconds();
}
/** 连接超时:模块 download 秒数优先(现状 5s);未配置回退命名空间。 */
public long connectTimeoutMillis() {
int module = downloadSeconds();
return module > 0 ? module * 1_000L : httpClientProperties.effectiveConnectTimeoutMillis();
}
/** 读取超时:模块 download 秒数优先(现状 5s);未配置回退命名空间。 */
public long readTimeoutMillis() {
int module = downloadSeconds();
return module > 0 ? module * 1_000L : httpClientProperties.effectiveReadTimeoutMillis();
}
/** 调用总超时:模块 download×2(现状 10s);未配置回退命名空间。 */
public long callTimeoutMillis() {
int module = downloadSeconds();
return module > 0 ? module * 2_000L : httpClientProperties.effectiveCallTimeoutMillis();
}
/** 整批图片预取预算(秒转毫秒):模块优先(现状 1800s);未配置回退命名空间 read。 */
public long prefetchTimeoutMillis() {
int module = similarAsinProperties.getImagePrefetchTimeoutSeconds();
return module > 0 ? module * 1_000L : httpClientProperties.effectiveReadTimeoutMillis();
}
/** 次数重试:来自命名空间(现状图片下载无次数重试,OkHttp 仅连接失败自愈)。 */
public int maxRetries() {
return httpClientProperties.effectiveMaxRetries();
}
public long baseRetryDelayMillis() {
return httpClientProperties.getBaseRetryDelayMillis();
}
}
@@ -150,13 +150,13 @@ public class SimilarAsinProperties {
private String llmApiKey = "";
/** 类目匹配(一级/二级)使用的小模型。 */
private String llmCategoryModel = "gemini-3.5-flash-lite";
private String llmCategoryModel = "glm-5.3-flash";
/** 合规检查(is_conform/reason/category)使用的小模型。 */
private String llmConformModel = "gemini-3.5-flash-lite";
private String llmConformModel = "glm-5.3-flash";
/** 图片相似度对比(主图 vs 拼接图)使用的模型。 */
private String llmImageCompareModel = "gemini-3.7-flash";
private String llmImageCompareModel = "glm-5.3-flash";
private int llmMaxTokens = 64000;
private int llmConnectTimeoutMillis = 10000;
@@ -0,0 +1,84 @@
package com.nanri.aiimage.config;
import java.util.List;
import java.util.Map;
/**
* 业务日志结构化字段规范(task-178,spec 11 §2)。
*
* 规范字段集与缺失占位:业务日志统一携带 traceId/taskId/moduleType/stage/submissionId/
* chunkIndex/chunkTotal/jobId/result/errorType;某条日志缺失某字段用 {@link #MISSING} 占位,
* 保证字段名稳定、可检索。值写入前消毒(去除换行/控制字符、超长截断),防止日志注入与字段错乱。
*
* 不删任何现有日志;本类作为字段名与渲染的单一来源,供后续可观测性埋点(180+)统一使用。
*/
public final class StructuredLog {
/** 规范字段名(顺序即输出顺序,与 spec 11 §2 一致)。 */
public static final List<String> FIELD_NAMES = List.of(
"traceId", "taskId", "moduleType", "stage",
"submissionId", "chunkIndex", "chunkTotal",
"jobId", "result", "errorType");
/** 缺失字段占位符。 */
public static final String MISSING = "-";
/** 单值最大展示长度(字节),避免超长字段撑爆日志。 */
public static final int MAX_VALUE_LENGTH = 256;
private StructuredLog() {
}
/** 渲染单个字段值:null/空 → 占位;消毒换行与控制字符;截断到上限。 */
public static String field(Object value) {
if (value == null) {
return MISSING;
}
String text = String.valueOf(value);
if (text.isEmpty()) {
return MISSING;
}
text = text.replace('\r', ' ').replace('\n', ' ').replace('\t', ' ');
StringBuilder cleaned = new StringBuilder(text.length());
for (int i = 0; i < text.length(); i++) {
char c = text.charAt(i);
if (c < 0x20 && c != ' ') {
continue; // 丢弃其余控制字符
}
cleaned.append(c);
}
String result = cleaned.toString().trim();
if (result.length() > MAX_VALUE_LENGTH) {
result = result.substring(0, MAX_VALUE_LENGTH);
}
return result.isEmpty() ? MISSING : result;
}
/** 统一 Token 部分脱敏:≤10 位全打码,长 token 保留前6+后4,中间 ***(与既有 maskForLog 一致)。 */
public static String maskToken(String value) {
if (value == null || value.isBlank()) {
return "";
}
String text = value.trim();
if (text.length() <= 10) {
return "***";
}
return text.substring(0, 6) + "***" + text.substring(text.length() - 4);
}
/**
* 按规范字段集渲染 key=value 串:仅输出规范字段、顺序固定,缺失字段用占位符,
* 避免不同日志字段漂移。
*/
public static String format(Map<String, ?> fields) {
StringBuilder sb = new StringBuilder();
for (String name : FIELD_NAMES) {
if (sb.length() > 0) {
sb.append(' ');
}
Object value = fields == null ? null : fields.get(name);
sb.append(name).append('=').append(field(value));
}
return sb.toString();
}
}
@@ -12,22 +12,22 @@ import com.nanri.aiimage.modules.queryasin.service.QueryAsinTaskService;
import com.nanri.aiimage.modules.shopdatacrawl.service.ShopDataCrawlTaskService;
import com.nanri.aiimage.modules.shopmatch.service.ShopMatchTaskService;
import com.nanri.aiimage.modules.similarasin.service.SimilarAsinTaskService;
import com.nanri.aiimage.modules.task.service.AppearancePatentResultFileJobHandler;
import com.nanri.aiimage.modules.task.service.BrandResultFileJobHandler;
import com.nanri.aiimage.modules.task.service.CollectDataResultFileJobHandler;
import com.nanri.aiimage.modules.task.service.DeleteBrandResultFileJobHandler;
import com.nanri.aiimage.modules.task.service.PatrolDeleteResultFileJobHandler;
import com.nanri.aiimage.modules.task.service.PriceTrackResultFileJobHandler;
import com.nanri.aiimage.modules.task.service.ProductRiskResultFileJobHandler;
import com.nanri.aiimage.modules.task.service.PublishResultFileJobHandler;
import com.nanri.aiimage.modules.task.service.QueryAsinResultFileJobHandler;
import com.nanri.aiimage.modules.appearancepatent.service.AppearancePatentResultFileJobHandler;
import com.nanri.aiimage.modules.brand.service.BrandResultFileJobHandler;
import com.nanri.aiimage.modules.collectdata.service.CollectDataResultFileJobHandler;
import com.nanri.aiimage.modules.deletebrand.service.DeleteBrandResultFileJobHandler;
import com.nanri.aiimage.modules.patroldelete.service.PatrolDeleteResultFileJobHandler;
import com.nanri.aiimage.modules.pricetrack.service.PriceTrackResultFileJobHandler;
import com.nanri.aiimage.modules.productrisk.service.ProductRiskResultFileJobHandler;
import com.nanri.aiimage.modules.publish.service.PublishResultFileJobHandler;
import com.nanri.aiimage.modules.queryasin.service.QueryAsinResultFileJobHandler;
import com.nanri.aiimage.modules.task.service.ResultFileJobHandler;
import com.nanri.aiimage.modules.task.service.ResultFileJobHandlerRegistry;
import com.nanri.aiimage.modules.task.service.ShopDataCrawlResultFileJobHandler;
import com.nanri.aiimage.modules.task.service.ShopMatchResultFileJobHandler;
import com.nanri.aiimage.modules.task.service.SimilarAsinResultFileJobHandler;
import com.nanri.aiimage.modules.shopdatacrawl.service.ShopDataCrawlResultFileJobHandler;
import com.nanri.aiimage.modules.shopmatch.service.ShopMatchResultFileJobHandler;
import com.nanri.aiimage.modules.similarasin.service.SimilarAsinResultFileJobHandler;
import com.nanri.aiimage.modules.task.service.TaskResultPayloadService;
import com.nanri.aiimage.modules.task.service.WithdrawResultFileJobHandler;
import com.nanri.aiimage.modules.withdraw.service.WithdrawResultFileJobHandler;
import com.nanri.aiimage.modules.withdraw.service.WithdrawTaskService;
import io.micrometer.core.instrument.MeterRegistry;
import org.springframework.beans.factory.ObjectProvider;
@@ -0,0 +1,50 @@
package com.nanri.aiimage.config;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
/**
* 紫鸟客户端统一配置解析(task-170)。
*
* 真实基线(已核实):连接超时来自共享 HttpClientHttpClientPool connect=10s
* 不读 aiimage.ziniao.connect 字段);读取超时由 ZiniaoClientImpl 取
* aiimage.ziniao.read-timeout-seconds*1000(生产默认 15s)经 requestFactory 生效;
* 当前无 call timeout / 无重试。
*
* 语义:read 优先取模块级现有配置(保证接入切点时行为不变),未配置时回退命名空间
* aiimage.http-client.*connect/call/retry 取命名空间(未来由命名空间统一治理)。
* 仅提供稳定访问面,不改动 ZiniaoClientImpl 调用点。
*/
@Component
@RequiredArgsConstructor
public class ZiniaoHttpConfigResolver {
private final HttpClientProperties httpClientProperties;
private final ZiniaoProperties ziniaoProperties;
/** 连接超时:共享 HttpClient 决定,命名空间默认 10s 与 HttpClientPool 现状一致。 */
public long connectTimeoutMillis() {
return httpClientProperties.effectiveConnectTimeoutMillis();
}
/** 读取超时:模块级秒数优先(现状 15s);未配置时回退命名空间。 */
public long readTimeoutMillis() {
int moduleSeconds = ziniaoProperties.getReadTimeoutSeconds();
if (moduleSeconds > 0) {
return moduleSeconds * 1_000L;
}
return httpClientProperties.effectiveReadTimeoutMillis();
}
public long callTimeoutMillis() {
return httpClientProperties.effectiveCallTimeoutMillis();
}
public int maxRetries() {
return httpClientProperties.effectiveMaxRetries();
}
public long baseRetryDelayMillis() {
return httpClientProperties.getBaseRetryDelayMillis();
}
}
@@ -0,0 +1,171 @@
package com.nanri.aiimage.metrics;
import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.DistributionSummary;
import io.micrometer.core.instrument.Gauge;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.Tag;
import io.micrometer.core.instrument.Timer;
import lombok.extern.slf4j.Slf4j;
import java.util.List;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicLong;
/**
* 任务/结果文件/上传/临时磁盘统一指标 recordermodule 11spec §1 标准名)。
*
* 指标名与标签按 spec 11 §1 冻结:aiimage.task.* / aiimage.file-job.* / aiimage.upload.* /
* aiimage.tmpdisk.*,标签 moduleType/instanceId/stagemoduleType/instanceId null 一律归一
* unknown;不记录用户级数据。Micrometer 注册表可选:未配置时事件仅降级 debug 日志(不引入
* 新监控组件;生产接线按 spec 待监控基础设施就绪后挂载,本类为唯一接线面)。
*
* 供各业务 Service 状态流转 / TaskResultFileJobWorker / TaskHeartbeatService / 上传 / 临时磁盘
* 巡检调用;instanceId 由 InstanceMetadata 解析(双实例 server-110/server-121 区分)。
*/
@Slf4j
public class TaskObservabilityMetrics {
private static final String UNKNOWN = "unknown";
private final String instanceId;
private final MeterRegistry registry;
private final ConcurrentMap<String, AtomicLong> runningValues = new ConcurrentHashMap<>();
private final ConcurrentMap<String, AtomicLong> heartbeatAgeValues = new ConcurrentHashMap<>();
private final ConcurrentMap<String, AtomicLong> capacityValues = new ConcurrentHashMap<>();
private final ConcurrentMap<String, AtomicLong> usedValues = new ConcurrentHashMap<>();
public TaskObservabilityMetrics(String instanceId, MeterRegistry registry) {
this.instanceId = instanceId == null || instanceId.isBlank() ? UNKNOWN : instanceId.trim();
this.registry = registry;
}
// ---- 任务生命周期 task.* ----
/** 任务创建计数(moduleType/instanceId 标签)。 */
public void taskCreated(String moduleType) {
bump("aiimage.task.created", moduleType);
}
/** 运行中计数增量:开始 +1 / 结束 -1,底稿是 gaugenever 负)。 */
public void taskRunningDelta(String moduleType, int delta) {
AtomicLong value = runningValues.computeIfAbsent(moduleType, k -> new AtomicLong());
long next = Math.max(0, value.get() + delta);
value.set(next);
if (registry != null) {
Gauge.builder("aiimage.task.running", value, AtomicLong::get)
.tags(baseTags(moduleType))
.register(registry);
}
}
/** 终态计数:terminal ∈ success/failed/cancelled。 */
public void taskTerminal(String moduleType, String terminal) {
bump("aiimage.task." + terminal, moduleType);
}
/** 任务耗时(毫秒):Timer 记录,无注册表时降级 debug 日志。 */
public void taskDuration(String moduleType, long durationMs) {
recordTimer("aiimage.task.duration", moduleType, durationMs, "任务耗时");
}
/** 心跳年龄 gauge(毫秒):由 TaskHeartbeatService/巡检把"最后心跳距今"写入。 */
public void taskHeartbeatAge(String moduleType, long ageMillis) {
AtomicLong value = heartbeatAgeValues.computeIfAbsent(moduleType, k -> new AtomicLong());
value.set(Math.max(0, ageMillis));
if (registry != null) {
Gauge.builder("aiimage.task.heartbeat.age", value, AtomicLong::get)
.tags(baseTags(moduleType))
.register(registry);
}
}
// ---- 结果文件 file-job.* ----
/** file-job 阶段计数:state ∈ pending/running/success/failed/retry。 */
public void fileJobState(String moduleType, String state) {
bump("aiimage.file-job." + state, moduleType);
}
public void fileJobDuration(String moduleType, long durationMs) {
recordTimer("aiimage.file-job.duration", moduleType, durationMs, "文件任务耗时");
}
// ---- 上传 upload.* ----
/** 上传统计:耗时 Timer + 大小 summary + 结果计数。 */
public void upload(String moduleType, long bytes, long durationMs, boolean success) {
if (registry != null) {
Timer timer = Timer.builder("aiimage.upload.duration")
.tags(baseTags(moduleType)).register(registry);
timer.record(durationMs, TimeUnit.MILLISECONDS);
DistributionSummary size = DistributionSummary.builder("aiimage.upload.size")
.tags(baseTags(moduleType)).register(registry);
size.record(bytes);
} else {
log.debug("[metrics][upload] 无监控栈,降级记录 moduleType={} bytes={} durationMs={} success={}",
moduleType, bytes, durationMs, success);
}
bump("aiimage.upload.result", moduleType);
bump("aiimage.upload.result." + (success ? "success" : "failure"), moduleType);
}
// ---- 临时磁盘 tmpdisk.* ----
/** 临时磁盘容量/占用 gauge(标签 path)。 */
public void disk(String path, long capacityBytes, long usedBytes) {
AtomicLong cap = capacityValues.computeIfAbsent(path, k -> new AtomicLong());
AtomicLong used = usedValues.computeIfAbsent(path, k -> new AtomicLong());
cap.set(capacityBytes);
used.set(usedBytes);
if (registry != null) {
Gauge.builder("aiimage.tmpdisk.capacity", cap, AtomicLong::get)
.tags(pathTags(path)).register(registry);
Gauge.builder("aiimage.tmpdisk.used", used, AtomicLong::get)
.tags(pathTags(path)).register(registry);
}
}
// ---- helpers ----
private void bump(String name, String moduleType) {
if (registry == null) {
log.debug("[metrics] 无监控栈,降级记录 name={} moduleType={} instanceId={}",
name, moduleType, instanceId);
return;
}
Counter.builder(name).tags(baseTags(moduleType)).register(registry).increment();
}
private void recordTimer(String name, String moduleType, long durationMs, String label) {
long safe = Math.max(0, durationMs);
if (registry != null) {
Timer.builder(name).tags(baseTags(moduleType)).register(registry)
.record(safe, TimeUnit.MILLISECONDS);
} else {
log.debug("[metrics] 无监控栈,{}降级 moduleType={} durationMs={} instanceId={}",
label, moduleType, safe, instanceId);
}
}
private List<Tag> baseTags(String moduleType) {
return List.of(Tag.of("moduleType", normalize(moduleType)),
Tag.of("instanceId", instanceId));
}
private List<Tag> pathTags(String path) {
return List.of(Tag.of("path", normalize(path)));
}
private static String normalize(String value) {
return value == null || value.isBlank() ? UNKNOWN : value.trim();
}
public String instanceId() {
return instanceId;
}
}
@@ -1,4 +1,5 @@
package com.nanri.aiimage.modules.task.service;
package com.nanri.aiimage.modules.appearancepatent.service;
import com.nanri.aiimage.modules.task.service.ResultFileJobHandler;
import com.nanri.aiimage.modules.appearancepatent.service.AppearancePatentTaskService;
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
@@ -1,4 +1,6 @@
package com.nanri.aiimage.modules.task.service;
package com.nanri.aiimage.modules.brand.service;
import com.nanri.aiimage.modules.task.service.TaskResultPayloadService;
import com.nanri.aiimage.modules.task.service.ResultFileJobHandler;
import com.nanri.aiimage.modules.brand.service.BrandTaskService;
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
@@ -1,4 +1,5 @@
package com.nanri.aiimage.modules.task.service;
package com.nanri.aiimage.modules.collectdata.service;
import com.nanri.aiimage.modules.task.service.ResultFileJobHandler;
import com.nanri.aiimage.modules.collectdata.service.CollectDataService;
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
@@ -1,4 +1,5 @@
package com.nanri.aiimage.modules.task.service;
package com.nanri.aiimage.modules.deletebrand.service;
import com.nanri.aiimage.modules.task.service.ResultFileJobHandler;
import com.nanri.aiimage.modules.deletebrand.service.DeleteBrandRunService;
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
@@ -787,13 +787,7 @@ public class ImageVideoCozeService {
}
private String maskForLog(String value) {
if (!hasText(value)) {
return "";
}
String text = value.trim();
if (text.length() <= 10) {
return "***";
}
return text.substring(0, 6) + "***" + text.substring(text.length() - 4);
// 统一走 StructuredLog.maskTokentask-190Token 部分脱敏规则单一来源)
return com.nanri.aiimage.config.StructuredLog.maskToken(value);
}
}
@@ -1,4 +1,6 @@
package com.nanri.aiimage.modules.task.service;
package com.nanri.aiimage.modules.patroldelete.service;
import com.nanri.aiimage.modules.task.service.TaskResultPayloadService;
import com.nanri.aiimage.modules.task.service.ResultFileJobHandler;
import com.nanri.aiimage.modules.patroldelete.service.PatrolDeleteTaskService;
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
@@ -1,4 +1,6 @@
package com.nanri.aiimage.modules.task.service;
package com.nanri.aiimage.modules.pricetrack.service;
import com.nanri.aiimage.modules.task.service.TaskResultPayloadService;
import com.nanri.aiimage.modules.task.service.ResultFileJobHandler;
import com.nanri.aiimage.modules.pricetrack.service.PriceTrackTaskService;
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
@@ -1,4 +1,6 @@
package com.nanri.aiimage.modules.task.service;
package com.nanri.aiimage.modules.productrisk.service;
import com.nanri.aiimage.modules.task.service.TaskResultPayloadService;
import com.nanri.aiimage.modules.task.service.ResultFileJobHandler;
import com.nanri.aiimage.modules.productrisk.service.ProductRiskTaskService;
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
@@ -1,4 +1,5 @@
package com.nanri.aiimage.modules.task.service;
package com.nanri.aiimage.modules.publish.service;
import com.nanri.aiimage.modules.task.service.ResultFileJobHandler;
import com.nanri.aiimage.modules.publish.service.PublishTaskService;
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
@@ -1,4 +1,6 @@
package com.nanri.aiimage.modules.task.service;
package com.nanri.aiimage.modules.queryasin.service;
import com.nanri.aiimage.modules.task.service.TaskResultPayloadService;
import com.nanri.aiimage.modules.task.service.ResultFileJobHandler;
import com.nanri.aiimage.modules.queryasin.service.QueryAsinTaskService;
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
@@ -1,4 +1,6 @@
package com.nanri.aiimage.modules.task.service;
package com.nanri.aiimage.modules.shopdatacrawl.service;
import com.nanri.aiimage.modules.task.service.TaskResultPayloadService;
import com.nanri.aiimage.modules.task.service.ResultFileJobHandler;
import com.nanri.aiimage.modules.shopdatacrawl.service.ShopDataCrawlTaskService;
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
@@ -183,13 +183,13 @@ public class ShopDataDuplicateCheckQueryService {
List<DuplicateItem> cropped = cropItems(view.payload().items(), visibleKeys);
List<DuplicateItem> matched = filterItems(cropped, monitor, filters);
List<List<String>> rows = new ArrayList<>();
rows.add(List.of("ASIN", "店铺数", "店铺", "分组", "站点", "上架时间", "价格", "品牌"));
rows.add(List.of("ASIN", "店铺数", "店铺", "分组", "国家", "上架时间", "价格", "品牌"));
List<Object[]> flat = new ArrayList<>();
for (DuplicateItem item : matched) {
String shopCount = String.valueOf(item.shopCount());
for (DuplicateOccurrence occ : item.occurrences()) {
flat.add(new Object[]{item.asin(), shopCount, occ.shopName(), occ.groupName(),
effectiveSite(occ), safe(occ.date()), safe(occ.price()), safe(occ.brand())});
effectiveCountryCn(occ), safe(occ.date()), safe(occ.price()), safe(occ.brand())});
}
}
flat.sort(flatExportComparator());
@@ -358,9 +358,18 @@ public class ShopDataDuplicateCheckQueryService {
source == null ? "" : source);
}
private String effectiveSite(DuplicateOccurrence occ) {
/** 站点代码 → 中文国家名(导出「国家」列,与页面 DUP_SITE_LABELS 一致),未识别回退原值。 */
private static final Map<String, String> SITE_CN_LABELS = Map.of(
"DE", "德国", "UK", "英国", "FR", "法国", "IT", "意大利", "ES", "西班牙");
private static String siteCnLabel(String code) {
String key = safe(code).trim().toUpperCase(Locale.ROOT);
return SITE_CN_LABELS.containsKey(key) ? SITE_CN_LABELS.get(key) : safe(code);
}
private String effectiveCountryCn(DuplicateOccurrence occ) {
if (!safe(occ.country()).isEmpty()) {
return occ.country().toUpperCase(Locale.ROOT);
return siteCnLabel(occ.country());
}
List<String> codes = occ.countryCodes() == null ? List.of() : occ.countryCodes();
if (codes.isEmpty()) {
@@ -371,7 +380,7 @@ public class ShopDataDuplicateCheckQueryService {
if (sb.length() > 0) {
sb.append('、');
}
sb.append(safe(code).toUpperCase(Locale.ROOT));
sb.append(siteCnLabel(code));
}
return sb.toString();
}
@@ -1,4 +1,6 @@
package com.nanri.aiimage.modules.task.service;
package com.nanri.aiimage.modules.shopmatch.service;
import com.nanri.aiimage.modules.task.service.TaskResultPayloadService;
import com.nanri.aiimage.modules.task.service.ResultFileJobHandler;
import com.nanri.aiimage.modules.shopmatch.service.ShopMatchTaskService;
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
@@ -0,0 +1,31 @@
package com.nanri.aiimage.modules.similarasin.model;
/**
* similarasin 结果提交语义的纯计算(task-207 提取,防业务漂移)。
*
* done=true / error 非空 => 本批为终态回调(触发读元数据/终态处理);
* chunkIndex/chunkTotal 缺省归一(0/1)。纯函数,便于离线单测。
*/
public final class SimilarAsinSubmitResultSemantics {
private SimilarAsinSubmitResultSemantics() {
}
/** 是否终态回调:done=true 或 error 非空。 */
public static boolean isTerminalRequest(boolean done, String error) {
if (done) {
return true;
}
return error != null && !error.isBlank();
}
/** chunkIndex 缺省(含 <=0 之外的 null)归一为 0(与既有 submit 逻辑一致)。 */
public static int chunkIndex(Integer chunkIndex) {
return chunkIndex == null ? 0 : chunkIndex;
}
/** chunkTotal 缺省归一为 1。 */
public static int chunkTotal(Integer chunkTotal) {
return chunkTotal == null ? 1 : chunkTotal;
}
}
@@ -1,4 +1,5 @@
package com.nanri.aiimage.modules.task.service;
package com.nanri.aiimage.modules.similarasin.service;
import com.nanri.aiimage.modules.task.service.ResultFileJobHandler;
import com.nanri.aiimage.modules.similarasin.service.SimilarAsinTaskService;
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
@@ -1017,12 +1017,15 @@ public class SimilarAsinTaskService {
}
ensureTaskOwnedByCurrentInstance(task, "submit result");
int chunkIndex = request.getChunkIndex() == null ? 0 : request.getChunkIndex();
int chunkTotal = request.getChunkTotal() == null ? 1 : request.getChunkTotal();
int chunkIndex = com.nanri.aiimage.modules.similarasin.model.SimilarAsinSubmitResultSemantics
.chunkIndex(request.getChunkIndex());
int chunkTotal = com.nanri.aiimage.modules.similarasin.model.SimilarAsinSubmitResultSemantics
.chunkTotal(request.getChunkTotal());
boolean done = Boolean.TRUE.equals(request.getDone());
String scopeKey = firstNonBlank(request.getSubmissionId(), "task:" + taskId);
String scopeHash = DigestUtil.sha256Hex(scopeKey);
boolean terminalCallback = done || request.getError() != null && !request.getError().isBlank();
boolean terminalCallback = com.nanri.aiimage.modules.similarasin.model.SimilarAsinSubmitResultSemantics
.isTerminalRequest(done, request.getError());
SubmittedTaskMetadata taskMetadata = terminalCallback
? readSubmittedTaskMetadata(task)
: null;
@@ -0,0 +1,108 @@
package com.nanri.aiimage.modules.task.service;
import com.nanri.aiimage.common.service.DistributedJobLockService;
import com.nanri.aiimage.config.InspectionProperties;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.io.ClassPathResource;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.List;
import java.util.Map;
/**
* 巡检只读 SQL 报表任务(task-200)。
*
* 与 InspectionScheduler 同开关/调度风格:aiimage.inspection.enabled 默认 false;启用后按
* aiimage.inspection.cron 执行 6 张只读报表 SQLresources/inspection/*.sqltask-194..199),
* 逐条输出行数与耗时;单条失败记录日志不阻断其余;分布式锁防双实例重复;只 query 不改数据。
*/
@Slf4j
@Service
public class InspectionSqlReportTask {
private static final Duration REPORT_LOCK_TTL = Duration.ofMinutes(10);
/** 与巡检 SQL 文件一一对应(顺序执行)。 */
static final List<String> REPORT_FILES = List.of(
"01_orphan_job.sql", "02_orphan_result.sql", "03_task_missing_result.sql",
"04_result_missing_file.sql", "05_terminal_task_active_job.sql",
"06_over_retention_active_job.sql");
private final InspectionProperties inspectionProperties;
private final DistributedJobLockService distributedJobLockService;
private final ObjectProvider<JdbcTemplate> jdbcTemplateProvider;
/** 测试/无锁构造。 */
public InspectionSqlReportTask(InspectionProperties inspectionProperties,
ObjectProvider<JdbcTemplate> jdbcTemplateProvider) {
this(inspectionProperties, null, jdbcTemplateProvider);
}
@Autowired
public InspectionSqlReportTask(InspectionProperties inspectionProperties,
DistributedJobLockService distributedJobLockService,
ObjectProvider<JdbcTemplate> jdbcTemplateProvider) {
this.inspectionProperties = inspectionProperties;
this.distributedJobLockService = distributedJobLockService;
this.jdbcTemplateProvider = jdbcTemplateProvider;
}
@Scheduled(cron = "${aiimage.inspection.cron:0 0 3 * * *}")
public void runIfEnabled() {
if (!inspectionProperties.isEnabled()) {
return;
}
if (distributedJobLockService == null) {
return;
}
DistributedJobLockService.LockHandle lockHandle =
distributedJobLockService.tryLock("inspection-sql-report", REPORT_LOCK_TTL);
if (lockHandle == null) {
log.info("[inspection-sql] skip because another instance holds the report lock");
return;
}
try (lockHandle) {
runAll();
}
}
/** 执行全部只读报表 SQL;返回成功执行的报表数(disabled/无 JDBC 返回 0,失败单条不阻断)。 */
public int runAll() {
if (!inspectionProperties.isEnabled()) {
return 0;
}
JdbcTemplate jdbcTemplate = jdbcTemplateProvider.getIfAvailable();
if (jdbcTemplate == null) {
log.warn("[inspection-sql] enabled but no JdbcTemplate bean, skip reports");
return 0;
}
int ok = 0;
for (String file : REPORT_FILES) {
long startedAt = System.nanoTime();
try {
String sql = loadSql(file);
List<Map<String, Object>> rows = jdbcTemplate.queryForList(sql);
long elapsedMs = (System.nanoTime() - startedAt) / 1_000_000;
log.info("[inspection-sql] report {} rows={} elapsedMs={}",
file, rows.size(), elapsedMs);
ok++;
} catch (Exception ex) {
long elapsedMs = (System.nanoTime() - startedAt) / 1_000_000;
log.error("[inspection-sql] report {} failed elapsedMs={} cause={}",
file, elapsedMs, ex.toString());
}
}
return ok;
}
private String loadSql(String file) throws java.io.IOException {
ClassPathResource resource = new ClassPathResource("inspection/" + file);
return new String(resource.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
}
}
@@ -2,13 +2,8 @@ package com.nanri.aiimage.modules.task.service;
import com.nanri.aiimage.common.exception.BusinessException;
import com.nanri.aiimage.common.exception.TaskOwnerMismatchException;
import com.nanri.aiimage.modules.appearancepatent.service.AppearancePatentTaskService;
import com.nanri.aiimage.config.InstanceMetadata;
import com.nanri.aiimage.modules.brand.service.BrandTaskService;
import com.nanri.aiimage.modules.deletebrand.service.DeleteBrandRunService;
import com.nanri.aiimage.modules.publish.service.PublishTaskService;
import com.nanri.aiimage.modules.shopdatacrawl.service.ShopDataCrawlTaskService;
import com.nanri.aiimage.modules.similarasin.service.SimilarAsinTaskService;
import com.nanri.aiimage.modules.task.mapper.FileResultMapper;
import com.nanri.aiimage.modules.task.model.entity.FileResultEntity;
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
@@ -117,7 +112,7 @@ public class TaskResultFileJobWorker {
// 重试耗尽后把归属机上跑得好好的任务判成失败。
// job 不会因此卡死:归属实例的 runPendingJobs 每 15 秒扫一次自己 owner 的
// PENDING/FAILED jobenqueueAssembleResult / stuck 扫描也会补发 dispatch。
log.info("[task-file-job] skip owner-scoped job because owner is another instance jobId={} taskId={} moduleType={} status={} owner={} current={}",
log.info("[task-file-job] skip owner-scoped job because owner is another instance jobId={} taskId={} moduleType={} status={} owner={} current={} stage=SKIP_OWNER",
job.getId(), job.getTaskId(), job.getModuleType(), job.getStatus(),
ownerFromScopeKey(job.getScopeKey()), currentInstanceId());
return;
@@ -160,7 +155,7 @@ public class TaskResultFileJobWorker {
try {
taskFileJobService.touchRunning(job.getId());
} catch (Exception ex) {
log.warn("[task-file-job] heartbeat failed jobId={} taskId={} moduleType={} msg={}",
log.warn("[task-file-job] heartbeat failed jobId={} taskId={} moduleType={} msg={} stage=HEARTBEAT",
job.getId(), job.getTaskId(), job.getModuleType(), ex.getMessage());
}
}, interval, interval, TimeUnit.MILLISECONDS);
@@ -194,7 +189,7 @@ public class TaskResultFileJobWorker {
taskDistributedLockService.acquire(job.getModuleType(), job.getTaskId(), TaskDistributedLockService.DEFAULT_WAIT_MILLIS);
if (lockHandle == null) {
taskFileJobService.requeue(job.getId(), "Task is busy, waiting for previous task operation");
log.info("[task-file-job] process requeued because task lock is busy jobId={} taskId={} moduleType={} resultId={}",
log.info("[task-file-job] process requeued because task lock is busy jobId={} taskId={} moduleType={} resultId={} stage=REQUEUE",
job.getId(), job.getTaskId(), job.getModuleType(), job.getResultId());
return;
}
@@ -209,13 +204,13 @@ public class TaskResultFileJobWorker {
if (!completed) {
if (isOwnerScopedJob(job)) {
taskFileJobService.touchRunning(job.getId());
log.info("[task-file-job] process waiting for async llm result jobId={} taskId={} moduleType={} resultId={} elapsedMs={}",
log.info("[task-file-job] process waiting for async llm result jobId={} taskId={} moduleType={} resultId={} elapsedMs={} stage=WAIT_LLM",
job.getId(), job.getTaskId(), job.getModuleType(), job.getResultId(),
System.currentTimeMillis() - startedAt);
return;
}
taskFileJobService.deferRunning(job.getId(), "Waiting for LLM/file assembly to continue");
log.info("[task-file-job] process deferred jobId={} taskId={} moduleType={} resultId={} elapsedMs={}",
log.info("[task-file-job] process deferred jobId={} taskId={} moduleType={} resultId={} elapsedMs={} stage=DEFER",
job.getId(), job.getTaskId(), job.getModuleType(), job.getResultId(),
System.currentTimeMillis() - startedAt);
return;
@@ -224,7 +219,7 @@ public class TaskResultFileJobWorker {
taskFileJobService.markSuccess(job, resultFileUrl);
cleanupAfterSuccess(job);
finalizeWithdraw = "WITHDRAW".equals(job.getModuleType());
log.info("[task-file-job] process success jobId={} taskId={} moduleType={} resultId={} elapsedMs={} resultFileUrl={}",
log.info("[task-file-job] process success jobId={} taskId={} moduleType={} resultId={} elapsedMs={} resultFileUrl={} stage=SUCCESS",
job.getId(), job.getTaskId(), job.getModuleType(), job.getResultId(),
System.currentTimeMillis() - startedAt, resultFileUrl);
}
@@ -248,13 +243,13 @@ public class TaskResultFileJobWorker {
// 孤儿 jobfileTask/fileResult 已被删除却残留 task_file_job 行,重试 5 次也救不回来,
// 直接拉满 retryCount 让 worker 跳过,避免每 15 秒刷一次 task not found 警告日志。
if (isOrphanJobFailure(ex, message)) {
log.warn("[task-file-job] process aborted because owning task/result no longer exists jobId={} taskId={} moduleType={} resultId={} msg={}",
job.getId(), job.getTaskId(), job.getModuleType(), job.getResultId(), message);
log.warn("[task-file-job] process aborted because owning task/result no longer exists jobId={} taskId={} moduleType={} resultId={} msg={} stage=ORPHAN errorType={}",
job.getId(), job.getTaskId(), job.getModuleType(), job.getResultId(), message, ex.getClass().getSimpleName());
taskFileJobService.markFailedPermanent(job, message);
return;
}
log.warn("[task-file-job] process failed jobId={} taskId={} moduleType={} resultId={} msg={}",
job.getId(), job.getTaskId(), job.getModuleType(), job.getResultId(), message);
log.warn("[task-file-job] process failed jobId={} taskId={} moduleType={} resultId={} msg={} stage=FAILED errorType={}",
job.getId(), job.getTaskId(), job.getModuleType(), job.getResultId(), message, ex.getClass().getSimpleName());
taskFileJobService.markFailed(job, message);
if (taskFileJobService.isRetryExhausted(job.getId())) {
finalizeRetryExhausted(job, message);
@@ -266,7 +261,7 @@ public class TaskResultFileJobWorker {
try {
notifyRetryExhausted(job, message);
if (!taskFileJobService.markFailureFinalized(job.getId(), message)) {
log.warn("[task-file-job] exhausted job terminal callback was already finalized or claim was lost jobId={} taskId={} moduleType={}",
log.warn("[task-file-job] exhausted job terminal callback was already finalized or claim was lost jobId={} taskId={} moduleType={} stage=FINALIZE",
job.getId(), job.getTaskId(), job.getModuleType());
}
} catch (Exception ex) {
@@ -1,4 +1,6 @@
package com.nanri.aiimage.modules.task.service;
package com.nanri.aiimage.modules.withdraw.service;
import com.nanri.aiimage.modules.task.service.TaskResultPayloadService;
import com.nanri.aiimage.modules.task.service.ResultFileJobHandler;
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
import com.nanri.aiimage.modules.withdraw.service.WithdrawTaskService;
@@ -0,0 +1,17 @@
-- =============================================================
-- 巡检 01:孤儿 task_file_jobjob 无对应任务/结果)— 只读报表(task-194)
-- 对应 Java 巡检:OrphanJobInspectormodule-09);仅副本库只读执行,无副作用。
-- 结果按 updated_at 升序;每批建议 limit <= 500。
-- =============================================================
SELECT j.id AS job_id,
j.task_id,
j.result_id,
j.module_type,
j.status,
j.updated_at
FROM biz_task_file_job j
WHERE NOT EXISTS (SELECT 1 FROM biz_file_task t WHERE t.id = j.task_id)
AND (j.result_id IS NULL
OR NOT EXISTS (SELECT 1 FROM biz_file_result r WHERE r.id = j.result_id))
ORDER BY j.updated_at ASC
LIMIT 200;
@@ -0,0 +1,16 @@
-- =============================================================
-- 巡检 02:孤儿结果记录(biz_file_result 无对应任务)— 只读报表(task-195)
-- 结果(payload)行丢失父任务即为孤儿;仅副本库只读执行,无副作用。
-- 结果按 updated_at 升序;每批建议 limit <= 500。
-- =============================================================
SELECT r.id,
r.task_id,
r.module_type,
r.source_file_url,
r.result_file_url,
r.user_id,
r.updated_at
FROM biz_file_result r
WHERE NOT EXISTS (SELECT 1 FROM biz_file_task t WHERE t.id = r.task_id)
ORDER BY r.updated_at ASC
LIMIT 200;
@@ -0,0 +1,14 @@
-- =============================================================
-- 巡检 03:终态任务缺结果(SUCCESS/FAILED 无任何 biz_file_result)— 只读报表(task-196
-- 对应 Java 巡检:TaskResultMissingInspectormodule-09);仅副本库只读执行。
-- 结果按 updated_at 升序;每批建议 limit <= 500。
-- =============================================================
SELECT t.id,
t.module_type,
t.status,
t.updated_at
FROM biz_file_task t
WHERE t.status IN ('SUCCESS', 'FAILED')
AND NOT EXISTS (SELECT 1 FROM biz_file_result r WHERE r.task_id = t.id)
ORDER BY t.updated_at ASC
LIMIT 200;
@@ -0,0 +1,17 @@
-- =============================================================
-- 巡检 04:结果缺文件候选(result_file_url 非空的结果行)— 只读报表(task-197)
-- 对象是否存在由 Java ResultFileMissingInspector + OSS 反查确认;
-- 本 SQL 只负责在副本库给出待反查的候选清单(resultId/taskId/moduleType/url)。
-- 仅只读执行,无副作用。
-- =============================================================
SELECT r.id AS result_id,
r.task_id,
r.module_type,
r.result_file_url,
r.updated_at
FROM biz_file_result r
JOIN biz_file_task t ON t.id = r.task_id
WHERE r.result_file_url IS NOT NULL
AND r.result_file_url <> ''
ORDER BY r.updated_at DESC
LIMIT 200;
@@ -0,0 +1,17 @@
-- =============================================================
-- 巡检 05:终态任务仍有活跃 jobSUCCESS/FAILED 任务下存在非终态 job)— 只读报表(task-198
-- 对应 Java 巡检:CompletedTaskActiveJobInspectormodule-09);仅只读执行。
-- 结果按 job.updated_at 降序;每批建议 limit <= 500。
-- =============================================================
SELECT t.id AS task_id,
t.module_type,
j.id AS job_id,
j.job_type,
j.status AS job_status,
j.updated_at
FROM biz_file_task t
JOIN biz_task_file_job j ON j.task_id = t.id
WHERE t.status IN ('SUCCESS', 'FAILED')
AND j.status NOT IN ('SUCCESS', 'FAILED')
ORDER BY j.updated_at DESC
LIMIT 200;
@@ -0,0 +1,18 @@
-- =============================================================
-- 巡检 06:超保留期仍活跃的 jobRUNNING 且 updated_at 超 24h)— 只读报表(task-199
-- 疑似滞留/心跳丢失的 job;实际清理走 stuck 扫描(module-09),本 SQL 仅给出候选。
-- 保留期阈值按需调整 INTERVAL;仅副本库只读执行,无副作用。
-- =============================================================
SELECT j.id AS job_id,
j.task_id,
j.module_type,
j.status,
j.updated_at,
t.module_type AS task_module_type,
TIMESTAMPDIFF(MINUTE, j.updated_at, NOW()) AS age_minutes
FROM biz_task_file_job j
JOIN biz_file_task t ON t.id = j.task_id
WHERE j.status = 'RUNNING'
AND j.updated_at < DATE_SUB(NOW(), INTERVAL 24 HOUR)
ORDER BY j.updated_at ASC
LIMIT 200;
@@ -2084,10 +2084,10 @@
return isNaN(t) ? -1 : t;
}
// 「查看明细」按钮点击开抽屉(动态元素,事件委托)
// 「查看明细」按钮点击开抽屉(动态元素,事件委托),并把被点按钮作为抽屉的垂直锚点
function handleDuplicateDetailClick(event) {
var button = event.target.closest('[data-open-asin-detail]');
if (button) openShopDataDuplicateDrawer(button.dataset.openAsinDetail);
if (button) openShopDataDuplicateDrawer(button.dataset.openAsinDetail, button);
}
// 站点徽章:中文国家(未识别回退原值)
@@ -2197,21 +2197,26 @@
}
// 图3 样式抽屉:ASIN + 店在售徽章 / 品牌行 / 彩色徽章组 / 按时间降序明细 + 次数列
// 块内定位:抽屉贴到「撞款详情」区块顶部(anchorRect),而不是浏览器左侧顶部;
// 区块滚出视口时钳制视口内可见区域(顶部矩阵点 ASIN 打开也要能看到抽屉)
// 块内定位:抽屉横向贴到「撞款详情」区块,纵向以触发元素(被点的 ASIN/「查看明细」按钮)为锚点、
// 钳制视口内,保证弹窗出现在点击处附近而非页面/滚动区顶部(顶部弹出会被当前滚动位置盖住看不见)。
var dupDrawerTriggerEl = null;
function positionDupDrawer() {
var mask = document.getElementById('dupCheckDrawerMask');
var aside = mask ? mask.querySelector('.drawer') : null;
var anchor = document.getElementById('dupCheckDetailBlock');
if (!aside || !anchor) return;
var rect = anchor.getBoundingClientRect();
var block = document.getElementById('dupCheckDetailBlock');
if (!aside || !block) return;
var bodyHeight = Math.max(document.documentElement.clientHeight, window.innerHeight);
var padTop = 24, padRight = 24, padBottom = 24;
var maxHeight = Math.max(260, bodyHeight - padTop - padBottom);
var left = Math.max(12, Math.min(rect.left + 12, Math.max(12, bodyWidth() - 860)));
var blockRect = block.getBoundingClientRect();
var left = Math.max(12, Math.min(blockRect.left + 12, Math.max(12, bodyWidth() - 860)));
// 垂直锚点优先取触发元素;未传入或已脱离文档时回退到「撞款详情」区块顶部
var anchor = (dupDrawerTriggerEl && dupDrawerTriggerEl.isConnected) ? dupDrawerTriggerEl : block;
var rect = anchor.getBoundingClientRect();
var top = Math.max(padTop, rect.top - padTop);
// 区块整体在视口外(下方/上方)时回退到视口顶部,避免抽屉出现在视口外
if (rect.top >= bodyHeight - 120 || rect.bottom <= padTop) top = padTop;
// 点击处整体已滚出视口下方时,改放到视口底部上方留出可读高度
if (rect.top >= bodyHeight - padBottom) top = Math.max(padTop, bodyHeight - 300);
// 顶部再钳制一次,保证抽屉头部可见
top = Math.min(top, Math.max(padTop, bodyHeight - 260));
aside.style.left = left + 'px';
@@ -2249,8 +2254,9 @@
return mask;
}
function openShopDataDuplicateDrawer(asin) {
function openShopDataDuplicateDrawer(asin, triggerEl) {
mountDupDrawerMaskToBody();
dupDrawerTriggerEl = triggerEl || null;
var item = null;
(shopDataDuplicateDetailItems || []).forEach(function (it) { if (it.asin === asin) item = it; });
if (!item) {
@@ -2738,10 +2744,10 @@
document.getElementById('dupCheckImportModal').onclick = function (event) {
if (event.target === this) closeDupCheckImportModal();
};
// 矩阵表格内 ASIN / 数字徽标点击打开抽屉(动态元素,事件委托)
// 矩阵表格内 ASIN / 数字徽标点击打开抽屉(动态元素,事件委托),并传入被点元素作为锚点
document.getElementById('shopDataDuplicateList').onclick = function (event) {
var target = event.target.closest('[data-open-drawer]');
if (target) openShopDataDuplicateDrawer(target.dataset.openDrawer);
if (target) openShopDataDuplicateDrawer(target.dataset.openDrawer, target);
};
// 撞款详情卡片区「查看明细」按钮(动态元素,事件委托)
document.getElementById('dupCheckDetailCards').onclick = handleDuplicateDetailClick;
@@ -0,0 +1,107 @@
package com.nanri.aiimage;
import com.tngtech.archunit.core.domain.JavaClasses;
import com.tngtech.archunit.core.importer.ClassFileImporter;
import com.tngtech.archunit.core.importer.ImportOption;
import org.junit.jupiter.api.Test;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* task-210/211/212:模块边界 ArchUnit 规则(plan 13)。
*
* 已通过(零违规):任何 Controller 不得直接访问 Mapper 或外部 Client211)。
* 债务防恶化(210/212,探针 2026-09-05):task 模块经 *ResultFileJobHandler 等依赖具体业务
* Service/Mapper 曾达 128 处、modules 切片存在 collectdata→dedupe→task 等环;Handler 已迁回
* 业务模块(212 大幅收敛)。整体模块切片无环依赖进一步 SPI 化(deletebrand 跨模块扫描/心跳等),
* 尚未达成,故本文件冻结"存量不新增"上限。
*/
class ArchitectureBoundaryTest {
private static final int TASK_TO_BUSINESS_BASELINE = 84;
private static volatile JavaClasses cached;
private static JavaClasses app() {
if (cached == null) {
cached = new ClassFileImporter()
.withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_TESTS)
.importPackages("com.nanri.aiimage.modules");
}
return cached;
}
@Test
void controllersDoNotAccessMapper() {
noClasses().that().resideInAPackage("..controller..")
.should().dependOnClassesThat().resideInAPackage("..mapper..")
.because("Controller 只经 Service 访问 Mapper(单向分层)")
.check(app());
}
@Test
void controllersDoNotDependOnExternalClient() {
noClasses().that().resideInAPackage("..controller..")
.should().dependOnClassesThat().resideInAPackage("..client..")
.because("外部 Client 细节由 Service 层收敛,Controller 不应感知")
.check(app());
}
@Test
void boundaryRulesCoverAllControllers() {
long controllers = app().stream()
.filter(c -> c.getPackageName().endsWith(".controller")).count();
assertTrue(controllers >= 10, "应覆盖全部 Controller,实际 " + controllers);
}
@Test
void taskToBusinessDependencyDoesNotGrow() {
// 冻结存量:Handler 迁移后 task→业务 依赖已大幅收敛;不允许继续增长
int violations = countViolations(taskRule());
assertTrue(violations >= 1, "task→业务 依赖仍有存量(心跳/清理等待 SPI 化)");
assertTrue(violations <= TASK_TO_BUSINESS_BASELINE,
"task→业务 依赖不得超过基线 " + TASK_TO_BUSINESS_BASELINE + ",当前 " + violations
+ ";新增依赖走 Handler SPI 或先消除存量");
}
@Test
void ruleFailuresCarryClearReason() {
// 规则附 because 说明,失败信息可定位
assertTrue(taskRule().getDescription().contains("no classes that reside in a package"),
"规则描述应可读");
}
@Test
void spiHandlersAreTheSanctionedBridge() {
JavaClasses classes = app();
long handlers = classes.stream()
.filter(c -> c.getSimpleName().endsWith("ResultFileJobHandler"))
.count();
assertTrue(handlers >= 13, "Handler SPI 实现应在各业务模块注册,实际 " + handlers);
}
private static com.tngtech.archunit.lang.ArchRule taskRule() {
return noClasses().that().resideInAPackage("..modules.task..")
.should().dependOnClassesThat().resideInAPackage(
"..modules.(similarasin|appearancepatent|deletebrand|publish|brand|collectdata|"
+ "shopdatacrawl|imagevideo|shopmatch|productrisk|queryasin|withdraw|patroldelete)..")
.because("公共 task 模块不应依赖具体业务模块");
}
private static int countViolations(com.tngtech.archunit.lang.ArchRule rule) {
try {
rule.check(app());
return 0;
} catch (AssertionError e) {
String msg = e.getMessage();
long count = msg.lines()
.filter(l -> l.trim().startsWith("Constructor <")
|| l.trim().startsWith("Field <")
|| l.trim().startsWith("Method <")
|| l.trim().startsWith("Class <"))
.count();
return (int) count;
}
}
}
@@ -0,0 +1,122 @@
package com.nanri.aiimage.config;
import com.nanri.aiimage.metrics.TaskObservabilityMetrics;
import org.junit.jupiter.api.Test;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* 启动冒烟(收尾验证前置):离线 Spring 装配切片。
*
* 在真实 Spring 容器里装配模块 10/11 新增的配置组件,验证:@ConfigurationProperties 绑定默认值、
* @Component 自动注入、可选 MeterRegistry(ObjectProvider) 路径、@ConfigurationProperties 注册
* 均正常——上线前先排除"bean 装配/属性绑定"类启动崩溃。
*
* 注意:不加载 DataSource/Redis/MinIO 等(会连生产),全量真实启动需在非生产环境执行。
*/
class ConfigWiringSmokeTest {
@Configuration
@EnableConfigurationProperties({HttpClientProperties.class, ZiniaoProperties.class,
SimilarAsinProperties.class})
@Import({LlmHttpConfigResolver.class, BrandCheckHttpConfigResolver.class,
ZiniaoHttpConfigResolver.class, ImageDownloadHttpConfigResolver.class,
IdempotentRetryGuard.class})
static class Slice {
}
private static AnnotationConfigApplicationContext context() {
AnnotationConfigApplicationContext ctx = new AnnotationConfigApplicationContext();
ctx.register(Slice.class);
ctx.refresh();
return ctx;
}
@Test
void configPropertiesBeansBindable() {
try (AnnotationConfigApplicationContext ctx = context()) {
HttpClientProperties http = ctx.getBean(HttpClientProperties.class);
assertNotNull(http);
assertEquals(10_000L, http.effectiveConnectTimeoutMillis(), "命名空间默认 connect 10s 应绑定");
assertEquals(3, http.effectiveMaxRetries());
assertNotNull(ctx.getBean(ZiniaoProperties.class));
assertNotNull(ctx.getBean(SimilarAsinProperties.class));
}
}
@Test
void resolversAreSpringBeansAndInjectable() {
try (AnnotationConfigApplicationContext ctx = context()) {
assertNotNull(ctx.getBean(LlmHttpConfigResolver.class));
assertNotNull(ctx.getBean(BrandCheckHttpConfigResolver.class));
assertNotNull(ctx.getBean(ZiniaoHttpConfigResolver.class));
assertNotNull(ctx.getBean(ImageDownloadHttpConfigResolver.class));
}
}
@Test
void resolversReadBoundConfig() {
try (AnnotationConfigApplicationContext ctx = context()) {
ZiniaoHttpConfigResolver ziniao = ctx.getBean(ZiniaoHttpConfigResolver.class);
// 模块未显式配置 read 秒数(默认 0) → 回退命名空间 60s
assertEquals(60_000L, ziniao.readTimeoutMillis());
assertEquals(10_000L, ziniao.connectTimeoutMillis());
ImageDownloadHttpConfigResolver image = ctx.getBean(ImageDownloadHttpConfigResolver.class);
// SimilarAsinProperties.imageDownloadTimeoutSeconds 默认 5s → 模块优先
assertEquals(5_000L, image.readTimeoutMillis());
}
}
@Test
void retryGuardWiresWithOptionalRegistry() {
try (AnnotationConfigApplicationContext ctx = context()) {
IdempotentRetryGuard guard = ctx.getBean(IdempotentRetryGuard.class);
assertNotNull(guard);
assertTrue(guard.isIdempotent("GET", false), "GET 应视为幂等");
assertEquals(500L, guard.backoffMillis(1), "守卫读取命名空间默认基础退避 500ms");
}
}
@Test
void observabilityRecorderIsPlainInstantiable() {
TaskObservabilityMetrics metrics = new TaskObservabilityMetrics("server-110", null);
assertEquals("server-110", metrics.instanceId());
// registry=null 时不抛错(无监控栈降级)
metrics.taskCreated("similarasin");
metrics.fileJobState("similarasin", "success");
}
@Test
void containerClosesCleanly() {
AnnotationConfigApplicationContext ctx = context();
ctx.close();
assertTrue(true);
}
@Test
void sliceRegistersExactlyExpectedConfigBeans() {
try (AnnotationConfigApplicationContext ctx = context()) {
String[] names = ctx.getBeanDefinitionNames();
assertTrue(names.length >= 8, "装配 bean 数量应充足");
assertNotNull(ctx.getBean(HttpClientProperties.class));
assertNotNull(ctx.getBean(IdempotentRetryGuard.class));
}
}
@Test
void defaultPropsMatchSpecBaseline() {
try (AnnotationConfigApplicationContext ctx = context()) {
HttpClientProperties http = ctx.getBean(HttpClientProperties.class);
assertSame(http, ctx.getBean(HttpClientProperties.class), "命名空间属性 bean 单例");
assertEquals(90_000L, http.effectiveCallTimeoutMillis());
assertEquals(500L, http.getBaseRetryDelayMillis());
}
}
}
@@ -0,0 +1,66 @@
package com.nanri.aiimage.config;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* task-201:迁移演练 runbook 契约。
* docs/flyway-migration-drill.md 固化演练步骤:干净迁移/validate/验证 SQL/回滚/锁窗口/校验和/
* 幂等重跑/不动历史;on-DB 步骤由运维在副本库按 runbook 执行(本环境无库)。
*/
class FlywayMigrationDrillDocTest {
private static final String DOC = "docs/flyway-migration-drill.md";
private String doc() throws IOException {
return Files.readString(Path.of(System.getProperty("user.dir"), DOC));
}
@Test
void drillDocExists() {
assertTrue(Files.exists(Path.of(System.getProperty("user.dir"), DOC)), "演练 runbook 应存在");
}
@Test
void cleanMigrateStepDescribed() throws IOException {
assertTrue(doc().contains("migrate"), "应描述干净迁移步骤");
}
@Test
void validateStepDescribed() throws IOException {
assertTrue(doc().contains("validate"), "应包含 validate 校验历史 checksum 步骤");
}
@Test
void validationSqlStepDescribed() throws IOException {
assertTrue(doc().contains("验证 SQL"), "应包含验证 SQL 步骤");
}
@Test
void rollbackStepDescribed() throws IOException {
assertTrue(doc().contains("回滚"), "应包含回滚验证步骤");
}
@Test
void lockWindowEstimatedStepDescribed() throws IOException {
assertTrue(doc().contains("锁窗口"), "应包含锁窗口估算步骤");
}
@Test
void checksumAndIdempotentRerunDescribed() throws IOException {
String text = doc();
assertTrue(text.contains("checksum") || text.contains("校验和"), "应包含校验和检查");
assertTrue(text.contains("幂等重跑") || text.contains("idempotent"), "应包含幂等重跑");
}
@Test
void noLegacyTouchGuarded() throws IOException {
assertTrue(doc().contains("历史") && (doc().contains("未被动") || doc().contains("未被改动")),
"应明确演练不动历史迁移");
}
}
@@ -0,0 +1,76 @@
package com.nanri.aiimage.config;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* task-192:迁移规范模板契约。
* docs/flyway-migration-template.md 存在且六项必填(目的/影响表与数据量/锁表风险/验证 SQL/
* 回滚步骤/上线窗口)齐全,含示例 SQL,可被新迁移文件头注释引用。
*/
class FlywayMigrationTemplateDocTest {
private static final String DOC = "docs/flyway-migration-template.md";
private String doc() throws IOException {
return Files.readString(Path.of(System.getProperty("user.dir"), DOC));
}
@Test
void templateExists() throws IOException {
assertTrue(Files.exists(Path.of(System.getProperty("user.dir"), DOC)), "模板文件应存在");
}
@Test
void sixMandatoryFieldsPresent() throws IOException {
String text = doc();
for (int i = 1; i <= 6; i++) {
assertTrue(text.contains(i + ". "), "头注释应含第 " + i + " 项必填");
}
}
@Test
void fieldNamesListed() throws IOException {
String text = doc();
assertTrue(text.contains("变更目的"));
assertTrue(text.contains("影响表与数据量"));
assertTrue(text.contains("锁表风险"));
assertTrue(text.contains("验证 SQL"));
assertTrue(text.contains("回滚步骤"));
assertTrue(text.contains("上线窗口"));
}
@Test
void hasCopyableHeaderComment() throws IOException {
assertTrue(doc().contains("复制以下头注释"), "模板应提供可复制头注释");
assertTrue(doc().contains("V{N+1}__"), "头注释示例应含占位版本号");
}
@Test
void hasValidationSqlExample() throws IOException {
assertTrue(doc().contains("SELECT COUNT(*)"), "应含验证 SQL 示例");
}
@Test
void hasRollbackField() throws IOException {
assertTrue(doc().contains("回滚步骤"), "应含回滚步骤说明");
}
@Test
void hasUpgradeWindowField() throws IOException {
assertTrue(doc().contains("上线窗口"), "应含上线窗口说明");
}
@Test
void templateUsableByNewMigrations() throws IOException {
String text = doc();
assertTrue(text.contains("src/main/resources/db/"), "应说明迁移文件目录");
assertTrue(text.contains("只追加"), "应强调只追加不改历史");
assertTrue(text.contains("V109") || text.contains("最大现有版本"), "应说明版本号取最大现有版本+1");
}
}
@@ -0,0 +1,162 @@
package com.nanri.aiimage.config;
import org.junit.jupiter.api.Test;
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.regex.Pattern;
import java.util.stream.Stream;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* task-179:禁止记录清单审计(spec 11 §2Token/Cookie/完整请求体/文件二进制/凭据/敏感 API Key
* 不入日志;允许脱敏/截断后的字段保留)。
*
* 语句级扫描 src/main 的全部 log.* 调用(含跨行参数):凡 format 会出现完整敏感值的高风险
* 模式做白名单校验——body={} 或 authorization/Bearer/token 的语句必须经脱敏/截断 helper
* maskChatBody/maskSecretsForLog/maskForLog/toJsonForLog/abbreviate 等);Cookie/凭据/
* API Key/二进制类字段一律不允许出现。纯扫描可重复,违规即列出 file:line。
*/
class ForbiddenLogAuditTest {
private static final List<String> MASK_HELPERS = List.of(
"maskChatBody", "maskSecretsForLog", "maskForLog", "toJsonForLog",
"abbreviate", "mask(", "redact");
private static final Pattern LOG_CALL = Pattern.compile("log\\.(info|warn|error|debug|trace)\\s*\\(");
private static final Pattern TOKEN_AUTH = Pattern.compile(
"\"[^\"]*(authorization|Bearer|token=)[^\"]*\"");
private static final Pattern COOKIE = Pattern.compile("cookie=");
private static final Pattern FULL_BODY = Pattern.compile("body=\\{\\}");
private static final Pattern BINARY = Pattern.compile(
"(fileBinary|binary=|base64|fileContent=|contentBytes=)");
private static final Pattern CREDENTIAL = Pattern.compile("(password=|secret=|credential=)");
private static final Pattern API_KEY = Pattern.compile("(apiKey=|api_secret=|clientSecret=)");
@Test
void noTokenLogged() throws IOException {
assertNoViolations("Token/鉴权头", TOKEN_AUTH, true);
}
@Test
void noCookieLogged() throws IOException {
assertNoViolations("Cookie", COOKIE, false);
}
@Test
void noFullRequestBodyLogged() throws IOException {
assertNoViolations("完整请求体 body={}", FULL_BODY, true);
}
@Test
void noBinaryLogged() throws IOException {
assertNoViolations("文件二进制", BINARY, false);
}
@Test
void noCredentialLogged() throws IOException {
assertNoViolations("用户凭据", CREDENTIAL, false);
}
@Test
void noApiKeyLogged() throws IOException {
assertNoViolations("敏感 API Key", API_KEY, false);
}
@Test
void allowedMaskedFieldsKept() throws IOException {
// 允许项保留:LLM 请求/响应的 body 经 maskChatBody/toJsonForLog/abbreviate 脱敏后仍记录,
// 审计不得把这些"已脱敏的可用诊断"当违规删掉(否则白名单空转)。
int maskedBodyStatements = 0;
try (Stream<Path> paths = Files.walk(Path.of("src/main/java"))) {
for (Path path : paths.filter(p -> p.toString().endsWith(".java")).toList()) {
List<String> lines = Files.readAllLines(path);
for (int i = 0; i < lines.size(); i++) {
if (!LOG_CALL.matcher(lines.get(i)).find()) {
continue;
}
String stmt = joinStatement(lines, i);
if (FULL_BODY.matcher(stmt).find() && hasMaskHelper(stmt)) {
maskedBodyStatements++;
}
}
}
}
assertTrue(maskedBodyStatements >= 4,
"应保留至少 4 处已脱敏的 body 诊断日志,实际 " + maskedBodyStatements);
}
@Test
void auditScriptRepeatable() throws IOException {
assertEquals(violations(TOKEN_AUTH, true), violations(TOKEN_AUTH, true),
"审计可重复执行且结果稳定");
assertEquals(0, violations(FULL_BODY, true).size(), "重复扫描 body 违规应为 0");
}
private static void assertNoViolations(String label, Pattern pattern, boolean requireMask) throws IOException {
List<String> found = violations(pattern, requireMask);
assertTrue(found.isEmpty(), label + " 不应入日志(需脱敏或移除):\n " + String.join("\n ", found));
}
private static List<String> violations(Pattern pattern, boolean requireMask) throws IOException {
List<String> violations = new ArrayList<>();
try (Stream<Path> paths = Files.walk(Path.of("src/main/java"))) {
for (Path path : paths.filter(p -> p.toString().endsWith(".java")).toList()) {
List<String> lines = Files.readAllLines(path);
for (int i = 0; i < lines.size(); i++) {
if (!LOG_CALL.matcher(lines.get(i)).find()) {
continue;
}
String statement = joinStatement(lines, i);
if (pattern.matcher(statement).find()) {
boolean ok = !requireMask || hasMaskHelper(statement);
if (!ok) {
violations.add(path + ":" + (i + 1) + " " + trim(lines.get(i)));
}
}
}
}
}
return violations;
}
private static String joinStatement(List<String> lines, int start) {
StringBuilder sb = new StringBuilder(lines.get(start));
int depth = 0;
for (int i = start; i < lines.size() && i < start + 8; i++) {
if (i > start) {
sb.append(' ').append(lines.get(i));
}
depth += count(lines.get(i), '(') - count(lines.get(i), ')');
if (depth <= 0 && lines.get(i).contains(");")) {
break;
}
}
return sb.toString();
}
private static int count(String text, char c) {
int n = 0;
for (int i = 0; i < text.length(); i++) {
if (text.charAt(i) == c) {
n++;
}
}
return n;
}
private static boolean hasMaskHelper(String statement) {
return MASK_HELPERS.stream().anyMatch(statement::contains);
}
private static String trim(String line) {
String t = line.trim();
return t.length() > 160 ? t.substring(0, 160) : t;
}
}
@@ -0,0 +1,286 @@
package com.nanri.aiimage.config;
import org.junit.jupiter.api.Test;
import org.springframework.web.client.RestClient;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.InetAddress;
import java.net.ServerSocket;
import java.net.Socket;
import java.net.http.HttpClient;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.stream.IntStream;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* task-174:连接复用测试(plan 10)。
*
* 用本机计数 HTTP/1.1 服务统计真实 TCP 连接数,验证 HttpClientPool 共享池的 keep-alive
* 复用:连续顺序请求复用同一连接、不同客户端工厂共用同一连接池、同 host 突发不新建连接、
* 不同 host 各自建连、非共享池客户端不复用。全部本地确定性,不发外部网络请求。
*
* 平台边界:JDK HttpClient HTTP/1.1 无多路复用,并发在途请求会各自建连;复用体现在
* 顺序/间歇请求复用空闲连接上,故并发后追加的顺序请求应复用已建立的连接而不新增。
*/
class HttpClientConnectionReuseTest2 {
/** 极小的本地 HTTP/1.1 keep-alive 计数服务。 */
static final class CountingServer implements AutoCloseable {
private final ServerSocket serverSocket;
private final AtomicInteger accepted = new AtomicInteger();
private final AtomicInteger requests = new AtomicInteger();
private final List<Socket> sockets = new CopyOnWriteArrayList<>();
private final Thread acceptThread;
private volatile boolean running = true;
CountingServer() throws IOException {
serverSocket = new ServerSocket(0, 256, InetAddress.getByName("127.0.0.1"));
acceptThread = new Thread(this::acceptLoop, "conn-reuse-srv");
acceptThread.setDaemon(true);
acceptThread.start();
}
int port() {
return serverSocket.getLocalPort();
}
int accepted() {
return accepted.get();
}
int requests() {
return requests.get();
}
private void acceptLoop() {
while (running) {
try {
Socket socket = serverSocket.accept();
sockets.add(socket);
accepted.incrementAndGet();
Thread worker = new Thread(() -> handle(socket), "conn-reuse-worker");
worker.setDaemon(true);
worker.start();
} catch (IOException e) {
if (running) {
// accept 被关闭时退出
}
return;
}
}
}
private void handle(Socket socket) {
try (socket) {
InputStream in = socket.getInputStream();
OutputStream out = socket.getOutputStream();
while (running && readRequest(in)) {
requests.incrementAndGet();
byte[] body = "ok".getBytes(StandardCharsets.US_ASCII);
String head = "HTTP/1.1 200 OK\r\nContent-Length: " + body.length
+ "\r\nConnection: keep-alive\r\n\r\n";
out.write(head.getBytes(StandardCharsets.US_ASCII));
out.write(body);
out.flush();
}
} catch (IOException ignored) {
// 客户端断开/连接被关闭,属预期
} finally {
sockets.remove(socket);
}
}
/** 读取到空行分隔的请求头结束;EOF 返回 false。 */
private static boolean readRequest(InputStream in) throws IOException {
final byte[] end = {'\r', '\n', '\r', '\n'};
int matched = 0;
while (true) {
int b = in.read();
if (b == -1) {
return false;
}
if ((byte) b == end[matched]) {
matched++;
} else {
matched = (b == '\r') ? 1 : 0;
}
if (matched == 4) {
return true;
}
}
}
@Override
public void close() {
running = false;
try {
serverSocket.close();
} catch (IOException ignored) {
// ignore
}
for (Socket socket : sockets) {
try {
socket.close();
} catch (IOException ignored) {
// ignore
}
}
}
}
private static RestClient pooledClient() {
return RestClient.builder()
.requestFactory(HttpClientPool.requestFactory(5_000))
.build();
}
private static void get(RestClient client, int port) {
client.get().uri("http://127.0.0.1:" + port + "/").retrieve().toBodilessEntity();
}
@Test
void sequentialRequestsReuseSingleConnection() throws Exception {
try (CountingServer server = new CountingServer()) {
RestClient client = pooledClient();
for (int i = 0; i < 6; i++) {
get(client, server.port());
}
assertEquals(6, server.requests());
assertEquals(1, server.accepted(), "连续 6 次请求应复用同一连接");
}
}
@Test
void sharedAcrossClientFactoriesReuseOneConnection() throws Exception {
try (CountingServer server = new CountingServer()) {
// 三个独立 RestClient(各自 JdkClientHttpRequestFactory)共享底层连接池
RestClient brand = pooledClient();
RestClient ziniao = pooledClient();
RestClient llm = pooledClient();
get(brand, server.port());
get(ziniao, server.port());
get(llm, server.port());
assertEquals(3, server.requests());
assertEquals(1, server.accepted(), "不同客户端工厂共用同一连接池,应复用同一连接");
}
}
@Test
void reuseSurvivesShortIdleGap() throws Exception {
try (CountingServer server = new CountingServer()) {
RestClient client = pooledClient();
for (int i = 0; i < 3; i++) {
get(client, server.port());
}
Thread.sleep(80);
for (int i = 0; i < 3; i++) {
get(client, server.port());
}
assertEquals(6, server.requests());
assertEquals(1, server.accepted(), "短暂空闲后连接应被 keep-alive 复用");
}
}
@Test
void manySequentialRequestsDoNotBurstConnections() throws Exception {
try (CountingServer server = new CountingServer()) {
RestClient client = pooledClient();
for (int i = 0; i < 12; i++) {
get(client, server.port());
}
assertEquals(12, server.requests());
assertEquals(1, server.accepted(), "顺序突发不应新建连接");
}
}
@Test
void poolIsSingletonAcrossAccesses() {
// 共享池单例;不同 RestClient 各自持工厂,但底层 HttpClient 唯一(Task77 已测实例级)
assertSame(HttpClientPool.sharedHttpClient(), HttpClientPool.sharedHttpClient(),
"共享池单例");
}
@Test
void distinctHostsGetDistinctConnections() throws Exception {
try (CountingServer a = new CountingServer(); CountingServer b = new CountingServer()) {
RestClient client = pooledClient();
get(client, a.port());
get(client, b.port());
get(client, a.port());
assertEquals(1, a.accepted(), "host A 两次请求复用");
assertEquals(1, b.accepted(), "host B 独立建连");
}
}
@Test
void nonPooledHttpClientDoesNotReusePoolConnection() throws Exception {
try (CountingServer server = new CountingServer()) {
RestClient pool = pooledClient();
get(pool, server.port());
assertEquals(1, server.accepted());
// 独立 HttpClient(不参与共享池)访问同一 host → 新建连接,不复用池连接
HttpClient standalone = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(5))
.build();
org.springframework.http.client.JdkClientHttpRequestFactory factory =
new org.springframework.http.client.JdkClientHttpRequestFactory(standalone);
RestClient standaloneRest = RestClient.builder().requestFactory(factory).build();
get(standaloneRest, server.port());
assertEquals(2, server.accepted(), "非共享池客户端应另建连接(不复用时暴露)");
}
}
@Test
void concurrentRequestsSucceedAndSettledConnectionsAreReused() throws Exception {
try (CountingServer server = new CountingServer()) {
RestClient client = pooledClient();
int concurrency = 6;
ExecutorService executor = Executors.newFixedThreadPool(concurrency);
CountDownLatch start = new CountDownLatch(1);
CountDownLatch done = new CountDownLatch(concurrency);
try {
IntStream.range(0, concurrency).forEach(i -> executor.submit(() -> {
try {
start.await();
get(client, server.port());
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
done.countDown();
}
}));
start.countDown();
assertTrue(done.await(10, TimeUnit.SECONDS), "并发请求应在时限内完成");
assertEquals(concurrency, server.requests());
int acceptedAfterConcurrent = server.accepted();
assertTrue(acceptedAfterConcurrent >= 1 && acceptedAfterConcurrent <= concurrency,
"并发建连数应在合理范围,实际 " + acceptedAfterConcurrent);
// 并发平息后追加顺序请求应复用已建立的空闲连接,不新增
for (int i = 0; i < 3; i++) {
get(client, server.port());
}
assertEquals(concurrency + 3, server.requests());
assertEquals(acceptedAfterConcurrent, server.accepted(),
"顺序追加应复用既有连接,不新增");
} finally {
executor.shutdownNow();
}
}
}
}
@@ -0,0 +1,210 @@
package com.nanri.aiimage.config;
import com.sun.net.httpserver.HttpServer;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.springframework.web.client.ResourceAccessException;
import org.springframework.web.client.RestClient;
import java.io.IOException;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.net.http.HttpClient;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeoutException;
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.junit.jupiter.api.Assertions.fail;
/**
* task-173:超时生效测试(plan 10mock 慢端点)。
*
* 用本机回环 HttpServer 模拟慢端点,验证 HttpClientPool.requestFactory(readTimeout)
* 真实管线:慢于配置超时的端点超时内失败、快/预算内响应不误伤、超时类型为
* java.util.concurrent.TimeoutException、超时时长随配置缩放、连接超时对本机不可达地址生效。
* 全部本地确定性,不发外部网络请求。
*
* 已核实的平台边界(task-166 记录):JDK 请求超时封顶的是"到响应头"的时长,
* 流式 body 读取不受请求超时约束,因此不为此编造"总时长封顶 body"的断言。
*/
class HttpClientTimeoutEffectiveTest {
private static HttpServer server;
private static ExecutorService serverExecutor;
@BeforeAll
static void startServer() throws IOException {
serverExecutor = Executors.newCachedThreadPool(r -> {
Thread t = new Thread(r, "http-timeout-test");
t.setDaemon(true);
return t;
});
server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.setExecutor(serverExecutor);
server.createContext("/slow", exchange -> {
String query = exchange.getRequestURI().getQuery(); // ms=N
long ms = 2_000;
if (query != null && query.startsWith("ms=")) {
ms = Long.parseLong(query.substring(3));
}
sleep(ms);
respond(exchange, "slow-done");
});
server.createContext("/fast", exchange -> respond(exchange, "ok"));
server.start();
}
@AfterAll
static void stopServer() {
if (server != null) {
server.stop(0);
}
if (serverExecutor != null) {
serverExecutor.shutdownNow();
}
}
private static void respond(com.sun.net.httpserver.HttpExchange exchange, String body) throws IOException {
byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
exchange.sendResponseHeaders(200, bytes.length);
try (OutputStream out = exchange.getResponseBody()) {
out.write(bytes);
}
}
private static void sleep(long ms) {
try {
Thread.sleep(ms);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
private static String baseUrl() {
return "http://127.0.0.1:" + server.getAddress().getPort();
}
private static RestClient clientWithReadTimeout(int readTimeoutMillis) {
return RestClient.builder()
.requestFactory(HttpClientPool.requestFactory(readTimeoutMillis))
.build();
}
@Test
void readTimeoutFiresOnSlowEndpoint() {
RestClient client = clientWithReadTimeout(300);
long startedAt = System.nanoTime();
ResourceAccessException ex = assertThrows(ResourceAccessException.class,
() -> client.get().uri(baseUrl() + "/slow?ms=3000").retrieve().toBodilessEntity());
long elapsedMs = (System.nanoTime() - startedAt) / 1_000_000;
assertTrue(isTimeoutRoot(ex), "根因应为超时异常,实际 " + root(ex));
assertTrue(elapsedMs < 1_500, "慢端点应在配置超时附近失败,实际 " + elapsedMs + "ms");
}
@Test
void fastEndpointSucceedsWithoutTimeout() {
RestClient client = clientWithReadTimeout(300);
client.get().uri(baseUrl() + "/fast").retrieve().toBodilessEntity();
// 未抛异常即通过:快响应不应被误判超时
}
@Test
void moderatelySlowWithinBudgetSucceeds() {
// 预算内慢响应(500ms < readTimeout 3000ms)应正常返回,不得提前误杀
RestClient client = clientWithReadTimeout(3_000);
client.get().uri(baseUrl() + "/slow?ms=500").retrieve().toBodilessEntity();
}
@Test
void timeoutRootIsTimeoutException() {
RestClient client = clientWithReadTimeout(200);
ResourceAccessException ex = assertThrows(ResourceAccessException.class,
() -> client.get().uri(baseUrl() + "/slow?ms=2000").retrieve().toBodilessEntity());
// JDK 在负载下可能抛 java.util.concurrent.TimeoutException 或 java.net.http.HttpTimeoutException
// 两者同属"超时";按超时语义断言而非绑定具体类型。
assertTrue(isTimeoutRoot(ex), "根因应为超时异常,实际 " + root(ex));
}
@Test
void readTimeoutScalesWithConfiguredValue() {
long elapsedShort = timeToFail(150);
long elapsedLong = timeToFail(700);
assertTrue(elapsedShort < elapsedLong,
"更小的 readTimeout 应更快失败:" + elapsedShort + "ms vs " + elapsedLong + "ms");
assertTrue(elapsedLong < 2_500, "较长配置也应在合理范围内失败");
}
@Test
void requestFactoryClampsNonPositiveReadTimeout() {
// HttpClientPool.requestFactory 把非正值钳制到最小 1ms,慢端点极快失败
RestClient client = RestClient.builder()
.requestFactory(HttpClientPool.requestFactory(-5))
.build();
long startedAt = System.nanoTime();
assertThrows(ResourceAccessException.class,
() -> client.get().uri(baseUrl() + "/slow?ms=2000").retrieve().toBodilessEntity());
long elapsedMs = (System.nanoTime() - startedAt) / 1_000_000;
assertTrue(elapsedMs < 800, "钳制到 1ms 后应极快失败,实际 " + elapsedMs + "ms");
}
@Test
void connectTimeoutFiresOnUnreachableHost() {
// 本机不可达地址(TEST-NET-1 保留段)+ 短连接超时 → 在限定时间内以传输错误失败
HttpClientProperties props = new HttpClientProperties();
props.setConnectTimeoutMillis(500);
HttpClient client = HttpClient.newBuilder()
.connectTimeout(Duration.ofMillis(props.effectiveConnectTimeoutMillis()))
.build();
org.springframework.http.client.JdkClientHttpRequestFactory factory =
new org.springframework.http.client.JdkClientHttpRequestFactory(client);
RestClient rest = RestClient.builder().requestFactory(factory).build();
long startedAt = System.nanoTime();
try {
rest.get().uri("http://192.0.2.1:81/").retrieve().toBodilessEntity();
fail("不可达地址不应成功");
} catch (ResourceAccessException expected) {
// 预期:连接超时或快速不可达,均属传输错误
}
long elapsedMs = (System.nanoTime() - startedAt) / 1_000_000;
assertTrue(elapsedMs < 3_000, "连接超时应在限定时间内失败,实际 " + elapsedMs + "ms");
}
@Test
void sharedPoolAndNamespaceDefaultsAreOperative() {
// 连接超时来源=共享 HttpClient(10s);命名空间默认 read=60s 经 requestFactory 生效且不误伤快响应
assertEquals(Duration.ofSeconds(10), HttpClientPool.sharedHttpClient().connectTimeout().orElseThrow(),
"共享 HttpClient 连接超时为 10s");
HttpClientProperties props = new HttpClientProperties();
assertEquals(60_000L, props.effectiveReadTimeoutMillis());
RestClient client = clientWithReadTimeout((int) props.effectiveReadTimeoutMillis());
client.get().uri(baseUrl() + "/fast").retrieve().toBodilessEntity();
}
private static long timeToFail(int readTimeoutMillis) {
RestClient client = clientWithReadTimeout(readTimeoutMillis);
long startedAt = System.nanoTime();
assertThrows(ResourceAccessException.class,
() -> client.get().uri(baseUrl() + "/slow?ms=5000").retrieve().toBodilessEntity());
return (System.nanoTime() - startedAt) / 1_000_000;
}
private static Throwable root(Throwable error) {
Throwable current = error;
while (current.getCause() != null && current.getCause() != current) {
current = current.getCause();
}
return current;
}
private static boolean isTimeoutRoot(Throwable error) {
return root(error) instanceof TimeoutException
|| root(error) instanceof java.net.http.HttpTimeoutException;
}
}
@@ -0,0 +1,207 @@
package com.nanri.aiimage.config;
import ch.qos.logback.classic.Logger;
import ch.qos.logback.classic.spi.ILoggingEvent;
import ch.qos.logback.core.read.ListAppender;
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
import org.junit.jupiter.api.Test;
import org.slf4j.LoggerFactory;
import org.slf4j.event.Level;
import java.io.IOException;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* task-176:重试计数与日志字段契约(plan 10,扩展 task-172 守卫的观测面)。
*
* 守卫每次重试打 WARN 日志(client/url/method/retry/耗时时长/延迟/原因类名),重试记
* aiimage.http.retry 指标(Micrometer 可选);成功不记重试日志;日志不打印可能含敏感
* 信息的异常消息体。
*/
class IdempotentRetryGuardObservabilityTest {
private static ListAppender<ILoggingEvent> attachCapture() {
Logger guardLogger = (Logger) LoggerFactory.getLogger(IdempotentRetryGuard.class);
ListAppender<ILoggingEvent> appender = new ListAppender<>();
appender.start();
guardLogger.addAppender(appender);
return appender;
}
private static void detachCapture(ListAppender<ILoggingEvent> appender) {
Logger guardLogger = (Logger) LoggerFactory.getLogger(IdempotentRetryGuard.class);
guardLogger.detachAppender(appender);
}
private static List<String> messages(ListAppender<ILoggingEvent> appender) {
return appender.list.stream().map(ILoggingEvent::getFormattedMessage).toList();
}
private static String join(ListAppender<ILoggingEvent> appender) {
return String.join("\n", messages(appender));
}
@Test
void retryLoggedWithGuardPrefix() throws Exception {
HttpClientProperties props = fastProps();
IdempotentRetryGuard guard = new IdempotentRetryGuard(props);
ListAppender<ILoggingEvent> captured = attachCapture();
try {
AtomicInteger n = new AtomicInteger();
guard.execute("GET", false, "brand", "http://example/brand/check", () -> {
if (n.incrementAndGet() < 2) {
throw new IOException("连接中断");
}
return "ok";
});
assertTrue(join(captured).contains("http-retry-guard"), "重试应打 [http-retry-guard] 日志");
} finally {
detachCapture(captured);
}
}
@Test
void retryLogCarriesClientUrlMethodAndAttemptFields() throws Exception {
HttpClientProperties props = fastProps();
IdempotentRetryGuard guard = new IdempotentRetryGuard(props);
ListAppender<ILoggingEvent> captured = attachCapture();
try {
AtomicInteger n = new AtomicInteger();
guard.execute("GET", false, "brand", "http://example/brand/check", () -> {
if (n.incrementAndGet() < 2) {
throw new IOException("连接中断");
}
return "ok";
});
String text = join(captured);
assertTrue(text.contains("client=brand"), "应含 client 字段");
assertTrue(text.contains("url=http://example/brand/check"), "应含 url 字段");
assertTrue(text.contains("method=GET"), "应含 method 字段");
assertTrue(text.contains("retry=1/3"), "应含重试计数与上限字段");
} finally {
detachCapture(captured);
}
}
@Test
void retryLogCarriesDurationAndDelay() throws Exception {
HttpClientProperties props = fastProps();
IdempotentRetryGuard guard = new IdempotentRetryGuard(props);
ListAppender<ILoggingEvent> captured = attachCapture();
try {
AtomicInteger n = new AtomicInteger();
guard.execute("GET", false, () -> {
if (n.incrementAndGet() < 2) {
throw new IOException("连接中断");
}
return "ok";
});
String text = join(captured);
assertTrue(text.contains("durationMs="), "应含耗时字段");
assertTrue(text.contains("delayMs="), "应含延迟字段");
} finally {
detachCapture(captured);
}
}
@Test
void retryMetricRecorded() throws Exception {
SimpleMeterRegistry registry = new SimpleMeterRegistry();
HttpClientProperties props = fastProps();
IdempotentRetryGuard guard = new IdempotentRetryGuard(props, registry);
AtomicInteger n = new AtomicInteger();
guard.execute("GET", false, "ziniao", "http://ziniao/x", () -> {
if (n.incrementAndGet() < 2) {
throw new IOException("连接中断");
}
return "ok";
});
assertEquals(1.0, registry.get("aiimage.http.retry").counter().count(), "一次重试应计数 1");
assertEquals("GET", registry.get("aiimage.http.retry").counter().getId().getTag("method"));
assertEquals("ziniao", registry.get("aiimage.http.retry").counter().getId().getTag("client"));
}
@Test
void noSensitiveInfoInRetryLog() throws Exception {
HttpClientProperties props = fastProps();
IdempotentRetryGuard guard = new IdempotentRetryGuard(props);
ListAppender<ILoggingEvent> captured = attachCapture();
try {
AtomicInteger n = new AtomicInteger();
guard.execute("POST", true, "brand", "http://example/brand/check?token=secretQuery", () -> {
if (n.incrementAndGet() < 2) {
throw new IOException("响应含 Bearer secret-token-abc");
}
return "ok";
});
String text = join(captured);
assertTrue(!text.contains("secret"), "日志不应含异常消息里的敏感串,实际: " + text);
assertTrue(!text.contains("Bearer"), "日志不应含 Authorization 字样");
} finally {
detachCapture(captured);
}
}
@Test
void successWithoutRetryLogsNothing() throws Exception {
HttpClientProperties props = fastProps();
IdempotentRetryGuard guard = new IdempotentRetryGuard(props);
ListAppender<ILoggingEvent> captured = attachCapture();
try {
guard.execute("GET", false, "brand", "http://example/brand/check", () -> "ok");
assertEquals(0, captured.list.size(), "成功不应产生任何重试日志");
} finally {
detachCapture(captured);
}
}
@Test
void retryLogLevelIsWarn() throws Exception {
HttpClientProperties props = fastProps();
IdempotentRetryGuard guard = new IdempotentRetryGuard(props);
ListAppender<ILoggingEvent> captured = attachCapture();
try {
AtomicInteger n = new AtomicInteger();
guard.execute("GET", false, () -> {
if (n.incrementAndGet() < 2) {
throw new IOException("连接中断");
}
return "ok";
});
assertTrue(!captured.list.isEmpty());
assertEquals(Level.WARN.toString(), captured.list.get(0).getLevel().toString(),
"重试日志应为 WARN 级别");
} finally {
detachCapture(captured);
}
}
@Test
void finalFailureLoggedAfterRetryExhausted() throws Exception {
HttpClientProperties props = fastProps();
props.setMaxRetries(2);
IdempotentRetryGuard guard = new IdempotentRetryGuard(props);
ListAppender<ILoggingEvent> captured = attachCapture();
try {
assertThrows(IOException.class, () -> guard.execute("GET", false, "brand", "http://example/x", () -> {
throw new IOException("连接中断");
}));
String text = join(captured);
assertTrue(text.contains("最终失败"), "重试耗尽应记录最终失败,实际: " + text);
assertTrue(text.contains("retry=2/2"), "最终失败应携带已重试次数");
} finally {
detachCapture(captured);
}
}
private static HttpClientProperties fastProps() {
HttpClientProperties props = new HttpClientProperties();
props.setBaseRetryDelayMillis(1);
return props;
}
}
@@ -0,0 +1,127 @@
package com.nanri.aiimage.config;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* task-172:幂等重试守卫契约(plan 10)。
*
* 统一重试策略:仅幂等请求(GET/HEAD 或显式幂等标记)在网络传输错误(IOException 及包装链)
* 时自动重试;非幂等(POST 等)不自动重试;重试次数与基础退避取自 aiimage.http-client.*
* 命名空间;退避指数增长并以 5s 封顶,防止重试风暴。守卫仅作为策略组件,不接任何调用点。
*/
class IdempotentRetryGuardTest {
private HttpClientProperties properties;
private IdempotentRetryGuard guard;
@BeforeEach
void setUp() {
properties = new HttpClientProperties();
guard = new IdempotentRetryGuard(properties);
}
@Test
void getIsRetriedOnTransportError() throws Exception {
AtomicInteger attempts = new AtomicInteger();
String result = guard.execute("GET", false, () -> {
if (attempts.incrementAndGet() < 2) {
throw new IOException("连接中断");
}
return "ok";
});
assertEquals("ok", result);
assertEquals(2, attempts.get(), "GET 传输错误应自动重试一次");
}
@Test
void postIsNotRetried() {
AtomicInteger attempts = new AtomicInteger();
assertThrows(IOException.class, () -> guard.execute("POST", false, () -> {
attempts.incrementAndGet();
throw new IOException("连接中断");
}));
assertEquals(1, attempts.get(), "POST 非幂等不应自动重试");
}
@Test
void retryCountIsLimitedByConfig() {
properties.setMaxRetries(3);
AtomicInteger attempts = new AtomicInteger();
assertThrows(IOException.class, () -> guard.execute("GET", false, () -> {
attempts.incrementAndGet();
throw new IOException("连接中断");
}));
assertEquals(4, attempts.get(), "1 次初始 + 3 次重试");
}
@Test
void backoffIsExponentialAndCapped() {
properties.setBaseRetryDelayMillis(500);
assertEquals(500L, guard.backoffMillis(1));
assertEquals(1_000L, guard.backoffMillis(2));
assertEquals(2_000L, guard.backoffMillis(3));
assertEquals(4_000L, guard.backoffMillis(4));
assertEquals(5_000L, guard.backoffMillis(5), "超过 5s 应封顶");
properties.setBaseRetryDelayMillis(100);
assertEquals(100L, guard.backoffMillis(1));
assertEquals(200L, guard.backoffMillis(2));
assertTrue(guard.backoffMillis(1) <= guard.backoffMillis(2));
}
@Test
void explicitIdempotentFlagOverridesMethod() throws Exception {
AtomicInteger attempts = new AtomicInteger();
String result = guard.execute("POST", true, () -> {
if (attempts.incrementAndGet() < 2) {
throw new IOException("连接中断");
}
return "ok";
});
assertEquals("ok", result);
assertEquals(2, attempts.get(), "显式幂等标记应允许重试");
}
@Test
void succeedsAfterMultipleFailures() throws Exception {
AtomicInteger attempts = new AtomicInteger();
String result = guard.execute("GET", false, () -> {
if (attempts.incrementAndGet() < 3) {
throw new IOException("连接中断");
}
return "done";
});
assertEquals("done", result);
assertEquals(3, attempts.get());
}
@Test
void transportErrorClassification() {
assertTrue(guard.isTransportError(new IOException("boom")));
assertTrue(guard.isTransportError(new RuntimeException(new IOException("cause"))),
"包装链中含 IOException 应视为传输错误");
assertFalse(guard.isTransportError(new IllegalStateException("业务错误")));
assertFalse(guard.isTransportError(null));
}
@Test
void noRetryStormBeyondConfiguredMax() {
properties.setMaxRetries(6);
properties.setBaseRetryDelayMillis(1);
AtomicInteger attempts = new AtomicInteger();
assertThrows(IOException.class, () -> guard.execute("GET", false, () -> {
attempts.incrementAndGet();
throw new IOException("连接中断");
}));
assertEquals(7, attempts.get(), "总尝试次数严格受配置上限约束,不跑飞");
}
}
@@ -0,0 +1,100 @@
package com.nanri.aiimage.config;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* task-171:图片下载客户端配置接入契约(plan 10,诚实模式)。
*
* 现网真实基线(已核实,对应 task-166 契约表"图片下载"行):
* - SimilarAsinImageEmbedder 用自建 OkHttpClient(非共享 JDK 池):connect/read/write =
* aiimage.similar-asin.image-download-timeout-seconds(默认 5s),call = 2×download10s);
* - 整批预取预算 aiimage.similar-asin.image-prefetch-timeout-seconds(默认 1800s);
* - 无显式次数重试(依赖 OkHttp retryOnConnectionFailure + 上游批次重提),图片缓存幂等。
*
* resolver 语义:模块级现有配置优先(保证接入切点行为不变),命名空间 aiimage.http-client.*
* 兜底;不改动 SimilarAsinImageEmbedder 调用点,仅提供稳定访问面。
*/
class ImageDownloadHttpConfigResolverTest {
private HttpClientProperties http;
private SimilarAsinProperties similarAsin;
private ImageDownloadHttpConfigResolver resolver;
@BeforeEach
void setUp() {
http = new HttpClientProperties();
similarAsin = new SimilarAsinProperties();
resolver = new ImageDownloadHttpConfigResolver(http, similarAsin);
}
@Test
void defaultsMatchCurrentEmbedder() {
// 模块默认 imageDownloadTimeoutSeconds=5 → OkHttp connect/read 5s、call 10s
assertEquals(5_000L, resolver.readTimeoutMillis());
assertEquals(5_000L, resolver.connectTimeoutMillis());
assertEquals(10_000L, resolver.callTimeoutMillis());
}
@Test
void prefetchBudgetFromModule() {
// 模块默认 imagePrefetchTimeoutSeconds=1800 → 1800s
assertEquals(1_800_000L, resolver.prefetchTimeoutMillis());
}
@Test
void moduleWinsOverNamespace() {
// 模块级现有配置优先:命名空间 read 调大不影响图片下载 read(行为不变)
http.setReadTimeoutMillis(120_000);
assertEquals(5_000L, resolver.readTimeoutMillis());
}
@Test
void downloadOverrideTakesEffect() {
similarAsin.setImageDownloadTimeoutSeconds(8);
assertEquals(8_000L, resolver.readTimeoutMillis());
assertEquals(8_000L, resolver.connectTimeoutMillis());
assertEquals(16_000L, resolver.callTimeoutMillis(), "call = 2×download");
}
@Test
void fallsBackToNamespaceWhenModuleUnset() {
// 模块未配置(<=0) → 命名空间(默认 connect 10s/read 60s/call 90s
similarAsin.setImageDownloadTimeoutSeconds(0);
similarAsin.setImagePrefetchTimeoutSeconds(0);
assertEquals(10_000L, resolver.connectTimeoutMillis());
assertEquals(60_000L, resolver.readTimeoutMillis());
assertEquals(90_000L, resolver.callTimeoutMillis());
assertEquals(60_000L, resolver.prefetchTimeoutMillis());
}
@Test
void retryFromNamespaceClamped() {
// 无显式次数重试是现状;次数来自命名空间,供未来接入切点
assertEquals(3, resolver.maxRetries());
http.setMaxRetries(99);
assertEquals(10, resolver.maxRetries(), "命名空间重试钳制到 10");
assertEquals(500L, resolver.baseRetryDelayMillis());
}
@Test
void invalidNamespaceTimeoutsClamped() {
similarAsin.setImageDownloadTimeoutSeconds(0);
similarAsin.setImagePrefetchTimeoutSeconds(0);
http.setConnectTimeoutMillis(-1);
assertEquals(1_000L, resolver.connectTimeoutMillis());
http.setReadTimeoutMillis(999_999_999L);
assertEquals(3_600_000L, resolver.readTimeoutMillis());
}
@Test
void resolverWiredAsStableAccessSurface() {
assertTrue(resolver.connectTimeoutMillis() > 0);
assertTrue(resolver.readTimeoutMillis() > 0);
assertTrue(resolver.callTimeoutMillis() > 0);
assertTrue(resolver.maxRetries() >= 0);
}
}
@@ -0,0 +1,97 @@
package com.nanri.aiimage.config;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* 巡检只读 SQL 报表静态契约基座(task-194..199)。
* 校验各 inspection/*.sql:以 SELECT 开头、无任何写语句/锁语句、含 NOT EXISTS/JOIN 关联、
* 带 LIMIT、分号结尾、头注释含目的与只读声明。子类提供文件名。
*/
abstract class InspectionSqlFileTestBase {
abstract String fileName();
private Path path() {
return Path.of(System.getProperty("user.dir"), "src/main/resources/inspection", fileName());
}
private String sql() throws IOException {
return Files.readString(path());
}
private String statements() throws IOException {
// 去掉整行注释与空行后的可执行 SQL 文本(头注释说明可能含"清理/删除"等字样,须排除)
StringBuilder sb = new StringBuilder();
for (String line : sql().split("\n")) {
String t = line.trim();
if (t.isEmpty() || t.startsWith("--")) {
continue;
}
sb.append(line).append('\n');
}
return sb.toString();
}
@Test
void fileExists() throws IOException {
assertTrue(Files.exists(path()), "巡检 SQL 应存在: " + fileName());
}
@Test
void startsWithSelect() throws IOException {
String first = statements().stripLeading();
assertTrue(first.toUpperCase().startsWith("SELECT"), "可执行 SQL 应以 SELECT 开头: " + fileName());
}
private static final String[] FORBIDDEN_WRITE_TOKENS = {
"INSERT", "UPDATE", "DELETE", "DROP", "TRUNCATE", "ALTER", "CREATE",
"GRANT", "REPLACE", "CALL", "LOAD", "DO"
};
@Test
void readOnlyNoWriteKeywords() throws IOException {
String upper = statements().toUpperCase();
for (String kw : FORBIDDEN_WRITE_TOKENS) {
java.util.regex.Pattern p = java.util.regex.Pattern.compile("(?<!\\w)" + java.util.regex.Pattern.quote(kw) + "(?!\\w)");
assertFalse(p.matcher(upper).find(), fileName() + " 不应含写语句 " + kw);
}
}
@Test
void hasRelationalJoinCondition() throws IOException {
String upper = statements().toUpperCase();
assertTrue(upper.contains("NOT EXISTS") || upper.contains("JOIN"),
fileName() + " 应含 NOT EXISTS 或 JOIN 关联条件");
}
@Test
void hasRowLimit() throws IOException {
assertTrue(statements().toUpperCase().contains("LIMIT"), fileName() + " 应带 LIMIT");
}
@Test
void noRowLock() throws IOException {
String upper = statements().toUpperCase();
assertFalse(upper.contains("FOR UPDATE"), fileName() + " 不应 FOR UPDATE 加锁");
assertFalse(upper.contains("LOCK"), fileName() + " 不应加锁");
}
@Test
void endsWithSemicolon() throws IOException {
assertTrue(sql().trim().endsWith(";"), fileName() + " 应以分号结尾");
}
@Test
void headerDocumentsPurposeAndReadOnly() throws IOException {
String text = sql();
assertTrue(text.contains("巡检"), fileName() + " 头注释应含巡检说明");
assertTrue(text.contains("只读"), fileName() + " 头注释应声明只读");
}
}
@@ -0,0 +1,124 @@
package com.nanri.aiimage.config;
import ch.qos.logback.classic.Logger;
import ch.qos.logback.classic.spi.ILoggingEvent;
import ch.qos.logback.core.read.ListAppender;
import org.junit.jupiter.api.Test;
import org.slf4j.LoggerFactory;
import java.io.IOException;
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-190:日志脱敏契约(spec 11 §2)。
* Token 部分脱敏规则单一来源(StructuredLog.maskTokenImageVideo 已委托);结构化字段值
* 消毒防日志注入;重试守卫的 URL(去 query)/异常消息体不入日志,保证敏感原文不落盘。
*/
class LogRedactionTest {
@Test
void shortTokenFullyMasked() {
assertEquals("***", StructuredLog.maskToken("abc123"));
}
@Test
void longTokenPartiallyMaskedNoOriginal() {
String original = "abcdefghij-ABCDEFGHIJ-secret-token-000";
String masked = StructuredLog.maskToken(original);
assertEquals(original.substring(0, 6) + "***" + original.substring(original.length() - 4), masked);
assertFalse(masked.contains(original), "脱敏结果不应包含原始 token");
}
@Test
void nullBlankTokenMaskedEmpty() {
assertEquals("", StructuredLog.maskToken(null));
assertEquals("", StructuredLog.maskToken(" "));
}
@Test
void maskingDeterministic() {
String token = "abcdefghijk-1234567890";
assertEquals(StructuredLog.maskToken(token), StructuredLog.maskToken(token));
}
@Test
void structuredFieldStripsNewlineNoLogInjection() {
String value = StructuredLog.field("正常前缀\r\nInject: stolen; more");
assertFalse(value.contains("\r"), "字段值不应含回车");
assertFalse(value.contains("\n"), "字段值不应含换行,防止日志注入");
}
@Test
void guardStripsTokenFromUrlQueryInLog() throws Exception {
IdempotentRetryGuard guard = new IdempotentRetryGuard(fastProps());
ListAppender<ILoggingEvent> captured = attach();
try {
AtomicInteger n = new AtomicInteger();
guard.execute("POST", true, "brand", "http://example/x?token=SECRET-QUERY-999", () -> {
if (n.incrementAndGet() < 2) {
throw new IOException("连接中断");
}
return "ok";
});
String text = join(captured);
assertFalse(text.contains("SECRET-QUERY-999"), "URL query 中的 token 不应入日志: " + text);
} finally {
detach(captured);
}
}
@Test
void guardExceptionMessageNotLogged() throws Exception {
IdempotentRetryGuard guard = new IdempotentRetryGuard(fastProps());
ListAppender<ILoggingEvent> captured = attach();
try {
AtomicInteger n = new AtomicInteger();
guard.execute("POST", true, "brand", "http://example/x", () -> {
if (n.incrementAndGet() < 2) {
throw new IOException("响应: Bearer secret-token-abc-9999");
}
return "ok";
});
String text = join(captured);
assertFalse(text.contains("secret-token-abc-9999"), "异常消息体不应入日志: " + text);
assertFalse(text.contains("Bearer"), "鉴权字样不应入日志");
} finally {
detach(captured);
}
}
@Test
void normalFieldsKeptWhileSensitiveMasked() {
// 普通字段原样保留;敏感字段走 maskToken 后不再含原文
assertTrue(StructuredLog.format(Map.of("taskId", 7L)).contains("taskId=7"), "普通字段应保留");
String token = "normal-token-value-1234567890";
assertFalse(StructuredLog.maskToken(token).equals(token), "maskToken 不应原样返回 token");
}
private static HttpClientProperties fastProps() {
HttpClientProperties p = new HttpClientProperties();
p.setBaseRetryDelayMillis(1);
return p;
}
private static ListAppender<ILoggingEvent> attach() {
Logger guardLogger = (Logger) LoggerFactory.getLogger(IdempotentRetryGuard.class);
ListAppender<ILoggingEvent> appender = new ListAppender<>();
appender.start();
guardLogger.addAppender(appender);
return appender;
}
private static void detach(ListAppender<ILoggingEvent> appender) {
((Logger) LoggerFactory.getLogger(IdempotentRetryGuard.class)).detachAppender(appender);
}
private static String join(ListAppender<ILoggingEvent> appender) {
return String.join("\n", appender.list.stream().map(ILoggingEvent::getFormattedMessage).toList());
}
}
@@ -0,0 +1,160 @@
package com.nanri.aiimage.config;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.MessageDigest;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Stream;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* task-193:历史迁移盘点审计(只读)。
* 校验 src/main/resources/db 版本化迁移:整数版本 1..max 连续、无重复、命名合规、
* 历史小版本(V25_1)被识别、校验和可复算稳定;产出 docs/migration-inventory.md 快照已提交。
*/
class MigrationInventoryTest {
private static final Path DB_DIR = Path.of(System.getProperty("user.dir"),
"src/main/resources/db");
private static final Pattern VERSION_FILE = Pattern.compile("^V(\\d+)(?:_(\\d+))?__.*\\.sql$");
private record Migration(int major, int minor) implements Comparable<Migration> {
boolean isInteger() {
return minor == 0;
}
@Override
public int compareTo(Migration o) {
return Integer.compare(major, o.major) != 0
? Integer.compare(major, o.major) : Integer.compare(minor, o.minor);
}
}
private List<Path> versionFiles() throws IOException {
try (Stream<Path> s = Files.list(DB_DIR)) {
return s.filter(p -> p.getFileName().toString().startsWith("V"))
.filter(p -> VERSION_FILE.matcher(p.getFileName().toString()).matches())
.sorted(Comparator.comparing(p -> p.getFileName().toString()))
.toList();
}
}
private List<Migration> parseAll(List<Path> files) {
List<Migration> list = new ArrayList<>();
for (Path f : files) {
Matcher m = VERSION_FILE.matcher(f.getFileName().toString());
if (m.matches()) {
list.add(new Migration(Integer.parseInt(m.group(1)),
m.group(2) == null ? 0 : Integer.parseInt(m.group(2))));
}
}
return list;
}
@Test
void integerVersionsSequentialFromOne() throws IOException {
List<Migration> all = parseAll(versionFiles());
List<Integer> integers = all.stream().filter(Migration::isInteger)
.map(Migration::major).sorted().toList();
for (int i = 0; i < integers.size(); i++) {
assertEquals(i + 1, integers.get(i), "整数版本应 1..N 连续,出现断号");
}
assertEquals(integers.size(), integers.get(integers.size() - 1), "整数版本应以 max 收尾且覆盖 1..max");
}
@Test
void legacySubversionIdentified() throws IOException {
List<Migration> all = parseAll(versionFiles());
assertTrue(all.stream().anyMatch(m -> m.major() == 25 && m.minor() == 1),
"历史小版本 V25_1 应被识别并保留(Flyway 语义 25.1");
}
@Test
void noDuplicateVersionFiles() throws IOException {
List<Path> files = versionFiles();
Set<String> names = new HashSet<>();
for (Path f : files) {
assertTrue(names.add(f.getFileName().toString()), "不应有重复版本文件名: " + f.getFileName());
}
}
@Test
void latestVersionPresentInDirectory() throws IOException {
List<Migration> all = parseAll(versionFiles());
int maxMajor = all.stream().filter(Migration::isInteger).mapToInt(Migration::major).max().orElse(0);
assertTrue(maxMajor >= 108, "最新迁移应 ≥ V108,实际 " + maxMajor);
boolean hasLatest = versionFiles().stream()
.anyMatch(p -> p.getFileName().toString().startsWith("V" + maxMajor + "__"));
assertTrue(hasLatest, "目录应含最新整数版本文件 V" + maxMajor + "__*");
}
@Test
void migrationFileNamesWellFormed() throws IOException {
for (Path f : versionFiles()) {
String name = f.getFileName().toString();
assertTrue(!name.contains(" "), "迁移文件名不应含空格: " + name);
assertTrue(name.matches("^V\\d+(?:_\\d+)?__[A-Za-z0-9_.-]+$"),
"命名应 V<版本>__<snake 描述>.sql: " + name);
}
}
@Test
void inventoryDocCommittedAndConsistent() throws IOException {
Path doc = Path.of(System.getProperty("user.dir"), "docs/migration-inventory.md");
assertTrue(Files.exists(doc), "盘点文档应存在并随仓库提交");
String text = Files.readString(doc);
assertTrue(text.contains("V108"), "文档应记录最新版本");
assertTrue(text.contains("25_1") || text.contains("V25_1"), "文档应记录历史小版本");
}
@Test
void checksumStableAcrossReads() throws IOException {
Map<String, String> first = sha256All(versionFiles());
Map<String, String> second = sha256All(versionFiles());
assertEquals(first, second, "迁移文件校验和应可复算稳定");
}
@Test
void auditRepeatable() throws IOException {
// 同一输入两次解析结果一致(纯读,可重复执行)
assertEquals(parseAll(versionFiles()), parseAll(versionFiles()));
assertEquals(sha256All(versionFiles()).size(), versionFiles().size());
}
private static Map<String, String> sha256All(List<Path> files) throws IOException {
Map<String, String> hashes = new HashMap<>();
for (Path f : files) {
hashes.put(f.getFileName().toString(), sha256(f));
}
return hashes;
}
private static String sha256(Path f) throws IOException {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] hash = digest.digest(Files.readAllBytes(f));
StringBuilder sb = new StringBuilder();
for (byte b : hash) {
sb.append(String.format("%02x", b));
}
return sb.toString();
} catch (java.security.NoSuchAlgorithmException e) {
throw new IllegalStateException(e);
}
}
}
@@ -0,0 +1,83 @@
package com.nanri.aiimage.config;
import org.junit.jupiter.api.Test;
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.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Stream;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* module_type 对齐守卫(task 外防回归)。
*
* 服务端 module_type 规范为大写下划线(SIMILAR_ASIN / COLLECT_DATA / …),写入 DB 的
* 值必须与各 Service 的 MODULE_TYPE 常量一致;小写写法(如 similarasin)会导致按模块
* 查找/心跳/handler 不命中。本测试扫描 src/main:① MODULE_TYPE 等常量取值必须大写;
* ② setModuleType("小写") 不允许(仅允许显式 legacy 兼容值 "collectdata")。
*/
class ModuleTypeAlignmentTest {
private static final Set<String> ALLOWED_LEGACY = Set.of("collectdata");
private static final Pattern CONST_DECL = Pattern.compile(
"(?:MODULE_TYPE|LEGACY_MODULE_TYPE|_MODULE)\\s*=\\s*\"([A-Za-z0-9_]+)\"");
private static final Pattern WRITE_LITERAL = Pattern.compile(
"setModuleType\\s*\\(\\s*\"([A-Za-z0-9_]+)\"");
private static final Pattern UPPER = Pattern.compile("^[A-Z][A-Z0-9_]*$");
private static List<Path> javaFiles() throws IOException {
Path root = Path.of(System.getProperty("user.dir"), "src/main/java/com/nanri/aiimage");
try (Stream<Path> s = Files.walk(root)) {
return s.filter(p -> p.toString().endsWith(".java")).toList();
}
}
@Test
void moduleTypeConstantsAreUppercase() throws IOException {
List<String> violations = new ArrayList<>();
for (Path f : javaFiles()) {
List<String> lines = Files.readAllLines(f);
for (int i = 0; i < lines.size(); i++) {
Matcher m = CONST_DECL.matcher(lines.get(i));
while (m.find()) {
String value = m.group(1);
if (!ALLOWED_LEGACY.contains(value) && !UPPER.matcher(value).matches()) {
violations.add(f + ":" + (i + 1) + " 小写/不规范模块常量: " + value);
}
}
}
}
assertTrue(violations.isEmpty(), "module_type 常量必须规范大写:\n " + String.join("\n ", violations));
}
@Test
void noLowercaseModuleTypeWrites() throws IOException {
List<String> violations = new ArrayList<>();
for (Path f : javaFiles()) {
List<String> lines = Files.readAllLines(f);
for (int i = 0; i < lines.size(); i++) {
Matcher m = WRITE_LITERAL.matcher(lines.get(i));
while (m.find()) {
String value = m.group(1);
if (!ALLOWED_LEGACY.contains(value) && !UPPER.matcher(value).matches()) {
violations.add(f + ":" + (i + 1) + " setModuleType(\"" + value + "\") 应用常量而非小写字面量");
}
}
}
}
assertTrue(violations.isEmpty(), "写入 module_type 应用规范大写常量:\n " + String.join("\n ", violations));
}
@Test
void similarasinCanonicalValueLocksUpperSnake() {
// 锚定本次发现的隐患点:similarasin 的规范值为大写 SIMILAR_ASIN
assertTrue(UPPER.matcher("SIMILAR_ASIN").matches());
}
}
@@ -0,0 +1,9 @@
package com.nanri.aiimage.config;
/** task-194:孤儿 task_file_job 巡检 SQL 契约。 */
class OrphanJobSqlInspectionTest extends InspectionSqlFileTestBase {
@Override
String fileName() {
return "01_orphan_job.sql";
}
}
@@ -0,0 +1,9 @@
package com.nanri.aiimage.config;
/** task-195:孤儿结果(payload) 巡检 SQL 契约。 */
class OrphanResultSqlInspectionTest extends InspectionSqlFileTestBase {
@Override
String fileName() {
return "02_orphan_result.sql";
}
}
@@ -0,0 +1,9 @@
package com.nanri.aiimage.config;
/** task-199:超保留期仍活跃 job 巡检 SQL 契约。 */
class OverRetentionActiveJobSqlInspectionTest extends InspectionSqlFileTestBase {
@Override
String fileName() {
return "06_over_retention_active_job.sql";
}
}
@@ -0,0 +1,117 @@
package com.nanri.aiimage.config;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
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.Set;
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-202Python-contract 夹具准备契约。
* 校验 python-contract/*.json 存在、为合法 JSON、顶层字段与真实 DTO 对齐、已脱敏(无真实凭据)、
* 可复现;字段来源与脱敏口径见同目录 README.md。
*/
class PythonContractFixtureTest {
private static final Path DIR = Path.of(System.getProperty("user.dir"),
"src/test/resources/python-contract");
private static final ObjectMapper MAPPER = new ObjectMapper();
private JsonNode json(String name) throws IOException {
return MAPPER.readTree(Files.readAllBytes(DIR.resolve(name)));
}
private static Set<String> keys(JsonNode node) {
List<String> names = new ArrayList<>();
node.fieldNames().forEachRemaining(names::add);
return Set.copyOf(names);
}
@Test
void fixtureResultRequestExists() throws IOException {
JsonNode n = json("result.request.json");
assertEquals("sim-20260905-0001", n.path("submissionId").asText());
}
@Test
void fixtureItemsExists() throws IOException {
JsonNode n = json("items.response.json");
assertTrue(n.path("items").isArray() && n.path("items").size() >= 1);
}
@Test
void fixtureParsedPayloadExists() throws IOException {
JsonNode n = json("parsed_payload.response.json");
assertTrue(n.has("allItems") && n.has("groups") && n.has("items"));
}
@Test
void fixtureHeartbeatExists() throws IOException {
JsonNode req = json("heartbeat.request.json");
JsonNode resp = json("heartbeat.response.json");
assertTrue(req.has("moduleType") && req.has("phase"));
assertTrue(resp.has("alive") && resp.has("status"));
assertEquals("SIMILAR_ASIN", req.path("moduleType").asText(),
"夹具 moduleType 须用规范大写(与服务 MODULE_TYPE 常量一致,防小写漂移 bug)");
}
@Test
void fixtureSanitizedNoRealCredentials() throws IOException {
String all = String.join("\n", List.of(
Files.readString(DIR.resolve("parsed_payload.response.json")),
Files.readString(DIR.resolve("result.request.json"))));
assertFalse(all.contains("WTFrb"), "不得出现真实 DB/内部凭据");
assertFalse(all.toLowerCase().contains("bearer "), "不得出现真实 token");
JsonNode parsed = json("parsed_payload.response.json");
assertEquals("<redacted>", parsed.path("apiKey").asText(), "apiKey 必须占位");
assertFalse(parsed.path("aiPrompt").asText().toLowerCase().contains("真实"),
"aiPrompt 不得含真实内容");
}
@Test
void fixtureShapeValidJson() throws IOException {
for (String name : List.of("heartbeat.request.json", "heartbeat.response.json",
"items.response.json", "result.request.json", "parsed_payload.response.json")) {
JsonNode n = json(name);
assertTrue(n.isContainerNode(), name + " 应为 JSON 对象/数组");
}
}
@Test
void fixtureFieldsDocumentedByReadme() throws IOException {
String readme = Files.readString(DIR.resolve("README.md"));
assertTrue(readme.contains("TaskHeartbeatRequest") && readme.contains("CollectDataItemsPageVo")
&& readme.contains("SimilarAsinSubmitResultRequest")
&& readme.contains("SimilarAsinParsedPayloadDto"), "README 应说明字段来源");
assertTrue(readme.contains("脱敏"), "README 应说明脱敏");
}
@Test
void fixtureHeartbeatTopKeysMatchDto() throws IOException {
assertEquals(Set.of("moduleType", "phase", "current", "total", "collectStage",
"currentKeyword", "searchCurrentPage", "searchTotalPages",
"detailProcessedAsins", "detailTotalAsins"),
keys(json("heartbeat.request.json")), "心跳请求顶层字段应与 TaskHeartbeatRequest 对齐");
assertEquals(Set.of("alive", "status", "moduleType", "message"),
keys(json("heartbeat.response.json")), "心跳响应应与 TaskHeartbeatVo 对齐");
}
@Test
void fixtureReproducibleStable() throws IOException {
for (String name : List.of("heartbeat.request.json", "items.response.json",
"result.request.json", "parsed_payload.response.json")) {
String a = Files.readString(DIR.resolve(name));
String b = Files.readString(DIR.resolve(name));
assertEquals(a, b, name + " 应可复现");
}
}
}
@@ -0,0 +1,9 @@
package com.nanri.aiimage.config;
/** task-197:结果缺文件候选 巡检 SQL 契约(对象存在由 Java+OSS 反查)。 */
class ResultMissingFileSqlInspectionTest extends InspectionSqlFileTestBase {
@Override
String fileName() {
return "04_result_missing_file.sql";
}
}
@@ -0,0 +1,101 @@
package com.nanri.aiimage.config;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* task-178:日志字段规范落地契约(spec 11 §2)。
*
* 规范字段集与缺失占位固化:StructuredLog 只输出规范字段、顺序固定、缺失用 "-" 占位、
* 值消毒(换行/控制字符、超长截断)。并用源码锚点断言代表性日志(task-file-job Worker /
* task-heartbeat)已携带 taskId/moduleType 等核心字段(不删现有日志)。
*/
class StructuredLogFieldSpecTest {
@Test
void canonicalFieldNamesFrozenInSpecOrder() {
assertEquals(List.of("traceId", "taskId", "moduleType", "stage",
"submissionId", "chunkIndex", "chunkTotal", "jobId", "result", "errorType"),
StructuredLog.FIELD_NAMES, "字段名与顺序应冻结,勿漂移");
}
@Test
void missingFieldsRenderedWithPlaceholder() {
String out = StructuredLog.format(Map.of());
assertTrue(out.contains("taskId=-"), "缺失字段应占位,实际: " + out);
assertTrue(out.contains("moduleType=-"));
assertTrue(out.contains("stage=-"));
assertTrue(out.contains("jobId=-"));
}
@Test
void partialFieldsKeepCanonicalKeysAndSkipUnknown() {
String out = StructuredLog.format(Map.of("taskId", 7L, "jobId", 9L, "unknownField", "x"));
assertTrue(out.contains("taskId=7"), "实际: " + out);
assertTrue(out.contains("jobId=9"));
assertFalse(out.contains("unknownField"), "不应输出非规范字段: " + out);
assertTrue(out.contains("stage=-"));
assertTrue(out.contains("result=-"));
assertTrue(out.contains("errorType=-"));
}
@Test
void controlCharsAndNewlineSanitized() {
String value = StructuredLog.field("a\nb=1\r");
assertFalse(value.contains("\n"), "值内不应出现换行: " + value);
assertFalse(value.contains("\r"));
assertFalse(value.chars().anyMatch(c -> c < 0x20 && c != ' '), "不应含控制字符");
assertTrue(value.contains("b=1"), "普通内容应保留: " + value);
}
@Test
void nullEmptyWhitespaceNormalizedToPlaceholder() {
assertEquals(StructuredLog.MISSING, StructuredLog.field(null));
assertEquals(StructuredLog.MISSING, StructuredLog.field(""));
assertEquals(StructuredLog.MISSING, StructuredLog.field(" "));
}
@Test
void overlongValueTruncated() {
String longValue = "x".repeat(1000);
assertEquals(StructuredLog.MAX_VALUE_LENGTH, StructuredLog.field(longValue).length());
}
@Test
void workerLogLinesCarryTaskAndModuleFields() throws IOException {
String source = readSource("src/main/java/com/nanri/aiimage/modules/task/service/TaskResultFileJobWorker.java");
List<String> perJobLines = source.lines()
.filter(line -> line.contains("[task-file-job]") && line.contains("jobId="))
.toList();
assertFalse(perJobLines.isEmpty(), "Worker 应存在按 job 的日志行");
for (String line : perJobLines) {
assertTrue(line.contains("taskId="), "job 级日志应带 taskId: " + line.trim());
assertTrue(line.contains("moduleType="), "job 级日志应带 moduleType: " + line.trim());
}
}
@Test
void heartbeatLogLinesCarryTaskField() throws IOException {
String source = readSource("src/main/java/com/nanri/aiimage/modules/task/service/TaskHeartbeatService.java");
List<String> lines = source.lines()
.filter(line -> line.contains("[task-heartbeat]") && line.contains("log."))
.toList();
assertFalse(lines.isEmpty(), "心跳服务应存在日志行");
for (String line : lines) {
assertTrue(line.contains("taskId="), "心跳日志应带 taskId: " + line.trim());
}
}
private static String readSource(String relative) throws IOException {
return Files.readString(Path.of(System.getProperty("user.dir"), relative));
}
}
@@ -0,0 +1,9 @@
package com.nanri.aiimage.config;
/** task-196:终态任务缺结果 巡检 SQL 契约。 */
class TaskMissingResultSqlInspectionTest extends InspectionSqlFileTestBase {
@Override
String fileName() {
return "03_task_missing_result.sql";
}
}
@@ -0,0 +1,126 @@
package com.nanri.aiimage.config;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* task-180TaskResultFileJobWorker 日志字段与规范对齐(spec 11 §2)。
*
* Worker 的 [task-file-job] 日志统一携带 jobId/taskId/moduleType,并补充规范字段 stage
* (阶段字面量)与 errorType(异常类名);保留既有前缀与语义,不记录敏感内容。
* 源码锚点审计,纯本地可重复。
*/
class TaskResultFileJobWorkerLogAuditTest {
private static final String WORKER = "src/main/java/com/nanri/aiimage/modules/task/service/TaskResultFileJobWorker.java";
private List<String> lines() throws IOException {
return Files.readAllLines(Path.of(System.getProperty("user.dir"), WORKER));
}
private static boolean hasLine(List<String> lines, String needle) {
return lines.stream().anyMatch(l -> l.contains(needle));
}
@Test
void dispatchLogHasStageAndJobFields() throws IOException {
List<String> lines = lines();
String target = lines.stream()
.filter(l -> l.contains("[task-file-job]") && l.contains("stage=SKIP_OWNER"))
.findFirst().orElseThrow();
assertTrue(target.contains("jobId={}") && target.contains("taskId={}") && target.contains("moduleType={}"),
"dispatch 日志应带 jobId/taskId/moduleType: " + target.trim());
}
@Test
void successLogHasStageAndFields() throws IOException {
List<String> lines = lines();
String target = lines.stream()
.filter(l -> l.contains("process success") && l.contains("[task-file-job]"))
.findFirst().orElseThrow();
assertTrue(target.contains("stage=SUCCESS"), "成功日志应带 stage=SUCCESS");
assertTrue(target.contains("jobId={}") && target.contains("taskId={}") && target.contains("moduleType={}"));
}
@Test
void failureLogHasStageAndErrorType() throws IOException {
List<String> lines = lines();
String target = lines.stream()
.filter(l -> l.contains("process failed") && l.contains("[task-file-job]"))
.findFirst().orElseThrow();
assertTrue(target.contains("stage=FAILED"), "失败日志应带 stage=FAILED");
assertTrue(target.contains("errorType={}"), "失败日志应带 errorType 占位");
assertTrue(hasLine(lines, "message, ex.getClass().getSimpleName());"),
"失败日志应记录异常类名");
}
@Test
void retryAndRecoverLogsHaveStage() throws IOException {
List<String> lines = lines();
assertTrue(hasLine(lines, "stage=REQUEUE"), "锁忙重排应带 stage=REQUEUE");
assertTrue(hasLine(lines, "stage=DEFER"), "延后等待应带 stage=DEFER");
assertTrue(hasLine(lines, "stage=WAIT_LLM"), "等待异步 LLM 应带 stage=WAIT_LLM");
}
@Test
void heartbeatLogHasStageAndFields() throws IOException {
List<String> lines = lines();
String target = lines.stream()
.filter(l -> l.contains("heartbeat failed") && l.contains("[task-file-job]"))
.findFirst().orElseThrow();
assertTrue(target.contains("stage=HEARTBEAT"), "心跳日志应带 stage=HEARTBEAT");
assertTrue(target.contains("jobId={}") && target.contains("taskId={}") && target.contains("moduleType={}"));
}
@Test
void stuckFinalizeLogsHaveStage() throws IOException {
List<String> lines = lines();
assertTrue(hasLine(lines, "stage=FINALIZE"), "重试耗尽终态回调应带 stage=FINALIZE");
assertTrue(hasLine(lines, "stage=ORPHAN"), "孤儿 job 中断应带 stage=ORPHAN");
}
@Test
void fieldNamesConsistentAndPrefixKept() throws IOException {
List<String> lines = lines();
List<String> perJob = lines.stream()
.filter(l -> l.contains("[task-file-job]") && l.contains("log."))
.toList();
assertFalse(perJob.isEmpty());
for (String l : perJob) {
assertFalse(l.contains("job="), "不应使用 job= 漂移字段: " + l.trim());
assertFalse(l.contains("task="), "不应使用 task= 漂移字段: " + l.trim());
assertFalse(l.contains("module="), "不应使用 module= 漂移字段: " + l.trim());
}
// 阶段字面量来自允许集合(防新增阶段拼写漂移)
List<String> allowed = List.of("SKIP_OWNER", "REQUEUE", "WAIT_LLM", "DEFER", "SUCCESS",
"ORPHAN", "FAILED", "FINALIZE", "HEARTBEAT");
for (String l : perJob) {
if (l.contains("stage=")) {
int idx = l.indexOf("stage=");
String rawValue = l.substring(idx + "stage=".length()).split("[^A-Z0-9_]")[0];
assertTrue(allowed.contains(rawValue),
"阶段字面量应来自允许集合: " + rawValue + " @ " + l.trim());
}
}
}
@Test
void noSensitiveFieldsInWorkerLogs() throws IOException {
List<String> lines = lines();
for (String l : lines) {
if (!l.contains("[task-file-job]")) {
continue;
}
assertFalse(l.contains("password=") || l.contains("secret=") || l.contains("apiKey=")
|| l.contains("cookie=") || l.contains("Bearer") || l.contains("authorization"),
"Worker 日志不应记录敏感字段: " + l.trim());
}
}
}
@@ -0,0 +1,9 @@
package com.nanri.aiimage.config;
/** task-198:终态任务仍有活跃 job 巡检 SQL 契约。 */
class TerminalTaskActiveJobSqlInspectionTest extends InspectionSqlFileTestBase {
@Override
String fileName() {
return "05_terminal_task_active_job.sql";
}
}
@@ -0,0 +1,96 @@
package com.nanri.aiimage.config;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* task-170:紫鸟客户端配置接入契约(plan 10,诚实模式)。
*
* 现网真实基线(已核实):
* - 连接超时:共用 HttpClientHttpClientPool connect=10s),不读 aiimage.ziniao.connect 字段;
* - 读取超时:ZiniaoClientImpl 用 ziniaoProperties.readTimeoutSeconds*1000(生产默认 15s);
* - 无 call timeout / 无重试。
*
* resolver 语义:模块级现有配置优先(read 取 aiimage.ziniao 秒数),命名空间兜底;
* 不改调用点,保证接入未来切点时行为不变。
*/
class ZiniaoHttpConfigResolverTest {
private HttpClientProperties http;
private ZiniaoProperties ziniao;
private ZiniaoHttpConfigResolver resolver;
@BeforeEach
void setUp() {
http = new HttpClientProperties();
ziniao = new ZiniaoProperties();
resolver = new ZiniaoHttpConfigResolver(http, ziniao);
}
@Test
void readDefaultsToCurrentZiniaoModuleConfig() {
// 生产现状:aiimage.ziniao.read-timeout-seconds=15 → requestFactory(15000)
ziniao.setReadTimeoutSeconds(15);
assertEquals(15_000L, resolver.readTimeoutMillis());
}
@Test
void moduleReadWinsOverNamespace() {
// 模块级现有配置优先,命名空间覆盖不影响紫鸟 read(行为不变)
ziniao.setReadTimeoutSeconds(15);
http.setReadTimeoutMillis(120_000);
assertEquals(15_000L, resolver.readTimeoutMillis());
}
@Test
void readFallsBackToNamespaceWhenModuleUnset() {
// 模块未配置(<=0) → 命名空间 read(默认 60s,可覆盖)
ziniao.setReadTimeoutSeconds(0);
assertEquals(60_000L, resolver.readTimeoutMillis());
http.setReadTimeoutMillis(30_000);
assertEquals(30_000L, resolver.readTimeoutMillis());
}
@Test
void connectMatchesSharedClientBaseline() {
// 连接超时由共享 HttpClient 决定(HttpClientPool 硬编码 10s),不读模块 connect 字段
assertEquals(10_000L, resolver.connectTimeoutMillis());
http.setConnectTimeoutMillis(30_000);
assertEquals(30_000L, resolver.connectTimeoutMillis());
}
@Test
void callTimeoutFromNamespace() {
assertEquals(90_000L, resolver.callTimeoutMillis());
http.setCallTimeoutMillis(180_000);
assertEquals(180_000L, resolver.callTimeoutMillis());
}
@Test
void retryFromNamespaceClamped() {
assertEquals(3, resolver.maxRetries());
http.setMaxRetries(99);
assertEquals(10, resolver.maxRetries(), "命名空间重试钳制到 10");
assertEquals(500L, resolver.baseRetryDelayMillis());
}
@Test
void invalidNamespaceTimeoutsClamped() {
http.setConnectTimeoutMillis(-1);
assertEquals(1_000L, resolver.connectTimeoutMillis());
ziniao.setReadTimeoutSeconds(0);
http.setReadTimeoutMillis(999_999_999L);
assertEquals(3_600_000L, resolver.readTimeoutMillis(), "命名空间 read 钳制到 3600s");
}
@Test
void resolverWiredAsStableAccessSurface() {
// 作为未来 ZiniaoClientImpl 切换读取源时的稳定访问面
assertTrue(resolver.connectTimeoutMillis() > 0);
assertTrue(resolver.readTimeoutMillis() > 0);
assertTrue(resolver.maxRetries() >= 0);
}
}
@@ -0,0 +1,96 @@
package com.nanri.aiimage.metrics;
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
import org.junit.jupiter.api.Test;
import java.util.stream.Collectors;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* task-191:双实例指标区分回归契约(spec 11 §1instanceId 区分 server-110/server-121)。
* 同一 registry 下两实例同模块指标按 instanceId 分桶、不串;未配置 instanceId 归 unknown
* 指标名集合稳定一致。
*/
class DualInstanceMetricTest {
private final SimpleMeterRegistry registry = new SimpleMeterRegistry();
@Test
void instance110LabelApplied() {
new TaskObservabilityMetrics("server-110", registry).taskCreated("similarasin");
assertEquals("server-110",
registry.get("aiimage.task.created").counter().getId().getTag("instanceId"));
}
@Test
void instance121LabelApplied() {
new TaskObservabilityMetrics("server-121", registry).taskCreated("similarasin");
assertEquals("server-121",
registry.get("aiimage.task.created").counter().getId().getTag("instanceId"));
}
@Test
void twoInstancesDoNotCrossCount() {
TaskObservabilityMetrics a = new TaskObservabilityMetrics("server-110", registry);
TaskObservabilityMetrics b = new TaskObservabilityMetrics("server-121", registry);
a.taskCreated("similarasin");
b.taskCreated("similarasin");
b.taskCreated("similarasin");
assertEquals(1.0, registry.get("aiimage.task.created").tag("moduleType", "similarasin")
.tag("instanceId", "server-110").counter().count());
assertEquals(2.0, registry.get("aiimage.task.created").tag("moduleType", "similarasin")
.tag("instanceId", "server-121").counter().count());
}
@Test
void unconfiguredInstanceFallsBackToUnknown() {
new TaskObservabilityMetrics(null, registry).taskCreated("brand");
assertEquals("unknown",
registry.get("aiimage.task.created").counter().getId().getTag("instanceId"));
new TaskObservabilityMetrics(" ", registry).taskCreated("brand");
}
@Test
void metricsAggregatableAcrossInstances() {
TaskObservabilityMetrics a = new TaskObservabilityMetrics("server-110", registry);
TaskObservabilityMetrics b = new TaskObservabilityMetrics("server-121", registry);
a.taskTerminal("similarasin", "success");
b.taskTerminal("similarasin", "success");
// 聚合:跨 instanceId 各 meter 求和,双实例总数应为 2
double total = registry.find("aiimage.task.success").tag("moduleType", "similarasin").counters().stream()
.mapToDouble(io.micrometer.core.instrument.Counter::count).sum();
assertEquals(2.0, total, "同模块跨实例指标可聚合");
}
@Test
void instanceLabelStableAcrossCalls() {
TaskObservabilityMetrics a = new TaskObservabilityMetrics("server-110", registry);
a.taskCreated("publish");
a.taskCreated("publish");
assertEquals(1, registry.find("aiimage.task.created").tag("moduleType", "publish")
.tag("instanceId", "server-110").counters().size(), "同实例同模块应为同一 meter");
assertEquals(2.0, registry.get("aiimage.task.created").tag("moduleType", "publish")
.tag("instanceId", "server-110").counter().count());
}
@Test
void instanceIdGetterReturnsConfiguredValue() {
assertEquals("server-121", new TaskObservabilityMetrics("server-121", null).instanceId());
assertNotEquals("", new TaskObservabilityMetrics("server-121", null).instanceId());
}
@Test
void metricNameSetIdenticalAcrossInstances() {
TaskObservabilityMetrics a = new TaskObservabilityMetrics("server-110", registry);
TaskObservabilityMetrics b = new TaskObservabilityMetrics("server-121", registry);
a.taskCreated("x");
b.taskCreated("x");
java.util.Set<String> names = registry.getMeters().stream()
.map(m -> m.getId().getName()).collect(Collectors.toSet());
assertTrue(names.contains("aiimage.task.created"));
assertEquals(1, names.size(), "仅指标名集合应一致(标签不同不算漂移)");
}
}
@@ -0,0 +1,82 @@
package com.nanri.aiimage.metrics;
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
import org.junit.jupiter.api.Test;
import java.util.concurrent.TimeUnit;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* task-185file-job.* 指标契约(spec 11 §1TaskResultFileJobWorker 接线面)。
* pending/running/success/failed/retry 计数 + duration 耗时,分 moduleType/instanceId。
*/
class FileJobMetricsTest {
private final SimpleMeterRegistry registry = new SimpleMeterRegistry();
private final TaskObservabilityMetrics metrics = new TaskObservabilityMetrics("server-110", registry);
private double count(String state, String moduleType) {
io.micrometer.core.instrument.Counter c = registry.find("aiimage.file-job." + state)
.tag("moduleType", moduleType).counter();
return c == null ? 0.0 : c.count();
}
@Test
void pendingCounted() {
metrics.fileJobState("similarasin", "pending");
assertEquals(1.0, count("pending", "similarasin"));
}
@Test
void runningCounted() {
metrics.fileJobState("similarasin", "running");
assertEquals(1.0, count("running", "similarasin"));
}
@Test
void successCounted() {
metrics.fileJobState("similarasin", "success");
assertEquals(1.0, count("success", "similarasin"));
}
@Test
void failedCounted() {
metrics.fileJobState("similarasin", "failed");
assertEquals(1.0, count("failed", "similarasin"));
}
@Test
void retryCounted() {
metrics.fileJobState("similarasin", "retry");
assertEquals(1.0, count("retry", "similarasin"));
}
@Test
void durationRecorded() {
metrics.fileJobDuration("similarasin", 2_000L);
assertEquals(1, registry.get("aiimage.file-job.duration").tag("moduleType", "similarasin").timer().count());
assertEquals(2_000.0,
registry.get("aiimage.file-job.duration").tag("moduleType", "similarasin").timer()
.totalTime(TimeUnit.MILLISECONDS), 0.001);
}
@Test
void splitByModuleAndInstance() {
metrics.fileJobState("similarasin", "success");
metrics.fileJobState("publish", "success");
assertEquals(1.0, count("success", "similarasin"));
assertEquals(1.0, count("success", "publish"));
assertEquals("server-110",
registry.get("aiimage.file-job.success").counter().getId().getTag("instanceId"));
}
@Test
void fileJobNamesFrozen() {
metrics.fileJobState("similarasin", "success");
metrics.fileJobDuration("similarasin", 10L);
assertTrue(registry.getMeters().stream().anyMatch(m -> m.getId().getName().equals("aiimage.file-job.success")));
assertTrue(registry.getMeters().stream().anyMatch(m -> m.getId().getName().equals("aiimage.file-job.duration")));
}
}
@@ -0,0 +1,124 @@
package com.nanri.aiimage.metrics;
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
import org.junit.jupiter.api.Test;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* task-189:指标命名一致性契约(spec 11 §1 标准名)。
* 全部指标名 aiimage.* 前缀、小写点分、无拼写漂移;recorder 全量指标名快照与 spec 对齐。
*/
class MetricNamingConsistencyTest {
private final SimpleMeterRegistry registry = new SimpleMeterRegistry();
private final TaskObservabilityMetrics metrics = new TaskObservabilityMetrics("server-110", registry);
private void createAllMetrics() {
metrics.taskCreated("similarasin");
metrics.taskRunningDelta("similarasin", 1);
metrics.taskTerminal("similarasin", "success");
metrics.taskTerminal("similarasin", "failed");
metrics.taskTerminal("similarasin", "cancelled");
metrics.taskDuration("similarasin", 1L);
metrics.taskHeartbeatAge("similarasin", 1L);
metrics.fileJobState("similarasin", "pending");
metrics.fileJobState("similarasin", "running");
metrics.fileJobState("similarasin", "success");
metrics.fileJobState("similarasin", "failed");
metrics.fileJobState("similarasin", "retry");
metrics.fileJobDuration("similarasin", 1L);
metrics.upload("similarasin", 1L, 1L, true);
metrics.upload("similarasin", 1L, 1L, false);
metrics.disk("/tmp/x", 1L, 1L);
}
@Test
void allNamesHaveAiimagePrefix() {
createAllMetrics();
for (String name : names()) {
assertTrue(name.startsWith("aiimage."), "指标应带 aiimage. 前缀: " + name);
}
}
@Test
void allNamesLowercaseDotSegments() {
createAllMetrics();
for (String name : names()) {
assertTrue(name.equals(name.toLowerCase()), "指标名应全小写: " + name);
assertTrue(!name.contains(".."), "指标名不应有连续点: " + name);
assertTrue(!name.endsWith("."), "指标名不应以点结尾: " + name);
}
}
@Test
void taskFamilyNamesFrozen() {
createAllMetrics();
for (String n : List.of("aiimage.task.created", "aiimage.task.running", "aiimage.task.success",
"aiimage.task.failed", "aiimage.task.cancelled", "aiimage.task.duration",
"aiimage.task.heartbeat.age")) {
assertTrue(names().contains(n), "缺少 spec 任务指标名: " + n);
}
}
@Test
void fileJobFamilyNamesFrozen() {
createAllMetrics();
for (String n : List.of("aiimage.file-job.pending", "aiimage.file-job.running",
"aiimage.file-job.success", "aiimage.file-job.failed", "aiimage.file-job.retry",
"aiimage.file-job.duration")) {
assertTrue(names().contains(n), "缺少 spec file-job 指标名: " + n);
}
}
@Test
void noTypoDriftSnapshot() {
createAllMetrics();
Set<String> names = names();
// 与 spec 11 §1 允许集完全一致:不允许出现拼写漂移产生的多余名字
Set<String> allowed = Set.of(
"aiimage.task.created", "aiimage.task.running", "aiimage.task.success",
"aiimage.task.failed", "aiimage.task.cancelled", "aiimage.task.duration",
"aiimage.task.heartbeat.age",
"aiimage.file-job.pending", "aiimage.file-job.running", "aiimage.file-job.success",
"aiimage.file-job.failed", "aiimage.file-job.retry", "aiimage.file-job.duration",
"aiimage.upload.duration", "aiimage.upload.size", "aiimage.upload.result",
"aiimage.upload.result.success", "aiimage.upload.result.failure",
"aiimage.tmpdisk.capacity", "aiimage.tmpdisk.used");
Set<String> unexpected = names.stream().filter(n -> !allowed.contains(n)).collect(Collectors.toSet());
assertEquals(Set.of(), unexpected, "不应出现 spec 之外的漂移指标名");
}
@Test
void snapshotAllMetricsCaptured() {
createAllMetrics();
assertTrue(names().size() >= 19, "全量指标快照应覆盖任务/文件/上传/磁盘族,实际 " + names().size());
}
@Test
void caseConsistentAcrossFamilies() {
createAllMetrics();
long upperCount = names().stream().filter(n -> n.chars().anyMatch(Character::isUpperCase)).count();
assertEquals(0, upperCount, "指标名应统一小写");
}
@Test
void metricNamingStableAcrossRepeatedCreation() {
createAllMetrics();
Set<String> first = names();
TaskObservabilityMetrics metrics2 = new TaskObservabilityMetrics("server-121", registry);
metrics2.taskCreated("similarasin");
Set<String> second = names();
assertEquals(first, second, "新增实例不应改变既有指标名集合");
}
private Set<String> names() {
return registry.getMeters().stream()
.map(m -> m.getId().getName()).collect(Collectors.toSet());
}
}
@@ -0,0 +1,112 @@
package com.nanri.aiimage.metrics;
import io.micrometer.core.instrument.Meter;
import io.micrometer.core.instrument.Tag;
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
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.assertTrue;
/**
* task-188:指标标签规范契约(spec 11 §1)。
* moduleType/instanceId 必带(任务/文件/上传族);null/blank 归一 unknown;不记录用户级标签;
* 值清洗;磁盘按 path 标签。标签名冻结。
*/
class MetricTagSpecTest {
private final SimpleMeterRegistry registry = new SimpleMeterRegistry();
private final TaskObservabilityMetrics metrics = new TaskObservabilityMetrics("server-110", registry);
private void seedAllFamilies() {
metrics.taskCreated("similarasin");
metrics.taskRunningDelta("similarasin", 1);
metrics.taskTerminal("similarasin", "success");
metrics.taskTerminal("similarasin", "failed");
metrics.taskDuration("similarasin", 10L);
metrics.taskHeartbeatAge("similarasin", 10L);
metrics.fileJobState("similarasin", "running");
metrics.fileJobDuration("similarasin", 10L);
metrics.upload("similarasin", 1L, 1L, true);
}
@Test
void taskFamilyCarriesModuleAndInstanceLabels() {
seedAllFamilies();
for (String name : List.of("aiimage.task.created", "aiimage.task.running", "aiimage.task.success",
"aiimage.task.duration", "aiimage.task.heartbeat.age")) {
assertTrue(hasTagKey(name, "moduleType"), name + " 应带 moduleType");
assertTrue(hasTagKey(name, "instanceId"), name + " 应带 instanceId");
}
}
@Test
void fileJobAndUploadCarryModuleAndInstanceLabels() {
seedAllFamilies();
for (String name : List.of("aiimage.file-job.running", "aiimage.file-job.duration",
"aiimage.upload.duration", "aiimage.upload.size")) {
assertTrue(hasTagKey(name, "moduleType"), name + " 应带 moduleType");
assertTrue(hasTagKey(name, "instanceId"), name + " 应带 instanceId");
}
}
@Test
void nullModuleNormalizedToUnknown() {
metrics.taskCreated(null);
metrics.taskCreated(" ");
assertEquals("unknown",
registry.get("aiimage.task.created").counter().getId().getTag("moduleType"));
}
@Test
void nullInstanceNormalizedToUnknown() {
TaskObservabilityMetrics noInstance = new TaskObservabilityMetrics(null, registry);
noInstance.taskCreated("brand");
assertEquals("unknown",
registry.get("aiimage.task.created").counter().getId().getTag("instanceId"));
}
@Test
void noUserLevelLabels() {
seedAllFamilies();
for (Meter meter : registry.getMeters()) {
for (Tag tag : meter.getId().getTags()) {
assertFalse(List.of("userId", "user_id", "uid", "operatorId", "operator_id").contains(tag.getKey()),
"指标不应带用户级标签: " + tag.getKey());
}
}
}
@Test
void labelValueTrimmed() {
metrics.taskCreated(" publish ");
assertEquals("publish",
registry.get("aiimage.task.created").counter().getId().getTag("moduleType"));
}
@Test
void tagKeysFrozen() {
metrics.taskCreated("brand");
Meter meter = registry.get("aiimage.task.created").counter();
List<String> keys = meter.getId().getTags().stream().map(Tag::getKey).sorted().toList();
assertEquals(List.of("instanceId", "moduleType"), keys, "任务族标签应只有 moduleType/instanceId");
}
@Test
void diskFamilyUsesPathLabel() {
metrics.disk("/tmp/x", 10L, 1L);
assertTrue(registry.get("aiimage.tmpdisk.used").gauge().getId().getTags().stream()
.anyMatch(t -> t.getKey().equals("path") && t.getValue().equals("/tmp/x")));
assertFalse(registry.get("aiimage.tmpdisk.used").gauge().getId().getTags().stream()
.anyMatch(t -> t.getKey().equals("instanceId")), "磁盘指标按 path,不必带 instanceId");
}
private boolean hasTagKey(String name, String key) {
return registry.find(name).meters().stream()
.flatMap(m -> m.getId().getTags().stream())
.anyMatch(t -> t.getKey().equals(key));
}
}
@@ -0,0 +1,77 @@
package com.nanri.aiimage.metrics;
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* task-183task.duration 耗时记录契约(spec 11 §1)。
* 开始→终态耗时以 Timer 记录(未配置注册表时降级 debug 日志);非负;分 moduleType。
*/
class TaskDurationMetricsTest {
private final SimpleMeterRegistry registry = new SimpleMeterRegistry();
private final TaskObservabilityMetrics metrics = new TaskObservabilityMetrics("server-110", registry);
private io.micrometer.core.instrument.Timer timer(String moduleType) {
return registry.get("aiimage.task.duration").tag("moduleType", moduleType).timer();
}
@Test
void durationRecorded() {
metrics.taskDuration("similarasin", 1_234L);
assertEquals(1, timer("similarasin").count());
assertEquals(1_234.0, timer("similarasin").totalTime(java.util.concurrent.TimeUnit.MILLISECONDS), 0.001);
}
@Test
void durationAccumulates() {
metrics.taskDuration("similarasin", 100L);
metrics.taskDuration("similarasin", 200L);
assertEquals(2, timer("similarasin").count());
assertEquals(300.0, timer("similarasin").totalTime(java.util.concurrent.TimeUnit.MILLISECONDS), 0.001);
}
@Test
void shortTaskRecorded() {
metrics.taskDuration("similarasin", 1L);
assertTrue(timer("similarasin").count() >= 1);
}
@Test
void longTaskRecordedLarger() {
metrics.taskDuration("similarasin", 600_000L);
assertEquals(600_000.0, timer("similarasin").totalTime(java.util.concurrent.TimeUnit.MILLISECONDS), 0.001);
}
@Test
void durationNeverNegative() {
metrics.taskDuration("similarasin", -5L);
assertEquals(0.0, timer("similarasin").totalTime(java.util.concurrent.TimeUnit.MILLISECONDS), 0.001,
"负耗时按 0 处理,不应破坏指标");
}
@Test
void durationSplitByModule() {
metrics.taskDuration("similarasin", 100L);
metrics.taskDuration("publish", 50L);
assertEquals(1, timer("similarasin").count());
assertEquals(1, timer("publish").count());
}
@Test
void durationNameFrozen() {
metrics.taskDuration("similarasin", 10L);
assertTrue(registry.getMeters().stream()
.anyMatch(m -> m.getId().getName().equals("aiimage.task.duration")));
}
@Test
void durationCarriesInstanceLabel() {
metrics.taskDuration("similarasin", 10L);
assertEquals("server-110",
registry.get("aiimage.task.duration").timer().getId().getTag("instanceId"));
}
}
@@ -0,0 +1,79 @@
package com.nanri.aiimage.metrics;
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* task-184task.heartbeat.age 心跳年龄 gauge 契约(spec 11 §1)。
* 年龄 = 距最后心跳的毫秒数,由 TaskHeartbeatService/巡检写入;新鲜/陈旧可观测;
* 刚心跳≈0;时钟负保护;未设置不产生 meter。
*/
class TaskHeartbeatAgeMetricsTest {
private final SimpleMeterRegistry registry = new SimpleMeterRegistry();
private final TaskObservabilityMetrics metrics = new TaskObservabilityMetrics("server-110", registry);
private io.micrometer.core.instrument.Gauge gauge(String moduleType) {
return registry.get("aiimage.task.heartbeat.age").tag("moduleType", moduleType).gauge();
}
@Test
void ageComputedFreshAndStale() {
metrics.taskHeartbeatAge("similarasin", 200L);
assertEquals(200.0, gauge("similarasin").value(), "新鲜心跳年龄应小");
metrics.taskHeartbeatAge("similarasin", 3_600_000L);
assertEquals(3_600_000.0, gauge("similarasin").value(), "陈旧心跳年龄应大");
}
@Test
void ageJustHeartbeatIsZero() {
metrics.taskHeartbeatAge("similarasin", 0L);
assertEquals(0.0, gauge("similarasin").value());
}
@Test
void ageClockNegativeGuard() {
metrics.taskHeartbeatAge("similarasin", -100L);
assertEquals(0.0, gauge("similarasin").value(), "时钟回拨导致的负年龄按 0 处理");
}
@Test
void ageUpdatesOnEachWrite() {
metrics.taskHeartbeatAge("similarasin", 1_000L);
metrics.taskHeartbeatAge("similarasin", 2_000L);
assertEquals(2_000.0, gauge("similarasin").value());
}
@Test
void ageSplitByModule() {
metrics.taskHeartbeatAge("similarasin", 500L);
metrics.taskHeartbeatAge("publish", 900L);
assertEquals(500.0, gauge("similarasin").value());
assertEquals(900.0, gauge("publish").value());
}
@Test
void noHeartbeatYetNoGauge() {
assertNull(registry.find("aiimage.task.heartbeat.age")
.tag("moduleType", "withdraw").gauge(),
"从未写入心跳年龄时不应产生 gauge");
}
@Test
void ageNameFrozen() {
metrics.taskHeartbeatAge("similarasin", 10L);
assertTrue(registry.getMeters().stream()
.anyMatch(m -> m.getId().getName().equals("aiimage.task.heartbeat.age")));
}
@Test
void ageCarriesInstanceLabel() {
metrics.taskHeartbeatAge("similarasin", 10L);
assertEquals("server-110",
registry.get("aiimage.task.heartbeat.age").gauge().getId().getTag("instanceId"));
}
}
@@ -0,0 +1,92 @@
package com.nanri.aiimage.metrics;
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* task-181task.created/running 指标埋点契约(spec 11 §1)。
*
* aiimage.task.created 计数与 aiimage.task.running gauge,带 moduleType/instanceId 标签;
* 无监控栈(registry=null) 时降级 debug 日志不抛错。运行计数不为负;指标名冻结。
*/
class TaskObservabilityLifecycleMetricsTest {
private final SimpleMeterRegistry registry = new SimpleMeterRegistry();
private final TaskObservabilityMetrics metrics = new TaskObservabilityMetrics("server-110", registry);
@Test
void createdIncrementedPerModule() {
metrics.taskCreated("similarasin");
metrics.taskCreated("similarasin");
metrics.taskCreated("publish");
assertEquals(2.0, counter("aiimage.task.created", "similarasin").count());
assertEquals(1.0, counter("aiimage.task.created", "publish").count());
}
@Test
void runningGaugeReflectsDelta() {
metrics.taskRunningDelta("similarasin", 3);
metrics.taskRunningDelta("similarasin", 1);
assertEquals(4.0, gauge("aiimage.task.running", "similarasin").value());
}
@Test
void runningGaugeNeverNegative() {
metrics.taskRunningDelta("similarasin", -5);
assertEquals(0.0, gauge("aiimage.task.running", "similarasin").value(), "运行计数不应为负");
}
@Test
void moduleLabelAttached() {
metrics.taskCreated("shopdatacrawl");
assertEquals("shopdatacrawl",
registry.get("aiimage.task.created").counter().getId().getTag("moduleType"));
}
@Test
void instanceLabelAttached() {
metrics.taskCreated("brand");
assertEquals("server-110",
registry.get("aiimage.task.created").counter().getId().getTag("instanceId"));
}
@Test
void counterAccumulatesAcrossCalls() {
for (int i = 0; i < 5; i++) {
metrics.taskCreated("ziniao");
}
assertEquals(5.0, counter("aiimage.task.created", "ziniao").count());
}
@Test
void metricNamesFrozen() {
metrics.taskCreated("withdraw");
metrics.taskRunningDelta("withdraw", 1);
boolean hasCreated = registry.getMeters().stream()
.anyMatch(m -> m.getId().getName().equals("aiimage.task.created"));
boolean hasRunning = registry.getMeters().stream()
.anyMatch(m -> m.getId().getName().equals("aiimage.task.running"));
assertTrue(hasCreated, "应存在 aiimage.task.created");
assertTrue(hasRunning, "应存在 aiimage.task.running");
}
@Test
void registryOptionalLogsFallbackWithoutError() {
// 无监控栈(registry=null):调用不抛错,事件降级 debug 日志
TaskObservabilityMetrics withoutRegistry = new TaskObservabilityMetrics("server-121", null);
withoutRegistry.taskCreated("similarasin");
withoutRegistry.taskRunningDelta("similarasin", 2);
assertEquals("server-121", withoutRegistry.instanceId());
}
private io.micrometer.core.instrument.Counter counter(String name, String moduleType) {
return registry.get(name).tag("moduleType", moduleType).counter();
}
private io.micrometer.core.instrument.Gauge gauge(String name, String moduleType) {
return registry.get(name).tag("moduleType", moduleType).gauge();
}
}
@@ -0,0 +1,86 @@
package com.nanri.aiimage.metrics;
import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* task-182task.success/failed/cancelled 终态计数契约spec 11 §1
* 每个终态独立计数 moduleType单次终态调用精确 +1互不串扰
*/
class TaskTerminalMetricsTest {
private final SimpleMeterRegistry registry = new SimpleMeterRegistry();
private final TaskObservabilityMetrics metrics = new TaskObservabilityMetrics("server-110", registry);
private Counter counter(String terminal, String moduleType) {
return registry.get("aiimage.task." + terminal).tag("moduleType", moduleType).counter();
}
@Test
void successCounted() {
metrics.taskTerminal("similarasin", "success");
assertEquals(1.0, counter("success", "similarasin").count());
}
@Test
void failedCounted() {
metrics.taskTerminal("similarasin", "failed");
assertEquals(1.0, counter("failed", "similarasin").count());
}
@Test
void cancelledCounted() {
metrics.taskTerminal("similarasin", "cancelled");
assertEquals(1.0, counter("cancelled", "similarasin").count());
}
@Test
void moduleSplitIndependent() {
metrics.taskTerminal("similarasin", "success");
metrics.taskTerminal("publish", "success");
assertEquals(1.0, counter("success", "similarasin").count());
assertEquals(1.0, counter("success", "publish").count());
}
@Test
void singleTerminalCallCountsOnce() {
metrics.taskTerminal("brand", "failed");
assertEquals(1.0, counter("failed", "brand").count(), "单次终态调用精确 +1");
}
@Test
void terminalTypesDoNotCrossCount() {
metrics.taskTerminal("similarasin", "success");
metrics.taskTerminal("similarasin", "failed");
assertEquals(1.0, counter("success", "similarasin").count());
assertEquals(1.0, counter("failed", "similarasin").count());
assertEquals(0.0, registry.find("aiimage.task.cancelled").tag("moduleType", "similarasin").counter() == null
? 0.0
: registry.find("aiimage.task.cancelled").tag("moduleType", "similarasin").counter().count(),
"success/failed 不应污染 cancelled");
}
@Test
void terminalNamesFrozen() {
metrics.taskTerminal("similarasin", "success");
metrics.taskTerminal("similarasin", "failed");
metrics.taskTerminal("similarasin", "cancelled");
assertTrue(registry.getMeters().stream()
.anyMatch(m -> m.getId().getName().equals("aiimage.task.success")));
assertTrue(registry.getMeters().stream()
.anyMatch(m -> m.getId().getName().equals("aiimage.task.failed")));
assertTrue(registry.getMeters().stream()
.anyMatch(m -> m.getId().getName().equals("aiimage.task.cancelled")));
}
@Test
void countersCarryInstanceLabel() {
metrics.taskTerminal("similarasin", "success");
assertEquals("server-110",
registry.get("aiimage.task.success").counter().getId().getTag("instanceId"));
}
}
@@ -0,0 +1,79 @@
package com.nanri.aiimage.metrics;
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* task-187临时磁盘容量/占用指标契约spec 11 §109 临时磁盘巡检/告警接线面
* aiimage.tmpdisk.capacity/used gauge标签 path 09 告警阈值联动时可直接读值比判
*/
class TempDiskMetricsTest {
private final SimpleMeterRegistry registry = new SimpleMeterRegistry();
private final TaskObservabilityMetrics metrics = new TaskObservabilityMetrics("server-110", registry);
private static final String TMP = "/data/tmp-files";
@Test
void diskCapacityMeasured() {
metrics.disk(TMP, 100_000_000L, 30_000_000L);
assertEquals(100_000_000.0,
registry.get("aiimage.tmpdisk.capacity").tag("path", TMP).gauge().value(), 0.001);
}
@Test
void diskUsedMeasured() {
metrics.disk(TMP, 100_000_000L, 30_000_000L);
assertEquals(30_000_000.0,
registry.get("aiimage.tmpdisk.used").tag("path", TMP).gauge().value(), 0.001);
}
@Test
void usagePercentDerivableForThreshold() {
metrics.disk(TMP, 100_000_000L, 90_000_000L);
double capacity = registry.get("aiimage.tmpdisk.capacity").tag("path", TMP).gauge().value();
double used = registry.get("aiimage.tmpdisk.used").tag("path", TMP).gauge().value();
double percent = used / capacity * 100;
assertEquals(90.0, percent, 0.001, "09 告警可按占用百分比比判阈值");
}
@Test
void emptyDirectoryRecordsZero() {
metrics.disk(TMP, 100_000_000L, 0L);
assertEquals(0.0, registry.get("aiimage.tmpdisk.used").tag("path", TMP).gauge().value(), 0.001);
}
@Test
void pathTagAttached() {
metrics.disk(TMP, 1L, 1L);
assertEquals(TMP, registry.get("aiimage.tmpdisk.capacity").gauge().getId().getTag("path"));
}
@Test
void gaugeUpdatesOnLaterWrite() {
metrics.disk(TMP, 100L, 10L);
metrics.disk(TMP, 100L, 55L);
assertEquals(55.0, registry.get("aiimage.tmpdisk.used").tag("path", TMP).gauge().value(), 0.001);
}
@Test
void diskNamesFrozen() {
metrics.disk(TMP, 1L, 1L);
assertTrue(registry.getMeters().stream()
.anyMatch(m -> m.getId().getName().equals("aiimage.tmpdisk.capacity")));
assertTrue(registry.getMeters().stream()
.anyMatch(m -> m.getId().getName().equals("aiimage.tmpdisk.used")));
}
@Test
void measureCallDoesNotBlockOrThrow() {
// 多次写 gauge 不抛错巡检每轮都调用registry 为空也不影响
TaskObservabilityMetrics without = new TaskObservabilityMetrics("server-121", null);
without.disk(TMP, 1L, 1L);
metrics.disk(TMP, 1L, 1L);
metrics.disk(TMP + "/sub", 1L, 0L);
}
}
@@ -0,0 +1,82 @@
package com.nanri.aiimage.metrics;
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
import org.junit.jupiter.api.Test;
import java.util.concurrent.TimeUnit;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* task-186上传耗时/大小/成功率指标契约spec 11 §1模块 file 上传接线面
* aiimage.upload.duration Timer + aiimage.upload.size summary + aiimage.upload.result(.success/failure)
*/
class UploadMetricsTest {
private final SimpleMeterRegistry registry = new SimpleMeterRegistry();
private final TaskObservabilityMetrics metrics = new TaskObservabilityMetrics("server-110", registry);
@Test
void uploadDurationRecorded() {
metrics.upload("collectdata", 10_000L, 350L, true);
assertEquals(1, registry.get("aiimage.upload.duration").tag("moduleType", "collectdata").timer().count());
assertEquals(350.0, registry.get("aiimage.upload.duration").tag("moduleType", "collectdata").timer()
.totalTime(TimeUnit.MILLISECONDS), 0.001);
}
@Test
void uploadSizeRecorded() {
metrics.upload("collectdata", 1024L, 100L, true);
assertEquals(1024.0,
registry.get("aiimage.upload.size").tag("moduleType", "collectdata").summary().totalAmount(), 0.001);
}
@Test
void uploadSuccessResultCounted() {
metrics.upload("collectdata", 1L, 1L, true);
assertEquals(1.0, registry.get("aiimage.upload.result.success").tag("moduleType", "collectdata").counter().count());
}
@Test
void uploadFailureResultCounted() {
metrics.upload("collectdata", 1L, 1L, false);
assertEquals(1.0, registry.get("aiimage.upload.result.failure").tag("moduleType", "collectdata").counter().count());
assertEquals(0.0, registry.find("aiimage.upload.result.success").tag("moduleType", "collectdata").counter() == null
? 0.0
: registry.find("aiimage.upload.result.success").tag("moduleType", "collectdata").counter().count());
}
@Test
void uploadSplitByModule() {
metrics.upload("similarasin", 10L, 10L, true);
metrics.upload("publish", 20L, 20L, true);
assertEquals(1, registry.get("aiimage.upload.duration").tag("moduleType", "similarasin").timer().count());
assertEquals(1, registry.get("aiimage.upload.duration").tag("moduleType", "publish").timer().count());
}
@Test
void largeUploadRecorded() {
metrics.upload("shopdatacrawl", 500_000_000L, 30_000L, true);
assertEquals(500_000_000.0,
registry.get("aiimage.upload.size").tag("moduleType", "shopdatacrawl").summary().totalAmount(), 0.001);
}
@Test
void uploadNamesFrozen() {
metrics.upload("similarasin", 1L, 1L, true);
assertTrue(registry.getMeters().stream()
.anyMatch(m -> m.getId().getName().equals("aiimage.upload.duration")));
assertTrue(registry.getMeters().stream()
.anyMatch(m -> m.getId().getName().equals("aiimage.upload.size")));
assertTrue(registry.getMeters().stream()
.anyMatch(m -> m.getId().getName().equals("aiimage.upload.result.success")));
}
@Test
void uploadNoMonitoringStackFallbackNoError() {
TaskObservabilityMetrics without = new TaskObservabilityMetrics("server-121", null);
without.upload("similarasin", 1L, 1L, true);
assertEquals("server-121", without.instanceId());
}
}
@@ -0,0 +1,110 @@
package com.nanri.aiimage.modules.appearancepatent.service.support;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.ss.usermodel.Workbook;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.junit.jupiter.api.Test;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* task-218离线可收部分结果文件格式契约真实 Sheet builder 纯调用
*
* AppearancePatentSheetBuilder 在内存 Workbook 生成 Sheet断言 Sheet 主表列序
* 原因表列名冻结空行也能产出空结果文件文件名 `<stem>-result.xlsx` ZIP 结构
* 由运行时组装/上传环节生成依赖真实流水线env-gated 部分另行验证
*/
class AppearancePatentResultFormatTest {
private static Workbook build() {
Workbook wb = new XSSFWorkbook();
AppearancePatentSheetBuilder.buildResultSheet(wb, List.of(), Map.of());
return wb;
}
private static List<String> expectedMainHeaders() {
List<String> headers = new ArrayList<>(AppearancePatentSheetBuilder.RESULT_HEADERS);
headers.add(6, "标题");
headers.add(7, "图片链接");
headers.add(8, "sku");
return headers;
}
@Test
void mainSheetNameFrozen() throws Exception {
try (Workbook wb = build()) {
assertNotNull(wb.getSheet("外观专利检测结果"), "主 Sheet 名应冻结为 外观专利检测结果");
}
}
@Test
void reasonSheetNameFrozen() throws Exception {
try (Workbook wb = build()) {
assertNotNull(wb.getSheet("原因"), "原因 Sheet 名应冻结为 原因");
}
}
@Test
void mainHeaderColumnOrderMatchesContract() throws Exception {
try (Workbook wb = build()) {
Sheet sheet = wb.getSheet("外观专利检测结果");
Row header = sheet.getRow(0);
List<String> actual = new ArrayList<>();
for (int i = 0; i < header.getLastCellNum(); i++) {
actual.add(header.getCell(i).getStringCellValue());
}
assertEquals(expectedMainHeaders(), actual, "主表列序应与 RESULT_HEADERS + 标题/图片链接/sku 一致");
}
}
@Test
void reasonHeaderColumnsFrozen() throws Exception {
try (Workbook wb = build()) {
Sheet reason = wb.getSheet("原因");
Row header = reason.getRow(0);
assertEquals("ASIN", header.getCell(0).getStringCellValue());
assertEquals("外观原因", header.getCell(1).getStringCellValue());
assertEquals("专利原因", header.getCell(2).getStringCellValue());
assertEquals("标题原因", header.getCell(3).getStringCellValue());
}
}
@Test
void emptyRowsStillGenerateSheets() throws Exception {
try (Workbook wb = build()) {
assertNotNull(wb.getSheet("外观专利检测结果"));
assertNotNull(wb.getSheet("原因"));
assertEquals(0, wb.getSheet("外观专利检测结果").getPhysicalNumberOfRows() - 1,
"空行时应仅表头,无数据行抛错");
}
}
@Test
void resultHeadersConstantDefinesBrandColumns() throws Exception {
List<String> headers = AppearancePatentSheetBuilder.RESULT_HEADERS;
assertTrue(headers.contains("品牌") && headers.contains("价格") && headers.contains("结论")
&& headers.contains("状态"), "RESULT_HEADERS 应含 品牌/价格/结论/状态");
}
@Test
void firstColumnIsRowId() throws Exception {
List<String> headers = expectedMainHeaders();
assertEquals("id", headers.get(0), "首列应为 id");
assertEquals("asin", headers.get(1));
}
@Test
void columnCountMatchesContract() throws Exception {
try (Workbook wb = build()) {
int headerCount = wb.getSheet("外观专利检测结果").getRow(0).getLastCellNum();
assertEquals(expectedMainHeaders().size(), headerCount, "列数应与契约表头一致");
}
}
}
@@ -0,0 +1,93 @@
package com.nanri.aiimage.modules.collectdata.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Assumptions;
import org.junit.jupiter.api.Test;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.Statement;
import java.time.Duration;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* task-204/items 分页语义契约回放真实服务env-gated
*
* 依赖运行应用与可写副本库CONTRACT_BASE_URL + CONTRACT_DB_*未设则跳过CI 绿
* 流程 COLLECT_DATA RUNNING 任务 + 5 biz_collect_data_item GET items 各分页参数
* 断言 page/pageSize/count/total/totalPages/items 与缺省/越界/空页语义 Python Worker 拉取约定一致
*/
class CollectDataItemsContractReplayTest {
private static final long TASK_ID = 9000002L;
private static final ObjectMapper MAPPER = new ObjectMapper();
@Test
void itemsPaginationSemantics() throws Exception {
String base = System.getenv("CONTRACT_BASE_URL");
String dbUrl = System.getenv("CONTRACT_DB_URL");
String dbUser = System.getenv("CONTRACT_DB_USER");
String dbPass = System.getenv("CONTRACT_DB_PASSWORD");
Assumptions.assumeTrue(base != null && dbUrl != null && dbUser != null, "未设 CONTRACT_* 跳过");
seed(dbUrl, dbUser, dbPass, 5);
HttpClient client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)).build();
JsonNode page1 = getPage(client, base, 1, 2);
assertTrue(page1.path("success").asBoolean(), page1.toString());
JsonNode data = page1.path("data");
assertEquals(1, data.path("page").asInt());
assertEquals(2, data.path("pageSize").asInt());
assertEquals(5, data.path("total").asLong());
assertEquals(3, data.path("totalPages").asInt());
assertEquals(2, data.path("count").asInt());
assertEquals(2, data.path("items").size());
assertEquals(1, data.path("items").get(0).path("rowIndex").asInt(), "应按 rowIndex 升序");
JsonNode page3 = getPage(client, base, 3, 2);
assertEquals(1, page3.path("data").path("items").size(), "page3 应剩 1 行");
assertEquals(5, page3.path("data").path("items").get(0).path("rowIndex").asInt());
JsonNode beyond = getPage(client, base, 99, 2);
assertEquals(0, beyond.path("data").path("items").size(), "越界页应空且成功");
assertEquals(5, beyond.path("data").path("total").asLong());
JsonNode defaults = getPage(client, base, 1, 50);
JsonNode d = defaults.path("data");
assertEquals(1, d.path("page").asInt());
assertEquals(50, d.path("pageSize").asInt());
assertEquals(1, d.path("totalPages").asInt(), "5 行 50 一页应 1 页");
assertEquals(5, d.path("count").asInt());
}
private static JsonNode getPage(HttpClient client, String base, int page, int size) throws Exception {
String url = base + "/api/collect-data/tasks/" + TASK_ID + "/items?user_id=1&page=" + page + "&page_size=" + size;
HttpResponse<String> resp = client.send(HttpRequest.newBuilder(URI.create(url)).GET()
.timeout(Duration.ofSeconds(15)).build(),
HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
assertEquals(200, resp.statusCode(), url);
return MAPPER.readTree(resp.body());
}
private static void seed(String dbUrl, String user, String pass, int rows) throws Exception {
try (Connection c = DriverManager.getConnection(dbUrl, user, pass);
Statement s = c.createStatement()) {
s.executeUpdate("INSERT INTO biz_file_task (id,task_no,module_type,task_mode,status,created_at,updated_at,user_id,owner_instance_id) "
+ "VALUES (" + TASK_ID + ",'CD-REPLAY-" + TASK_ID + "','COLLECT_DATA','collect','RUNNING',NOW(),NOW(),1,'local-smoke') "
+ "ON DUPLICATE KEY UPDATE module_type='COLLECT_DATA',status='RUNNING',user_id=1");
s.executeUpdate("DELETE FROM biz_collect_data_item WHERE task_id=" + TASK_ID);
for (int i = 1; i <= rows; i++) {
s.executeUpdate("INSERT INTO biz_collect_data_item (task_id,row_index,keyword,status_value,created_at) VALUES ("
+ TASK_ID + "," + i + ",'keyword-" + i + "','PENDING',NOW())");
}
}
}
}
@@ -147,10 +147,14 @@ class ShopDataDuplicateCheckQueryServiceTest {
assertThat(bytes[2]).isEqualTo((byte) 0xBF);
String text = new String(bytes, StandardCharsets.UTF_8);
String[] lines = text.split("\r\n");
assertThat(lines[0]).endsWith("ASIN,店铺数,店铺,分组,站点,上架时间,价格,品牌");
assertThat(lines[0]).endsWith("ASIN,店铺数,店铺,分组,国家,上架时间,价格,品牌");
// 数据行 = E(3 记录) + A(2) + F(2) = 7非空行总数为 8尾随 CRLF 会产生一个空元素
long nonEmpty = java.util.Arrays.stream(lines).filter(line -> !line.isEmpty()).count();
assertThat(nonEmpty).isEqualTo(8);
assertThat(lines[1]).startsWith("E0000001,3,");
// 站点代码列导出为中文国家名UK英国FR法国与页面国家展示一致
assertThat(lines[1]).startsWith("E0000001,3,ShopA,GroupA,英国,");
assertThat(text).contains(",法国,");
assertThat(lines[1]).doesNotContain(",UK,");
}
}
@@ -0,0 +1,76 @@
package com.nanri.aiimage.modules.similarasin.model;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* task-207服务层部分done=true 最后一批语义纯单测
*
* 覆盖 done/error终态回调决策chunkIndex/chunkTotal 缺省归一末片判定并解析
* python-contract/result.request.jsondone=true断言终态触发文件生成xlsx/终态落库
* 依赖真实 LLM 流水线 3+2 约定在真实运行时经 env-gated 回放验证本类不依赖 DB
*/
class SimilarAsinSubmitResultSemanticsTest {
private static final ObjectMapper MAPPER = new ObjectMapper();
@Test
void doneTrueIsTerminalEvenWithoutError() {
assertTrue(SimilarAsinSubmitResultSemantics.isTerminalRequest(true, null));
assertTrue(SimilarAsinSubmitResultSemantics.isTerminalRequest(true, ""));
}
@Test
void errorNonBlankIsTerminal() {
assertTrue(SimilarAsinSubmitResultSemantics.isTerminalRequest(false, "source timeout"));
}
@Test
void runningChunkWithoutDoneOrErrorIsNotTerminal() {
assertFalse(SimilarAsinSubmitResultSemantics.isTerminalRequest(false, null));
assertFalse(SimilarAsinSubmitResultSemantics.isTerminalRequest(false, " "));
}
@Test
void blankOrNullErrorNotTerminalWhenNotDone() {
assertFalse(SimilarAsinSubmitResultSemantics.isTerminalRequest(false, ""));
assertFalse(SimilarAsinSubmitResultSemantics.isTerminalRequest(false, " "));
}
@Test
void chunkIndexNullDefaultsZero() {
assertTrue(SimilarAsinSubmitResultSemantics.chunkIndex(null) == 0);
assertTrue(SimilarAsinSubmitResultSemantics.chunkIndex(3) == 3);
}
@Test
void chunkTotalNullDefaultsOne() {
assertTrue(SimilarAsinSubmitResultSemantics.chunkTotal(null) == 1);
assertTrue(SimilarAsinSubmitResultSemantics.chunkTotal(5) == 5);
}
@Test
void explicitValuesPassedThrough() {
assertTrue(SimilarAsinSubmitResultSemantics.chunkIndex(7) == 7);
assertTrue(SimilarAsinSubmitResultSemantics.chunkTotal(2) == 2);
}
@Test
void fixtureDoneTrueRequestIsTerminal() throws Exception {
Path fixture = Path.of(System.getProperty("user.dir"),
"src/test/resources/python-contract/result.request.json");
JsonNode req = MAPPER.readTree(Files.readAllBytes(fixture));
boolean done = req.path("done").asBoolean(false);
String error = req.path("error").isNull() ? null : req.path("error").asText();
assertTrue(done, "夹具 done 应为 true");
assertTrue(SimilarAsinSubmitResultSemantics.isTerminalRequest(done, error),
"done=true 的提交应触发终态回调");
}
}
@@ -61,7 +61,7 @@ public class LlmGatewayTlsProbe {
}
HttpClient client = builder.build();
try {
String body = "{\"model\":\"gemini-3.5-flash-lite\",\"messages\":[{\"role\":\"user\",\"content\":\"ping\"}],\"max_tokens\":10}";
String body = "{\"model\":\"glm-5.3-flash\",\"messages\":[{\"role\":\"user\",\"content\":\"ping\"}],\"max_tokens\":10}";
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://" + host + "/v1/chat/completions"))
.timeout(Duration.ofSeconds(30))
@@ -0,0 +1,102 @@
package com.nanri.aiimage.modules.similarasin.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Assumptions;
import org.junit.jupiter.api.Test;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.ResultSet;
import java.sql.Statement;
import java.time.Duration;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* task-219env-gated 全链路similarasin 最小链路 done=true assemble 上传结果文件
*
* CONTRACT_BASE_URL + CONTRACT_DB_* + 本地 MinIO nanri-ai-images已建否则跳过
* 流程 SIMILAR_ASIN RUNNING 任务result_json 内联含 items+groupsgroup-1 POST done=true
* 按同名 group 回填结果行 轮询任务直至终态 断言 SUCCESS result_file_url 非空真实文件已上传
* 本地已验证task 9000012 SUCCESSMinIO 存在 3.8KB xlsxPK
*/
class SimilarAsinFullAssembleReplayTest {
private static final long TASK_ID = 9000020L;
private static final ObjectMapper MAPPER = new ObjectMapper();
@Test
void doneTrueGeneratesResultFile() throws Exception {
String base = System.getenv("CONTRACT_BASE_URL");
String dbUrl = System.getenv("CONTRACT_DB_URL");
String dbUser = System.getenv("CONTRACT_DB_USER");
String dbPass = System.getenv("CONTRACT_DB_PASSWORD");
Assumptions.assumeTrue(base != null && dbUrl != null && dbUser != null, "未设 CONTRACT_* 跳过");
seed(dbUrl, dbUser, dbPass);
HttpClient client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)).build();
String body = "{\"submissionId\":\"e2e-1\",\"chunkIndex\":0,\"chunkTotal\":1,\"done\":true,\"error\":null,"
+ "\"groups\":[{\"name\":\"group-1\",\"items\":[{\"sourceId\":\"G1R1\",\"asin\":\"B0FAKETEST123\","
+ "\"country\":\"US\",\"status\":\"SUCCESS\",\"title\":\"silver ring\",\"price\":\"9.99\"}]}]}";
HttpResponse<String> resp = client.send(HttpRequest.newBuilder(URI.create(
base + "/api/similar-asin/tasks/" + TASK_ID + "/result?user_id=1"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(body, StandardCharsets.UTF_8))
.timeout(Duration.ofSeconds(20)).build(),
HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
assertEquals(200, resp.statusCode());
assertTrue(MAPPER.readTree(resp.body()).path("success").asBoolean(), resp.body());
// 轮询直到终态worker ~15s超时 150s
String status = "RUNNING";
long deadline = System.currentTimeMillis() + 150_000;
while (System.currentTimeMillis() < deadline && "RUNNING".equals(status)) {
Thread.sleep(5_000);
try (Connection c = DriverManager.getConnection(dbUrl, dbUser, dbPass);
Statement s = c.createStatement();
ResultSet rs = s.executeQuery("SELECT status FROM biz_file_task WHERE id=" + TASK_ID)) {
if (rs.next()) {
status = rs.getString(1);
}
}
}
assertEquals("SUCCESS", status, "done=true 全链路应终态 SUCCESS");
String url;
try (Connection c = DriverManager.getConnection(dbUrl, dbUser, dbPass);
Statement s = c.createStatement();
ResultSet rs = s.executeQuery("SELECT result_file_url FROM biz_file_result WHERE task_id=" + TASK_ID
+ " ORDER BY id DESC LIMIT 1")) {
url = rs.next() ? rs.getString(1) : null;
}
assertNotNull(url, "结果文件 URL 应非空");
assertTrue(url.startsWith("result/") && url.endsWith(".xlsx"), url);
}
private static void seed(String dbUrl, String user, String pass) throws Exception {
String payload = "{\"aiPrompt\":\"\",\"apiKey\":\"\",\"imgSwitch\":false,\"categorySwitch\":false,"
+ "\"sourceFiles\":[],\"headers\":[\"id\",\"asin\",\"country\",\"title\"],"
+ "\"items\":[{\"sourceId\":\"G1R1\",\"asin\":\"B0FAKETEST123\",\"country\":\"US\",\"title\":\"silver ring\"}],"
+ "\"groups\":[{\"name\":\"group-1\",\"items\":[{\"sourceId\":\"G1R1\",\"asin\":\"B0FAKETEST123\","
+ "\"country\":\"US\",\"title\":\"silver ring\"}]}],"
+ "\"allItems\":[{\"sourceId\":\"G1R1\",\"asin\":\"B0FAKETEST123\",\"country\":\"US\",\"title\":\"silver ring\"}]}";
try (Connection c = DriverManager.getConnection(dbUrl, user, pass);
Statement s = c.createStatement()) {
s.executeUpdate("DELETE FROM biz_task_file_job WHERE task_id=" + TASK_ID);
s.executeUpdate("DELETE FROM biz_task_chunk WHERE task_id=" + TASK_ID);
s.executeUpdate("DELETE FROM biz_file_result WHERE task_id=" + TASK_ID);
s.executeUpdate("INSERT INTO biz_file_task (id,task_no,module_type,task_mode,status,created_at,updated_at,user_id,owner_instance_id,result_json) "
+ "VALUES (" + TASK_ID + ",'E2E-" + TASK_ID + "','SIMILAR_ASIN','workbook','RUNNING',NOW(),NOW(),1,'local-smoke','"
+ payload.replace("'", "''") + "') "
+ "ON DUPLICATE KEY UPDATE module_type='SIMILAR_ASIN',status='RUNNING',user_id=1,result_json=VALUES(result_json)");
}
}
}
@@ -0,0 +1,79 @@
package com.nanri.aiimage.modules.similarasin.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Assumptions;
import org.junit.jupiter.api.Test;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.Statement;
import java.time.Duration;
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-205parsed-payload 契约回放真实服务env-gated
* CONTRACT_BASE_URL + CONTRACT_DB_* 设则跑否则跳过CI 绿
* SIMILAR_ASIN 任务 + 内联解析载荷 GET parsed-payloadsuccess顶层字段(items/allItems/groups/headers)
* 行数据submissionId 关联字段就绪未知任务返回 success:false错误语义
*/
class SimilarAsinParsedPayloadContractReplayTest {
private static final long TASK_ID = 9000003L;
private static final ObjectMapper MAPPER = new ObjectMapper();
@Test
void parsedPayloadContract() throws Exception {
String base = System.getenv("CONTRACT_BASE_URL");
String dbUrl = System.getenv("CONTRACT_DB_URL");
String dbUser = System.getenv("CONTRACT_DB_USER");
String dbPass = System.getenv("CONTRACT_DB_PASSWORD");
Assumptions.assumeTrue(base != null && dbUrl != null && dbUser != null, "未设 CONTRACT_* 跳过");
seed(dbUrl, dbUser, dbPass);
HttpClient client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)).build();
HttpResponse<String> ok = get(client, base + "/api/similar-asin/tasks/" + TASK_ID + "/parsed-payload?user_id=1");
assertEquals(200, ok.statusCode());
JsonNode body = MAPPER.readTree(ok.body());
assertTrue(body.path("success").asBoolean(), ok.body());
JsonNode data = body.path("data");
assertTrue(data.has("aiPrompt") && data.has("apiKey") && data.has("items") && data.has("allItems")
&& data.has("groups") && data.has("headers"), "parsed-payload 顶层字段缺失: " + data);
assertEquals("<redacted>", data.path("apiKey").asText(), "apiKey 已脱敏");
assertTrue(data.path("items").isArray() && data.path("items").size() >= 1, "应有解析行");
assertEquals("B0FAKETEST123", data.path("items").get(0).path("asin").asText());
HttpResponse<String> unknown = get(client, base + "/api/similar-asin/tasks/999999999/parsed-payload?user_id=1");
assertEquals(200, unknown.statusCode());
JsonNode unknownBody = MAPPER.readTree(unknown.body());
assertFalse(unknownBody.path("success").asBoolean(), "未知任务应 success:false");
}
private static void seed(String dbUrl, String user, String pass) throws Exception {
String payload = "{\"aiPrompt\":\"<redacted>\",\"apiKey\":\"<redacted>\",\"imgSwitch\":false,"
+ "\"categorySwitch\":false,\"sourceFiles\":[],\"headers\":[\"asin\",\"keyword\",\"title\"],"
+ "\"items\":[{\"rowIndex\":1,\"asin\":\"B0FAKETEST123\",\"keyword\":\"ring\"}],"
+ "\"groups\":[],\"allItems\":[{\"rowIndex\":1,\"asin\":\"B0FAKETEST123\",\"keyword\":\"ring\"}]}";
try (Connection c = DriverManager.getConnection(dbUrl, user, pass);
Statement s = c.createStatement()) {
s.executeUpdate("INSERT INTO biz_file_task (id,task_no,module_type,task_mode,status,created_at,updated_at,user_id,owner_instance_id,result_json) "
+ "VALUES (" + TASK_ID + ",'SA-REPLAY-" + TASK_ID + "','SIMILAR_ASIN','workbook','RUNNING',NOW(),NOW(),1,'local-smoke','"
+ payload.replace("'", "''") + "') ON DUPLICATE KEY UPDATE module_type='SIMILAR_ASIN',status='RUNNING',user_id=1,result_json=VALUES(result_json)");
}
}
private static HttpResponse<String> get(HttpClient client, String url) throws Exception {
return client.send(HttpRequest.newBuilder(URI.create(url)).GET()
.timeout(Duration.ofSeconds(15)).build(),
HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
}
}
@@ -0,0 +1,81 @@
package com.nanri.aiimage.modules.similarasin.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Assumptions;
import org.junit.jupiter.api.Test;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.Statement;
import java.time.Duration;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* task-214progress/batch轻量响应契约回放env-gated
* CONTRACT_BASE_URL + CONTRACT_DB_* 设则跑否则跳过CI 绿
* SIMILAR_ASIN RUNNING 任务 POST progress/batch progress/light
* 断言 items/missingTaskIds 结构task 轻量字段(id/taskNo/status)items 文件字段存在
*/
class SimilarAsinProgressBatchContractReplayTest {
private static final long TASK_ID = 9000005L;
private static final ObjectMapper MAPPER = new ObjectMapper();
@Test
void progressBatchAndLightContract() throws Exception {
String base = System.getenv("CONTRACT_BASE_URL");
String dbUrl = System.getenv("CONTRACT_DB_URL");
String dbUser = System.getenv("CONTRACT_DB_USER");
String dbPass = System.getenv("CONTRACT_DB_PASSWORD");
Assumptions.assumeTrue(base != null && dbUrl != null && dbUser != null, "未设 CONTRACT_* 跳过");
seed(dbUrl, dbUser, dbPass);
HttpClient client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)).build();
HttpResponse<String> batch = post(client, base + "/api/similar-asin/tasks/progress/batch?user_id=1",
"{\"taskIds\":[" + TASK_ID + "]}");
assertEquals(200, batch.statusCode());
JsonNode b = MAPPER.readTree(batch.body());
assertTrue(b.path("success").asBoolean(), batch.body());
JsonNode data = b.path("data");
assertTrue(data.has("items") && data.has("missingTaskIds"), "batch 应含 items/missingTaskIds");
assertTrue(data.path("items").isArray() && data.path("items").size() >= 1, "应在跑任务出现在 items");
JsonNode first = data.path("items").get(0);
JsonNode task = first.path("task");
assertTrue(task.path("id").asLong() == TASK_ID, "task.id 应对");
assertTrue(task.has("taskNo") && task.has("status"), "task 轻量字段缺失");
assertTrue(first.has("items") && first.path("items").isArray(), "task 下应有文件/明细 items");
HttpResponse<String> light = post(client, base + "/api/similar-asin/tasks/progress/light?user_id=1",
"{\"taskIds\":[" + TASK_ID + "]}");
JsonNode l = MAPPER.readTree(light.body());
assertTrue(l.path("success").asBoolean(), light.body());
assertTrue(l.path("data").has("items") && l.path("data").has("missingTaskIds"),
"light 应含 items/missingTaskIds");
}
private static void seed(String dbUrl, String user, String pass) throws Exception {
try (Connection c = DriverManager.getConnection(dbUrl, user, pass);
Statement s = c.createStatement()) {
s.executeUpdate("INSERT INTO biz_file_task (id,task_no,module_type,task_mode,status,created_at,updated_at,user_id,owner_instance_id) "
+ "VALUES (" + TASK_ID + ",'PB-REPLAY-" + TASK_ID + "','SIMILAR_ASIN','workbook','RUNNING',NOW(),NOW(),1,'local-smoke') "
+ "ON DUPLICATE KEY UPDATE module_type='SIMILAR_ASIN',status='RUNNING',user_id=1");
}
}
private static HttpResponse<String> post(HttpClient client, String url, String body) throws Exception {
return client.send(HttpRequest.newBuilder(URI.create(url))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(body, StandardCharsets.UTF_8))
.timeout(Duration.ofSeconds(15)).build(),
HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
}
}
@@ -0,0 +1,102 @@
package com.nanri.aiimage.modules.similarasin.service;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.Assumptions;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.Statement;
import java.time.Duration;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* task-203/result 幂等契约回放真实服务env-gated
*
* 依赖运行中的应用与可写本地库复现生产 schema CONTRACT_BASE_URLCONTRACT_DB_URL
* CONTRACT_DB_USERCONTRACT_DB_PASSWORD 后启用未设则跳过保证 CI 全绿
* 流程 SIMILAR_ASIN RUNNING 任务 + 内联解析载荷 GET parsed-payload 验证成功
* POST /result submissionId/chunk 两次 断言两次 success:true biz_task_chunk 只落 1 幂等
* done=true 封口/终态因需真实 LLM 流水线不在本回放覆盖 commit 说明
*/
class SimilarAsinResultContractReplayTest {
private static final long TASK_ID = 9000001L;
private static final String SUBMISSION = "contract-replay-001";
@Test
void duplicateSubmitResultIsIdempotent() throws Exception {
String base = System.getenv("CONTRACT_BASE_URL");
String dbUrl = System.getenv("CONTRACT_DB_URL");
String dbUser = System.getenv("CONTRACT_DB_USER");
String dbPass = System.getenv("CONTRACT_DB_PASSWORD");
Assumptions.assumeTrue(base != null && dbUrl != null && dbUser != null,
"未设 CONTRACT_BASE_URL / CONTRACT_DB_*,跳过真实服务回放");
seedTask(dbUrl, dbUser, dbPass);
HttpClient client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)).build();
// parsed-payload 成功返回全量载荷
HttpResponse<String> parsed = get(client, base + "/api/similar-asin/tasks/" + TASK_ID + "/parsed-payload?user_id=1");
assertEquals(200, parsed.statusCode());
assertTrue(parsed.body().contains("\"success\":true"), "parsed-payload 应 success:true: " + parsed.body());
assertTrue(parsed.body().contains("B0FAKETEST123"), "应含解析行数据");
// submissionId/chunk 提交两次
String body = "{\"submissionId\":\"" + SUBMISSION + "\",\"chunkIndex\":0,\"chunkTotal\":1,\"done\":false,"
+ "\"error\":null,\"groups\":[{\"name\":\"g\",\"items\":[{\"rowIndex\":1,\"asin\":\"B0FAKETEST123\","
+ "\"status\":\"SUCCESS\"}]}]}";
HttpResponse<String> first = post(client, base + "/api/similar-asin/tasks/" + TASK_ID + "/result?user_id=1", body);
HttpResponse<String> second = post(client, base + "/api/similar-asin/tasks/" + TASK_ID + "/result?user_id=1", body);
assertEquals(200, first.statusCode());
assertEquals(200, second.statusCode());
assertTrue(first.body().contains("\"success\":true"), "首次提交应成功: " + first.body());
assertTrue(second.body().contains("\"success\":true"), "重复提交应幂等成功: " + second.body());
// 幂等只落一份 chunk
assertEquals(1, countChunks(dbUrl, dbUser, dbPass, TASK_ID), "同 submissionId/chunk 应只落 1 行");
}
private static void seedTask(String dbUrl, String user, String pass) throws Exception {
String payload = "{\"aiPrompt\":\"<redacted>\",\"apiKey\":\"<redacted>\",\"imgSwitch\":false,"
+ "\"categorySwitch\":false,\"sourceFiles\":[],\"headers\":[\"asin\",\"keyword\"],"
+ "\"items\":[{\"rowIndex\":1,\"asin\":\"B0FAKETEST123\",\"keyword\":\"ring\"}],"
+ "\"groups\":[],\"allItems\":[{\"rowIndex\":1,\"asin\":\"B0FAKETEST123\",\"keyword\":\"ring\"}]}";
try (Connection c = DriverManager.getConnection(dbUrl, user, pass);
Statement s = c.createStatement()) {
s.executeUpdate("INSERT INTO biz_file_task (id,task_no,module_type,task_mode,status,created_at,updated_at,user_id,owner_instance_id,result_json) "
+ "VALUES (" + TASK_ID + ",'REPLAY-" + TASK_ID + "','SIMILAR_ASIN','workbook','RUNNING',NOW(),NOW(),1,'local-smoke','"
+ payload.replace("'", "''") + "') ON DUPLICATE KEY UPDATE module_type='SIMILAR_ASIN',status='RUNNING',user_id=1,result_json=VALUES(result_json)");
s.executeUpdate("DELETE FROM biz_task_chunk WHERE task_id=" + TASK_ID);
}
}
private static int countChunks(String dbUrl, String user, String pass, long taskId) throws Exception {
try (Connection c = DriverManager.getConnection(dbUrl, user, pass);
Statement s = c.createStatement();
var rs = s.executeQuery("SELECT COUNT(*) FROM biz_task_chunk WHERE task_id=" + taskId)) {
rs.next();
return rs.getInt(1);
}
}
private static HttpResponse<String> get(HttpClient client, String url) throws Exception {
return client.send(HttpRequest.newBuilder(URI.create(url)).GET()
.timeout(Duration.ofSeconds(15)).build(),
HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
}
private static HttpResponse<String> post(HttpClient client, String url, String body) throws Exception {
return client.send(HttpRequest.newBuilder(URI.create(url))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(body, StandardCharsets.UTF_8))
.timeout(Duration.ofSeconds(15)).build(),
HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
}
}
@@ -563,6 +563,7 @@ class SimilarAsinTaskServicePerf500Test {
writer.println(lines.get(1));
}
assertTrue(Files.exists(report), "性能报告应写入 target/perf-report/");
assertTrue(lightMs < batchMs, "light 应快于 batch(报告记录)");
// 墙钟不作为断言口径见类注释JIT/GC/调度下毫秒不可比"light 更轻"由本类确定性
// 查询次数/字段量用例覆盖
}
}
@@ -1,4 +1,5 @@
package com.nanri.aiimage.modules.task.service;
import com.nanri.aiimage.modules.appearancepatent.service.AppearancePatentResultFileJobHandler;
import com.nanri.aiimage.modules.appearancepatent.service.AppearancePatentTaskService;
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
@@ -1,4 +1,5 @@
package com.nanri.aiimage.modules.task.service;
import com.nanri.aiimage.modules.brand.service.BrandResultFileJobHandler;
import com.nanri.aiimage.modules.brand.service.BrandTaskService;
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
@@ -1,4 +1,5 @@
package com.nanri.aiimage.modules.task.service;
import com.nanri.aiimage.modules.collectdata.service.CollectDataResultFileJobHandler;
import com.nanri.aiimage.modules.collectdata.service.CollectDataService;
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
@@ -1,4 +1,5 @@
package com.nanri.aiimage.modules.task.service;
import com.nanri.aiimage.modules.deletebrand.service.DeleteBrandResultFileJobHandler;
import com.nanri.aiimage.modules.deletebrand.service.DeleteBrandRunService;
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
@@ -0,0 +1,181 @@
package com.nanri.aiimage.modules.task.service;
import ch.qos.logback.classic.Logger;
import ch.qos.logback.classic.spi.ILoggingEvent;
import ch.qos.logback.core.read.ListAppender;
import com.nanri.aiimage.config.InspectionProperties;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.jdbc.core.JdbcTemplate;
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.assertTrue;
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;
/**
* task-200巡检报表任务契约
* 默认 disabled 不执行启用后跑 6 张只读报表 SQL 并输出行数单条失败不阻断 query 不改数据
* 可重复执行无锁/JDBC 时安全返回
*/
class InspectionSqlReportTaskTest {
private static ObjectProvider<JdbcTemplate> provider(JdbcTemplate jdbc) {
return new ObjectProvider<JdbcTemplate>() {
@Override
public JdbcTemplate getObject() {
return jdbc;
}
@Override
public JdbcTemplate getObject(Object... args) {
return jdbc;
}
@Override
public JdbcTemplate getIfAvailable() {
return jdbc;
}
@Override
public JdbcTemplate getIfUnique() {
return jdbc;
}
};
}
private static ListAppender<ILoggingEvent> attach() {
Logger logger = (Logger) LoggerFactory.getLogger(InspectionSqlReportTask.class);
ListAppender<ILoggingEvent> appender = new ListAppender<>();
appender.start();
logger.addAppender(appender);
return appender;
}
@Test
void disabledByDefaultRunsNothing() {
InspectionProperties props = new InspectionProperties();
JdbcTemplate jdbc = mock(JdbcTemplate.class);
assertEquals(false, props.isEnabled(), "巡检默认 disabled");
InspectionSqlReportTask task = new InspectionSqlReportTask(props, provider(jdbc));
assertEquals(0, task.runAll(), "disabled 不执行");
Mockito.verifyNoInteractions(jdbc);
}
@Test
void enabledRunsAllSixReports() {
InspectionProperties props = new InspectionProperties();
props.setEnabled(true);
JdbcTemplate jdbc = mock(JdbcTemplate.class);
Mockito.when(jdbc.queryForList(anyString())).thenReturn(List.of());
InspectionSqlReportTask task = new InspectionSqlReportTask(props, provider(jdbc));
assertEquals(6, task.runAll());
verify(jdbc, times(6)).queryForList(anyString());
}
@Test
void reportOutputLogsRowCount() {
InspectionProperties props = new InspectionProperties();
props.setEnabled(true);
JdbcTemplate jdbc = mock(JdbcTemplate.class);
Mockito.when(jdbc.queryForList(anyString())).thenReturn(List.of(Map.of("a", 1), Map.of("a", 2), Map.of("a", 3)));
InspectionSqlReportTask task = new InspectionSqlReportTask(props, provider(jdbc));
ListAppender<ILoggingEvent> captured = attach();
try {
task.runAll();
String text = String.join("\n", captured.list.stream().map(ILoggingEvent::getFormattedMessage).toList());
assertTrue(text.contains("rows=3"), "报表日志应含行数: " + text);
assertTrue(text.contains("[inspection-sql] report"), "应含报表前缀");
} finally {
((Logger) LoggerFactory.getLogger(InspectionSqlReportTask.class)).detachAppender(captured);
}
}
@Test
void singleReportFailureDoesNotBlockOthers() {
InspectionProperties props = new InspectionProperties();
props.setEnabled(true);
JdbcTemplate jdbc = mock(JdbcTemplate.class);
AtomicInteger calls = new AtomicInteger();
Mockito.doAnswer(inv -> {
if (calls.incrementAndGet() == 3) {
throw new RuntimeException("模拟第三条 SQL 失败");
}
return List.of();
}).when(jdbc).queryForList(anyString());
InspectionSqlReportTask task = new InspectionSqlReportTask(props, provider(jdbc));
assertEquals(5, task.runAll(), "单条失败应被捕获,其余继续");
verify(jdbc, times(6)).queryForList(anyString());
}
@Test
void scheduleConfigDefaults() {
InspectionProperties props = new InspectionProperties();
assertEquals("0 0 3 * * *", props.getCron());
assertEquals(false, props.isEnabled());
}
@Test
void onlyReadQueriesNoWrites() {
InspectionProperties props = new InspectionProperties();
props.setEnabled(true);
JdbcTemplate jdbc = mock(JdbcTemplate.class);
Mockito.when(jdbc.queryForList(anyString())).thenReturn(List.of());
InspectionSqlReportTask task = new InspectionSqlReportTask(props, provider(jdbc));
task.runAll();
verify(jdbc, times(6)).queryForList(anyString());
verify(jdbc, never()).execute(anyString());
}
@Test
void noLockOrJdbcSafelyNoop() {
InspectionProperties props = new InspectionProperties();
props.setEnabled(true);
InspectionSqlReportTask noJdbc = new InspectionSqlReportTask(props, new ObjectProvider<JdbcTemplate>() {
@Override
public JdbcTemplate getObject() {
return null;
}
@Override
public JdbcTemplate getObject(Object... args) {
return null;
}
@Override
public JdbcTemplate getIfAvailable() {
return null;
}
@Override
public JdbcTemplate getIfUnique() {
return null;
}
});
assertEquals(0, noJdbc.runAll(), "启用但无 JdbcTemplate 应安全跳过");
// 无分布式锁实例时 runIfEnabled 不应抛错测试构造传入 null lock
InspectionSqlReportTask noLock = new InspectionSqlReportTask(props, null, provider(mock(JdbcTemplate.class)));
noLock.runIfEnabled();
}
@Test
void enabledStableAcrossRuns() {
InspectionProperties props = new InspectionProperties();
props.setEnabled(true);
JdbcTemplate jdbc = mock(JdbcTemplate.class);
Mockito.when(jdbc.queryForList(anyString())).thenReturn(List.of());
InspectionSqlReportTask task = new InspectionSqlReportTask(props, provider(jdbc));
assertEquals(6, task.runAll());
assertEquals(6, task.runAll());
verify(jdbc, times(12)).queryForList(anyString());
}
}
@@ -1,4 +1,5 @@
package com.nanri.aiimage.modules.task.service;
import com.nanri.aiimage.modules.patroldelete.service.PatrolDeleteResultFileJobHandler;
import com.nanri.aiimage.modules.patroldelete.service.PatrolDeleteTaskService;
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
@@ -1,4 +1,5 @@
package com.nanri.aiimage.modules.task.service;
import com.nanri.aiimage.modules.pricetrack.service.PriceTrackResultFileJobHandler;
import com.nanri.aiimage.modules.pricetrack.service.PriceTrackTaskService;
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
@@ -1,4 +1,5 @@
package com.nanri.aiimage.modules.task.service;
import com.nanri.aiimage.modules.productrisk.service.ProductRiskResultFileJobHandler;
import com.nanri.aiimage.modules.productrisk.service.ProductRiskTaskService;
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
@@ -1,4 +1,5 @@
package com.nanri.aiimage.modules.task.service;
import com.nanri.aiimage.modules.publish.service.PublishResultFileJobHandler;
import com.nanri.aiimage.modules.publish.service.PublishTaskService;
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
@@ -1,4 +1,5 @@
package com.nanri.aiimage.modules.task.service;
import com.nanri.aiimage.modules.queryasin.service.QueryAsinResultFileJobHandler;
import com.nanri.aiimage.modules.queryasin.service.QueryAsinTaskService;
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
@@ -1,4 +1,17 @@
package com.nanri.aiimage.modules.task.service;
import com.nanri.aiimage.modules.withdraw.service.WithdrawResultFileJobHandler;
import com.nanri.aiimage.modules.similarasin.service.SimilarAsinResultFileJobHandler;
import com.nanri.aiimage.modules.shopmatch.service.ShopMatchResultFileJobHandler;
import com.nanri.aiimage.modules.shopdatacrawl.service.ShopDataCrawlResultFileJobHandler;
import com.nanri.aiimage.modules.queryasin.service.QueryAsinResultFileJobHandler;
import com.nanri.aiimage.modules.publish.service.PublishResultFileJobHandler;
import com.nanri.aiimage.modules.productrisk.service.ProductRiskResultFileJobHandler;
import com.nanri.aiimage.modules.pricetrack.service.PriceTrackResultFileJobHandler;
import com.nanri.aiimage.modules.patroldelete.service.PatrolDeleteResultFileJobHandler;
import com.nanri.aiimage.modules.deletebrand.service.DeleteBrandResultFileJobHandler;
import com.nanri.aiimage.modules.collectdata.service.CollectDataResultFileJobHandler;
import com.nanri.aiimage.modules.brand.service.BrandResultFileJobHandler;
import com.nanri.aiimage.modules.appearancepatent.service.AppearancePatentResultFileJobHandler;
import com.nanri.aiimage.modules.appearancepatent.service.AppearancePatentTaskService;
import com.nanri.aiimage.modules.brand.service.BrandTaskService;
@@ -25,6 +38,7 @@ import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals;
@@ -178,8 +192,18 @@ class ResultFileJobHandlerMappingSnapshotTest {
}
private static String handlerSource(String simpleName) {
// 04 Handler SPI 实现已迁到各业务模块 service task 模块只留接口/注册表
String prefix = simpleName.replace("ResultFileJobHandler", "");
String module = Map.ofEntries(
Map.entry("AppearancePatent", "appearancepatent"), Map.entry("Brand", "brand"),
Map.entry("CollectData", "collectdata"), Map.entry("DeleteBrand", "deletebrand"),
Map.entry("PatrolDelete", "patroldelete"), Map.entry("PriceTrack", "pricetrack"),
Map.entry("ProductRisk", "productrisk"), Map.entry("Publish", "publish"),
Map.entry("QueryAsin", "queryasin"), Map.entry("ShopDataCrawl", "shopdatacrawl"),
Map.entry("ShopMatch", "shopmatch"), Map.entry("SimilarAsin", "similarasin"),
Map.entry("Withdraw", "withdraw")).get(prefix);
Path source = Paths.get("src", "main", "java", "com", "nanri", "aiimage", "modules",
"task", "service", simpleName + ".java");
module == null ? "task" : module, "service", simpleName + ".java");
try {
return new String(Files.readAllBytes(source), StandardCharsets.UTF_8);
} catch (IOException e) {
@@ -1,4 +1,5 @@
package com.nanri.aiimage.modules.task.service;
import com.nanri.aiimage.modules.shopdatacrawl.service.ShopDataCrawlResultFileJobHandler;
import com.nanri.aiimage.modules.shopdatacrawl.service.ShopDataCrawlTaskService;
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
@@ -1,4 +1,5 @@
package com.nanri.aiimage.modules.task.service;
import com.nanri.aiimage.modules.shopmatch.service.ShopMatchResultFileJobHandler;
import com.nanri.aiimage.modules.shopmatch.service.ShopMatchTaskService;
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
@@ -1,4 +1,5 @@
package com.nanri.aiimage.modules.task.service;
import com.nanri.aiimage.modules.similarasin.service.SimilarAsinResultFileJobHandler;
import com.nanri.aiimage.modules.similarasin.service.SimilarAsinTaskService;
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
@@ -0,0 +1,87 @@
package com.nanri.aiimage.modules.task.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Assumptions;
import org.junit.jupiter.api.Test;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.Statement;
import java.time.Duration;
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-206heartbeat 旧字段契约回放真实服务env-gated
* CONTRACT_BASE_URL + CONTRACT_DB_* 设则跑否则跳过CI 绿
* 覆盖RUNNING 任务 heartbeat alive:true路径/响应字段非运行任务 alive:false未知任务
* alive:false+message缺省/多余()字段容忍
*/
class TaskHeartbeatContractReplayTest {
private static final long TASK_ID = 9000004L;
private static final ObjectMapper MAPPER = new ObjectMapper();
@Test
void heartbeatContract() throws Exception {
String base = System.getenv("CONTRACT_BASE_URL");
String dbUrl = System.getenv("CONTRACT_DB_URL");
String dbUser = System.getenv("CONTRACT_DB_USER");
String dbPass = System.getenv("CONTRACT_DB_PASSWORD");
Assumptions.assumeTrue(base != null && dbUrl != null && dbUser != null, "未设 CONTRACT_* 跳过");
seed(dbUrl, dbUser, dbPass, "RUNNING");
HttpClient client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)).build();
// RUNNING + /额外字段 alive:true兼容多余字段
String body = "{\"moduleType\":\"SIMILAR_ASIN\",\"phase\":\"search\",\"current\":1,\"total\":10,"
+ "\"collectStage\":\"keyword-search\",\"currentKeyword\":\"ring\",\"searchCurrentPage\":1,"
+ "\"searchTotalPages\":4,\"detailProcessedAsins\":1,\"detailTotalAsins\":50,"
+ "\"extraLegacyField\":\"ignored\"}";
HttpResponse<String> ok = post(client, base + "/api/tasks/" + TASK_ID + "/heartbeat", body);
assertEquals(200, ok.statusCode());
JsonNode okBody = MAPPER.readTree(ok.body());
assertTrue(okBody.path("success").asBoolean(), ok.body());
assertTrue(okBody.path("data").path("alive").asBoolean(), "RUNNING 任务应 alive:true: " + ok.body());
assertEquals("SIMILAR_ASIN", okBody.path("data").path("moduleType").asText());
// 非运行 alive:false
seed(dbUrl, dbUser, dbPass, "FINISHED");
HttpResponse<String> finished = post(client, base + "/api/tasks/" + TASK_ID + "/heartbeat",
"{\"moduleType\":\"SIMILAR_ASIN\",\"phase\":\"search\",\"current\":1,\"total\":1}");
JsonNode f = MAPPER.readTree(finished.body());
assertFalse(f.path("data").path("alive").asBoolean(), "非运行任务应 alive:false: " + finished.body());
// 未知任务 alive:false + message
HttpResponse<String> unknown = post(client, base + "/api/tasks/999999999/heartbeat",
"{\"moduleType\":\"SIMILAR_ASIN\"}");
JsonNode u = MAPPER.readTree(unknown.body());
assertFalse(u.path("data").path("alive").asBoolean(), "未知任务应 alive:false");
assertTrue(u.path("data").has("message"), "应带 message");
}
private static void seed(String dbUrl, String user, String pass, String status) throws Exception {
try (Connection c = DriverManager.getConnection(dbUrl, user, pass);
Statement s = c.createStatement()) {
s.executeUpdate("INSERT INTO biz_file_task (id,task_no,module_type,task_mode,status,created_at,updated_at,user_id,owner_instance_id) "
+ "VALUES (" + TASK_ID + ",'HB-REPLAY-" + TASK_ID + "','SIMILAR_ASIN','workbook','" + status + "',NOW(),NOW(),1,'local-smoke') "
+ "ON DUPLICATE KEY UPDATE module_type='SIMILAR_ASIN',status='" + status + "',user_id=1");
}
}
private static HttpResponse<String> post(HttpClient client, String url, String body) throws Exception {
return client.send(HttpRequest.newBuilder(URI.create(url))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(body, StandardCharsets.UTF_8))
.timeout(Duration.ofSeconds(15)).build(),
HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
}
}
@@ -1,4 +1,17 @@
package com.nanri.aiimage.modules.task.service;
import com.nanri.aiimage.modules.withdraw.service.WithdrawResultFileJobHandler;
import com.nanri.aiimage.modules.similarasin.service.SimilarAsinResultFileJobHandler;
import com.nanri.aiimage.modules.shopmatch.service.ShopMatchResultFileJobHandler;
import com.nanri.aiimage.modules.shopdatacrawl.service.ShopDataCrawlResultFileJobHandler;
import com.nanri.aiimage.modules.queryasin.service.QueryAsinResultFileJobHandler;
import com.nanri.aiimage.modules.publish.service.PublishResultFileJobHandler;
import com.nanri.aiimage.modules.productrisk.service.ProductRiskResultFileJobHandler;
import com.nanri.aiimage.modules.pricetrack.service.PriceTrackResultFileJobHandler;
import com.nanri.aiimage.modules.patroldelete.service.PatrolDeleteResultFileJobHandler;
import com.nanri.aiimage.modules.deletebrand.service.DeleteBrandResultFileJobHandler;
import com.nanri.aiimage.modules.collectdata.service.CollectDataResultFileJobHandler;
import com.nanri.aiimage.modules.brand.service.BrandResultFileJobHandler;
import com.nanri.aiimage.modules.appearancepatent.service.AppearancePatentResultFileJobHandler;
import com.nanri.aiimage.config.InstanceMetadata;
import com.nanri.aiimage.modules.appearancepatent.service.AppearancePatentTaskService;
@@ -1,4 +1,17 @@
package com.nanri.aiimage.modules.task.service;
import com.nanri.aiimage.modules.withdraw.service.WithdrawResultFileJobHandler;
import com.nanri.aiimage.modules.similarasin.service.SimilarAsinResultFileJobHandler;
import com.nanri.aiimage.modules.shopmatch.service.ShopMatchResultFileJobHandler;
import com.nanri.aiimage.modules.shopdatacrawl.service.ShopDataCrawlResultFileJobHandler;
import com.nanri.aiimage.modules.queryasin.service.QueryAsinResultFileJobHandler;
import com.nanri.aiimage.modules.publish.service.PublishResultFileJobHandler;
import com.nanri.aiimage.modules.productrisk.service.ProductRiskResultFileJobHandler;
import com.nanri.aiimage.modules.pricetrack.service.PriceTrackResultFileJobHandler;
import com.nanri.aiimage.modules.patroldelete.service.PatrolDeleteResultFileJobHandler;
import com.nanri.aiimage.modules.deletebrand.service.DeleteBrandResultFileJobHandler;
import com.nanri.aiimage.modules.collectdata.service.CollectDataResultFileJobHandler;
import com.nanri.aiimage.modules.brand.service.BrandResultFileJobHandler;
import com.nanri.aiimage.modules.appearancepatent.service.AppearancePatentResultFileJobHandler;
import com.nanri.aiimage.config.InstanceMetadata;
import com.nanri.aiimage.modules.appearancepatent.service.AppearancePatentTaskService;
@@ -1,4 +1,17 @@
package com.nanri.aiimage.modules.task.service;
import com.nanri.aiimage.modules.withdraw.service.WithdrawResultFileJobHandler;
import com.nanri.aiimage.modules.similarasin.service.SimilarAsinResultFileJobHandler;
import com.nanri.aiimage.modules.shopmatch.service.ShopMatchResultFileJobHandler;
import com.nanri.aiimage.modules.shopdatacrawl.service.ShopDataCrawlResultFileJobHandler;
import com.nanri.aiimage.modules.queryasin.service.QueryAsinResultFileJobHandler;
import com.nanri.aiimage.modules.publish.service.PublishResultFileJobHandler;
import com.nanri.aiimage.modules.productrisk.service.ProductRiskResultFileJobHandler;
import com.nanri.aiimage.modules.pricetrack.service.PriceTrackResultFileJobHandler;
import com.nanri.aiimage.modules.patroldelete.service.PatrolDeleteResultFileJobHandler;
import com.nanri.aiimage.modules.deletebrand.service.DeleteBrandResultFileJobHandler;
import com.nanri.aiimage.modules.collectdata.service.CollectDataResultFileJobHandler;
import com.nanri.aiimage.modules.brand.service.BrandResultFileJobHandler;
import com.nanri.aiimage.modules.appearancepatent.service.AppearancePatentResultFileJobHandler;
import com.nanri.aiimage.common.exception.BusinessException;
import com.nanri.aiimage.config.InstanceMetadata;
@@ -1,4 +1,17 @@
package com.nanri.aiimage.modules.task.service;
import com.nanri.aiimage.modules.withdraw.service.WithdrawResultFileJobHandler;
import com.nanri.aiimage.modules.similarasin.service.SimilarAsinResultFileJobHandler;
import com.nanri.aiimage.modules.shopmatch.service.ShopMatchResultFileJobHandler;
import com.nanri.aiimage.modules.shopdatacrawl.service.ShopDataCrawlResultFileJobHandler;
import com.nanri.aiimage.modules.queryasin.service.QueryAsinResultFileJobHandler;
import com.nanri.aiimage.modules.publish.service.PublishResultFileJobHandler;
import com.nanri.aiimage.modules.productrisk.service.ProductRiskResultFileJobHandler;
import com.nanri.aiimage.modules.pricetrack.service.PriceTrackResultFileJobHandler;
import com.nanri.aiimage.modules.patroldelete.service.PatrolDeleteResultFileJobHandler;
import com.nanri.aiimage.modules.deletebrand.service.DeleteBrandResultFileJobHandler;
import com.nanri.aiimage.modules.collectdata.service.CollectDataResultFileJobHandler;
import com.nanri.aiimage.modules.brand.service.BrandResultFileJobHandler;
import com.nanri.aiimage.modules.appearancepatent.service.AppearancePatentResultFileJobHandler;
import com.nanri.aiimage.config.InstanceMetadata;
import com.nanri.aiimage.modules.appearancepatent.service.AppearancePatentTaskService;
@@ -1,4 +1,17 @@
package com.nanri.aiimage.modules.task.service;
import com.nanri.aiimage.modules.withdraw.service.WithdrawResultFileJobHandler;
import com.nanri.aiimage.modules.similarasin.service.SimilarAsinResultFileJobHandler;
import com.nanri.aiimage.modules.shopmatch.service.ShopMatchResultFileJobHandler;
import com.nanri.aiimage.modules.shopdatacrawl.service.ShopDataCrawlResultFileJobHandler;
import com.nanri.aiimage.modules.queryasin.service.QueryAsinResultFileJobHandler;
import com.nanri.aiimage.modules.publish.service.PublishResultFileJobHandler;
import com.nanri.aiimage.modules.productrisk.service.ProductRiskResultFileJobHandler;
import com.nanri.aiimage.modules.pricetrack.service.PriceTrackResultFileJobHandler;
import com.nanri.aiimage.modules.patroldelete.service.PatrolDeleteResultFileJobHandler;
import com.nanri.aiimage.modules.deletebrand.service.DeleteBrandResultFileJobHandler;
import com.nanri.aiimage.modules.collectdata.service.CollectDataResultFileJobHandler;
import com.nanri.aiimage.modules.brand.service.BrandResultFileJobHandler;
import com.nanri.aiimage.modules.appearancepatent.service.AppearancePatentResultFileJobHandler;
import com.nanri.aiimage.config.InstanceMetadata;
import com.nanri.aiimage.modules.appearancepatent.service.AppearancePatentTaskService;
@@ -1,4 +1,17 @@
package com.nanri.aiimage.modules.task.service;
import com.nanri.aiimage.modules.withdraw.service.WithdrawResultFileJobHandler;
import com.nanri.aiimage.modules.similarasin.service.SimilarAsinResultFileJobHandler;
import com.nanri.aiimage.modules.shopmatch.service.ShopMatchResultFileJobHandler;
import com.nanri.aiimage.modules.shopdatacrawl.service.ShopDataCrawlResultFileJobHandler;
import com.nanri.aiimage.modules.queryasin.service.QueryAsinResultFileJobHandler;
import com.nanri.aiimage.modules.publish.service.PublishResultFileJobHandler;
import com.nanri.aiimage.modules.productrisk.service.ProductRiskResultFileJobHandler;
import com.nanri.aiimage.modules.pricetrack.service.PriceTrackResultFileJobHandler;
import com.nanri.aiimage.modules.patroldelete.service.PatrolDeleteResultFileJobHandler;
import com.nanri.aiimage.modules.deletebrand.service.DeleteBrandResultFileJobHandler;
import com.nanri.aiimage.modules.collectdata.service.CollectDataResultFileJobHandler;
import com.nanri.aiimage.modules.brand.service.BrandResultFileJobHandler;
import com.nanri.aiimage.modules.appearancepatent.service.AppearancePatentResultFileJobHandler;
import com.nanri.aiimage.common.exception.BusinessException;
import com.nanri.aiimage.config.InstanceMetadata;

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