fix: refine mini program nickname collection flow

This commit is contained in:
kdletters
2026-06-07 00:42:05 +08:00
parent 79d0d7a305
commit e3ecac85f3
8 changed files with 109 additions and 10 deletions

View File

@@ -74,7 +74,7 @@ npm run check:server-rs-ddd
### 认证态用户与会话摘要下发口径 ### 认证态用户与会话摘要下发口径
- `AuthUserPayload` / `AuthUser` 只保留前端当前会用到的身份与绑定展示字段:`id``publicUserCode``displayName``avatarUrl``phoneNumber``phoneNumberMasked``loginMethod``bindingStatus``wechatBound``wechatDisplayName``wechatAccount`。账号信息面板展示微信绑定时优先使用 `wechatDisplayName`;该字段只能来自微信平台 profile、历史已保存的微信身份资料或小程序原生 `input type="nickname"` 提交的 `displayName`,不得用系统账号显示名或“微信旅人”这类假昵称兜底。小程序 `/api/auth/wechat/miniprogram-login``/api/auth/wechat/bind-phone` 可接收 `displayName``jscode2session` 无法直接返回微信昵称或个人微信号,只能稳定拿到小程序维度 `openid`,后端以 `wechatAccount` 下发可区分的绑定账号标识,前端在缺少真实昵称时展示账号尾号。 - `AuthUserPayload` / `AuthUser` 只保留前端当前会用到的身份与绑定展示字段:`id``publicUserCode``displayName``avatarUrl``phoneNumber``phoneNumberMasked``loginMethod``bindingStatus``wechatBound``wechatDisplayName``wechatAccount`。账号信息面板展示微信绑定时优先使用 `wechatDisplayName`;该字段只能来自微信平台 profile、历史已保存的微信身份资料或小程序原生 `input type="nickname"` 提交的 `displayName`,不得用系统账号显示名或“微信旅人”这类假昵称兜底。小程序 `/api/auth/wechat/miniprogram-login``/api/auth/wechat/bind-phone` 可接收 `displayName``/api/auth/wechat/miniprogram-login` 额外返回 `created`,供小程序壳在快捷登录后判断是否需要补采集微信昵称。`jscode2session` 无法直接返回微信昵称或个人微信号,只能稳定拿到小程序维度 `openid`,后端以 `wechatAccount` 下发可区分的绑定账号标识,前端在缺少真实昵称时展示账号尾号。
- `AuthSessionSummaryPayload` / `AuthSessionSummary` 只保留设备卡片与撤销需要的摘要字段:`sessionId``sessionIds``sessionCount``clientLabel``ipMasked``isCurrent``createdAt``lastSeenAt``expiresAt` - `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 登录弹窗,也不走手输手机号 / 短信验证码流程;统一先通过原生 `input type="nickname"` 获取微信昵称并作为 `displayName`,再通过 `wx.login` 获取微信登录 code 并调用 `/api/auth/wechat/miniprogram-login`。若后端返回 `pending_bind_phone`,再通过原生 `button open-type="getPhoneNumber"` 获取微信手机号授权并调用 `/api/auth/wechat/bind-phone` 换取系统登录态。 5. 小程序内需要登录时不展示 H5 登录弹窗,也不走手输手机号 / 短信验证码流程;统一先通过 `wx.login` 获取微信登录 code 并调用 `/api/auth/wechat/miniprogram-login` 完成快捷登录。若该接口返回 `created=true`,或返回用户昵称仍是手机号、公开陶泥号、“微信旅人”等默认展示值,才展示原生 `input type="nickname"` 补充微信昵称并再次调用 `/api/auth/wechat/miniprogram-login` 写入 `displayName`。若后端返回 `pending_bind_phone`,再通过原生 `button open-type="getPhoneNumber"` 获取微信手机号授权并调用 `/api/auth/wechat/bind-phone` 换取系统登录态。
6. 小程序外壳注入到 H5 URL 的 `clientType``clientRuntime``miniProgramEnv` 是宿主上下文H5 内部 `pushState` / 阶段导航必须跨页面保留,避免登录和充值误判为普通浏览器;首点时微信 JS bridge 可能尚未就绪,前端还需用 `MicroMessenger + miniProgram` User-Agent 作为小程序识别兜底。 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. 账号信息面板只展示 `账号信息` 标题;绑定手机号和绑定微信以紧凑模块展示当前绑定状态,已绑定手机号展示完整手机号,已绑定微信优先展示微信平台实际返回并由后端保存的 `wechatDisplayName`。小程序 `jscode2session` 不能直接返回微信昵称或个人微信号,只能稳定拿到当前小程序维度的 `openid`,并在满足微信开放平台条件时拿到 `unionid`;小程序昵称来自原生 `input type="nickname"` 提交的 `displayName`。后端下发 `wechatAccount` 作为绑定账号标识,前端在没有真实昵称时展示微信账号尾号,不展示裸“已绑定”。换绑入口放在对应模块右上角,退出登录和退出全部设备固定放在面板内容最底部。 9. 账号信息面板只展示 `账号信息` 标题;绑定手机号和绑定微信以紧凑模块展示当前绑定状态,已绑定手机号展示完整手机号,已绑定微信优先展示微信平台实际返回并由后端保存的 `wechatDisplayName`。小程序 `jscode2session` 不能直接返回微信昵称或个人微信号,只能稳定拿到当前小程序维度的 `openid`,并在满足微信开放平台条件时拿到 `unionid`;小程序昵称来自快捷登录后按需展示的原生 `input type="nickname"` 提交的 `displayName`。后端下发 `wechatAccount` 作为绑定账号标识,前端在没有真实昵称时展示微信账号尾号,不展示裸“已绑定”。换绑入口放在对应模块右上角,退出登录和退出全部设备固定放在面板内容最底部。
## 账户与充值 ## 账户与充值

View File

@@ -111,6 +111,58 @@ function normalizeNicknameInput(value) {
return String(value || '').trim(); return String(value || '').trim();
} }
function normalizeNicknameForMatch(value) {
return normalizeNicknameInput(value).replace(/\s+/gu, '').toLowerCase();
}
function isPhoneLikeDisplayName(value) {
const normalized = normalizeNicknameForMatch(value);
if (!normalized) {
return false;
}
const digits = normalized.replace(/\D/gu, '');
return (
/^(\+?86)?1\d{10}$/u.test(normalized) ||
/^1\d{2}\*{4}\d{4}$/u.test(normalized) ||
(/[*x]/iu.test(normalized) && digits.length >= 7) ||
digits.length >= 11
);
}
function isDefaultDisplayName(value, publicUserCode) {
const normalized = normalizeNicknameForMatch(value);
const normalizedPublicUserCode = normalizeNicknameForMatch(publicUserCode);
if (!normalized) {
return true;
}
return (
normalized === '微信旅人' ||
normalized === '玩家' ||
normalized === normalizedPublicUserCode ||
/^sy-\d{8}$/iu.test(normalized) ||
/^user[_-]/iu.test(normalized) ||
isPhoneLikeDisplayName(normalized)
);
}
function shouldRequestNicknameAfterLogin(authResult) {
const user = authResult && authResult.user ? authResult.user : {};
const wechatDisplayName = normalizeNicknameInput(user.wechatDisplayName);
if (wechatDisplayName && !isDefaultDisplayName(wechatDisplayName, user.publicUserCode)) {
return false;
}
return (
authResult &&
(authResult.created ||
isDefaultDisplayName(user.displayName, user.publicUserCode) ||
(wechatDisplayName &&
isDefaultDisplayName(wechatDisplayName, user.publicUserCode)))
);
}
function normalizeMiniProgramEnv(value) { function normalizeMiniProgramEnv(value) {
const normalized = String(value || '').trim().toLowerCase(); const normalized = String(value || '').trim().toLowerCase();
if (normalized === 'release') { if (normalized === 'release') {
@@ -372,6 +424,8 @@ async function resolveAuthResult(displayName) {
return { return {
token: response.token, token: response.token,
bindingStatus: response.bindingStatus || 'pending_bind_phone', bindingStatus: response.bindingStatus || 'pending_bind_phone',
user: response.user || null,
created: response.created === true,
}; };
} }
@@ -431,13 +485,14 @@ Page({
authResult: null, authResult: null,
bindingPhone: false, bindingPhone: false,
errorMessage: '', errorMessage: '',
loggingIn: false, loggingIn: true,
loading: false, loading: true,
nicknameRequired: true, nicknameRequired: false,
phoneBindingRequired: false, phoneBindingRequired: false,
returnToPreviousPage, returnToPreviousPage,
webViewUrl: '', webViewUrl: '',
}); });
await this.startAuthFlow(returnToPreviousPage, '');
}, },
handleNicknameInput(event) { handleNicknameInput(event) {
@@ -465,6 +520,20 @@ Page({
async startAuthFlow(returnToPreviousPage, displayName) { async startAuthFlow(returnToPreviousPage, displayName) {
try { try {
const authResult = await resolveAuthResult(displayName); const authResult = await resolveAuthResult(displayName);
if (!displayName && shouldRequestNicknameAfterLogin(authResult)) {
this.setData({
authResult,
errorMessage: '',
loggingIn: false,
loading: false,
nicknameRequired: true,
phoneBindingRequired: false,
returnToPreviousPage,
webViewUrl: '',
});
return;
}
if (authResult.bindingStatus === 'pending_bind_phone') { if (authResult.bindingStatus === 'pending_bind_phone') {
this.setData({ this.setData({
authResult, authResult,
@@ -512,7 +581,7 @@ Page({
error && error.message ? error.message : '微信登录失败,请稍后重试。', error && error.message ? error.message : '微信登录失败,请稍后重试。',
loggingIn: false, loggingIn: false,
loading: false, loading: false,
nicknameRequired: true, nicknameRequired: false,
phoneBindingRequired: false, phoneBindingRequired: false,
returnToPreviousPage, returnToPreviousPage,
webViewUrl: '', webViewUrl: '',

View File

@@ -10,7 +10,7 @@
<view wx:elif="{{nicknameRequired}}" class="setup-screen"> <view wx:elif="{{nicknameRequired}}" class="setup-screen">
<view class="setup-card"> <view class="setup-card">
<view class="setup-title">登录</view> <view class="setup-title">完善昵称</view>
<view wx:if="{{errorMessage}}" class="setup-text setup-text--danger"> <view wx:if="{{errorMessage}}" class="setup-text setup-text--danger">
{{errorMessage}} {{errorMessage}}
</view> </view>
@@ -29,7 +29,7 @@
disabled="{{loggingIn}}" disabled="{{loggingIn}}"
bindtap="handleStartLogin" bindtap="handleStartLogin"
> >
{{loggingIn ? '正在登录' : '微信快捷登录'}} {{loggingIn ? '正在提交' : '确认昵称'}}
</button> </button>
</view> </view>
</view> </view>

View File

@@ -143,6 +143,7 @@ export type AuthWechatMiniProgramLoginResponse = {
token: string; token: string;
bindingStatus: AuthBindingStatus; bindingStatus: AuthBindingStatus;
user: AuthUser; user: AuthUser;
created: boolean;
}; };
export type AuthPhoneChangeRequest = { export type AuthPhoneChangeRequest = {

View File

@@ -2640,6 +2640,7 @@ mod tests {
login_payload["bindingStatus"], login_payload["bindingStatus"],
Value::String("pending_bind_phone".to_string()) Value::String("pending_bind_phone".to_string())
); );
assert_eq!(login_payload["created"], Value::Bool(true));
assert_eq!( assert_eq!(
login_payload["user"]["loginMethod"], login_payload["user"]["loginMethod"],
Value::String("wechat".to_string()) Value::String("wechat".to_string())
@@ -2746,6 +2747,7 @@ mod tests {
login_payload["bindingStatus"], login_payload["bindingStatus"],
Value::String("pending_bind_phone".to_string()) Value::String("pending_bind_phone".to_string())
); );
assert_eq!(login_payload["created"], Value::Bool(true));
let bind_response = app let bind_response = app
.oneshot( .oneshot(
@@ -4423,4 +4425,4 @@ mod tests {
assert_eq!(response.status(), StatusCode::NOT_FOUND, "{path}"); assert_eq!(response.status(), StatusCode::NOT_FOUND, "{path}");
} }
} }
} }

View File

@@ -349,6 +349,7 @@ pub async fn login_wechat_mini_program(
token: signed_session.access_token, token: signed_session.access_token,
binding_status: result.user.binding_status.as_str().to_string(), binding_status: result.user.binding_status.as_str().to_string(),
user: map_auth_user_payload(result.user), user: map_auth_user_payload(result.user),
created: result.created,
}, },
), ),
)) ))

View File

@@ -253,6 +253,7 @@ pub struct WechatMiniProgramLoginResponse {
pub token: String, pub token: String,
pub binding_status: String, pub binding_status: String,
pub user: AuthUserPayload, pub user: AuthUserPayload,
pub created: bool,
} }
pub fn build_available_login_methods( pub fn build_available_login_methods(
@@ -389,4 +390,29 @@ mod tests {
}) })
); );
} }
#[test]
fn wechat_mini_program_login_response_marks_created_user() {
let payload = serde_json::to_value(WechatMiniProgramLoginResponse {
token: "token-001".to_string(),
binding_status: AUTH_BINDING_STATUS_PENDING_BIND_PHONE.to_string(),
user: AuthUserPayload {
id: "user_001".to_string(),
public_user_code: "SY-00000001".to_string(),
display_name: "微信旅人".to_string(),
avatar_url: None,
phone_number: None,
phone_number_masked: None,
login_method: AUTH_LOGIN_METHOD_WECHAT.to_string(),
binding_status: AUTH_BINDING_STATUS_PENDING_BIND_PHONE.to_string(),
wechat_bound: true,
wechat_display_name: None,
wechat_account: Some("wx-openid-001".to_string()),
},
created: true,
})
.expect("payload should serialize");
assert_eq!(payload["created"], serde_json::Value::Bool(true));
}
} }