diff --git a/src/api/auth.ts b/src/api/auth.ts index 023e58f..284ab65 100644 --- a/src/api/auth.ts +++ b/src/api/auth.ts @@ -34,9 +34,10 @@ export interface WxLoginResult { openId?: string // 首次登录返回,需本地缓存 } -// 登录响应联合体:Message === 'needUserInfo' 时为绑定凭证形态(needUserInfo 由 http 层标记) +// 登录响应联合体:Message 子状态由 http 层附加到 message 字段 +// ''/undefined = 完整登录;'needUserInfo' = 需头像昵称授权;'noMobile' = 需手机号授权 export type WxMiniappLoginResponse = WxLoginResult & - Partial & { needUserInfo?: boolean } + Partial & { message?: string } export function wxMiniappLogin(params: WxMiniappLoginParams): Promise { return request.post( diff --git a/src/http/client.ts b/src/http/client.ts index fdab0b5..0007462 100644 --- a/src/http/client.ts +++ b/src/http/client.ts @@ -44,7 +44,8 @@ export interface RequestConfig { export interface ApiEnvelope { 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( } 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( 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).message = subState + } return body.Content } diff --git a/src/pages/login/index.module.scss b/src/pages/login/index.module.scss index 535d798..b6650e3 100644 --- a/src/pages/login/index.module.scss +++ b/src/pages/login/index.module.scss @@ -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; diff --git a/src/pages/login/index.tsx b/src/pages/login/index.tsx index 3d390ac..b34baa7 100644 --- a/src/pages/login/index.tsx +++ b/src/pages/login/index.tsx @@ -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(null) + // 注册中间态:needUserInfo(待头像昵称授权)→ noMobile(待手机号授权)→ null(初始/完成) + const [subState, setSubState] = useState(null) + const [openId, setOpenId] = useState('') // 已有 token 直接进首页(本期不做 checkLogin 校验,spec D5) useDidShow(() => { if (token && !loading) reLaunch(ROUTES.HOME) }) + // 完整登录态校验与落地;未完成时推进 subState + const applyResult = async (result: Awaited>) => { + 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 { - 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) + 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 ( @@ -70,13 +108,21 @@ export default function Login() { 一起守护家人 - {pendingAuth === null ? ( - - {loading ? '登录中...' : '微信一键登录'} - + {subState === 'noMobile' ? ( + // 手机号授权必须用原生 Button open-type,微信弹官方授权框 + ) : ( - - {loading ? '授权中...' : '授权头像昵称,完成登录'} + + {submitText()} )} diff --git a/src/pages/login/wxLoginFlow.ts b/src/pages/login/wxLoginFlow.ts index 674426c..28d195f 100644 --- a/src/pages/login/wxLoginFlow.ts +++ b/src/pages/login/wxLoginFlow.ts @@ -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 { // Taro.login reject 时返回 { errMsg } 形状对象而非 Error,此处归一化为带 message 的 Error let code: string @@ -31,12 +35,34 @@ export async function performWxLogin(): Promise { } if (!code) throw new Error('获取微信登录凭证失败') const openId = storage.get(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 { + 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 { let profile: { iv: string; encryptedData: string } try { @@ -45,22 +71,16 @@ export async function completeUserInfoLogin(openId: string): Promise { + if (!detail.iv || !detail.encryptedData) { + throw new Error(detail.errMsg || '手机号授权失败') + } + return reloginWith(openId, { iv: detail.iv, encryptedData: detail.encryptedData }) } diff --git a/tests/unit/http/client.spec.ts b/tests/unit/http/client.spec.ts index 83d3de8..f2b40b5 100644 --- a/tests/unit/http/client.spec.ts +++ b/tests/unit/http/client.spec.ts @@ -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' }) }) }) diff --git a/tests/unit/login/wxLoginFlow.spec.ts b/tests/unit/login/wxLoginFlow.spec.ts index 42aa3b1..04c1a57 100644 --- a/tests/unit/login/wxLoginFlow.spec.ts +++ b/tests/unit/login/wxLoginFlow.spec.ts @@ -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 = {}) => ({ userInfo: { @@ -34,11 +34,17 @@ const result = (over: Record = {}) => ({ ...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() + }) +})