fix: 脱敏多模态图片请求日志

- 隐去图片 Data URI 编码正文并保留 MIME 与长度

- 补充日志脱敏和 image_url 序列化测试
This commit is contained in:
2026-07-31 14:52:45 +08:00
parent 4af2d7cd34
commit fd3d9ad419
4 changed files with 142 additions and 1 deletions

View File

@@ -0,0 +1,72 @@
/*
* Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com).
* <p>
* Licensed under the Apache License, Version 2.0 (the "License");
*/
package com.easyagents.core.model.chat.log;
/**
* 清理聊天请求日志中的大体积或敏感媒体内容。
*/
final class ChatLogSanitizer {
private static final String DATA_URI_PREFIX = "data:image/";
private static final String BASE64_MARKER = ";base64,";
private ChatLogSanitizer() {
}
/**
* 隐去图片 Data URI 的编码正文,仅保留 MIME 和字符长度。
*
* @param message 原始日志消息
* @return 脱敏后的日志消息
*/
static String redactImageDataUris(String message) {
if (message == null || message.indexOf(DATA_URI_PREFIX) < 0) {
return message;
}
StringBuilder sanitized = new StringBuilder(message.length());
int cursor = 0;
while (cursor < message.length()) {
int dataUriStart = message.indexOf(DATA_URI_PREFIX, cursor);
if (dataUriStart < 0) {
sanitized.append(message, cursor, message.length());
break;
}
int base64Marker = message.indexOf(BASE64_MARKER, dataUriStart);
if (base64Marker < 0) {
sanitized.append(message, cursor, message.length());
break;
}
int payloadStart = base64Marker + BASE64_MARKER.length();
int payloadEnd = payloadStart;
while (payloadEnd < message.length() && isBase64Character(message.charAt(payloadEnd))) {
payloadEnd++;
}
sanitized.append(message, cursor, payloadStart);
sanitized.append("<已脱敏,编码长度=").append(payloadEnd - payloadStart).append('>');
cursor = payloadEnd;
}
return sanitized.toString();
}
/**
* 判断字符是否可能属于 Base64 正文。
*
* @param value 待判断字符
* @return 是否属于 Base64 字符集
*/
private static boolean isBase64Character(char value) {
return value >= 'A' && value <= 'Z'
|| value >= 'a' && value <= 'z'
|| value >= '0' && value <= '9'
|| value == '+'
|| value == '/'
|| value == '='
|| value == '-'
|| value == '_';
}
}

View File

@@ -36,7 +36,8 @@ public class DefaultChatMessageLogger implements IChatMessageLogger {
if (shouldLog(config)) {
String provider = getProviderName(config);
String model = getModelName(config);
logConsumer.accept(String.format("[%s/%s] >>>> request: %s", provider, model, message));
logConsumer.accept(String.format("[%s/%s] >>>> request: %s",
provider, model, ChatLogSanitizer.redactImageDataUris(message)));
}
}

View File

@@ -0,0 +1,32 @@
package com.easyagents.core.model.chat.log;
import com.easyagents.core.model.chat.ChatConfig;
import org.junit.Assert;
import org.junit.Test;
import java.util.concurrent.atomic.AtomicReference;
/**
* 默认聊天日志记录器测试。
*/
public class DefaultChatMessageLoggerTest {
/**
* 验证请求日志不会输出完整图片 Base64。
*/
@Test
public void shouldRedactImageDataUriPayload() {
AtomicReference<String> logged = new AtomicReference<>();
DefaultChatMessageLogger logger = new DefaultChatMessageLogger(logged::set);
ChatConfig config = new ChatConfig();
config.setProvider("test");
config.setModel("vision");
logger.logRequest(config,
"{\"url\":\"data:image/png;base64,AQIDBA==\",\"text\":\"ok\"}");
Assert.assertNotNull(logged.get());
Assert.assertTrue(logged.get().contains("data:image/png;base64,<已脱敏,编码长度=8>"));
Assert.assertFalse(logged.get().contains("AQIDBA=="));
}
}

View File

@@ -0,0 +1,36 @@
package com.easyagents.core.test.model.client;
import com.easyagents.core.message.UserMessage;
import com.easyagents.core.model.chat.ChatConfig;
import com.easyagents.core.model.client.OpenAIChatMessageSerializer;
import org.junit.Assert;
import org.junit.Test;
import java.util.List;
import java.util.Map;
/**
* OpenAI-compatible 多模态消息序列化测试。
*/
public class OpenAIChatMessageSerializerTest {
/**
* 验证 Data URI 会写入标准的 image_url.url 字段。
*/
@Test
public void shouldSerializeImageDataUriIntoImageUrlField() {
String dataUri = "data:image/png;base64,AQID";
UserMessage message = new UserMessage("识别图片");
message.addImageUrl(dataUri);
List<Map<String, Object>> messages = new OpenAIChatMessageSerializer()
.serializeMessages(List.of(message), new ChatConfig());
Assert.assertNotNull(messages);
List<?> content = (List<?>) messages.get(0).get("content");
Map<?, ?> imageContent = (Map<?, ?>) content.get(1);
Map<?, ?> imageUrl = (Map<?, ?>) imageContent.get("image_url");
Assert.assertEquals("image_url", imageContent.get("type"));
Assert.assertEquals(dataUri, imageUrl.get("url"));
}
}