feat(passport): WU1 契约层——用户名密码注册登录 schema/VO/错误码/outbox

内测种子用户「用户名+密码」注册登录(无短信)的契约层,照 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) <noreply@anthropic.com>
This commit is contained in:
lili 2026-07-07 11:52:05 -07:00
parent 58d6eb50ca
commit 987d489b63
12 changed files with 356 additions and 2 deletions

View File

@ -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)';

View File

@ -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`;

View File

@ -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, "邀请码无效"); // 码不存在(与已停用/已过期/已耗尽区分,便于运营定位)

View File

@ -97,6 +97,13 @@
<artifactId>huijing-spring-boot-starter-mq</artifactId>
</dependency>
<!-- RocketMQ(2026-07-07 WU1 §3.6:passport 注册成功事件 outbox 投递器 RocketMQTemplate):
starter-mq 以 optional=true 声明本 starter(不传递),故本模块须显式直依赖;版本由 huijing 依赖管理统一控制。 -->
<dependency>
<groupId>org.apache.rocketmq</groupId>
<artifactId>rocketmq-spring-boot-starter</artifactId>
</dependency>
<!-- 服务保障相关:passport 发码端点 @RateLimiter(IP 维度限频,R6 §7.2)需此 starter,2026-06-10 鉴权件取消注释 -->
<dependency>
<groupId>com.wanxiang</groupId>

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;

View File

@ -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 事务上,杜绝「注册成功但事件从未入队」黑洞。
* <p>
* 多租户:本表为机制表,标 {@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;
}

View File

@ -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;
/**
* 昵称(注册默认生成,可改)
*/

View File

@ -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<PassportRegisterOutboxDO> {
/**
* 扫描未投递事件(status=0),按 id 升序取前 limit 条(投递器批量投递用)
*
* @param limit 单批上限(有界扫描,避免一次拉全表)
* @return 未投递 outbox 列表
*/
default List<PassportRegisterOutboxDO> selectPendingList(int limit) {
LambdaQueryWrapper<PassportRegisterOutboxDO> query = new LambdaQueryWrapper<PassportRegisterOutboxDO>()
.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<PassportRegisterOutboxDO> update = new LambdaUpdateWrapper<PassportRegisterOutboxDO>()
.set(PassportRegisterOutboxDO::getStatus, PassportRegisterOutboxDO.STATUS_DELIVERED)
.set(PassportRegisterOutboxDO::getDeliveredTime, deliveredTime)
.eq(PassportRegisterOutboxDO::getId, id)
.eq(PassportRegisterOutboxDO::getStatus, PassportRegisterOutboxDO.STATUS_PENDING);
return update(null, update);
}
}

View File

@ -29,6 +29,16 @@ public interface PlayerMapper extends BaseMapperX<PlayerDO> {
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
*

View File

@ -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)';