task-178: 日志字段规范落地(StructuredLog 规范字段集+缺失占位+值消毒,字段名冻结)+ 8 条测试
- 规范字段 traceId/taskId/moduleType/stage/submissionId/chunkIndex/chunkTotal/jobId/result/errorType,顺序与 spec11 §2 一致 - format 只输出规范字段、缺失用 "-" 占位、换行/控制字符消毒、超长截断,防日志注入与字段漂移 - 源码锚点断言 Worker(task-file-job)/心跳(task-heartbeat) 代表性日志已带 taskId/moduleType 核心字段;不删现有日志
This commit is contained in:
@@ -0,0 +1,72 @@
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按规范字段集渲染 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();
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user