From 987d489b6391094d9682977563cb44710d44de97 Mon Sep 17 00:00:00 2001 From: lili Date: Tue, 7 Jul 2026 11:52:05 -0700 Subject: [PATCH] =?UTF-8?q?feat(passport):=20WU1=20=E5=A5=91=E7=BA=A6?= =?UTF-8?q?=E5=B1=82=E2=80=94=E2=80=94=E7=94=A8=E6=88=B7=E5=90=8D=E5=AF=86?= =?UTF-8?q?=E7=A0=81=E6=B3=A8=E5=86=8C=E7=99=BB=E5=BD=95=20schema/VO/?= =?UTF-8?q?=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)';