fix(login): 适配首次登录 needUserInfo 二次授权流程,修复响应结构不符崩溃
This commit is contained in:
parent
bd71d52c7f
commit
46c517ff42
|
|
@ -5,10 +5,17 @@ export interface WxMiniappLoginParams {
|
|||
appId: string // 小程序 appId
|
||||
code: string // Taro.login() 一次性 code,5 分钟有效
|
||||
openId?: string // 首次登录响应返回,后续登录/解密回传
|
||||
iv?: string
|
||||
encryptedData?: string
|
||||
iv?: string // 加密资料初始向量(getUserProfile 授权后补传)
|
||||
encryptedData?: string // 加密用户资料(getUserProfile 授权后补传)
|
||||
}
|
||||
|
||||
// 首次登录(needUserInfo)响应:仅含绑定凭证,无 token/用户信息
|
||||
export interface WxNeedUserInfoResult {
|
||||
openId: string
|
||||
bindKey: string
|
||||
}
|
||||
|
||||
// 用户信息(补授权后完整登录返回;联调前字段按文档版,实际字段名待确认)
|
||||
export interface WxUserInfo {
|
||||
userId: string
|
||||
userType: number
|
||||
|
|
@ -20,13 +27,18 @@ export interface WxUserInfo {
|
|||
loginFrom: string
|
||||
}
|
||||
|
||||
// 完整登录响应(needUserInfo=false 时)
|
||||
export interface WxLoginResult {
|
||||
userInfo: WxUserInfo
|
||||
code: string // 登录凭证(token)
|
||||
openId?: string // 首次登录返回,需本地缓存
|
||||
}
|
||||
|
||||
export function wxMiniappLogin(params: WxMiniappLoginParams): Promise<WxLoginResult> {
|
||||
// 登录响应联合体:Message === 'needUserInfo' 时为绑定凭证形态(needUserInfo 由 http 层标记)
|
||||
export type WxMiniappLoginResponse = WxLoginResult &
|
||||
Partial<WxNeedUserInfoResult> & { needUserInfo?: boolean }
|
||||
|
||||
export function wxMiniappLogin(params: WxMiniappLoginParams): Promise<WxMiniappLoginResponse> {
|
||||
return request.post<WxLoginResult>(
|
||||
'/thirdPlatform/wxDevLogin/miniapp/login',
|
||||
params as unknown as Record<string, unknown>,
|
||||
|
|
|
|||
|
|
@ -7,8 +7,8 @@ import { ApiError, BusinessError, AuthExpiredError } from './errors'
|
|||
|
||||
// 测试环境(内网,开发者工具需勾选「不校验合法域名」);正式域名为占位符,上线前必须替换(spec D2)
|
||||
const BASE_URL = process.env.NODE_ENV === 'development'
|
||||
? 'http://192.168.10.11:39101/api/biz'
|
||||
: 'https://PROD_PLACEHOLDER/api/biz'
|
||||
? 'http://192.168.10.11:39101/api/user'
|
||||
: 'https://PROD_PLACEHOLDER/api/user'
|
||||
|
||||
// 后端约定的未登录业务码(占位 '401',联调确认后更新——spec D5/Open Questions 1)
|
||||
const UNAUTHORIZED_CODES = new Set(['401'])
|
||||
|
|
@ -44,7 +44,7 @@ export interface RequestConfig {
|
|||
export interface ApiEnvelope<T> {
|
||||
Code: string
|
||||
Message?: string
|
||||
Content: T
|
||||
Content: T & { needUserInfo?: boolean }
|
||||
RecordCount?: number
|
||||
PageIndex?: number
|
||||
PageSize?: number
|
||||
|
|
@ -108,6 +108,8 @@ 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
|
||||
return body.Content
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { View, Text } from '@tarojs/components'
|
|||
import { useDidShow } from '@tarojs/taro'
|
||||
import { useAppDispatch, useAppSelector } from '@/store/hooks'
|
||||
import { setAuth } from '@/store/authSlice'
|
||||
import { performWxLogin } from './wxLoginFlow'
|
||||
import { performWxLogin, completeUserInfoLogin, isNeedUserInfo } from './wxLoginFlow'
|
||||
import { useToast } from '@/hooks/useToast'
|
||||
import { ROUTES } from '@/constants/routes'
|
||||
import { reLaunch } from '@/utils/navigation'
|
||||
|
|
@ -14,6 +14,8 @@ 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)
|
||||
|
||||
// 已有 token 直接进首页(本期不做 checkLogin 校验,spec D5)
|
||||
useDidShow(() => {
|
||||
|
|
@ -24,8 +26,15 @@ export default function Login() {
|
|||
if (loading) return
|
||||
setLoading(true)
|
||||
try {
|
||||
const { code, userInfo } = await performWxLogin()
|
||||
dispatch(setAuth({ token: code, userInfo }))
|
||||
const result = await performWxLogin()
|
||||
if (isNeedUserInfo(result)) {
|
||||
// 首次登录:后端要求补授权,弹授权按钮等用户点击
|
||||
setPendingAuth(result.openId ?? '')
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
if (!result.code || !result.userInfo) throw new Error('登录响应缺少凭证')
|
||||
dispatch(setAuth({ token: result.code, userInfo: result.userInfo }))
|
||||
await reLaunch(ROUTES.HOME)
|
||||
} catch (e) {
|
||||
// code 失效/已用等场景:toast 后端 Message,用户重按按钮重新 Taro.login(code 一次性,重试安全)
|
||||
|
|
@ -34,6 +43,23 @@ export default function Login() {
|
|||
}
|
||||
}
|
||||
|
||||
const handleAuthUserInfo = async () => {
|
||||
if (loading || !pendingAuth) 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)
|
||||
} catch (e) {
|
||||
// 授权失败/取消:保留待授权态,用户可重试(getUserProfile 每次都需用户确认)
|
||||
toast.error(e instanceof Error && e.message ? e.message : '授权失败')
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<View className={styles.page}>
|
||||
<View className={styles.hero}>
|
||||
|
|
@ -44,9 +70,15 @@ export default function Login() {
|
|||
<Text className={styles.slogan}>一起守护家人</Text>
|
||||
</View>
|
||||
<View className={styles.form}>
|
||||
<View className={styles.submit} onClick={handleWxLogin}>
|
||||
<Text className={styles.submitText}>{loading ? '登录中...' : '微信一键登录'}</Text>
|
||||
</View>
|
||||
{pendingAuth === null ? (
|
||||
<View className={styles.submit} onClick={handleWxLogin}>
|
||||
<Text className={styles.submitText}>{loading ? '登录中...' : '微信一键登录'}</Text>
|
||||
</View>
|
||||
) : (
|
||||
<View className={styles.submit} onClick={handleAuthUserInfo}>
|
||||
<Text className={styles.submitText}>{loading ? '授权中...' : '授权头像昵称,完成登录'}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import Taro from '@tarojs/taro'
|
||||
import { wxMiniappLogin, type WxLoginResult } from '@/api/auth'
|
||||
import { wxMiniappLogin, type WxMiniappLoginResponse } from '@/api/auth'
|
||||
import { storage } from '@/utils/storage'
|
||||
import { WX_OPENID_STORAGE_KEY, FALLBACK_WX_APP_ID } from '@/constants/auth'
|
||||
|
||||
|
|
@ -12,9 +12,15 @@ function resolveAppId(): string {
|
|||
}
|
||||
}
|
||||
|
||||
// 微信一键登录主流程(spec D3):
|
||||
// 判断响应是否为"首次登录需补授权"形态(Message=needUserInfo 时 http 层打的标记)
|
||||
export function isNeedUserInfo(res: WxMiniappLoginResponse): boolean {
|
||||
return res.needUserInfo === true
|
||||
}
|
||||
|
||||
// 微信一键登录主流程:
|
||||
// Taro.login 换一次性 code → 组装 appId/openId → 调 miniapp/login → 缓存响应中的 openId
|
||||
export async function performWxLogin(): Promise<WxLoginResult> {
|
||||
// 首次登录后端返回 needUserInfo(仅 openId/bindKey,无 token),需二次授权后重调接口补全
|
||||
export async function performWxLogin(): Promise<WxMiniappLoginResponse> {
|
||||
// Taro.login reject 时返回 { errMsg } 形状对象而非 Error,此处归一化为带 message 的 Error
|
||||
let code: string
|
||||
try {
|
||||
|
|
@ -29,3 +35,32 @@ export async function performWxLogin(): Promise<WxLoginResult> {
|
|||
if (result.openId) storage.set(WX_OPENID_STORAGE_KEY, result.openId)
|
||||
return result
|
||||
}
|
||||
|
||||
// needUserInfo 二次授权:getUserProfile 拿 iv/encryptedData → 换新 code 重调登录接口补全用户资料
|
||||
export async function completeUserInfoLogin(openId: string): Promise<WxMiniappLoginResponse> {
|
||||
let profile: { iv: string; encryptedData: string }
|
||||
try {
|
||||
const r = await Taro.getUserProfile({ desc: '用于完善会员资料' })
|
||||
profile = { iv: r.iv, encryptedData: r.encryptedData }
|
||||
} 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('获取微信登录凭证失败,请重试')
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -118,7 +118,8 @@ describe('client envelope 解析', () => {
|
|||
it('BASE_URL 拼接:请求 url = BASE_URL + path', async () => {
|
||||
mockRequest.mockResolvedValue(okBody(null))
|
||||
await request.get('/thirdPlatform/wxDevLogin/miniapp/login')
|
||||
expect(mockRequest.mock.calls[0][0].url).toMatch(/\/api\/biz\/thirdPlatform\/wxDevLogin\/miniapp\/login$/)
|
||||
// BASE_URL 前缀由后端网关决定(当前 /api/user),此处只锁定 path 拼接
|
||||
expect(mockRequest.mock.calls[0][0].url).toMatch(/\/api\/user\/thirdPlatform\/wxDevLogin\/miniapp\/login$/)
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -196,4 +197,13 @@ describe('登录态失效踢回登录页', () => {
|
|||
mockRequest.mockResolvedValue({ statusCode: 200, data: null })
|
||||
await expect(request.get('/demo')).rejects.toBeInstanceOf(ApiError)
|
||||
})
|
||||
|
||||
it("Message='needUserInfo' 的 200 响应在 Content 上打标记", 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 })
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -2,8 +2,13 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'
|
|||
|
||||
const mockLogin = vi.hoisted(() => vi.fn())
|
||||
const mockGetAccountInfo = vi.hoisted(() => vi.fn())
|
||||
const mockGetUserProfile = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@tarojs/taro', () => ({
|
||||
default: { login: mockLogin, getAccountInfoSync: mockGetAccountInfo }
|
||||
default: {
|
||||
login: mockLogin,
|
||||
getAccountInfoSync: mockGetAccountInfo,
|
||||
getUserProfile: mockGetUserProfile
|
||||
}
|
||||
}))
|
||||
|
||||
const mockWxMiniappLogin = vi.hoisted(() => vi.fn())
|
||||
|
|
@ -18,7 +23,7 @@ vi.mock('@/utils/storage', () => ({
|
|||
}
|
||||
}))
|
||||
|
||||
import { performWxLogin } from '@/pages/login/wxLoginFlow'
|
||||
import { performWxLogin, completeUserInfoLogin, isNeedUserInfo } from '@/pages/login/wxLoginFlow'
|
||||
|
||||
const result = (over: Record<string, unknown> = {}) => ({
|
||||
userInfo: {
|
||||
|
|
@ -29,10 +34,18 @@ const result = (over: Record<string, unknown> = {}) => ({
|
|||
...over
|
||||
})
|
||||
|
||||
// 首次登录 needUserInfo 响应形态(http 层在 Content 上打的标记)
|
||||
const needInfoResult = () => ({
|
||||
openId: 'o-need',
|
||||
bindKey: 'bind-1',
|
||||
needUserInfo: true as const
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
mem.clear()
|
||||
mockLogin.mockReset()
|
||||
mockGetAccountInfo.mockReset()
|
||||
mockGetUserProfile.mockReset()
|
||||
mockWxMiniappLogin.mockReset()
|
||||
mockLogin.mockResolvedValue({ code: 'wx-code-1' })
|
||||
mockGetAccountInfo.mockReturnValue({ miniProgram: { appId: 'wx-appid-live' } })
|
||||
|
|
@ -97,3 +110,46 @@ describe('performWxLogin', () => {
|
|||
await expect(performWxLogin()).rejects.toThrow('code 已被使用')
|
||||
})
|
||||
})
|
||||
|
||||
describe('needUserInfo 首次登录分支', () => {
|
||||
it('performWxLogin 返回 needUserInfo 形态时缓存 openId 并原样返回', async () => {
|
||||
mockWxMiniappLogin.mockResolvedValue(needInfoResult())
|
||||
const res = await performWxLogin()
|
||||
expect(isNeedUserInfo(res)).toBe(true)
|
||||
expect(res.openId).toBe('o-need')
|
||||
expect(mem.get('wx_openid')).toBe('o-need')
|
||||
})
|
||||
|
||||
it('isNeedUserInfo 对完整登录响应返回 false', () => {
|
||||
expect(isNeedUserInfo(result())).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('completeUserInfoLogin 二次授权', () => {
|
||||
it('getUserProfile → 换新 code → 带 iv/encryptedData/openId 重调登录', async () => {
|
||||
mockGetUserProfile.mockResolvedValue({ iv: 'iv-1', encryptedData: 'enc-1' })
|
||||
mockWxMiniappLogin.mockResolvedValue(result({ openId: 'o-need' }))
|
||||
const res = await completeUserInfoLogin('o-need')
|
||||
expect(res.code).toBe('tk-abc')
|
||||
expect(mockWxMiniappLogin).toHaveBeenCalledWith({
|
||||
appId: 'wx-appid-live',
|
||||
code: 'wx-code-1',
|
||||
openId: 'o-need',
|
||||
iv: 'iv-1',
|
||||
encryptedData: 'enc-1'
|
||||
})
|
||||
})
|
||||
|
||||
it('用户拒绝授权(errMsg 形状 reject)抛带 message 的 Error', async () => {
|
||||
mockGetUserProfile.mockRejectedValue({ errMsg: 'getUserProfile:fail auth deny' })
|
||||
await expect(completeUserInfoLogin('o-need')).rejects.toThrow('getUserProfile:fail auth deny')
|
||||
expect(mockWxMiniappLogin).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('授权成功但换 code 失败:抛错且不发起登录请求', async () => {
|
||||
mockGetUserProfile.mockResolvedValue({ iv: 'iv-1', encryptedData: 'enc-1' })
|
||||
mockLogin.mockRejectedValue({ errMsg: 'login:fail' })
|
||||
await expect(completeUserInfoLogin('o-need')).rejects.toThrow('获取微信登录凭证失败,请重试')
|
||||
expect(mockWxMiniappLogin).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in New Issue