From 46c517ff42fb7bbea560c712b625c641cab624a3 Mon Sep 17 00:00:00 2001 From: ozh Date: Wed, 16 Sep 2026 10:41:59 +0800 Subject: [PATCH] =?UTF-8?q?fix(login):=20=E9=80=82=E9=85=8D=E9=A6=96?= =?UTF-8?q?=E6=AC=A1=E7=99=BB=E5=BD=95=20needUserInfo=20=E4=BA=8C=E6=AC=A1?= =?UTF-8?q?=E6=8E=88=E6=9D=83=E6=B5=81=E7=A8=8B=EF=BC=8C=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E5=93=8D=E5=BA=94=E7=BB=93=E6=9E=84=E4=B8=8D=E7=AC=A6=E5=B4=A9?= =?UTF-8?q?=E6=BA=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/auth.ts | 18 +++++++-- src/http/client.ts | 8 ++-- src/pages/login/index.tsx | 44 +++++++++++++++++--- src/pages/login/wxLoginFlow.ts | 41 +++++++++++++++++-- tests/unit/http/client.spec.ts | 12 +++++- tests/unit/login/wxLoginFlow.spec.ts | 60 +++++++++++++++++++++++++++- 6 files changed, 165 insertions(+), 18 deletions(-) diff --git a/src/api/auth.ts b/src/api/auth.ts index 7f302de..023e58f 100644 --- a/src/api/auth.ts +++ b/src/api/auth.ts @@ -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 { +// 登录响应联合体:Message === 'needUserInfo' 时为绑定凭证形态(needUserInfo 由 http 层标记) +export type WxMiniappLoginResponse = WxLoginResult & + Partial & { needUserInfo?: boolean } + +export function wxMiniappLogin(params: WxMiniappLoginParams): Promise { return request.post( '/thirdPlatform/wxDevLogin/miniapp/login', params as unknown as Record, diff --git a/src/http/client.ts b/src/http/client.ts index 4cdda1f..fdab0b5 100644 --- a/src/http/client.ts +++ b/src/http/client.ts @@ -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 { Code: string Message?: string - Content: T + Content: T & { needUserInfo?: boolean } RecordCount?: number PageIndex?: number PageSize?: number @@ -108,6 +108,8 @@ export async function request( if (bizCode !== '200') { throw new BusinessError(body.Message || '业务错误', bizCode) } + // needUserInfo 是 200 成功内的子状态(Message 携带),附到返回值供调用方分支处理 + if (body.Message === 'needUserInfo') body.Content.needUserInfo = true return body.Content } diff --git a/src/pages/login/index.tsx b/src/pages/login/index.tsx index 421407e..3d390ac 100644 --- a/src/pages/login/index.tsx +++ b/src/pages/login/index.tsx @@ -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(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 ( @@ -44,9 +70,15 @@ export default function Login() { 一起守护家人 - - {loading ? '登录中...' : '微信一键登录'} - + {pendingAuth === null ? ( + + {loading ? '登录中...' : '微信一键登录'} + + ) : ( + + {loading ? '授权中...' : '授权头像昵称,完成登录'} + + )} ) diff --git a/src/pages/login/wxLoginFlow.ts b/src/pages/login/wxLoginFlow.ts index 8f9f413..674426c 100644 --- a/src/pages/login/wxLoginFlow.ts +++ b/src/pages/login/wxLoginFlow.ts @@ -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 { +// 首次登录后端返回 needUserInfo(仅 openId/bindKey,无 token),需二次授权后重调接口补全 +export async function performWxLogin(): Promise { // Taro.login reject 时返回 { errMsg } 形状对象而非 Error,此处归一化为带 message 的 Error let code: string try { @@ -29,3 +35,32 @@ export async function performWxLogin(): Promise { 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 { + 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 +} diff --git a/tests/unit/http/client.spec.ts b/tests/unit/http/client.spec.ts index 059a3e7..83d3de8 100644 --- a/tests/unit/http/client.spec.ts +++ b/tests/unit/http/client.spec.ts @@ -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 }) + }) }) diff --git a/tests/unit/login/wxLoginFlow.spec.ts b/tests/unit/login/wxLoginFlow.spec.ts index 2455685..42aa3b1 100644 --- a/tests/unit/login/wxLoginFlow.spec.ts +++ b/tests/unit/login/wxLoginFlow.spec.ts @@ -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 = {}) => ({ userInfo: { @@ -29,10 +34,18 @@ const result = (over: Record = {}) => ({ ...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() + }) +})