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