feat(passport): staging 限定免真实短信验证登录——内测解锁(profile 门控·生产不受影响)

创始人:「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) <noreply@anthropic.com>
This commit is contained in:
zizi 2026-06-15 10:40:06 +00:00
parent 3c6e3d4ad0
commit 23decfefaf
3 changed files with 148 additions and 6 deletions

View File

@ -86,6 +86,30 @@ public class PassportServiceImpl implements PassportService {
@Value("${wanxiang.passport.invite-register-enabled:true}")
private boolean inviteRegisterEnabled;
/**
* ⚠⚠⚠ 仅内测/staging「免真实短信验证」总开关(信任边界=后端,profile/配置级门控)。
* <p>
* 背景:staging 未接真实短信网关,发码会因无短信渠道/模板而失败,且随机验证码外人无从得知,
* 导致创始人人测与 e2e UI 走查均无法登录。开启后:① 发码不依赖真实网关(mock 受理成功)
* ② sms-login 接受固定测试码({@link #smsMockCode})即登录/自动注册成功(任意合法手机号皆可)。
* <p>
* <b>缺省 false(生产/本地默认关闭,走真实短信发码+校验,绝不被本改动削弱)</b>;
* 仅 application-staging.yaml 显式置 true。门控在后端服务层(本类,§6.3 既定信任边界),不依赖前端。
* <p>
* ⚠ 公网上线前必须关闭(=实名/短信网关日历闸门,见 mvp-binding-constraint-calendar-gates 记忆):
* 开启 = 任何人凭固定码 {@link #smsMockCode} 即可登录任意手机号,<b>绝不可进生产</b>。
*/
@Value("${wanxiang.passport.sms-mock-enabled:false}")
private boolean smsMockEnabled;
/**
* ⚠ 内测「免真实短信验证」固定测试码(仅 {@link #smsMockEnabled}=true 时生效,可配,默认 8888)。
* <p>
* 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());

View File

@ -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

View File

@ -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)