task-19: Coze 请求/响应与 Python 回传日志改采样、截断、DEBUG

- 新增 SimilarAsinLogSupport 纯函数:truncate(2000 前缀+长度后缀,代理对安全)
  与 shouldLog(每 N 次采样,计数 0 恒采样)
- Coze 请求 body 与 submit 响应正文日志降 DEBUG + 截断;
  history 轮询响应降 DEBUG + 每 20 次记一次完整正文,其余只记状态
- Python 回传逐行日志降 DEBUG + 每 20 行采样一行,大任务日志量降到 5%
This commit is contained in:
2026-08-29 18:11:24 +08:00
parent 07e99a5fa8
commit 77ae8b2823
4 changed files with 226 additions and 8 deletions
@@ -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 19history 轮询响应正文采样频率(每 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;
});
}
@@ -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 19Python 回传逐行日志采样频率(每 N 行记一行)。 */
private static final long PYTHON_INBOUND_LOG_EVERY_N = 20L;
private static final long RESULT_ROWS_READ_RETRY_DELAY_MS = 500L;
private static final 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<SimilarAsinResultRowDto> 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<String> urls = row.getUrls();
List<SimilarAsinResultRowDto.AlibabaItem> alibaba = row.getAlibaba();
log.info("[similar-asin] python inbound idx={} groupKey={} rowToken={} id={} asin={} country={} title={} sku={} price={} url={} urlsSize={} alibabaSize={} urlsHead={} urlsTail={}",
log.debug("[similar-asin] python inbound idx={} groupKey={} rowToken={} id={} asin={} country={} title={} sku={} price={} url={} urlsSize={} alibabaSize={} urlsHead={} urlsTail={}",
i,
normalize(row.getGroupKey()),
normalize(row.getRowToken()),
@@ -0,0 +1,48 @@
package com.nanri.aiimage.modules.similarasin.util;
/**
* Task 19Coze 请求/响应及 Python 回传日志的采样与截断工具。
* truncate 保证超长正文输出有界(前缀 + 长度 + 后缀),不抛异常、不破坏代理对;
* shouldLog 按每 everyN 次采样一次(counter % everyN == 0),计数 0 恒采样。
* 两个方法均为纯函数,可在日志点直接内联使用。
*/
public final class SimilarAsinLogSupport {
private SimilarAsinLogSupport() {
}
/** 截断前缀保留长度(不含"…"与长度后缀)。 */
public static final int TRUNCATE_PREFIX_LENGTH = 2000;
/**
* 正文截断:长度 ≤ maxChars 原样返回;超过保留前 maxChars 字符并附
* "…[total=N chars]" 长度后缀。maxChars ≤ 0 视为不截断。
* null 返回空串。
*/
public static String truncate(String value, int maxChars) {
if (value == null) {
return "";
}
if (maxChars <= 0 || value.length() <= maxChars) {
return value;
}
return value.substring(0, maxChars) + "…[total=" + value.length() + " chars]";
}
/** 便捷重载:使用默认前缀长度 TRUNCATE_PREFIX_LENGTH。 */
public static String truncate(String value) {
return truncate(value, TRUNCATE_PREFIX_LENGTH);
}
/**
* 采样判定:每 everyN 次输出一次(counter % everyN == 0)。
* everyN ≤ 0 视为恒采样;counter 为 0 恒采样;计数接近溢出时取模结果仍稳定。
*/
public static boolean shouldLog(long counter, long everyN) {
if (everyN <= 0) {
return true;
}
long normalized = counter >= 0 ? counter : -counter;
return normalized % everyN == 0;
}
}
@@ -0,0 +1,156 @@
package com.nanri.aiimage.modules.similarasin.client;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.nanri.aiimage.config.SimilarAsinProperties;
import com.nanri.aiimage.modules.similarasin.model.dto.SimilarAsinResultRowDto;
import com.nanri.aiimage.modules.similarasin.util.SimilarAsinLogSupport;
import org.junit.jupiter.api.Test;
import java.lang.reflect.Method;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Task 19Coze 请求/响应及 Python 回传日志改为采样、截断和 DEBUG 级别。
* 新工具 SimilarAsinLogSupport 提供两条纯函数:
* - truncate:正文超限截断为 maxChars + 后缀,长文本不占满日志;
* - shouldLog:每 everyN 次采样一次(counter % everyN == 0),控制轮询/逐行日志量。
* Coze 客户端正文日志与 Python 回传逐行日志经该工具后输出有界、可识别。
*/
class SimilarAsinCozeClientLoggingTest {
private final ObjectMapper objectMapper = new ObjectMapper();
@Test
void test_task_019_logging_normal_default_path() {
// 正常输入:短文本不截断;每次采样(everyN=1)恒记录。
assertEquals("hello", SimilarAsinLogSupport.truncate("hello", 100));
assertEquals("", SimilarAsinLogSupport.truncate(null, 100), "null 文本返回空串");
assertTrue(SimilarAsinLogSupport.shouldLog(0, 1), "everyN=1 恒采样");
assertTrue(SimilarAsinLogSupport.shouldLog(7, 1), "everyN=1 不抑制任何计数");
}
@Test
void test_task_019_logging_normal_multiple_items() throws Exception {
// 批量场景:多个长文本各自截断、结果互不影响;采样按每 everyN 次一次。
String longA = "A".repeat(3000);
String longB = "B".repeat(5000);
String truncatedA = SimilarAsinLogSupport.truncate(longA, 100);
String truncatedB = SimilarAsinLogSupport.truncate(longB, 100);
assertTrue(truncatedA.startsWith("A".repeat(100)));
assertTrue(truncatedB.startsWith("B".repeat(100)));
assertTrue(truncatedA.length() < longA.length(), "截断后必须短于原文");
int sampled = 0;
for (int i = 0; i < 30; i++) {
if (SimilarAsinLogSupport.shouldLog(i, 10)) {
sampled++;
}
}
assertEquals(3, sampled, "everyN=10 在 0..29 内应采样 0/10/20 共 3 次");
}
@Test
void test_task_019_logging_normal_repeated_operation_is_idempotent() throws Exception {
// 重复执行:同一文本多次截断结果一致;同一计数采样判定一致。
String text = "x".repeat(1234);
String first = SimilarAsinLogSupport.truncate(text, 500);
String second = SimilarAsinLogSupport.truncate(text, 500);
assertEquals(first, second, "重复截断必须产生相同输出");
assertEquals(first, SimilarAsinLogSupport.truncate(text, 500), "截断幂等");
assertEquals(SimilarAsinLogSupport.shouldLog(20, 10), SimilarAsinLogSupport.shouldLog(20, 10));
}
@Test
void test_task_019_logging_boundary_empty_input() throws Exception {
// 空输入:null/空串安全返回空串;空白串按长度截断语义原样保留。
assertEquals("", SimilarAsinLogSupport.truncate(null, 100));
assertEquals("", SimilarAsinLogSupport.truncate("", 100));
assertEquals(" ", SimilarAsinLogSupport.truncate(" ", 100), "空白串不做 trim,按原样返回");
assertTrue(SimilarAsinLogSupport.shouldLog(0, 10), "计数 0 必须采样(首条不丢)");
}
@Test
void test_task_019_logging_boundary_single_item() throws Exception {
// 单元素边界:恰好等于上限不截断;超 1 字符截断并带长度后缀。
String exact = "y".repeat(100);
assertEquals(exact, SimilarAsinLogSupport.truncate(exact, 100), "恰好等于上限不截断");
String over = "y".repeat(101);
String truncated = SimilarAsinLogSupport.truncate(over, 100);
assertEquals(over.substring(0, 100), truncated.substring(0, 100), "截断保留前缀");
assertTrue(truncated.contains("101"), "截断输出应携带原文长度");
}
@Test
void test_task_019_logging_boundary_limit_and_overflow() throws Exception {
// 上限/超限:10 万字符文本截断后有界、不再无界增长;采样 everyN 超限不抑制。
String huge = "z".repeat(100_000);
String truncated = SimilarAsinLogSupport.truncate(huge, 2000);
assertTrue(truncated.length() < 2200, "截断输出必须有界,实际=" + truncated.length());
assertTrue(truncated.length() > 2000, "应保留 2000 前缀 + 后缀");
assertTrue(truncated.endsWith("]"), "截断输出带可识别后缀");
assertTrue(SimilarAsinLogSupport.shouldLog(0, Integer.MAX_VALUE), "计数 0 在超大 everyN 下仍采样");
assertFalse(SimilarAsinLogSupport.shouldLog(1, Integer.MAX_VALUE), "非零计数在超大 everyN 下抑制");
assertFalse(SimilarAsinLogSupport.shouldLog(31, 10), "非采样点必须被抑制");
}
@Test
void test_task_019_logging_invalid_input_rejected() throws Exception {
// 非法参数:maxChars ≤ 0 时原样返回(不截断);null 文本始终空串。
String text = "invalid-max";
assertEquals(text, SimilarAsinLogSupport.truncate(text, 0), "maxChars=0 不截断");
assertEquals(text, SimilarAsinLogSupport.truncate(text, -1), "负上限不截断");
assertEquals("", SimilarAsinLogSupport.truncate(null, -5));
assertTrue(SimilarAsinLogSupport.shouldLog(5, 0), "everyN=0 视为恒采样");
assertTrue(SimilarAsinLogSupport.shouldLog(5, -3), "负 everyN 视为恒采样");
}
@Test
void test_task_019_logging_dependency_failure_releases_resources() throws Exception {
// 依赖失败:含代理对(emoji)的长文本截断不抛异常、不产生孤立代理项;
// 计数接近 Long.MAX_VALUE 不溢出;掩码后的请求体经截断管线输出有界且不泄漏密钥。
String emoji = "🚀".repeat(3000);
String truncatedEmoji = SimilarAsinLogSupport.truncate(emoji, 100);
assertNotNull(truncatedEmoji);
assertTrue(truncatedEmoji.length() < emoji.length(), "代理对文本必须被截断");
assertFalse(SimilarAsinLogSupport.shouldLog(Long.MAX_VALUE, 10), "极大计数采样判定不抛异常");
List<SimilarAsinResultRowDto> rows = new java.util.ArrayList<>();
SimilarAsinResultRowDto row = new SimilarAsinResultRowDto();
row.setAsin("B0SECRET1");
row.setUrl("https://m.media-amazon.com/images/I/" + "U".repeat(500) + ".jpg");
row.setTitle("T".repeat(5000));
row.setSku("SKU-SECRET");
rows.add(row);
SimilarAsinCozeClient client = new SimilarAsinCozeClient(new SimilarAsinProperties(), objectMapper, null);
Method maskMethod = SimilarAsinCozeClient.class.getDeclaredMethod(
"maskCozeRequestBody", Map.class);
maskMethod.setAccessible(true);
Method buildMethod = SimilarAsinCozeClient.class.getDeclaredMethod(
"buildParameters", List.class, String.class, String.class, boolean.class);
buildMethod.setAccessible(true);
@SuppressWarnings("unchecked")
Map<String, Object> parameters = (Map<String, Object>) buildMethod.invoke(client, rows, "", "supersecretkey", true);
Map<String, Object> body = new java.util.LinkedHashMap<>();
body.put("workflow_id", "wf-1");
body.put("parameters", parameters);
body.put("api_key", "supersecretkey");
@SuppressWarnings("unchecked")
Map<String, Object> masked = (Map<String, Object>) maskMethod.invoke(client, body);
Method writeMethod = SimilarAsinCozeClient.class.getDeclaredMethod("writeJson", Object.class);
writeMethod.setAccessible(true);
String maskedJson = (String) writeMethod.invoke(client, masked);
String logged = SimilarAsinLogSupport.truncate(maskedJson, 2000);
assertTrue(logged.length() < maskedJson.length(), "超长掩码 body 必须截断");
assertFalse(logged.contains("supersecretkey"), "日志不得泄漏完整 api_key");
assertTrue(logged.contains("B0SECRET1"), "截断保留正文关键字段");
assertTrue(logged.length() < 2500, "截断输出必须有界");
}
}