feat(login): 接续 noMobile 手机号授权注册步骤,登录链路全量调试日志
This commit is contained in:
parent
46c517ff42
commit
96c2255c10
|
|
@ -34,9 +34,10 @@ export interface WxLoginResult {
|
|||
openId?: string // 首次登录返回,需本地缓存
|
||||
}
|
||||
|
||||
// 登录响应联合体:Message === 'needUserInfo' 时为绑定凭证形态(needUserInfo 由 http 层标记)
|
||||
// 登录响应联合体:Message 子状态由 http 层附加到 message 字段
|
||||
// ''/undefined = 完整登录;'needUserInfo' = 需头像昵称授权;'noMobile' = 需手机号授权
|
||||
export type WxMiniappLoginResponse = WxLoginResult &
|
||||
Partial<WxNeedUserInfoResult> & { needUserInfo?: boolean }
|
||||
Partial<WxNeedUserInfoResult> & { message?: string }
|
||||
|
||||
export function wxMiniappLogin(params: WxMiniappLoginParams): Promise<WxMiniappLoginResponse> {
|
||||
return request.post<WxLoginResult>(
|
||||
|
|
|
|||
|
|
@ -44,7 +44,8 @@ export interface RequestConfig {
|
|||
export interface ApiEnvelope<T> {
|
||||
Code: string
|
||||
Message?: string
|
||||
Content: T & { needUserInfo?: boolean }
|
||||
// message 为 http 层附加的 200 内子状态(needUserInfo/noMobile 等)
|
||||
Content: T & { message?: string }
|
||||
RecordCount?: number
|
||||
PageIndex?: number
|
||||
PageSize?: number
|
||||
|
|
@ -83,9 +84,12 @@ export async function request<T>(
|
|||
} catch (e) {
|
||||
// Taro 网络 API reject 的是 { errMsg } 形状对象而非 Error 实例,需兼容读取以便透出真实原因
|
||||
const raw = e instanceof Error ? e.message : (e as { errMsg?: string })?.errMsg
|
||||
console.error(`[http] ${method} ${url} 网络失败:`, raw)
|
||||
throw new ApiError(raw || '网络异常')
|
||||
}
|
||||
|
||||
console.log(`[http] ${method} ${url} ←`, res.statusCode, res.data)
|
||||
|
||||
if (res.statusCode !== 200) {
|
||||
if (res.statusCode === 401) {
|
||||
redirectToLogin(tokenAtStart)
|
||||
|
|
@ -108,8 +112,12 @@ export async function request<T>(
|
|||
if (bizCode !== '200') {
|
||||
throw new BusinessError(body.Message || '业务错误', bizCode)
|
||||
}
|
||||
// needUserInfo 是 200 成功内的子状态(Message 携带),附到返回值供调用方分支处理
|
||||
if (body.Message === 'needUserInfo') body.Content.needUserInfo = true
|
||||
// 200 成功内的子状态(Message 携带,如 needUserInfo/noMobile),附到返回值供调用方分支处理
|
||||
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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -63,6 +63,27 @@
|
|||
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 {
|
||||
color: #ffffff;
|
||||
font-size: 32px;
|
||||
|
|
|
|||
|
|
@ -1,9 +1,15 @@
|
|||
import { useState } from 'react'
|
||||
import { View, Text } from '@tarojs/components'
|
||||
import { View, Text, Button } from '@tarojs/components'
|
||||
import { useDidShow } from '@tarojs/taro'
|
||||
import { useAppDispatch, useAppSelector } from '@/store/hooks'
|
||||
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 { ROUTES } from '@/constants/routes'
|
||||
import { reLaunch } from '@/utils/navigation'
|
||||
|
|
@ -14,52 +20,84 @@ export default function Login() {
|
|||
const toast = useToast()
|
||||
const token = useAppSelector((s) => s.auth.token)
|
||||
const [loading, setLoading] = useState(false)
|
||||
// 首次登录 needUserInfo 中间态:持有 openId 等待用户授权补全
|
||||
const [pendingAuth, setPendingAuth] = useState<string | null>(null)
|
||||
// 注册中间态:needUserInfo(待头像昵称授权)→ noMobile(待手机号授权)→ null(初始/完成)
|
||||
const [subState, setSubState] = useState<LoginSubState | null>(null)
|
||||
const [openId, setOpenId] = useState('')
|
||||
|
||||
// 已有 token 直接进首页(本期不做 checkLogin 校验,spec D5)
|
||||
useDidShow(() => {
|
||||
if (token && !loading) reLaunch(ROUTES.HOME)
|
||||
})
|
||||
|
||||
const handleWxLogin = async () => {
|
||||
if (loading) return
|
||||
setLoading(true)
|
||||
try {
|
||||
const result = await performWxLogin()
|
||||
if (isNeedUserInfo(result)) {
|
||||
// 首次登录:后端要求补授权,弹授权按钮等用户点击
|
||||
setPendingAuth(result.openId ?? '')
|
||||
// 完整登录态校验与落地;未完成时推进 subState
|
||||
const applyResult = async (result: Awaited<ReturnType<typeof performWxLogin>>) => {
|
||||
const state = getLoginSubState(result)
|
||||
if (state !== 'complete') {
|
||||
console.log('[login-page] 注册未完成,推进到', state)
|
||||
if (result.openId) setOpenId(result.openId)
|
||||
setSubState(state)
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
if (!result.code || !result.userInfo) throw new Error('登录响应缺少凭证')
|
||||
console.log('[login-page] 登录完成,token 已落地')
|
||||
setSubState(null)
|
||||
dispatch(setAuth({ token: result.code, userInfo: result.userInfo }))
|
||||
await reLaunch(ROUTES.HOME)
|
||||
}
|
||||
|
||||
const handleWxLogin = async () => {
|
||||
if (loading) return
|
||||
setLoading(true)
|
||||
try {
|
||||
applyResult(await performWxLogin())
|
||||
} catch (e) {
|
||||
// code 失效/已用等场景:toast 后端 Message,用户重按按钮重新 Taro.login(code 一次性,重试安全)
|
||||
console.error('[login-page] performWxLogin 失败', e)
|
||||
toast.error(e instanceof Error && e.message ? e.message : '登录失败')
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// needUserInfo:getUserProfile 授权头像昵称
|
||||
const handleAuthUserInfo = async () => {
|
||||
if (loading || !pendingAuth) return
|
||||
if (loading || !openId) return
|
||||
setLoading(true)
|
||||
try {
|
||||
const result = await completeUserInfoLogin(pendingAuth)
|
||||
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)
|
||||
applyResult(await completeUserInfoLogin(openId))
|
||||
} catch (e) {
|
||||
// 授权失败/取消:保留待授权态,用户可重试(getUserProfile 每次都需用户确认)
|
||||
console.error('[login-page] completeUserInfoLogin 失败', e)
|
||||
toast.error(e instanceof Error && e.message ? e.message : '授权失败')
|
||||
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 (
|
||||
<View className={styles.page}>
|
||||
<View className={styles.hero}>
|
||||
|
|
@ -70,13 +108,21 @@ export default function Login() {
|
|||
<Text className={styles.slogan}>一起守护家人</Text>
|
||||
</View>
|
||||
<View className={styles.form}>
|
||||
{pendingAuth === null ? (
|
||||
<View className={styles.submit} onClick={handleWxLogin}>
|
||||
<Text className={styles.submitText}>{loading ? '登录中...' : '微信一键登录'}</Text>
|
||||
</View>
|
||||
{subState === 'noMobile' ? (
|
||||
// 手机号授权必须用原生 Button open-type,微信弹官方授权框
|
||||
<Button
|
||||
className={styles.submitBtn}
|
||||
openType="getPhoneNumber"
|
||||
onGetPhoneNumber={handleGetPhoneNumber}
|
||||
>
|
||||
{submitText()}
|
||||
</Button>
|
||||
) : (
|
||||
<View className={styles.submit} onClick={handleAuthUserInfo}>
|
||||
<Text className={styles.submitText}>{loading ? '授权中...' : '授权头像昵称,完成登录'}</Text>
|
||||
<View
|
||||
className={styles.submit}
|
||||
onClick={subState === 'needUserInfo' ? handleAuthUserInfo : handleWxLogin}
|
||||
>
|
||||
<Text className={styles.submitText}>{submitText()}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
|
|
|||
|
|
@ -12,14 +12,18 @@ function resolveAppId(): string {
|
|||
}
|
||||
}
|
||||
|
||||
// 判断响应是否为"首次登录需补授权"形态(Message=needUserInfo 时 http 层打的标记)
|
||||
export function isNeedUserInfo(res: WxMiniappLoginResponse): boolean {
|
||||
return res.needUserInfo === true
|
||||
// 登录步骤子状态(http 层把 200 内 Message 附加到返回值 message 字段)
|
||||
export type LoginSubState = 'needUserInfo' | 'noMobile' | 'complete'
|
||||
|
||||
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
|
||||
// 首次登录后端返回 needUserInfo(仅 openId/bindKey,无 token),需二次授权后重调接口补全
|
||||
// 首次登录后端可能分步返回 needUserInfo / noMobile(仅绑定凭证,无 token),逐步授权后重调补全
|
||||
export async function performWxLogin(): Promise<WxMiniappLoginResponse> {
|
||||
// Taro.login reject 时返回 { errMsg } 形状对象而非 Error,此处归一化为带 message 的 Error
|
||||
let code: string
|
||||
|
|
@ -31,12 +35,34 @@ export async function performWxLogin(): Promise<WxMiniappLoginResponse> {
|
|||
}
|
||||
if (!code) throw new Error('获取微信登录凭证失败')
|
||||
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 })
|
||||
if (result.openId) storage.set(WX_OPENID_STORAGE_KEY, result.openId)
|
||||
console.log('[login] performWxLogin 结果', getLoginSubState(result), 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> {
|
||||
let profile: { iv: string; encryptedData: string }
|
||||
try {
|
||||
|
|
@ -45,22 +71,16 @@ export async function completeUserInfoLogin(openId: string): Promise<WxMiniappLo
|
|||
} catch (e) {
|
||||
throw new Error(e instanceof Error ? e.message : (e as { errMsg?: string })?.errMsg || '授权失败')
|
||||
}
|
||||
|
||||
let code: string
|
||||
try {
|
||||
const r = await Taro.login()
|
||||
code = r.code
|
||||
} catch {
|
||||
throw new Error('获取微信登录凭证失败,请重试')
|
||||
return reloginWith(openId, profile)
|
||||
}
|
||||
|
||||
const result = await wxMiniappLogin({
|
||||
appId: resolveAppId(),
|
||||
code,
|
||||
openId,
|
||||
iv: profile.iv,
|
||||
encryptedData: profile.encryptedData
|
||||
})
|
||||
if (result.openId) storage.set(WX_OPENID_STORAGE_KEY, result.openId)
|
||||
return result
|
||||
// noMobile 三次授权:手机号授权按钮回调(open-type=getPhoneNumber)拿 iv/encryptedData → 重调登录接口
|
||||
export async function completeMobileLogin(
|
||||
openId: string,
|
||||
detail: { iv: string; encryptedData: string; errMsg?: string }
|
||||
): Promise<WxMiniappLoginResponse> {
|
||||
if (!detail.iv || !detail.encryptedData) {
|
||||
throw new Error(detail.errMsg || '手机号授权失败')
|
||||
}
|
||||
return reloginWith(openId, { iv: detail.iv, encryptedData: detail.encryptedData })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -198,12 +198,21 @@ describe('登录态失效踢回登录页', () => {
|
|||
await expect(request.get('/demo')).rejects.toBeInstanceOf(ApiError)
|
||||
})
|
||||
|
||||
it("Message='needUserInfo' 的 200 响应在 Content 上打标记", async () => {
|
||||
it("200 响应的 Message 子状态(needUserInfo 等)附加到返回值 message 字段", async () => {
|
||||
mockRequest.mockResolvedValue({
|
||||
statusCode: 200,
|
||||
data: { Code: '200', Message: 'needUserInfo', Content: { openId: 'o-1', bindKey: 'b-1' } }
|
||||
})
|
||||
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' })
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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> = {}) => ({
|
||||
userInfo: {
|
||||
|
|
@ -34,11 +34,17 @@ const result = (over: Record<string, unknown> = {}) => ({
|
|||
...over
|
||||
})
|
||||
|
||||
// 首次登录 needUserInfo 响应形态(http 层在 Content 上打的标记)
|
||||
// 首次登录 needUserInfo 响应形态(http 层把 Message 附加到 message 字段)
|
||||
const needInfoResult = () => ({
|
||||
openId: 'o-need',
|
||||
bindKey: 'bind-1',
|
||||
needUserInfo: true as const
|
||||
message: 'needUserInfo' as const
|
||||
})
|
||||
|
||||
// noMobile 响应形态(授权头像后仍未绑定手机号)
|
||||
const noMobileResult = () => ({
|
||||
bindKey: 'bind-2',
|
||||
message: 'noMobile' as const
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
|
|
@ -115,13 +121,17 @@ describe('needUserInfo 首次登录分支', () => {
|
|||
it('performWxLogin 返回 needUserInfo 形态时缓存 openId 并原样返回', async () => {
|
||||
mockWxMiniappLogin.mockResolvedValue(needInfoResult())
|
||||
const res = await performWxLogin()
|
||||
expect(isNeedUserInfo(res)).toBe(true)
|
||||
expect(getLoginSubState(res)).toBe('needUserInfo')
|
||||
expect(res.openId).toBe('o-need')
|
||||
expect(mem.get('wx_openid')).toBe('o-need')
|
||||
})
|
||||
|
||||
it('isNeedUserInfo 对完整登录响应返回 false', () => {
|
||||
expect(isNeedUserInfo(result())).toBe(false)
|
||||
it('getLoginSubState 对完整登录响应返回 complete', () => {
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in New Issue