feat(auth): 小程序登录采集微信昵称

This commit is contained in:
2026-06-06 23:59:15 +08:00
parent caa65bf15f
commit b74440373f
16 changed files with 432 additions and 58 deletions

View File

@@ -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 素材策略 ## 2026-06-03 拼消消收敛为单关 6x6 与 4-sheet 素材策略
- 背景:最初 4 关 / 135 次消除 / 单张大 atlas 方案生图数量和空间一致性成本过高,真实 image2 结果容易被布局提示词诱导成带文字、边框或编号的说明图,不适合运行态 1x1 切片。 - 背景:最初 4 关 / 135 次消除 / 单张大 atlas 方案生图数量和空间一致性成本过高,真实 image2 结果容易被布局提示词诱导成带文字、边框或编号的说明图,不适合运行态 1x1 切片。

View File

@@ -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` - `AuthSessionSummaryPayload` / `AuthSessionSummary` 只保留设备卡片与撤销需要的摘要字段:`sessionId``sessionIds``sessionCount``clientLabel``ipMasked``isCurrent``createdAt``lastSeenAt``expiresAt`
- 设备诊断信息(例如原始 `clientType` / `clientRuntime` / `clientPlatform` / `userAgent` / `miniProgramAppId` / `miniProgramEnv` / `deviceDisplayName`)不再默认下发到前端;若未来确需展示,优先单独加窄 DTO而不是把账号 / 会话快照恢复为全量对象。 - 设备诊断信息(例如原始 `clientType` / `clientRuntime` / `clientPlatform` / `userAgent` / `miniProgramAppId` / `miniProgramEnv` / `deviceDisplayName`)不再默认下发到前端;若未来确需展示,优先单独加窄 DTO而不是把账号 / 会话快照恢复为全量对象。

View File

@@ -45,11 +45,11 @@ Genarrative / 陶泥儿是一个 AI 原生互动内容与小游戏平台。当
2. `login-options` 为空、失败、只返回 `phone` 或只返回 `password` 时,前端仍要同时展示验证码登录页签和密码登录页签;短信能力真实可用性由发送验证码接口返回结果表达。 2. `login-options` 为空、失败、只返回 `phone` 或只返回 `password` 时,前端仍要同时展示验证码登录页签和密码登录页签;短信能力真实可用性由发送验证码接口返回结果表达。
3. 登录弹窗继续复用现有独立 modal 和页签结构,不在页面中新增功能说明类文案,也不把邀请码输入放回登录面板。 3. 登录弹窗继续复用现有独立 modal 和页签结构,不在页面中新增功能说明类文案,也不把邀请码输入放回登录面板。
4. 微信小程序 `web-view` 外壳默认不预登录,首次进入直接打开 H5并保持与 Web 端一致的未登录状态;只有 H5 触发 `openLoginModal` / `requireAuth` 等受保护入口时,才跳转小程序原生授权态。 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 作为小程序识别兜底。 6. 小程序外壳注入到 H5 URL 的 `clientType``clientRuntime``miniProgramEnv` 是宿主上下文H5 内部 `pushState` / 阶段导航必须跨页面保留,避免登录和充值误判为普通浏览器;首点时微信 JS bridge 可能尚未就绪,前端还需用 `MicroMessenger + miniProgram` User-Agent 作为小程序识别兜底。
7. 小程序 `web-view` 页必须启用好友分享与朋友圈分享,分享目标固定回到 `pages/web-view/index`,不把 H5 当前 URL 作为不受控启动参数传回小程序页。 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` 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` 作为绑定账号标识,前端在没有真实昵称时展示微信账号尾号,不展示裸“已绑定”。换绑入口放在对应模块右上角,退出登录和退出全部设备固定放在面板内容最底部。
## 账户与充值 ## 账户与充值

View File

@@ -107,6 +107,10 @@ function parseBooleanQueryFlag(value) {
return value === true || value === '1' || value === 'true' || value === 'yes'; return value === true || value === '1' || value === 'true' || value === 'yes';
} }
function normalizeNicknameInput(value) {
return String(value || '').trim();
}
function normalizeMiniProgramEnv(value) { function normalizeMiniProgramEnv(value) {
const normalized = String(value || '').trim().toLowerCase(); const normalized = String(value || '').trim().toLowerCase();
if (normalized === 'release') { if (normalized === 'release') {
@@ -268,7 +272,7 @@ function wxLogin() {
}); });
} }
function requestMiniProgramLogin(code) { function requestMiniProgramLogin(code, displayName) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const runtimeConfig = resolveMiniProgramRuntimeConfig(); const runtimeConfig = resolveMiniProgramRuntimeConfig();
const apiBaseUrl = trimTrailingSlash(runtimeConfig.apiBaseUrl); const apiBaseUrl = trimTrailingSlash(runtimeConfig.apiBaseUrl);
@@ -280,7 +284,10 @@ function requestMiniProgramLogin(code) {
wx.request({ wx.request({
url: `${apiBaseUrl}/api/auth/wechat/miniprogram-login`, url: `${apiBaseUrl}/api/auth/wechat/miniprogram-login`,
method: 'POST', method: 'POST',
data: { code }, data: {
code,
...(displayName ? { displayName } : {}),
},
header: { header: {
'content-type': 'application/json', 'content-type': 'application/json',
'x-client-type': MINI_PROGRAM_CLIENT_TYPE, '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) => { return new Promise((resolve, reject) => {
const runtimeConfig = resolveMiniProgramRuntimeConfig(); const runtimeConfig = resolveMiniProgramRuntimeConfig();
const apiBaseUrl = trimTrailingSlash(runtimeConfig.apiBaseUrl); const apiBaseUrl = trimTrailingSlash(runtimeConfig.apiBaseUrl);
@@ -322,7 +329,10 @@ function requestMiniProgramBindPhone(authToken, wechatPhoneCode) {
wx.request({ wx.request({
url: `${apiBaseUrl}/api/auth/wechat/bind-phone`, url: `${apiBaseUrl}/api/auth/wechat/bind-phone`,
method: 'POST', method: 'POST',
data: { wechatPhoneCode }, data: {
wechatPhoneCode,
...(displayName ? { displayName } : {}),
},
header: { header: {
authorization: `Bearer ${authToken}`, authorization: `Bearer ${authToken}`,
'content-type': 'application/json', 'content-type': 'application/json',
@@ -353,9 +363,9 @@ function requestMiniProgramBindPhone(authToken, wechatPhoneCode) {
}); });
} }
async function resolveAuthResult() { async function resolveAuthResult(displayName) {
const code = await wxLogin(); const code = await wxLogin();
const response = await requestMiniProgramLogin(code); const response = await requestMiniProgramLogin(code, displayName);
if (!response || !response.token) { if (!response || !response.token) {
throw new Error('服务器未返回登录态'); throw new Error('服务器未返回登录态');
} }
@@ -370,7 +380,10 @@ Page({
authResult: null, authResult: null,
bindingPhone: false, bindingPhone: false,
errorMessage: '', errorMessage: '',
loggingIn: false,
loading: true, loading: true,
nicknameInput: '',
nicknameRequired: false,
phoneBindingRequired: false, phoneBindingRequired: false,
returnToPreviousPage: false, returnToPreviousPage: false,
webViewUrl: '', webViewUrl: '',
@@ -395,6 +408,7 @@ Page({
if (!shouldStartAuthFromQuery(query) && !forcedPhoneBinding) { if (!shouldStartAuthFromQuery(query) && !forcedPhoneBinding) {
this.setData({ this.setData({
authResult: null, authResult: null,
bindingPhone: false,
errorMessage: '', errorMessage: '',
loading: false, loading: false,
phoneBindingRequired: false, phoneBindingRequired: false,
@@ -414,20 +428,50 @@ Page({
} }
this.setData({ this.setData({
loading: true, authResult: null,
bindingPhone: false,
errorMessage: '',
loggingIn: false,
loading: false,
nicknameRequired: true,
phoneBindingRequired: false, phoneBindingRequired: false,
returnToPreviousPage, returnToPreviousPage,
errorMessage: '',
webViewUrl: '', 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 { try {
const authResult = await resolveAuthResult(); const authResult = await resolveAuthResult(displayName);
if (authResult.bindingStatus === 'pending_bind_phone') { if (authResult.bindingStatus === 'pending_bind_phone') {
this.setData({ this.setData({
authResult, authResult,
errorMessage: '', errorMessage: '',
loggingIn: false,
loading: false, loading: false,
nicknameRequired: false,
phoneBindingRequired: true, phoneBindingRequired: true,
returnToPreviousPage, returnToPreviousPage,
webViewUrl: '', webViewUrl: '',
@@ -437,6 +481,16 @@ Page({
if (returnToPreviousPage) { if (returnToPreviousPage) {
persistAuthResult(authResult); persistAuthResult(authResult);
this.setData({
authResult,
errorMessage: '',
loggingIn: false,
loading: false,
nicknameRequired: false,
phoneBindingRequired: false,
returnToPreviousPage,
webViewUrl: '',
});
wx.navigateBack(); wx.navigateBack();
return; return;
} }
@@ -444,7 +498,9 @@ Page({
this.setData({ this.setData({
authResult, authResult,
errorMessage: '', errorMessage: '',
loggingIn: false,
loading: false, loading: false,
nicknameRequired: false,
phoneBindingRequired: false, phoneBindingRequired: false,
returnToPreviousPage, returnToPreviousPage,
webViewUrl: resolveWebViewUrl(authResult), webViewUrl: resolveWebViewUrl(authResult),
@@ -454,7 +510,9 @@ Page({
authResult: null, authResult: null,
errorMessage: errorMessage:
error && error.message ? error.message : '微信登录失败,请稍后重试。', error && error.message ? error.message : '微信登录失败,请稍后重试。',
loggingIn: false,
loading: false, loading: false,
nicknameRequired: true,
phoneBindingRequired: false, phoneBindingRequired: false,
returnToPreviousPage, returnToPreviousPage,
webViewUrl: '', webViewUrl: '',
@@ -466,6 +524,13 @@ Page({
const authResult = consumeAuthResult(); const authResult = consumeAuthResult();
if (authResult) { if (authResult) {
this.setData({ this.setData({
authResult,
bindingPhone: false,
errorMessage: '',
loggingIn: false,
loading: false,
nicknameRequired: false,
phoneBindingRequired: false,
webViewUrl: resolveWebViewUrl(authResult), webViewUrl: resolveWebViewUrl(authResult),
}); });
} }
@@ -510,6 +575,7 @@ Page({
const response = await requestMiniProgramBindPhone( const response = await requestMiniProgramBindPhone(
this.data.authResult.token, this.data.authResult.token,
detail.code, detail.code,
normalizeNicknameInput(this.data.nicknameInput),
); );
if (!response || !response.token) { if (!response || !response.token) {
throw new Error('服务器未返回绑定后的登录态'); throw new Error('服务器未返回绑定后的登录态');
@@ -523,7 +589,9 @@ Page({
this.setData({ this.setData({
bindingPhone: false, bindingPhone: false,
errorMessage: '', errorMessage: '',
loggingIn: false,
loading: false, loading: false,
nicknameRequired: false,
phoneBindingRequired: false, phoneBindingRequired: false,
}); });
wx.navigateBack(); wx.navigateBack();
@@ -533,7 +601,9 @@ Page({
authResult: nextAuthResult, authResult: nextAuthResult,
bindingPhone: false, bindingPhone: false,
errorMessage: '', errorMessage: '',
loggingIn: false,
loading: false, loading: false,
nicknameRequired: false,
phoneBindingRequired: false, phoneBindingRequired: false,
webViewUrl: resolveWebViewUrl(nextAuthResult), webViewUrl: resolveWebViewUrl(nextAuthResult),
}); });
@@ -553,7 +623,10 @@ Page({
authResult: null, authResult: null,
bindingPhone: false, bindingPhone: false,
errorMessage: '', errorMessage: '',
loggingIn: false,
loading: true, loading: true,
nicknameInput: '',
nicknameRequired: false,
phoneBindingRequired: false, phoneBindingRequired: false,
returnToPreviousPage: false, returnToPreviousPage: false,
webViewUrl: '', webViewUrl: '',

View File

@@ -8,6 +8,32 @@
/> />
</block> </block>
<view wx:elif="{{nicknameRequired}}" class="setup-screen">
<view class="setup-card">
<view class="setup-title">登录</view>
<view wx:if="{{errorMessage}}" class="setup-text setup-text--danger">
{{errorMessage}}
</view>
<input
class="nickname-input"
type="nickname"
value="{{nicknameInput}}"
placeholder="微信昵称"
disabled="{{loggingIn}}"
bindinput="handleNicknameInput"
bindblur="handleNicknameInput"
/>
<button
class="retry-button"
loading="{{loggingIn}}"
disabled="{{loggingIn}}"
bindtap="handleStartLogin"
>
{{loggingIn ? '正在登录' : '微信快捷登录'}}
</button>
</view>
</view>
<view wx:elif="{{loading}}" class="setup-screen"> <view wx:elif="{{loading}}" class="setup-screen">
<view class="setup-card"> <view class="setup-card">
<view class="setup-title">正在登录</view> <view class="setup-title">正在登录</view>

View File

@@ -36,6 +36,19 @@
color: #ffb4a9; 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 { .retry-button {
margin-top: 28rpx; margin-top: 28rpx;
width: 100%; width: 100%;

View File

@@ -126,6 +126,7 @@ export type AuthWechatBindPhoneRequest = {
phone?: string; phone?: string;
code?: string; code?: string;
wechatPhoneCode?: string; wechatPhoneCode?: string;
displayName?: string;
}; };
export type AuthWechatBindPhoneResponse = { export type AuthWechatBindPhoneResponse = {
@@ -135,6 +136,7 @@ export type AuthWechatBindPhoneResponse = {
export type AuthWechatMiniProgramLoginRequest = { export type AuthWechatMiniProgramLoginRequest = {
code: string; code: string;
displayName?: string;
}; };
export type AuthWechatMiniProgramLoginResponse = { export type AuthWechatMiniProgramLoginResponse = {

View File

@@ -2502,7 +2502,7 @@ mod tests {
} }
#[tokio::test] #[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 { let config = AppConfig {
wechat_auth_enabled: true, wechat_auth_enabled: true,
..AppConfig::default() ..AppConfig::default()
@@ -2524,7 +2524,8 @@ mod tests {
.header("x-mini-program-env", "develop") .header("x-mini-program-env", "develop")
.body(Body::from( .body(Body::from(
serde_json::json!({ serde_json::json!({
"code": "wx-mini-code-001" "code": "wx-mini-code-001",
"displayName": "微信旅人"
}) })
.to_string(), .to_string(),
)) ))
@@ -2561,6 +2562,14 @@ mod tests {
login_payload["user"]["loginMethod"], login_payload["user"]["loginMethod"],
Value::String("wechat".to_string()) 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=")); assert!(refresh_cookie.contains("genarrative_refresh_session="));
let sessions_response = app let sessions_response = app
@@ -2585,16 +2594,23 @@ mod tests {
let sessions_payload: Value = let sessions_payload: Value =
serde_json::from_slice(&sessions_body).expect("sessions payload should be json"); serde_json::from_slice(&sessions_body).expect("sessions payload should be json");
assert_eq!( assert_eq!(
sessions_payload["sessions"][0]["clientType"], sessions_payload["sessions"][0]["clientLabel"],
Value::String("mini_program".to_string()) Value::String("微信小程序 / iPhone".to_string())
); );
assert_eq!( assert_eq!(
sessions_payload["sessions"][0]["clientRuntime"], sessions_payload["sessions"][0]["sessionCount"],
Value::String("wechat_mini_program".to_string()) Value::Number(1.into())
); );
assert_eq!( assert_eq!(
sessions_payload["sessions"][0]["miniProgramAppId"], sessions_payload["sessions"][0]["isCurrent"],
Value::String("wx-mini-test".to_string()) 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") .header("x-mini-program-env", "develop")
.body(Body::from( .body(Body::from(
serde_json::json!({ serde_json::json!({
"code": "wx-mini-code-bind-001" "code": "wx-mini-code-bind-001",
"displayName": "微信旅人"
}) })
.to_string(), .to_string(),
)) ))
@@ -2663,7 +2680,8 @@ mod tests {
.header("x-mini-program-env", "develop") .header("x-mini-program-env", "develop")
.body(Body::from( .body(Body::from(
serde_json::json!({ serde_json::json!({
"wechatPhoneCode": "13800138000" "wechatPhoneCode": "13800138000",
"displayName": "微信旅人"
}) })
.to_string(), .to_string(),
)) ))
@@ -2914,7 +2932,7 @@ mod tests {
} }
#[tokio::test] #[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"); let state = AppState::new(AppConfig::default()).expect("state should build");
seed_phone_user_with_password(&state, "13800138013", TEST_PASSWORD).await; seed_phone_user_with_password(&state, "13800138013", TEST_PASSWORD).await;
let app = build_router(state); let app = build_router(state);
@@ -3014,23 +3032,19 @@ mod tests {
assert_eq!(sessions.len(), 2); assert_eq!(sessions.len(), 2);
assert!(sessions.iter().any(|session| { assert!(sessions.iter().any(|session| {
session["clientType"] == Value::String("web_browser".to_string()) session["clientLabel"] == Value::String("Windows / Chrome".to_string())
&& session["clientRuntime"] == Value::String("chrome".to_string())
&& session["clientPlatform"] == Value::String("windows".to_string())
&& session["sessionCount"] == Value::Number(1.into()) && session["sessionCount"] == Value::Number(1.into())
&& session["sessionIds"] && session["sessionIds"]
.as_array() .as_array()
.is_some_and(|ids| ids.len() == 1) .is_some_and(|ids| ids.len() == 1)
&& session["deviceDisplayName"] == Value::String("Windows / Chrome".to_string())
&& session["isCurrent"] == Value::Bool(true) && session["isCurrent"] == Value::Bool(true)
})); }));
assert!(sessions.iter().any(|session| { assert!(sessions.iter().any(|session| {
session["clientType"] == Value::String("mini_program".to_string()) session["clientLabel"] == Value::String("微信小程序 / Android".to_string())
&& session["clientRuntime"] == Value::String("wechat_mini_program".to_string())
&& session["sessionCount"] == Value::Number(1.into()) && session["sessionCount"] == Value::Number(1.into())
&& session["miniProgramAppId"] == Value::String("wx-session-test".to_string()) && session["sessionIds"]
&& session["miniProgramEnv"] == Value::String("release".to_string()) .as_array()
&& session["deviceDisplayName"] == Value::String("微信小程序 / Android".to_string()) .is_some_and(|ids| ids.len() == 1)
&& session["isCurrent"] == Value::Bool(false) && session["isCurrent"] == Value::Bool(false)
})); }));
} }

View File

@@ -14,6 +14,7 @@ use shared_contracts::auth::{
WechatMiniProgramLoginRequest, WechatMiniProgramLoginResponse, WechatStartQuery, WechatMiniProgramLoginRequest, WechatMiniProgramLoginResponse, WechatStartQuery,
WechatStartResponse, WechatStartResponse,
}; };
use shared_kernel::normalize_optional_string;
use time::OffsetDateTime; use time::OffsetDateTime;
use url::Url; use url::Url;
@@ -208,6 +209,7 @@ pub async fn bind_wechat_phone(
.bind_wechat_verified_phone(BindWechatVerifiedPhoneInput { .bind_wechat_verified_phone(BindWechatVerifiedPhoneInput {
user_id: authenticated.claims().user_id().to_string(), user_id: authenticated.claims().user_id().to_string(),
phone_number: phone_profile.phone_number, phone_number: phone_profile.phone_number,
wechat_display_name: payload.display_name.clone(),
}) })
.await .await
.map_err(map_wechat_bind_phone_error)? .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(), user_id: authenticated.claims().user_id().to_string(),
phone_number: phone.to_string(), phone_number: phone.to_string(),
verify_code: code.to_string(), verify_code: code.to_string(),
wechat_display_name: payload.display_name.clone(),
}, },
OffsetDateTime::now_utc(), OffsetDateTime::now_utc(),
) )
@@ -313,7 +316,7 @@ pub async fn login_wechat_mini_program(
let result = state let result = state
.wechat_auth_service() .wechat_auth_service()
.resolve_login(module_auth::ResolveWechatLoginInput { .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 .await
.map_err(map_wechat_auth_error)?; .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<String>,
) -> 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 { 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 { let Some(raw_value) = raw_value.map(str::trim).filter(|value| !value.is_empty()) else {
return fallback.to_string(); return fallback.to_string();

View File

@@ -65,12 +65,14 @@ pub struct BindWechatPhoneInput {
pub user_id: String, pub user_id: String,
pub phone_number: String, pub phone_number: String,
pub verify_code: String, pub verify_code: String,
pub wechat_display_name: Option<String>,
} }
#[derive(Clone, Debug, PartialEq, Eq)] #[derive(Clone, Debug, PartialEq, Eq)]
pub struct BindWechatVerifiedPhoneInput { pub struct BindWechatVerifiedPhoneInput {
pub user_id: String, pub user_id: String,
pub phone_number: String, pub phone_number: String,
pub wechat_display_name: Option<String>,
} }
#[derive(Clone, Debug, PartialEq, Eq)] #[derive(Clone, Debug, PartialEq, Eq)]

View File

@@ -111,11 +111,7 @@ fn hydrate_private_auth_fields(
.find(|identity| identity.user_id == hydrated.user.id); .find(|identity| identity.user_id == hydrated.user.id);
if hydrated.user.wechat_display_name.is_none() { if hydrated.user.wechat_display_name.is_none() {
hydrated.user.wechat_display_name = hydrated_wechat_identity hydrated.user.wechat_display_name = hydrated_wechat_identity
.and_then(|identity| identity.display_name.clone()) .and_then(|identity| normalize_optional_string(identity.display_name.clone()));
.or_else(|| {
(hydrated.user.login_method == AuthLoginMethod::Wechat)
.then(|| hydrated.user.display_name.clone())
});
} }
if hydrated.user.wechat_account.is_none() { if hydrated.user.wechat_account.is_none() {
hydrated.user.wechat_account = hydrated.user.wechat_account =
@@ -655,9 +651,11 @@ impl PhoneAuthService {
return Err(PhoneAuthError::UserStateMismatch); return Err(PhoneAuthError::UserStateMismatch);
} }
let (merged_user, activated_new_user) = self let (merged_user, activated_new_user) = self.store.bind_wechat_phone_to_user(
.store &input.user_id,
.bind_wechat_phone_to_user(&input.user_id, normalized_phone)?; normalized_phone,
input.wechat_display_name,
)?;
Ok(BindWechatPhoneResult { Ok(BindWechatPhoneResult {
user: merged_user, user: merged_user,
@@ -711,9 +709,11 @@ impl PhoneAuthService {
return Err(PhoneAuthError::UserStateMismatch); return Err(PhoneAuthError::UserStateMismatch);
} }
let (merged_user, activated_new_user) = self let (merged_user, activated_new_user) = self.store.bind_wechat_phone_to_user(
.store &input.user_id,
.bind_wechat_phone_to_user(&input.user_id, normalized_phone)?; normalized_phone,
input.wechat_display_name,
)?;
Ok(BindWechatPhoneResult { Ok(BindWechatPhoneResult {
user: merged_user, user: merged_user,
@@ -1365,8 +1365,7 @@ impl InMemoryAuthStore {
.filter(|value| !value.is_empty()) .filter(|value| !value.is_empty())
.unwrap_or("微信旅人") .unwrap_or("微信旅人")
.to_string(); .to_string();
let wechat_display_name = normalize_optional_string(profile.display_name.clone()) let wechat_display_name = normalize_optional_string(profile.display_name.clone());
.or_else(|| Some(display_name.clone()));
let username = build_wechat_username(&display_name, &profile.provider_uid); let username = build_wechat_username(&display_name, &profile.provider_uid);
let provider_uid = normalize_required_string(&profile.provider_uid).unwrap_or_default(); let provider_uid = normalize_required_string(&profile.provider_uid).unwrap_or_default();
let user = AuthUser { let user = AuthUser {
@@ -1758,11 +1757,13 @@ impl InMemoryAuthStore {
&self, &self,
pending_user_id: &str, pending_user_id: &str,
phone_number: PhoneNumberSnapshot, phone_number: PhoneNumberSnapshot,
wechat_display_name: Option<String>,
) -> Result<(AuthUser, bool), PhoneAuthError> { ) -> Result<(AuthUser, bool), PhoneAuthError> {
let mut state = self let mut state = self
.inner .inner
.lock() .lock()
.map_err(|_| PhoneAuthError::Store("用户仓储锁已中毒".to_string()))?; .map_err(|_| PhoneAuthError::Store("用户仓储锁已中毒".to_string()))?;
let submitted_wechat_display_name = normalize_optional_string(wechat_display_name);
let existing_phone_user_id = let existing_phone_user_id =
Self::resolve_phone_user_locked(&mut state, &phone_number.e164) Self::resolve_phone_user_locked(&mut state, &phone_number.e164)
@@ -1777,20 +1778,24 @@ impl InMemoryAuthStore {
.cloned() .cloned()
.ok_or(PhoneAuthError::UserStateMismatch)?; .ok_or(PhoneAuthError::UserStateMismatch)?;
let pending_wechat_account = pending_wechat_identity.provider_uid.clone(); let pending_wechat_account = pending_wechat_identity.provider_uid.clone();
let pending_wechat_display_name = pending_wechat_identity.display_name.clone(); let pending_user = state
let pending_username = state
.users_by_username .users_by_username
.values() .values()
.find(|stored| stored.user.id == pending_user_id) .find(|stored| stored.user.id == pending_user_id)
.map(|stored| stored.user.username.clone()) .cloned()
.ok_or(PhoneAuthError::UserNotFound)?; .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.users_by_username.remove(&pending_username);
state.wechat_identity_by_provider_uid.insert( state.wechat_identity_by_provider_uid.insert(
pending_wechat_identity.provider_uid.clone(), pending_wechat_identity.provider_uid.clone(),
StoredWechatIdentity { StoredWechatIdentity {
user_id: target_user_id.clone(), user_id: target_user_id.clone(),
display_name: pending_wechat_display_name.clone(),
..pending_wechat_identity.clone() ..pending_wechat_identity.clone()
}, },
); );
@@ -1825,11 +1830,31 @@ impl InMemoryAuthStore {
.values() .values()
.find(|identity| identity.user_id == pending_user_id) .find(|identity| identity.user_id == pending_user_id)
.map(|identity| identity.provider_uid.clone()); .map(|identity| identity.provider_uid.clone());
let bound_wechat_display_name = state let bound_wechat_display_name = submitted_wechat_display_name.clone().or_else(|| {
.wechat_identity_by_provider_uid state
.values() .wechat_identity_by_provider_uid
.find(|identity| identity.user_id == pending_user_id) .values()
.and_then(|identity| identity.display_name.clone()); .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 let stored_user = state
.users_by_username .users_by_username
@@ -3584,6 +3609,7 @@ mod tests {
user_id: wechat_user.id.clone(), user_id: wechat_user.id.clone(),
phone_number: "13800138000".to_string(), phone_number: "13800138000".to_string(),
verify_code: "123456".to_string(), verify_code: "123456".to_string(),
wechat_display_name: None,
}, },
now + Duration::seconds(3), now + Duration::seconds(3),
) )
@@ -3619,4 +3645,97 @@ mod tests {
Some("已归并微信用户") 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")
);
}
} }

View File

@@ -796,7 +796,7 @@ impl WechatProvider {
) -> Result<WechatIdentityProfile, WechatProviderError> { ) -> Result<WechatIdentityProfile, WechatProviderError> {
match self { match self {
Self::Disabled => Err(WechatProviderError::Disabled), 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, Self::Real(provider) => provider.resolve_mini_program_login_profile(code).await,
} }
} }
@@ -839,6 +839,21 @@ impl MockWechatProvider {
session_key: None, 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 { impl RealWechatProvider {
@@ -2274,6 +2289,42 @@ mod tests {
assert_eq!(profile.display_name.as_deref(), Some("微信测试用户")); 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 { fn build_jwt_config() -> JwtConfig {
JwtConfig::new( JwtConfig::new(
"https://auth.genarrative.local".to_string(), "https://auth.genarrative.local".to_string(),

View File

@@ -228,6 +228,8 @@ pub struct WechatBindPhoneRequest {
pub code: Option<String>, pub code: Option<String>,
#[serde(default)] #[serde(default)]
pub wechat_phone_code: Option<String>, pub wechat_phone_code: Option<String>,
#[serde(default)]
pub display_name: Option<String>,
} }
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
@@ -241,6 +243,8 @@ pub struct WechatBindPhoneResponse {
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct WechatMiniProgramLoginRequest { pub struct WechatMiniProgramLoginRequest {
pub code: String, pub code: String,
#[serde(default)]
pub display_name: Option<String>,
} }
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
@@ -354,6 +358,7 @@ mod tests {
phone: None, phone: None,
code: None, code: None,
wechat_phone_code: Some("wx-phone-code-001".to_string()), wechat_phone_code: Some("wx-phone-code-001".to_string()),
display_name: Some("陶泥儿玩家".to_string()),
}) })
.expect("payload should serialize"); .expect("payload should serialize");
@@ -362,7 +367,25 @@ mod tests {
json!({ json!({
"phone": null, "phone": null,
"code": 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": "陶泥儿玩家"
}) })
); );
} }

View File

@@ -177,6 +177,23 @@ test('account panel uses compact binding cards and keeps logout actions at the b
).toBe('true'); ).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 () => { test('account actions open in independent panels instead of inline expansion', async () => {
const user = userEvent.setup(); const user = userEvent.setup();

View File

@@ -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({ function SettingsEntryCard({
label, label,
detail, detail,
@@ -444,7 +453,9 @@ export function AccountModal({
const boundPhoneNumber = const boundPhoneNumber =
user.phoneNumber?.trim() || user.phoneNumberMasked || '未绑定'; user.phoneNumber?.trim() || user.phoneNumberMasked || '未绑定';
const boundWechatDisplayName = const boundWechatDisplayName =
user.wechatDisplayName?.trim() || (user.wechatBound ? '已绑定' : '未绑定'); user.wechatDisplayName?.trim() ||
formatBoundWechatAccount(user.wechatAccount) ||
(user.wechatBound ? '微信账号已绑定' : '未绑定');
const sectionSummaries: Record<PrimarySettingsSection, string> = { const sectionSummaries: Record<PrimarySettingsSection, string> = {
appearance: appearance:

View File

@@ -11806,6 +11806,7 @@ test('creation hub gives jump hop wooden fish and bark battle cards the shared d
sourceSessionId: 'jump-hop-session-delete', sourceSessionId: 'jump-hop-session-delete',
workTitle: '跳台删除草稿', workTitle: '跳台删除草稿',
workDescription: '跳一跳草稿也应接入统一删除。', workDescription: '跳一跳草稿也应接入统一删除。',
themeText: '跳台',
themeTags: ['跳台'], themeTags: ['跳台'],
difficulty: 'standard', difficulty: 'standard',
stylePreset: 'paper-toy', stylePreset: 'paper-toy',