From 1eef3ae6144747759b965039fae7ca5d264c2e10 Mon Sep 17 00:00:00 2001 From: Karlcarlo Date: Wed, 27 May 2026 18:48:24 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E5=B0=8F=E7=A8=8B=E5=BA=8F?= =?UTF-8?q?=E5=8F=82=E6=95=B0=E7=9B=B4=E4=BC=A0=20uid/openid/phone?= =?UTF-8?q?=EF=BC=8C=E5=89=8D=E7=AB=AF=E6=9E=84=E5=BB=BA=E6=9C=AC=E5=9C=B0?= =?UTF-8?q?=20token=20=E5=AE=8C=E6=88=90=E7=99=BB=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 createDummyToken() 从前端构建本地 JWT(后端不验签) - LoginPage 检测 URL 参数 uid/openid/phone 直接登录 - 新增 /api/auth/params-login 后端接口(后续可选) - 移除 paramsLogin API 调用,简化流程 - Debug 面板默认显示 - 修复 JWT base64url 解码 --- backend/app/routers/auth.py | 27 ++++++ frontend/src/app/pages/LoginPage.tsx | 139 ++++++++++++++++----------- frontend/src/app/services/api.ts | 14 +++ 3 files changed, 124 insertions(+), 56 deletions(-) diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index 0903256..73e021e 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -74,6 +74,33 @@ async def exchange_code(req: ExchangeCodeRequest) -> TokenResponse: ) +class ParamsLoginRequest(BaseModel): + uid: str = "" + openid: str + phone: str = "" + + +@router.post("/params-login") +async def params_login(req: ParamsLoginRequest) -> TokenResponse: + """小程序传 uid/openid/phone → 查找或创建用户 → 返回自签 token""" + user = await User.find_one(User.openid == req.openid) + if not user: + user = User(openid=req.openid, phone=req.phone or None) + await user.insert() + else: + if req.phone and req.phone != (user.phone or ""): + user.phone = req.phone + await user.save() + + token = _build_token(user) + return TokenResponse( + token=token, + user_id=str(user.id), + openid=user.openid, + mobile=user.phone or "", + ) + + @router.post("/dev-login") async def dev_login() -> TokenResponse: """开发环境:使用固定测试账号,不依赖微信""" diff --git a/frontend/src/app/pages/LoginPage.tsx b/frontend/src/app/pages/LoginPage.tsx index 6a3d3a4..b77c1e6 100644 --- a/frontend/src/app/pages/LoginPage.tsx +++ b/frontend/src/app/pages/LoginPage.tsx @@ -1,9 +1,9 @@ -import { useEffect, useState } from "react"; +import { useEffect, useState, useRef } from "react"; import { useNavigate } from "react-router"; import { motion } from "motion/react"; import { Heart } from "lucide-react"; import { useUser } from "../context/UserContext"; -import { decodeToken, devLogin, getToken, setToken as saveToken } from "../services/api"; +import { decodeToken, devLogin, createDummyToken, getToken, setToken as saveToken } from "../services/api"; /** 判断是否在微信小程序 webview 环境 */ function isWechatMiniProgram(): boolean { @@ -65,6 +65,7 @@ export function LoginPage() { sessionStorage.setItem('debug', '1'); return { ...INIT_DEBUG }; }); + const processedRef = useRef(false); // 防止重复处理 // ─── 🐞 调试更新函数 ─── const dbg = (patch: Partial) => { @@ -72,78 +73,91 @@ export function LoginPage() { }; // ─── 🐞 调试更新结束 ─── - useEffect(() => { - // ─── 🐞 ─── - if (debug) { - debug.url = window.location.href; - debug.ua = navigator.userAgent; - debug.tokenInStorage = !!getToken(); + /** 处理 token:解码 → 登录 → 拉取数据 → 跳转 */ + const processToken = (token: string) => { + if (processedRef.current) return; + processedRef.current = true; + + const oldToken = getToken(); + if (token !== oldToken) { + try { + const payload = decodeToken(token); + dbg({ step: "decode-ok", decodeResult: `uid=${payload.uid}, openid=${payload.openid?.substring(0,15)}..., phone=${payload.phone}` }); + setTokenAndUser(token, { + id: String(payload.uid), + openid: payload.openid, + phone: payload.phone, + }); + } catch (e: any) { + dbg({ step: "decode-fail", decodeResult: `解析失败: ${e?.message || e}` }); + setError("Token 解析失败: " + (e?.message || "格式错误")); + return; + } + } else { + saveToken(token); } - // ─── 🐞 结束 ─── - // 优先使用 URL 中的 token(小程序传参) + dbg({ step: "fetch-profile" }); + fetchProfile().then((dto) => { + dbg({ step: "fetch-ok", fetchError: `is_onboarded=${dto?.is_onboarded}` }); + const target = dto?.is_onboarded ? "/home" : "/onboarding"; + navigate(sessionStorage.getItem('debug') ? target + "?debug=1" : target, { replace: true }); + }).catch((e: any) => { + dbg({ step: "fetch-fail", fetchError: `${e?.message || e}` }); + setError("登录已失效,请在小程序重新进入"); + }); + }; + + // 暴露到全局,供小程序 evaluateJavascript 直接调用 + (window as any).handleInjectedToken = processToken; + + useEffect(() => { + dbg({ url: window.location.href, ua: navigator.userAgent, tokenInStorage: !!getToken() }); + + // 从 URL 获取 token(query / hash / 注入) const params = new URLSearchParams(window.location.search); - let tokenFromUrl = params.get('token'); + let token = params.get('token'); - // 兜底:从 hash 读取(微信 web-view 可能丢 query 但 hash 安全) - if (!tokenFromUrl) { + if (!token) { const hash = window.location.hash; if (hash.startsWith('#token=')) { - tokenFromUrl = decodeURIComponent(hash.replace('#token=', '')); - // ─── 🐞 ─── - dbg({ step: "token-from-hash", tokenPrefix: tokenFromUrl.substring(0, 30) + "..." }); - // ─── 🐞 ─── + token = decodeURIComponent(hash.replace('#token=', '')); + dbg({ step: "token-from-hash", tokenPrefix: token.substring(0, 30) + "..." }); } } - // ─── 🐞 ─── - dbg({ step: "check-url-token", hasTokenParam: !!tokenFromUrl, tokenPrefix: tokenFromUrl ? tokenFromUrl.substring(0, 30) + "..." : "" }); - // ─── 🐞 ─── + if (!token && (window as any).__INJECTED_TOKEN__) { + token = (window as any).__INJECTED_TOKEN__; + dbg({ step: "token-from-inject", tokenPrefix: token.substring(0, 30) + "..." }); + } - if (tokenFromUrl) { - // token 变了就更新存储 - const oldToken = getToken(); - if (tokenFromUrl !== oldToken) { - try { - const payload = decodeToken(tokenFromUrl); - // ─── 🐞 ─── - dbg({ step: "decode-ok", decodeResult: `uid=${payload.uid}, openid=${payload.openid?.substring(0,15)}..., phone=${payload.phone}` }); - // ─── 🐞 ─── - setTokenAndUser(tokenFromUrl, { - id: String(payload.uid), - openid: payload.openid, - phone: payload.phone, - }); - } catch (e: any) { - // ─── 🐞 ─── - dbg({ step: "decode-fail", decodeResult: `解析失败: ${e?.message || e}` }); - // ─── 🐞 ─── - setError("Token 解析失败: " + (e?.message || "格式错误")); - return; - } - } else { - // token 没变,确保 localStorage 有数据 - saveToken(tokenFromUrl); - } + if (token) { + dbg({ step: "found-token", hasTokenParam: true, tokenPrefix: token.substring(0, 30) + "..." }); + processToken(token); + return; + } - // 拉取用户数据,进入首页或 onboarding - dbg({ step: "fetch-profile" }); + // 从 URL 参数 uid/openid/phone 直接构建本地 token + const uidParam = params.get('uid'); + const openidParam = params.get('openid'); + if (openidParam) { + const uid = parseInt(uidParam || '0'); + const phone = params.get('phone') || ''; + const dummyToken = createDummyToken(uid, openidParam, phone); + dbg({ step: "params-login", hasTokenParam: true, tokenPrefix: `uid=${uidParam}, openid=${openidParam.substring(0, 15)}...` }); + setTokenAndUser(dummyToken, { id: String(uid), openid: openidParam, phone }); fetchProfile().then((dto) => { - // ─── 🐞 ─── - dbg({ step: "fetch-ok", fetchError: `is_onboarded=${dto?.is_onboarded}` }); - // ─── 🐞 ─── + dbg({ step: "params-ok", fetchError: `is_onboarded=${dto?.is_onboarded}` }); const target = dto?.is_onboarded ? "/home" : "/onboarding"; navigate(sessionStorage.getItem('debug') ? target + "?debug=1" : target, { replace: true }); }).catch((e: any) => { - // ─── 🐞 ─── - dbg({ step: "fetch-fail", fetchError: `${e?.message || e}` }); - // ─── 🐞 ─── - setError("登录已失效,请在小程序重新进入"); + dbg({ step: "params-fail", fetchError: `${e?.message || e}` }); + setError("登录失败: " + (e?.message || "")); }); return; } - // 小程序环境但没有 token → 通知小程序传递 + // 没有 token → 小程序环境则发 NEED_TOKEN dbg({ step: "check-wx-mp", isWxMp: isWechatMiniProgram() }); if (isWechatMiniProgram()) { try { @@ -153,10 +167,23 @@ export function LoginPage() { dbg({ step: "wx-postMessage-error", fetchError: `${e?.message || e}` }); } setError("正在获取登录信息..."); + + // 轮询:等待小程序 evaluateJavascript 注入 token + let attempts = 0; + const timer = setInterval(() => { + const injected = (window as any).__INJECTED_TOKEN__; + if (injected) { + clearInterval(timer); + dbg({ step: "token-polled", tokenPrefix: injected.substring(0, 30) + "..." }); + processToken(injected); + } + attempts++; + if (attempts >= 20) clearInterval(timer); // 最多等 10 秒 + }, 500); return; } - // 浏览器开发环境 → 自动使用测试账号 + // 浏览器开发环境 → dev-login dbg({ step: "dev-login" }); devLogin().then((res) => { dbg({ step: "dev-login-ok" }); diff --git a/frontend/src/app/services/api.ts b/frontend/src/app/services/api.ts index 5ddaf28..bce78ac 100644 --- a/frontend/src/app/services/api.ts +++ b/frontend/src/app/services/api.ts @@ -71,11 +71,25 @@ export function decodeToken(token: string): TokenPayload { return JSON.parse(atob(base64)); } +/** 从 uid/openid/phone 构建本地 token(后端不验证签名,可解析) */ +export function createDummyToken(uid: number, openid: string, phone: string): string { + const header = btoa(JSON.stringify({ alg: "HS256", typ: "JWT" })); + const payload = btoa(JSON.stringify({ + uid, openid, phone, exp: Math.floor(Date.now() / 1000) + 86400 * 30, + })); + return header + '.' + payload + '.'; +} + /** 开发环境:获取测试账号 token */ export async function devLogin(): Promise<{ token: string; user_id: string; openid: string; mobile: string }> { return fetchApi("/auth/dev-login", { method: "POST" }); } +/** 小程序传 uid/openid/phone 参数登录 */ +export async function paramsLogin(data: { uid?: string; openid: string; phone?: string }): Promise<{ token: string; user_id: string; openid: string; mobile: string }> { + return fetchApi("/auth/params-login", { method: "POST", body: JSON.stringify(data) }); +} + // ─── User Profile ─── export interface UserProfileDTO {