feat(passport): WU1 服务层——注册扩参/BCrypt/防枚举/IP 泛化/outbox 生产者

照 WU1 设计 §3.2/§3.3/§3.6:

- registerPlayer 扩 username/encodedPassword 两可空参,按路径落列;默认昵称有 nickname 用之、
  用户名路取 username、手机号路后4位;uk_username 冲突报 PLAYER_USERNAME_ALREADY_REGISTERED(不转登录)。
- passwordRegister/passwordLogin:复用 admin BCrypt 范式 encode/matches + buildLoginResp(MEMBER token);
  防枚举——查无用户名与密码错误统一 PLAYER_USERNAME_OR_PASSWORD_ERROR,且查无用户名跑固定假 hash
  matches 对齐 BCrypt 耗时(惰性以本类 encoder 编出,cost 一致,堵时序侧信道)。
- PassportSmsIpCounterRedisDAO 泛化按 scene 分桶(sms/register/login/invite),Redis 异常 fail-open+WARN;
  发码回填 scene=sms,回填 sms-login(scene=login)/invite-register(scene=invite) 服务层纯 IP 闸。
- 注册事务内与建号原子写 outbox(三路共用出口);@Scheduled 投递器扫未投递发 RocketMQ(至少一次,
  RocketMQTemplate 经 ObjectProvider 兜底 name-server 未装配、CAS 置已投递防重投)。
- getPlayerMe 补 username + mobile 为 null 短路回空串(用户名用户防 NPE 回归)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
lili 2026-07-07 11:52:23 -07:00
parent 987d489b63
commit d52696c589
5 changed files with 470 additions and 62 deletions

View File

@ -1,5 +1,6 @@
package com.wanxiang.huijing.module.system.dal.redis.passport;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Repository;
@ -10,26 +11,45 @@ import java.time.format.DateTimeFormatter;
import java.util.concurrent.TimeUnit;
/**
* passport 发码 IP 频控计数 RedisDAOR6 切渠道安全前置§7.2
* passport 应用层纯 IP 频控计数 RedisDAOR6 发码前置 + 2026-07-07 WU1 泛化为按 scene 分桶
*
* passport 发码 Service 入层补IP 每日/每小时上限不改上游 SmsCodeServiceImpl避免 fork 侵入
* 上游 :65-66 该项为 TODO Redis 自增计数 + 首次设过期实现滑动自然窗口按日历日/小时分桶
* KEY 格式passport_sms_ip_day:{ip}:{yyyyMMdd} / passport_sms_ip_hour:{ip}:{yyyyMMddHH}
* VALUEString 计数TTL 在首次自增时设置日桶 1 小时桶 1 小时到期自动清
* passport 各免登端点入层按 IP 维度的时/日双桶计数key 只含 scene+IP+日期不含请求体
* Redis 自增 + 首次设过期实现按日历日/小时的自然窗口原发码专用 KEY 前缀泛化为 scene 分桶
* scene {sms 发码, register 用户名注册, login 登录(sms/password 共用), invite 邀请码注册}
* <p>
* KEY 格式passport_ip:{scene}:hour:{ip}:{yyyyMMddHH} / passport_ip:{scene}:day:{ip}:{yyyyMMdd}
* VALUEString 计数TTL 在首次自增时设置日桶 1 小时桶 1 小时到期自动清
* <p>
* 一次性重置§3.3 如实记账上线本改动那一刻旧前缀 passport_sms_ip_* 的存量计数桶全部失效
* 等于对已在计数的 IP 一次性重置限流窗口 TTL 有界只重置一次内测无害观测同步改名即可
* <p>
* <b>频控后端故障姿态§3.3 定死fail-open + WARN 告警</b>Redis 抖动/超时时捕获异常记一条含 IP+scene
* 告警日志返回 0视为未超限放行内测阶段认证可用性优先于把防刷做到严丝合缝绝不让 Redis 单点静默拖垮登录
* 代价Redis 挂时防刷短暂失效记账接受Redis 恢复后限流自动复位
*
* @author 绘境AI
*/
@Slf4j
@Repository
public class PassportSmsIpCounterRedisDAO {
/** 发码场景send-sms-code 回填此值,与原行为计数语义等价)。 */
public static final String SCENE_SMS = "sms";
/** 用户名密码注册场景。 */
public static final String SCENE_REGISTER = "register";
/** 登录场景sms-login 回填 + password-login 共用)。 */
public static final String SCENE_LOGIN = "login";
/** 邀请码注册场景。 */
public static final String SCENE_INVITE = "invite";
/**
* 日维度计数 KEY 前缀
* 日维度计数 KEY 模板passport_ip:{scene}:day:{ip}:{yyyyMMdd}
*/
private static final String KEY_DAY = "passport_sms_ip_day:%s:%s";
private static final String KEY_DAY = "passport_ip:%s:day:%s:%s";
/**
* 小时维度计数 KEY 前缀
* 小时维度计数 KEY 模板passport_ip:{scene}:hour:{ip}:{yyyyMMddHH}
*/
private static final String KEY_HOUR = "passport_sms_ip_hour:%s:%s";
private static final String KEY_HOUR = "passport_ip:%s:hour:%s:%s";
private static final DateTimeFormatter DAY_FMT = DateTimeFormatter.ofPattern("yyyyMMdd");
private static final DateTimeFormatter HOUR_FMT = DateTimeFormatter.ofPattern("yyyyMMddHH");
@ -38,35 +58,48 @@ public class PassportSmsIpCounterRedisDAO {
private StringRedisTemplate stringRedisTemplate;
/**
* 自增当日计数并返回自增后的值首次自增时设 1 TTL
* 自增指定场景当日计数并返回自增后的值首次自增时设 1 TTL
*
* @param ip 请求 IP
* @return 自增后的当日计数
* @param scene 场景{@link #SCENE_SMS}
* @param ip 请求 IP
* @return 自增后的当日计数Redis 异常时 fail-open 返回 0放行
*/
public long incrDayAndGet(String ip) {
String key = String.format(KEY_DAY, ip, LocalDate.now().format(DAY_FMT));
Long val = stringRedisTemplate.opsForValue().increment(key);
if (val != null && val == 1L) {
// 首次计数设过期避免 key 永久堆积日桶 1
stringRedisTemplate.expire(key, 1, TimeUnit.DAYS);
public long incrDayAndGet(String scene, String ip) {
String key = String.format(KEY_DAY, scene, ip, LocalDate.now().format(DAY_FMT));
try {
Long val = stringRedisTemplate.opsForValue().increment(key);
if (val != null && val == 1L) {
// 首次计数设过期避免 key 永久堆积日桶 1
stringRedisTemplate.expire(key, 1, TimeUnit.DAYS);
}
return val == null ? 0L : val;
} catch (Exception e) {
// 频控后端故障 fail-open§3.3记含 IP+scene 的告警放行本次绝不让 Redis 单点拖垮认证
log.warn("[incrDayAndGet] ⚠ 频控 Redis 异常fail-open 放行防刷本次失效记账接受scene={}, ip={}", scene, ip, e);
return 0L;
}
return val == null ? 0L : val;
}
/**
* 自增当前小时计数并返回自增后的值首次自增时设 1 小时 TTL
* 自增指定场景当前小时计数并返回自增后的值首次自增时设 1 小时 TTL
*
* @param ip 请求 IP
* @return 自增后的当前小时计数
* @param scene 场景
* @param ip 请求 IP
* @return 自增后的当前小时计数Redis 异常时 fail-open 返回 0放行
*/
public long incrHourAndGet(String ip) {
String key = String.format(KEY_HOUR, ip, LocalDateTime.now().format(HOUR_FMT));
Long val = stringRedisTemplate.opsForValue().increment(key);
if (val != null && val == 1L) {
// 首次计数设过期小时桶 1 小时
stringRedisTemplate.expire(key, 1, TimeUnit.HOURS);
public long incrHourAndGet(String scene, String ip) {
String key = String.format(KEY_HOUR, scene, ip, LocalDateTime.now().format(HOUR_FMT));
try {
Long val = stringRedisTemplate.opsForValue().increment(key);
if (val != null && val == 1L) {
// 首次计数设过期小时桶 1 小时
stringRedisTemplate.expire(key, 1, TimeUnit.HOURS);
}
return val == null ? 0L : val;
} catch (Exception e) {
log.warn("[incrHourAndGet] ⚠ 频控 Redis 异常fail-open 放行防刷本次失效记账接受scene={}, ip={}", scene, ip, e);
return 0L;
}
return val == null ? 0L : val;
}
}

View File

@ -0,0 +1,35 @@
package com.wanxiang.huijing.module.system.mq.message.passport;
import lombok.Data;
import java.io.Serializable;
/**
* 注册成功事件 MQ 消息体2026-07-07 WU1 §3.6
*
* 三条注册路sms 自动注册 / invite / password的公共出口事件消费的是注册成功而非password 注册成功
* 口径统一在三条路的公共出口WU2 new-api 开户充值消费端据 {@link #userId} 幂等至少一次投递必带重复
*
* @author 绘境AI
*/
@Data
public class PassportRegisterMessage implements Serializable {
/**
* 新注册玩家编号game_player.id消费端开户充值幂等键
*/
private Long userId;
/**
* 注册通道sms / invite / password归因用不区分登录方式的消费口径
*/
private String registerChannel;
/**
* 注册时携带的客户端 anonId匿名登录归因缺失为空串
*/
private String anonId;
/**
* 幂等键=register:{userId}消费端去重
*/
private String idempotentKey;
}

View File

@ -0,0 +1,122 @@
package com.wanxiang.huijing.module.system.mq.producer.passport;
import com.wanxiang.huijing.module.system.dal.dataobject.passport.PassportRegisterOutboxDO;
import com.wanxiang.huijing.module.system.dal.mysql.passport.PassportRegisterOutboxMapper;
import com.wanxiang.huijing.module.system.mq.message.passport.PassportRegisterMessage;
import lombok.extern.slf4j.Slf4j;
import org.apache.rocketmq.client.producer.SendResult;
import org.apache.rocketmq.client.producer.SendStatus;
import org.apache.rocketmq.spring.core.RocketMQTemplate;
import org.apache.rocketmq.spring.support.RocketMQHeaders;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.messaging.Message;
import org.springframework.messaging.support.MessageBuilder;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import jakarta.annotation.Resource;
import java.time.LocalDateTime;
import java.util.List;
/**
* 注册成功事件 outbox 投递器事务外 @Scheduled2026-07-07 WU1 §3.6
*
* 与建号原子写入的 outbox {@link PassportRegisterOutboxDO}由本投递器周期扫描status=0 未投递
* 同步发 RocketMQ带超时发成功用条件更新 CAS 置已投递status=1至少一次锚在 DB 事务上而非
* MQ 即时可达性上建号成功则 outbox 必落库投递器保证最终发出MQ 抖动/超时时该行保留未投递态下轮重投
* <p>
* 外部交互纪律创始人铁律syncSend 带发送超时发送失败/ SEND_OK <b>不置已投递</b>下轮重投有界批扫兜底
* RocketMQTemplate {@link ObjectProvider} name-server 未装配如部分本地/测试 profile时优雅缺席
* 本轮跳过并 WARN绝不因 MQ 不可达抛穿定时线程幂等由消费侧据 userId 去重至少一次必带重复投递
*
* @author 绘境AI
*/
@Slf4j
@Component
public class PassportRegisterOutboxDeliverer {
/** 「注册成功」事件主题WU2 new-api 开户充值消费端订阅)。 */
public static final String TOPIC = "passport-register";
/** 发送超时(毫秒)——外部交互必设超时,避免定时线程被 MQ 抖动无限拖住。 */
private static final long SEND_TIMEOUT_MS = 3000L;
/** 单轮扫描上限(有界批扫,避免一次拉全表)。 */
private static final int BATCH_LIMIT = 200;
@Resource
private PassportRegisterOutboxMapper outboxMapper;
/**
* RocketMQ 模板rocketmq-spring 自动装配ObjectProvider 兜底 name-server 未配时缺席不阻断上下文/定时线程
*/
@Resource
private ObjectProvider<RocketMQTemplate> rocketMQTemplateProvider;
/**
* 周期扫描未投递 outbox 并投递fixedDelay 15s上轮结束后再隔 15s 起下轮避免堆叠
* initialDelay 30s 让应用先启动完成再首扫
*/
@Scheduled(fixedDelayString = "${wanxiang.passport.register-outbox.deliver-interval-ms:15000}", initialDelay = 30_000L)
public void deliverPendingBatch() {
RocketMQTemplate rocketMQTemplate = rocketMQTemplateProvider.getIfAvailable();
if (rocketMQTemplate == null) {
// name-server 未装配部分本地/测试 profile本轮跳过不阻断 MQ 就绪后下轮自然投出至少一次不丢
log.warn("[passport-outbox] RocketMQTemplate 缺席rocketmq name-server 未装配本轮跳过投递outbox 保留未投递态待后续");
return;
}
List<PassportRegisterOutboxDO> pending = outboxMapper.selectPendingList(BATCH_LIMIT);
if (pending.isEmpty()) {
return;
}
int ok = 0;
for (PassportRegisterOutboxDO row : pending) {
if (deliverOne(rocketMQTemplate, row)) {
ok++;
}
}
log.info("[passport-outbox] 本轮投递完成 total={}, delivered={}", pending.size(), ok);
}
/**
* 投递单条syncSend 成功且 SEND_OK CAS 置已投递否则保留未投递态下轮重投
*
* @return 是否本次投递并置位成功
*/
private boolean deliverOne(RocketMQTemplate rocketMQTemplate, PassportRegisterOutboxDO row) {
try {
PassportRegisterMessage payload = new PassportRegisterMessage();
payload.setUserId(row.getUserId());
payload.setRegisterChannel(row.getRegisterChannel());
payload.setAnonId(row.getAnonId());
payload.setIdempotentKey(row.getIdempotentKey());
// keys 置幂等键便于全链路排障对账消费侧幂等真身同为 idempotentKey/userId
Message<PassportRegisterMessage> message = MessageBuilder.withPayload(payload)
.setHeader(RocketMQHeaders.KEYS, row.getIdempotentKey())
.build();
SendResult result = rocketMQTemplate.syncSend(TOPIC, message, SEND_TIMEOUT_MS);
boolean sendOk = result != null && result.getSendStatus() == SendStatus.SEND_OK;
if (!sendOk) {
// SEND_OK刷盘/主从超时等不置位保留未投递态下轮重投
log.warn("[passport-outbox] 投递非 SEND_OK保留未投递态待重投 outboxId={}, userId={}, status={}",
row.getId(), row.getUserId(), result == null ? null : result.getSendStatus());
return false;
}
// CAS 置已投递防并发/重入双投affected=0 说明已被抢先本次不重复计
int affected = outboxMapper.markDelivered(row.getId(), LocalDateTime.now());
if (affected == 0) {
log.warn("[passport-outbox] 置已投递 CAS 未命中已被抢先outboxId={}, userId={}", row.getId(), row.getUserId());
return false;
}
log.info("[passport-outbox] 注册成功事件已投递 topic={}, outboxId={}, userId={}, channel={}, msgId={}",
TOPIC, row.getId(), row.getUserId(), row.getRegisterChannel(), result.getMsgId());
return true;
} catch (Exception e) {
// 外部交互错误路径必须留痕MQ 抖动/超时不外抛不拖垮整批保留未投递态下轮重投有界重试
log.warn("[passport-outbox] 投递失败不外抛保留未投递态待重投outboxId={}, userId={}", row.getId(), row.getUserId(), e);
return false;
}
}
}

View File

@ -3,6 +3,8 @@ package com.wanxiang.huijing.module.system.service.passport;
import com.wanxiang.huijing.module.system.api.passport.dto.PlayerRespDTO;
import com.wanxiang.huijing.module.system.controller.app.passport.vo.InviteRegisterReqVO;
import com.wanxiang.huijing.module.system.controller.app.passport.vo.LoginRespVO;
import com.wanxiang.huijing.module.system.controller.app.passport.vo.PasswordLoginReqVO;
import com.wanxiang.huijing.module.system.controller.app.passport.vo.PasswordRegisterReqVO;
import com.wanxiang.huijing.module.system.controller.app.passport.vo.PlayerMeRespVO;
import com.wanxiang.huijing.module.system.controller.app.passport.vo.SmsLoginReqVO;
@ -51,6 +53,33 @@ public interface PassportService {
*/
LoginRespVO inviteRegister(InviteRegisterReqVO reqVO, String clientIp);
/**
* 用户名密码注册内测种子用户register_channel=password2026-07-07 WU1 §3.2
*
* 总开关 wanxiang.passport.password-auth-enabled 关闭时抛 PASSWORD_AUTH_DISABLED信任边界=后端服务层
* 应用层纯 IP 计数防刷scene=register越限抛 TOO_MANY_REQUESTS密码 BCrypt 编码后落库
* 用户名撞 uk_username PLAYER_USERNAME_ALREADY_REGISTERED注册态明确报占用不静默转登录
* 注册事务内与建号原子写一行 outbox注册成功事件生产者三条注册路共用出口
*
* @param reqVO username/password/nickname?/anonId?
* @param clientIp 请求 IP取连接层 request.getRemoteAddr()不采信可伪造 XFF§3.3
* @return 登录响应 OAuth2 token + 身份摘要与短信路一字不差
*/
LoginRespVO passwordRegister(PasswordRegisterReqVO reqVO, String clientIp);
/**
* 用户名密码登录内测种子用户2026-07-07 WU1 §3.2
*
* 总开关关闭抛 PASSWORD_AUTH_DISABLED应用层纯 IP 计数scene=login越限抛 TOO_MANY_REQUESTS
* 防枚举查无用户名与密码错误统一返 PLAYER_USERNAME_OR_PASSWORD_ERROR且查无用户名时也跑一次固定假 BCrypt
* hash matches 对齐耗时消除用户名存在与否的时序侧信道禁用态拒登录
*
* @param reqVO username/password/anonId?
* @param clientIp 请求 IP取连接层 request.getRemoteAddr()不采信 XFF
* @return 登录响应同短信路 token
*/
LoginRespVO passwordLogin(PasswordLoginReqVO reqVO, String clientIp);
/**
* 当前登录玩家信息me 端点登录态手机号脱敏返回
*

View File

@ -11,10 +11,14 @@ import com.wanxiang.huijing.module.system.api.sms.dto.code.SmsCodeSendReqDTO;
import com.wanxiang.huijing.module.system.api.sms.dto.code.SmsCodeUseReqDTO;
import com.wanxiang.huijing.module.system.controller.app.passport.vo.InviteRegisterReqVO;
import com.wanxiang.huijing.module.system.controller.app.passport.vo.LoginRespVO;
import com.wanxiang.huijing.module.system.controller.app.passport.vo.PasswordLoginReqVO;
import com.wanxiang.huijing.module.system.controller.app.passport.vo.PasswordRegisterReqVO;
import com.wanxiang.huijing.module.system.controller.app.passport.vo.PlayerMeRespVO;
import com.wanxiang.huijing.module.system.controller.app.passport.vo.SmsLoginReqVO;
import com.wanxiang.huijing.module.system.dal.dataobject.oauth2.OAuth2AccessTokenDO;
import com.wanxiang.huijing.module.system.dal.dataobject.passport.PassportRegisterOutboxDO;
import com.wanxiang.huijing.module.system.dal.dataobject.passport.PlayerDO;
import com.wanxiang.huijing.module.system.dal.mysql.passport.PassportRegisterOutboxMapper;
import com.wanxiang.huijing.module.system.dal.mysql.passport.PlayerMapper;
import com.wanxiang.huijing.module.system.dal.redis.passport.PassportSmsIpCounterRedisDAO;
import com.wanxiang.huijing.module.system.enums.oauth2.OAuth2ClientConstants;
@ -24,6 +28,7 @@ import com.wanxiang.huijing.module.system.service.sms.SmsCodeService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
@ -54,6 +59,18 @@ public class PassportServiceImpl implements PassportService {
*/
private static final String DEFAULT_NICKNAME_PREFIX = "玩家";
/**
* 用户名密码注册通道值register_channel=password对齐 V31 迁移值域
*/
private static final String CHANNEL_PASSWORD = "password";
/**
* 生成固定假 BCrypt hash 的种子§3.2 防枚举时序对齐查无用户名时对本种子编出的假 hash 跑一次 matches
* 用户名存在/不存在两分支耗时不可区分 hash 由本类 passwordEncoder 惰性编码
* 与真实存储 hash cost 一致才能真正对齐耗时而非硬编码某 cost 造成新的耗时差
*/
private static final String FAKE_MATCH_SEED = "passport-fake-timing-seed";
@Resource
private SmsCodeService smsCodeService;
@Resource
@ -65,7 +82,16 @@ public class PassportServiceImpl implements PassportService {
@Resource
private SecurityProperties securityProperties; // mock 豁免判定依据§6.3
@Resource
private PassportSmsIpCounterRedisDAO smsIpCounterRedisDAO; // R6 IP 频控§7.2
private PassportSmsIpCounterRedisDAO smsIpCounterRedisDAO; // 应用层纯 IP 频控R6 + WU1 泛化 scene
@Resource
private PasswordEncoder passwordEncoder; // BCrypt 编码/校验复用 admin 范式§3.2
@Resource
private PassportRegisterOutboxMapper registerOutboxMapper; // 注册成功事件 outbox 生产者§3.6
/**
* 惰性缓存的固定假 BCrypt hash防枚举时序对齐用首次登录查无用户名时以本类 encoder 编出cost 与真实 hash 一致
*/
private volatile String fakeMatchHash;
/**
* 发码 IP 每小时上限R6 §7.2推断初值 10 /小时/IP可配置与运营确认后调整
@ -110,20 +136,42 @@ public class PassportServiceImpl implements PassportService {
@Value("${wanxiang.passport.sms-mock-code:8888}")
private String smsMockCode;
/**
* 用户名密码注册+登录总开关§3.4staging true缺省 false关闭时两端点返回 PASSWORD_AUTH_DISABLED
* 信任边界在后端服务层不靠前端隐藏 Tab
*/
@Value("${wanxiang.passport.password-auth-enabled:false}")
private boolean passwordAuthEnabled;
/**
* 注册场景 IP 每小时上限§3.4 推断初值 20注册比发码宽松一档与运营确认后调用于 password-register invite-register 回填
*/
@Value("${wanxiang.passport.register-ip-limit-per-hour:20}")
private int registerIpLimitPerHour;
/**
* 注册场景 IP 每日上限§3.4 推断初值 60
*/
@Value("${wanxiang.passport.register-ip-limit-per-day:60}")
private int registerIpLimitPerDay;
/**
* 登录场景 IP 每小时上限§3.4 推断初值 30用于 password-login sms-login 回填
*/
@Value("${wanxiang.passport.login-ip-limit-per-hour:30}")
private int loginIpLimitPerHour;
/**
* 登录场景 IP 每日上限§3.4 推断初值 100
*/
@Value("${wanxiang.passport.login-ip-limit-per-day:100}")
private int loginIpLimitPerDay;
@Override
public void sendSmsCode(String mobile, String clientIp) {
// R6 §7.2先做 IP 维度频控每小时/每日上限不改上游 SmsCodeServiceImpl手机号维度频控由其内部承载
// 计数即自增先增后判超限抛 TOO_MANY_REQUESTS复用全局错误码避免新增
if (StringUtils.hasText(clientIp)) {
long hourCount = smsIpCounterRedisDAO.incrHourAndGet(clientIp);
long dayCount = smsIpCounterRedisDAO.incrDayAndGet(clientIp);
if (hourCount > smsIpLimitPerHour || dayCount > smsIpLimitPerDay) {
log.warn("[sendSmsCode] 发码 IP 频控触发 ip={}, hourCount={}/{}, dayCount={}/{}",
clientIp, hourCount, smsIpLimitPerHour, dayCount, smsIpLimitPerDay);
// 不区分小时/统一返回请求过快与防枚举一致不泄露内部阈值细节
throw exception(GlobalErrorCodeConstants.TOO_MANY_REQUESTS);
}
}
// 计数即自增先增后判超限抛 TOO_MANY_REQUESTS复用全局错误码避免新增WU1 泛化scene=sms
checkIpLimit(PassportSmsIpCounterRedisDAO.SCENE_SMS, clientIp, smsIpLimitPerHour, smsIpLimitPerDay);
// 仅内测/staging免真实短信验证开关开启时发码不依赖真实网关mock 受理成功直接返回
// 原因staging 未配短信渠道/模板 SmsCodeService.sendSmsCode 会因渠道/模板缺失抛异常发不出码
// 开启后 sms-login 用固定测试码 smsMockCode 登录 smsLogin故此处无需真实落库验证码
@ -146,6 +194,8 @@ public class PassportServiceImpl implements PassportService {
@Override
@Transactional(rollbackFor = Exception.class) // 自动注册建号与验证码使用需同一本地事务避免验证码已用但建号失败的半截态
public LoginRespVO smsLogin(SmsLoginReqVO reqVO, String clientIp) {
// WU1 §3.3 回填此前 sms-login 裸奔无 IP 补服务层纯 IP 计数scene=login password-login 同桶越限 429
checkIpLimit(PassportSmsIpCounterRedisDAO.SCENE_LOGIN, clientIp, loginIpLimitPerHour, loginIpLimitPerDay);
// 1) 校验并使用验证码场景 MEMBER_LOGIN不存在/过期/已用由 SmsCodeService 抛精确错误码
// 仅内测/staging免短信验证开关开启 提交码==固定测试码 smsMockCode 跳过 system_sms_code 表校验
// 直接视为验证通过任意合法手机号 + 固定码即可登录/自动注册否则一律走真实校验
@ -166,8 +216,8 @@ public class PassportServiceImpl implements PassportService {
// 2) 已注册登录未注册自动注册register_channel=sms验证码即占有证明拍板3
PlayerDO player = playerMapper.selectByMobile(reqVO.getMobile());
if (player == null) {
// sms-login 自动注册不收昵称验证码路径无昵称入参 null 走默认生成
player = registerPlayer(reqVO.getMobile(), "sms", null, reqVO.getAnonId(), null, clientIp);
// sms-login 自动注册不收昵称验证码路径无昵称入参 null 走默认生成用户名/密码路参传 null手机号路
player = registerPlayer(reqVO.getMobile(), null, null, "sms", null, reqVO.getAnonId(), null, clientIp);
} else {
// 已注册禁用态拒登录正常态刷新最近登录时间
assertPlayerEnabled(player);
@ -184,6 +234,8 @@ public class PassportServiceImpl implements PassportService {
if (!inviteRegisterEnabled) {
throw exception(INVITE_REGISTER_DISABLED);
}
// WU1 §3.3 回填此前 invite-register 裸奔无 IP 补服务层纯 IP 计数scene=invite注册档阈值越限 429
checkIpLimit(PassportSmsIpCounterRedisDAO.SCENE_INVITE, clientIp, registerIpLimitPerHour, registerIpLimitPerDay);
// 2) 手机号已注册 提示直接验证码登录invite-register 路径明确报已注册 sms-login 防枚举口径区分
PlayerDO exist = playerMapper.selectByMobile(reqVO.getMobile());
if (exist != null) {
@ -191,31 +243,86 @@ public class PassportServiceImpl implements PassportService {
}
// 3) 核销邀请码条件更新原子累加同事务耗尽/停用/过期/不存在抛 1-002-091 段错误码 整体回滚
Long inviteCodeId = inviteCodeService.validateAndRedeem(reqVO.getInviteCode());
// 4) 建号register_channel=invite关联核销的 invite_code_id+ token
PlayerDO player = registerPlayer(reqVO.getMobile(), "invite", inviteCodeId,
// 4) 建号register_channel=invite关联核销的 invite_code_id用户名/密码路参传 null+ token
PlayerDO player = registerPlayer(reqVO.getMobile(), null, null, "invite", inviteCodeId,
reqVO.getAnonId(), reqVO.getNickname(), clientIp);
return buildLoginResp(player);
}
@Override
@Transactional(rollbackFor = Exception.class) // 建号 + outbox 写入需同一本地事务注册成功事件与建号原子落库§3.6
public LoginRespVO passwordRegister(PasswordRegisterReqVO reqVO, String clientIp) {
// 1) 总开关§3.4 信任边界=后端关闭时拒服务不靠前端隐藏 Tab
if (!passwordAuthEnabled) {
throw exception(PASSWORD_AUTH_DISABLED);
}
// 2) 应用层纯 IP 计数防刷scene=register先增后判越限 429IP 取连接层 remoteAddr controller 传入不采信 XFF
checkIpLimit(PassportSmsIpCounterRedisDAO.SCENE_REGISTER, clientIp, registerIpLimitPerHour, registerIpLimitPerDay);
// 3) 密码 BCrypt 编码复用 admin 范式 passwordEncoder.encode§3.2明文用后即弃绝不落库/落日志
String encodedPassword = passwordEncoder.encode(reqVO.getPassword());
// 4) 建号register_channel=passwordmobile=null 走用户名路+ 事务内写 outbox + token
// 用户名撞 uk_username registerPlayer PLAYER_USERNAME_ALREADY_REGISTERED注册态不静默转登录
PlayerDO player = registerPlayer(null, reqVO.getUsername(), encodedPassword, CHANNEL_PASSWORD, null,
reqVO.getAnonId(), reqVO.getNickname(), clientIp);
return buildLoginResp(player);
}
@Override
public LoginRespVO passwordLogin(PasswordLoginReqVO reqVO, String clientIp) {
// 1) 总开关关闭拒服务
if (!passwordAuthEnabled) {
throw exception(PASSWORD_AUTH_DISABLED);
}
// 2) 应用层纯 IP 计数scene=login sms-login 同桶越限 429
checkIpLimit(PassportSmsIpCounterRedisDAO.SCENE_LOGIN, clientIp, loginIpLimitPerHour, loginIpLimitPerDay);
// 3) 查用户名命中 uk_username
PlayerDO player = playerMapper.selectByUsername(reqVO.getUsername());
if (player == null) {
// 防枚举§3.2查无用户名时也对固定假 hash 跑一次 matches用户名存在/不存在耗时不可区分
// 堵住登录接口的时序侧信道随后与密码错误统一返 PLAYER_USERNAME_OR_PASSWORD_ERROR不给区分
passwordEncoder.matches(reqVO.getPassword(), getFakeMatchHash());
log.info("[passwordLogin] 登录失败:用户名不存在(防枚举统一错误码 + 假 hash 对齐耗时username={}", reqVO.getUsername());
throw exception(PLAYER_USERNAME_OR_PASSWORD_ERROR);
}
// 4) 校验密码BCrypt matches不匹配与查无统一错误码绝不区分
if (!passwordEncoder.matches(reqVO.getPassword(), player.getPassword())) {
log.info("[passwordLogin] 登录失败密码错误防枚举统一错误码userId={}", player.getId());
throw exception(PLAYER_USERNAME_OR_PASSWORD_ERROR);
}
// 5) 禁用态拒登录正常态刷新最近登录时间
assertPlayerEnabled(player);
playerMapper.updateLoginDate(player.getId(), LocalDateTime.now());
// 6) 发真 OAuth2 token与短信路一字不差
return buildLoginResp(player);
}
/**
* 建玩家自动注册 / 邀请码注册共用
* 建玩家sms 自动注册 / invite 邀请码注册 / password 用户名密码注册三路共用
*
* 竞态兜底uk_mobile 唯一键冲突并发同号注册 回查转登录语义与防枚举口径协调
* invite 通道命中冲突属极端竞态前置已查无回查后按已存在玩家继续邀请码已核销不退 WARN
* 落列按路径手机号路sms/invite mobileusername/password null用户名路password username/encodedPassword
* mobile null默认昵称 nickname 用之否则用户名路取 username 本身手机号路取手机号后 4
* 竞态兜底按 channel 区分冲突语义
* - 手机号路 uk_mobile 冲突 回查转登录与防枚举口径协调
* - 用户名路 uk_username 冲突 PLAYER_USERNAME_ALREADY_REGISTERED注册态不能撞名就送进别人账号不转登录
* 注册成功事件insert 成功后在**同一事务内**原子写一行 outbox§3.6三路共用出口投递器事务外发 MQ至少一次
*
* @param mobile 手机号
* @param channel 注册通道sms / invite
* @param inviteCodeId 核销的邀请码 IDsms 通道传 null
* @param anonId 客户端 anonId仅注册时落 first_anon_id缺失落空串
* @param nickname 昵称不传则默认生成
* @param clientIp 注册 IP
* @param mobile 手机号用户名路传 null
* @param username 用户名手机号路传 null
* @param encodedPassword BCrypt 编码的密码密文手机号路传 null
* @param channel 注册通道sms / invite / password
* @param inviteCodeId 核销的邀请码 IDsms/password 通道传 null
* @param anonId 客户端 anonId仅注册时落 first_anon_id缺失落空串
* @param nickname 昵称不传则默认生成
* @param clientIp 注册 IP
* @return 玩家 DO含回填 id
*/
private PlayerDO registerPlayer(String mobile, String channel, Long inviteCodeId,
String anonId, String nickname, String clientIp) {
private PlayerDO registerPlayer(String mobile, String username, String encodedPassword, String channel,
Long inviteCodeId, String anonId, String nickname, String clientIp) {
PlayerDO player = new PlayerDO();
player.setMobile(mobile);
player.setNickname(StringUtils.hasText(nickname) ? nickname : generateNickname(mobile));
player.setUsername(username);
player.setPassword(encodedPassword); // 密文不落日志
player.setNickname(resolveNickname(nickname, username, mobile));
player.setAvatar("");
player.setStatus(CommonStatusEnum.ENABLE.getStatus()); // 0 正常
player.setCreatorFlag(0); // 默认玩家非创作者A2 白名单由 admin set-creator 后置
@ -225,7 +332,7 @@ 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 无登录态
// 2026-06-10 鉴权波 e2e 实证P0-1免登注册三路共用此点@PermitAll 无登录态
// 且注册者本人 id 此刻尚未生成DefaultDBFieldHandler.insertFill 仅在 getLoginUserId()!=null 时填 creator/updater
// huijing-spring-boot-starter-mybatis .../handler/DefaultDBFieldHandler.java:38-43匿名两列保持 null NOT NULL 500
// 故无条件显式兜底 "0"与系统身份 id=0 同口径显式值优先于 handler isNull 才填不覆盖
@ -233,11 +340,19 @@ public class PassportServiceImpl implements PassportService {
player.setUpdater("0");
try {
playerMapper.insert(player);
log.info("[registerPlayer] 新玩家注册成功 userId={}, channel={}, mobile={}, hasAnonId={}",
player.getId(), channel, DesensitizedUtil.mobilePhone(mobile), StringUtils.hasText(anonId));
log.info("[registerPlayer] 新玩家注册成功 userId={}, channel={}, mobile={}, hasUsername={}, hasAnonId={}",
player.getId(), channel, DesensitizedUtil.mobilePhone(mobile),
StringUtils.hasText(username), StringUtils.hasText(anonId));
// §3.6 注册成功事件生产者同一事务内原子写 outbox建号成功则 outbox 必落库投递器保证最终发出
writeRegisterOutbox(player.getId(), channel, anonId);
return player;
} catch (DuplicateKeyException e) {
// uk_mobile 并发冲突回查转登录同手机号竞态兜底§12
// 用户名路password冲突必是 uk_usernamemobile=null 不触发 uk_mobile 明确报占用不转登录
if (CHANNEL_PASSWORD.equals(channel)) {
log.warn("[registerPlayer] 用户名并发注册冲突uk_username报占用不转登录 username={}", username);
throw exception(PLAYER_USERNAME_ALREADY_REGISTERED);
}
// 手机号路 uk_mobile 并发冲突回查转登录同手机号竞态兜底§12
log.warn("[registerPlayer] 手机号并发注册冲突,转登录语义 mobile={}, channel={}",
DesensitizedUtil.mobilePhone(mobile), channel);
PlayerDO existed = playerMapper.selectByMobile(mobile);
@ -250,6 +365,23 @@ public class PassportServiceImpl implements PassportService {
}
}
/**
* 注册成功事件 outbox 生产者§3.6注册事务内与建号原子写一行未投递记录事务外投递器扫描发 RocketMQ
* 幂等键 = register:{userId}uk_idempotent_key 保证每次注册至多一行creator/updater 显式兜底 "0"免登无登录态
*/
private void writeRegisterOutbox(Long userId, String channel, String anonId) {
PassportRegisterOutboxDO outbox = new PassportRegisterOutboxDO();
outbox.setUserId(userId);
outbox.setRegisterChannel(channel);
outbox.setAnonId(StringUtils.hasText(anonId) ? anonId : "");
outbox.setIdempotentKey("register:" + userId);
outbox.setStatus(PassportRegisterOutboxDO.STATUS_PENDING);
outbox.setCreator("0");
outbox.setUpdater("0");
registerOutboxMapper.insert(outbox);
log.info("[registerPlayer] 注册成功事件已写 outbox待投递器发 MQuserId={}, channel={}", userId, channel);
}
/**
* 校验玩家未被禁用禁用态拒登录
*/
@ -259,6 +391,20 @@ public class PassportServiceImpl implements PassportService {
}
}
/**
* 解析注册默认昵称§3.2 nickname 用之否则用户名路取 username 本身手机号路取玩家+手机号后4位
*/
private static String resolveNickname(String nickname, String username, String mobile) {
if (StringUtils.hasText(nickname)) {
return nickname;
}
// 用户名路无手机号直接以用户名为默认昵称避免生成玩家空后缀
if (StringUtils.hasText(username)) {
return username;
}
return generateNickname(mobile);
}
/**
* 生成默认昵称玩家+手机号后4位手机号不足4位时取全量兜底空串
*/
@ -268,6 +414,46 @@ public class PassportServiceImpl implements PassportService {
return DEFAULT_NICKNAME_PREFIX + suffix;
}
/**
* 应用层纯 IP 计数防刷§3.3 scene 分桶先增后判/日任一超限抛 TOO_MANY_REQUESTS复用全局 429不为限流新造码
* clientIp 空则跳过 IP 无从计数Redis 异常由计数组件内部 fail-open 放行返回 0本方法据此不误拒
*
* @param scene 场景{@link PassportSmsIpCounterRedisDAO#SCENE_SMS}
* @param clientIp 连接层 IPpassword 路取 request.getRemoteAddr()不采信 XFF
* @param hourLimit 每小时上限
* @param dayLimit 每日上限
*/
private void checkIpLimit(String scene, String clientIp, int hourLimit, int dayLimit) {
if (!StringUtils.hasText(clientIp)) {
return;
}
long hourCount = smsIpCounterRedisDAO.incrHourAndGet(scene, clientIp);
long dayCount = smsIpCounterRedisDAO.incrDayAndGet(scene, clientIp);
if (hourCount > hourLimit || dayCount > dayLimit) {
// 越限留痕含 scene+IP+计数§5 验收限流 WARN 可审计不区分小时/统一返回请求过快不泄露内部阈值
log.warn("[checkIpLimit] IP 频控触发 scene={}, ip={}, hourCount={}/{}, dayCount={}/{}",
scene, clientIp, hourCount, hourLimit, dayCount, dayLimit);
throw exception(GlobalErrorCodeConstants.TOO_MANY_REQUESTS);
}
}
/**
* 获取固定假 BCrypt hash§3.2 防枚举时序对齐惰性以本类 passwordEncoder 编出一次并缓存
* cost 与真实存储 hash 一致查无用户名时对它跑 matches 才能真正对齐 BCrypt 耗时
*/
private String getFakeMatchHash() {
String cached = fakeMatchHash;
if (cached == null) {
synchronized (this) {
if (fakeMatchHash == null) {
fakeMatchHash = passwordEncoder.encode(FAKE_MATCH_SEED);
}
cached = fakeMatchHash;
}
}
return cached;
}
/**
* OAuth2 token 并组装登录响应token 不落日志
*/
@ -297,7 +483,10 @@ public class PassportServiceImpl implements PassportService {
}
PlayerMeRespVO resp = new PlayerMeRespVO();
resp.setUserId(player.getId());
resp.setMobile(DesensitizedUtil.mobilePhone(player.getMobile())); // 安全基线脱敏返回禁回明文
// 安全基线手机号脱敏返回禁回明文WU1 §3.2 空安全用户名用户 mobile null短路回空串不把 null 塞进脱敏函数 NPE 回归
resp.setMobile(StringUtils.hasText(player.getMobile()) ? DesensitizedUtil.mobilePhone(player.getMobile()) : "");
// WU1 username明文非敏感用户名用户前端据此展示非手机号身份手机号用户 username null
resp.setUsername(player.getUsername());
resp.setNickname(player.getNickname());
resp.setAvatar(player.getAvatar());
resp.setCreatorFlag(player.getCreatorFlag());