feat(auth): 小程序登录采集微信昵称
This commit is contained in:
@@ -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 切片。
|
||||||
|
|||||||
@@ -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,而不是把账号 / 会话快照恢复为全量对象。
|
||||||
|
|
||||||
|
|||||||
@@ -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` 作为绑定账号标识,前端在没有真实昵称时展示微信账号尾号,不展示裸“已绑定”。换绑入口放在对应模块右上角,退出登录和退出全部设备固定放在面板内容最底部。
|
||||||
|
|
||||||
## 账户与充值
|
## 账户与充值
|
||||||
|
|
||||||
|
|||||||
@@ -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: '',
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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%;
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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)
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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)]
|
||||||
|
|||||||
@@ -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")
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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": "陶泥儿玩家"
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
Reference in New Issue
Block a user