fix(login): 适配首次登录 needUserInfo 二次授权流程,修复响应结构不符崩溃

This commit is contained in:
ozh 2026-09-16 10:41:59 +08:00
parent bd71d52c7f
commit 46c517ff42
6 changed files with 165 additions and 18 deletions

View File

@ -5,10 +5,17 @@ export interface WxMiniappLoginParams {
appId: string // 小程序 appId appId: string // 小程序 appId
code: string // Taro.login() 一次性 code,5 分钟有效 code: string // Taro.login() 一次性 code,5 分钟有效
openId?: string // 首次登录响应返回,后续登录/解密回传 openId?: string // 首次登录响应返回,后续登录/解密回传
iv?: string iv?: string // 加密资料初始向量(getUserProfile 授权后补传)
encryptedData?: string encryptedData?: string // 加密用户资料(getUserProfile 授权后补传)
} }
// 首次登录(needUserInfo)响应:仅含绑定凭证,无 token/用户信息
export interface WxNeedUserInfoResult {
openId: string
bindKey: string
}
// 用户信息(补授权后完整登录返回;联调前字段按文档版,实际字段名待确认)
export interface WxUserInfo { export interface WxUserInfo {
userId: string userId: string
userType: number userType: number
@ -20,13 +27,18 @@ export interface WxUserInfo {
loginFrom: string loginFrom: string
} }
// 完整登录响应(needUserInfo=false 时)
export interface WxLoginResult { export interface WxLoginResult {
userInfo: WxUserInfo userInfo: WxUserInfo
code: string // 登录凭证(token) code: string // 登录凭证(token)
openId?: string // 首次登录返回,需本地缓存 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>( return request.post<WxLoginResult>(
'/thirdPlatform/wxDevLogin/miniapp/login', '/thirdPlatform/wxDevLogin/miniapp/login',
params as unknown as Record<string, unknown>, params as unknown as Record<string, unknown>,

View File

@ -7,8 +7,8 @@ import { ApiError, BusinessError, AuthExpiredError } from './errors'
// 测试环境(内网,开发者工具需勾选「不校验合法域名」);正式域名为占位符,上线前必须替换(spec D2) // 测试环境(内网,开发者工具需勾选「不校验合法域名」);正式域名为占位符,上线前必须替换(spec D2)
const BASE_URL = process.env.NODE_ENV === 'development' const BASE_URL = process.env.NODE_ENV === 'development'
? 'http://192.168.10.11:39101/api/biz' ? 'http://192.168.10.11:39101/api/user'
: 'https://PROD_PLACEHOLDER/api/biz' : 'https://PROD_PLACEHOLDER/api/user'
// 后端约定的未登录业务码(占位 '401',联调确认后更新——spec D5/Open Questions 1) // 后端约定的未登录业务码(占位 '401',联调确认后更新——spec D5/Open Questions 1)
const UNAUTHORIZED_CODES = new Set(['401']) const UNAUTHORIZED_CODES = new Set(['401'])
@ -44,7 +44,7 @@ export interface RequestConfig {
export interface ApiEnvelope<T> { export interface ApiEnvelope<T> {
Code: string Code: string
Message?: string Message?: string
Content: T Content: T & { needUserInfo?: boolean }
RecordCount?: number RecordCount?: number
PageIndex?: number PageIndex?: number
PageSize?: number PageSize?: number
@ -108,6 +108,8 @@ 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 携带),附到返回值供调用方分支处理
if (body.Message === 'needUserInfo') body.Content.needUserInfo = true
return body.Content return body.Content
} }

View File

@ -3,7 +3,7 @@ import { View, Text } 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 } from './wxLoginFlow' import { performWxLogin, completeUserInfoLogin, isNeedUserInfo } 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,6 +14,8 @@ 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 等待用户授权补全
const [pendingAuth, setPendingAuth] = useState<string | null>(null)
// 已有 token 直接进首页(本期不做 checkLogin 校验,spec D5) // 已有 token 直接进首页(本期不做 checkLogin 校验,spec D5)
useDidShow(() => { useDidShow(() => {
@ -24,8 +26,15 @@ export default function Login() {
if (loading) return if (loading) return
setLoading(true) setLoading(true)
try { try {
const { code, userInfo } = await performWxLogin() const result = await performWxLogin()
dispatch(setAuth({ token: code, userInfo })) 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) await reLaunch(ROUTES.HOME)
} catch (e) { } catch (e) {
// code 失效/已用等场景:toast 后端 Message,用户重按按钮重新 Taro.login(code 一次性,重试安全) // 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 ( return (
<View className={styles.page}> <View className={styles.page}>
<View className={styles.hero}> <View className={styles.hero}>
@ -44,9 +70,15 @@ export default function Login() {
<Text className={styles.slogan}>一起守护家人</Text> <Text className={styles.slogan}>一起守护家人</Text>
</View> </View>
<View className={styles.form}> <View className={styles.form}>
<View className={styles.submit} onClick={handleWxLogin}> {pendingAuth === null ? (
<Text className={styles.submitText}>{loading ? '登录中...' : '微信一键登录'}</Text> <View className={styles.submit} onClick={handleWxLogin}>
</View> <Text className={styles.submitText}>{loading ? '登录中...' : '微信一键登录'}</Text>
</View>
) : (
<View className={styles.submit} onClick={handleAuthUserInfo}>
<Text className={styles.submitText}>{loading ? '授权中...' : '授权头像昵称,完成登录'}</Text>
</View>
)}
</View> </View>
</View> </View>
) )

View File

@ -1,5 +1,5 @@
import Taro from '@tarojs/taro' 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 { storage } from '@/utils/storage'
import { WX_OPENID_STORAGE_KEY, FALLBACK_WX_APP_ID } from '@/constants/auth' 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 // 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 // Taro.login reject 时返回 { errMsg } 形状对象而非 Error,此处归一化为带 message 的 Error
let code: string let code: string
try { try {
@ -29,3 +35,32 @@ export async function performWxLogin(): Promise<WxLoginResult> {
if (result.openId) storage.set(WX_OPENID_STORAGE_KEY, result.openId) if (result.openId) storage.set(WX_OPENID_STORAGE_KEY, result.openId)
return result 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
}

View File

@ -118,7 +118,8 @@ describe('client envelope 解析', () => {
it('BASE_URL 拼接:请求 url = BASE_URL + path', async () => { it('BASE_URL 拼接:请求 url = BASE_URL + path', async () => {
mockRequest.mockResolvedValue(okBody(null)) mockRequest.mockResolvedValue(okBody(null))
await request.get('/thirdPlatform/wxDevLogin/miniapp/login') 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 }) mockRequest.mockResolvedValue({ statusCode: 200, data: null })
await expect(request.get('/demo')).rejects.toBeInstanceOf(ApiError) 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 })
})
}) })

View File

@ -2,8 +2,13 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'
const mockLogin = vi.hoisted(() => vi.fn()) const mockLogin = vi.hoisted(() => vi.fn())
const mockGetAccountInfo = vi.hoisted(() => vi.fn()) const mockGetAccountInfo = vi.hoisted(() => vi.fn())
const mockGetUserProfile = vi.hoisted(() => vi.fn())
vi.mock('@tarojs/taro', () => ({ vi.mock('@tarojs/taro', () => ({
default: { login: mockLogin, getAccountInfoSync: mockGetAccountInfo } default: {
login: mockLogin,
getAccountInfoSync: mockGetAccountInfo,
getUserProfile: mockGetUserProfile
}
})) }))
const mockWxMiniappLogin = vi.hoisted(() => vi.fn()) 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> = {}) => ({ const result = (over: Record<string, unknown> = {}) => ({
userInfo: { userInfo: {
@ -29,10 +34,18 @@ const result = (over: Record<string, unknown> = {}) => ({
...over ...over
}) })
// 首次登录 needUserInfo 响应形态(http 层在 Content 上打的标记)
const needInfoResult = () => ({
openId: 'o-need',
bindKey: 'bind-1',
needUserInfo: true as const
})
beforeEach(() => { beforeEach(() => {
mem.clear() mem.clear()
mockLogin.mockReset() mockLogin.mockReset()
mockGetAccountInfo.mockReset() mockGetAccountInfo.mockReset()
mockGetUserProfile.mockReset()
mockWxMiniappLogin.mockReset() mockWxMiniappLogin.mockReset()
mockLogin.mockResolvedValue({ code: 'wx-code-1' }) mockLogin.mockResolvedValue({ code: 'wx-code-1' })
mockGetAccountInfo.mockReturnValue({ miniProgram: { appId: 'wx-appid-live' } }) mockGetAccountInfo.mockReturnValue({ miniProgram: { appId: 'wx-appid-live' } })
@ -97,3 +110,46 @@ describe('performWxLogin', () => {
await expect(performWxLogin()).rejects.toThrow('code 已被使用') 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()
})
})