From e3ecac85f3df98f8b67b6b1be255de5910c16cef Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Sun, 7 Jun 2026 00:42:05 +0800 Subject: [PATCH] fix: refine mini program nickname collection flow --- ...】server-rs与SpacetimeDB数据契约-2026-05-15.md | 2 +- ...项目基线】当前产品与工程约束-2026-05-15.md | 4 +- miniprogram/pages/web-view/index.js | 77 ++++++++++++++++++- miniprogram/pages/web-view/index.wxml | 4 +- packages/shared/src/contracts/auth.ts | 1 + server-rs/crates/api-server/src/app.rs | 4 +- .../crates/api-server/src/wechat_auth.rs | 1 + server-rs/crates/shared-contracts/src/auth.rs | 26 +++++++ 8 files changed, 109 insertions(+), 10 deletions(-) diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index 0a048ad0..5f583c46 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -74,7 +74,7 @@ npm run check:server-rs-ddd ### 认证态用户与会话摘要下发口径 -- `AuthUserPayload` / `AuthUser` 只保留前端当前会用到的身份与绑定展示字段:`id`、`publicUserCode`、`displayName`、`avatarUrl`、`phoneNumber`、`phoneNumberMasked`、`loginMethod`、`bindingStatus`、`wechatBound`、`wechatDisplayName`、`wechatAccount`。账号信息面板展示微信绑定时优先使用 `wechatDisplayName`;该字段只能来自微信平台 profile、历史已保存的微信身份资料,或小程序原生 `input type="nickname"` 提交的 `displayName`,不得用系统账号显示名或“微信旅人”这类假昵称兜底。小程序 `/api/auth/wechat/miniprogram-login` 与 `/api/auth/wechat/bind-phone` 可接收 `displayName`;`jscode2session` 无法直接返回微信昵称或个人微信号,只能稳定拿到小程序维度 `openid`,后端以 `wechatAccount` 下发可区分的绑定账号标识,前端在缺少真实昵称时展示账号尾号。 +- `AuthUserPayload` / `AuthUser` 只保留前端当前会用到的身份与绑定展示字段:`id`、`publicUserCode`、`displayName`、`avatarUrl`、`phoneNumber`、`phoneNumberMasked`、`loginMethod`、`bindingStatus`、`wechatBound`、`wechatDisplayName`、`wechatAccount`。账号信息面板展示微信绑定时优先使用 `wechatDisplayName`;该字段只能来自微信平台 profile、历史已保存的微信身份资料,或小程序原生 `input type="nickname"` 提交的 `displayName`,不得用系统账号显示名或“微信旅人”这类假昵称兜底。小程序 `/api/auth/wechat/miniprogram-login` 与 `/api/auth/wechat/bind-phone` 可接收 `displayName`;`/api/auth/wechat/miniprogram-login` 额外返回 `created`,供小程序壳在快捷登录后判断是否需要补采集微信昵称。`jscode2session` 无法直接返回微信昵称或个人微信号,只能稳定拿到小程序维度 `openid`,后端以 `wechatAccount` 下发可区分的绑定账号标识,前端在缺少真实昵称时展示账号尾号。 - `AuthSessionSummaryPayload` / `AuthSessionSummary` 只保留设备卡片与撤销需要的摘要字段:`sessionId`、`sessionIds`、`sessionCount`、`clientLabel`、`ipMasked`、`isCurrent`、`createdAt`、`lastSeenAt`、`expiresAt`。 - 设备诊断信息(例如原始 `clientType` / `clientRuntime` / `clientPlatform` / `userAgent` / `miniProgramAppId` / `miniProgramEnv` / `deviceDisplayName`)不再默认下发到前端;若未来确需展示,优先单独加窄 DTO,而不是把账号 / 会话快照恢复为全量对象。 diff --git a/docs/【项目基线】当前产品与工程约束-2026-05-15.md b/docs/【项目基线】当前产品与工程约束-2026-05-15.md index 33c98a46..d35b1139 100644 --- a/docs/【项目基线】当前产品与工程约束-2026-05-15.md +++ b/docs/【项目基线】当前产品与工程约束-2026-05-15.md @@ -45,11 +45,11 @@ Genarrative / 陶泥儿是一个 AI 原生互动内容与小游戏平台。当 2. `login-options` 为空、失败、只返回 `phone` 或只返回 `password` 时,前端仍要同时展示验证码登录页签和密码登录页签;短信能力真实可用性由发送验证码接口返回结果表达。 3. 登录弹窗继续复用现有独立 modal 和页签结构,不在页面中新增功能说明类文案,也不把邀请码输入放回登录面板。 4. 微信小程序 `web-view` 外壳默认不预登录,首次进入直接打开 H5,并保持与 Web 端一致的未登录状态;只有 H5 触发 `openLoginModal` / `requireAuth` 等受保护入口时,才跳转小程序原生授权态。 -5. 小程序内需要登录时不展示 H5 登录弹窗,也不走手输手机号 / 短信验证码流程;统一先通过原生 `input type="nickname"` 获取微信昵称并作为 `displayName`,再通过 `wx.login` 获取微信登录 code 并调用 `/api/auth/wechat/miniprogram-login`。若后端返回 `pending_bind_phone`,再通过原生 `button open-type="getPhoneNumber"` 获取微信手机号授权并调用 `/api/auth/wechat/bind-phone` 换取系统登录态。 +5. 小程序内需要登录时不展示 H5 登录弹窗,也不走手输手机号 / 短信验证码流程;统一先通过 `wx.login` 获取微信登录 code 并调用 `/api/auth/wechat/miniprogram-login` 完成快捷登录。若该接口返回 `created=true`,或返回用户昵称仍是手机号、公开陶泥号、“微信旅人”等默认展示值,才展示原生 `input type="nickname"` 补充微信昵称并再次调用 `/api/auth/wechat/miniprogram-login` 写入 `displayName`。若后端返回 `pending_bind_phone`,再通过原生 `button open-type="getPhoneNumber"` 获取微信手机号授权并调用 `/api/auth/wechat/bind-phone` 换取系统登录态。 6. 小程序外壳注入到 H5 URL 的 `clientType`、`clientRuntime`、`miniProgramEnv` 是宿主上下文,H5 内部 `pushState` / 阶段导航必须跨页面保留,避免登录和充值误判为普通浏览器;首点时微信 JS bridge 可能尚未就绪,前端还需用 `MicroMessenger + miniProgram` User-Agent 作为小程序识别兜底。 7. 小程序 `web-view` 页必须启用好友分享与朋友圈分享,分享目标固定回到 `pages/web-view/index`,不把 H5 当前 URL 作为不受控启动参数传回小程序页。 8. 小程序 `web-view` 外壳运行时通过 `wx.getAccountInfoSync().miniProgram.envVersion` 自动识别版本:线上版 `release` 使用 `www.genarrative.world`,体验版 `trial` 与开发版 `develop` 使用 `dev.genarrative.world`;传给后端的 `x-mini-program-env` 分别为 `release`、`trial`、`dev`。 -9. 账号信息面板只展示 `账号信息` 标题;绑定手机号和绑定微信以紧凑模块展示当前绑定状态,已绑定手机号展示完整手机号,已绑定微信优先展示微信平台实际返回并由后端保存的 `wechatDisplayName`。小程序 `jscode2session` 不能直接返回微信昵称或个人微信号,只能稳定拿到当前小程序维度的 `openid`,并在满足微信开放平台条件时拿到 `unionid`;小程序昵称来自原生 `input type="nickname"` 提交的 `displayName`。后端下发 `wechatAccount` 作为绑定账号标识,前端在没有真实昵称时展示微信账号尾号,不展示裸“已绑定”。换绑入口放在对应模块右上角,退出登录和退出全部设备固定放在面板内容最底部。 +9. 账号信息面板只展示 `账号信息` 标题;绑定手机号和绑定微信以紧凑模块展示当前绑定状态,已绑定手机号展示完整手机号,已绑定微信优先展示微信平台实际返回并由后端保存的 `wechatDisplayName`。小程序 `jscode2session` 不能直接返回微信昵称或个人微信号,只能稳定拿到当前小程序维度的 `openid`,并在满足微信开放平台条件时拿到 `unionid`;小程序昵称来自快捷登录后按需展示的原生 `input type="nickname"` 提交的 `displayName`。后端下发 `wechatAccount` 作为绑定账号标识,前端在没有真实昵称时展示微信账号尾号,不展示裸“已绑定”。换绑入口放在对应模块右上角,退出登录和退出全部设备固定放在面板内容最底部。 ## 账户与充值 diff --git a/miniprogram/pages/web-view/index.js b/miniprogram/pages/web-view/index.js index 51724d71..db8f0233 100644 --- a/miniprogram/pages/web-view/index.js +++ b/miniprogram/pages/web-view/index.js @@ -111,6 +111,58 @@ function normalizeNicknameInput(value) { return String(value || '').trim(); } +function normalizeNicknameForMatch(value) { + return normalizeNicknameInput(value).replace(/\s+/gu, '').toLowerCase(); +} + +function isPhoneLikeDisplayName(value) { + const normalized = normalizeNicknameForMatch(value); + if (!normalized) { + return false; + } + + const digits = normalized.replace(/\D/gu, ''); + return ( + /^(\+?86)?1\d{10}$/u.test(normalized) || + /^1\d{2}\*{4}\d{4}$/u.test(normalized) || + (/[*x]/iu.test(normalized) && digits.length >= 7) || + digits.length >= 11 + ); +} + +function isDefaultDisplayName(value, publicUserCode) { + const normalized = normalizeNicknameForMatch(value); + const normalizedPublicUserCode = normalizeNicknameForMatch(publicUserCode); + if (!normalized) { + return true; + } + + return ( + normalized === '微信旅人' || + normalized === '玩家' || + normalized === normalizedPublicUserCode || + /^sy-\d{8}$/iu.test(normalized) || + /^user[_-]/iu.test(normalized) || + isPhoneLikeDisplayName(normalized) + ); +} + +function shouldRequestNicknameAfterLogin(authResult) { + const user = authResult && authResult.user ? authResult.user : {}; + const wechatDisplayName = normalizeNicknameInput(user.wechatDisplayName); + if (wechatDisplayName && !isDefaultDisplayName(wechatDisplayName, user.publicUserCode)) { + return false; + } + + return ( + authResult && + (authResult.created || + isDefaultDisplayName(user.displayName, user.publicUserCode) || + (wechatDisplayName && + isDefaultDisplayName(wechatDisplayName, user.publicUserCode))) + ); +} + function normalizeMiniProgramEnv(value) { const normalized = String(value || '').trim().toLowerCase(); if (normalized === 'release') { @@ -372,6 +424,8 @@ async function resolveAuthResult(displayName) { return { token: response.token, bindingStatus: response.bindingStatus || 'pending_bind_phone', + user: response.user || null, + created: response.created === true, }; } @@ -431,13 +485,14 @@ Page({ authResult: null, bindingPhone: false, errorMessage: '', - loggingIn: false, - loading: false, - nicknameRequired: true, + loggingIn: true, + loading: true, + nicknameRequired: false, phoneBindingRequired: false, returnToPreviousPage, webViewUrl: '', }); + await this.startAuthFlow(returnToPreviousPage, ''); }, handleNicknameInput(event) { @@ -465,6 +520,20 @@ Page({ async startAuthFlow(returnToPreviousPage, displayName) { try { const authResult = await resolveAuthResult(displayName); + if (!displayName && shouldRequestNicknameAfterLogin(authResult)) { + this.setData({ + authResult, + errorMessage: '', + loggingIn: false, + loading: false, + nicknameRequired: true, + phoneBindingRequired: false, + returnToPreviousPage, + webViewUrl: '', + }); + return; + } + if (authResult.bindingStatus === 'pending_bind_phone') { this.setData({ authResult, @@ -512,7 +581,7 @@ Page({ error && error.message ? error.message : '微信登录失败,请稍后重试。', loggingIn: false, loading: false, - nicknameRequired: true, + nicknameRequired: false, phoneBindingRequired: false, returnToPreviousPage, webViewUrl: '', diff --git a/miniprogram/pages/web-view/index.wxml b/miniprogram/pages/web-view/index.wxml index a00efe76..942190a9 100644 --- a/miniprogram/pages/web-view/index.wxml +++ b/miniprogram/pages/web-view/index.wxml @@ -10,7 +10,7 @@ - 登录 + 完善昵称 {{errorMessage}} @@ -29,7 +29,7 @@ disabled="{{loggingIn}}" bindtap="handleStartLogin" > - {{loggingIn ? '正在登录' : '微信快捷登录'}} + {{loggingIn ? '正在提交' : '确认昵称'}} diff --git a/packages/shared/src/contracts/auth.ts b/packages/shared/src/contracts/auth.ts index cc9eff13..ab3202d9 100644 --- a/packages/shared/src/contracts/auth.ts +++ b/packages/shared/src/contracts/auth.ts @@ -143,6 +143,7 @@ export type AuthWechatMiniProgramLoginResponse = { token: string; bindingStatus: AuthBindingStatus; user: AuthUser; + created: boolean; }; export type AuthPhoneChangeRequest = { diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs index 8b6cb151..339abb1a 100644 --- a/server-rs/crates/api-server/src/app.rs +++ b/server-rs/crates/api-server/src/app.rs @@ -2640,6 +2640,7 @@ mod tests { login_payload["bindingStatus"], Value::String("pending_bind_phone".to_string()) ); + assert_eq!(login_payload["created"], Value::Bool(true)); assert_eq!( login_payload["user"]["loginMethod"], Value::String("wechat".to_string()) @@ -2746,6 +2747,7 @@ mod tests { login_payload["bindingStatus"], Value::String("pending_bind_phone".to_string()) ); + assert_eq!(login_payload["created"], Value::Bool(true)); let bind_response = app .oneshot( @@ -4423,4 +4425,4 @@ mod tests { assert_eq!(response.status(), StatusCode::NOT_FOUND, "{path}"); } } -} \ No newline at end of file +} diff --git a/server-rs/crates/api-server/src/wechat_auth.rs b/server-rs/crates/api-server/src/wechat_auth.rs index a8c930b5..165dc5e7 100644 --- a/server-rs/crates/api-server/src/wechat_auth.rs +++ b/server-rs/crates/api-server/src/wechat_auth.rs @@ -349,6 +349,7 @@ pub async fn login_wechat_mini_program( token: signed_session.access_token, binding_status: result.user.binding_status.as_str().to_string(), user: map_auth_user_payload(result.user), + created: result.created, }, ), )) diff --git a/server-rs/crates/shared-contracts/src/auth.rs b/server-rs/crates/shared-contracts/src/auth.rs index a9024f9b..038e260c 100644 --- a/server-rs/crates/shared-contracts/src/auth.rs +++ b/server-rs/crates/shared-contracts/src/auth.rs @@ -253,6 +253,7 @@ pub struct WechatMiniProgramLoginResponse { pub token: String, pub binding_status: String, pub user: AuthUserPayload, + pub created: bool, } pub fn build_available_login_methods( @@ -389,4 +390,29 @@ mod tests { }) ); } + + #[test] + fn wechat_mini_program_login_response_marks_created_user() { + let payload = serde_json::to_value(WechatMiniProgramLoginResponse { + token: "token-001".to_string(), + binding_status: AUTH_BINDING_STATUS_PENDING_BIND_PHONE.to_string(), + user: AuthUserPayload { + id: "user_001".to_string(), + public_user_code: "SY-00000001".to_string(), + display_name: "微信旅人".to_string(), + avatar_url: None, + phone_number: None, + phone_number_masked: None, + login_method: AUTH_LOGIN_METHOD_WECHAT.to_string(), + binding_status: AUTH_BINDING_STATUS_PENDING_BIND_PHONE.to_string(), + wechat_bound: true, + wechat_display_name: None, + wechat_account: Some("wx-openid-001".to_string()), + }, + created: true, + }) + .expect("payload should serialize"); + + assert_eq!(payload["created"], serde_json::Value::Bool(true)); + } }