Compare commits
59 Commits
5ee99f9441
...
445c139bce
| Author | SHA1 | Date | |
|---|---|---|---|
| 445c139bce | |||
| 1924e273e9 | |||
| be332a47ab | |||
| 01e8c608e7 | |||
| d4b4e1e165 | |||
| fe3b8624bf | |||
| 3ee281a433 | |||
| 736760aa31 | |||
| 8cbc86f6fa | |||
| 6bd3e76048 | |||
| 4735a18390 | |||
| c282327a0b | |||
| dacdab7adb | |||
| f0f9ba29f0 | |||
| 0b83c9a3c1 | |||
| db0c1c1fdc | |||
| 92bd114e71 | |||
| e69d82e7b1 | |||
| 5c0f535aca | |||
| d3b499b596 | |||
| d6086ec7b0 | |||
| 9d172ac170 | |||
| 2304e634ee | |||
| 68b743f18b | |||
| 63b7cd82a6 | |||
| c1922d9e26 | |||
| 541e397ca6 | |||
| f5d4410063 | |||
| 5c63604b5b | |||
| 1b27a3f1ca | |||
| d6b223ba25 | |||
| b6cf693e05 | |||
| 187c123071 | |||
| 402c3e2cb3 | |||
| 7f7090f433 | |||
| bcd5879a08 | |||
| 5688870788 | |||
| 36463f2119 | |||
| 75d553f99a | |||
| b468750d00 | |||
| 4179e0de23 | |||
| 7967f454ec | |||
| 7b73a2cf6e | |||
| 03f5b827e2 | |||
| e365fc84f8 | |||
| 17cfd6902b | |||
| 0ec4fb3534 | |||
| 0863614e66 | |||
| a2c60d319f | |||
| 39fb23bd16 | |||
| 5fa59680f2 | |||
| 620ee15a48 | |||
| 05da03c5ba | |||
| 02cff8ea63 | |||
| 2a2d23e0dc | |||
| 19121f6239 | |||
| e248b2b935 | |||
| c1f03d64d1 | |||
| ec23c0d77c |
@@ -14,13 +14,35 @@ jobs:
|
|||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
container: maven:3.9-eclipse-temurin-21
|
container: maven:3.9-eclipse-temurin-21
|
||||||
|
timeout-minutes: 30
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
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
|
run: mvn clean package -DskipTests -q
|
||||||
- name: Upload artifact
|
- name: Upload artifact
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: backend-jar
|
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
|
||||||
|
|||||||
@@ -150,6 +150,12 @@
|
|||||||
<artifactId>spring-boot-starter-test</artifactId>
|
<artifactId>spring-boot-starter-test</artifactId>
|
||||||
<scope>test</scope>
|
<scope>test</scope>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.tngtech.archunit</groupId>
|
||||||
|
<artifactId>archunit-junit5</artifactId>
|
||||||
|
<version>1.3.0</version>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
</dependencies>
|
</dependencies>
|
||||||
|
|
||||||
<build>
|
<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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+61
@@ -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 llmApiKey = "";
|
||||||
|
|
||||||
/** 类目匹配(一级/二级)使用的小模型。 */
|
/** 类目匹配(一级/二级)使用的小模型。 */
|
||||||
private String llmCategoryModel = "gemini-3.5-flash-lite";
|
private String llmCategoryModel = "glm-5.3-flash";
|
||||||
|
|
||||||
/** 合规检查(is_conform/reason/category)使用的小模型。 */
|
/** 合规检查(is_conform/reason/category)使用的小模型。 */
|
||||||
private String llmConformModel = "gemini-3.5-flash-lite";
|
private String llmConformModel = "glm-5.3-flash";
|
||||||
|
|
||||||
/** 图片相似度对比(主图 vs 拼接图)使用的模型。 */
|
/** 图片相似度对比(主图 vs 拼接图)使用的模型。 */
|
||||||
private String llmImageCompareModel = "gemini-3.7-flash";
|
private String llmImageCompareModel = "glm-5.3-flash";
|
||||||
|
|
||||||
private int llmMaxTokens = 64000;
|
private int llmMaxTokens = 64000;
|
||||||
private int llmConnectTimeoutMillis = 10000;
|
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.shopdatacrawl.service.ShopDataCrawlTaskService;
|
||||||
import com.nanri.aiimage.modules.shopmatch.service.ShopMatchTaskService;
|
import com.nanri.aiimage.modules.shopmatch.service.ShopMatchTaskService;
|
||||||
import com.nanri.aiimage.modules.similarasin.service.SimilarAsinTaskService;
|
import com.nanri.aiimage.modules.similarasin.service.SimilarAsinTaskService;
|
||||||
import com.nanri.aiimage.modules.task.service.AppearancePatentResultFileJobHandler;
|
import com.nanri.aiimage.modules.appearancepatent.service.AppearancePatentResultFileJobHandler;
|
||||||
import com.nanri.aiimage.modules.task.service.BrandResultFileJobHandler;
|
import com.nanri.aiimage.modules.brand.service.BrandResultFileJobHandler;
|
||||||
import com.nanri.aiimage.modules.task.service.CollectDataResultFileJobHandler;
|
import com.nanri.aiimage.modules.collectdata.service.CollectDataResultFileJobHandler;
|
||||||
import com.nanri.aiimage.modules.task.service.DeleteBrandResultFileJobHandler;
|
import com.nanri.aiimage.modules.deletebrand.service.DeleteBrandResultFileJobHandler;
|
||||||
import com.nanri.aiimage.modules.task.service.PatrolDeleteResultFileJobHandler;
|
import com.nanri.aiimage.modules.patroldelete.service.PatrolDeleteResultFileJobHandler;
|
||||||
import com.nanri.aiimage.modules.task.service.PriceTrackResultFileJobHandler;
|
import com.nanri.aiimage.modules.pricetrack.service.PriceTrackResultFileJobHandler;
|
||||||
import com.nanri.aiimage.modules.task.service.ProductRiskResultFileJobHandler;
|
import com.nanri.aiimage.modules.productrisk.service.ProductRiskResultFileJobHandler;
|
||||||
import com.nanri.aiimage.modules.task.service.PublishResultFileJobHandler;
|
import com.nanri.aiimage.modules.publish.service.PublishResultFileJobHandler;
|
||||||
import com.nanri.aiimage.modules.task.service.QueryAsinResultFileJobHandler;
|
import com.nanri.aiimage.modules.queryasin.service.QueryAsinResultFileJobHandler;
|
||||||
import com.nanri.aiimage.modules.task.service.ResultFileJobHandler;
|
import com.nanri.aiimage.modules.task.service.ResultFileJobHandler;
|
||||||
import com.nanri.aiimage.modules.task.service.ResultFileJobHandlerRegistry;
|
import com.nanri.aiimage.modules.task.service.ResultFileJobHandlerRegistry;
|
||||||
import com.nanri.aiimage.modules.task.service.ShopDataCrawlResultFileJobHandler;
|
import com.nanri.aiimage.modules.shopdatacrawl.service.ShopDataCrawlResultFileJobHandler;
|
||||||
import com.nanri.aiimage.modules.task.service.ShopMatchResultFileJobHandler;
|
import com.nanri.aiimage.modules.shopmatch.service.ShopMatchResultFileJobHandler;
|
||||||
import com.nanri.aiimage.modules.task.service.SimilarAsinResultFileJobHandler;
|
import com.nanri.aiimage.modules.similarasin.service.SimilarAsinResultFileJobHandler;
|
||||||
import com.nanri.aiimage.modules.task.service.TaskResultPayloadService;
|
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 com.nanri.aiimage.modules.withdraw.service.WithdrawTaskService;
|
||||||
import io.micrometer.core.instrument.MeterRegistry;
|
import io.micrometer.core.instrument.MeterRegistry;
|
||||||
import org.springframework.beans.factory.ObjectProvider;
|
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)。
|
||||||
|
*
|
||||||
|
* 真实基线(已核实):连接超时来自共享 HttpClient(HttpClientPool 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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 任务/结果文件/上传/临时磁盘统一指标 recorder(module 11,spec §1 标准名)。
|
||||||
|
*
|
||||||
|
* 指标名与标签按 spec 11 §1 冻结:aiimage.task.* / aiimage.file-job.* / aiimage.upload.* /
|
||||||
|
* aiimage.tmpdisk.*,标签 moduleType/instanceId/stage;moduleType/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,底稿是 gauge(never 负)。 */
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
-1
@@ -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.appearancepatent.service.AppearancePatentTaskService;
|
||||||
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
|
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
|
||||||
+3
-1
@@ -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.brand.service.BrandTaskService;
|
||||||
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
|
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
|
||||||
+2
-1
@@ -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.collectdata.service.CollectDataService;
|
||||||
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
|
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
|
||||||
+2
-1
@@ -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.deletebrand.service.DeleteBrandRunService;
|
||||||
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
|
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
|
||||||
+2
-8
@@ -787,13 +787,7 @@ public class ImageVideoCozeService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private String maskForLog(String value) {
|
private String maskForLog(String value) {
|
||||||
if (!hasText(value)) {
|
// 统一走 StructuredLog.maskToken(task-190:Token 部分脱敏规则单一来源)
|
||||||
return "";
|
return com.nanri.aiimage.config.StructuredLog.maskToken(value);
|
||||||
}
|
|
||||||
String text = value.trim();
|
|
||||||
if (text.length() <= 10) {
|
|
||||||
return "***";
|
|
||||||
}
|
|
||||||
return text.substring(0, 6) + "***" + text.substring(text.length() - 4);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-1
@@ -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.patroldelete.service.PatrolDeleteTaskService;
|
||||||
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
|
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
|
||||||
+3
-1
@@ -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.pricetrack.service.PriceTrackTaskService;
|
||||||
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
|
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
|
||||||
+3
-1
@@ -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.productrisk.service.ProductRiskTaskService;
|
||||||
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
|
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
|
||||||
+2
-1
@@ -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.publish.service.PublishTaskService;
|
||||||
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
|
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
|
||||||
+3
-1
@@ -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.queryasin.service.QueryAsinTaskService;
|
||||||
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
|
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
|
||||||
+3
-1
@@ -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.shopdatacrawl.service.ShopDataCrawlTaskService;
|
||||||
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
|
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
|
||||||
+14
-5
@@ -183,13 +183,13 @@ public class ShopDataDuplicateCheckQueryService {
|
|||||||
List<DuplicateItem> cropped = cropItems(view.payload().items(), visibleKeys);
|
List<DuplicateItem> cropped = cropItems(view.payload().items(), visibleKeys);
|
||||||
List<DuplicateItem> matched = filterItems(cropped, monitor, filters);
|
List<DuplicateItem> matched = filterItems(cropped, monitor, filters);
|
||||||
List<List<String>> rows = new ArrayList<>();
|
List<List<String>> rows = new ArrayList<>();
|
||||||
rows.add(List.of("ASIN", "店铺数", "店铺", "分组", "站点", "上架时间", "价格", "品牌"));
|
rows.add(List.of("ASIN", "店铺数", "店铺", "分组", "国家", "上架时间", "价格", "品牌"));
|
||||||
List<Object[]> flat = new ArrayList<>();
|
List<Object[]> flat = new ArrayList<>();
|
||||||
for (DuplicateItem item : matched) {
|
for (DuplicateItem item : matched) {
|
||||||
String shopCount = String.valueOf(item.shopCount());
|
String shopCount = String.valueOf(item.shopCount());
|
||||||
for (DuplicateOccurrence occ : item.occurrences()) {
|
for (DuplicateOccurrence occ : item.occurrences()) {
|
||||||
flat.add(new Object[]{item.asin(), shopCount, occ.shopName(), occ.groupName(),
|
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());
|
flat.sort(flatExportComparator());
|
||||||
@@ -358,9 +358,18 @@ public class ShopDataDuplicateCheckQueryService {
|
|||||||
source == null ? "" : source);
|
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()) {
|
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();
|
List<String> codes = occ.countryCodes() == null ? List.of() : occ.countryCodes();
|
||||||
if (codes.isEmpty()) {
|
if (codes.isEmpty()) {
|
||||||
@@ -371,7 +380,7 @@ public class ShopDataDuplicateCheckQueryService {
|
|||||||
if (sb.length() > 0) {
|
if (sb.length() > 0) {
|
||||||
sb.append('、');
|
sb.append('、');
|
||||||
}
|
}
|
||||||
sb.append(safe(code).toUpperCase(Locale.ROOT));
|
sb.append(siteCnLabel(code));
|
||||||
}
|
}
|
||||||
return sb.toString();
|
return sb.toString();
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-1
@@ -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.shopmatch.service.ShopMatchTaskService;
|
||||||
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
|
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
|
||||||
+31
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
-1
@@ -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.similarasin.service.SimilarAsinTaskService;
|
||||||
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
|
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
|
||||||
+6
-3
@@ -1017,12 +1017,15 @@ public class SimilarAsinTaskService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ensureTaskOwnedByCurrentInstance(task, "submit result");
|
ensureTaskOwnedByCurrentInstance(task, "submit result");
|
||||||
int chunkIndex = request.getChunkIndex() == null ? 0 : request.getChunkIndex();
|
int chunkIndex = com.nanri.aiimage.modules.similarasin.model.SimilarAsinSubmitResultSemantics
|
||||||
int chunkTotal = request.getChunkTotal() == null ? 1 : request.getChunkTotal();
|
.chunkIndex(request.getChunkIndex());
|
||||||
|
int chunkTotal = com.nanri.aiimage.modules.similarasin.model.SimilarAsinSubmitResultSemantics
|
||||||
|
.chunkTotal(request.getChunkTotal());
|
||||||
boolean done = Boolean.TRUE.equals(request.getDone());
|
boolean done = Boolean.TRUE.equals(request.getDone());
|
||||||
String scopeKey = firstNonBlank(request.getSubmissionId(), "task:" + taskId);
|
String scopeKey = firstNonBlank(request.getSubmissionId(), "task:" + taskId);
|
||||||
String scopeHash = DigestUtil.sha256Hex(scopeKey);
|
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
|
SubmittedTaskMetadata taskMetadata = terminalCallback
|
||||||
? readSubmittedTaskMetadata(task)
|
? readSubmittedTaskMetadata(task)
|
||||||
: null;
|
: null;
|
||||||
|
|||||||
+108
@@ -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 张只读报表 SQL(resources/inspection/*.sql,task-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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+11
-16
@@ -2,13 +2,8 @@ package com.nanri.aiimage.modules.task.service;
|
|||||||
|
|
||||||
import com.nanri.aiimage.common.exception.BusinessException;
|
import com.nanri.aiimage.common.exception.BusinessException;
|
||||||
import com.nanri.aiimage.common.exception.TaskOwnerMismatchException;
|
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.config.InstanceMetadata;
|
||||||
import com.nanri.aiimage.modules.brand.service.BrandTaskService;
|
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.mapper.FileResultMapper;
|
||||||
import com.nanri.aiimage.modules.task.model.entity.FileResultEntity;
|
import com.nanri.aiimage.modules.task.model.entity.FileResultEntity;
|
||||||
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
|
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
|
||||||
@@ -117,7 +112,7 @@ public class TaskResultFileJobWorker {
|
|||||||
// 重试耗尽后把归属机上跑得好好的任务判成失败。
|
// 重试耗尽后把归属机上跑得好好的任务判成失败。
|
||||||
// job 不会因此卡死:归属实例的 runPendingJobs 每 15 秒扫一次自己 owner 的
|
// job 不会因此卡死:归属实例的 runPendingJobs 每 15 秒扫一次自己 owner 的
|
||||||
// PENDING/FAILED job,enqueueAssembleResult / stuck 扫描也会补发 dispatch。
|
// PENDING/FAILED job,enqueueAssembleResult / 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(),
|
job.getId(), job.getTaskId(), job.getModuleType(), job.getStatus(),
|
||||||
ownerFromScopeKey(job.getScopeKey()), currentInstanceId());
|
ownerFromScopeKey(job.getScopeKey()), currentInstanceId());
|
||||||
return;
|
return;
|
||||||
@@ -160,7 +155,7 @@ public class TaskResultFileJobWorker {
|
|||||||
try {
|
try {
|
||||||
taskFileJobService.touchRunning(job.getId());
|
taskFileJobService.touchRunning(job.getId());
|
||||||
} catch (Exception ex) {
|
} 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());
|
job.getId(), job.getTaskId(), job.getModuleType(), ex.getMessage());
|
||||||
}
|
}
|
||||||
}, interval, interval, TimeUnit.MILLISECONDS);
|
}, interval, interval, TimeUnit.MILLISECONDS);
|
||||||
@@ -194,7 +189,7 @@ public class TaskResultFileJobWorker {
|
|||||||
taskDistributedLockService.acquire(job.getModuleType(), job.getTaskId(), TaskDistributedLockService.DEFAULT_WAIT_MILLIS);
|
taskDistributedLockService.acquire(job.getModuleType(), job.getTaskId(), TaskDistributedLockService.DEFAULT_WAIT_MILLIS);
|
||||||
if (lockHandle == null) {
|
if (lockHandle == null) {
|
||||||
taskFileJobService.requeue(job.getId(), "Task is busy, waiting for previous task operation");
|
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());
|
job.getId(), job.getTaskId(), job.getModuleType(), job.getResultId());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -209,13 +204,13 @@ public class TaskResultFileJobWorker {
|
|||||||
if (!completed) {
|
if (!completed) {
|
||||||
if (isOwnerScopedJob(job)) {
|
if (isOwnerScopedJob(job)) {
|
||||||
taskFileJobService.touchRunning(job.getId());
|
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(),
|
job.getId(), job.getTaskId(), job.getModuleType(), job.getResultId(),
|
||||||
System.currentTimeMillis() - startedAt);
|
System.currentTimeMillis() - startedAt);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
taskFileJobService.deferRunning(job.getId(), "Waiting for LLM/file assembly to continue");
|
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(),
|
job.getId(), job.getTaskId(), job.getModuleType(), job.getResultId(),
|
||||||
System.currentTimeMillis() - startedAt);
|
System.currentTimeMillis() - startedAt);
|
||||||
return;
|
return;
|
||||||
@@ -224,7 +219,7 @@ public class TaskResultFileJobWorker {
|
|||||||
taskFileJobService.markSuccess(job, resultFileUrl);
|
taskFileJobService.markSuccess(job, resultFileUrl);
|
||||||
cleanupAfterSuccess(job);
|
cleanupAfterSuccess(job);
|
||||||
finalizeWithdraw = "WITHDRAW".equals(job.getModuleType());
|
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(),
|
job.getId(), job.getTaskId(), job.getModuleType(), job.getResultId(),
|
||||||
System.currentTimeMillis() - startedAt, resultFileUrl);
|
System.currentTimeMillis() - startedAt, resultFileUrl);
|
||||||
}
|
}
|
||||||
@@ -248,13 +243,13 @@ public class TaskResultFileJobWorker {
|
|||||||
// 孤儿 job:fileTask/fileResult 已被删除却残留 task_file_job 行,重试 5 次也救不回来,
|
// 孤儿 job:fileTask/fileResult 已被删除却残留 task_file_job 行,重试 5 次也救不回来,
|
||||||
// 直接拉满 retryCount 让 worker 跳过,避免每 15 秒刷一次 task not found 警告日志。
|
// 直接拉满 retryCount 让 worker 跳过,避免每 15 秒刷一次 task not found 警告日志。
|
||||||
if (isOrphanJobFailure(ex, message)) {
|
if (isOrphanJobFailure(ex, message)) {
|
||||||
log.warn("[task-file-job] process aborted because owning task/result no longer exists jobId={} taskId={} moduleType={} resultId={} msg={}",
|
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);
|
job.getId(), job.getTaskId(), job.getModuleType(), job.getResultId(), message, ex.getClass().getSimpleName());
|
||||||
taskFileJobService.markFailedPermanent(job, message);
|
taskFileJobService.markFailedPermanent(job, message);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
log.warn("[task-file-job] process failed jobId={} taskId={} moduleType={} resultId={} msg={}",
|
log.warn("[task-file-job] process failed jobId={} taskId={} moduleType={} resultId={} msg={} stage=FAILED errorType={}",
|
||||||
job.getId(), job.getTaskId(), job.getModuleType(), job.getResultId(), message);
|
job.getId(), job.getTaskId(), job.getModuleType(), job.getResultId(), message, ex.getClass().getSimpleName());
|
||||||
taskFileJobService.markFailed(job, message);
|
taskFileJobService.markFailed(job, message);
|
||||||
if (taskFileJobService.isRetryExhausted(job.getId())) {
|
if (taskFileJobService.isRetryExhausted(job.getId())) {
|
||||||
finalizeRetryExhausted(job, message);
|
finalizeRetryExhausted(job, message);
|
||||||
@@ -266,7 +261,7 @@ public class TaskResultFileJobWorker {
|
|||||||
try {
|
try {
|
||||||
notifyRetryExhausted(job, message);
|
notifyRetryExhausted(job, message);
|
||||||
if (!taskFileJobService.markFailureFinalized(job.getId(), 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());
|
job.getId(), job.getTaskId(), job.getModuleType());
|
||||||
}
|
}
|
||||||
} catch (Exception ex) {
|
} catch (Exception ex) {
|
||||||
|
|||||||
+3
-1
@@ -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.task.model.entity.TaskFileJobEntity;
|
||||||
import com.nanri.aiimage.modules.withdraw.service.WithdrawTaskService;
|
import com.nanri.aiimage.modules.withdraw.service.WithdrawTaskService;
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
-- =============================================================
|
||||||
|
-- 巡检 01:孤儿 task_file_job(job 无对应任务/结果)— 只读报表(task-194)
|
||||||
|
-- 对应 Java 巡检:OrphanJobInspector(module-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 巡检:TaskResultMissingInspector(module-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:终态任务仍有活跃 job(SUCCESS/FAILED 任务下存在非终态 job)— 只读报表(task-198)
|
||||||
|
-- 对应 Java 巡检:CompletedTaskActiveJobInspector(module-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:超保留期仍活跃的 job(RUNNING 且 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;
|
return isNaN(t) ? -1 : t;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 「查看明细」按钮点击开抽屉(动态元素,事件委托)
|
// 「查看明细」按钮点击开抽屉(动态元素,事件委托),并把被点按钮作为抽屉的垂直锚点
|
||||||
function handleDuplicateDetailClick(event) {
|
function handleDuplicateDetailClick(event) {
|
||||||
var button = event.target.closest('[data-open-asin-detail]');
|
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 + 店在售徽章 / 品牌行 / 彩色徽章组 / 按时间降序明细 + 次数列
|
// 图3 样式抽屉:ASIN + 店在售徽章 / 品牌行 / 彩色徽章组 / 按时间降序明细 + 次数列
|
||||||
// 块内定位:抽屉贴到「撞款详情」区块顶部(anchorRect),而不是浏览器左侧顶部;
|
// 块内定位:抽屉横向贴到「撞款详情」区块,纵向以触发元素(被点的 ASIN/「查看明细」按钮)为锚点、
|
||||||
// 区块滚出视口时钳制到视口内可见区域(顶部矩阵点 ASIN 打开也要能看到抽屉)
|
// 钳制在视口内,保证弹窗出现在点击处附近而非页面/滚动区顶部(顶部弹出会被当前滚动位置盖住看不见)。
|
||||||
|
var dupDrawerTriggerEl = null;
|
||||||
|
|
||||||
function positionDupDrawer() {
|
function positionDupDrawer() {
|
||||||
var mask = document.getElementById('dupCheckDrawerMask');
|
var mask = document.getElementById('dupCheckDrawerMask');
|
||||||
var aside = mask ? mask.querySelector('.drawer') : null;
|
var aside = mask ? mask.querySelector('.drawer') : null;
|
||||||
var anchor = document.getElementById('dupCheckDetailBlock');
|
var block = document.getElementById('dupCheckDetailBlock');
|
||||||
if (!aside || !anchor) return;
|
if (!aside || !block) return;
|
||||||
var rect = anchor.getBoundingClientRect();
|
|
||||||
var bodyHeight = Math.max(document.documentElement.clientHeight, window.innerHeight);
|
var bodyHeight = Math.max(document.documentElement.clientHeight, window.innerHeight);
|
||||||
var padTop = 24, padRight = 24, padBottom = 24;
|
var padTop = 24, padRight = 24, padBottom = 24;
|
||||||
var maxHeight = Math.max(260, bodyHeight - padTop - padBottom);
|
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);
|
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));
|
top = Math.min(top, Math.max(padTop, bodyHeight - 260));
|
||||||
aside.style.left = left + 'px';
|
aside.style.left = left + 'px';
|
||||||
@@ -2249,8 +2254,9 @@
|
|||||||
return mask;
|
return mask;
|
||||||
}
|
}
|
||||||
|
|
||||||
function openShopDataDuplicateDrawer(asin) {
|
function openShopDataDuplicateDrawer(asin, triggerEl) {
|
||||||
mountDupDrawerMaskToBody();
|
mountDupDrawerMaskToBody();
|
||||||
|
dupDrawerTriggerEl = triggerEl || null;
|
||||||
var item = null;
|
var item = null;
|
||||||
(shopDataDuplicateDetailItems || []).forEach(function (it) { if (it.asin === asin) item = it; });
|
(shopDataDuplicateDetailItems || []).forEach(function (it) { if (it.asin === asin) item = it; });
|
||||||
if (!item) {
|
if (!item) {
|
||||||
@@ -2738,10 +2744,10 @@
|
|||||||
document.getElementById('dupCheckImportModal').onclick = function (event) {
|
document.getElementById('dupCheckImportModal').onclick = function (event) {
|
||||||
if (event.target === this) closeDupCheckImportModal();
|
if (event.target === this) closeDupCheckImportModal();
|
||||||
};
|
};
|
||||||
// 矩阵表格内 ASIN / 数字徽标点击打开抽屉(动态元素,事件委托)
|
// 矩阵表格内 ASIN / 数字徽标点击打开抽屉(动态元素,事件委托),并传入被点元素作为锚点
|
||||||
document.getElementById('shopDataDuplicateList').onclick = function (event) {
|
document.getElementById('shopDataDuplicateList').onclick = function (event) {
|
||||||
var target = event.target.closest('[data-open-drawer]');
|
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;
|
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 或外部 Client(211)。
|
||||||
|
* 债务防恶化(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("未被改动")),
|
||||||
|
"应明确演练不动历史迁移");
|
||||||
|
}
|
||||||
|
}
|
||||||
+76
@@ -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 §2:Token/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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+286
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+210
@@ -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 10,mock 慢端点)。
|
||||||
|
*
|
||||||
|
* 用本机回环 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+207
@@ -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(), "总尝试次数严格受配置上限约束,不跑飞");
|
||||||
|
}
|
||||||
|
}
|
||||||
+100
@@ -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×download(10s);
|
||||||
|
* - 整批预取预算 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.maskToken,ImageVideo 已委托);结构化字段值
|
||||||
|
* 消毒防日志注入;重试守卫的 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";
|
||||||
|
}
|
||||||
|
}
|
||||||
+9
@@ -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-202:Python-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 + " 应可复现");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+9
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
+9
@@ -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";
|
||||||
|
}
|
||||||
|
}
|
||||||
+126
@@ -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-180:TaskResultFileJobWorker 日志字段与规范对齐(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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+9
@@ -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,诚实模式)。
|
||||||
|
*
|
||||||
|
* 现网真实基线(已核实):
|
||||||
|
* - 连接超时:共用 HttpClient(HttpClientPool 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 §1:instanceId 区分 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-185:file-job.* 指标契约(spec 11 §1,TaskResultFileJobWorker 接线面)。
|
||||||
|
* 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")));
|
||||||
|
}
|
||||||
|
}
|
||||||
+124
@@ -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-183:task.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-184:task.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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
+92
@@ -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-181:task.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-182:task.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 §1,09 临时磁盘巡检/告警接线面)。
|
||||||
|
* 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
+110
@@ -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, "列数应与契约表头一致");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+93
@@ -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())");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+5
-1
@@ -147,10 +147,14 @@ class ShopDataDuplicateCheckQueryServiceTest {
|
|||||||
assertThat(bytes[2]).isEqualTo((byte) 0xBF);
|
assertThat(bytes[2]).isEqualTo((byte) 0xBF);
|
||||||
String text = new String(bytes, StandardCharsets.UTF_8);
|
String text = new String(bytes, StandardCharsets.UTF_8);
|
||||||
String[] lines = text.split("\r\n");
|
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 会产生一个空元素)
|
// 数据行 = E(3 记录) + A(2) + F(2) = 7,非空行总数为 8(尾随 CRLF 会产生一个空元素)
|
||||||
long nonEmpty = java.util.Arrays.stream(lines).filter(line -> !line.isEmpty()).count();
|
long nonEmpty = java.util.Arrays.stream(lines).filter(line -> !line.isEmpty()).count();
|
||||||
assertThat(nonEmpty).isEqualTo(8);
|
assertThat(nonEmpty).isEqualTo(8);
|
||||||
assertThat(lines[1]).startsWith("E0000001,3,");
|
assertThat(lines[1]).startsWith("E0000001,3,");
|
||||||
|
// 站点代码列导出为中文国家名(UK→英国、FR→法国),与页面「国家」展示一致
|
||||||
|
assertThat(lines[1]).startsWith("E0000001,3,ShopA,GroupA,英国,");
|
||||||
|
assertThat(text).contains(",法国,");
|
||||||
|
assertThat(lines[1]).doesNotContain(",UK,");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+76
@@ -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.json(done=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 的提交应触发终态回调");
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -61,7 +61,7 @@ public class LlmGatewayTlsProbe {
|
|||||||
}
|
}
|
||||||
HttpClient client = builder.build();
|
HttpClient client = builder.build();
|
||||||
try {
|
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()
|
HttpRequest request = HttpRequest.newBuilder()
|
||||||
.uri(URI.create("https://" + host + "/v1/chat/completions"))
|
.uri(URI.create("https://" + host + "/v1/chat/completions"))
|
||||||
.timeout(Duration.ofSeconds(30))
|
.timeout(Duration.ofSeconds(30))
|
||||||
|
|||||||
+102
@@ -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-219(env-gated 全链路):similarasin 最小链路 done=true → assemble → 上传结果文件。
|
||||||
|
*
|
||||||
|
* 需 CONTRACT_BASE_URL + CONTRACT_DB_* + 本地 MinIO 桶(nanri-ai-images)已建;否则跳过。
|
||||||
|
* 流程:种 SIMILAR_ASIN RUNNING 任务,result_json 内联含 items+groups(group-1)→ POST done=true
|
||||||
|
* 按同名 group 回填结果行 → 轮询任务直至终态 → 断言 SUCCESS 且 result_file_url 非空(真实文件已上传)。
|
||||||
|
* 本地已验证:task 9000012 SUCCESS,MinIO 存在 3.8KB xlsx(PK 头)。
|
||||||
|
*/
|
||||||
|
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)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+79
@@ -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-205:parsed-payload 契约回放(真实服务,env-gated)。
|
||||||
|
* CONTRACT_BASE_URL + CONTRACT_DB_* 设则跑、否则跳过(CI 绿)。
|
||||||
|
* 造 SIMILAR_ASIN 任务 + 内联解析载荷 → GET parsed-payload:success、顶层字段(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));
|
||||||
|
}
|
||||||
|
}
|
||||||
+81
@@ -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-214:progress/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));
|
||||||
|
}
|
||||||
|
}
|
||||||
+102
@@ -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_URL、CONTRACT_DB_URL、
|
||||||
|
* CONTRACT_DB_USER、CONTRACT_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));
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
-1
@@ -563,6 +563,7 @@ class SimilarAsinTaskServicePerf500Test {
|
|||||||
writer.println(lines.get(1));
|
writer.println(lines.get(1));
|
||||||
}
|
}
|
||||||
assertTrue(Files.exists(report), "性能报告应写入 target/perf-report/");
|
assertTrue(Files.exists(report), "性能报告应写入 target/perf-report/");
|
||||||
assertTrue(lightMs < batchMs, "light 应快于 batch(报告记录)");
|
// 墙钟不作为断言口径(见类注释:JIT/GC/调度下毫秒不可比);"light 更轻"由本类确定性
|
||||||
|
// 查询次数/字段量用例覆盖
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
@@ -1,4 +1,5 @@
|
|||||||
package com.nanri.aiimage.modules.task.service;
|
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.appearancepatent.service.AppearancePatentTaskService;
|
||||||
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
|
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
|
||||||
|
|||||||
+1
@@ -1,4 +1,5 @@
|
|||||||
package com.nanri.aiimage.modules.task.service;
|
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.brand.service.BrandTaskService;
|
||||||
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
|
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
|
||||||
|
|||||||
+1
@@ -1,4 +1,5 @@
|
|||||||
package com.nanri.aiimage.modules.task.service;
|
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.collectdata.service.CollectDataService;
|
||||||
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
|
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
|
||||||
|
|||||||
+1
@@ -1,4 +1,5 @@
|
|||||||
package com.nanri.aiimage.modules.task.service;
|
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.deletebrand.service.DeleteBrandRunService;
|
||||||
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
|
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
|
||||||
|
|||||||
+181
@@ -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
@@ -1,4 +1,5 @@
|
|||||||
package com.nanri.aiimage.modules.task.service;
|
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.patroldelete.service.PatrolDeleteTaskService;
|
||||||
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
|
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
|
||||||
|
|||||||
+1
@@ -1,4 +1,5 @@
|
|||||||
package com.nanri.aiimage.modules.task.service;
|
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.pricetrack.service.PriceTrackTaskService;
|
||||||
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
|
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
|
||||||
|
|||||||
+1
@@ -1,4 +1,5 @@
|
|||||||
package com.nanri.aiimage.modules.task.service;
|
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.productrisk.service.ProductRiskTaskService;
|
||||||
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
|
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
|
||||||
|
|||||||
+1
@@ -1,4 +1,5 @@
|
|||||||
package com.nanri.aiimage.modules.task.service;
|
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.publish.service.PublishTaskService;
|
||||||
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
|
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
|
||||||
|
|||||||
+1
@@ -1,4 +1,5 @@
|
|||||||
package com.nanri.aiimage.modules.task.service;
|
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.queryasin.service.QueryAsinTaskService;
|
||||||
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
|
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
|
||||||
|
|||||||
+25
-1
@@ -1,4 +1,17 @@
|
|||||||
package com.nanri.aiimage.modules.task.service;
|
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.appearancepatent.service.AppearancePatentTaskService;
|
||||||
import com.nanri.aiimage.modules.brand.service.BrandTaskService;
|
import com.nanri.aiimage.modules.brand.service.BrandTaskService;
|
||||||
@@ -25,6 +38,7 @@ import java.nio.file.Paths;
|
|||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Comparator;
|
import java.util.Comparator;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
@@ -178,8 +192,18 @@ class ResultFileJobHandlerMappingSnapshotTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static String handlerSource(String simpleName) {
|
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",
|
Path source = Paths.get("src", "main", "java", "com", "nanri", "aiimage", "modules",
|
||||||
"task", "service", simpleName + ".java");
|
module == null ? "task" : module, "service", simpleName + ".java");
|
||||||
try {
|
try {
|
||||||
return new String(Files.readAllBytes(source), StandardCharsets.UTF_8);
|
return new String(Files.readAllBytes(source), StandardCharsets.UTF_8);
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
|
|||||||
+1
@@ -1,4 +1,5 @@
|
|||||||
package com.nanri.aiimage.modules.task.service;
|
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.shopdatacrawl.service.ShopDataCrawlTaskService;
|
||||||
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
|
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
|
||||||
|
|||||||
+1
@@ -1,4 +1,5 @@
|
|||||||
package com.nanri.aiimage.modules.task.service;
|
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.shopmatch.service.ShopMatchTaskService;
|
||||||
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
|
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
|
||||||
|
|||||||
+1
@@ -1,4 +1,5 @@
|
|||||||
package com.nanri.aiimage.modules.task.service;
|
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.similarasin.service.SimilarAsinTaskService;
|
||||||
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
|
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
|
||||||
|
|||||||
+87
@@ -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-206:heartbeat 旧字段契约回放(真实服务,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));
|
||||||
|
}
|
||||||
|
}
|
||||||
+13
@@ -1,4 +1,17 @@
|
|||||||
package com.nanri.aiimage.modules.task.service;
|
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.config.InstanceMetadata;
|
||||||
import com.nanri.aiimage.modules.appearancepatent.service.AppearancePatentTaskService;
|
import com.nanri.aiimage.modules.appearancepatent.service.AppearancePatentTaskService;
|
||||||
|
|||||||
+13
@@ -1,4 +1,17 @@
|
|||||||
package com.nanri.aiimage.modules.task.service;
|
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.config.InstanceMetadata;
|
||||||
import com.nanri.aiimage.modules.appearancepatent.service.AppearancePatentTaskService;
|
import com.nanri.aiimage.modules.appearancepatent.service.AppearancePatentTaskService;
|
||||||
|
|||||||
+13
@@ -1,4 +1,17 @@
|
|||||||
package com.nanri.aiimage.modules.task.service;
|
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.common.exception.BusinessException;
|
||||||
import com.nanri.aiimage.config.InstanceMetadata;
|
import com.nanri.aiimage.config.InstanceMetadata;
|
||||||
|
|||||||
+13
@@ -1,4 +1,17 @@
|
|||||||
package com.nanri.aiimage.modules.task.service;
|
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.config.InstanceMetadata;
|
||||||
import com.nanri.aiimage.modules.appearancepatent.service.AppearancePatentTaskService;
|
import com.nanri.aiimage.modules.appearancepatent.service.AppearancePatentTaskService;
|
||||||
|
|||||||
+13
@@ -1,4 +1,17 @@
|
|||||||
package com.nanri.aiimage.modules.task.service;
|
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.config.InstanceMetadata;
|
||||||
import com.nanri.aiimage.modules.appearancepatent.service.AppearancePatentTaskService;
|
import com.nanri.aiimage.modules.appearancepatent.service.AppearancePatentTaskService;
|
||||||
|
|||||||
+13
@@ -1,4 +1,17 @@
|
|||||||
package com.nanri.aiimage.modules.task.service;
|
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.common.exception.BusinessException;
|
||||||
import com.nanri.aiimage.config.InstanceMetadata;
|
import com.nanri.aiimage.config.InstanceMetadata;
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user