From 620ee15a48622bd67be7ad78cd1d55ce54aba84d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E8=87=AA=E8=BE=BE?= <980324341@qq.com> Date: Fri, 4 Sep 2026 23:37:50 +0800 Subject: [PATCH] =?UTF-8?q?task-178:=20=E6=97=A5=E5=BF=97=E5=AD=97?= =?UTF-8?q?=E6=AE=B5=E8=A7=84=E8=8C=83=E8=90=BD=E5=9C=B0=EF=BC=88Structure?= =?UTF-8?q?dLog=20=E8=A7=84=E8=8C=83=E5=AD=97=E6=AE=B5=E9=9B=86+=E7=BC=BA?= =?UTF-8?q?=E5=A4=B1=E5=8D=A0=E4=BD=8D+=E5=80=BC=E6=B6=88=E6=AF=92?= =?UTF-8?q?=EF=BC=8C=E5=AD=97=E6=AE=B5=E5=90=8D=E5=86=BB=E7=BB=93=EF=BC=89?= =?UTF-8?q?+=208=20=E6=9D=A1=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 规范字段 traceId/taskId/moduleType/stage/submissionId/chunkIndex/chunkTotal/jobId/result/errorType,顺序与 spec11 §2 一致 - format 只输出规范字段、缺失用 "-" 占位、换行/控制字符消毒、超长截断,防日志注入与字段漂移 - 源码锚点断言 Worker(task-file-job)/心跳(task-heartbeat) 代表性日志已带 taskId/moduleType 核心字段;不删现有日志 --- .../nanri/aiimage/config/StructuredLog.java | 72 +++++++++++++ .../config/StructuredLogFieldSpecTest.java | 101 ++++++++++++++++++ 2 files changed, 173 insertions(+) create mode 100644 backend-java/src/main/java/com/nanri/aiimage/config/StructuredLog.java create mode 100644 backend-java/src/test/java/com/nanri/aiimage/config/StructuredLogFieldSpecTest.java diff --git a/backend-java/src/main/java/com/nanri/aiimage/config/StructuredLog.java b/backend-java/src/main/java/com/nanri/aiimage/config/StructuredLog.java new file mode 100644 index 00000000..5b93c997 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/config/StructuredLog.java @@ -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 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 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(); + } +} diff --git a/backend-java/src/test/java/com/nanri/aiimage/config/StructuredLogFieldSpecTest.java b/backend-java/src/test/java/com/nanri/aiimage/config/StructuredLogFieldSpecTest.java new file mode 100644 index 00000000..de910b75 --- /dev/null +++ b/backend-java/src/test/java/com/nanri/aiimage/config/StructuredLogFieldSpecTest.java @@ -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 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 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)); + } +}