From 987d489b6391094d9682977563cb44710d44de97 Mon Sep 17 00:00:00 2001 From: lili Date: Tue, 7 Jul 2026 11:52:05 -0700 Subject: [PATCH 01/10] =?UTF-8?q?feat(passport):=20WU1=20=E5=A5=91?= =?UTF-8?q?=E7=BA=A6=E5=B1=82=E2=80=94=E2=80=94=E7=94=A8=E6=88=B7=E5=90=8D?= =?UTF-8?q?=E5=AF=86=E7=A0=81=E6=B3=A8=E5=86=8C=E7=99=BB=E5=BD=95=20schema?= =?UTF-8?q?/VO/=E9=94=99=E8=AF=AF=E7=A0=81/outbox?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 内测种子用户「用户名+密码」注册登录(无短信)的契约层,照 2026-07-07 WU1 设计 §3.1/§3.6 落地: - Flyway V31.0.0(同落 contracts 源+huijing-server 执行副本):game_player 加 username/password 两列、mobile 放宽可空、加 uk_username、register_channel 值域扩 password;建 game_passport_register_outbox 注册成功事件本地消息表。V31.0.1 回滚补偿仅置 contracts 源(手动执行,不进执行副本避免 Flyway 自动 DROP)。 - PlayerDO 加 username/password(password 仅服务层内部,永不进 RespVO);PlayerMapper 加 selectByUsername。 - PasswordRegisterReqVO/PasswordLoginReqVO;PlayerMeRespVO 加 username。 - 错误码 1_002_090_004/005/006(用户名占用/用户名或密码错误/通道未开启)。 - outbox DO+Mapper(@TenantIgnore 供 @Scheduled 投递器全表扫);pom 显式直依赖 rocketmq-spring-boot-starter。 Co-Authored-By: Claude Opus 4.8 (1M context) --- ..._passport_player_add_username_password.sql | 52 +++++++++++++++ ..._player_add_username_password_rollback.sql | 24 +++++++ .../system/enums/ErrorCodeConstants.java | 4 ++ .../huijing-module-system-server/pom.xml | 7 ++ .../app/passport/vo/PasswordLoginReqVO.java | 34 ++++++++++ .../passport/vo/PasswordRegisterReqVO.java | 42 ++++++++++++ .../app/passport/vo/PlayerMeRespVO.java | 5 +- .../passport/PassportRegisterOutboxDO.java | 65 +++++++++++++++++++ .../dal/dataobject/passport/PlayerDO.java | 10 ++- .../PassportRegisterOutboxMapper.java | 53 +++++++++++++++ .../dal/mysql/passport/PlayerMapper.java | 10 +++ ..._passport_player_add_username_password.sql | 52 +++++++++++++++ 12 files changed, 356 insertions(+), 2 deletions(-) create mode 100644 contracts/db-schemas/V31.0.0__passport_player_add_username_password.sql create mode 100644 contracts/db-schemas/V31.0.1__passport_player_add_username_password_rollback.sql create mode 100644 game-cloud/huijing-module-system/huijing-module-system-server/src/main/java/com/wanxiang/huijing/module/system/controller/app/passport/vo/PasswordLoginReqVO.java create mode 100644 game-cloud/huijing-module-system/huijing-module-system-server/src/main/java/com/wanxiang/huijing/module/system/controller/app/passport/vo/PasswordRegisterReqVO.java create mode 100644 game-cloud/huijing-module-system/huijing-module-system-server/src/main/java/com/wanxiang/huijing/module/system/dal/dataobject/passport/PassportRegisterOutboxDO.java create mode 100644 game-cloud/huijing-module-system/huijing-module-system-server/src/main/java/com/wanxiang/huijing/module/system/dal/mysql/passport/PassportRegisterOutboxMapper.java create mode 100644 game-cloud/huijing-server/src/main/resources/db/migration/V31.0.0__passport_player_add_username_password.sql diff --git a/contracts/db-schemas/V31.0.0__passport_player_add_username_password.sql b/contracts/db-schemas/V31.0.0__passport_player_add_username_password.sql new file mode 100644 index 00000000..985f7cb7 --- /dev/null +++ b/contracts/db-schemas/V31.0.0__passport_player_add_username_password.sql @@ -0,0 +1,52 @@ +-- ============================================================================= +-- 契约 #2 DB 迁移 | 主题:内测·用户名+密码注册登录(WU1,2026-07-07 设计 §3.1/§3.6)| owner:passport(system 内扩展) +-- 文件:V31.0.0__passport_player_add_username_password.sql(Flyway,只 ALTER/新增;已合入禁止修改,回滚见补偿迁移 V31.0.1) +-- 依据:docs/agent-specs/2026-07-07-内测-WU1-注册登录用户名密码-设计.md(草案,创始人内测口径拍定)。 +-- 版本定序:执行副本 db/migration/ 实测已连续到 V30.0.0(含 V26 aigc idempotency),本件取其上下一号 V31.0.0(已 ls|sort -V|tail 复核)。 +-- 守门①(沿用 V11):本件同时落 contracts/db-schemas/(契约源)+ huijing-server 执行副本(唯一执行 classpath), +-- 不放任何单模块 -server/db/migration/(避免同版本 V31 出现在多个 classpath jar 触发 Flyway 重复校验失败)。 +-- 守门②:含中文 SQL,mini-desktop 执行必须 --default-character-set=utf8mb4。 +-- 内容两段: +-- 1. ALTER game_player —— 加 username/password 两列、放宽 mobile 可空、加 uk_username、register_channel 值域扩 password; +-- 2. CREATE game_passport_register_outbox —— 「注册成功」事件本地消息表(三条注册路共用出口,至少一次投递生产者,§3.6)。 +-- ============================================================================= + +-- ----------------------------------------------------------------------------- +-- 段 1:ALTER game_player —— 单表双身份路径共存(手机号 / 用户名 二选一,§3.1) +-- 关键:mobile 由 NOT NULL 放宽为 NULL(承载无手机号的用户名用户);uk_mobile 对 NULL 行不施约束, +-- uk_username 对 NULL 行(手机号用户)不施约束,两条唯一键在同表互不干扰。 +-- 存量兼容:存量行 mobile 均有值、username/password 取新列默认 NULL,天然落「手机号路径」侧;放宽为松约束不破坏任何存量行。 +-- password 列:BCrypt 密文(约 60 字符,留至 100 兼容算法前缀/未来迁移);⚠ 永不返回任何 RespVO、永不落日志。 +-- ----------------------------------------------------------------------------- +ALTER TABLE `game_player` + ADD COLUMN `username` VARCHAR(30) NULL COMMENT '用户名(内测用户名密码路登录主键之一,^[a-zA-Z0-9]{4,30}$;手机号用户为 NULL)' AFTER `mobile`, + ADD COLUMN `password` VARCHAR(100) NULL COMMENT 'BCrypt 密文(用户名路 60 字符左右,留 100 兼容算法前缀;⚠ 永不返回、永不落日志;非用户名用户为 NULL)' AFTER `username`, + MODIFY COLUMN `mobile` VARCHAR(11) NULL COMMENT '手机号(登录主键之一;放宽可空承载无手机号的用户名用户,2026-07-07 WU1;日志/响应必须脱敏)', + MODIFY COLUMN `register_channel` VARCHAR(16) NOT NULL DEFAULT 'sms' COMMENT '注册通道:sms验证码 / invite邀请码旁路 / password用户名密码(漏斗归因,2026-07-07 WU1 扩 password)', + ADD UNIQUE KEY `uk_username` (`username`, `deleted`, `tenant_id`) COMMENT '用户名唯一(含 deleted/tenant_id 适配逻辑删+多租户,同 uk_mobile 范式;NULL 行不参与约束,手机号用户不受限)'; + +-- ----------------------------------------------------------------------------- +-- 段 2:CREATE game_passport_register_outbox —— 「注册成功」事件本地消息表(事务性 outbox,§3.6) +-- 语义:注册事务内与建号原子写一行(status=0 未投递);事务外投递器(@Scheduled)扫未投递发 RocketMQ、 +-- 发成功置 status=1 已投递。把「至少一次」锚在 DB 事务上而非 MQ 即时可达性上,杜绝「注册成功但事件从未入队」黑洞。 +-- 三条注册路(sms 自动注册 / invite / password)共用此出口,WU2 new-api 开户充值消费端据 user_id 幂等(至少一次必带重复投递)。 +-- 多租户:本表为机制表,DO 标 @TenantIgnore(不含 tenant_id 列),投递器 @Scheduled 无租户上下文可全表扫。 +-- ----------------------------------------------------------------------------- +CREATE TABLE `game_passport_register_outbox` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '编号', + `user_id` BIGINT NOT NULL COMMENT '新注册玩家编号(game_player.id;WU2 开户充值幂等键)', + `register_channel` VARCHAR(16) NOT NULL COMMENT '注册通道:sms / invite / password(消费端口径统一在此,不区分登录方式)', + `anon_id` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '注册时携带的客户端 anonId(匿名↔登录归因;缺失落空串)', + `idempotent_key` VARCHAR(64) NOT NULL COMMENT '幂等键(=register:{userId},uk 保证每次注册至多一行 outbox)', + `status` TINYINT NOT NULL DEFAULT 0 COMMENT '投递状态:0未投递 1已投递(投递器发 MQ 成功后置 1)', + `delivered_time` DATETIME NULL COMMENT '投递成功时间(status=1 时回填,审计)', + -- 标准审计列(与 game_player 同范式;@PermitAll 免登注册无登录态,creator/updater 服务层显式兜底 "0") + `creator` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '创建者(审计列)', + `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updater` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '更新者', + `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + `deleted` BIT(1) NOT NULL DEFAULT b'0' COMMENT '逻辑删除:0未删 1已删', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_idempotent_key` (`idempotent_key`, `deleted`) COMMENT '幂等键唯一(防重复写 outbox)', + KEY `idx_status` (`status`) COMMENT '投递器按未投递态扫描(status=0)' +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '注册成功事件本地消息表(事务性 outbox,至少一次投递生产者,WU1 §3.6)'; diff --git a/contracts/db-schemas/V31.0.1__passport_player_add_username_password_rollback.sql b/contracts/db-schemas/V31.0.1__passport_player_add_username_password_rollback.sql new file mode 100644 index 00000000..c9a685b1 --- /dev/null +++ b/contracts/db-schemas/V31.0.1__passport_player_add_username_password_rollback.sql @@ -0,0 +1,24 @@ +-- ============================================================================= +-- 回滚补偿迁移 | 主题:撤销 V31.0.0 用户名+密码注册登录 schema(WU1) +-- 文件:V31.0.1__passport_player_add_username_password_rollback.sql +-- ⚠ 这是「手动补偿」脚本,**只放契约源 contracts/db-schemas/,绝不放入执行副本 db/migration/**: +-- Flyway 会自动应用 db/migration/ 下的所有版本,若把本回滚脚本放进去,migrate 时会立刻把刚加的列/表 DROP 掉。 +-- 需要回滚时由 DBA 手动执行本脚本(并按下述前置确认),执行后同步删除 db/migration/ 的 V31.0.0 与 flyway_schema_history 对应行。 +-- 与 V11 对 player_user_id 放宽的回滚纪律同口径:mobile 收回 NOT NULL 前必须先确认无 NULL 行。 +-- ============================================================================= + +-- 前置确认①(收回 mobile NOT NULL 的硬前置):确认无「无手机号」的用户名用户,否则收回 NOT NULL 会失败。 +-- SELECT COUNT(*) FROM `game_player` WHERE `mobile` IS NULL; -- 必须为 0,否则先清理/迁移这些用户名用户 +-- 前置确认②:确认 outbox 无未投递残留(status=0),或接受丢弃这些未投递事件。 +-- SELECT COUNT(*) FROM `game_passport_register_outbox` WHERE `status` = 0 AND `deleted` = b'0'; + +-- 撤销段 1:drop 唯一键与两列,mobile 收回 NOT NULL(前置确认①通过后执行) +ALTER TABLE `game_player` + DROP INDEX `uk_username`, + DROP COLUMN `password`, + DROP COLUMN `username`, + MODIFY COLUMN `mobile` VARCHAR(11) NOT NULL COMMENT '手机号(登录主键;日志/响应必须脱敏,库内 MVP 明文+唯一键)'; +-- register_channel 的 COMMENT 扩了 password 值域,属纯注释放宽、无需强制回退(存量数据不含该值时收回亦可,按需)。 + +-- 撤销段 2:drop outbox 机制表 +DROP TABLE IF EXISTS `game_passport_register_outbox`; diff --git a/game-cloud/huijing-module-system/huijing-module-system-api/src/main/java/com/wanxiang/huijing/module/system/enums/ErrorCodeConstants.java b/game-cloud/huijing-module-system/huijing-module-system-api/src/main/java/com/wanxiang/huijing/module/system/enums/ErrorCodeConstants.java index ca27d9e7..080aa522 100644 --- a/game-cloud/huijing-module-system/huijing-module-system-api/src/main/java/com/wanxiang/huijing/module/system/enums/ErrorCodeConstants.java +++ b/game-cloud/huijing-module-system/huijing-module-system-api/src/main/java/com/wanxiang/huijing/module/system/enums/ErrorCodeConstants.java @@ -180,6 +180,10 @@ public interface ErrorCodeConstants { ErrorCode PLAYER_NOT_EXISTS = new ErrorCode(1_002_090_001, "玩家不存在"); ErrorCode PLAYER_IS_DISABLED = new ErrorCode(1_002_090_002, "该账号已被禁用"); ErrorCode PLAYER_CREATE_NOT_IN_WHITELIST = new ErrorCode(1_002_090_003, "创作功能限内测白名单,当前账号暂无创作权限"); // 拍板4:creator_flag 白名单校验未过(PlayerApi#validateCreator) + // ========== 内测用户名密码注册登录(2026-07-07 WU1 §3.2)========== + ErrorCode PLAYER_USERNAME_ALREADY_REGISTERED = new ErrorCode(1_002_090_004, "用户名已被占用"); // password-register 用户名撞 uk_username(注册态明确报占用,不静默转登录) + ErrorCode PLAYER_USERNAME_OR_PASSWORD_ERROR = new ErrorCode(1_002_090_005, "用户名或密码错误"); // password-login 防枚举:查无用户名与密码错误统一此码(配合固定假 hash 对齐耗时,堵时序侧信道) + ErrorCode PASSWORD_AUTH_DISABLED = new ErrorCode(1_002_090_006, "用户名密码登录通道未开启"); // wanxiang.passport.password-auth-enabled=false 时两端点拒服务(信任边界=后端服务层) // ========== passport 邀请码 1-002-091-000 ========== ErrorCode INVITE_CODE_NOT_EXISTS = new ErrorCode(1_002_091_000, "邀请码无效"); // 码不存在(与已停用/已过期/已耗尽区分,便于运营定位) diff --git a/game-cloud/huijing-module-system/huijing-module-system-server/pom.xml b/game-cloud/huijing-module-system/huijing-module-system-server/pom.xml index f6da8ceb..6995339a 100644 --- a/game-cloud/huijing-module-system/huijing-module-system-server/pom.xml +++ b/game-cloud/huijing-module-system/huijing-module-system-server/pom.xml @@ -97,6 +97,13 @@ huijing-spring-boot-starter-mq + + + org.apache.rocketmq + rocketmq-spring-boot-starter + + com.wanxiang diff --git a/game-cloud/huijing-module-system/huijing-module-system-server/src/main/java/com/wanxiang/huijing/module/system/controller/app/passport/vo/PasswordLoginReqVO.java b/game-cloud/huijing-module-system/huijing-module-system-server/src/main/java/com/wanxiang/huijing/module/system/controller/app/passport/vo/PasswordLoginReqVO.java new file mode 100644 index 00000000..5489e3ba --- /dev/null +++ b/game-cloud/huijing-module-system/huijing-module-system-server/src/main/java/com/wanxiang/huijing/module/system/controller/app/passport/vo/PasswordLoginReqVO.java @@ -0,0 +1,34 @@ +package com.wanxiang.huijing.module.system.controller.app.passport.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import lombok.Data; + +/** + * 用户名密码登录 Request VO(内测种子用户,2026-07-07 WU1 §3.2) + * + * 防枚举:查无用户名与密码错误统一返 PLAYER_USERNAME_OR_PASSWORD_ERROR,且查无用户名时也跑固定假 hash 对齐耗时。 + * password 仅 @NotEmpty,登录不重复长度校验(避免给出密码策略线索)。 + * + * @author 绘境AI + */ +@Schema(description = "产品端 - 用户名密码登录 Request VO") +@Data +public class PasswordLoginReqVO { + + @Schema(description = "用户名(登录主键之一)", requiredMode = Schema.RequiredMode.REQUIRED, example = "seed_alice") + @NotEmpty(message = "用户名不能为空") + @Pattern(regexp = "^[a-zA-Z0-9]{4,30}$", message = "用户名格式为数字以及字母") + private String username; + + @Schema(description = "密码(明文)", requiredMode = Schema.RequiredMode.REQUIRED, example = "buzhidao") + @NotEmpty(message = "密码不能为空") + private String password; + + @Schema(description = "客户端 anonId(可选;登录不落库,仅遥测归因透传)", example = "anon-xyz") + @Size(max = 64, message = "anonId 不能超过 64 个字符") + private String anonId; + +} diff --git a/game-cloud/huijing-module-system/huijing-module-system-server/src/main/java/com/wanxiang/huijing/module/system/controller/app/passport/vo/PasswordRegisterReqVO.java b/game-cloud/huijing-module-system/huijing-module-system-server/src/main/java/com/wanxiang/huijing/module/system/controller/app/passport/vo/PasswordRegisterReqVO.java new file mode 100644 index 00000000..d977dc40 --- /dev/null +++ b/game-cloud/huijing-module-system/huijing-module-system-server/src/main/java/com/wanxiang/huijing/module/system/controller/app/passport/vo/PasswordRegisterReqVO.java @@ -0,0 +1,42 @@ +package com.wanxiang.huijing.module.system.controller.app.passport.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import lombok.Data; +import org.hibernate.validator.constraints.Length; + +/** + * 用户名密码注册 Request VO(内测种子用户,2026-07-07 WU1 §3.2) + * + * 用户名沿用 admin AuthLoginReqVO 范式(^[a-zA-Z0-9]{4,30}$);密码明文由服务层立即 BCrypt 编码, + * 下限收紧至 6(本端点直面公网,威胁模型比 admin 后台建号更严,生产稳定优先)。 + * ⚠ password 为明文入参,服务层编码后落库,绝不回显、绝不落日志(端点已代码级绕过 stdout body 打印)。 + * + * @author 绘境AI + */ +@Schema(description = "产品端 - 用户名密码注册 Request VO") +@Data +public class PasswordRegisterReqVO { + + @Schema(description = "用户名(登录主键之一)", requiredMode = Schema.RequiredMode.REQUIRED, example = "seed_alice") + @NotEmpty(message = "用户名不能为空") + @Length(min = 4, max = 30, message = "用户名长度为 4-30 位") + @Pattern(regexp = "^[a-zA-Z0-9]{4,30}$", message = "用户名格式为数字以及字母") + private String username; + + @Schema(description = "密码(明文,服务层立即 BCrypt 编码)", requiredMode = Schema.RequiredMode.REQUIRED, example = "buzhidao") + @NotEmpty(message = "密码不能为空") + @Length(min = 6, max = 32, message = "密码长度为 6-32 位") + private String password; + + @Schema(description = "昵称(可选;不传则默认取用户名)", example = "新玩家") + @Size(max = 30, message = "昵称不能超过 30 个字符") + private String nickname; + + @Schema(description = "客户端 anonId(可选;同 sms-login 口径,仅注册落 first_anon_id 一次,匿名↔登录归因)", example = "anon-xyz") + @Size(max = 64, message = "anonId 不能超过 64 个字符") + private String anonId; + +} diff --git a/game-cloud/huijing-module-system/huijing-module-system-server/src/main/java/com/wanxiang/huijing/module/system/controller/app/passport/vo/PlayerMeRespVO.java b/game-cloud/huijing-module-system/huijing-module-system-server/src/main/java/com/wanxiang/huijing/module/system/controller/app/passport/vo/PlayerMeRespVO.java index 410d6533..39312323 100644 --- a/game-cloud/huijing-module-system/huijing-module-system-server/src/main/java/com/wanxiang/huijing/module/system/controller/app/passport/vo/PlayerMeRespVO.java +++ b/game-cloud/huijing-module-system/huijing-module-system-server/src/main/java/com/wanxiang/huijing/module/system/controller/app/passport/vo/PlayerMeRespVO.java @@ -17,9 +17,12 @@ public class PlayerMeRespVO { @Schema(description = "玩家编号", example = "1024") private Long userId; - @Schema(description = "脱敏手机号(如 138****0000;安全基线,禁回明文)", example = "138****0000") + @Schema(description = "脱敏手机号(如 138****0000;安全基线,禁回明文;用户名用户 mobile 为 null 时回空串)", example = "138****0000") private String mobile; + @Schema(description = "用户名(用户名密码路身份展示,非敏感明文;手机号用户为空,2026-07-07 WU1)", example = "seed_alice") + private String username; + @Schema(description = "昵称", example = "新玩家") private String nickname; diff --git a/game-cloud/huijing-module-system/huijing-module-system-server/src/main/java/com/wanxiang/huijing/module/system/dal/dataobject/passport/PassportRegisterOutboxDO.java b/game-cloud/huijing-module-system/huijing-module-system-server/src/main/java/com/wanxiang/huijing/module/system/dal/dataobject/passport/PassportRegisterOutboxDO.java new file mode 100644 index 00000000..21019ced --- /dev/null +++ b/game-cloud/huijing-module-system/huijing-module-system-server/src/main/java/com/wanxiang/huijing/module/system/dal/dataobject/passport/PassportRegisterOutboxDO.java @@ -0,0 +1,65 @@ +package com.wanxiang.huijing.module.system.dal.dataobject.passport; + +import com.wanxiang.huijing.framework.mybatis.core.dataobject.BaseDO; +import com.wanxiang.huijing.framework.tenant.core.aop.TenantIgnore; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.time.LocalDateTime; + +/** + * 「注册成功」事件本地消息表 DO(事务性 outbox,2026-07-07 WU1 §3.6) + * + * 对应表 game_passport_register_outbox。三条注册路(sms 自动注册 / invite / password)共用出口: + * 注册事务内与建号原子写一行(status=0 未投递);事务外投递器(@Scheduled)扫未投递发 RocketMQ、 + * 发成功置 status=1。把「至少一次」锚在 DB 事务上,杜绝「注册成功但事件从未入队」黑洞。 + *

+ * 多租户:本表为机制表,标 {@link TenantIgnore}(表不含 tenant_id 列)——投递器 @Scheduled 无租户上下文, + * 若走租户拦截器会因 getRequiredTenantId() 抛错;忽略后可全表扫未投递行。 + * + * @author 绘境AI + */ +@TableName("game_passport_register_outbox") +@TenantIgnore // 机制表,@Scheduled 投递器无租户上下文,忽略多租户过滤(表无 tenant_id 列) +@Data +@EqualsAndHashCode(callSuper = true) +public class PassportRegisterOutboxDO extends BaseDO { + + /** 投递状态:未投递(待投递器扫描发 MQ)。 */ + public static final int STATUS_PENDING = 0; + /** 投递状态:已投递(投递器发 MQ 成功后置位)。 */ + public static final int STATUS_DELIVERED = 1; + + /** + * 编号 + */ + @TableId + private Long id; + /** + * 新注册玩家编号(game_player.id;WU2 开户充值幂等键) + */ + private Long userId; + /** + * 注册通道:sms / invite / password(消费端口径统一在此,不区分登录方式) + */ + private String registerChannel; + /** + * 注册时携带的客户端 anonId(匿名↔登录归因;缺失落空串) + */ + private String anonId; + /** + * 幂等键(=register:{userId},uk_idempotent_key 保证每次注册至多一行 outbox) + */ + private String idempotentKey; + /** + * 投递状态:0未投递 1已投递 + */ + private Integer status; + /** + * 投递成功时间(status=1 时回填,审计) + */ + private LocalDateTime deliveredTime; + +} diff --git a/game-cloud/huijing-module-system/huijing-module-system-server/src/main/java/com/wanxiang/huijing/module/system/dal/dataobject/passport/PlayerDO.java b/game-cloud/huijing-module-system/huijing-module-system-server/src/main/java/com/wanxiang/huijing/module/system/dal/dataobject/passport/PlayerDO.java index 0bf2cac8..03e3714a 100644 --- a/game-cloud/huijing-module-system/huijing-module-system-server/src/main/java/com/wanxiang/huijing/module/system/dal/dataobject/passport/PlayerDO.java +++ b/game-cloud/huijing-module-system/huijing-module-system-server/src/main/java/com/wanxiang/huijing/module/system/dal/dataobject/passport/PlayerDO.java @@ -31,9 +31,17 @@ public class PlayerDO extends TenantBaseDO { @TableId private Long id; /** - * 手机号(登录主键;日志/响应必须脱敏,库内 MVP 明文 + uk_mobile 唯一键兜底并发重复注册) + * 手机号(登录主键之一;2026-07-07 WU1 放宽可空,承载无手机号的用户名用户;日志/响应必须脱敏,uk_mobile 兜底并发重复注册) */ private String mobile; + /** + * 用户名(登录主键之一,内测用户名密码路;^[a-zA-Z0-9]{4,30}$,uk_username 唯一;手机号用户为 NULL,2026-07-07 WU1) + */ + private String username; + /** + * 密码 BCrypt 密文(⚠ 仅服务层内部用于编码/校验,永不进任何 RespVO、永不落日志;非用户名用户为 NULL,2026-07-07 WU1) + */ + private String password; /** * 昵称(注册默认生成,可改) */ diff --git a/game-cloud/huijing-module-system/huijing-module-system-server/src/main/java/com/wanxiang/huijing/module/system/dal/mysql/passport/PassportRegisterOutboxMapper.java b/game-cloud/huijing-module-system/huijing-module-system-server/src/main/java/com/wanxiang/huijing/module/system/dal/mysql/passport/PassportRegisterOutboxMapper.java new file mode 100644 index 00000000..dd48fe18 --- /dev/null +++ b/game-cloud/huijing-module-system/huijing-module-system-server/src/main/java/com/wanxiang/huijing/module/system/dal/mysql/passport/PassportRegisterOutboxMapper.java @@ -0,0 +1,53 @@ +package com.wanxiang.huijing.module.system.dal.mysql.passport; + +import com.wanxiang.huijing.framework.mybatis.core.mapper.BaseMapperX; +import com.wanxiang.huijing.module.system.dal.dataobject.passport.PassportRegisterOutboxDO; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * 「注册成功」事件本地消息表 Mapper(2026-07-07 WU1 §3.6) + * + * 投递器(@Scheduled)按未投递态分页扫描发 MQ;发成功用条件更新 CAS 置已投递(幂等,避免重复 MQ 与并发双投)。 + * + * @author 绘境AI + */ +@Mapper +public interface PassportRegisterOutboxMapper extends BaseMapperX { + + /** + * 扫描未投递事件(status=0),按 id 升序取前 limit 条(投递器批量投递用) + * + * @param limit 单批上限(有界扫描,避免一次拉全表) + * @return 未投递 outbox 列表 + */ + default List selectPendingList(int limit) { + LambdaQueryWrapper query = new LambdaQueryWrapper() + .eq(PassportRegisterOutboxDO::getStatus, PassportRegisterOutboxDO.STATUS_PENDING) + .orderByAsc(PassportRegisterOutboxDO::getId) + .last("LIMIT " + limit); + return selectList(query); + } + + /** + * 条件更新置已投递(CAS:仅当当前仍为未投递态才更新,返回 1 表示本次投递赢得该行;0 表示已被其他投递器/重入抢先) + * + * @param id outbox 行编号 + * @param deliveredTime 投递成功时间 + * @return 实际更新行数(1=本次置位成功;0=已被抢先,避免重复 MQ) + */ + default int markDelivered(@Param("id") Long id, LocalDateTime deliveredTime) { + LambdaUpdateWrapper update = new LambdaUpdateWrapper() + .set(PassportRegisterOutboxDO::getStatus, PassportRegisterOutboxDO.STATUS_DELIVERED) + .set(PassportRegisterOutboxDO::getDeliveredTime, deliveredTime) + .eq(PassportRegisterOutboxDO::getId, id) + .eq(PassportRegisterOutboxDO::getStatus, PassportRegisterOutboxDO.STATUS_PENDING); + return update(null, update); + } + +} diff --git a/game-cloud/huijing-module-system/huijing-module-system-server/src/main/java/com/wanxiang/huijing/module/system/dal/mysql/passport/PlayerMapper.java b/game-cloud/huijing-module-system/huijing-module-system-server/src/main/java/com/wanxiang/huijing/module/system/dal/mysql/passport/PlayerMapper.java index 5794afb1..8a40603b 100644 --- a/game-cloud/huijing-module-system/huijing-module-system-server/src/main/java/com/wanxiang/huijing/module/system/dal/mysql/passport/PlayerMapper.java +++ b/game-cloud/huijing-module-system/huijing-module-system-server/src/main/java/com/wanxiang/huijing/module/system/dal/mysql/passport/PlayerMapper.java @@ -29,6 +29,16 @@ public interface PlayerMapper extends BaseMapperX { return selectOne(PlayerDO::getMobile, mobile); } + /** + * 按用户名查玩家(命中 uk_username;内测用户名密码路登录主键查询,2026-07-07 WU1) + * + * @param username 用户名 + * @return 玩家 DO;不存在返回 null + */ + default PlayerDO selectByUsername(String username) { + return selectOne(PlayerDO::getUsername, username); + } + /** * 创作者白名单置位(拍板4 A2 名单运营动作):按 userId 更新 creator_flag * diff --git a/game-cloud/huijing-server/src/main/resources/db/migration/V31.0.0__passport_player_add_username_password.sql b/game-cloud/huijing-server/src/main/resources/db/migration/V31.0.0__passport_player_add_username_password.sql new file mode 100644 index 00000000..985f7cb7 --- /dev/null +++ b/game-cloud/huijing-server/src/main/resources/db/migration/V31.0.0__passport_player_add_username_password.sql @@ -0,0 +1,52 @@ +-- ============================================================================= +-- 契约 #2 DB 迁移 | 主题:内测·用户名+密码注册登录(WU1,2026-07-07 设计 §3.1/§3.6)| owner:passport(system 内扩展) +-- 文件:V31.0.0__passport_player_add_username_password.sql(Flyway,只 ALTER/新增;已合入禁止修改,回滚见补偿迁移 V31.0.1) +-- 依据:docs/agent-specs/2026-07-07-内测-WU1-注册登录用户名密码-设计.md(草案,创始人内测口径拍定)。 +-- 版本定序:执行副本 db/migration/ 实测已连续到 V30.0.0(含 V26 aigc idempotency),本件取其上下一号 V31.0.0(已 ls|sort -V|tail 复核)。 +-- 守门①(沿用 V11):本件同时落 contracts/db-schemas/(契约源)+ huijing-server 执行副本(唯一执行 classpath), +-- 不放任何单模块 -server/db/migration/(避免同版本 V31 出现在多个 classpath jar 触发 Flyway 重复校验失败)。 +-- 守门②:含中文 SQL,mini-desktop 执行必须 --default-character-set=utf8mb4。 +-- 内容两段: +-- 1. ALTER game_player —— 加 username/password 两列、放宽 mobile 可空、加 uk_username、register_channel 值域扩 password; +-- 2. CREATE game_passport_register_outbox —— 「注册成功」事件本地消息表(三条注册路共用出口,至少一次投递生产者,§3.6)。 +-- ============================================================================= + +-- ----------------------------------------------------------------------------- +-- 段 1:ALTER game_player —— 单表双身份路径共存(手机号 / 用户名 二选一,§3.1) +-- 关键:mobile 由 NOT NULL 放宽为 NULL(承载无手机号的用户名用户);uk_mobile 对 NULL 行不施约束, +-- uk_username 对 NULL 行(手机号用户)不施约束,两条唯一键在同表互不干扰。 +-- 存量兼容:存量行 mobile 均有值、username/password 取新列默认 NULL,天然落「手机号路径」侧;放宽为松约束不破坏任何存量行。 +-- password 列:BCrypt 密文(约 60 字符,留至 100 兼容算法前缀/未来迁移);⚠ 永不返回任何 RespVO、永不落日志。 +-- ----------------------------------------------------------------------------- +ALTER TABLE `game_player` + ADD COLUMN `username` VARCHAR(30) NULL COMMENT '用户名(内测用户名密码路登录主键之一,^[a-zA-Z0-9]{4,30}$;手机号用户为 NULL)' AFTER `mobile`, + ADD COLUMN `password` VARCHAR(100) NULL COMMENT 'BCrypt 密文(用户名路 60 字符左右,留 100 兼容算法前缀;⚠ 永不返回、永不落日志;非用户名用户为 NULL)' AFTER `username`, + MODIFY COLUMN `mobile` VARCHAR(11) NULL COMMENT '手机号(登录主键之一;放宽可空承载无手机号的用户名用户,2026-07-07 WU1;日志/响应必须脱敏)', + MODIFY COLUMN `register_channel` VARCHAR(16) NOT NULL DEFAULT 'sms' COMMENT '注册通道:sms验证码 / invite邀请码旁路 / password用户名密码(漏斗归因,2026-07-07 WU1 扩 password)', + ADD UNIQUE KEY `uk_username` (`username`, `deleted`, `tenant_id`) COMMENT '用户名唯一(含 deleted/tenant_id 适配逻辑删+多租户,同 uk_mobile 范式;NULL 行不参与约束,手机号用户不受限)'; + +-- ----------------------------------------------------------------------------- +-- 段 2:CREATE game_passport_register_outbox —— 「注册成功」事件本地消息表(事务性 outbox,§3.6) +-- 语义:注册事务内与建号原子写一行(status=0 未投递);事务外投递器(@Scheduled)扫未投递发 RocketMQ、 +-- 发成功置 status=1 已投递。把「至少一次」锚在 DB 事务上而非 MQ 即时可达性上,杜绝「注册成功但事件从未入队」黑洞。 +-- 三条注册路(sms 自动注册 / invite / password)共用此出口,WU2 new-api 开户充值消费端据 user_id 幂等(至少一次必带重复投递)。 +-- 多租户:本表为机制表,DO 标 @TenantIgnore(不含 tenant_id 列),投递器 @Scheduled 无租户上下文可全表扫。 +-- ----------------------------------------------------------------------------- +CREATE TABLE `game_passport_register_outbox` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '编号', + `user_id` BIGINT NOT NULL COMMENT '新注册玩家编号(game_player.id;WU2 开户充值幂等键)', + `register_channel` VARCHAR(16) NOT NULL COMMENT '注册通道:sms / invite / password(消费端口径统一在此,不区分登录方式)', + `anon_id` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '注册时携带的客户端 anonId(匿名↔登录归因;缺失落空串)', + `idempotent_key` VARCHAR(64) NOT NULL COMMENT '幂等键(=register:{userId},uk 保证每次注册至多一行 outbox)', + `status` TINYINT NOT NULL DEFAULT 0 COMMENT '投递状态:0未投递 1已投递(投递器发 MQ 成功后置 1)', + `delivered_time` DATETIME NULL COMMENT '投递成功时间(status=1 时回填,审计)', + -- 标准审计列(与 game_player 同范式;@PermitAll 免登注册无登录态,creator/updater 服务层显式兜底 "0") + `creator` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '创建者(审计列)', + `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updater` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '更新者', + `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + `deleted` BIT(1) NOT NULL DEFAULT b'0' COMMENT '逻辑删除:0未删 1已删', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_idempotent_key` (`idempotent_key`, `deleted`) COMMENT '幂等键唯一(防重复写 outbox)', + KEY `idx_status` (`status`) COMMENT '投递器按未投递态扫描(status=0)' +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '注册成功事件本地消息表(事务性 outbox,至少一次投递生产者,WU1 §3.6)'; From d52696c5890c5d78109e4c6fe0211b3abb47b5b8 Mon Sep 17 00:00:00 2001 From: lili Date: Tue, 7 Jul 2026 11:52:23 -0700 Subject: [PATCH 02/10] =?UTF-8?q?feat(passport):=20WU1=20=E6=9C=8D?= =?UTF-8?q?=E5=8A=A1=E5=B1=82=E2=80=94=E2=80=94=E6=B3=A8=E5=86=8C=E6=89=A9?= =?UTF-8?q?=E5=8F=82/BCrypt/=E9=98=B2=E6=9E=9A=E4=B8=BE/IP=20=E6=B3=9B?= =?UTF-8?q?=E5=8C=96/outbox=20=E7=94=9F=E4=BA=A7=E8=80=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 照 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) --- .../PassportSmsIpCounterRedisDAO.java | 91 +++++-- .../passport/PassportRegisterMessage.java | 35 +++ .../PassportRegisterOutboxDeliverer.java | 122 +++++++++ .../service/passport/PassportService.java | 29 ++ .../service/passport/PassportServiceImpl.java | 255 +++++++++++++++--- 5 files changed, 470 insertions(+), 62 deletions(-) create mode 100644 game-cloud/huijing-module-system/huijing-module-system-server/src/main/java/com/wanxiang/huijing/module/system/mq/message/passport/PassportRegisterMessage.java create mode 100644 game-cloud/huijing-module-system/huijing-module-system-server/src/main/java/com/wanxiang/huijing/module/system/mq/producer/passport/PassportRegisterOutboxDeliverer.java 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()); From 28bd5339d4b19e9312742aff16aea16f3a7157be Mon Sep 17 00:00:00 2001 From: lili Date: Tue, 7 Jul 2026 11:52:40 -0700 Subject: [PATCH 03/10] =?UTF-8?q?feat(passport):=20WU1=20=E7=AB=AF?= =?UTF-8?q?=E7=82=B9+=E9=85=8D=E7=BD=AE+=E9=98=B2=E6=98=8E=E6=96=87+?= =?UTF-8?q?=E5=8D=95=E6=B5=8B=E2=80=94=E2=80=94=E4=B8=A4=20@PermitAll=20?= =?UTF-8?q?=E7=AB=AF=E7=82=B9/remoteAddr/stdout=20=E5=B1=8F=E8=94=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 照 WU1 设计 §3.2/§3.3/§3.4: - AppPassportController 加 password-register/password-login 两 @PermitAll 端点,IP 一律取连接层 request.getRemoteAddr()(不读可伪造 XFF,§3.3 命门);@ApiAccessLog(sanitizeKeys=password)。 - ApiAccessLogInterceptor 代码级屏蔽两 password URI 的 stdout body 打印(不受 sanitizeKeys 约束的第二条 链路,防明文密码进控制台,不只靠 logging.level)。 - application-staging.yaml wanxiang.passport 加 password-auth-enabled(true)/register-ip-limit(20,60)/ login-ip-limit(30,100)。 - 单测:本地 mvn 34 通过(PassportServiceImplTest 31 + PassportSmsIpCounterRedisDAOTest 3),覆盖用户名占用、 登录防枚举(统一错误码+假 hash 对齐耗时)、禁用拒登、并发撞名兜底、IP 越限 429、Redis fail-open、outbox 原子落、 /me 空安全。 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../interceptor/ApiAccessLogInterceptor.java | 35 ++- .../app/passport/AppPassportController.java | 26 ++ .../PassportSmsIpCounterRedisDAOTest.java | 73 +++++ .../passport/PassportServiceImplTest.java | 252 +++++++++++++++++- .../main/resources/application-staging.yaml | 6 + 5 files changed, 379 insertions(+), 13 deletions(-) create mode 100644 game-cloud/huijing-module-system/huijing-module-system-server/src/test/java/com/wanxiang/huijing/module/system/dal/redis/passport/PassportSmsIpCounterRedisDAOTest.java diff --git a/game-cloud/huijing-framework/huijing-spring-boot-starter-web/src/main/java/com/wanxiang/huijing/framework/apilog/core/interceptor/ApiAccessLogInterceptor.java b/game-cloud/huijing-framework/huijing-spring-boot-starter-web/src/main/java/com/wanxiang/huijing/framework/apilog/core/interceptor/ApiAccessLogInterceptor.java index 89784251..0a68ea35 100644 --- a/game-cloud/huijing-framework/huijing-spring-boot-starter-web/src/main/java/com/wanxiang/huijing/framework/apilog/core/interceptor/ApiAccessLogInterceptor.java +++ b/game-cloud/huijing-framework/huijing-spring-boot-starter-web/src/main/java/com/wanxiang/huijing/framework/apilog/core/interceptor/ApiAccessLogInterceptor.java @@ -33,6 +33,18 @@ public class ApiAccessLogInterceptor implements HandlerInterceptor { private static final String ATTRIBUTE_STOP_WATCH = "ApiAccessLogInterceptor.StopWatch"; + /** + * 敏感 URI 后缀白名单(2026-07-07 WU1 §3.2):命中的请求体不打印到 stdout/控制台。 + *

+ * 背景:本 Interceptor 在非 prod 环境用 log.info 把**原始 requestBody** 直接打到控制台,不受 {@code @ApiAccessLog(sanitizeKeys)} + * 约束(那条只脱敏持久化日志 system_api_access_log)。密码是可跨站复用的持久凭据,任何环境抬高日志级别或聚合 stdout + * 即明文外泄——不能只靠 staging 的 logging.level 兜。故对这两个 passport 密码端点做**代码级**保证:不读、不打印其 body。 + */ + private static final String[] SENSITIVE_BODY_URI_SUFFIXES = { + "/passport/password-register", + "/passport/password-login" + }; + @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { // 记录 HandlerMethod,提供给 ApiAccessLogFilter 使用 @@ -44,8 +56,12 @@ public class ApiAccessLogInterceptor implements HandlerInterceptor { // 打印 request 日志 if (!SpringUtils.isProd()) { Map queryString = ServletUtils.getParamMap(request); - String requestBody = ServletUtils.getBody(request); - if (CollUtil.isEmpty(queryString) && StrUtil.isEmpty(requestBody)) { + // WU1 §3.2:敏感 URI(密码端点)不读、不打印请求体,代码级杜绝明文密码进 stdout(不受 sanitizeKeys 约束的第二条链路) + boolean sensitiveBody = isSensitiveBodyUri(request.getRequestURI()); + String requestBody = sensitiveBody ? null : ServletUtils.getBody(request); + if (sensitiveBody) { + log.info("[preHandle][开始请求 URL({}) 参数(已屏蔽敏感请求体)]", request.getRequestURI()); + } else if (CollUtil.isEmpty(queryString) && StrUtil.isEmpty(requestBody)) { log.info("[preHandle][开始请求 URL({}) 无参数]", request.getRequestURI()); } else { log.info("[preHandle][开始请求 URL({}) 参数({})]", request.getRequestURI(), @@ -72,6 +88,21 @@ public class ApiAccessLogInterceptor implements HandlerInterceptor { } } + /** + * 判断请求 URI 是否属于敏感请求体白名单(命中则不打印 body,见 {@link #SENSITIVE_BODY_URI_SUFFIXES}) + */ + private boolean isSensitiveBodyUri(String uri) { + if (StrUtil.isEmpty(uri)) { + return false; + } + for (String suffix : SENSITIVE_BODY_URI_SUFFIXES) { + if (uri.endsWith(suffix)) { + return true; + } + } + return false; + } + /** * 打印 Controller 方法路径 */ diff --git a/game-cloud/huijing-module-system/huijing-module-system-server/src/main/java/com/wanxiang/huijing/module/system/controller/app/passport/AppPassportController.java b/game-cloud/huijing-module-system/huijing-module-system-server/src/main/java/com/wanxiang/huijing/module/system/controller/app/passport/AppPassportController.java index 5b8b5746..7803ba85 100644 --- a/game-cloud/huijing-module-system/huijing-module-system-server/src/main/java/com/wanxiang/huijing/module/system/controller/app/passport/AppPassportController.java +++ b/game-cloud/huijing-module-system/huijing-module-system-server/src/main/java/com/wanxiang/huijing/module/system/controller/app/passport/AppPassportController.java @@ -8,6 +8,8 @@ import com.wanxiang.huijing.framework.ratelimiter.core.keyresolver.impl.ClientIp import com.wanxiang.huijing.framework.security.core.util.SecurityFrameworkUtils; 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.SendSmsCodeReqVO; import com.wanxiang.huijing.module.system.controller.app.passport.vo.SmsLoginReqVO; @@ -16,6 +18,7 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.annotation.Resource; import jakarta.annotation.security.PermitAll; +import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; import java.util.concurrent.TimeUnit; import org.springframework.validation.annotation.Validated; @@ -80,6 +83,29 @@ public class AppPassportController { return success(passportService.inviteRegister(reqVO, ServletUtils.getClientIP())); } + @PostMapping("/password-register") + @PermitAll // 免登:内测种子用户用户名密码注册(受总开关 wanxiang.passport.password-auth-enabled 管控) + // WU1 §3.2 安全实证:脱敏持久化访问日志的 password 键(禁明文落 system_api_access_log); + // ⚠ 控制台 stdout 明文由 ApiAccessLogInterceptor 代码级屏蔽本 URI 的 body 打印(不只靠 logging.level,§3.2)。 + @ApiAccessLog(sanitizeKeys = {"password"}) + @Operation(summary = "用户名密码注册", description = "内测种子用户;register_channel=password,返回体与短信路 token 一字不差") + public CommonResult passwordRegister(@Valid @RequestBody PasswordRegisterReqVO reqVO, + HttpServletRequest request) { + // WU1 §3.3 命门:IP 一律采信连接层 getRemoteAddr(),不读客户端可伪造的 XFF(不走 ServletUtils.getClientIP) + return success(passportService.passwordRegister(reqVO, request.getRemoteAddr())); + } + + @PostMapping("/password-login") + @PermitAll // 免登:内测种子用户用户名密码登录 + // WU1 §3.2 安全实证:同 password-register,脱敏持久化日志 password 键 + 代码级屏蔽 stdout body 打印。 + @ApiAccessLog(sanitizeKeys = {"password"}) + @Operation(summary = "用户名密码登录", description = "防枚举:查无用户名与密码错误统一错误码 + 假 hash 对齐耗时") + public CommonResult passwordLogin(@Valid @RequestBody PasswordLoginReqVO reqVO, + HttpServletRequest request) { + // WU1 §3.3 命门:取连接层 getRemoteAddr(),不采信 XFF(伪造 XFF 换桶绕过的堵漏) + return success(passportService.passwordLogin(reqVO, request.getRemoteAddr())); + } + @GetMapping("/me") @Operation(summary = "当前登录玩家信息", description = "登录态必需(无 @PermitAll,未登录 401);手机号脱敏返回") public CommonResult getPlayerMe() { diff --git a/game-cloud/huijing-module-system/huijing-module-system-server/src/test/java/com/wanxiang/huijing/module/system/dal/redis/passport/PassportSmsIpCounterRedisDAOTest.java b/game-cloud/huijing-module-system/huijing-module-system-server/src/test/java/com/wanxiang/huijing/module/system/dal/redis/passport/PassportSmsIpCounterRedisDAOTest.java new file mode 100644 index 00000000..e0f551f6 --- /dev/null +++ b/game-cloud/huijing-module-system/huijing-module-system-server/src/test/java/com/wanxiang/huijing/module/system/dal/redis/passport/PassportSmsIpCounterRedisDAOTest.java @@ -0,0 +1,73 @@ +package com.wanxiang.huijing.module.system.dal.redis.passport; + +import com.wanxiang.huijing.framework.test.core.ut.BaseMockitoUnitTest; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.springframework.data.redis.RedisConnectionFailureException; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.ValueOperations; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.when; + +/** + * {@link PassportSmsIpCounterRedisDAO} 单元测试(纯 Mockito,不依赖真实 Redis) + * + * 重点验证 §3.3 定死的「频控后端故障 fail-open」姿态:Redis 抖动/超时时不外抛、返回 0(放行), + * 绝不让 Redis 单点静默拖垮认证;正常路径返回自增值并对首次计数设 TTL。 + * + * @author 绘境AI + */ +class PassportSmsIpCounterRedisDAOTest extends BaseMockitoUnitTest { + + @InjectMocks + private PassportSmsIpCounterRedisDAO counterDAO; + + @Mock + private StringRedisTemplate stringRedisTemplate; + @Mock + private ValueOperations valueOperations; + + @Test + @DisplayName("小时计数:Redis 异常 → fail-open 返回 0(放行,不外抛)") + void testIncrHourAndGet_redisFailOpen() { + when(stringRedisTemplate.opsForValue()).thenReturn(valueOperations); + // 模拟 Redis 抖动/超时:increment 抛连接异常 + when(valueOperations.increment(anyString())) + .thenThrow(new RedisConnectionFailureException("redis down")); + + long[] result = new long[1]; + assertDoesNotThrow(() -> result[0] = counterDAO.incrHourAndGet( + PassportSmsIpCounterRedisDAO.SCENE_LOGIN, "1.2.3.4")); + // fail-open:返回 0,服务层据此视为未超限放行 + assertEquals(0L, result[0]); + } + + @Test + @DisplayName("日计数:Redis 异常 → fail-open 返回 0(放行,不外抛)") + void testIncrDayAndGet_redisFailOpen() { + when(stringRedisTemplate.opsForValue()).thenReturn(valueOperations); + when(valueOperations.increment(anyString())) + .thenThrow(new RedisConnectionFailureException("redis down")); + + long[] result = new long[1]; + assertDoesNotThrow(() -> result[0] = counterDAO.incrDayAndGet( + PassportSmsIpCounterRedisDAO.SCENE_REGISTER, "1.2.3.4")); + assertEquals(0L, result[0]); + } + + @Test + @DisplayName("正常路径:increment 返回自增值,DAO 原样透传") + void testIncrHourAndGet_normal() { + when(stringRedisTemplate.opsForValue()).thenReturn(valueOperations); + when(valueOperations.increment(anyString())).thenReturn(5L); // 非首次(不设 TTL) + + long count = counterDAO.incrHourAndGet(PassportSmsIpCounterRedisDAO.SCENE_SMS, "1.2.3.4"); + assertEquals(5L, count); + } + +} diff --git a/game-cloud/huijing-module-system/huijing-module-system-server/src/test/java/com/wanxiang/huijing/module/system/service/passport/PassportServiceImplTest.java b/game-cloud/huijing-module-system/huijing-module-system-server/src/test/java/com/wanxiang/huijing/module/system/service/passport/PassportServiceImplTest.java index 0da13f35..e58dd0be 100644 --- a/game-cloud/huijing-module-system/huijing-module-system-server/src/test/java/com/wanxiang/huijing/module/system/service/passport/PassportServiceImplTest.java +++ b/game-cloud/huijing-module-system/huijing-module-system-server/src/test/java/com/wanxiang/huijing/module/system/service/passport/PassportServiceImplTest.java @@ -6,9 +6,13 @@ import com.wanxiang.huijing.framework.security.config.SecurityProperties; import com.wanxiang.huijing.framework.test.core.ut.BaseMockitoUnitTest; 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.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; @@ -21,15 +25,20 @@ import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; +import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.test.util.ReflectionTestUtils; import java.time.LocalDateTime; import static com.wanxiang.huijing.framework.common.exception.enums.GlobalErrorCodeConstants.TOO_MANY_REQUESTS; +import static com.wanxiang.huijing.module.system.dal.redis.passport.PassportSmsIpCounterRedisDAO.SCENE_LOGIN; +import static com.wanxiang.huijing.module.system.dal.redis.passport.PassportSmsIpCounterRedisDAO.SCENE_REGISTER; +import static com.wanxiang.huijing.module.system.dal.redis.passport.PassportSmsIpCounterRedisDAO.SCENE_SMS; import static com.wanxiang.huijing.module.system.enums.ErrorCodeConstants.*; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.*; @@ -58,6 +67,10 @@ class PassportServiceImplTest extends BaseMockitoUnitTest { private SecurityProperties securityProperties; @Mock private PassportSmsIpCounterRedisDAO smsIpCounterRedisDAO; + @Mock + private PasswordEncoder passwordEncoder; + @Mock + private PassportRegisterOutboxMapper registerOutboxMapper; @BeforeEach void setUp() { @@ -68,6 +81,12 @@ class PassportServiceImplTest extends BaseMockitoUnitTest { // ⚠ 免短信验证开关默认 false(与生产缺省一致);mock 路径用例在各自 case 内显式置 true ReflectionTestUtils.setField(passportService, "smsMockEnabled", false); ReflectionTestUtils.setField(passportService, "smsMockCode", "8888"); + // WU1 用户名密码路配置(默认开启 + 注册/登录 IP 阈值,与 application-staging 初值一致) + ReflectionTestUtils.setField(passportService, "passwordAuthEnabled", true); + ReflectionTestUtils.setField(passportService, "registerIpLimitPerHour", 20); + ReflectionTestUtils.setField(passportService, "registerIpLimitPerDay", 60); + ReflectionTestUtils.setField(passportService, "loginIpLimitPerHour", 30); + ReflectionTestUtils.setField(passportService, "loginIpLimitPerDay", 100); } // ============================== sendSmsCode IP 频控 ============================== @@ -75,8 +94,8 @@ class PassportServiceImplTest extends BaseMockitoUnitTest { @Test @DisplayName("发码:IP 未超限 → 透传 SmsCodeService 发码(场景 MEMBER_LOGIN)") void testSendSmsCode_underLimit() { - when(smsIpCounterRedisDAO.incrHourAndGet("1.2.3.4")).thenReturn(1L); - when(smsIpCounterRedisDAO.incrDayAndGet("1.2.3.4")).thenReturn(1L); + when(smsIpCounterRedisDAO.incrHourAndGet(SCENE_SMS, "1.2.3.4")).thenReturn(1L); + when(smsIpCounterRedisDAO.incrDayAndGet(SCENE_SMS, "1.2.3.4")).thenReturn(1L); passportService.sendSmsCode("13800000000", "1.2.3.4"); @@ -91,8 +110,8 @@ class PassportServiceImplTest extends BaseMockitoUnitTest { @Test @DisplayName("发码:IP 每小时超限 → 抛 429 且不发码(不区分小时/日,防泄露阈值)") void testSendSmsCode_hourLimitExceeded() { - when(smsIpCounterRedisDAO.incrHourAndGet("1.2.3.4")).thenReturn(11L); // > 10 - when(smsIpCounterRedisDAO.incrDayAndGet("1.2.3.4")).thenReturn(11L); + when(smsIpCounterRedisDAO.incrHourAndGet(SCENE_SMS, "1.2.3.4")).thenReturn(11L); // > 10 + when(smsIpCounterRedisDAO.incrDayAndGet(SCENE_SMS, "1.2.3.4")).thenReturn(11L); ServiceException ex = assertThrows(ServiceException.class, () -> passportService.sendSmsCode("13800000000", "1.2.3.4")); @@ -104,8 +123,8 @@ class PassportServiceImplTest extends BaseMockitoUnitTest { @Test @DisplayName("发码:IP 每日超限 → 抛 429 且不发码") void testSendSmsCode_dayLimitExceeded() { - when(smsIpCounterRedisDAO.incrHourAndGet("1.2.3.4")).thenReturn(3L); // 小时未超 - when(smsIpCounterRedisDAO.incrDayAndGet("1.2.3.4")).thenReturn(31L); // 日超 30 + when(smsIpCounterRedisDAO.incrHourAndGet(SCENE_SMS, "1.2.3.4")).thenReturn(3L); // 小时未超 + when(smsIpCounterRedisDAO.incrDayAndGet(SCENE_SMS, "1.2.3.4")).thenReturn(31L); // 日超 30 ServiceException ex = assertThrows(ServiceException.class, () -> passportService.sendSmsCode("13800000000", "1.2.3.4")); @@ -119,15 +138,15 @@ class PassportServiceImplTest extends BaseMockitoUnitTest { @DisplayName("免短信发码:开关开 → 跳过真实发码(mock 受理)+ IP 频控仍生效") void testSendSmsCode_mockEnabledSkipsRealSend() { ReflectionTestUtils.setField(passportService, "smsMockEnabled", true); - when(smsIpCounterRedisDAO.incrHourAndGet("1.2.3.4")).thenReturn(1L); - when(smsIpCounterRedisDAO.incrDayAndGet("1.2.3.4")).thenReturn(1L); + when(smsIpCounterRedisDAO.incrHourAndGet(SCENE_SMS, "1.2.3.4")).thenReturn(1L); + when(smsIpCounterRedisDAO.incrDayAndGet(SCENE_SMS, "1.2.3.4")).thenReturn(1L); passportService.sendSmsCode("13800000000", "1.2.3.4"); // 开关开:不调用真实发码(不依赖短信渠道/模板,规避 staging 无网关失败) verify(smsCodeService, never()).sendSmsCode(any()); // IP 频控仍执行(先增后判,防刷不被绕过) - verify(smsIpCounterRedisDAO).incrHourAndGet("1.2.3.4"); + verify(smsIpCounterRedisDAO).incrHourAndGet(SCENE_SMS, "1.2.3.4"); } @Test @@ -193,8 +212,8 @@ class PassportServiceImplTest extends BaseMockitoUnitTest { @DisplayName("免短信发码:开关关(生产缺省)→ 照常走真实发码(生产不受影响)") void testSendSmsCode_mockDisabledUsesRealSend() { // smsMockEnabled 默认 false(setUp 已置) - when(smsIpCounterRedisDAO.incrHourAndGet("1.2.3.4")).thenReturn(1L); - when(smsIpCounterRedisDAO.incrDayAndGet("1.2.3.4")).thenReturn(1L); + when(smsIpCounterRedisDAO.incrHourAndGet(SCENE_SMS, "1.2.3.4")).thenReturn(1L); + when(smsIpCounterRedisDAO.incrDayAndGet(SCENE_SMS, "1.2.3.4")).thenReturn(1L); passportService.sendSmsCode("13800000000", "1.2.3.4"); @@ -395,6 +414,217 @@ class PassportServiceImplTest extends BaseMockitoUnitTest { assertEquals(PLAYER_NOT_EXISTS.getCode(), ex.getCode()); } + // ============================== 内测用户名密码注册(WU1 §3.2)============================== + + @Test + @DisplayName("用户名注册:成功建号(channel=password / username / BCrypt 密文 / mobile=null)+ 事务内原子写 outbox") + void testPasswordRegister_success() { + PasswordRegisterReqVO reqVO = new PasswordRegisterReqVO(); + reqVO.setUsername("seedalice"); + reqVO.setPassword("secret6"); + reqVO.setAnonId("anon-p"); + when(passwordEncoder.encode("secret6")).thenReturn("$2a$10$encodedhash"); + doAnswer(inv -> { + PlayerDO d = inv.getArgument(0); + d.setId(2048L); + return 1; + }).when(playerMapper).insert(any(PlayerDO.class)); + stubToken(2048L); + + LoginRespVO resp = passportService.passwordRegister(reqVO, "9.9.9.9"); + + // 建号:channel=password、username 落列、password 落 BCrypt 密文、mobile=null(用户名路)、默认昵称取用户名 + ArgumentCaptor pc = ArgumentCaptor.forClass(PlayerDO.class); + verify(playerMapper).insert(pc.capture()); + assertEquals("password", pc.getValue().getRegisterChannel()); + assertEquals("seedalice", pc.getValue().getUsername()); + assertEquals("$2a$10$encodedhash", pc.getValue().getPassword()); + assertNull(pc.getValue().getMobile()); + assertEquals("seedalice", pc.getValue().getNickname()); // 无 nickname → 用户名路取 username + assertEquals("anon-p", pc.getValue().getFirstAnonId()); + assertEquals("0", pc.getValue().getCreator()); + // §3.6:注册成功事件与建号原子写 outbox(幂等键 register:{userId},未投递态) + ArgumentCaptor oc = ArgumentCaptor.forClass(PassportRegisterOutboxDO.class); + verify(registerOutboxMapper).insert(oc.capture()); + assertEquals(2048L, oc.getValue().getUserId()); + assertEquals("password", oc.getValue().getRegisterChannel()); + assertEquals("register:2048", oc.getValue().getIdempotentKey()); + assertEquals(PassportRegisterOutboxDO.STATUS_PENDING, oc.getValue().getStatus()); + // 发真 token(与短信路一字不差) + assertEquals(2048L, resp.getUserId()); + assertEquals("happy-token", resp.getAccessToken()); + } + + @Test + @DisplayName("用户名注册:并发撞 uk_username → 报 PLAYER_USERNAME_ALREADY_REGISTERED(不转登录、不写 outbox)") + void testPasswordRegister_usernameAlreadyRegistered() { + PasswordRegisterReqVO reqVO = new PasswordRegisterReqVO(); + reqVO.setUsername("seedalice"); + reqVO.setPassword("secret6"); + when(passwordEncoder.encode("secret6")).thenReturn("$2a$10$encodedhash"); + // 模拟 uk_username 并发冲突 + doThrow(new org.springframework.dao.DuplicateKeyException("uk_username")) + .when(playerMapper).insert(any(PlayerDO.class)); + + ServiceException ex = assertThrows(ServiceException.class, + () -> passportService.passwordRegister(reqVO, "9.9.9.9")); + assertEquals(PLAYER_USERNAME_ALREADY_REGISTERED.getCode(), ex.getCode()); + // 注册态撞名不静默转登录:不回查手机号、不发 token、不写 outbox + verify(playerMapper, never()).selectByMobile(any()); + verify(oauth2TokenService, never()).createAccessToken(anyLong(), any(), any(), any()); + verify(registerOutboxMapper, never()).insert(any(PassportRegisterOutboxDO.class)); + } + + @Test + @DisplayName("用户名注册:总开关关闭 → 抛 PASSWORD_AUTH_DISABLED(信任边界=后端,连 IP 都不计)") + void testPasswordRegister_authDisabled() { + ReflectionTestUtils.setField(passportService, "passwordAuthEnabled", false); + PasswordRegisterReqVO reqVO = new PasswordRegisterReqVO(); + reqVO.setUsername("seedalice"); + reqVO.setPassword("secret6"); + + ServiceException ex = assertThrows(ServiceException.class, + () -> passportService.passwordRegister(reqVO, "9.9.9.9")); + assertEquals(PASSWORD_AUTH_DISABLED.getCode(), ex.getCode()); + verify(playerMapper, never()).insert(any(PlayerDO.class)); + verify(smsIpCounterRedisDAO, never()).incrHourAndGet(any(), any()); + } + + @Test + @DisplayName("用户名注册:同 IP 越注册档小时阈值 → 抛 429 且不建号") + void testPasswordRegister_ipLimitExceeded() { + PasswordRegisterReqVO reqVO = new PasswordRegisterReqVO(); + reqVO.setUsername("seedalice"); + reqVO.setPassword("secret6"); + when(smsIpCounterRedisDAO.incrHourAndGet(SCENE_REGISTER, "9.9.9.9")).thenReturn(21L); // > 20 + when(smsIpCounterRedisDAO.incrDayAndGet(SCENE_REGISTER, "9.9.9.9")).thenReturn(21L); + + ServiceException ex = assertThrows(ServiceException.class, + () -> passportService.passwordRegister(reqVO, "9.9.9.9")); + assertEquals(TOO_MANY_REQUESTS.getCode(), ex.getCode()); + verify(playerMapper, never()).insert(any(PlayerDO.class)); + verify(registerOutboxMapper, never()).insert(any(PassportRegisterOutboxDO.class)); + } + + // ============================== 内测用户名密码登录(WU1 §3.2 防枚举)============================== + + @Test + @DisplayName("用户名登录:用户名+密码正确 → 刷新登录时间 + 发 token") + void testPasswordLogin_success() { + PasswordLoginReqVO reqVO = new PasswordLoginReqVO(); + reqVO.setUsername("seedalice"); + reqVO.setPassword("secret6"); + PlayerDO exist = player(2048L, 0, 0); + exist.setPassword("$2a$10$stored"); + when(playerMapper.selectByUsername("seedalice")).thenReturn(exist); + when(passwordEncoder.matches("secret6", "$2a$10$stored")).thenReturn(true); + stubToken(2048L); + + LoginRespVO resp = passportService.passwordLogin(reqVO, "9.9.9.9"); + + verify(playerMapper).updateLoginDate(eq(2048L), any(LocalDateTime.class)); + assertEquals(2048L, resp.getUserId()); + assertEquals("happy-token", resp.getAccessToken()); + } + + @Test + @DisplayName("用户名登录·防枚举:查无用户名 → 跑固定假 hash 对齐耗时 + 统一 PLAYER_USERNAME_OR_PASSWORD_ERROR") + void testPasswordLogin_usernameNotFound_antiEnumeration() { + PasswordLoginReqVO reqVO = new PasswordLoginReqVO(); + reqVO.setUsername("ghost"); + reqVO.setPassword("secret6"); + when(playerMapper.selectByUsername("ghost")).thenReturn(null); + when(passwordEncoder.encode(anyString())).thenReturn("$2a$10$fakehash"); // getFakeMatchHash 惰性编码 + + ServiceException ex = assertThrows(ServiceException.class, + () -> passportService.passwordLogin(reqVO, "9.9.9.9")); + assertEquals(PLAYER_USERNAME_OR_PASSWORD_ERROR.getCode(), ex.getCode()); + // 时序对齐:查无用户名时也对固定假 hash 跑一次 matches(消除存在性 oracle) + verify(passwordEncoder).matches("secret6", "$2a$10$fakehash"); + } + + @Test + @DisplayName("用户名登录·防枚举:密码错误与查无用户名返回同一错误码(不可区分)") + void testPasswordLogin_wrongPassword_sameErrorCode() { + PasswordLoginReqVO reqVO = new PasswordLoginReqVO(); + reqVO.setUsername("seedalice"); + reqVO.setPassword("wrongpwd"); + PlayerDO exist = player(2048L, 0, 0); + exist.setPassword("$2a$10$stored"); + when(playerMapper.selectByUsername("seedalice")).thenReturn(exist); + when(passwordEncoder.matches("wrongpwd", "$2a$10$stored")).thenReturn(false); + + ServiceException ex = assertThrows(ServiceException.class, + () -> passportService.passwordLogin(reqVO, "9.9.9.9")); + // 与「查无用户名」同码,不给区分(防枚举) + assertEquals(PLAYER_USERNAME_OR_PASSWORD_ERROR.getCode(), ex.getCode()); + verify(oauth2TokenService, never()).createAccessToken(anyLong(), any(), any(), any()); + } + + @Test + @DisplayName("用户名登录:账号被禁用(密码正确)→ 抛 PLAYER_IS_DISABLED") + void testPasswordLogin_disabledRejected() { + PasswordLoginReqVO reqVO = new PasswordLoginReqVO(); + reqVO.setUsername("seedalice"); + reqVO.setPassword("secret6"); + PlayerDO exist = player(2048L, 1, 0); // status=1 禁用 + exist.setPassword("$2a$10$stored"); + when(playerMapper.selectByUsername("seedalice")).thenReturn(exist); + when(passwordEncoder.matches("secret6", "$2a$10$stored")).thenReturn(true); + + ServiceException ex = assertThrows(ServiceException.class, + () -> passportService.passwordLogin(reqVO, "9.9.9.9")); + assertEquals(PLAYER_IS_DISABLED.getCode(), ex.getCode()); + verify(oauth2TokenService, never()).createAccessToken(anyLong(), any(), any(), any()); + } + + @Test + @DisplayName("用户名登录:总开关关闭 → 抛 PASSWORD_AUTH_DISABLED(连查库都不做)") + void testPasswordLogin_authDisabled() { + ReflectionTestUtils.setField(passportService, "passwordAuthEnabled", false); + PasswordLoginReqVO reqVO = new PasswordLoginReqVO(); + reqVO.setUsername("seedalice"); + reqVO.setPassword("secret6"); + + ServiceException ex = assertThrows(ServiceException.class, + () -> passportService.passwordLogin(reqVO, "9.9.9.9")); + assertEquals(PASSWORD_AUTH_DISABLED.getCode(), ex.getCode()); + verify(playerMapper, never()).selectByUsername(any()); + } + + @Test + @DisplayName("用户名登录:同 IP 越登录档小时阈值 → 抛 429 且不查库") + void testPasswordLogin_ipLimitExceeded() { + PasswordLoginReqVO reqVO = new PasswordLoginReqVO(); + reqVO.setUsername("seedalice"); + reqVO.setPassword("secret6"); + when(smsIpCounterRedisDAO.incrHourAndGet(SCENE_LOGIN, "9.9.9.9")).thenReturn(31L); // > 30 + when(smsIpCounterRedisDAO.incrDayAndGet(SCENE_LOGIN, "9.9.9.9")).thenReturn(31L); + + ServiceException ex = assertThrows(ServiceException.class, + () -> passportService.passwordLogin(reqVO, "9.9.9.9")); + assertEquals(TOO_MANY_REQUESTS.getCode(), ex.getCode()); + verify(playerMapper, never()).selectByUsername(any()); + } + + // ============================== /me 空安全(WU1 §3.2 用户名用户 mobile=null)============================== + + @Test + @DisplayName("/me:用户名用户 mobile 为 null → 脱敏短路回空串(不 NPE)+ 返回 username") + void testGetPlayerMe_usernameUserNullMobileSafe() { + PlayerDO player = new PlayerDO(); + player.setId(2048L); + player.setMobile(null); // 用户名用户无手机号 + player.setUsername("seedalice"); + player.setNickname("seedalice"); + player.setCreatorFlag(0); + when(playerMapper.selectById(2048L)).thenReturn(player); + + var resp = assertDoesNotThrow(() -> passportService.getPlayerMe(2048L)); + assertEquals("", resp.getMobile()); // null 短路回空串,不把 null 塞进脱敏函数 + assertEquals("seedalice", resp.getUsername()); + } + // ============================== 测试夹具 ============================== /** stub 发 token 返回固定 access/refresh/过期时间 */ diff --git a/game-cloud/huijing-server/src/main/resources/application-staging.yaml b/game-cloud/huijing-server/src/main/resources/application-staging.yaml index 069eec30..bf0cb904 100644 --- a/game-cloud/huijing-server/src/main/resources/application-staging.yaml +++ b/game-cloud/huijing-server/src/main/resources/application-staging.yaml @@ -201,3 +201,9 @@ wanxiang: # ⚠ 公网上线前必须置 false / 删除本项(=实名/短信网关日历闸门):开启=任何人凭 8888 登录任意手机号。 sms-mock-enabled: true # ⚠ 仅 staging 内测:免真实短信验证(缺省 false,生产绝不开启) sms-mock-code: "8888" # ⚠ 仅 staging 内测:免短信验证的固定测试码(配合 sms-mock-enabled) + # ========== 内测·用户名密码注册登录(2026-07-07 WU1 §3.4)========== + password-auth-enabled: true # 用户名密码注册+登录总开关(staging 内测开启;缺省 false;信任边界=后端服务层,不靠前端隐藏 Tab) + register-ip-limit-per-hour: 20 # 注册场景 IP 每小时上限(推断初值,注册比发码宽松一档,与运营确认后调) + register-ip-limit-per-day: 60 # 注册场景 IP 每日上限(推断初值) + login-ip-limit-per-hour: 30 # 登录场景 IP 每小时上限(推断初值;sms-login 与 password-login 同桶 scene=login) + login-ip-limit-per-day: 100 # 登录场景 IP 每日上限(推断初值) From da05ecc0b17c3dbae44e8c84c271c8a6a2ff5da8 Mon Sep 17 00:00:00 2001 From: lili Date: Tue, 7 Jul 2026 12:04:02 -0700 Subject: [PATCH 04/10] =?UTF-8?q?feat(studio):=20WU1=20=E5=89=8D=E7=AB=AF?= =?UTF-8?q?=E2=80=94=E2=80=94=E7=99=BB=E5=BD=95=E9=A1=B5=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E7=94=A8=E6=88=B7=E5=90=8D=E5=AF=86=E7=A0=81=20Tab=EF=BC=88?= =?UTF-8?q?=E6=B3=A8=E5=86=8C/=E7=99=BB=E5=BD=95=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WU1 §3.5 步骤④前端落地:Login.vue 在 sms/invite 之外加第三个「用户名密码」 Tab,内含登录/注册子模式段控——用户名、密码两输入框,前端做与后端一致的 用户名正则 ^[a-zA-Z0-9]{4,30}$ + 密码 6-32 位预校验(仅提示,信任边界在后端); 复用既有隐私政策勾选、?redirect= 回跳、setLogin 落持久化、user_login 遥测 双带四段,登录成功路径与短信路一致(同拿 LoginResp)。 passport.ts 加 PasswordRegisterReq/PasswordLoginReq 入参接口与 passwordRegister/ passwordLogin 两函数(贴 VO 契约、走同一 request 信封解包),PlayerMeResp 补 username;Profile.vue fallbackMe 同步补 username 空串兜底。entry.zh/en 补 Tab 标题、用户名/密码占位、子模式与提交按钮多态文案、两条本地预校验提示。 自证:npm run build(vue-tsc -b && vite build)退出 0。 Co-Authored-By: Claude Opus 4.8 (1M context) --- game-studio/src/api/passport.ts | 61 +++++++- game-studio/src/locales/entry.en.ts | 12 ++ game-studio/src/locales/entry.zh.ts | 17 ++- game-studio/src/views/login/Login.vue | 176 +++++++++++++++++++--- game-studio/src/views/profile/Profile.vue | 3 +- 5 files changed, 247 insertions(+), 22 deletions(-) diff --git a/game-studio/src/api/passport.ts b/game-studio/src/api/passport.ts index 79206aa9..ff2706c5 100644 --- a/game-studio/src/api/passport.ts +++ b/game-studio/src/api/passport.ts @@ -39,6 +39,35 @@ export interface InviteRegisterReq { nickname?: string } +/** + * 用户名密码注册请求体(贴 PasswordRegisterReqVO;内测种子用户建号即登录,WU1 §3.2)。 + * 前端校验仅提示(信任边界在后端):username 须匹配 ^[a-zA-Z0-9]{4,30}$、password 6-32 位; + * 用户名已占用后端返回 1-002-090-004,由 request 拦截器统一 Toast。 + */ +export interface PasswordRegisterReq { + /** 用户名(登录主键之一;^[a-zA-Z0-9]{4,30}$) */ + username: string + /** 密码(明文,6-32 位;后端立即 BCrypt 编码,前端不落日志、不回显) */ + password: string + /** 昵称(可选;不传则后端默认取用户名) */ + nickname?: string + /** 客户端 anonId(可选;仅注册落 first_anon_id 一次,匿名↔登录归因) */ + anonId?: string +} + +/** + * 用户名密码登录请求体(贴 PasswordLoginReqVO;WU1 §3.2)。 + * 防枚举:后端对「查无用户名」与「密码错误」统一返 1-002-090-005(文案一致,不可区分)。 + */ +export interface PasswordLoginReq { + /** 用户名(登录主键之一;^[a-zA-Z0-9]{4,30}$) */ + username: string + /** 密码(明文;登录不重复长度校验,仅非空) */ + password: string + /** 客户端 anonId(可选;登录不落库,仅遥测归因透传) */ + anonId?: string +} + /** * 登录/注册成功响应(贴 LoginRespVO;sms-login 与 invite-register 共用)。 * token 不落日志、手机号不在此返回(安全基线)。 @@ -65,8 +94,10 @@ export interface LoginResp { export interface PlayerMeResp { /** 玩家编号 */ userId: number - /** 脱敏手机号(如 138****0000;后端禁回明文) */ + /** 脱敏手机号(如 138****0000;后端禁回明文;用户名用户 mobile 为 null 时回空串) */ mobile: string + /** 用户名(用户名密码路身份展示,非敏感明文;手机号用户为空串,WU1 §3.2) */ + username: string /** 昵称 */ nickname: string /** 头像 URL */ @@ -117,6 +148,34 @@ export function inviteRegister(dto: InviteRegisterReq): Promise { }) } +/** + * 用户名密码注册(建号即登录,@PermitAll 免登;受总开关 password-auth-enabled 管控,WU1 §3.2)。 + * POST /app-api/passport/password-register + * @param dto 注册请求体(username + password + 可选 nickname/anonId) + * @returns 真 OAuth2 token 与玩家身份摘要(与短信路 token 一字不差) + */ +export function passwordRegister(dto: PasswordRegisterReq): Promise { + return request({ + url: '/app-api/passport/password-register', + method: 'post', + data: dto, + }) +} + +/** + * 用户名密码登录(@PermitAll 免登;防枚举:查无用户名与密码错误统一 1-002-090-005,WU1 §3.2)。 + * POST /app-api/passport/password-login + * @param dto 登录请求体(username + password + 可选 anonId) + * @returns 真 OAuth2 token 与玩家身份摘要(同 sms-login 响应) + */ +export function passwordLogin(dto: PasswordLoginReq): Promise { + return request({ + url: '/app-api/passport/password-login', + method: 'post', + data: dto, + }) +} + /** * 当前登录玩家信息(登录态必需;未登录返回 401,由 request.ts 401 拦截兜底)。 * GET /app-api/passport/me diff --git a/game-studio/src/locales/entry.en.ts b/game-studio/src/locales/entry.en.ts index ece12aa8..186cc731 100644 --- a/game-studio/src/locales/entry.en.ts +++ b/game-studio/src/locales/entry.en.ts @@ -9,18 +9,28 @@ export default { subtitle: 'Sign in to create and interact with your mini-games', tabSms: 'Code login', tabInvite: 'Invite register', + tabPassword: 'Username', mobileLabel: 'Phone', mobilePlaceholder: 'Enter phone number', codeLabel: 'Code', codePlaceholder: 'Enter SMS code', inviteLabel: 'Invite code', invitePlaceholder: 'Enter beta invite code', + modeLogin: 'Log in', + modeRegister: 'Register', + usernameLabel: 'Username', + usernamePlaceholder: '4-30 letters or digits', + passwordLabel: 'Password', + passwordPlaceholder: '6-32 characters', + passwordHint: 'Seed users: register and sign in with a username and password, no phone needed', sendCode: 'Get code', sending: 'Sending…', resendIn: 'Resend in {n}s', smsHint: 'Unregistered numbers will be registered and signed in automatically', inviteHint: 'Beta invite: currently in restricted activation, an invite code is required', invalidMobile: 'Please enter a valid phone number', + invalidUsername: 'Username must be 4-30 letters or digits', + invalidPassword: 'Password must be 6-32 characters', codeSent: 'Code sent (seed-phase Debug channel, check the backend for the code)', needAgree: 'Please read and accept the User Agreement and Privacy Policy first', loginSuccess: 'Signed in', @@ -31,6 +41,8 @@ export default { agreePlaceholder: '(placeholder, final copy pending legal)', submitSms: 'Log in / register', submitInvite: 'Register & log in', + submitPasswordLogin: 'Log in', + submitPasswordRegister: 'Register & log in', }, // —— Share landing —— diff --git a/game-studio/src/locales/entry.zh.ts b/game-studio/src/locales/entry.zh.ts index 15703888..4f04e288 100644 --- a/game-studio/src/locales/entry.zh.ts +++ b/game-studio/src/locales/entry.zh.ts @@ -10,9 +10,10 @@ export default { login: { // 品牌副标题(标题本身用 common.brand) subtitle: '登录后即可创作、互动你的小游戏', - // 两 Tab 名 + // 三 Tab 名 tabSms: '验证码登录', tabInvite: '邀请码注册', + tabPassword: '用户名密码', // 手机号 / 验证码 / 邀请码占位符 + 标签 mobileLabel: '手机号', mobilePlaceholder: '请输入手机号', @@ -20,6 +21,14 @@ export default { codePlaceholder: '请输入短信验证码', inviteLabel: '邀请码', invitePlaceholder: '请输入内测邀请码', + // 用户名密码(第三 Tab,内测种子用户;WU1 §3.5):子模式切换 + 输入占位 + 提示 + modeLogin: '登录', + modeRegister: '注册', + usernameLabel: '用户名', + usernamePlaceholder: '4-30 位数字或字母', + passwordLabel: '密码', + passwordPlaceholder: '6-32 位密码', + passwordHint: '内测种子用户:用户名 + 密码即可注册登录,无需手机号', // 发码按钮三态:默认 / 发送中 / 倒计时重发({n} 为剩余秒数,模板插值) sendCode: '获取验证码', sending: '发送中…', @@ -29,6 +38,8 @@ export default { inviteHint: '内测邀请:当前为受限激活期,需邀请码注册', // 表单校验 / 流程 Toast invalidMobile: '请输入正确的手机号', + invalidUsername: '用户名为 4-30 位数字或字母', + invalidPassword: '密码长度为 6-32 位', codeSent: '验证码已发送(种子期 Debug 渠道,后台查码)', needAgree: '请先阅读并勾选用户协议与隐私政策', loginSuccess: '登录成功', @@ -38,9 +49,11 @@ export default { agreeAnd: '与', agreePrivacy: '《隐私政策》', agreePlaceholder: '(占位,正式文案待法务)', - // 提交按钮两态(按当前 Tab) + // 提交按钮多态(按当前 Tab;用户名密码 Tab 再按登录/注册子模式) submitSms: '登录 / 注册', submitInvite: '注册并登录', + submitPasswordLogin: '登录', + submitPasswordRegister: '注册并登录', }, // —— 分享落地页(Share.vue,/share/:gameId)—— diff --git a/game-studio/src/views/login/Login.vue b/game-studio/src/views/login/Login.vue index 857b66c6..988c3820 100644 --- a/game-studio/src/views/login/Login.vue +++ b/game-studio/src/views/login/Login.vue @@ -3,10 +3,13 @@ * 登录页 | Login.vue(路由 /login,真实鉴权件 spec §9.1) * ---------------------------------------------------------------------------- * 职责: - * 1. 两 Tab 登录/注册: + * 1. 三 Tab 登录/注册: * ① 手机号 + 验证码(主路径,拍板1C):send-sms-code 发码 → sms-login 登录/自动注册一体; * ② 邀请码注册(受限激活期陌生玩家入口,拍板2/3):mobile + inviteCode → invite-register, * 页内文案注明「内测邀请」。 + * ③ 用户名密码(内测种子用户,WU1 §3.5):username + password,子模式登录/注册二选一 + * → password-login / password-register(建号即登录);前端做与后端一致的用户名正则+ + * 密码长度预校验(仅提示,信任边界在后端)。 * 2. 隐私政策/用户协议占位勾选(P-ACC-02 占位提前;未勾选不可提交)。 * 3. 携带 useUserStore().anonId 入参(匿名↔登录身份衔接,拍板7=B)。 * 4. 支持 ?redirect= 回跳(守卫/互动跳转登录后回到原页)。 @@ -19,7 +22,14 @@ import { useRoute, useRouter } from 'vue-router' import { useI18n } from 'vue-i18n' import { showToast } from 'vant' import { useUserStore } from '../../store/user' -import { sendSmsCode, smsLogin, inviteRegister, type LoginResp } from '../../api/passport' +import { + sendSmsCode, + smsLogin, + inviteRegister, + passwordRegister, + passwordLogin, + type LoginResp, +} from '../../api/passport' import { track } from '../../telemetry' import { AppButton } from '../../components' import entryZh from '../../locales/entry.zh' @@ -36,15 +46,21 @@ const route = useRoute() const router = useRouter() const userStore = useUserStore() -/** 当前 Tab:'sms'=验证码登录(默认主路径) / 'invite'=邀请码注册 */ -const activeTab = ref<'sms' | 'invite'>('sms') +/** 当前 Tab:'sms'=验证码登录(默认主路径) / 'invite'=邀请码注册 / 'password'=用户名密码 */ +const activeTab = ref<'sms' | 'invite' | 'password'>('sms') -/** 手机号(两 Tab 共用) */ +/** 手机号(sms/invite 两 Tab 共用;password Tab 不用) */ const mobile = ref('') /** 短信验证码(sms Tab) */ const code = ref('') /** 邀请码(invite Tab) */ const inviteCode = ref('') +/** 用户名(password Tab) */ +const username = ref('') +/** 密码(password Tab;不 trim,允许含空格,绝不落日志/遥测) */ +const password = ref('') +/** 用户名密码 Tab 子模式:'login'=登录(默认)/ 'register'=注册(建号即登录) */ +const passwordMode = ref<'login' | 'register'>('login') /** 隐私政策/用户协议勾选(占位;未勾选不可提交) */ const agreed = ref(false) @@ -60,6 +76,21 @@ const MOBILE_RE = /^1[3-9]\d{9}$/ /** 手机号是否合法 */ const mobileValid = computed(() => MOBILE_RE.test(mobile.value.trim())) +/** 用户名校验:与后端 PasswordReqVO 一致 ^[a-zA-Z0-9]{4,30}$(前端仅提示,信任边界在后端) */ +const USERNAME_RE = /^[a-zA-Z0-9]{4,30}$/ +/** 用户名是否合法 */ +const usernameValid = computed(() => USERNAME_RE.test(username.value.trim())) +/** + * 密码是否合法(与后端一致的长度预校验,仅提示): + * - 注册:6-32 位(后端 @Length(6,32)); + * - 登录:仅非空(后端登录不重复长度校验,避免给出策略线索)。 + * 密码不 trim(前导/尾随空格可能是密码一部分)。 + */ +const passwordValid = computed(() => { + const len = password.value.length + return passwordMode.value === 'register' ? len >= 6 && len <= 32 : len > 0 +}) + /** * 解析回跳地址(?redirect=)。 * 仅接受站内相对路径(以 / 开头且非 //),防开放重定向;缺省回 /feed。 @@ -78,11 +109,26 @@ const canSend = computed(() => mobileValid.value && !sending.value && countdown. /** 提交按钮是否可点(按当前 Tab 校验必填项 + 已勾选协议 + 不在提交中) */ const canSubmit = computed(() => { - if (!agreed.value || submitting.value || !mobileValid.value) return false + if (!agreed.value || submitting.value) return false + if (activeTab.value === 'password') { + // 用户名密码 Tab:不依赖手机号,按 username 正则 + 密码长度(子模式相关)预校验 + return usernameValid.value && passwordValid.value + } + // sms / invite 两 Tab 共用手机号校验 + if (!mobileValid.value) return false if (activeTab.value === 'sms') return code.value.trim().length > 0 return inviteCode.value.trim().length > 0 }) +/** 提交按钮文案(按当前 Tab;用户名密码 Tab 再按登录/注册子模式二态) */ +const submitLabel = computed(() => { + if (activeTab.value === 'sms') return t('login.submitSms') + if (activeTab.value === 'invite') return t('login.submitInvite') + return passwordMode.value === 'register' + ? t('login.submitPasswordRegister') + : t('login.submitPasswordLogin') +}) + /** 启动 60s 发码倒计时 */ function startCountdown(): void { countdown.value = 60 @@ -138,12 +184,21 @@ function afterLoginSuccess(resp: LoginResp): void { /** * 提交(按当前 Tab 分派): * - sms:smsLogin(已注册登录 / 未注册自动注册一体); - * - invite:inviteRegister(手机号+邀请码受限激活)。 - * 两路均携带 anonId(身份衔接,可选);失败由拦截器 Toast 错误码,仅解锁。 + * - invite:inviteRegister(手机号+邀请码受限激活); + * - password:登录子模式 passwordLogin / 注册子模式 passwordRegister(建号即登录)。 + * 三路均携带 anonId(身份衔接,可选);失败由拦截器 Toast 错误码(1-002-090/091),仅解锁。 */ async function onSubmit(): Promise { if (!canSubmit.value) { - if (!agreed.value) showToast({ message: t('login.needAgree'), type: 'fail' }) + if (!agreed.value) { + showToast({ message: t('login.needAgree'), type: 'fail' }) + return + } + // 用户名密码 Tab 的预校验提示(仅本地提示,后端仍会二次校验) + if (activeTab.value === 'password') { + if (!usernameValid.value) showToast({ message: t('login.invalidUsername'), type: 'fail' }) + else if (!passwordValid.value) showToast({ message: t('login.invalidPassword'), type: 'fail' }) + } return } submitting.value = true @@ -152,16 +207,23 @@ async function onSubmit(): Promise { let resp: LoginResp if (activeTab.value === 'sms') { resp = await smsLogin({ mobile: mobile.value.trim(), code: code.value.trim(), anonId }) - } else { + } else if (activeTab.value === 'invite') { resp = await inviteRegister({ mobile: mobile.value.trim(), inviteCode: inviteCode.value.trim(), anonId, }) + } else { + // 用户名密码路:username 去空白后提交;password 原样(不 trim);子模式决定注册/登录端点 + const uname = username.value.trim() + resp = + passwordMode.value === 'register' + ? await passwordRegister({ username: uname, password: password.value, anonId }) + : await passwordLogin({ username: uname, password: password.value, anonId }) } afterLoginSuccess(resp) } catch { - // 错误码(验证码错误 / 邀请码无效·耗尽·过期 / 已注册等 1-002-090/091)已由拦截器 Toast;解锁停留本页 + // 错误码(验证码错误 / 邀请码无效·耗尽·过期 / 用户名已占用·用户名或密码错误 1-002-090/091)已由拦截器 Toast;解锁停留本页 submitting.value = false } // 成功后 router.replace 跳转,本组件卸载,无需复位 submitting @@ -176,7 +238,7 @@ async function onSubmit(): Promise {

- +