From 33b8f6becfa48e9537f87cf3d75b72e4b329b532 Mon Sep 17 00:00:00 2001 From: zizi Date: Tue, 9 Jun 2026 15:50:09 +0000 Subject: [PATCH] =?UTF-8?q?fix(telemetry):=20props=20=E8=90=BD=20MySQL=20j?= =?UTF-8?q?son=20=E5=88=97=E6=94=B9=E7=94=A8=20JsonUtils=20=E5=BA=8F?= =?UTF-8?q?=E5=88=97=E5=8C=96=EF=BC=88=E4=BF=AE=20B1=20=E6=BD=9C=E4=BC=8F?= =?UTF-8?q?=20Map.toString=20=E8=87=B4=20Invalid=20JSON=20=E6=95=B4?= =?UTF-8?q?=E6=89=B9=E5=9B=9E=E6=BB=9A=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 根因:game_telemetry_event.props 为 MySQL json 列,buildEventDO 早期用 Map.toString() 存(产出 {k=v} 键无引号、非合法 JSON),真库 INSERT 报 Invalid JSON text → 同步写事务整批回滚。B1 未暴露:telemetry 从未真落库 + 单测 mock mapper 绕开 JSON 约束;B2 数据回路首次真落库逮到。 修复:toString() → JsonUtils.toJsonString()。新增回归守卫单测 testIngestOne_propsSerializedAsValidJson(断言 props 为合法 JSON 且不含 = 分隔符)。telemetry 模块 13 tests 全绿;staging 重部署后 B2 A/B e2e 全过。 Co-Authored-By: Claude Opus 4.8 --- .../service/event/EventIngestServiceImpl.java | 7 +++-- .../event/EventIngestServiceImplTest.java | 28 +++++++++++++++++++ 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/game-cloud/game-module-telemetry/game-module-telemetry-server/src/main/java/cn/wanxiang/game/module/telemetry/service/event/EventIngestServiceImpl.java b/game-cloud/game-module-telemetry/game-module-telemetry-server/src/main/java/cn/wanxiang/game/module/telemetry/service/event/EventIngestServiceImpl.java index 3240848a..7778fd6c 100644 --- a/game-cloud/game-module-telemetry/game-module-telemetry-server/src/main/java/cn/wanxiang/game/module/telemetry/service/event/EventIngestServiceImpl.java +++ b/game-cloud/game-module-telemetry/game-module-telemetry-server/src/main/java/cn/wanxiang/game/module/telemetry/service/event/EventIngestServiceImpl.java @@ -11,6 +11,7 @@ import cn.wanxiang.game.module.telemetry.enums.EventProcessStatusEnum; import cn.wanxiang.game.module.telemetry.enums.TelemetryEventEnum; import cn.wanxiang.game.module.feed.api.FeedApi; import cn.wanxiang.game.module.feed.dto.FeedRankUpsertReqDTO; +import cn.iocoder.yudao.framework.common.util.json.JsonUtils; import jakarta.annotation.Resource; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -310,8 +311,10 @@ public class EventIngestServiceImpl implements EventIngestService { eventDO.setChannel(""); eventDO.setDeviceType(""); } - // props:DO 以字符串承载(DB JSON 列);MVP 不深解析,存原始 toString(前端按 #5 传 JSON) - eventDO.setProps(envelope.getProps() == null ? null : envelope.getProps().toString()); + // props 落 MySQL json 列:必须写合法 JSON 文本。早期误用 Map.toString() 产出 {k=v} 形态(键无引号、= 分隔, + // MySQL 在 position 1 即报 Invalid JSON text → 整批事务回滚);改用 Jackson 序列化为合法 JSON({"k":v})。 + // props 为 null 时透传 null(列可空);空对象序列化为 "{}"(合法 JSON)。 + eventDO.setProps(envelope.getProps() == null ? null : JsonUtils.toJsonString(envelope.getProps())); eventDO.setProcessStatus(EventProcessStatusEnum.PENDING.getStatus()); return eventDO; } diff --git a/game-cloud/game-module-telemetry/game-module-telemetry-server/src/test/java/cn/wanxiang/game/module/telemetry/service/event/EventIngestServiceImplTest.java b/game-cloud/game-module-telemetry/game-module-telemetry-server/src/test/java/cn/wanxiang/game/module/telemetry/service/event/EventIngestServiceImplTest.java index 3bb43b19..bc113b1c 100644 --- a/game-cloud/game-module-telemetry/game-module-telemetry-server/src/test/java/cn/wanxiang/game/module/telemetry/service/event/EventIngestServiceImplTest.java +++ b/game-cloud/game-module-telemetry/game-module-telemetry-server/src/test/java/cn/wanxiang/game/module/telemetry/service/event/EventIngestServiceImplTest.java @@ -10,6 +10,7 @@ import cn.wanxiang.game.module.telemetry.dal.mysql.stat.GameStatMapper; import cn.wanxiang.game.module.feed.api.FeedApi; import cn.wanxiang.game.module.feed.dto.FeedRankUpsertReqDTO; import cn.iocoder.yudao.framework.common.exception.ServiceException; +import cn.iocoder.yudao.framework.common.util.json.JsonUtils; import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; @@ -144,6 +145,33 @@ class EventIngestServiceImplTest extends BaseMockitoUnitTest { verify(telemetryEventMapper).updateById(any(TelemetryEventDO.class)); } + // ============================== props JSON 序列化回归守卫(B2 实测 bug)============================== + + @Test + void testIngestOne_propsSerializedAsValidJson() { + // 回归守卫:props 必须以合法 JSON 文本落库(game_telemetry_event.props 为 MySQL json 列)。 + // 早期误用 Map.toString() 产出 {completed=true, duration_ms=8000}(键无引号、= 分隔,非合法 JSON), + // 真库 INSERT 即报 "Invalid JSON text" 整批回滚。此用例锁住「序列化为合法 JSON」不回归。 + Map props = new HashMap<>(); + props.put("completed", true); + props.put("duration_ms", 8000); + EnvelopeReqVO env = gameEnvelope("game_play_end", "t1", "1024", "2048", props); + when(telemetryEventMapper.selectByEventId(env.getEventId())).thenReturn(null); + when(gameStatMapper.selectByGameAndDate(eq(1024L), any())).thenReturn(null); + + eventIngestService.ingestBatch(batchOf(env), 99L); + + ArgumentCaptor evtCaptor = ArgumentCaptor.forClass(TelemetryEventDO.class); + verify(telemetryEventMapper).insert(evtCaptor.capture()); + String propsJson = evtCaptor.getValue().getProps(); + // 能被 Jackson 解析回 Map(合法 JSON),且不含 Map.toString() 的 = 分隔符 + Map parsed = JsonUtils.parseObject(propsJson, Map.class); + assertNotNull(parsed, "props 应为合法 JSON 文本"); + assertEquals(Boolean.TRUE, parsed.get("completed")); + assertEquals(8000, ((Number) parsed.get("duration_ms")).intValue()); + assertFalse(propsJson.contains("="), "props 不应为 Map.toString() 的 {k=v} 形态"); + } + @Test void testIngestOne_likeAccumulatesOnExistingStat() { // like 事件命中既有 stat(play_count=2,like_count=1)→ like_count 累加为 2;quality_score 含 like 率