diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/client/SimilarAsinCozeClient.java b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/client/SimilarAsinCozeClient.java index 0802cb5b..b6419e00 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/client/SimilarAsinCozeClient.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/client/SimilarAsinCozeClient.java @@ -5,6 +5,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.nanri.aiimage.config.SimilarAsinProperties; import com.nanri.aiimage.modules.coze.service.CozeCredentialPoolService; import com.nanri.aiimage.modules.similarasin.model.dto.SimilarAsinResultRowDto; +import com.nanri.aiimage.modules.similarasin.util.SimilarAsinLogSupport; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpHeaders; @@ -30,11 +31,14 @@ public class SimilarAsinCozeClient { private static final String MODULE_TYPE = "SIMILAR_ASIN"; private static final MediaType APPLICATION_JSON_UTF8 = new MediaType("application", "json", StandardCharsets.UTF_8); + /** Task 19:history 轮询响应正文采样频率(每 N 次记一次完整正文,其余只记状态)。 */ + static final long HISTORY_RESPONSE_LOG_EVERY_N = 20L; private final SimilarAsinProperties properties; private final ObjectMapper objectMapper; private final CozeCredentialPoolService cozeCredentialPoolService; private final AtomicLong credentialCursor = new AtomicLong(); + private final AtomicLong historyResponseLogCounter = new AtomicLong(); /** * P1-7:单例 RestClient。原 restClient() 每次提交/poll 都新建 SimpleClientHttpRequestFactory + RestClient, * 几千行任务并发时会反复创建短命对象造成不必要 GC 压力。RestClient 与 SimpleClientHttpRequestFactory @@ -267,10 +271,10 @@ public class SimilarAsinCozeClient { body.put("workflow_id", credential.workflowId()); body.put("parameters", parameters); body.put("is_async", Boolean.TRUE); - log.info("[similar-asin] coze request credential={} url={} body={}", + log.debug("[similar-asin] coze request credential={} url={} body={}", credential.name(), joinUrl(properties.getCozeBaseUrl(), properties.getCozeWorkflowPath()), - writeJson(maskCozeRequestBody(body))); + SimilarAsinLogSupport.truncate(writeJson(maskCozeRequestBody(body)))); RestClient.RequestBodySpec request = restClient().post() .uri(joinUrl(properties.getCozeBaseUrl(), properties.getCozeWorkflowPath())) @@ -283,9 +287,9 @@ public class SimilarAsinCozeClient { return request.exchange((clientRequest, clientResponse) -> { byte[] responseBytes = StreamUtils.copyToByteArray(clientResponse.getBody()); String responseText = responseBytes.length == 0 ? "" : new String(responseBytes, StandardCharsets.UTF_8); - log.info("[similar-asin] coze submit response status={} body={}", + log.debug("[similar-asin] coze submit response status={} body={}", clientResponse.getStatusCode(), - responseText); + SimilarAsinLogSupport.truncate(responseText)); return responseText; }); } @@ -294,6 +298,7 @@ public class SimilarAsinCozeClient { String path = properties.getCozeWorkflowHistoryPath() .replace("{workflow_id}", credential.workflowId()) .replace("{execute_id}", executeId); + long historyLogCounter = historyResponseLogCounter.getAndIncrement(); return restClient().get() .uri(joinUrl(properties.getCozeBaseUrl(), path)) .headers(headers -> { @@ -304,10 +309,12 @@ public class SimilarAsinCozeClient { .exchange((clientRequest, clientResponse) -> { byte[] responseBytes = StreamUtils.copyToByteArray(clientResponse.getBody()); String responseText = responseBytes.length == 0 ? "" : new String(responseBytes, StandardCharsets.UTF_8); - log.info("[similar-asin] coze history response credential={} executeId={} status={} body={}", + log.debug("[similar-asin] coze history response credential={} executeId={} status={} body={}", credential.name(), executeId, clientResponse.getStatusCode(), - responseText); + SimilarAsinLogSupport.shouldLog(historyLogCounter, HISTORY_RESPONSE_LOG_EVERY_N) + ? SimilarAsinLogSupport.truncate(responseText) + : "[sampled out]"); return responseText; }); } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskService.java index 625d3986..86abb21a 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskService.java @@ -40,6 +40,7 @@ import com.nanri.aiimage.modules.similarasin.model.vo.SimilarAsinTaskItemVo; import com.nanri.aiimage.modules.similarasin.util.BoundedImageCache; import com.nanri.aiimage.modules.similarasin.util.ExcelCellImageWriter; import com.nanri.aiimage.modules.similarasin.util.SimilarAsinImageEmbedder; +import com.nanri.aiimage.modules.similarasin.util.SimilarAsinLogSupport; import com.nanri.aiimage.modules.file.service.LocalFileStorageService; import com.nanri.aiimage.modules.file.service.oss.OssStorageService; import com.nanri.aiimage.modules.task.mapper.FileResultMapper; @@ -137,6 +138,8 @@ public class SimilarAsinTaskService { private static final String CONTENT_TYPE_XLSX = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; private static final String CONTENT_TYPE_ZIP = "application/zip"; private static final int RESULT_ROWS_READ_RETRY_LIMIT = 3; + /** Task 19:Python 回传逐行日志采样频率(每 N 行记一行)。 */ + private static final long PYTHON_INBOUND_LOG_EVERY_N = 20L; private static final long RESULT_ROWS_READ_RETRY_DELAY_MS = 500L; private static final Duration TASK_LOCK_TTL = Duration.ofMinutes(5); private static final long TASK_LOCK_WAIT_MILLIS = 10000L; @@ -1982,21 +1985,25 @@ public class SimilarAsinTaskService { * 打印 Python 端回传给 Java 的每一行 row 关键字段,确认 url(主图)/ urls(同类商品图)/ title / sku * 是否按预期到达。该日志与 SimilarAsinCozeClient 的 coze items diff 日志成对, * 便于排查"Python 回传了什么、Java 又把什么发到 Coze"。 + * Task 19:改为 DEBUG 级别并按行采样(每 20 行记一行),减少大任务日志量。 */ private void logPythonInboundRows(List rows) { if (rows == null || rows.isEmpty()) { return; } - log.info("[similar-asin] python inbound start size={}", rows.size()); + log.debug("[similar-asin] python inbound start size={}", rows.size()); for (int i = 0; i < rows.size(); i++) { SimilarAsinResultRowDto row = rows.get(i); if (row == null) { continue; } + if (!SimilarAsinLogSupport.shouldLog(i, PYTHON_INBOUND_LOG_EVERY_N)) { + continue; + } String url = row.getUrl(); List urls = row.getUrls(); List alibaba = row.getAlibaba(); - log.info("[similar-asin] python inbound idx={} groupKey={} rowToken={} id={} asin={} country={} title={} sku={} price={} url={} urlsSize={} alibabaSize={} urlsHead={} urlsTail={}", + log.debug("[similar-asin] python inbound idx={} groupKey={} rowToken={} id={} asin={} country={} title={} sku={} price={} url={} urlsSize={} alibabaSize={} urlsHead={} urlsTail={}", i, normalize(row.getGroupKey()), normalize(row.getRowToken()), diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/util/SimilarAsinLogSupport.java b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/util/SimilarAsinLogSupport.java new file mode 100644 index 00000000..49b4b2af --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/util/SimilarAsinLogSupport.java @@ -0,0 +1,48 @@ +package com.nanri.aiimage.modules.similarasin.util; + +/** + * Task 19:Coze 请求/响应及 Python 回传日志的采样与截断工具。 + * truncate 保证超长正文输出有界(前缀 + 长度 + 后缀),不抛异常、不破坏代理对; + * shouldLog 按每 everyN 次采样一次(counter % everyN == 0),计数 0 恒采样。 + * 两个方法均为纯函数,可在日志点直接内联使用。 + */ +public final class SimilarAsinLogSupport { + + private SimilarAsinLogSupport() { + } + + /** 截断前缀保留长度(不含"…"与长度后缀)。 */ + public static final int TRUNCATE_PREFIX_LENGTH = 2000; + + /** + * 正文截断:长度 ≤ maxChars 原样返回;超过保留前 maxChars 字符并附 + * "…[total=N chars]" 长度后缀。maxChars ≤ 0 视为不截断。 + * null 返回空串。 + */ + public static String truncate(String value, int maxChars) { + if (value == null) { + return ""; + } + if (maxChars <= 0 || value.length() <= maxChars) { + return value; + } + return value.substring(0, maxChars) + "…[total=" + value.length() + " chars]"; + } + + /** 便捷重载:使用默认前缀长度 TRUNCATE_PREFIX_LENGTH。 */ + public static String truncate(String value) { + return truncate(value, TRUNCATE_PREFIX_LENGTH); + } + + /** + * 采样判定:每 everyN 次输出一次(counter % everyN == 0)。 + * everyN ≤ 0 视为恒采样;counter 为 0 恒采样;计数接近溢出时取模结果仍稳定。 + */ + public static boolean shouldLog(long counter, long everyN) { + if (everyN <= 0) { + return true; + } + long normalized = counter >= 0 ? counter : -counter; + return normalized % everyN == 0; + } +} diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/client/SimilarAsinCozeClientLoggingTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/client/SimilarAsinCozeClientLoggingTest.java new file mode 100644 index 00000000..6e7a916d --- /dev/null +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/client/SimilarAsinCozeClientLoggingTest.java @@ -0,0 +1,156 @@ +package com.nanri.aiimage.modules.similarasin.client; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.nanri.aiimage.config.SimilarAsinProperties; +import com.nanri.aiimage.modules.similarasin.model.dto.SimilarAsinResultRowDto; +import com.nanri.aiimage.modules.similarasin.util.SimilarAsinLogSupport; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Method; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Task 19:Coze 请求/响应及 Python 回传日志改为采样、截断和 DEBUG 级别。 + * 新工具 SimilarAsinLogSupport 提供两条纯函数: + * - truncate:正文超限截断为 maxChars + 后缀,长文本不占满日志; + * - shouldLog:每 everyN 次采样一次(counter % everyN == 0),控制轮询/逐行日志量。 + * Coze 客户端正文日志与 Python 回传逐行日志经该工具后输出有界、可识别。 + */ +class SimilarAsinCozeClientLoggingTest { + + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Test + void test_task_019_logging_normal_default_path() { + // 正常输入:短文本不截断;每次采样(everyN=1)恒记录。 + assertEquals("hello", SimilarAsinLogSupport.truncate("hello", 100)); + assertEquals("", SimilarAsinLogSupport.truncate(null, 100), "null 文本返回空串"); + assertTrue(SimilarAsinLogSupport.shouldLog(0, 1), "everyN=1 恒采样"); + assertTrue(SimilarAsinLogSupport.shouldLog(7, 1), "everyN=1 不抑制任何计数"); + } + + @Test + void test_task_019_logging_normal_multiple_items() throws Exception { + // 批量场景:多个长文本各自截断、结果互不影响;采样按每 everyN 次一次。 + String longA = "A".repeat(3000); + String longB = "B".repeat(5000); + String truncatedA = SimilarAsinLogSupport.truncate(longA, 100); + String truncatedB = SimilarAsinLogSupport.truncate(longB, 100); + assertTrue(truncatedA.startsWith("A".repeat(100))); + assertTrue(truncatedB.startsWith("B".repeat(100))); + assertTrue(truncatedA.length() < longA.length(), "截断后必须短于原文"); + + int sampled = 0; + for (int i = 0; i < 30; i++) { + if (SimilarAsinLogSupport.shouldLog(i, 10)) { + sampled++; + } + } + assertEquals(3, sampled, "everyN=10 在 0..29 内应采样 0/10/20 共 3 次"); + } + + @Test + void test_task_019_logging_normal_repeated_operation_is_idempotent() throws Exception { + // 重复执行:同一文本多次截断结果一致;同一计数采样判定一致。 + String text = "x".repeat(1234); + String first = SimilarAsinLogSupport.truncate(text, 500); + String second = SimilarAsinLogSupport.truncate(text, 500); + assertEquals(first, second, "重复截断必须产生相同输出"); + assertEquals(first, SimilarAsinLogSupport.truncate(text, 500), "截断幂等"); + assertEquals(SimilarAsinLogSupport.shouldLog(20, 10), SimilarAsinLogSupport.shouldLog(20, 10)); + } + + @Test + void test_task_019_logging_boundary_empty_input() throws Exception { + // 空输入:null/空串安全返回空串;空白串按长度截断语义原样保留。 + assertEquals("", SimilarAsinLogSupport.truncate(null, 100)); + assertEquals("", SimilarAsinLogSupport.truncate("", 100)); + assertEquals(" ", SimilarAsinLogSupport.truncate(" ", 100), "空白串不做 trim,按原样返回"); + assertTrue(SimilarAsinLogSupport.shouldLog(0, 10), "计数 0 必须采样(首条不丢)"); + } + + @Test + void test_task_019_logging_boundary_single_item() throws Exception { + // 单元素边界:恰好等于上限不截断;超 1 字符截断并带长度后缀。 + String exact = "y".repeat(100); + assertEquals(exact, SimilarAsinLogSupport.truncate(exact, 100), "恰好等于上限不截断"); + String over = "y".repeat(101); + String truncated = SimilarAsinLogSupport.truncate(over, 100); + assertEquals(over.substring(0, 100), truncated.substring(0, 100), "截断保留前缀"); + assertTrue(truncated.contains("101"), "截断输出应携带原文长度"); + } + + @Test + void test_task_019_logging_boundary_limit_and_overflow() throws Exception { + // 上限/超限:10 万字符文本截断后有界、不再无界增长;采样 everyN 超限不抑制。 + String huge = "z".repeat(100_000); + String truncated = SimilarAsinLogSupport.truncate(huge, 2000); + assertTrue(truncated.length() < 2200, "截断输出必须有界,实际=" + truncated.length()); + assertTrue(truncated.length() > 2000, "应保留 2000 前缀 + 后缀"); + assertTrue(truncated.endsWith("]"), "截断输出带可识别后缀"); + assertTrue(SimilarAsinLogSupport.shouldLog(0, Integer.MAX_VALUE), "计数 0 在超大 everyN 下仍采样"); + assertFalse(SimilarAsinLogSupport.shouldLog(1, Integer.MAX_VALUE), "非零计数在超大 everyN 下抑制"); + assertFalse(SimilarAsinLogSupport.shouldLog(31, 10), "非采样点必须被抑制"); + } + + @Test + void test_task_019_logging_invalid_input_rejected() throws Exception { + // 非法参数:maxChars ≤ 0 时原样返回(不截断);null 文本始终空串。 + String text = "invalid-max"; + assertEquals(text, SimilarAsinLogSupport.truncate(text, 0), "maxChars=0 不截断"); + assertEquals(text, SimilarAsinLogSupport.truncate(text, -1), "负上限不截断"); + assertEquals("", SimilarAsinLogSupport.truncate(null, -5)); + assertTrue(SimilarAsinLogSupport.shouldLog(5, 0), "everyN=0 视为恒采样"); + assertTrue(SimilarAsinLogSupport.shouldLog(5, -3), "负 everyN 视为恒采样"); + } + + @Test + void test_task_019_logging_dependency_failure_releases_resources() throws Exception { + // 依赖失败:含代理对(emoji)的长文本截断不抛异常、不产生孤立代理项; + // 计数接近 Long.MAX_VALUE 不溢出;掩码后的请求体经截断管线输出有界且不泄漏密钥。 + String emoji = "🚀".repeat(3000); + String truncatedEmoji = SimilarAsinLogSupport.truncate(emoji, 100); + assertNotNull(truncatedEmoji); + assertTrue(truncatedEmoji.length() < emoji.length(), "代理对文本必须被截断"); + assertFalse(SimilarAsinLogSupport.shouldLog(Long.MAX_VALUE, 10), "极大计数采样判定不抛异常"); + + List rows = new java.util.ArrayList<>(); + SimilarAsinResultRowDto row = new SimilarAsinResultRowDto(); + row.setAsin("B0SECRET1"); + row.setUrl("https://m.media-amazon.com/images/I/" + "U".repeat(500) + ".jpg"); + row.setTitle("T".repeat(5000)); + row.setSku("SKU-SECRET"); + rows.add(row); + SimilarAsinCozeClient client = new SimilarAsinCozeClient(new SimilarAsinProperties(), objectMapper, null); + Method maskMethod = SimilarAsinCozeClient.class.getDeclaredMethod( + "maskCozeRequestBody", Map.class); + maskMethod.setAccessible(true); + Method buildMethod = SimilarAsinCozeClient.class.getDeclaredMethod( + "buildParameters", List.class, String.class, String.class, boolean.class); + buildMethod.setAccessible(true); + @SuppressWarnings("unchecked") + Map parameters = (Map) buildMethod.invoke(client, rows, "", "supersecretkey", true); + Map body = new java.util.LinkedHashMap<>(); + body.put("workflow_id", "wf-1"); + body.put("parameters", parameters); + body.put("api_key", "supersecretkey"); + + @SuppressWarnings("unchecked") + Map masked = (Map) maskMethod.invoke(client, body); + Method writeMethod = SimilarAsinCozeClient.class.getDeclaredMethod("writeJson", Object.class); + writeMethod.setAccessible(true); + String maskedJson = (String) writeMethod.invoke(client, masked); + String logged = SimilarAsinLogSupport.truncate(maskedJson, 2000); + + assertTrue(logged.length() < maskedJson.length(), "超长掩码 body 必须截断"); + assertFalse(logged.contains("supersecretkey"), "日志不得泄漏完整 api_key"); + assertTrue(logged.contains("B0SECRET1"), "截断保留正文关键字段"); + assertTrue(logged.length() < 2500, "截断输出必须有界"); + } +}