games-development-ai/docs/agent-specs/2026-07-07-内测-WU1-注册登录用户名密码-设计.md
lili a54ba9bb42 docs(neice): 内测闭环三份设计档(WU1注册登录/WU2 newapi额度预置池/WU3 dify节点) + ops 池预置脚本
阶段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>
2026-07-07 18:05:00 -07:00

35 KiB
Raw Blame History

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
contracts/db-schemas/V11.0.0__create_passport_player_invite.sql @ cb9f135d被扩展的 game_player 建表)
game-cloud/huijing-module-system/.../service/passport/PassportServiceImpl.java @ cb9f135dregisterPlayer / buildLoginResp 复用锚)
game-cloud/huijing-module-system/.../controller/app/passport/AppPassportController.java @ cb9f135d新增端点挂载点
game-cloud/huijing-module-system/.../dal/redis/passport/PassportSmsIpCounterRedisDAO.java @ cb9f135d纯 IP 计数复用锚)
game-cloud/huijing-module-system/.../service/user/AdminUserServiceImpl.java:572-583 @ cb9f135dBCrypt 范式)
game-cloud/huijing-module-system/.../controller/admin/auth/vo/AuthLoginReqVO.java:24-33 @ cb9f135d用户名/密码校验范式)
game-studio/src/api/passport.ts、src/views/login/Login.vue @ cb9f135d前端改动面
图0 一图看懂
图1 双身份路径共存
图2 注册/登录时序含 IP 闸与开户 hook
图3 步骤计划

内测·用户名密码注册登录 功能设计

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-logininvite-register
  • 零回归:短信验证码登录、自动注册、邀请码注册、sms-mock 后门四条既有路径行为不变。

2 边界

game_playerusername / password 两列并放宽 mobile 为可空、加 uk_username;新增 password-registerpassword-login 两个免登端点及其请求/响应契约用户名密码校验、BCrypt 编码与校验、防枚举口径;应用层纯 IP 计数防刷(新端点 + 回填 sms-login / invite-registerwanxiang.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 为 NULLuk_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 schemacontract-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缺 V14V20 与 V26V30也就是执行副本反而领先契约源V11 守门①「同版本同时落源+执行副本」其实早已长期失守,方向与直觉相反)。本单 V31 必须同时落契约源与执行副本把守门①在本次做实;缺失的 V14V20、V26V30 回填契约源属既有欠账,另案登记补齐,不在本单强行夹带。文件不放任何单模块 -server/db/migration/,否则同版本出现在多个 classpath jar 会触发 Flyway 重复校验失败。含中文 SQLmini-desktop 执行须 --default-character-set=utf8mb4

存量兼容与迁移。存量 game_player 行全部有 mobileusername/password 为新加列取 NULL——mobile NOT NULL → NULL 是放宽,不破坏任何存量行;两个新列默认 NULL存量短信/邀请码用户天然落在"手机号路径"一侧。PlayerDO 随之补 username/password 两个字段(password 仅服务层内部用,不进任何 RespVO

回滚:写补偿迁移 V31.0.1DROP INDEX uk_usernameDROP COLUMN username/passwordmobile 收回 NOT NULL 前必须先确认无 NULL 行(即无用户名用户),否则收回会失败——因此回滚前置一步"确认或清理无手机号用户",与 V11 对 player_user_id 放宽的回滚纪律同口径。

3.2 端点与 Service复用建号事务、BCrypt、发 token 三段

不新建鉴权服务。在 AppPassportController 加两个 @PermitAll 免登端点,在 PassportServiceImpl 加两个方法,把已经验证过的三段拼起来:建号走现有 registerPlayer(扩参承载 username/password编码校验密码走 admin 侧同款 BCryptAdminUserServiceImplpasswordEncoder.encode/matches),发 token 与组装响应直接复用 buildLoginResp——userType=MEMBERclientId=defaultrefresh_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 一次,匿名↔登录归因,与短信路同口径

PasswordLoginReqVOusername(同上 Patternpassword@NotEmpty,登录不必重复长度校验以免给出策略线索)、anonId?

响应复用 LoginRespVOuserId / accessToken / refreshToken / expiresTime / nickname / creatorFlag),不新增结构。

registerPlayer 扩参。现签名 registerPlayer(mobile, channel, inviteCodeId, anonId, nickname, clientIp) 把手机号钉死为入参。改为再收 usernameencodedPassword 两个可空参数(短信/邀请码路传 null用户名路传 mobile=null插入时按路径落对应列。默认昵称生成 generateNickname 现取手机号后 4 位,用户名路无手机号,改为:有 nickname 用之,否则用户名路取 username 本身、手机号路维持后 4 位。register_channelpassword。并发兜底:现有 catch DuplicateKeyException 回查转登录是针对 uk_mobile 的;用户名路命中 uk_username 冲突应报 PLAYER_USERNAME_ALREADY_REGISTERED(不转登录——注册态不能因为撞名就把人送进别人账号),据此分支需按 channel 区分冲突语义。

/meusername,并封空安全PlayerMeRespVO 现返回脱敏 mobile;用户名用户 mobile 为 NULL前端拿不到可展示身份。补 username 字段(明文,用户名非敏感),mobile 保持脱敏且允许空。这里有个当前实现会踩的坑:getPlayerMePassportServiceImpl.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.preHandleApiAccessLogInterceptor.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=48080huijing-gateway 当前不在产品端链路、无活体网关实例。若字面执行"网关层限流",等于要为内测先把网关拉进产品链路,属于计划外的基建改动,与"内测能用就行"的验收强度不符。

两个方案摆清:

  • 方案 A推荐·应用层纯 IP 计数。复用 PassportSmsIpCounterRedisDAO 已经在用的模式——Redis 自增 + 首次设 TTL 的时/日双桶,纯 IP 维度key 只含 IP 与日期,不含请求体)。在 password-register/password-login 的 Service 入层先增后判,越限抛 TOO_MANY_REQUESTS。今天就能跑、无新基建、能返回精确业务错误码、计数可观测。

    客户端 IP 的取法是这套防刷成不成立的命门,不能沿用发码路的 ServletUtils.getClientIP()。它底层是 Hutool JakartaServletUtil.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 现在既挂了 @RateLimiterkey 含 mobile非纯 IP只挡同号同 IP 重放)又有服务层纯 IP 双闸;sms-logininvite-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/catchRedis 抖动/超时会把异常一路抛穿——若不处理,回填后连注册/登录/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.tsPasswordRegisterReq / PasswordLoginReq 两个入参接口与 passwordRegister / passwordLogin 两个函数,严格贴新增的 VO 契约,走同一个 request 信封解包;PlayerMeResp 接口补 usernamelocales/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 提交时与建号原子落库,载荷含 userIdregisterChannelanonId、幂等键);事务外由投递器扫 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/LoginReqVOLoginRespVO 复用、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/refreshTokenuserId>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-mock 8888、自动注册、邀请码注册四条路真机各跑一遍行为与改动前一致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.1mobile 收回 NOT NULL 前须先确认/清理无手机号用户,否则收回失败。前端回滚 = 隐藏用户名密码 Tab。三层可独立回退互不阻塞。

附 图清单与状态

  • 图0 一图看懂§0Mermaid事实源— 现行
  • 图1 单表双身份路径共存§3.1Mermaid— 现行
  • 图2 注册/登录时序含 IP 闸与开户 hook§3.2Mermaid— 现行
  • 图3 步骤计划§4Mermaid— 现行

SVG 门面§0 概览大图,给人 30 秒看懂)按 feature-design-doc 规约由图层 opus 子代理据本文字+Mermaid 派生尚未产出收口前补图文冲突以本文字为准。本档过双评审门Codex + OpusCodex 挂回落 Opus 单评)后交创始人批,批准后随阶段 1 落代码物。