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:
2026-05-27 18:48:24 +08:00
parent 8c50afc120
commit 1eef3ae614
3 changed files with 124 additions and 56 deletions

View File

@@ -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:
"""开发环境:使用固定测试账号,不依赖微信"""

View File

@@ -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" });

View File

@@ -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 {