feat(login): 接续 noMobile 手机号授权注册步骤,登录链路全量调试日志

This commit is contained in:
ozh 2026-09-16 10:59:38 +08:00
parent 46c517ff42
commit 96c2255c10
7 changed files with 200 additions and 63 deletions

View File

@ -34,9 +34,10 @@ export interface WxLoginResult {
openId?: string // 首次登录返回,需本地缓存 openId?: string // 首次登录返回,需本地缓存
} }
// 登录响应联合体:Message === 'needUserInfo' 时为绑定凭证形态(needUserInfo 由 http 层标记) // 登录响应联合体:Message 子状态由 http 层附加到 message 字段
// ''/undefined = 完整登录;'needUserInfo' = 需头像昵称授权;'noMobile' = 需手机号授权
export type WxMiniappLoginResponse = WxLoginResult & export type WxMiniappLoginResponse = WxLoginResult &
Partial<WxNeedUserInfoResult> & { needUserInfo?: boolean } Partial<WxNeedUserInfoResult> & { message?: string }
export function wxMiniappLogin(params: WxMiniappLoginParams): Promise<WxMiniappLoginResponse> { export function wxMiniappLogin(params: WxMiniappLoginParams): Promise<WxMiniappLoginResponse> {
return request.post<WxLoginResult>( return request.post<WxLoginResult>(

View File

@ -44,7 +44,8 @@ export interface RequestConfig {
export interface ApiEnvelope<T> { export interface ApiEnvelope<T> {
Code: string Code: string
Message?: string Message?: string
Content: T & { needUserInfo?: boolean } // message 为 http 层附加的 200 内子状态(needUserInfo/noMobile 等)
Content: T & { message?: string }
RecordCount?: number RecordCount?: number
PageIndex?: number PageIndex?: number
PageSize?: number PageSize?: number
@ -83,9 +84,12 @@ export async function request<T>(
} catch (e) { } catch (e) {
// Taro 网络 API reject 的是 { errMsg } 形状对象而非 Error 实例,需兼容读取以便透出真实原因 // Taro 网络 API reject 的是 { errMsg } 形状对象而非 Error 实例,需兼容读取以便透出真实原因
const raw = e instanceof Error ? e.message : (e as { errMsg?: string })?.errMsg const raw = e instanceof Error ? e.message : (e as { errMsg?: string })?.errMsg
console.error(`[http] ${method} ${url} 网络失败:`, raw)
throw new ApiError(raw || '网络异常') throw new ApiError(raw || '网络异常')
} }
console.log(`[http] ${method} ${url} ←`, res.statusCode, res.data)
if (res.statusCode !== 200) { if (res.statusCode !== 200) {
if (res.statusCode === 401) { if (res.statusCode === 401) {
redirectToLogin(tokenAtStart) redirectToLogin(tokenAtStart)
@ -108,8 +112,12 @@ export async function request<T>(
if (bizCode !== '200') { if (bizCode !== '200') {
throw new BusinessError(body.Message || '业务错误', bizCode) throw new BusinessError(body.Message || '业务错误', bizCode)
} }
// needUserInfo 是 200 成功内的子状态(Message 携带),附到返回值供调用方分支处理 // 200 成功内的子状态(Message 携带,如 needUserInfo/noMobile),附到返回值供调用方分支处理
if (body.Message === 'needUserInfo') body.Content.needUserInfo = true const subState = body.Message
if (subState && subState !== '') {
console.log(`[http] ${url} 响应 Message=${subState}`, body.Content)
;(body.Content as Record<string, unknown>).message = subState
}
return body.Content return body.Content
} }

View File

@ -63,6 +63,27 @@
margin-top: 32px; margin-top: 32px;
} }
// 手机号授权用原生 Button(open-type=getPhoneNumber),抹平默认样式与 .submit 对齐
.submitBtn {
height: 96px;
margin-top: 32px;
background: $color-primary-grad;
border-radius: 48px;
display: flex;
align-items: center;
justify-content: center;
color: #ffffff;
font-size: 32px;
font-weight: 600;
line-height: 1;
padding: 0;
border: none;
&::after {
border: none;
}
}
.submitText { .submitText {
color: #ffffff; color: #ffffff;
font-size: 32px; font-size: 32px;

View File

@ -1,9 +1,15 @@
import { useState } from 'react' import { useState } from 'react'
import { View, Text } from '@tarojs/components' import { View, Text, Button } from '@tarojs/components'
import { useDidShow } from '@tarojs/taro' import { useDidShow } from '@tarojs/taro'
import { useAppDispatch, useAppSelector } from '@/store/hooks' import { useAppDispatch, useAppSelector } from '@/store/hooks'
import { setAuth } from '@/store/authSlice' import { setAuth } from '@/store/authSlice'
import { performWxLogin, completeUserInfoLogin, isNeedUserInfo } from './wxLoginFlow' import {
performWxLogin,
completeUserInfoLogin,
completeMobileLogin,
getLoginSubState,
type LoginSubState
} from './wxLoginFlow'
import { useToast } from '@/hooks/useToast' import { useToast } from '@/hooks/useToast'
import { ROUTES } from '@/constants/routes' import { ROUTES } from '@/constants/routes'
import { reLaunch } from '@/utils/navigation' import { reLaunch } from '@/utils/navigation'
@ -14,52 +20,84 @@ export default function Login() {
const toast = useToast() const toast = useToast()
const token = useAppSelector((s) => s.auth.token) const token = useAppSelector((s) => s.auth.token)
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
// 首次登录 needUserInfo 中间态:持有 openId 等待用户授权补全 // 注册中间态:needUserInfo(待头像昵称授权)→ noMobile(待手机号授权)→ null(初始/完成)
const [pendingAuth, setPendingAuth] = useState<string | null>(null) const [subState, setSubState] = useState<LoginSubState | null>(null)
const [openId, setOpenId] = useState('')
// 已有 token 直接进首页(本期不做 checkLogin 校验,spec D5) // 已有 token 直接进首页(本期不做 checkLogin 校验,spec D5)
useDidShow(() => { useDidShow(() => {
if (token && !loading) reLaunch(ROUTES.HOME) if (token && !loading) reLaunch(ROUTES.HOME)
}) })
const handleWxLogin = async () => { // 完整登录态校验与落地;未完成时推进 subState
if (loading) return const applyResult = async (result: Awaited<ReturnType<typeof performWxLogin>>) => {
setLoading(true) const state = getLoginSubState(result)
try { if (state !== 'complete') {
const result = await performWxLogin() console.log('[login-page] 注册未完成,推进到', state)
if (isNeedUserInfo(result)) { if (result.openId) setOpenId(result.openId)
// 首次登录:后端要求补授权,弹授权按钮等用户点击 setSubState(state)
setPendingAuth(result.openId ?? '')
setLoading(false) setLoading(false)
return return
} }
if (!result.code || !result.userInfo) throw new Error('登录响应缺少凭证') if (!result.code || !result.userInfo) throw new Error('登录响应缺少凭证')
console.log('[login-page] 登录完成,token 已落地')
setSubState(null)
dispatch(setAuth({ token: result.code, userInfo: result.userInfo })) dispatch(setAuth({ token: result.code, userInfo: result.userInfo }))
await reLaunch(ROUTES.HOME) await reLaunch(ROUTES.HOME)
}
const handleWxLogin = async () => {
if (loading) return
setLoading(true)
try {
applyResult(await performWxLogin())
} catch (e) { } catch (e) {
// code 失效/已用等场景:toast 后端 Message,用户重按按钮重新 Taro.login(code 一次性,重试安全) // code 失效/已用等场景:toast 后端 Message,用户重按按钮重新 Taro.login(code 一次性,重试安全)
console.error('[login-page] performWxLogin 失败', e)
toast.error(e instanceof Error && e.message ? e.message : '登录失败') toast.error(e instanceof Error && e.message ? e.message : '登录失败')
setLoading(false) setLoading(false)
} }
} }
// needUserInfo:getUserProfile 授权头像昵称
const handleAuthUserInfo = async () => { const handleAuthUserInfo = async () => {
if (loading || !pendingAuth) return if (loading || !openId) return
setLoading(true) setLoading(true)
try { try {
const result = await completeUserInfoLogin(pendingAuth) applyResult(await completeUserInfoLogin(openId))
if (isNeedUserInfo(result)) throw new Error('授权后仍未获取到用户信息')
if (!result.code || !result.userInfo) throw new Error('登录响应缺少凭证')
setPendingAuth(null)
dispatch(setAuth({ token: result.code, userInfo: result.userInfo }))
await reLaunch(ROUTES.HOME)
} catch (e) { } catch (e) {
// 授权失败/取消:保留待授权态,用户可重试(getUserProfile 每次都需用户确认) // 授权失败/取消:保留待授权态,用户可重试(getUserProfile 每次都需用户确认)
console.error('[login-page] completeUserInfoLogin 失败', e)
toast.error(e instanceof Error && e.message ? e.message : '授权失败') toast.error(e instanceof Error && e.message ? e.message : '授权失败')
setLoading(false) setLoading(false)
} }
} }
// noMobile:手机号授权按钮回调(Button open-type=getPhoneNumber)
const handleGetPhoneNumber = async (e: {
detail: { iv: string; encryptedData: string; errMsg?: string }
}) => {
console.log('[login-page] 手机号授权回调', e.detail.errMsg)
if (loading || !openId) return
setLoading(true)
try {
applyResult(await completeMobileLogin(openId, e.detail))
} catch (err) {
console.error('[login-page] completeMobileLogin 失败', err)
toast.error(err instanceof Error && err.message ? err.message : '授权失败')
setLoading(false)
}
}
const submitText = () => {
if (loading) {
return subState === 'noMobile' ? '绑定中...' : subState === 'needUserInfo' ? '授权中...' : '登录中...'
}
if (subState === 'needUserInfo') return '授权头像昵称,继续'
if (subState === 'noMobile') return '授权手机号,完成注册'
return '微信一键登录'
}
return ( return (
<View className={styles.page}> <View className={styles.page}>
<View className={styles.hero}> <View className={styles.hero}>
@ -70,13 +108,21 @@ export default function Login() {
<Text className={styles.slogan}>一起守护家人</Text> <Text className={styles.slogan}>一起守护家人</Text>
</View> </View>
<View className={styles.form}> <View className={styles.form}>
{pendingAuth === null ? ( {subState === 'noMobile' ? (
<View className={styles.submit} onClick={handleWxLogin}> // 手机号授权必须用原生 Button open-type,微信弹官方授权框
<Text className={styles.submitText}>{loading ? '登录中...' : '微信一键登录'}</Text> <Button
</View> className={styles.submitBtn}
openType="getPhoneNumber"
onGetPhoneNumber={handleGetPhoneNumber}
>
{submitText()}
</Button>
) : ( ) : (
<View className={styles.submit} onClick={handleAuthUserInfo}> <View
<Text className={styles.submitText}>{loading ? '授权中...' : '授权头像昵称,完成登录'}</Text> className={styles.submit}
onClick={subState === 'needUserInfo' ? handleAuthUserInfo : handleWxLogin}
>
<Text className={styles.submitText}>{submitText()}</Text>
</View> </View>
)} )}
</View> </View>

View File

@ -12,14 +12,18 @@ function resolveAppId(): string {
} }
} }
// 判断响应是否为"首次登录需补授权"形态(Message=needUserInfo 时 http 层打的标记) // 登录步骤子状态(http 层把 200 内 Message 附加到返回值 message 字段)
export function isNeedUserInfo(res: WxMiniappLoginResponse): boolean { export type LoginSubState = 'needUserInfo' | 'noMobile' | 'complete'
return res.needUserInfo === true
export function getLoginSubState(res: WxMiniappLoginResponse): LoginSubState {
if (res.message === 'needUserInfo') return 'needUserInfo'
if (res.message === 'noMobile') return 'noMobile'
return 'complete'
} }
// 微信一键登录主流程: // 微信一键登录主流程:
// Taro.login 换一次性 code → 组装 appId/openId → 调 miniapp/login → 缓存响应中的 openId // Taro.login 换一次性 code → 组装 appId/openId → 调 miniapp/login → 缓存响应中的 openId
// 首次登录后端返回 needUserInfo(仅 openId/bindKey,无 token),需二次授权后重调接口补全 // 首次登录后端可能分步返回 needUserInfo / noMobile(仅绑定凭证,无 token),逐步授权后重调补全
export async function performWxLogin(): Promise<WxMiniappLoginResponse> { export async function performWxLogin(): Promise<WxMiniappLoginResponse> {
// Taro.login reject 时返回 { errMsg } 形状对象而非 Error,此处归一化为带 message 的 Error // Taro.login reject 时返回 { errMsg } 形状对象而非 Error,此处归一化为带 message 的 Error
let code: string let code: string
@ -31,12 +35,34 @@ export async function performWxLogin(): Promise<WxMiniappLoginResponse> {
} }
if (!code) throw new Error('获取微信登录凭证失败') if (!code) throw new Error('获取微信登录凭证失败')
const openId = storage.get<string>(WX_OPENID_STORAGE_KEY) ?? undefined const openId = storage.get<string>(WX_OPENID_STORAGE_KEY) ?? undefined
console.log('[login] performWxLogin 请求', { appId: resolveAppId(), code, openId })
const result = await wxMiniappLogin({ appId: resolveAppId(), code, openId }) const result = await wxMiniappLogin({ appId: resolveAppId(), code, openId })
if (result.openId) storage.set(WX_OPENID_STORAGE_KEY, result.openId) if (result.openId) storage.set(WX_OPENID_STORAGE_KEY, result.openId)
console.log('[login] performWxLogin 结果', getLoginSubState(result), result)
return result return result
} }
// needUserInfo 二次授权:getUserProfile 拿 iv/encryptedData → 换新 code 重调登录接口补全用户资料 // 重调登录接口的公共尾部:换新 code(一次性)→ 组装参数 → 调用 → 缓存 openId
async function reloginWith(
openId: string,
extra: { iv?: string; encryptedData?: string }
): Promise<WxMiniappLoginResponse> {
let code: string
try {
const r = await Taro.login()
code = r.code
} catch {
throw new Error('获取微信登录凭证失败,请重试')
}
const params = { appId: resolveAppId(), code, openId, ...extra }
console.log('[login] 重调登录接口', params)
const result = await wxMiniappLogin(params)
if (result.openId) storage.set(WX_OPENID_STORAGE_KEY, result.openId)
console.log('[login] 重调结果', getLoginSubState(result), result)
return result
}
// needUserInfo 二次授权:getUserProfile 拿 iv/encryptedData → 重调登录接口补用户资料
export async function completeUserInfoLogin(openId: string): Promise<WxMiniappLoginResponse> { export async function completeUserInfoLogin(openId: string): Promise<WxMiniappLoginResponse> {
let profile: { iv: string; encryptedData: string } let profile: { iv: string; encryptedData: string }
try { try {
@ -45,22 +71,16 @@ export async function completeUserInfoLogin(openId: string): Promise<WxMiniappLo
} catch (e) { } catch (e) {
throw new Error(e instanceof Error ? e.message : (e as { errMsg?: string })?.errMsg || '授权失败') throw new Error(e instanceof Error ? e.message : (e as { errMsg?: string })?.errMsg || '授权失败')
} }
return reloginWith(openId, profile)
let code: string }
try {
const r = await Taro.login() // noMobile 三次授权:手机号授权按钮回调(open-type=getPhoneNumber)拿 iv/encryptedData → 重调登录接口
code = r.code export async function completeMobileLogin(
} catch { openId: string,
throw new Error('获取微信登录凭证失败,请重试') detail: { iv: string; encryptedData: string; errMsg?: string }
} ): Promise<WxMiniappLoginResponse> {
if (!detail.iv || !detail.encryptedData) {
const result = await wxMiniappLogin({ throw new Error(detail.errMsg || '手机号授权失败')
appId: resolveAppId(), }
code, return reloginWith(openId, { iv: detail.iv, encryptedData: detail.encryptedData })
openId,
iv: profile.iv,
encryptedData: profile.encryptedData
})
if (result.openId) storage.set(WX_OPENID_STORAGE_KEY, result.openId)
return result
} }

View File

@ -198,12 +198,21 @@ describe('登录态失效踢回登录页', () => {
await expect(request.get('/demo')).rejects.toBeInstanceOf(ApiError) await expect(request.get('/demo')).rejects.toBeInstanceOf(ApiError)
}) })
it("Message='needUserInfo' 的 200 响应在 Content 上打标记", async () => { it("200 响应的 Message 子状态(needUserInfo 等)附加到返回值 message 字段", async () => {
mockRequest.mockResolvedValue({ mockRequest.mockResolvedValue({
statusCode: 200, statusCode: 200,
data: { Code: '200', Message: 'needUserInfo', Content: { openId: 'o-1', bindKey: 'b-1' } } data: { Code: '200', Message: 'needUserInfo', Content: { openId: 'o-1', bindKey: 'b-1' } }
}) })
const res = await request.get<{ openId: string }>('/demo') const res = await request.get<{ openId: string }>('/demo')
expect(res).toMatchObject({ openId: 'o-1', bindKey: 'b-1', needUserInfo: true }) expect(res).toMatchObject({ openId: 'o-1', bindKey: 'b-1', message: 'needUserInfo' })
})
it("Message='noMobile' 同样附加(手机号授权子状态)", async () => {
mockRequest.mockResolvedValue({
statusCode: 200,
data: { Code: '200', Message: 'noMobile', Content: { bindKey: 'b-2' } }
})
const res = await request.get('/demo')
expect(res).toMatchObject({ bindKey: 'b-2', message: 'noMobile' })
}) })
}) })

View File

@ -23,7 +23,7 @@ vi.mock('@/utils/storage', () => ({
} }
})) }))
import { performWxLogin, completeUserInfoLogin, isNeedUserInfo } from '@/pages/login/wxLoginFlow' import { performWxLogin, completeUserInfoLogin, completeMobileLogin, getLoginSubState } from '@/pages/login/wxLoginFlow'
const result = (over: Record<string, unknown> = {}) => ({ const result = (over: Record<string, unknown> = {}) => ({
userInfo: { userInfo: {
@ -34,11 +34,17 @@ const result = (over: Record<string, unknown> = {}) => ({
...over ...over
}) })
// 首次登录 needUserInfo 响应形态(http 层在 Content 上打的标记) // 首次登录 needUserInfo 响应形态(http 层把 Message 附加到 message 字段)
const needInfoResult = () => ({ const needInfoResult = () => ({
openId: 'o-need', openId: 'o-need',
bindKey: 'bind-1', bindKey: 'bind-1',
needUserInfo: true as const message: 'needUserInfo' as const
})
// noMobile 响应形态(授权头像后仍未绑定手机号)
const noMobileResult = () => ({
bindKey: 'bind-2',
message: 'noMobile' as const
}) })
beforeEach(() => { beforeEach(() => {
@ -115,13 +121,17 @@ describe('needUserInfo 首次登录分支', () => {
it('performWxLogin 返回 needUserInfo 形态时缓存 openId 并原样返回', async () => { it('performWxLogin 返回 needUserInfo 形态时缓存 openId 并原样返回', async () => {
mockWxMiniappLogin.mockResolvedValue(needInfoResult()) mockWxMiniappLogin.mockResolvedValue(needInfoResult())
const res = await performWxLogin() const res = await performWxLogin()
expect(isNeedUserInfo(res)).toBe(true) expect(getLoginSubState(res)).toBe('needUserInfo')
expect(res.openId).toBe('o-need') expect(res.openId).toBe('o-need')
expect(mem.get('wx_openid')).toBe('o-need') expect(mem.get('wx_openid')).toBe('o-need')
}) })
it('isNeedUserInfo 对完整登录响应返回 false', () => { it('getLoginSubState 对完整登录响应返回 complete', () => {
expect(isNeedUserInfo(result())).toBe(false) expect(getLoginSubState(result())).toBe('complete')
})
it('getLoginSubState 对 noMobile 响应返回 noMobile', () => {
expect(getLoginSubState(noMobileResult() as never)).toBe('noMobile')
}) })
}) })
@ -153,3 +163,25 @@ describe('completeUserInfoLogin 二次授权', () => {
expect(mockWxMiniappLogin).not.toHaveBeenCalled() expect(mockWxMiniappLogin).not.toHaveBeenCalled()
}) })
}) })
describe('completeMobileLogin 手机号授权', () => {
it('按钮回调 detail 带 iv/encryptedData → 重调登录接口补手机号', async () => {
mockWxMiniappLogin.mockResolvedValue(result())
const res = await completeMobileLogin('o-need', { iv: 'iv-ph', encryptedData: 'enc-ph' })
expect(res.code).toBe('tk-abc')
expect(mockWxMiniappLogin).toHaveBeenCalledWith({
appId: 'wx-appid-live',
code: 'wx-code-1',
openId: 'o-need',
iv: 'iv-ph',
encryptedData: 'enc-ph'
})
})
it('用户拒绝手机号授权(detail 无加密数据):抛 errMsg 且不发起请求', async () => {
await expect(
completeMobileLogin('o-need', { iv: '', encryptedData: '', errMsg: 'getPhoneNumber:fail user deny' })
).rejects.toThrow('getPhoneNumber:fail user deny')
expect(mockWxMiniappLogin).not.toHaveBeenCalled()
})
})