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:
parent
987d489b63
commit
d52696c589
@ -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 邀请码注册}。
|
||||
* <p>
|
||||
* KEY 格式:passport_ip:{scene}:hour:{ip}:{yyyyMMddHH} / passport_ip:{scene}:day:{ip}:{yyyyMMdd}
|
||||
* VALUE:String 计数;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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
}
|
||||
@ -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 抖动/超时时该行保留未投递态下轮重投。
|
||||
* <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;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@ -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 端点,登录态);手机号脱敏返回
|
||||
*
|
||||
|
||||
@ -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());
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user