From 23decfefafbdd05366c16bdd5f573807d5666453 Mon Sep 17 00:00:00 2001 From: zizi Date: Mon, 15 Jun 2026 10:40:06 +0000 Subject: [PATCH] =?UTF-8?q?feat(passport):=20staging=20=E9=99=90=E5=AE=9A?= =?UTF-8?q?=E5=85=8D=E7=9C=9F=E5=AE=9E=E7=9F=AD=E4=BF=A1=E9=AA=8C=E8=AF=81?= =?UTF-8?q?=E7=99=BB=E5=BD=95=E2=80=94=E2=80=94=E5=86=85=E6=B5=8B=E8=A7=A3?= =?UTF-8?q?=E9=94=81(profile=20=E9=97=A8=E6=8E=A7=C2=B7=E7=94=9F=E4=BA=A7?= =?UTF-8?q?=E4=B8=8D=E5=8F=97=E5=BD=B1=E5=93=8D)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 创始人:「allow login and registration without SMS verification for now」。staging 未接真实 短信网关,随机码外人无从得知→创始人人测/e2e UI 走查均无法登录。 实现(信任边界=后端服务层 PassportServiceImpl,profile/配置级门控): - 新增 @Value sms-mock-enabled(缺省 false)+ sms-mock-code(缺省 8888) - sendSmsCode:开关开→mock 受理成功(不依赖真实网关) - smsLogin:开关开 ∧ 提交码==固定码 才跳过 system_sms_code 校验,否则真实校验(双条件,非无脑放行) - 仅 application-staging.yaml 置 true;生产/dev/local 不配→@Value 缺省 false→真实短信校验绝不削弱 - log 经 DesensitizedUtil 脱敏手机号;安全注记三处冗余(Javadoc/行内+log.warn/yaml) ⚠ 公网上线前必须关闭(=实名/短信网关日历闸门):开启=任何人凭 8888 登录任意号,绝不可进生产。 编译 BUILD SUCCESS + 20/20 单测(含 5 新增,2 个"开关关→生产不受影响"反证)。 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../service/passport/PassportServiceImpl.java | 54 +++++++++-- .../passport/PassportServiceImplTest.java | 92 +++++++++++++++++++ .../main/resources/application-staging.yaml | 8 ++ 3 files changed, 148 insertions(+), 6 deletions(-) diff --git a/game-cloud/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/service/passport/PassportServiceImpl.java b/game-cloud/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/service/passport/PassportServiceImpl.java index 7e6eb60e..224a4143 100644 --- a/game-cloud/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/service/passport/PassportServiceImpl.java +++ b/game-cloud/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/service/passport/PassportServiceImpl.java @@ -86,6 +86,30 @@ public class PassportServiceImpl implements PassportService { @Value("${wanxiang.passport.invite-register-enabled:true}") private boolean inviteRegisterEnabled; + /** + * ⚠⚠⚠ 仅内测/staging「免真实短信验证」总开关(信任边界=后端,profile/配置级门控)。 + *

+ * 背景:staging 未接真实短信网关,发码会因无短信渠道/模板而失败,且随机验证码外人无从得知, + * 导致创始人人测与 e2e UI 走查均无法登录。开启后:① 发码不依赖真实网关(mock 受理成功) + * ② sms-login 接受固定测试码({@link #smsMockCode})即登录/自动注册成功(任意合法手机号皆可)。 + *

+ * 缺省 false(生产/本地默认关闭,走真实短信发码+校验,绝不被本改动削弱); + * 仅 application-staging.yaml 显式置 true。门控在后端服务层(本类,§6.3 既定信任边界),不依赖前端。 + *

+ * ⚠ 公网上线前必须关闭(=实名/短信网关日历闸门,见 mvp-binding-constraint-calendar-gates 记忆): + * 开启 = 任何人凭固定码 {@link #smsMockCode} 即可登录任意手机号,绝不可进生产。 + */ + @Value("${wanxiang.passport.sms-mock-enabled:false}") + private boolean smsMockEnabled; + + /** + * ⚠ 内测「免真实短信验证」固定测试码(仅 {@link #smsMockEnabled}=true 时生效,可配,默认 8888)。 + *

+ * sms-login 提交此码即视为验证通过(跳过 system_sms_code 表校验)。生产不开启 sms-mock-enabled 时本值无意义。 + */ + @Value("${wanxiang.passport.sms-mock-code:8888}") + private String smsMockCode; + @Override public void sendSmsCode(String mobile, String clientIp) { // R6 §7.2:先做 IP 维度频控(每小时/每日上限),不改上游 SmsCodeServiceImpl(手机号维度频控由其内部承载)。 @@ -100,6 +124,15 @@ public class PassportServiceImpl implements PassportService { throw exception(GlobalErrorCodeConstants.TOO_MANY_REQUESTS); } } + // ⚠ 仅内测/staging:免真实短信验证开关开启时,发码不依赖真实网关(mock 受理成功直接返回)。 + // 原因:staging 未配短信渠道/模板,走 SmsCodeService.sendSmsCode 会因渠道/模板缺失抛异常(发不出码)。 + // 开启后 sms-login 用固定测试码 smsMockCode 登录(见 smsLogin),故此处无需真实落库验证码。 + // 生产默认关闭(smsMockEnabled=false),不进此分支,照常走真实发码+校验,绝不被削弱。 + if (smsMockEnabled) { + log.warn("[sendSmsCode] ⚠ 内测免短信验证开关开启,跳过真实发码(mock 受理)mobile={}(仅 staging,公网上线前必须关闭)", + DesensitizedUtil.mobilePhone(mobile)); + return; + } // 防枚举:不校验手机号是否已注册(已注册→登录、未注册→自动注册均发码),统一受理。 SmsCodeSendReqDTO reqDTO = new SmsCodeSendReqDTO(); reqDTO.setMobile(mobile); @@ -114,12 +147,21 @@ public class PassportServiceImpl implements PassportService { @Transactional(rollbackFor = Exception.class) // 自动注册建号与验证码使用需同一本地事务(避免验证码已用但建号失败的半截态) public LoginRespVO smsLogin(SmsLoginReqVO reqVO, String clientIp) { // 1) 校验并使用验证码(场景 MEMBER_LOGIN);不存在/过期/已用由 SmsCodeService 抛精确错误码 - SmsCodeUseReqDTO useReqDTO = new SmsCodeUseReqDTO(); - useReqDTO.setMobile(reqVO.getMobile()); - useReqDTO.setCode(reqVO.getCode()); - useReqDTO.setScene(SmsSceneEnum.MEMBER_LOGIN.getScene()); - useReqDTO.setUsedIp(clientIp); - smsCodeService.useSmsCode(useReqDTO); + // ⚠ 仅内测/staging:免短信验证开关开启 且 提交码==固定测试码 smsMockCode 时,跳过 system_sms_code 表校验, + // 直接视为验证通过(任意合法手机号 + 固定码即可登录/自动注册)。否则一律走真实校验。 + // 信任边界在后端:生产 smsMockEnabled=false(缺省)不进此分支,真实验证码校验不可绕过,绝不被削弱。 + // ⚠ 公网上线前必须关闭 sms-mock-enabled(实名/短信网关日历闸门):开启=任何人凭 8888 登录任意号。 + if (smsMockEnabled && smsMockCode.equals(reqVO.getCode())) { + log.warn("[smsLogin] ⚠ 内测免短信验证开关开启,固定测试码命中,跳过验证码校验 mobile={}(仅 staging,公网上线前必须关闭)", + DesensitizedUtil.mobilePhone(reqVO.getMobile())); + } else { + SmsCodeUseReqDTO useReqDTO = new SmsCodeUseReqDTO(); + useReqDTO.setMobile(reqVO.getMobile()); + useReqDTO.setCode(reqVO.getCode()); + useReqDTO.setScene(SmsSceneEnum.MEMBER_LOGIN.getScene()); + useReqDTO.setUsedIp(clientIp); + smsCodeService.useSmsCode(useReqDTO); + } // 2) 已注册→登录;未注册→自动注册(register_channel=sms,验证码即占有证明,拍板3) PlayerDO player = playerMapper.selectByMobile(reqVO.getMobile()); diff --git a/game-cloud/yudao-module-system/yudao-module-system-server/src/test/java/cn/iocoder/yudao/module/system/service/passport/PassportServiceImplTest.java b/game-cloud/yudao-module-system/yudao-module-system-server/src/test/java/cn/iocoder/yudao/module/system/service/passport/PassportServiceImplTest.java index 018ead42..b339ec9c 100644 --- a/game-cloud/yudao-module-system/yudao-module-system-server/src/test/java/cn/iocoder/yudao/module/system/service/passport/PassportServiceImplTest.java +++ b/game-cloud/yudao-module-system/yudao-module-system-server/src/test/java/cn/iocoder/yudao/module/system/service/passport/PassportServiceImplTest.java @@ -65,6 +65,9 @@ class PassportServiceImplTest extends BaseMockitoUnitTest { ReflectionTestUtils.setField(passportService, "smsIpLimitPerHour", 10); ReflectionTestUtils.setField(passportService, "smsIpLimitPerDay", 30); ReflectionTestUtils.setField(passportService, "inviteRegisterEnabled", true); + // ⚠ 免短信验证开关默认 false(与生产缺省一致);mock 路径用例在各自 case 内显式置 true + ReflectionTestUtils.setField(passportService, "smsMockEnabled", false); + ReflectionTestUtils.setField(passportService, "smsMockCode", "8888"); } // ============================== sendSmsCode IP 频控 ============================== @@ -110,6 +113,95 @@ class PassportServiceImplTest extends BaseMockitoUnitTest { verify(smsCodeService, never()).sendSmsCode(any()); } + // ============================== ⚠ 内测免短信验证开关(仅 staging) ============================== + + @Test + @DisplayName("免短信发码:开关开 → 跳过真实发码(mock 受理)+ IP 频控仍生效") + void testSendSmsCode_mockEnabledSkipsRealSend() { + ReflectionTestUtils.setField(passportService, "smsMockEnabled", true); + when(smsIpCounterRedisDAO.incrHourAndGet("1.2.3.4")).thenReturn(1L); + when(smsIpCounterRedisDAO.incrDayAndGet("1.2.3.4")).thenReturn(1L); + + passportService.sendSmsCode("13800000000", "1.2.3.4"); + + // 开关开:不调用真实发码(不依赖短信渠道/模板,规避 staging 无网关失败) + verify(smsCodeService, never()).sendSmsCode(any()); + // IP 频控仍执行(先增后判,防刷不被绕过) + verify(smsIpCounterRedisDAO).incrHourAndGet("1.2.3.4"); + } + + @Test + @DisplayName("免短信登录:开关开 + 固定测试码 8888 → 跳过验证码校验,自动注册并发 token") + void testSmsLogin_mockEnabledFixedCodeBypass() { + ReflectionTestUtils.setField(passportService, "smsMockEnabled", true); + SmsLoginReqVO reqVO = new SmsLoginReqVO(); + reqVO.setMobile("13800000000"); + reqVO.setCode("8888"); // 固定测试码 + when(playerMapper.selectByMobile("13800000000")).thenReturn(null); // 未注册→自动注册 + doAnswer(inv -> { + PlayerDO d = inv.getArgument(0); + d.setId(2048L); + return 1; + }).when(playerMapper).insert(any(PlayerDO.class)); + stubToken(2048L); + + LoginRespVO resp = passportService.smsLogin(reqVO, "1.2.3.4"); + + // 固定码命中:跳过 system_sms_code 校验(不调 useSmsCode) + verify(smsCodeService, never()).useSmsCode(any()); + // 仍正常自动注册 + 发真 token + verify(playerMapper).insert(any(PlayerDO.class)); + assertEquals(2048L, resp.getUserId()); + assertEquals("happy-token", resp.getAccessToken()); + } + + @Test + @DisplayName("免短信登录:开关开 但 码≠固定测试码 → 仍走真实验证码校验(不无脑放行)") + void testSmsLogin_mockEnabledButWrongCodeStillValidates() { + ReflectionTestUtils.setField(passportService, "smsMockEnabled", true); + SmsLoginReqVO reqVO = new SmsLoginReqVO(); + reqVO.setMobile("13800000000"); + reqVO.setCode("1234"); // 非固定码 + PlayerDO exist = player(2048L, 0, 0); + when(playerMapper.selectByMobile("13800000000")).thenReturn(exist); + stubToken(2048L); + + passportService.smsLogin(reqVO, "1.2.3.4"); + + // 码不等于固定测试码:仍调真实校验(开关只对固定码放行,避免任意码登录) + verify(smsCodeService).useSmsCode(any()); + } + + @Test + @DisplayName("免短信登录:开关关(生产缺省)+ 固定码 8888 → 不放行,照常走真实校验(生产不受影响)") + void testSmsLogin_mockDisabledFixedCodeStillValidates() { + // smsMockEnabled 默认 false(setUp 已置),模拟生产 profile + SmsLoginReqVO reqVO = new SmsLoginReqVO(); + reqVO.setMobile("13800000000"); + reqVO.setCode("8888"); // 即便是固定码,开关关也不放行 + PlayerDO exist = player(2048L, 0, 0); + when(playerMapper.selectByMobile("13800000000")).thenReturn(exist); + stubToken(2048L); + + passportService.smsLogin(reqVO, "1.2.3.4"); + + // 生产铁律:开关关 → 真实验证码校验不可绕过 + verify(smsCodeService).useSmsCode(any()); + } + + @Test + @DisplayName("免短信发码:开关关(生产缺省)→ 照常走真实发码(生产不受影响)") + void testSendSmsCode_mockDisabledUsesRealSend() { + // smsMockEnabled 默认 false(setUp 已置) + when(smsIpCounterRedisDAO.incrHourAndGet("1.2.3.4")).thenReturn(1L); + when(smsIpCounterRedisDAO.incrDayAndGet("1.2.3.4")).thenReturn(1L); + + passportService.sendSmsCode("13800000000", "1.2.3.4"); + + // 生产铁律:开关关 → 真实发码照常执行 + verify(smsCodeService).sendSmsCode(any()); + } + // ============================== validateCreator 三分支(含 mock 豁免) ============================== @Test diff --git a/game-cloud/yudao-server/src/main/resources/application-staging.yaml b/game-cloud/yudao-server/src/main/resources/application-staging.yaml index 68c6966c..a97226bb 100644 --- a/game-cloud/yudao-server/src/main/resources/application-staging.yaml +++ b/game-cloud/yudao-server/src/main/resources/application-staging.yaml @@ -190,3 +190,11 @@ wanxiang: invite-register-enabled: true # 邀请码注册旁路开关(§5.4):报备前 true,报备后置 false 退役旁路 sms-ip-limit-per-hour: 10 # 发码 IP 每小时上限(R6 §7.2,推断初值,与运营确认后调整) sms-ip-limit-per-day: 30 # 发码 IP 每日上限(R6 §7.2,日桶兜底防单 IP 刷量) + # ⚠⚠⚠ 内测「免真实短信验证」开关 —— 仅 staging/内测解锁登录用,绝不可进生产 ⚠⚠⚠ + # 背景:staging 未接真实短信网关,发码会失败且随机码外人无从得知,创始人人测/e2e UI 走查无法登录。 + # 开启后:① 发码不依赖真实网关(mock 受理成功)② sms-login 接受固定测试码 sms-mock-code(默认 8888) + # 即登录/自动注册成功(任意合法手机号皆可)。门控在后端服务层 PassportServiceImpl(信任边界=后端,不靠前端)。 + # 生产 profile 不配此项 → 后端 @Value 缺省 false → 走真实短信发码+校验,本开关绝不削弱生产鉴权。 + # ⚠ 公网上线前必须置 false / 删除本项(=实名/短信网关日历闸门):开启=任何人凭 8888 登录任意手机号。 + sms-mock-enabled: true # ⚠ 仅 staging 内测:免真实短信验证(缺省 false,生产绝不开启) + sms-mock-code: "8888" # ⚠ 仅 staging 内测:免短信验证的固定测试码(配合 sms-mock-enabled)