refactor: 小程序参数直传 uid/openid/phone,前端构建本地 token 完成登录
- 新增 createDummyToken() 从前端构建本地 JWT(后端不验签) - LoginPage 检测 URL 参数 uid/openid/phone 直接登录 - 新增 /api/auth/params-login 后端接口(后续可选) - 移除 paramsLogin API 调用,简化流程 - Debug 面板默认显示 - 修复 JWT base64url 解码
This commit is contained in:
@@ -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:
|
||||
"""开发环境:使用固定测试账号,不依赖微信"""
|
||||
|
||||
@@ -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<DebugInfo>) => {
|
||||
@@ -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;
|
||||
|
||||
// 优先使用 URL 中的 token(小程序传参)
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
let tokenFromUrl = params.get('token');
|
||||
|
||||
// 兜底:从 hash 读取(微信 web-view 可能丢 query 但 hash 安全)
|
||||
if (!tokenFromUrl) {
|
||||
const hash = window.location.hash;
|
||||
if (hash.startsWith('#token=')) {
|
||||
tokenFromUrl = decodeURIComponent(hash.replace('#token=', ''));
|
||||
// ─── 🐞 ───
|
||||
dbg({ step: "token-from-hash", tokenPrefix: tokenFromUrl.substring(0, 30) + "..." });
|
||||
// ─── 🐞 ───
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 🐞 ───
|
||||
dbg({ step: "check-url-token", hasTokenParam: !!tokenFromUrl, tokenPrefix: tokenFromUrl ? tokenFromUrl.substring(0, 30) + "..." : "" });
|
||||
// ─── 🐞 ───
|
||||
|
||||
if (tokenFromUrl) {
|
||||
// token 变了就更新存储
|
||||
const oldToken = getToken();
|
||||
if (tokenFromUrl !== oldToken) {
|
||||
if (token !== oldToken) {
|
||||
try {
|
||||
const payload = decodeToken(tokenFromUrl);
|
||||
// ─── 🐞 ───
|
||||
const payload = decodeToken(token);
|
||||
dbg({ step: "decode-ok", decodeResult: `uid=${payload.uid}, openid=${payload.openid?.substring(0,15)}..., phone=${payload.phone}` });
|
||||
// ─── 🐞 ───
|
||||
setTokenAndUser(tokenFromUrl, {
|
||||
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 {
|
||||
// token 没变,确保 localStorage 有数据
|
||||
saveToken(tokenFromUrl);
|
||||
saveToken(token);
|
||||
}
|
||||
|
||||
// 拉取用户数据,进入首页或 onboarding
|
||||
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 token = params.get('token');
|
||||
|
||||
if (!token) {
|
||||
const hash = window.location.hash;
|
||||
if (hash.startsWith('#token=')) {
|
||||
token = decodeURIComponent(hash.replace('#token=', ''));
|
||||
dbg({ step: "token-from-hash", tokenPrefix: token.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 (token) {
|
||||
dbg({ step: "found-token", hasTokenParam: true, tokenPrefix: token.substring(0, 30) + "..." });
|
||||
processToken(token);
|
||||
return;
|
||||
}
|
||||
|
||||
// 从 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: "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: "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" });
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user