diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index 0ca4927b..b099ad78 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -16,6 +16,14 @@ --- +## 2026-06-06 小程序微信绑定展示使用原生昵称组件 + +- 背景:账号信息面板需要显示“绑定的是哪个微信号”。微信小程序登录 `jscode2session` 不返回昵称或个人微信号,但小程序提供 `input type="nickname"` 原生昵称填写 / 选择能力,可在登录前收集微信昵称用于展示。 +- 决策:小程序登录页先展示原生 `input type="nickname"`,将昵称作为 `displayName` 随 `/api/auth/wechat/miniprogram-login` 提交;若还需要绑定手机号,再随 `/api/auth/wechat/bind-phone` 一并提交。`wechatDisplayName` 只能来自微信平台 profile、历史已保存的微信身份资料或小程序原生昵称组件,不能用系统账号显示名或“微信旅人”兜底。小程序侧拿不到昵称时,前端使用后端下发的 `wechatAccount`(openid / provider_uid)尾号展示,避免只显示裸“已绑定”。 +- 影响范围:`platform-auth` 小程序登录 profile、`module-auth` 微信身份持久化、`api-server` 小程序登录 / 绑定响应、账号信息面板、项目基线和后端契约文档。 +- 验证方式:`npm run test -- src/components/auth/AccountModal.test.tsx`、`cargo test -p platform-auth --manifest-path server-rs/Cargo.toml`、`cargo test -p module-auth --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server --manifest-path server-rs/Cargo.toml wechat_miniprogram`、`npm run typecheck`、`npm run check:encoding`。 +- 关联文档:`docs/【项目基线】当前产品与工程约束-2026-05-15.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。 + ## 2026-06-03 拼消消收敛为单关 6x6 与 4-sheet 素材策略 - 背景:最初 4 关 / 135 次消除 / 单张大 atlas 方案生图数量和空间一致性成本过高,真实 image2 结果容易被布局提示词诱导成带文字、边框或编号的说明图,不适合运行态 1x1 切片。 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index 99b92d3a..0a048ad0 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`、`phoneNumberMasked`、`loginMethod`、`bindingStatus`、`wechatBound`。 +- `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` 下发可区分的绑定账号标识,前端在缺少真实昵称时展示账号尾号。 - `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 8638b222..33c98a46 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 登录弹窗,也不走手输手机号 / 短信验证码流程;统一通过原生 `button open-type="getPhoneNumber"` 获取微信手机号授权,再调用 `/api/auth/wechat/miniprogram-login` 与 `/api/auth/wechat/bind-phone` 换取系统登录态。 +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` 换取系统登录态。 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. 账号信息面板只展示 `账号信息` 标题;绑定手机号和绑定微信以紧凑模块展示当前绑定状态,已绑定手机号展示完整手机号,已绑定微信展示微信昵称而不是微信账号标识,换绑入口放在对应模块右上角,退出登录和退出全部设备固定放在面板内容最底部。 +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 4b9e22c7..51724d71 100644 --- a/miniprogram/pages/web-view/index.js +++ b/miniprogram/pages/web-view/index.js @@ -107,6 +107,10 @@ function parseBooleanQueryFlag(value) { return value === true || value === '1' || value === 'true' || value === 'yes'; } +function normalizeNicknameInput(value) { + return String(value || '').trim(); +} + function normalizeMiniProgramEnv(value) { const normalized = String(value || '').trim().toLowerCase(); if (normalized === 'release') { @@ -268,7 +272,7 @@ function wxLogin() { }); } -function requestMiniProgramLogin(code) { +function requestMiniProgramLogin(code, displayName) { return new Promise((resolve, reject) => { const runtimeConfig = resolveMiniProgramRuntimeConfig(); const apiBaseUrl = trimTrailingSlash(runtimeConfig.apiBaseUrl); @@ -280,7 +284,10 @@ function requestMiniProgramLogin(code) { wx.request({ url: `${apiBaseUrl}/api/auth/wechat/miniprogram-login`, method: 'POST', - data: { code }, + data: { + code, + ...(displayName ? { displayName } : {}), + }, header: { 'content-type': 'application/json', 'x-client-type': MINI_PROGRAM_CLIENT_TYPE, @@ -310,7 +317,7 @@ function requestMiniProgramLogin(code) { }); } -function requestMiniProgramBindPhone(authToken, wechatPhoneCode) { +function requestMiniProgramBindPhone(authToken, wechatPhoneCode, displayName) { return new Promise((resolve, reject) => { const runtimeConfig = resolveMiniProgramRuntimeConfig(); const apiBaseUrl = trimTrailingSlash(runtimeConfig.apiBaseUrl); @@ -322,7 +329,10 @@ function requestMiniProgramBindPhone(authToken, wechatPhoneCode) { wx.request({ url: `${apiBaseUrl}/api/auth/wechat/bind-phone`, method: 'POST', - data: { wechatPhoneCode }, + data: { + wechatPhoneCode, + ...(displayName ? { displayName } : {}), + }, header: { authorization: `Bearer ${authToken}`, 'content-type': 'application/json', @@ -353,9 +363,9 @@ function requestMiniProgramBindPhone(authToken, wechatPhoneCode) { }); } -async function resolveAuthResult() { +async function resolveAuthResult(displayName) { const code = await wxLogin(); - const response = await requestMiniProgramLogin(code); + const response = await requestMiniProgramLogin(code, displayName); if (!response || !response.token) { throw new Error('服务器未返回登录态'); } @@ -370,7 +380,10 @@ Page({ authResult: null, bindingPhone: false, errorMessage: '', + loggingIn: false, loading: true, + nicknameInput: '', + nicknameRequired: false, phoneBindingRequired: false, returnToPreviousPage: false, webViewUrl: '', @@ -395,6 +408,7 @@ Page({ if (!shouldStartAuthFromQuery(query) && !forcedPhoneBinding) { this.setData({ authResult: null, + bindingPhone: false, errorMessage: '', loading: false, phoneBindingRequired: false, @@ -414,20 +428,50 @@ Page({ } this.setData({ - loading: true, + authResult: null, + bindingPhone: false, + errorMessage: '', + loggingIn: false, + loading: false, + nicknameRequired: true, phoneBindingRequired: false, returnToPreviousPage, - errorMessage: '', webViewUrl: '', }); + }, + handleNicknameInput(event) { + this.setData({ + nicknameInput: event.detail ? event.detail.value : '', + }); + }, + + async handleStartLogin() { + const displayName = normalizeNicknameInput(this.data.nicknameInput); + if (!displayName) { + this.setData({ + errorMessage: '请先选择或填写微信昵称。', + }); + return; + } + + this.setData({ + errorMessage: '', + loggingIn: true, + }); + await this.startAuthFlow(this.data.returnToPreviousPage, displayName); + }, + + async startAuthFlow(returnToPreviousPage, displayName) { try { - const authResult = await resolveAuthResult(); + const authResult = await resolveAuthResult(displayName); if (authResult.bindingStatus === 'pending_bind_phone') { this.setData({ authResult, errorMessage: '', + loggingIn: false, loading: false, + nicknameRequired: false, phoneBindingRequired: true, returnToPreviousPage, webViewUrl: '', @@ -437,6 +481,16 @@ Page({ if (returnToPreviousPage) { persistAuthResult(authResult); + this.setData({ + authResult, + errorMessage: '', + loggingIn: false, + loading: false, + nicknameRequired: false, + phoneBindingRequired: false, + returnToPreviousPage, + webViewUrl: '', + }); wx.navigateBack(); return; } @@ -444,7 +498,9 @@ Page({ this.setData({ authResult, errorMessage: '', + loggingIn: false, loading: false, + nicknameRequired: false, phoneBindingRequired: false, returnToPreviousPage, webViewUrl: resolveWebViewUrl(authResult), @@ -454,7 +510,9 @@ Page({ authResult: null, errorMessage: error && error.message ? error.message : '微信登录失败,请稍后重试。', + loggingIn: false, loading: false, + nicknameRequired: true, phoneBindingRequired: false, returnToPreviousPage, webViewUrl: '', @@ -466,6 +524,13 @@ Page({ const authResult = consumeAuthResult(); if (authResult) { this.setData({ + authResult, + bindingPhone: false, + errorMessage: '', + loggingIn: false, + loading: false, + nicknameRequired: false, + phoneBindingRequired: false, webViewUrl: resolveWebViewUrl(authResult), }); } @@ -510,6 +575,7 @@ Page({ const response = await requestMiniProgramBindPhone( this.data.authResult.token, detail.code, + normalizeNicknameInput(this.data.nicknameInput), ); if (!response || !response.token) { throw new Error('服务器未返回绑定后的登录态'); @@ -523,7 +589,9 @@ Page({ this.setData({ bindingPhone: false, errorMessage: '', + loggingIn: false, loading: false, + nicknameRequired: false, phoneBindingRequired: false, }); wx.navigateBack(); @@ -533,7 +601,9 @@ Page({ authResult: nextAuthResult, bindingPhone: false, errorMessage: '', + loggingIn: false, loading: false, + nicknameRequired: false, phoneBindingRequired: false, webViewUrl: resolveWebViewUrl(nextAuthResult), }); @@ -553,7 +623,10 @@ Page({ authResult: null, bindingPhone: false, errorMessage: '', + loggingIn: false, loading: true, + nicknameInput: '', + nicknameRequired: false, phoneBindingRequired: false, returnToPreviousPage: false, webViewUrl: '', diff --git a/miniprogram/pages/web-view/index.wxml b/miniprogram/pages/web-view/index.wxml index b8985678..a00efe76 100644 --- a/miniprogram/pages/web-view/index.wxml +++ b/miniprogram/pages/web-view/index.wxml @@ -8,6 +8,32 @@ /> + + + 登录 + + {{errorMessage}} + + + + + + 正在登录 diff --git a/miniprogram/pages/web-view/index.wxss b/miniprogram/pages/web-view/index.wxss index 5877f417..568bbca5 100644 --- a/miniprogram/pages/web-view/index.wxss +++ b/miniprogram/pages/web-view/index.wxss @@ -36,6 +36,19 @@ color: #ffb4a9; } +.nickname-input { + margin-top: 28rpx; + width: 100%; + min-height: 88rpx; + padding: 0 24rpx; + border: 1rpx solid rgba(255, 255, 255, 0.22); + border-radius: 8rpx; + background: rgba(255, 255, 255, 0.1); + color: #f5f7fb; + font-size: 28rpx; + box-sizing: border-box; +} + .retry-button { margin-top: 28rpx; width: 100%; diff --git a/packages/shared/src/contracts/auth.ts b/packages/shared/src/contracts/auth.ts index 2a2aa6c6..cc9eff13 100644 --- a/packages/shared/src/contracts/auth.ts +++ b/packages/shared/src/contracts/auth.ts @@ -126,6 +126,7 @@ export type AuthWechatBindPhoneRequest = { phone?: string; code?: string; wechatPhoneCode?: string; + displayName?: string; }; export type AuthWechatBindPhoneResponse = { @@ -135,6 +136,7 @@ export type AuthWechatBindPhoneResponse = { export type AuthWechatMiniProgramLoginRequest = { code: string; + displayName?: string; }; export type AuthWechatMiniProgramLoginResponse = { diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs index 0e3e66fd..8b6cb151 100644 --- a/server-rs/crates/api-server/src/app.rs +++ b/server-rs/crates/api-server/src/app.rs @@ -2584,7 +2584,7 @@ mod tests { } #[tokio::test] - async fn wechat_miniprogram_login_returns_system_token_and_marks_session_source() { + async fn wechat_miniprogram_login_returns_system_token_and_marks_session_label() { let config = AppConfig { wechat_auth_enabled: true, ..AppConfig::default() @@ -2606,7 +2606,8 @@ mod tests { .header("x-mini-program-env", "develop") .body(Body::from( serde_json::json!({ - "code": "wx-mini-code-001" + "code": "wx-mini-code-001", + "displayName": "微信旅人" }) .to_string(), )) @@ -2643,6 +2644,14 @@ mod tests { login_payload["user"]["loginMethod"], Value::String("wechat".to_string()) ); + assert_eq!( + login_payload["user"]["wechatDisplayName"], + Value::String("微信旅人".to_string()) + ); + assert_eq!( + login_payload["user"]["wechatAccount"], + Value::String("wx-mini-code-001".to_string()) + ); assert!(refresh_cookie.contains("genarrative_refresh_session=")); let sessions_response = app @@ -2667,16 +2676,23 @@ mod tests { let sessions_payload: Value = serde_json::from_slice(&sessions_body).expect("sessions payload should be json"); assert_eq!( - sessions_payload["sessions"][0]["clientType"], - Value::String("mini_program".to_string()) + sessions_payload["sessions"][0]["clientLabel"], + Value::String("微信小程序 / iPhone".to_string()) ); assert_eq!( - sessions_payload["sessions"][0]["clientRuntime"], - Value::String("wechat_mini_program".to_string()) + sessions_payload["sessions"][0]["sessionCount"], + Value::Number(1.into()) ); assert_eq!( - sessions_payload["sessions"][0]["miniProgramAppId"], - Value::String("wx-mini-test".to_string()) + sessions_payload["sessions"][0]["isCurrent"], + Value::Bool(true) + ); + assert_eq!( + sessions_payload["sessions"][0]["sessionIds"] + .as_array() + .expect("session ids should exist") + .len(), + 1 ); } @@ -2703,7 +2719,8 @@ mod tests { .header("x-mini-program-env", "develop") .body(Body::from( serde_json::json!({ - "code": "wx-mini-code-bind-001" + "code": "wx-mini-code-bind-001", + "displayName": "微信旅人" }) .to_string(), )) @@ -2745,7 +2762,8 @@ mod tests { .header("x-mini-program-env", "develop") .body(Body::from( serde_json::json!({ - "wechatPhoneCode": "13800138000" + "wechatPhoneCode": "13800138000", + "displayName": "微信旅人" }) .to_string(), )) @@ -2996,7 +3014,7 @@ mod tests { } #[tokio::test] - async fn auth_sessions_returns_multi_device_session_fields() { + async fn auth_sessions_returns_multi_device_session_summaries() { let state = AppState::new(AppConfig::default()).expect("state should build"); seed_phone_user_with_password(&state, "13800138013", TEST_PASSWORD).await; let app = build_router(state); @@ -3096,23 +3114,19 @@ mod tests { assert_eq!(sessions.len(), 2); assert!(sessions.iter().any(|session| { - session["clientType"] == Value::String("web_browser".to_string()) - && session["clientRuntime"] == Value::String("chrome".to_string()) - && session["clientPlatform"] == Value::String("windows".to_string()) + session["clientLabel"] == Value::String("Windows / Chrome".to_string()) && session["sessionCount"] == Value::Number(1.into()) && session["sessionIds"] .as_array() .is_some_and(|ids| ids.len() == 1) - && session["deviceDisplayName"] == Value::String("Windows / Chrome".to_string()) && session["isCurrent"] == Value::Bool(true) })); assert!(sessions.iter().any(|session| { - session["clientType"] == Value::String("mini_program".to_string()) - && session["clientRuntime"] == Value::String("wechat_mini_program".to_string()) + session["clientLabel"] == Value::String("微信小程序 / Android".to_string()) && session["sessionCount"] == Value::Number(1.into()) - && session["miniProgramAppId"] == Value::String("wx-session-test".to_string()) - && session["miniProgramEnv"] == Value::String("release".to_string()) - && session["deviceDisplayName"] == Value::String("微信小程序 / Android".to_string()) + && session["sessionIds"] + .as_array() + .is_some_and(|ids| ids.len() == 1) && session["isCurrent"] == Value::Bool(false) })); } diff --git a/server-rs/crates/api-server/src/wechat_auth.rs b/server-rs/crates/api-server/src/wechat_auth.rs index f2766959..a8c930b5 100644 --- a/server-rs/crates/api-server/src/wechat_auth.rs +++ b/server-rs/crates/api-server/src/wechat_auth.rs @@ -14,6 +14,7 @@ use shared_contracts::auth::{ WechatMiniProgramLoginRequest, WechatMiniProgramLoginResponse, WechatStartQuery, WechatStartResponse, }; +use shared_kernel::normalize_optional_string; use time::OffsetDateTime; use url::Url; @@ -208,6 +209,7 @@ pub async fn bind_wechat_phone( .bind_wechat_verified_phone(BindWechatVerifiedPhoneInput { user_id: authenticated.claims().user_id().to_string(), phone_number: phone_profile.phone_number, + wechat_display_name: payload.display_name.clone(), }) .await .map_err(map_wechat_bind_phone_error)? @@ -235,6 +237,7 @@ pub async fn bind_wechat_phone( user_id: authenticated.claims().user_id().to_string(), phone_number: phone.to_string(), verify_code: code.to_string(), + wechat_display_name: payload.display_name.clone(), }, OffsetDateTime::now_utc(), ) @@ -313,7 +316,7 @@ pub async fn login_wechat_mini_program( let result = state .wechat_auth_service() .resolve_login(module_auth::ResolveWechatLoginInput { - profile: map_wechat_profile_to_domain(profile), + profile: map_wechat_profile_to_domain_with_display_name(profile, payload.display_name), }) .await .map_err(map_wechat_auth_error)?; @@ -389,6 +392,17 @@ fn map_wechat_profile_to_domain( } } +fn map_wechat_profile_to_domain_with_display_name( + profile: platform_auth::WechatIdentityProfile, + display_name: Option, +) -> module_auth::WechatIdentityProfile { + let mut profile = map_wechat_profile_to_domain(profile); + if let Some(display_name) = normalize_optional_string(display_name) { + profile.display_name = Some(display_name); + } + profile +} + fn normalize_redirect_path(raw_value: Option<&str>, fallback: &str) -> String { let Some(raw_value) = raw_value.map(str::trim).filter(|value| !value.is_empty()) else { return fallback.to_string(); diff --git a/server-rs/crates/module-auth/src/commands.rs b/server-rs/crates/module-auth/src/commands.rs index d84ce3cf..61985fc7 100644 --- a/server-rs/crates/module-auth/src/commands.rs +++ b/server-rs/crates/module-auth/src/commands.rs @@ -65,12 +65,14 @@ pub struct BindWechatPhoneInput { pub user_id: String, pub phone_number: String, pub verify_code: String, + pub wechat_display_name: Option, } #[derive(Clone, Debug, PartialEq, Eq)] pub struct BindWechatVerifiedPhoneInput { pub user_id: String, pub phone_number: String, + pub wechat_display_name: Option, } #[derive(Clone, Debug, PartialEq, Eq)] diff --git a/server-rs/crates/module-auth/src/lib.rs b/server-rs/crates/module-auth/src/lib.rs index fd809932..c5195388 100644 --- a/server-rs/crates/module-auth/src/lib.rs +++ b/server-rs/crates/module-auth/src/lib.rs @@ -111,11 +111,7 @@ fn hydrate_private_auth_fields( .find(|identity| identity.user_id == hydrated.user.id); if hydrated.user.wechat_display_name.is_none() { hydrated.user.wechat_display_name = hydrated_wechat_identity - .and_then(|identity| identity.display_name.clone()) - .or_else(|| { - (hydrated.user.login_method == AuthLoginMethod::Wechat) - .then(|| hydrated.user.display_name.clone()) - }); + .and_then(|identity| normalize_optional_string(identity.display_name.clone())); } if hydrated.user.wechat_account.is_none() { hydrated.user.wechat_account = @@ -655,9 +651,11 @@ impl PhoneAuthService { return Err(PhoneAuthError::UserStateMismatch); } - let (merged_user, activated_new_user) = self - .store - .bind_wechat_phone_to_user(&input.user_id, normalized_phone)?; + let (merged_user, activated_new_user) = self.store.bind_wechat_phone_to_user( + &input.user_id, + normalized_phone, + input.wechat_display_name, + )?; Ok(BindWechatPhoneResult { user: merged_user, @@ -711,9 +709,11 @@ impl PhoneAuthService { return Err(PhoneAuthError::UserStateMismatch); } - let (merged_user, activated_new_user) = self - .store - .bind_wechat_phone_to_user(&input.user_id, normalized_phone)?; + let (merged_user, activated_new_user) = self.store.bind_wechat_phone_to_user( + &input.user_id, + normalized_phone, + input.wechat_display_name, + )?; Ok(BindWechatPhoneResult { user: merged_user, @@ -1365,8 +1365,7 @@ impl InMemoryAuthStore { .filter(|value| !value.is_empty()) .unwrap_or("微信旅人") .to_string(); - let wechat_display_name = normalize_optional_string(profile.display_name.clone()) - .or_else(|| Some(display_name.clone())); + let wechat_display_name = normalize_optional_string(profile.display_name.clone()); let username = build_wechat_username(&display_name, &profile.provider_uid); let provider_uid = normalize_required_string(&profile.provider_uid).unwrap_or_default(); let user = AuthUser { @@ -1758,11 +1757,13 @@ impl InMemoryAuthStore { &self, pending_user_id: &str, phone_number: PhoneNumberSnapshot, + wechat_display_name: Option, ) -> Result<(AuthUser, bool), PhoneAuthError> { let mut state = self .inner .lock() .map_err(|_| PhoneAuthError::Store("用户仓储锁已中毒".to_string()))?; + let submitted_wechat_display_name = normalize_optional_string(wechat_display_name); let existing_phone_user_id = Self::resolve_phone_user_locked(&mut state, &phone_number.e164) @@ -1777,20 +1778,24 @@ impl InMemoryAuthStore { .cloned() .ok_or(PhoneAuthError::UserStateMismatch)?; let pending_wechat_account = pending_wechat_identity.provider_uid.clone(); - let pending_wechat_display_name = pending_wechat_identity.display_name.clone(); - - let pending_username = state + let pending_user = state .users_by_username .values() .find(|stored| stored.user.id == pending_user_id) - .map(|stored| stored.user.username.clone()) + .cloned() .ok_or(PhoneAuthError::UserNotFound)?; + let pending_username = pending_user.user.username.clone(); + let pending_wechat_display_name = submitted_wechat_display_name + .clone() + .or_else(|| normalize_optional_string(pending_wechat_identity.display_name.clone())) + .or_else(|| normalize_optional_string(pending_user.user.wechat_display_name)); state.users_by_username.remove(&pending_username); state.wechat_identity_by_provider_uid.insert( pending_wechat_identity.provider_uid.clone(), StoredWechatIdentity { user_id: target_user_id.clone(), + display_name: pending_wechat_display_name.clone(), ..pending_wechat_identity.clone() }, ); @@ -1825,11 +1830,31 @@ impl InMemoryAuthStore { .values() .find(|identity| identity.user_id == pending_user_id) .map(|identity| identity.provider_uid.clone()); - let bound_wechat_display_name = state - .wechat_identity_by_provider_uid - .values() - .find(|identity| identity.user_id == pending_user_id) - .and_then(|identity| identity.display_name.clone()); + let bound_wechat_display_name = submitted_wechat_display_name.clone().or_else(|| { + state + .wechat_identity_by_provider_uid + .values() + .find(|identity| identity.user_id == pending_user_id) + .and_then(|identity| normalize_optional_string(identity.display_name.clone())) + .or_else(|| { + state + .users_by_username + .values() + .find(|stored| stored.user.id == pending_user_id) + .and_then(|stored| { + normalize_optional_string(stored.user.wechat_display_name.clone()) + }) + }) + }); + + if let Some(display_name) = bound_wechat_display_name.clone() + && let Some(identity) = state + .wechat_identity_by_provider_uid + .values_mut() + .find(|identity| identity.user_id == pending_user_id) + { + identity.display_name = Some(display_name); + } let stored_user = state .users_by_username @@ -3584,6 +3609,7 @@ mod tests { user_id: wechat_user.id.clone(), phone_number: "13800138000".to_string(), verify_code: "123456".to_string(), + wechat_display_name: None, }, now + Duration::seconds(3), ) @@ -3619,4 +3645,97 @@ mod tests { Some("已归并微信用户") ); } + + #[tokio::test] + async fn bind_wechat_phone_keeps_account_marker_when_identity_has_no_display_name() { + let store = build_store(); + let phone_service = build_phone_service(store.clone()); + let wechat_service = WechatAuthService::new(store.clone()); + let now = OffsetDateTime::now_utc(); + + phone_service + .send_code( + SendPhoneCodeInput { + phone_number: "13800138031".to_string(), + scene: PhoneAuthScene::Login, + }, + now, + ) + .await + .expect("phone login code should send"); + let phone_user = phone_service + .login( + PhoneLoginInput { + phone_number: "13800138031".to_string(), + verify_code: "123456".to_string(), + }, + now + Duration::seconds(1), + ) + .await + .expect("phone login should succeed") + .user; + + let wechat_user = wechat_service + .resolve_login(ResolveWechatLoginInput { + profile: WechatIdentityProfile { + provider_uid: "wx-openid-mini-bind".to_string(), + provider_union_id: Some("wx-union-mini-bind".to_string()), + display_name: None, + avatar_url: None, + session_key: Some("mini-session-key".to_string()), + }, + }) + .await + .expect("mini program wechat login should succeed") + .user; + + assert_eq!(wechat_user.wechat_display_name, None); + assert_eq!( + wechat_user.wechat_account.as_deref(), + Some("wx-openid-mini-bind") + ); + assert_ne!(wechat_user.id, phone_user.id); + + phone_service + .send_code( + SendPhoneCodeInput { + phone_number: "13800138031".to_string(), + scene: PhoneAuthScene::BindPhone, + }, + now + Duration::seconds(2), + ) + .await + .expect("bind phone code should send"); + let merged = phone_service + .bind_wechat_phone( + BindWechatPhoneInput { + user_id: wechat_user.id.clone(), + phone_number: "13800138031".to_string(), + verify_code: "123456".to_string(), + wechat_display_name: None, + }, + now + Duration::seconds(3), + ) + .await + .expect("bind phone should succeed"); + + assert_eq!(merged.user.id, phone_user.id); + assert!(merged.user.wechat_bound); + assert_eq!(merged.user.wechat_display_name, None); + assert_eq!( + merged.user.wechat_account.as_deref(), + Some("wx-openid-mini-bind") + ); + + let restored_user = build_password_service(store) + .get_user_by_id(&phone_user.id) + .expect("user lookup should succeed") + .expect("merged user should exist") + .user; + assert_eq!(restored_user.wechat_display_name, None); + assert_eq!( + restored_user.wechat_account.as_deref(), + Some("wx-openid-mini-bind") + ); + } } diff --git a/server-rs/crates/platform-auth/src/lib.rs b/server-rs/crates/platform-auth/src/lib.rs index b3663080..65dbe3ae 100644 --- a/server-rs/crates/platform-auth/src/lib.rs +++ b/server-rs/crates/platform-auth/src/lib.rs @@ -796,7 +796,7 @@ impl WechatProvider { ) -> Result { match self { Self::Disabled => Err(WechatProviderError::Disabled), - Self::Mock(provider) => Ok(provider.resolve_callback_profile(code)), + Self::Mock(provider) => Ok(provider.resolve_mini_program_login_profile(code)), Self::Real(provider) => provider.resolve_mini_program_login_profile(code).await, } } @@ -839,6 +839,21 @@ impl MockWechatProvider { session_key: None, } } + + fn resolve_mini_program_login_profile(&self, mock_code: Option<&str>) -> WechatIdentityProfile { + let provider_uid = mock_code + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(self.mock_user_id.as_str()) + .to_string(); + WechatIdentityProfile { + provider_uid: provider_uid.clone(), + provider_union_id: self.mock_union_id.clone(), + display_name: None, + avatar_url: None, + session_key: Some(format!("mock-session-key-{provider_uid}")), + } + } } impl RealWechatProvider { @@ -2274,6 +2289,42 @@ mod tests { assert_eq!(profile.display_name.as_deref(), Some("微信测试用户")); } + #[tokio::test] + async fn mock_wechat_provider_resolves_mini_program_profile_without_nickname() { + let provider = WechatProvider::new(WechatAuthConfig::new( + true, + "mock".to_string(), + None, + None, + None, + None, + DEFAULT_WECHAT_AUTHORIZE_ENDPOINT.to_string(), + DEFAULT_WECHAT_ACCESS_TOKEN_ENDPOINT.to_string(), + DEFAULT_WECHAT_USER_INFO_ENDPOINT.to_string(), + DEFAULT_WECHAT_JS_CODE_SESSION_ENDPOINT.to_string(), + DEFAULT_WECHAT_STABLE_ACCESS_TOKEN_ENDPOINT.to_string(), + DEFAULT_WECHAT_PHONE_NUMBER_ENDPOINT.to_string(), + "wx-user-001".to_string(), + Some("wx-union-001".to_string()), + "微信测试用户".to_string(), + Some("https://example.test/avatar.png".to_string()), + )); + + let profile = provider + .resolve_mini_program_login_profile(Some("wx-mini-code-001")) + .await + .expect("mock mini program profile should resolve"); + + assert_eq!(profile.provider_uid, "wx-mini-code-001"); + assert_eq!(profile.provider_union_id.as_deref(), Some("wx-union-001")); + assert_eq!(profile.display_name, None); + assert_eq!(profile.avatar_url, None); + assert_eq!( + profile.session_key.as_deref(), + Some("mock-session-key-wx-mini-code-001") + ); + } + fn build_jwt_config() -> JwtConfig { JwtConfig::new( "https://auth.genarrative.local".to_string(), diff --git a/server-rs/crates/shared-contracts/src/auth.rs b/server-rs/crates/shared-contracts/src/auth.rs index 0c0f5809..a9024f9b 100644 --- a/server-rs/crates/shared-contracts/src/auth.rs +++ b/server-rs/crates/shared-contracts/src/auth.rs @@ -228,6 +228,8 @@ pub struct WechatBindPhoneRequest { pub code: Option, #[serde(default)] pub wechat_phone_code: Option, + #[serde(default)] + pub display_name: Option, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] @@ -241,6 +243,8 @@ pub struct WechatBindPhoneResponse { #[serde(rename_all = "camelCase")] pub struct WechatMiniProgramLoginRequest { pub code: String, + #[serde(default)] + pub display_name: Option, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] @@ -354,6 +358,7 @@ mod tests { phone: None, code: None, wechat_phone_code: Some("wx-phone-code-001".to_string()), + display_name: Some("陶泥儿玩家".to_string()), }) .expect("payload should serialize"); @@ -362,7 +367,25 @@ mod tests { json!({ "phone": null, "code": null, - "wechatPhoneCode": "wx-phone-code-001" + "wechatPhoneCode": "wx-phone-code-001", + "displayName": "陶泥儿玩家" + }) + ); + } + + #[test] + fn wechat_mini_program_login_request_accepts_native_nickname() { + let payload = serde_json::to_value(WechatMiniProgramLoginRequest { + code: "wx-mini-code-001".to_string(), + display_name: Some("陶泥儿玩家".to_string()), + }) + .expect("payload should serialize"); + + assert_eq!( + payload, + json!({ + "code": "wx-mini-code-001", + "displayName": "陶泥儿玩家" }) ); } diff --git a/src/components/auth/AccountModal.test.tsx b/src/components/auth/AccountModal.test.tsx index 834ede9c..2ee6a628 100644 --- a/src/components/auth/AccountModal.test.tsx +++ b/src/components/auth/AccountModal.test.tsx @@ -177,6 +177,23 @@ test('account panel uses compact binding cards and keeps logout actions at the b ).toBe('true'); }); +test('account panel avoids bare bound label when wechat display name is missing', () => { + renderAccountModal({ + entryMode: 'account', + user: { + ...baseUser, + wechatDisplayName: null, + wechatAccount: 'openid_abcdef123456', + }, + }); + + const accountDialog = screen.getByRole('dialog', { name: '账号信息' }); + expect(within(accountDialog).getByText('绑定微信')).toBeTruthy(); + expect(within(accountDialog).getByText('微信账号尾号 123456')).toBeTruthy(); + expect(within(accountDialog).queryByText('openid_abcdef123456')).toBeNull(); + expect(within(accountDialog).queryByText('已绑定')).toBeNull(); +}); + test('account actions open in independent panels instead of inline expansion', async () => { const user = userEvent.setup(); diff --git a/src/components/auth/AccountModal.tsx b/src/components/auth/AccountModal.tsx index 213c2055..6c74ccd8 100644 --- a/src/components/auth/AccountModal.tsx +++ b/src/components/auth/AccountModal.tsx @@ -109,6 +109,15 @@ function formatSessionTime(value: string) { }); } +function formatBoundWechatAccount(value: string | null | undefined) { + const normalized = value?.trim(); + if (!normalized) { + return null; + } + + return `微信账号尾号 ${normalized.slice(-6)}`; +} + function SettingsEntryCard({ label, detail, @@ -444,7 +453,9 @@ export function AccountModal({ const boundPhoneNumber = user.phoneNumber?.trim() || user.phoneNumberMasked || '未绑定'; const boundWechatDisplayName = - user.wechatDisplayName?.trim() || (user.wechatBound ? '已绑定' : '未绑定'); + user.wechatDisplayName?.trim() || + formatBoundWechatAccount(user.wechatAccount) || + (user.wechatBound ? '微信账号已绑定' : '未绑定'); const sectionSummaries: Record = { appearance: