fix(passport): e2e双P0修复——匿名写路径审计列兜底×5写点 + 手机号/验证码明文日志两面合围

- P0-1: @PermitAll匿名无登录态→DefaultDBFieldHandler不填creator/updater撞NOT NULL(V11漏继承审计列,与M-b执行器updater雷同根)。修=Service写点显式兜底"0"(注册/会话开局/会话收尾UPDATE形态/遥测batch+beacon共点/ad计费台账),归属维度user_id/anon_id/creator_user_id不受影响
- P0-2: ApiAccessLogInterceptor非prod无条件打印原始请求体(不读@ApiAccessLog注解,framework不可改)→staging抬该logger至WARN关控制台面+三端点sanitizeKeys脱敏持久化面;SmsSendConsumer脱敏mobile、code不打印
- 单测: 4模块匿名落库creator='0'断言+登录态不强置负向对照
- e2e第一轮判定: E3/E8/E12/UI过门;E2/E4/E6/E7/E1/E13被P0-1阻断待二轮重跑

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
zizi 2026-06-10 10:31:42 +00:00
parent 12e0c8f912
commit f0c4e810cb
11 changed files with 145 additions and 1 deletions

View File

@ -156,6 +156,14 @@ public class AdRevenueServiceImpl implements AdRevenueService {
revenueDO.setTraceId(traceId);
revenueDO.setSettleStatus(AdSettleStatusEnum.UNSETTLED.getStatus());
revenueDO.setStatDate(LocalDate.now());
// 2026-06-10 鉴权波 e2e 静态定论P0-1本波两计费端点report/impressionreport/reward @PermitAll
// 计费按 gameId 反查作者归因不依赖调用方身份匿名调用时 handler 无登录态可填 NOT NULL
// token 调用 test1 批跑虽可被 handler 填登录 id但台账审计列统一记 "0"=系统计费写入口径避免匿名/登录两态值漂移
// DefaultDBFieldHandler.insertFill 仅在 getLoginUserId()!=null 时填 creator/updater
// yudao-spring-boot-starter-mybatis .../handler/DefaultDBFieldHandler.java:38-43匿名两列保持 null NOT NULL 500
// 故无条件显式兜底 "0"与系统身份 id=0 同口径显式值优先于 handler收入归属另由 creator_user_id 列承载不受影响
revenueDO.setCreator("0");
revenueDO.setUpdater("0");
try {
adRevenueMapper.insert(revenueDO);
return new AdBillingResult(revenue, true);

View File

@ -144,6 +144,10 @@ class AdRevenueServiceImplTest extends BaseMockitoUnitTest {
verify(adRevenueMapper).insert(captor.capture());
assertEquals(AdEventTypeEnum.REWARD.getType(), captor.getValue().getEventType());
assertEquals(5L, captor.getValue().getRevenueAmount());
// 2026-06-10 鉴权波 e2e 静态定论P0-1计费端点已 @PermitAllbill() userId 入参 落台账必无登录态
// 显式兜底审计列 creator/updater="0"规避 DefaultDBFieldHandler 不填撞 NOT NULL 500
assertEquals("0", captor.getValue().getCreator(), "匿名计费落台账 creator 应兜底 0");
assertEquals("0", captor.getValue().getUpdater(), "匿名计费落台账 updater 应兜底 0");
}
// ============================== 拒计停用 / 归因失败 ==============================

View File

@ -59,6 +59,14 @@ public class RuntimeSessionServiceImpl implements RuntimeSessionService {
session.setDurationMs(0L);
session.setTraceId(reqVO.getTraceId());
session.setStartTime(LocalDateTime.now()); // 服务端记开局时间时长以服务端 end-start 为准
// 2026-06-10 鉴权波 e2e 实证P0-1匿名试玩 @PermitAll 无登录态DefaultDBFieldHandler.insertFill 仅在
// getLoginUserId()!=null 时填 creator/updater yudao-spring-boot-starter-mybatis .../handler/DefaultDBFieldHandler.java:38-43
// 匿名(userId==null)两列保持 null NOT NULL SQLIntegrityConstraintViolationException 500
// 故此处显式兜底 "0" M-b 执行器系统身份 LoginUser id=0 同口径显式值优先于 handlerhandler isNull 才填不覆盖
if (userId == null) {
session.setCreator("0");
session.setUpdater("0");
}
runtimeSessionMapper.insert(session);
// 关键链路日志含归属维度userId 可空=匿名anonId 归属便于匿名/登录会话排障与对账
log.info("[startSession] 新建试玩会话 sessionId={}, gameId={}, versionId={}, scene={}, userId={}, anonId={}",
@ -86,6 +94,13 @@ public class RuntimeSessionServiceImpl implements RuntimeSessionService {
update.setDurationMs(durationMs);
update.setEndReason(StringUtils.hasText(reqVO.getEndReason()) ? reqVO.getEndReason() : "game_end");
update.setEndTime(endTime);
// 2026-06-10 鉴权波 e2e 实证P0-1·M-b =UPDATE SET updater=null 形态匿名收尾 @PermitAll 无登录态
// DefaultDBFieldHandler.updateFill 仅在 getLoginUserId()!=null 时填 updater .../handler/DefaultDBFieldHandler.java:57-61
// 匿名(当前无登录态 userId==null 或会话本就为匿名归属 player_user_id 为空) updater 保持 null NOT NULL 500
// 故显式兜底 updater="0"与开局/系统身份同口径显式值优先于 handler isNull 才填不覆盖
if (userId == null || session.getPlayerUserId() == null) {
update.setUpdater("0");
}
runtimeSessionMapper.updateById(update);
// TODO 对接点telemetry 回灌会话收尾后投递 play_end 事件至 telemetry契约 #5 events.schema.json
// 约定 sessionId(int64) 透传进 telemetry envelope.session_id string作对账键

View File

@ -90,6 +90,24 @@ class RuntimeSessionServiceImplTest extends BaseMockitoUnitTest {
assertNull(saved.getPlayerUserId(), "匿名会话 player_user_id 应为 NULL");
assertEquals("anon-abc", saved.getAnonId(), "匿名会话应落 anonId 归属");
assertEquals(SessionStatusEnum.PLAYING.getStatus(), saved.getStatus());
// 2026-06-10 鉴权波 e2e 实证P0-1匿名开局显式兜底审计列 creator/updater="0"规避 DefaultDBFieldHandler 不填撞 NOT NULL 500
assertEquals("0", saved.getCreator(), "匿名开局 creator 应兜底 0");
assertEquals("0", saved.getUpdater(), "匿名开局 updater 应兜底 0");
}
@Test
void testStartSession_loggedIn_creatorNotForced() {
// 登录态开局userId 非空不显式兜底审计列交由 DefaultDBFieldHandler 按登录身份填充creator/updater 保持 null handler
when(runtimeSessionMapper.selectByClientPlayToken("ptk_login")).thenReturn(null);
when(runtimeSessionMapper.insert(any(RuntimeSessionDO.class))).thenReturn(1);
runtimeSessionService.startSession(startReq("ptk_login", "play"), 99L); // 登录态
ArgumentCaptor<RuntimeSessionDO> captor = ArgumentCaptor.forClass(RuntimeSessionDO.class);
verify(runtimeSessionMapper).insert(captor.capture());
RuntimeSessionDO saved = captor.getValue();
assertNull(saved.getCreator(), "登录态开局不应显式兜底 creator交 handler 按登录身份填)");
assertNull(saved.getUpdater(), "登录态开局不应显式兜底 updater交 handler 按登录身份填)");
}
@Test
@ -127,6 +145,7 @@ class RuntimeSessionServiceImplTest extends BaseMockitoUnitTest {
session.setId(888L);
session.setStatus(SessionStatusEnum.PLAYING.getStatus());
session.setStartTime(LocalDateTime.now().minusMinutes(2));
session.setPlayerUserId(99L); // 登录态会话明确非匿名避免触发匿名 updater 兜底分支
when(runtimeSessionMapper.selectById(888L)).thenReturn(session);
SessionEndReqVO reqVO = new SessionEndReqVO();
@ -142,6 +161,28 @@ class RuntimeSessionServiceImplTest extends BaseMockitoUnitTest {
assertTrue(update.getDurationMs() >= 119000L, "服务端时长应约 120000ms实际=" + update.getDurationMs());
assertEquals("game_end", update.getEndReason());
assertNotNull(update.getEndTime());
assertNull(update.getUpdater(), "登录态收尾不应显式兜底 updater交 handler 按登录身份填)");
}
@Test
void testEndSession_anonymous_updaterFallbackZero() {
// 2026-06-10 鉴权波 e2e 实证P0-1·M-b =UPDATE SET updater=null匿名收尾当前无登录态 userId=null
// 显式兜底 update.updater="0"规避 DefaultDBFieldHandler.updateFill 不填 updater NOT NULL 500
RuntimeSessionDO session = new RuntimeSessionDO();
session.setId(889L);
session.setStatus(SessionStatusEnum.PLAYING.getStatus());
session.setStartTime(LocalDateTime.now().minusMinutes(1));
session.setPlayerUserId(null); // 匿名会话归属
when(runtimeSessionMapper.selectById(889L)).thenReturn(session);
SessionEndReqVO reqVO = new SessionEndReqVO();
reqVO.setSessionId(889L);
reqVO.setEndReason("game_end");
runtimeSessionService.endSession(reqVO, null); // 匿名收尾
ArgumentCaptor<RuntimeSessionDO> captor = ArgumentCaptor.forClass(RuntimeSessionDO.class);
verify(runtimeSessionMapper).updateById(captor.capture());
assertEquals("0", captor.getValue().getUpdater(), "匿名收尾 updater 应兜底 0");
}
@Test

View File

@ -334,6 +334,15 @@ public class EventIngestServiceImpl implements EventIngestService {
// props null 时透传 null列可空空对象序列化为 "{}"合法 JSON
eventDO.setProps(envelope.getProps() == null ? null : JsonUtils.toJsonString(envelope.getProps()));
eventDO.setProcessStatus(EventProcessStatusEnum.PENDING.getStatus());
// 2026-06-10 鉴权波 e2e 实证P0-1匿名上报batch/beacon 两端点 @PermitAll 共用本点无登录态
// DefaultDBFieldHandler.insertFill 仅在 getLoginUserId()!=null 时填 creator/updater
// yudao-spring-boot-starter-mybatis .../handler/DefaultDBFieldHandler.java:38-43匿名两列保持 null NOT NULL 500
// 归属维度已用 user_id/anon_id 承载审计列匿名时显式兜底 "0"与系统身份 id=0 同口径显式值优先于 handler
// 后续 markAggregated updateById 仅更新 process_status updater=null 不进 UPDATE SETMP 跳过 null 字段不覆盖本行 "0"
if (eventDO.getUserId() == null) {
eventDO.setCreator("0");
eventDO.setUpdater("0");
}
return eventDO;
}

View File

@ -313,6 +313,41 @@ class EventIngestServiceImplTest extends BaseMockitoUnitTest {
verifyNoInteractions(feedApi);
}
// ============================== 匿名信封审计列兜底2026-06-10 鉴权波 P0-1==============================
@Test
void testIngestOne_anonymousEnvelope_creatorUpdaterFallbackZero() {
// 2026-06-10 鉴权波 e2e 实证P0-1匿名上报信封无 user.userId 且当前无登录态 userId=null 解析 user_id=null
// 显式兜底审计列 creator/updater="0"规避 DefaultDBFieldHandler.insertFill 不填撞 NOT NULL 500batch/beacon 共用此点
when(telemetryEventMapper.selectByEventId(anyString())).thenReturn(null);
EnvelopeReqVO env = validEnvelope("game_play_start", "t-anon"); // user 匿名
eventIngestService.ingestBatch(batchOf(env), null); // userId=null=匿名
ArgumentCaptor<TelemetryEventDO> captor = ArgumentCaptor.forClass(TelemetryEventDO.class);
verify(telemetryEventMapper).insert(captor.capture());
TelemetryEventDO saved = captor.getValue();
assertNull(saved.getUserId(), "匿名信封 user_id 应为 NULL");
assertEquals("0", saved.getCreator(), "匿名上报 creator 应兜底 0");
assertEquals("0", saved.getUpdater(), "匿名上报 updater 应兜底 0");
}
@Test
void testIngestOne_loggedInEnvelope_creatorNotForced() {
// 登录态上报当前登录 userId=99不显式兜底审计列交由 DefaultDBFieldHandler 按登录身份填充保持 null handler
when(telemetryEventMapper.selectByEventId(anyString())).thenReturn(null);
EnvelopeReqVO env = validEnvelope("game_play_start", "t-login");
eventIngestService.ingestBatch(batchOf(env), 99L); // 登录态
ArgumentCaptor<TelemetryEventDO> captor = ArgumentCaptor.forClass(TelemetryEventDO.class);
verify(telemetryEventMapper).insert(captor.capture());
TelemetryEventDO saved = captor.getValue();
assertEquals(99L, saved.getUserId());
assertNull(saved.getCreator(), "登录态上报不应显式兜底 creator交 handler 按登录身份填)");
assertNull(saved.getUpdater(), "登录态上报不应显式兜底 updater交 handler 按登录身份填)");
}
// ============================== 测试夹具 ==============================
/** 构造一条信封必填项齐全的事件(含 eventId无 context不触发聚合 */

View File

@ -1,5 +1,6 @@
package cn.iocoder.yudao.module.system.controller.app.passport;
import cn.iocoder.yudao.framework.apilog.core.annotation.ApiAccessLog;
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
import cn.iocoder.yudao.framework.common.util.servlet.ServletUtils;
import cn.iocoder.yudao.framework.ratelimiter.core.annotation.RateLimiter;
@ -48,6 +49,9 @@ public class AppPassportController {
@PostMapping("/send-sms-code")
@PermitAll // 免登种子期 Debug 渠道码落 system_sms_code 运营后台查码
// 2026-06-10 鉴权波 E11 安全实证响应安全基线 §5.1脱敏持久化访问日志(system_api_access_log) mobile 禁明文落库
// sanitizeKeys 仅由 ApiAccessLogFilter 消费持久化日志不影响 ApiAccessLogInterceptor 的控制台明文框架层不可改控制台面已由 staging logging.level 关闭 application-staging.yaml
@ApiAccessLog(sanitizeKeys = {"mobile"})
@Operation(summary = "发送登录短信验证码", description = "场景固定 MEMBER_LOGIN响应不区分手机号是否已注册防枚举")
// R6 安全前置§7.2IP 维度限频 10 /小时/IP配合 Service IP 每日/每小时 Redis 计数双闸切真实渠道前另补图形验证码
@RateLimiter(time = 1, timeUnit = TimeUnit.HOURS, count = SMS_RATE_LIMIT_COUNT,
@ -60,6 +64,8 @@ public class AppPassportController {
@PostMapping("/sms-login")
@PermitAll // 免登已注册登录 / 未注册自动注册一体验证码即占有证明拍板3
// 2026-06-10 鉴权波 E11 安全实证§5.1脱敏持久化访问日志的 mobile/code 手机号+验证码禁明文落库同口径见 send-sms-code 注释
@ApiAccessLog(sanitizeKeys = {"mobile", "code"})
@Operation(summary = "手机号验证码登录", description = "已注册→登录未注册→自动注册一体register_channel=sms")
public CommonResult<LoginRespVO> smsLogin(@Valid @RequestBody SmsLoginReqVO reqVO) {
return success(passportService.smsLogin(reqVO, ServletUtils.getClientIP()));
@ -67,6 +73,8 @@ public class AppPassportController {
@PostMapping("/invite-register")
@PermitAll // 免登报备前陌生玩家旁路受退役开关管控§5.4
// 2026-06-10 鉴权波 E11 安全实证§5.1脱敏持久化访问日志的 mobile/inviteCode 手机号+邀请码禁明文落库同口径见 send-sms-code 注释
@ApiAccessLog(sanitizeKeys = {"mobile", "inviteCode"})
@Operation(summary = "邀请码受限激活注册", description = "手机号+邀请码,零短信依赖;受退役开关 wanxiang.passport.invite-register-enabled 管控")
public CommonResult<LoginRespVO> inviteRegister(@Valid @RequestBody InviteRegisterReqVO reqVO) {
return success(passportService.inviteRegister(reqVO, ServletUtils.getClientIP()));

View File

@ -1,5 +1,6 @@
package cn.iocoder.yudao.module.system.mq.consumer.sms;
import cn.hutool.core.util.DesensitizedUtil;
import cn.iocoder.yudao.module.system.mq.message.sms.SmsSendMessage;
import cn.iocoder.yudao.module.system.service.sms.SmsSendService;
import lombok.extern.slf4j.Slf4j;
@ -24,7 +25,13 @@ public class SmsSendConsumer {
@EventListener
@Async // Spring Event 默认在 Producer 发送的线程通过 @Async 实现异步
public void onMessage(SmsSendMessage message) {
log.info("[onMessage][消息内容({})]", message);
// 2026-06-10 鉴权波 E11 安全实证响应安全基线 §5.1 log.info("[消息内容({})]", message) SmsSendMessage.toString()
// 会打印 mobile 明文 + templateParams含登录验证码 code明文造成手机号/验证码击穿
// 改为脱敏输出mobile DesensitizedUtil.mobilePhonecode 一律不打印templateParams 整体不落日志
// 仅保留 logId/channelId/apiTemplateId 供排障与对账关联 system_sms_code / system_sms_log
log.info("[onMessage][短信发送 logId({}) mobile({}) channelId({}) apiTemplateId({})]",
message.getLogId(), DesensitizedUtil.mobilePhone(message.getMobile()),
message.getChannelId(), message.getApiTemplateId());
smsSendService.doSendSms(message);
}

View File

@ -183,6 +183,12 @@ public class PassportServiceImpl implements PassportService {
// 目标3anonId 仅注册时落一次缺失落空串归因降级不阻断登录§12
player.setFirstAnonId(StringUtils.hasText(anonId) ? anonId : "");
player.setLoginDate(LocalDateTime.now());
// 2026-06-10 鉴权波 e2e 实证P0-1免登注册sms-login 自动注册 / invite-register 共用此点@PermitAll 无登录态
// 且注册者本人 id 此刻尚未生成DefaultDBFieldHandler.insertFill 仅在 getLoginUserId()!=null 时填 creator/updater
// yudao-spring-boot-starter-mybatis .../handler/DefaultDBFieldHandler.java:38-43匿名两列保持 null NOT NULL 500
// 故无条件显式兜底 "0"与系统身份 id=0 同口径显式值优先于 handler isNull 才填不覆盖
player.setCreator("0");
player.setUpdater("0");
try {
playerMapper.insert(player);
log.info("[registerPlayer] 新玩家注册成功 userId={}, channel={}, mobile={}, hasAnonId={}",

View File

@ -193,6 +193,9 @@ class PassportServiceImplTest extends BaseMockitoUnitTest {
assertEquals("sms", captor.getValue().getRegisterChannel());
assertEquals("anon-x", captor.getValue().getFirstAnonId());
assertEquals(0, captor.getValue().getCreatorFlag()); // 默认非创作者
// 2026-06-10 鉴权波 e2e 实证P0-1免登注册匿名路径显式兜底审计列 creator/updater="0"规避 NOT NULL 撞库 500
assertEquals("0", captor.getValue().getCreator(), "匿名注册 creator 应兜底 0");
assertEquals("0", captor.getValue().getUpdater(), "匿名注册 updater 应兜底 0");
// 发真 token响应带 userId
assertEquals(2048L, resp.getUserId());
assertEquals("happy-token", resp.getAccessToken());
@ -283,6 +286,9 @@ class PassportServiceImplTest extends BaseMockitoUnitTest {
assertEquals(77L, captor.getValue().getInviteCodeId()); // 关联核销的邀请码
assertEquals("anon-y", captor.getValue().getFirstAnonId());
assertEquals("小明", captor.getValue().getNickname()); // 传入昵称生效
// 2026-06-10 鉴权波 e2e 实证P0-1邀请码注册同为免登匿名路径审计列 creator/updater 显式兜底 "0"
assertEquals("0", captor.getValue().getCreator(), "匿名注册 creator 应兜底 0");
assertEquals("0", captor.getValue().getUpdater(), "匿名注册 updater 应兜底 0");
assertEquals(2048L, resp.getUserId());
}

View File

@ -127,6 +127,11 @@ spring:
logging:
level:
org.springframework.context.support.PostProcessorRegistrationDelegate: ERROR
# 2026-06-10 鉴权波 E11 安全实证(响应安全基线 §5.1ApiAccessLogInterceptor.preHandle 在非 prod 会 log.info 打印原始请求体
# (见 yudao-spring-boot-starter-web .../interceptor/ApiAccessLogInterceptor.java:51passport 三端点请求体含 mobile/code/inviteCode 明文。
# 该拦截器属 framework 层不可改、且无条件注册(不读 @ApiAccessLog 注解),唯一不改框架的关停手段=抬高其日志级别至 WARN信任边界=配置)。
# 控制台明文面由此关闭;持久化访问日志面由三端点 @ApiAccessLog(sanitizeKeys) 脱敏,两面合围。
cn.iocoder.yudao.framework.apilog.core.interceptor.ApiAccessLogInterceptor: WARN
debug: false