diff --git a/game-cloud/huijing-module-system/huijing-module-system-server/src/main/java/com/wanxiang/huijing/module/system/dal/redis/passport/PassportSmsIpCounterRedisDAO.java b/game-cloud/huijing-module-system/huijing-module-system-server/src/main/java/com/wanxiang/huijing/module/system/dal/redis/passport/PassportSmsIpCounterRedisDAO.java index 943c0e90..aa575372 100644 --- a/game-cloud/huijing-module-system/huijing-module-system-server/src/main/java/com/wanxiang/huijing/module/system/dal/redis/passport/PassportSmsIpCounterRedisDAO.java +++ b/game-cloud/huijing-module-system/huijing-module-system-server/src/main/java/com/wanxiang/huijing/module/system/dal/redis/passport/PassportSmsIpCounterRedisDAO.java @@ -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 频控计数 RedisDAO(R6 切渠道安全前置,§7.2) + * passport 应用层纯 IP 频控计数 RedisDAO(R6 发码前置 + 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} - * VALUE:String 计数;TTL 在首次自增时设置(日桶 1 天、小时桶 1 小时),到期自动清。 + * 在 passport 各免登端点入层按「纯 IP 维度」的时/日双桶计数(key 只含 scene+IP+日期,不含请求体), + * 以 Redis 自增 + 首次设过期实现按日历日/小时的自然窗口。原发码专用 KEY 前缀泛化为 scene 分桶, + * scene ∈ {sms 发码, register 用户名注册, login 登录(sms/password 共用), invite 邀请码注册}。 + *

+ * KEY 格式:passport_ip:{scene}:hour:{ip}:{yyyyMMddHH} / passport_ip:{scene}:day:{ip}:{yyyyMMdd} + * VALUE:String 计数;TTL 在首次自增时设置(日桶 1 天、小时桶 1 小时),到期自动清。 + *

+ * ⚠ 一次性重置(§3.3 如实记账):上线本改动那一刻,旧前缀 passport_sms_ip_* 的存量计数桶全部失效, + * 等于对已在计数的 IP 一次性重置限流窗口;桶 TTL 有界、只重置一次,内测无害,观测同步改名即可。 + *

+ * 频控后端故障姿态(§3.3 定死):fail-open + WARN 告警。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; } } diff --git a/game-cloud/huijing-module-system/huijing-module-system-server/src/main/java/com/wanxiang/huijing/module/system/mq/message/passport/PassportRegisterMessage.java b/game-cloud/huijing-module-system/huijing-module-system-server/src/main/java/com/wanxiang/huijing/module/system/mq/message/passport/PassportRegisterMessage.java new file mode 100644 index 00000000..94a2be26 --- /dev/null +++ b/game-cloud/huijing-module-system/huijing-module-system-server/src/main/java/com/wanxiang/huijing/module/system/mq/message/passport/PassportRegisterMessage.java @@ -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; + +} diff --git a/game-cloud/huijing-module-system/huijing-module-system-server/src/main/java/com/wanxiang/huijing/module/system/mq/producer/passport/PassportRegisterOutboxDeliverer.java b/game-cloud/huijing-module-system/huijing-module-system-server/src/main/java/com/wanxiang/huijing/module/system/mq/producer/passport/PassportRegisterOutboxDeliverer.java new file mode 100644 index 00000000..8d9ebc63 --- /dev/null +++ b/game-cloud/huijing-module-system/huijing-module-system-server/src/main/java/com/wanxiang/huijing/module/system/mq/producer/passport/PassportRegisterOutboxDeliverer.java @@ -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 投递器(事务外 @Scheduled,2026-07-07 WU1 §3.6) + * + * 与建号原子写入的 outbox 行({@link PassportRegisterOutboxDO})由本投递器周期扫描(status=0 未投递), + * 同步发 RocketMQ(带超时),发成功用条件更新 CAS 置已投递(status=1)。把「至少一次」锚在 DB 事务上而非 + * MQ 即时可达性上:建号成功则 outbox 必落库,投递器保证最终发出;MQ 抖动/超时时该行保留未投递态下轮重投。 + *

+ * 外部交互纪律(创始人铁律):syncSend 带发送超时;发送失败/非 SEND_OK 不置已投递(下轮重投,有界批扫兜底); + * 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 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 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 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; + } + } + +} diff --git a/game-cloud/huijing-module-system/huijing-module-system-server/src/main/java/com/wanxiang/huijing/module/system/service/passport/PassportService.java b/game-cloud/huijing-module-system/huijing-module-system-server/src/main/java/com/wanxiang/huijing/module/system/service/passport/PassportService.java index 1ffc05aa..abaa678b 100644 --- a/game-cloud/huijing-module-system/huijing-module-system-server/src/main/java/com/wanxiang/huijing/module/system/service/passport/PassportService.java +++ b/game-cloud/huijing-module-system/huijing-module-system-server/src/main/java/com/wanxiang/huijing/module/system/service/passport/PassportService.java @@ -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=password;2026-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 端点,登录态);手机号脱敏返回 * diff --git a/game-cloud/huijing-module-system/huijing-module-system-server/src/main/java/com/wanxiang/huijing/module/system/service/passport/PassportServiceImpl.java b/game-cloud/huijing-module-system/huijing-module-system-server/src/main/java/com/wanxiang/huijing/module/system/service/passport/PassportServiceImpl.java index d3dff35a..542f31db 100644 --- a/game-cloud/huijing-module-system/huijing-module-system-server/src/main/java/com/wanxiang/huijing/module/system/service/passport/PassportServiceImpl.java +++ b/game-cloud/huijing-module-system/huijing-module-system-server/src/main/java/com/wanxiang/huijing/module/system/service/passport/PassportServiceImpl.java @@ -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.4,staging 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,先增后判,越限 429;IP 取连接层 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=password,mobile=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)落 mobile、username/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 核销的邀请码 ID(sms 通道传 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 核销的邀请码 ID(sms/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 { // 目标3:anonId 仅注册时落一次;缺失落空串(归因降级不阻断登录,§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_username(mobile=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(待投递器发 MQ)userId={}, 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 连接层 IP(password 路取 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());