阶段0 三份 feature-design-doc 过对抗双评审+主控终审;WU2 按 S0 实测改预置池机制。 ops 脚本离线预建 new-api user+token+¥100 灌 game-cloud 池表。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
35 KiB
date, topic, status, sot-impact, 上级, 关联, 图清单
| date | topic | status | sot-impact | 上级 | 关联 | 图清单 | |||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 2026-07-07 | 内测-注册登录-用户名密码 | 草案 | 修订 topic 鉴权与权限(新增用户名+密码鉴权路径,与短信/邀请码并存);修订 topic 数据模型(game_player 增 username/password 列、mobile 放宽可空、加 uk_username)。收口时把两条结论折进 后端/鉴权与权限.md 与 后端/数据模型.md,本档降为留痕。 | docs/mvp/MVP作战清单.md |
|
|
内测·用户名密码注册登录 功能设计
0 一图看懂
内测种子用户不再依赖短信:输入用户名和密码即可完成注册与登录,账号身份从"必须有手机号"松绑为"用户名或手机号二选一"。后端在既有 passport 模块里新增一对免登端点,复用已经跑通的建号事务、BCrypt 编码、OAuth2 发 token 三段;防刷靠应用层按纯 IP 计数的时/日双桶实现,同时把这层闸补到此前漏保护的短信登录与邀请码注册上。
flowchart LR
U["种子用户"] -->|用户名+密码| REG["password-register<br/>password-login<br/>(app-api/passport)"]
REG --> IP{"同 IP 时/日<br/>计数闸"}
IP -->|超阈值| DENY["429 请求过频"]
IP -->|放行| SVC["PassportServiceImpl<br/>registerPlayer + BCrypt + buildLoginResp"]
SVC --> DB[("game_player<br/>username/password 新列<br/>mobile 放宽 NULL")]
SVC --> TOK["OAuth2 token<br/>userType=MEMBER"]
SVC -.事务内写 outbox.-> HOOK["WU2 new-api 开户充值 hook<br/>(至少一次·幂等·本档只交生产者)"]
核心思想:不新建鉴权体系,在 passport 现有骨架上加一条与短信/邀请码平权的登录路径,用一列 username、一列 password 和一处 mobile 放宽承载;防刷收敛到一处纯 IP 计数组件,避免每个端点各写一套。
边界:只做用户名密码的注册与登录(内测种子),不做找回密码、改密、手机号绑定、图形验证码、真正的边缘网关限流。
怎么算成功:真机上用户名密码注册返回 OAuth2 token、随后同凭据登录成功、同 IP 高频尝试被 429 拒、存量短信与邀请码两条路零回归。
1 意图与目标
内测阶段要把种子用户请进来试玩和创作,可当前 passport 只认手机号:登录靠短信验证码,注册靠"验证码即占有证明"的自动注册,或报备前的邀请码旁路。staging 未接真实短信网关,只能靠 sms-mock-enabled 发一个固定码 8888 兜底登录——这是给自己人测试用的后门,把它当成种子用户的注册入口既不体面也不安全(任何人凭 8888 登录任意手机号)。创始人据此拍定内测口径:种子用户"输入用户名和密码即可"注册登录,无需短信;网关层对同 IP 的注册/登录尝试做限流防刷。
目标有三条,都要能验:
- 可注册可登录:陌生用户提交合法用户名与密码即建号并拿到真 OAuth2 token;用同一对凭据能再次登录。token 结构、userType、client 与短信路完全一致,下游鉴权、
/me、创作守卫无需感知登录方式的差异。 - 可防刷:同一 IP 在时窗内的注册与登录尝试次数受限,越限返回 429,日志留痕可审计;这层保护同时补到目前裸奔的
sms-login与invite-register。 - 零回归:短信验证码登录、自动注册、邀请码注册、
sms-mock后门四条既有路径行为不变。
2 边界
做:game_player 增 username / password 两列并放宽 mobile 为可空、加 uk_username;新增 password-register 与 password-login 两个免登端点及其请求/响应契约;用户名密码校验、BCrypt 编码与校验、防枚举口径;应用层纯 IP 计数防刷(新端点 + 回填 sms-login / invite-register);wanxiang.passport.* 段新增开关与阈值;game-studio 登录页新增用户名密码 Tab、passport.ts 端点封装、文案;/me 返回体补 username 以便前端展示非手机号身份;交付"注册成功"事件的生产者(注册事务内写 outbox、投递器发 MQ,至少一次),作为 WU2 new-api 开户充值 hook 的接缝。
不做:找回密码 / 改密 / 手机号与用户名互绑(内测不需要,留后续账号中心);图形验证码或滑块(与短信路一样,列为公网上线前的加固项,非内测门槛);真正的边缘网关(huijing-gateway / nginx)限流作为主方案——现实是产品端走单体直连 48080,网关不在链路(详见 §3.3 的口径澄清与权衡);real_name / id_card_no 实名字段(V11 已预留,提现前才收集);把 WU2 的 new-api 开户逻辑实现进来(本档交"注册成功"事件的生产者与至少一次接缝契约,消费端开户充值随 WU2;即"不做"的是消费者,不是连事件发射也不做)。
3 方案
3.1 最大决策:单表双身份路径如何共存
game_player 当前把手机号钉成了身份主键——mobile VARCHAR(11) NOT NULL,唯一键 uk_mobile(mobile, deleted, tenant_id),没有任何用户名或密码的概念。要让"没有手机号"的用户也能落到同一张玩家表(这样 id 仍是 OAuth2 token 的 userId,下游一视同仁),必须动三处:加 username、加 password、把 mobile 从 NOT NULL 放宽为 NULL。
这里的关键是唯一键在 NULL 上的行为。MySQL 的 UNIQUE 索引允许索引列取 NULL 时存在多行——当 mobile 为 NULL,uk_mobile 不再对这些行施加唯一约束,于是任意多个"无手机号"的用户名用户可以共存,而手机号非空的用户之间仍然一号一人。反过来,新加的 uk_username(username, deleted, tenant_id) 对手机号用户(username 为 NULL)不设限,对用户名用户严格唯一。两条路径因此在同一张表里互不干扰:
flowchart TB
subgraph 短信/邀请码用户
A["mobile=13800000000<br/>username=NULL<br/>password=NULL"]
end
subgraph 用户名密码用户
B["mobile=NULL<br/>username=seed_alice<br/>password=BCrypt(...)"]
end
A -.受 uk_mobile 约束.-> UKM["uk_mobile 唯一<br/>(NULL 行不参与)"]
B -.受 uk_username 约束.-> UKU["uk_username 唯一<br/>(NULL 行不参与)"]
契约变更(DB schema,contract-first)。新增一支 Flyway 迁移,只做 ALTER,不碰存量数据的值:
| 变更 | 列/键 | 定义 | 说明 |
|---|---|---|---|
| 加列 | username |
VARCHAR(30) NULL |
用户名,^[a-zA-Z0-9]{4,30}$(沿用 admin AuthLoginReqVO 范式);手机号用户为 NULL |
| 加列 | password |
VARCHAR(100) NULL |
BCrypt 密文(约 60 字符,留至 100 兼容算法前缀/未来迁移);非用户名用户为 NULL;永不返回、永不落日志 |
| 加键 | uk_username |
UNIQUE(username, deleted, tenant_id) |
与 uk_mobile 同范式,含 deleted/tenant_id 适配逻辑删+多租户 |
| 改列 | mobile |
VARCHAR(11) NULL(原 NOT NULL) |
放宽可空,承载"无手机号"的用户名用户 |
| 扩语义 | register_channel |
值域加 password |
现有 sms/invite 之外新增,漏斗归因 |
版本号与放置。定序以执行副本 game-cloud/huijing-server/src/main/resources/db/migration/ 为唯一依据——它是真正被 Flyway 应用的 classpath,凡低于已应用版本或与已存在版本同号都会被拦。实测执行副本已连续到 V30.0.0(其中 V26.0.0__aigc_task_add_idempotency_key.sql 已占用 V26),因此新迁移取其上的下一号 V31.0.0__passport_player_add_username_password.sql。落地前把「用 ls .../db/migration | sort -V | tail 复核执行副本当时最大号、若已有人加了更高号则顺延」当作硬前置执行,不能只当脚注——早先本档误记执行副本停在 V19、据此取 V26,正好会与既有 V26 撞号导致 duplicate/checksum 校验失败、迁移根本无法应用,就是漏做这一步。
顺带订正一处 SoT 漂移:契约源 contracts/db-schemas/ 目前只到 V25,缺 V14–V20 与 V26–V30,也就是执行副本反而领先契约源(V11 守门①「同版本同时落源+执行副本」其实早已长期失守,方向与直觉相反)。本单 V31 必须同时落契约源与执行副本把守门①在本次做实;缺失的 V14–V20、V26–V30 回填契约源属既有欠账,另案登记补齐,不在本单强行夹带。文件不放任何单模块 -server/db/migration/,否则同版本出现在多个 classpath jar 会触发 Flyway 重复校验失败。含中文 SQL,mini-desktop 执行须 --default-character-set=utf8mb4。
存量兼容与迁移。存量 game_player 行全部有 mobile、username/password 为新加列取 NULL——mobile NOT NULL → NULL 是放宽,不破坏任何存量行;两个新列默认 NULL,存量短信/邀请码用户天然落在"手机号路径"一侧。PlayerDO 随之补 username/password 两个字段(password 仅服务层内部用,不进任何 RespVO)。
回滚:写补偿迁移 V31.0.1,DROP INDEX uk_username、DROP COLUMN username/password;mobile 收回 NOT NULL 前必须先确认无 NULL 行(即无用户名用户),否则收回会失败——因此回滚前置一步"确认或清理无手机号用户",与 V11 对 player_user_id 放宽的回滚纪律同口径。
3.2 端点与 Service:复用建号事务、BCrypt、发 token 三段
不新建鉴权服务。在 AppPassportController 加两个 @PermitAll 免登端点,在 PassportServiceImpl 加两个方法,把已经验证过的三段拼起来:建号走现有 registerPlayer(扩参承载 username/password),编码校验密码走 admin 侧同款 BCrypt(AdminUserServiceImpl 的 passwordEncoder.encode/matches),发 token 与组装响应直接复用 buildLoginResp——userType=MEMBER、clientId=default、refresh_token 有效期由 OAuth2 client 承载,与短信路产出的 token 一字不差。
sequenceDiagram
participant C as game-studio
participant K as AppPassportController
participant IP as IP 计数闸(纯 IP)
participant S as PassportServiceImpl
participant DB as game_player
participant T as OAuth2TokenService
participant H as WU2 new-api hook
Note over C,K: 注册 password-register
C->>K: {username,password,nickname?,anonId?}
K->>IP: 注册场景 时/日计数++
IP-->>K: 超阈值→429 / 放行
K->>S: passwordRegister(reqVO, clientIp)
S->>DB: selectByUsername(username)
alt 用户名已占用
S-->>C: 1-002-090-004 用户名已被占用
else 可注册
S->>DB: insert(username, BCrypt(password), mobile=NULL, channel=password)
Note over S,DB: 唯一键 uk_username 并发兜底→DuplicateKey 回查报占用
S->>T: createAccessToken(id, MEMBER, default)
S-->>C: LoginRespVO(token...)
S-)H: 事务内写 outbox→投递器发 MQ(至少一次·幂等)
end
Note over C,K: 登录 password-login
C->>K: {username,password,anonId?}
K->>IP: 登录场景 时/日计数++
IP-->>K: 超阈值→429 / 放行
K->>S: passwordLogin(reqVO, clientIp)
S->>DB: selectByUsername(username)
alt 查无 或 密码不匹配
S-->>C: 1-002-090-005 用户名或密码错误(统一)
else 匹配且未禁用
S->>T: createAccessToken(...)
S-->>C: LoginRespVO(token...)
end
防枚举口径的非对称,须讲清。短信路对手机号做"防枚举静默转登录":发码不区分是否已注册、sms-login 未注册即自动注册,外部无从探测某号是否存在。用户名路做不到这种静默——注册时若用户名已被别人占用,不可能悄悄并进别人的账号,必须明确报"用户名已被占用"(错误码 PLAYER_USERNAME_ALREADY_REGISTERED),这在语义上不可避免地暴露了"该用户名存在"。这是用户名体系的固有代价,缓解手段就是本设计的 IP 限流:让批量刷用户名探测的成本变高。登录侧则收紧——"查无此用户名"与"密码错误"一律返回同一个 PLAYER_USERNAME_OR_PASSWORD_ERROR,不给区分,避免用登录接口反推用户名是否存在。只统一错误码还不够:若查无用户名时立即返回、密码错时才跑 BCrypt.matches(有意的高开销),响应时延就把"用户名存在(慢)/不存在(快)"区分开,登录接口仍是个存在性 oracle。因此 passwordLogin 查无用户名时也对一个固定的假 BCrypt hash 跑一次 matches 再返回,让两个分支耗时不可区分,把时序侧信道一并堵掉。
请求/响应 VO 契约(字段级):
PasswordRegisterReqVO
| 字段 | 类型 | 校验 | 说明 |
|---|---|---|---|
username |
String | @NotEmpty @Length(4,30) @Pattern(^[a-zA-Z0-9]{4,30}$) |
用户名,登录主键之一;沿用 admin AuthLoginReqVO 范式 |
password |
String | @NotEmpty @Length(6,32) |
明文密码,服务层立即 BCrypt 编码;比 admin 的 4-16 收紧下限至 6,因本端点直面公网(admin 账号由运营在验证码后台内建,威胁模型不同——生产稳定优先) |
nickname |
String | @Size(max=30) 可选 |
不传则默认生成(见下) |
anonId |
String | @Size(max=64) 可选 |
客户端 anonId,仅注册落 first_anon_id 一次,匿名↔登录归因,与短信路同口径 |
PasswordLoginReqVO:username(同上 Pattern)、password(@NotEmpty,登录不必重复长度校验以免给出策略线索)、anonId?。
响应复用 LoginRespVO(userId / accessToken / refreshToken / expiresTime / nickname / creatorFlag),不新增结构。
registerPlayer 扩参。现签名 registerPlayer(mobile, channel, inviteCodeId, anonId, nickname, clientIp) 把手机号钉死为入参。改为再收 username 与 encodedPassword 两个可空参数(短信/邀请码路传 null,用户名路传 mobile=null),插入时按路径落对应列。默认昵称生成 generateNickname 现取手机号后 4 位,用户名路无手机号,改为:有 nickname 用之,否则用户名路取 username 本身、手机号路维持后 4 位。register_channel 落 password。并发兜底:现有 catch DuplicateKeyException 回查转登录是针对 uk_mobile 的;用户名路命中 uk_username 冲突应报 PLAYER_USERNAME_ALREADY_REGISTERED(不转登录——注册态不能因为撞名就把人送进别人账号),据此分支需按 channel 区分冲突语义。
/me 补 username,并封空安全。PlayerMeRespVO 现返回脱敏 mobile;用户名用户 mobile 为 NULL,前端拿不到可展示身份。补 username 字段(明文,用户名非敏感),mobile 保持脱敏且允许空。这里有个当前实现会踩的坑:getPlayerMe(PassportServiceImpl.java:300)现在无条件 DesensitizedUtil.mobilePhone(player.getMobile()),而用户名用户 mobile 恰为 null——/me 契约必须显式规定 mobile 为 null 时跳过脱敏、直接回空串,实现处对 null 短路(不把 null 塞进脱敏函数),否则用户名用户一调 /me 就可能 NPE 直接回归。这是一处响应契约扩展,前端 PlayerMeResp 接口同步加 username。
密码绝不落控制台,靠机制而非配置。@ApiAccessLog(sanitizeKeys={"password"}) 只脱敏一条链路——它由 ApiAccessLogFilter 消费、写进持久化的 system_api_access_log。但还有第二条链路会漏:ApiAccessLogInterceptor.preHandle(ApiAccessLogInterceptor.java:44-52)在 !isProd 时用 log.info 把原始 requestBody 直接打到控制台/stdout,不受 sanitizeKeys 约束,staging 现靠调低 logging.level 压着。密码是可跨站复用的持久凭据,价值远高于一次性验证码,任何环境抬高日志级别或聚合 stdout 就是明文外泄——不能只靠 staging 的 logging.level 配置兜。因此对 password-register/password-login 两端点做代码级保证:在所有环境让这两个路径的请求体不进 ApiAccessLogInterceptor 的 stdout 打印(按路径跳过该 interceptor 的 body 打印,或对这两个 URI 屏蔽 body),并在冒烟里加一条断言——注册/登录跑一遍后 grep 服务 stdout 必须搜不到明文密码。§5 的安全基线取证据此从「日志无明文」升级为「机制保证 + stdout 断言」,不接受仅配置压制。
错误码(1-002-090 段续接,api 模块 ErrorCodeConstants):
| 码 | 常量 | 文案 |
|---|---|---|
1_002_090_004 |
PLAYER_USERNAME_ALREADY_REGISTERED |
用户名已被占用 |
1_002_090_005 |
PLAYER_USERNAME_OR_PASSWORD_ERROR |
用户名或密码错误 |
1_002_090_006 |
PASSWORD_AUTH_DISABLED |
用户名密码登录通道未开启 |
禁用账号复用 PLAYER_IS_DISABLED(1_002_090_002),IP 越限复用全局 TOO_MANY_REQUESTS(429)——与短信路一致,不为限流新造码。
3.3 IP 限流:落应用层纯 IP 计数,兼澄清"网关层"口径
创始人原话是"网关层对同 IP 限流"。落地前须把口径对齐现实:产品端 game-studio 走单体直连(.env.staging VITE_API_BASE=48080),huijing-gateway 当前不在产品端链路、无活体网关实例。若字面执行"网关层限流",等于要为内测先把网关拉进产品链路,属于计划外的基建改动,与"内测能用就行"的验收强度不符。
两个方案摆清:
-
方案 A(推荐)·应用层纯 IP 计数。复用
PassportSmsIpCounterRedisDAO已经在用的模式——Redis 自增 + 首次设 TTL 的时/日双桶,纯 IP 维度(key 只含 IP 与日期,不含请求体)。在password-register/password-login的 Service 入层先增后判,越限抛TOO_MANY_REQUESTS。今天就能跑、无新基建、能返回精确业务错误码、计数可观测。客户端 IP 的取法是这套防刷成不成立的命门,不能沿用发码路的
ServletUtils.getClientIP()。它底层是 HutoolJakartaServletUtil.getClientIP,优先读X-Forwarded-For并取最左段——而最左段恰恰是客户端能自己写的。内测产品端浏览器单体直连 48080、链路里没有任何反代(§3.3 开头的现实),意味着 XFF 完全由客户端控制:攻击者每个请求塞一个新的伪造 XFF 就落进一个新桶,纯 IP 时/日闸被平凡绕过,用户名占用探测与密码暴破随之不受限。据此定死取 IP 的口径:内测直连场景一律采信连接层request.getRemoteAddr(),不读任何 XFF(无可信反代就不该相信可伪造的头);将来公网上线在 48080 前置一层强制改写 XFF 的可信反代后,再改为只采信反代自己 append 的最右侧受信段,配可信代理白名单,绝不采信客户端可写的最左段。Service 入层从 controller 显式接request.getRemoteAddr()传入计数器,不走getClientIP()。这样限流的桶键锚在真正的连接来源上,才对得起 §1「可防刷」的命名。 -
方案 B(备选/纵深)·nginx
limit_req。在 48080 前置反代上按$binary_remote_addr限速,是字面意义的"网关/边缘层"。优点是请求还没进 JVM 就被挡;缺点是它给的是 503/自定义页而非项目的CommonResult业务错误码、防枚举语义无法在这层表达,且 staging 是否有可控的前置 nginx 尚未确认。
推荐 A 为内测主方案,B 作为公网上线时的纵深加固(真有边缘反代后叠加,与图形验证码一并作上线闸门)。两者不互斥:A 保证业务语义与可测性,B 在更外层削峰。
一个必须避开的坑:框架另有 @RateLimiter + ClientIpRateLimiterKeyResolver 注解式限流,但它的 key 是 md5(方法名 + 方法入参 + IP)(ClientIpRateLimiterKeyResolver 第 20-24 行)。用它保护 password-register/login 会把请求体里的 username/password 拌进 key——攻击者每次换一个用户名就落进不同的桶,纯 IP 限流形同虚设。因此这两个端点不走该注解,一律走 §3.3 方案 A 的服务层纯 IP 计数。send-sms-code 现在既挂了 @RateLimiter(key 含 mobile,非纯 IP,只挡同号同 IP 重放)又有服务层纯 IP 双闸;sms-login 与 invite-register 目前两样都没有,本设计把服务层纯 IP 计数补给它们。
计数组件的复用方式。PassportSmsIpCounterRedisDAO 的 key 前缀写死为 passport_sms_ip_*、方法名是 incrDay/HourAndGet,语义绑死"发码"。若三个新场景(注册/登录/邀请码)直接调它,计数会与发码混桶、语义错乱。取最小且不漂移的改法:把它泛化为按 scene 参数分桶——key 变 passport_ip:{scene}:hour:{ip}:{yyyyMMddHH} / :day:,scene ∈ {sms, register, login, invite};现有发码调用回填 scene=sms。要说清的是这里计数语义等价、但不是无条件"行为等价":Redis key 字符串改了名,上线那一刻旧前缀 passport_sms_ip_* 的存量计数桶全部失效,等于限流窗口对已在计数的 IP 一次性重置,且原先盯旧前缀的观测/告警会断。这几件在内测都无害(桶 TTL 有界、只重置一次、观测同步改名即可),但落档要如实标为"一次性重置",不粉饰成零成本。这样每个场景独立时/日桶、阈值各自可配、单一组件承载,避免每个端点各抄一份计数代码。
频控后端故障时的姿态,须显式决策。现有 incrDay/HourAndGet 直接 stringRedisTemplate.increment 且无 try/catch,Redis 抖动/超时会把异常一路抛穿——若不处理,回填后连注册/登录/sms-login/invite-register 会被频控后端的单点故障整体打挂,认证不可用。按外部交互红线定死姿态:频控计数对 Redis 异常取 fail-open + WARN 告警(捕获异常、记一条含 IP 与场景的告警日志、放行本次请求),理由是内测阶段认证可用性优先于把防刷做到严丝合缝,绝不让 Redis 单点静默拖垮登录;代价(Redis 挂时防刷短暂失效)记账接受,Redis 恢复后限流自动复位。这条决策写进泛化后的计数组件,不留给实现临场拍。
3.4 配置开关与阈值(沿用 wanxiang.passport.* 段)
新增开关一枚、阈值四对,全部进 wanxiang.passport.*,与既有 invite-register-enabled / sms-ip-limit-* / sms-mock-* 同段:
| 键 | 默认 | 说明 |
|---|---|---|
wanxiang.passport.password-auth-enabled |
staging true |
用户名密码注册+登录总开关;关闭时两端点返回 PASSWORD_AUTH_DISABLED(信任边界在后端服务层,不靠前端隐藏 Tab) |
wanxiang.passport.register-ip-limit-per-hour / -per-day |
推断初值 20 / 60 | 注册场景 IP 时/日上限;注册比发码宽松一档,与运营确认后调 |
wanxiang.passport.login-ip-limit-per-hour / -per-day |
推断初值 30 / 100 | 登录场景 IP 时/日上限 |
阈值为推断初值(sms-ip-limit 现为 10/30),标注"与运营确认后调整",不写死进代码常量。
3.5 前端改动面(game-studio)
Login.vue 现为 sms / invite 两 Tab,加第三个"用户名密码"Tab:用户名、密码两个输入框,前端做与后端一致的用户名正则与密码长度预校验(前端不是信任边界,仅提示),复用现有隐私政策勾选、?redirect= 回跳、setLogin 落持久化、user_login 遥测双带(userId+anonId)四段。登录成功路径与短信路完全一致(都拿 LoginResp)。
passport.ts 加 PasswordRegisterReq / PasswordLoginReq 两个入参接口与 passwordRegister / passwordLogin 两个函数,严格贴新增的 VO 契约,走同一个 request 信封解包;PlayerMeResp 接口补 username。locales/entry.zh|en 加该 Tab 的字段标签、按钮、以及三个新错误码的前端文案回落(错误码文案后端已带,前端主要补 Tab 标题与占位符)。
3.6 使用路径与接缝:WU2 new-api 开户充值 hook 的挂载点
内测里"注册成功"是一个业务事件,WU2 要在此刻给新玩家在 new-api 侧开户并发放初始额度。本档只交挂载点与接缝契约,不实现:
外部调用绝不能与本地建号事务耦合——new-api 若超时或 502,不该把一个已经合法的注册回滚掉,也不该让用户卡在注册请求上等外部系统。但"解耦"不等于可以丢消息:如果只是 afterCommit 里直发 MQ,事务已提交、消息却在 produce 侧发送超时/失败或应用此刻崩溃,就会出现"注册成功了、开户事件从未入队"的黑洞,重试队列/死信只能兜"已投递但消费失败",兜不到根本没发出的那批——"先能登录、额度稍后补"会在这个丢消息窗口里静默落空。按"生产稳定>开发期简单"红线,afterCommit 直发是 lean 路径,生产级现货是事务性 outbox,本档对 WU2 的接缝按至少一次收紧、定死一种机制,不留给实现自选:
- 机制(定死一种·本地消息表 outbox):注册事务内同库写一行 outbox 记录(
registerPlayer提交时与建号原子落库,载荷含userId、registerChannel、anonId、幂等键);事务外由投递器扫 outbox 发 RocketMQ、发成功后标记已投递。建号成功则 outbox 记录必已落库,投递器保证最终发出——把"至少一次"锚在数据库事务上,而非 MQ 的即时可达性上。三条注册路(sms 自动注册、invite、password)共用这一张出口,WU2 无需感知登录方式。(若后续统一走 RocketMQ 事务消息亦可,但那也是"至少一次"的一种实现,接缝语义不变。) - 生产者归属:写 outbox 是 WU1 的交付(本单只做生产者),消费端 new-api 开户充值随 WU2;本单不实现消费。这样验收项"注册成功事件被发出"有对应交付物,不成孤儿。
- 幂等:以
userId为幂等键,new-api 开户与充值必须可重放不重复发额(同一 userId 二次消费为 no-op)——因为"至少一次"必然带来重复投递,消费侧幂等是配套硬要求。 - 超时/失败/重试:投递器发 MQ 设超时;发失败保留 outbox 未投递态下轮重投(有限次退避)。消费侧调 new-api 设超时;消费失败进重试队列(有限次退避)。
- 补偿:投递或消费重试耗尽落死信/告警,由运营侧补开户;注册本身已成功,不因开户失败对用户可见地失败——玩家先能登录,额度稍后补。
本设计对 WU2 的硬要求两条:一是 hook 消费的是"注册成功"事件而非"password 注册成功"事件,口径统一在三条路的公共出口;二是投递语义是至少一次(outbox 兜底),消费侧据此做幂等,WU2 不得实现只在 afterCommit 直发的有损版本再自称达标。
4 步骤计划
代码物随执行阶段(阶段 1)落地,本档只定序与验收。
flowchart LR
S1["① 契约<br/>Flyway V31 + VO + 错误码 + PlayerDO/Mapper"] --> S2["② 服务层<br/>registerPlayer 扩参 + password 两方法 + BCrypt + IP 计数泛化"]
S2 --> S3["③ 端点+配置<br/>两 @PermitAll 端点 + wanxiang.passport 开关阈值 + 回填 sms/invite IP 闸"]
S3 --> S4["④ 前端<br/>Login.vue Tab + passport.ts + locales"]
S4 --> S5["⑤ 真机验收<br/>注册→登录→限流→零回归 + hook 挂点冒烟"]
| 阶段 | 交付物 | 验证 | 依赖 | 风险 |
|---|---|---|---|---|
| ① 契约 | V31 迁移(两处:契约源+执行副本)、PasswordRegister/LoginReqVO、LoginRespVO 复用、PlayerMeRespVO 加 username、三个错误码、PlayerDO+PlayerMapper.selectByUsername |
Flyway 迁移在 dev 库应用无版本乱序/无同号;建表 DDL 校验通过 | 落地前 ls .../db/migration | sort -V | tail 硬复核执行副本最大号再定号 |
mobile 放宽的存量兼容(放宽为松约束,低风险) |
| ② 服务层 | registerPlayer 扩参、passwordRegister/passwordLogin(含查无用户名跑固定假 hash 对齐耗时)、BCrypt 编码校验、PassportSmsIpCounterRedisDAO 泛化 scene + Redis 异常 fail-open 告警、注册事务内写 outbox(注册成功事件生产者) |
服务层单测:注册占用、登录防枚举(错误码+耗时不可区分)、禁用拒登、并发撞名兜底、IP 越限抛 429、Redis 异常放行且告警、outbox 记录随建号原子落库 | ① | 泛化计数组件 key 改名致存量桶一次性重置(TTL 有界、内测无害,如实记账) |
| ③ 端点+配置 | 两 @PermitAll 端点(@ApiAccessLog(sanitizeKeys={"password"}) 且代码级绕过 ApiAccessLogInterceptor 的 stdout body 打印)、Service 入层取 request.getRemoteAddr() 传入计数、wanxiang.passport.* 新键、回填 sms-login/invite-register 的服务层 IP 闸 |
端点级:Knife4j/curl 打通;回填不改短信/邀请码语义;冒烟 grep stdout 无明文密码 | ② | IP 取连接层 remote-addr(不采信 XFF);上线接反代后须切最右受信段 |
| ④ 前端 | Login.vue 用户名密码 Tab、passport.ts 两函数+PlayerMeResp.username、locales |
构建通过;本地联调三 Tab 均可登录 | ③ | 与既有两 Tab 状态不串 |
| ⑤ 真机验收 | dev 单点串行 e2e | §5 判据全绿 | ①-④ | — |
5 验证方式
全部可真机取证或机器验,判据与 §1 目标一一对应:
- 注册返 token:对未占用用户名 POST
password-register,返回体含非空accessToken/refreshToken、userId>0;库内game_player出现username=该名, mobile=NULL, register_channel=password, password为 BCrypt 密文($2a$前缀、非明文)。 - 登录成功且防枚举:用同一对凭据 POST
password-login返回 token;密码错一位 →1-002-090-005;换一个不存在的用户名同样是1-002-090-005(两者文案一致,不可区分);抽样比对"查无用户名"与"密码错误"两分支的响应时延处于同一量级(固定假 hash 已对齐 BCrypt 耗时),不构成存在性 oracle。 - 占用可见、登录不可见:重复注册同用户名 →
1-002-090-004 用户名已被占用(这是有意暴露,配合限流)。 - IP 限流生效:同 IP 连续注册/登录超过配置阈值 →
429 请求过于频繁;日志出现限流 WARN 且含 IP 与计数;Redis 见passport_ip:register:*/:login:*计数桶。 - 伪造 XFF 不能绕过:携带每次变化的伪造
X-Forwarded-For高频打注册/登录,仍按连接来源被 429(证明限流锚在getRemoteAddr()、不采信客户端可写的 XFF)。 - 回填生效:同 IP 高频打
sms-login/invite-register同样被 429(此前无此保护)。 - 存量零回归:短信验证码登录(含
sms-mock8888)、自动注册、邀请码注册四条路真机各跑一遍,行为与改动前一致;OAuth2 token、/me、创作守卫不受影响。 - token 后续可用 + /me 空安全:password 路拿到的 token 调
/me不 NPE、返回该用户身份(username有值、mobile为 null 时回空串不报错),并能通行一次需登录态的下游调用——证明与短信路 token 等价、且脱敏对 null 已封空安全。 - 开户事件生产者冒烟:注册成功后 outbox 表出现对应记录且被投递器发出 MQ(日志/MQ 可观测),验证生产者已交付;WU2 实现前此处只验事件被发出,不验开户结果。
- 安全基线(机制保证,非配置压制):
system_api_access_log中 password 键被脱敏、不见明文;且冒烟里对password-register/password-login跑一遍后 grep 服务 stdout/控制台断言搜不到明文密码(覆盖ApiAccessLogInterceptor那条链路);服务日志只见userId,无密码、无 token。
6 风险与回滚
- 客户端 IP 取法:限流按 §3.3 采信连接层
request.getRemoteAddr()、不读客户端可伪造的 XFF,堵死「伪造 XFF 换桶绕过」这一最危险方向;§5 专门有一条「伪造 XFF 高频打注册/登录仍被 429」取证。反方向的残余前提是——将来公网上线在 48080 前置可信反代后,getRemoteAddr()会塌成反代地址、限流退化为全局闸;届时必须切到「只采信反代 append 的最右侧受信段 + 可信代理白名单」,这是上线闸门的一项,不在内测范围内提前做。 - 单账号多 IP 暴破:本单防刷是纯 IP 单维度,没有按
username的失败登录节流或锁定。配合弱口令下限(6 位、无复杂度、无图形验证码)与 login 默认 100 次/日/IP,攻击者用多 IP 轮转打同一个目标账号时,每个账号没有独立上限。内测阶段先记账接受该残余风险,触发处置是「一旦观测到针对单账号的高失败率,立即用 §3.3 的 scene 计数器加一个按username的失败桶做短时锁定」——组件现成(scene ∈ 增加login_fail),越限临时锁定该账号;账号级失败锁定与图形验证码同列为公网上线闸门。 - 密码策略偏弱:内测取
@Length(6,32)、无复杂度要求、无图形验证码,靠 IP 限流兜防刷。这是"内测能用就行"的刻意取舍;公网上线前把图形验证码/滑块与密码复杂度一并补上(与sms-mock关闭、短信网关接入同属上线闸门)。 - 用户名枚举:注册占用错误不可避免地暴露用户名存在性,限流是唯一缓解。若内测发现被刷,先下调
register-ip-limit,再考虑提前上图形验证码。 - 总开关误关:
password-auth-enabled=false会让两端点直接拒服务;它是后端信任边界的一部分,前端仅据此隐藏 Tab,不能反向依赖前端。 - 回滚:功能级回滚 =
password-auth-enabled=false(端点即时停用,无需改码)。schema 级回滚 = 补偿迁移V31.0.1,mobile收回 NOT NULL 前须先确认/清理无手机号用户,否则收回失败。前端回滚 = 隐藏用户名密码 Tab。三层可独立回退,互不阻塞。
附 图清单与状态
- 图0 一图看懂(§0,Mermaid,事实源)— 现行
- 图1 单表双身份路径共存(§3.1,Mermaid)— 现行
- 图2 注册/登录时序含 IP 闸与开户 hook(§3.2,Mermaid)— 现行
- 图3 步骤计划(§4,Mermaid)— 现行
SVG 门面(§0 概览大图,给人 30 秒看懂)按 feature-design-doc 规约由图层 opus 子代理据本文字+Mermaid 派生,尚未产出,收口前补;图文冲突以本文字为准。本档过双评审门(Codex + Opus,Codex 挂回落 Opus 单评)后交创始人批,批准后随阶段 1 落代码物。