fix(telemetry): props 落 MySQL json 列改用 JsonUtils 序列化(修 B1 潜伏 Map.toString 致 Invalid JSON 整批回滚)

根因: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 <noreply@anthropic.com>
This commit is contained in:
zizi 2026-06-09 15:50:09 +00:00
parent aa99e76026
commit 33b8f6becf
2 changed files with 33 additions and 2 deletions

View File

@ -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("");
}
// propsDO 以字符串承载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;
}

View File

@ -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<String, Object> 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<TelemetryEventDO> 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 事件命中既有 statplay_count=2,like_count=1 like_count 累加为 2quality_score like