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());