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 883e6663..4e2625f0 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 248c09d1..b422c964 100644
--- a/server-rs/crates/api-server/src/app.rs
+++ b/server-rs/crates/api-server/src/app.rs
@@ -2502,7 +2502,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()
@@ -2524,7 +2524,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(),
))
@@ -2561,6 +2562,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
@@ -2585,16 +2594,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
);
}
@@ -2621,7 +2637,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(),
))
@@ -2663,7 +2680,8 @@ mod tests {
.header("x-mini-program-env", "develop")
.body(Body::from(
serde_json::json!({
- "wechatPhoneCode": "13800138000"
+ "wechatPhoneCode": "13800138000",
+ "displayName": "微信旅人"
})
.to_string(),
))
@@ -2914,7 +2932,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);
@@ -3014,23 +3032,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:
diff --git a/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx b/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx
index 246e6826..9dbdd64b 100644
--- a/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx
+++ b/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx
@@ -11806,6 +11806,7 @@ test('creation hub gives jump hop wooden fish and bark battle cards the shared d
sourceSessionId: 'jump-hop-session-delete',
workTitle: '跳台删除草稿',
workDescription: '跳一跳草稿也应接入统一删除。',
+ themeText: '跳台',
themeTags: ['跳台'],
difficulty: 'standard',
stylePreset: 'paper-toy',