简化H5应用用户授权

This commit is contained in:
2026-05-23 01:59:52 +08:00
parent 53128a6f02
commit 08df1825a7
8 changed files with 168 additions and 423 deletions

2
.gitignore vendored
View File

@@ -27,7 +27,7 @@ uploads/
# Claude Code
.claude/
.trae/
# Dependencies
node_modules/

View File

@@ -1,87 +1,97 @@
import uuid
from __future__ import annotations
from datetime import datetime, timedelta, timezone
import os
from fastapi import APIRouter, HTTPException
from pymongo.errors import DuplicateKeyError
from jose import jwt
from pydantic import BaseModel
from app.config import settings
from app.models.user import User
from app.schemas.auth import WechatLoginRequest, PhoneLoginRequest, TokenResponse
from app.middleware.auth import create_access_token
from app.services.wechat import code_to_session
router = APIRouter()
@router.post("/demo-login", response_model=TokenResponse)
async def demo_login():
"""Web 体验用的演示登录,每次创建独立的临时用户"""
demo_openid = f"demo_{uuid.uuid4().hex[:12]}"
user = User(
openid=demo_openid,
name="体验用户",
)
await user.insert()
token = create_access_token(str(user.id))
return TokenResponse(
access_token=token,
user_id=str(user.id),
is_new_user=True,
)
DEV_MODE = os.environ.get("DEV_MODE", "").lower() in ("1", "true", "yes")
# 固定测试账号 openid(dev 模式下使用)
DEV_OPENID = "dev_test_account_001"
@router.post("/wechat-login", response_model=TokenResponse)
async def wechat_login(req: WechatLoginRequest):
"""微信小程序登录"""
try:
class ExchangeCodeRequest(BaseModel):
code: str
mobile: str | None = None
class TokenResponse(BaseModel):
token: str
user_id: str
openid: str
mobile: str
def _build_token(user: User) -> str:
"""生成自包含 JWT:sub + openid + mobile"""
expire = datetime.now(timezone.utc) + timedelta(days=30)
payload = {
"sub": str(user.id),
"openid": user.openid,
"mobile": user.phone or "",
"exp": expire,
}
return jwt.encode(payload, settings.jwt_secret_key, algorithm=settings.jwt_algorithm)
@router.post("/exchange-code")
async def exchange_code(req: ExchangeCodeRequest) -> TokenResponse:
"""小程序登录:code → openid → 创建/查找 User → 返回自包含 JWT"""
wechat_data = await code_to_session(req.code)
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
openid = wechat_data["openid"]
user = await User.find_one(User.openid == openid)
is_new = user is None
user = await User.find_one(User.openid == openid)
if not user:
user = User(
openid=openid,
session_key=wechat_data.get("session_key"),
union_id=wechat_data.get("unionid"),
phone=req.mobile or None,
)
await user.insert()
else:
# 更新 session_key + 手机号
user.session_key = wechat_data.get("session_key")
if req.mobile:
user.phone = req.mobile
await user.save()
token = create_access_token(str(user.id))
token = _build_token(user)
return TokenResponse(
access_token=token,
token=token,
user_id=str(user.id),
is_new_user=is_new,
openid=user.openid,
mobile=user.phone or "",
)
@router.post("/phone-login", response_model=TokenResponse)
async def phone_login(req: PhoneLoginRequest):
"""手机号登录(简化版,生产环境需接入短信验证)"""
# TODO: 接入真实短信验证服务
if req.sms_code != "123456":
raise HTTPException(status_code=400, detail="验证码错误")
user = await User.find_one(User.phone == req.phone)
is_new = user is None
@router.post("/dev-login")
async def dev_login() -> TokenResponse:
"""开发环境:使用固定测试账号,不依赖微信"""
if not DEV_MODE:
raise HTTPException(status_code=403, detail="仅开发环境可用")
user = await User.find_one(User.openid == DEV_OPENID)
if not user:
try:
user = User(openid=f"phone_{req.phone}", phone=req.phone)
await user.insert()
except DuplicateKeyError:
# 并发竞态:另一个请求已创建该用户,回退查找
user = await User.find_one(User.phone == req.phone)
is_new = False
token = create_access_token(str(user.id))
return TokenResponse(
access_token=token,
user_id=str(user.id),
is_new_user=is_new,
user = User(
openid=DEV_OPENID,
name="开发测试用户",
phone="13800000000",
)
await user.insert()
token = _build_token(user)
return TokenResponse(
token=token,
user_id=str(user.id),
openid=user.openid,
mobile=user.phone or "",
)

View File

@@ -1,20 +1,13 @@
from datetime import datetime
from typing import Optional
from pydantic import BaseModel
class WechatLoginRequest(BaseModel):
code: str # wx.login() 获取的 code
class PhoneLoginRequest(BaseModel):
phone: str
sms_code: str
class ExchangeCodeRequest(BaseModel):
code: str
mobile: str | None = None
class TokenResponse(BaseModel):
access_token: str
token_type: str = "bearer"
token: str
user_id: str
is_new_user: bool = False
openid: str
mobile: str

View File

@@ -64,6 +64,7 @@ init_config() {
APP_PORT=${APP_PORT:-${PORT:-8000}}
APP_WORKERS=${APP_WORKERS:-1}
APP_LOG_LEVEL=${APP_LOG_LEVEL:-"info"}
DEV_MODE=${DEV_MODE:-""}
# PID 文件路径
PID_FILE="${APP_DIR}/.${APP_NAME}.pid"
@@ -82,6 +83,7 @@ init_config() {
log_info "服务器端口: $APP_PORT"
log_info "工作进程数: $APP_WORKERS"
log_info "日志级别: $APP_LOG_LEVEL"
log_info "开发模式: $([ -n \"$DEV_MODE\" ] && echo \"已启用\" || echo \"已禁用\")"
log_info "日志文件: $LOG_FILE"
}

View File

@@ -51,6 +51,13 @@ const DEFAULT_PROFILE: UserProfile = {
};
const STORAGE_KEY = "pregnancy_user_profile";
const USER_BASIC_KEY = "user_basic";
export interface BasicUserInfo {
id: string;
openid: string;
phone: string;
}
function loadProfile(): UserProfile {
try {
@@ -169,6 +176,7 @@ interface UserContextValue {
updateUser: (data: Partial<UserProfile>) => void;
updateUserRemote: (data: Partial<UserProfile>) => Promise<void>;
setToken: (token: string) => void;
setTokenAndUser: (token: string, info: BasicUserInfo) => void;
fetchProfile: () => Promise<void>;
logout: () => void;
}
@@ -224,17 +232,28 @@ export function UserProvider({ children }: { children: ReactNode }) {
}
}, [updateUser]);
const fetchProfile = useCallback(async () => {
const fetchProfile = useCallback(async (): Promise<UserProfileDTO | null> => {
try {
const dto = await getUserProfile();
const profile = mapDtoToProfile(dto);
setUser(profile);
saveProfile(profile);
return dto;
} catch {
// Token 失效会被 api.ts 自动处理(跳转登录页)
return null;
}
}, []);
const setTokenAndUser = useCallback((newToken: string, info: BasicUserInfo) => {
// 清理前一个用户的本地数据
localStorage.removeItem(STORAGE_KEY);
setUser(DEFAULT_PROFILE);
saveToken(newToken);
localStorage.setItem(USER_BASIC_KEY, JSON.stringify(info));
setTokenState(newToken);
}, []);
const logout = useCallback(() => {
clearToken();
localStorage.removeItem(STORAGE_KEY);
@@ -250,7 +269,7 @@ export function UserProvider({ children }: { children: ReactNode }) {
}, []); // eslint-disable-line react-hooks/exhaustive-deps
return (
<UserContext.Provider value={{ user, token, updateUser, updateUserRemote, setToken, fetchProfile, logout }}>
<UserContext.Provider value={{ user, token, updateUser, updateUserRemote, setToken, setTokenAndUser, fetchProfile, logout }}>
{children}
</UserContext.Provider>
);

View File

@@ -1,10 +1,9 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { useEffect, useState } from "react";
import { useNavigate } from "react-router";
import { motion, AnimatePresence } from "motion/react";
import { Heart, Loader2, Phone, ArrowRight, ChevronLeft } from "lucide-react";
import { Button } from "../components/ui/button";
import { motion } from "motion/react";
import { Heart } from "lucide-react";
import { useUser } from "../context/UserContext";
import { loginByWechat, loginByDemo, loginByPhone, getToken } from "../services/api";
import { decodeToken, devLogin, getToken, setToken as saveToken } from "../services/api";
/** 判断是否在微信小程序 webview 环境 */
function isWechatMiniProgram(): boolean {
@@ -12,354 +11,80 @@ function isWechatMiniProgram(): boolean {
return ua.includes("micromessenger") && ua.includes("miniprogram");
}
/** 调用 wx.miniProgram.login 获取 code */
function wxLogin(): Promise<string> {
return new Promise((resolve, reject) => {
const wx = (window as any).wx;
if (!wx?.miniProgram) {
reject(new Error("非小程序环境"));
return;
}
wx.miniProgram.login({
success: (res: any) => {
if (res.code) {
resolve(res.code);
} else {
reject(new Error("获取登录凭证失败"));
}
},
fail: () => reject(new Error("微信登录失败")),
});
});
}
export function LoginPage() {
const navigate = useNavigate();
const { setToken, fetchProfile, user } = useUser();
const [isLoading, setIsLoading] = useState(false);
const { setTokenAndUser, fetchProfile } = useUser();
const [error, setError] = useState("");
const [autoLoginTried, setAutoLoginTried] = useState(false);
// 手机号登录状态
const [showPhoneLogin, setShowPhoneLogin] = useState(false);
const [phoneNumber, setPhoneNumber] = useState("");
const [smsCode, setSmsCode] = useState("");
const [smsSent, setSmsSent] = useState(false);
const [countdown, setCountdown] = useState(0);
const countdownRef = useRef<ReturnType<typeof setInterval> | null>(null);
// 已有 token 且已完成 onboarding,直接跳首页
useEffect(() => {
if (getToken() && user.isOnboarded) {
navigate("/home", { replace: true });
}
}, []);
// 优先使用 URL 中的 token(小程序传参)
const params = new URLSearchParams(window.location.search);
const tokenFromUrl = params.get('token');
// 倒计时
useEffect(() => {
if (countdown > 0) {
countdownRef.current = setInterval(() => {
setCountdown((prev) => {
if (prev <= 1) {
if (countdownRef.current) clearInterval(countdownRef.current);
return 0;
}
return prev - 1;
if (tokenFromUrl) {
// token 变了就更新存储
const oldToken = getToken();
if (tokenFromUrl !== oldToken) {
const payload = decodeToken(tokenFromUrl);
setTokenAndUser(tokenFromUrl, {
id: payload.sub,
openid: payload.openid,
phone: payload.mobile,
});
}, 1000);
}
return () => {
if (countdownRef.current) clearInterval(countdownRef.current);
};
}, [countdown > 0]);
const handleLoginSuccess = useCallback(
async (res: { access_token: string; is_new_user: boolean }) => {
setToken(res.access_token);
if (res.is_new_user) {
// 新用户 → 走 onboarding 流程
navigate("/onboarding");
} else {
// 老用户 → 拉取资料后直接进首页
await fetchProfile();
navigate("/home", { replace: true });
// token 没变,确保 localStorage 有数据
saveToken(tokenFromUrl);
}
},
[setToken, fetchProfile, navigate]
);
// 小程序环境自动登录
useEffect(() => {
if (autoLoginTried) return;
if (getToken() && user.isOnboarded) return;
// 拉取孕期数据,进入首页或 onboarding
fetchProfile().then((dto) => {
navigate(dto?.is_onboarded ? "/home" : "/onboarding", { replace: true });
}).catch(() => {
setError("登录已失效,请在小程序重新进入");
});
return;
}
// 小程序环境但没有 token → 通知小程序传递
if (isWechatMiniProgram()) {
setAutoLoginTried(true);
setIsLoading(true);
wxLogin()
.then((code) => loginByWechat(code))
.then(handleLoginSuccess)
.catch((err) => {
setError(err.message || "自动登录失败");
})
.finally(() => setIsLoading(false));
}
}, [autoLoginTried, user.isOnboarded, handleLoginSuccess]);
// 一键体验(demo)
const handleDemoLogin = async () => {
setError("");
setIsLoading(true);
try {
if (isWechatMiniProgram()) {
const code = await wxLogin();
const res = await loginByWechat(code);
await handleLoginSuccess(res);
} else {
const res = await loginByDemo();
await handleLoginSuccess(res);
(window as any).wx?.miniProgram?.postMessage({ type: "NEED_TOKEN" });
} catch { /* ignore */ }
setError("正在获取登录信息...");
return;
}
} catch (err: unknown) {
const message = err instanceof Error ? err.message : "登录失败,请重试";
setError(message);
} finally {
setIsLoading(false);
}
};
// 发送验证码
const handleSendSms = () => {
if (phoneNumber.length !== 11) {
setError("请输入正确的11位手机号");
return;
}
setError("");
setSmsSent(true);
setCountdown(60);
};
// 手机号登录
const handlePhoneLogin = async () => {
if (phoneNumber.length !== 11) {
setError("请输入正确的11位手机号");
return;
}
if (smsCode.length < 4) {
setError("请输入验证码");
return;
}
setError("");
setIsLoading(true);
try {
const res = await loginByPhone(phoneNumber, smsCode);
await handleLoginSuccess(res);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : "登录失败,请重试";
setError(message);
} finally {
setIsLoading(false);
}
};
// 浏览器开发环境 → 自动使用测试账号
devLogin().then((res) => {
setTokenAndUser(res.token, {
id: res.user_id,
openid: res.openid,
phone: res.mobile,
});
fetchProfile().then((dto) => {
navigate(dto?.is_onboarded ? "/home" : "/onboarding", { replace: true });
});
}).catch((err) => {
setError(err?.message || "登录失败,请刷新重试");
});
}, []); // eslint-disable-line react-hooks/exhaustive-deps
return (
<div className="min-h-dvh bg-morandi-50 flex items-center justify-center p-4">
<div className="text-center">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="w-full"
animate={{ rotate: 360 }}
transition={{ repeat: Infinity, duration: 1.5, ease: "linear" }}
className="inline-flex items-center justify-center w-16 h-16 bg-gradient-to-br from-morandi-300 to-morandi-400 rounded-full mb-4"
>
<div className="text-center mb-8">
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ delay: 0.2, type: "spring" }}
className="inline-flex items-center justify-center w-20 h-20 bg-gradient-to-br from-morandi-300 to-morandi-400 rounded-full mb-4"
>
<Heart className="w-10 h-10 text-white fill-white" />
<Heart className="w-8 h-8 text-white fill-white" />
</motion.div>
<h1 className="text-3xl font-semibold bg-gradient-to-r from-morandi-400 to-morandi-500 bg-clip-text text-transparent mb-2">
孕育无忧
</h1>
<p className="text-gray-600">您的专属孕期健康助手</p>
</div>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.3 }}
className="bg-white rounded-3xl shadow-md p-8"
>
<AnimatePresence mode="wait">
{!showPhoneLogin ? (
/* ─── 主登录页 ─── */
<motion.div
key="main"
initial={{ opacity: 0, x: 0 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
className="space-y-4"
>
{/* 错误提示 */}
{error && (
<motion.p
initial={{ opacity: 0, y: -5 }}
animate={{ opacity: 1, y: 0 }}
className="text-sm text-red-500 bg-red-50 px-4 py-2 rounded-xl"
>
{error}
</motion.p>
)}
{/* 手机号登录按钮 */}
<Button
onClick={() => { setError(""); setShowPhoneLogin(true); }}
disabled={isLoading}
className="w-full h-12 rounded-xl bg-gradient-to-r from-morandi-400 to-morandi-500 hover:from-morandi-500 hover:to-morandi-500 text-white font-medium"
>
<Phone className="w-4 h-4 mr-2" />
手机号登录
</Button>
{/* 分隔线 */}
<div className="flex items-center gap-3">
<div className="flex-1 h-px bg-gray-200" />
<span className="text-xs text-gray-400">或</span>
<div className="flex-1 h-px bg-gray-200" />
</div>
{/* 一键体验 */}
<Button
onClick={handleDemoLogin}
disabled={isLoading}
variant="outline"
className="w-full h-12 rounded-xl border-2 border-morandi-200 text-morandi-500 font-medium hover:bg-morandi-50"
>
{isLoading ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
登录中...
</>
) : isWechatMiniProgram() ? (
"微信一键授权登录"
{error ? (
<p className="text-sm text-red-500">{error}</p>
) : (
"游客体验"
)}
</Button>
<p className="text-xs text-gray-400 text-center">
游客模式数据不保留,建议使用手机号登录
</p>
</motion.div>
) : (
/* ─── 手机号登录表单 ─── */
<motion.div
key="phone"
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 20 }}
className="space-y-4"
>
{/* 返回 */}
<button
onClick={() => { setShowPhoneLogin(false); setError(""); setSmsSent(false); setSmsCode(""); }}
className="flex items-center gap-1 text-sm text-gray-500 hover:text-gray-700 -ml-1 mb-1"
>
<ChevronLeft className="w-4 h-4" />
返回
</button>
<h2 className="text-lg font-semibold text-gray-800">手机号登录</h2>
<p className="text-sm text-gray-500 -mt-2">登录后数据将绑定到您的手机号</p>
{/* 错误提示 */}
{error && (
<motion.p
initial={{ opacity: 0, y: -5 }}
animate={{ opacity: 1, y: 0 }}
className="text-sm text-red-500 bg-red-50 px-4 py-2 rounded-xl"
>
{error}
</motion.p>
)}
{/* 手机号输入 */}
<div className="space-y-1">
<label className="text-sm text-gray-600 font-medium">手机号</label>
<input
type="tel"
maxLength={11}
value={phoneNumber}
onChange={(e) => setPhoneNumber(e.target.value.replace(/\D/g, ""))}
placeholder="请输入11位手机号"
className="w-full h-12 px-4 rounded-xl border-2 border-gray-200 focus:border-morandi-400 outline-none transition-colors text-base"
/>
</div>
{/* 验证码输入 */}
<div className="space-y-1">
<label className="text-sm text-gray-600 font-medium">验证码</label>
<div className="flex gap-3">
<input
type="text"
maxLength={6}
value={smsCode}
onChange={(e) => setSmsCode(e.target.value.replace(/\D/g, ""))}
placeholder="请输入验证码"
className="flex-1 h-12 px-4 rounded-xl border-2 border-gray-200 focus:border-morandi-400 outline-none transition-colors text-base"
/>
<button
onClick={handleSendSms}
disabled={countdown > 0 || phoneNumber.length !== 11}
className={`px-4 h-12 rounded-xl text-sm font-medium whitespace-nowrap transition-all ${
countdown > 0 || phoneNumber.length !== 11
? "bg-gray-100 text-gray-400"
: "bg-morandi-100 text-morandi-500 hover:bg-morandi-200"
}`}
>
{countdown > 0 ? `${countdown}s` : smsSent ? "重新发送" : "获取验证码"}
</button>
</div>
{smsSent && (
<p className="text-xs text-morandi-400 mt-1">
验证码已发送,请输入 123456
</p>
<p className="text-sm text-gray-500">加载中...</p>
)}
</div>
{/* 登录按钮 */}
<Button
onClick={handlePhoneLogin}
disabled={isLoading || phoneNumber.length !== 11 || smsCode.length < 4}
className="w-full h-12 rounded-xl bg-gradient-to-r from-morandi-400 to-morandi-500 hover:from-morandi-500 hover:to-morandi-500 text-white font-medium disabled:opacity-50"
>
{isLoading ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
登录中...
</>
) : (
<>
登录
<ArrowRight className="w-4 h-4 ml-2" />
</>
)}
</Button>
</motion.div>
)}
</AnimatePresence>
<p className="text-xs text-gray-500 text-center mt-6">
登录即表示同意
<a href="#" className="text-morandi-400 hover:underline">
用户协议
</a>
和
<a href="#" className="text-morandi-400 hover:underline">
隐私政策
</a>
</p>
</motion.div>
</motion.div>
</div>
);
}

View File

@@ -24,8 +24,14 @@ import { getToken } from "./services/api";
/** 路由守卫:未登录跳转登录页 */
function ProtectedRoute() {
const token = getToken();
const hasProfile = !!localStorage.getItem("pregnancy_user_profile");
if (!token && !hasProfile) {
const urlToken = new URLSearchParams(window.location.search).get('token');
// 来自小程序的 token → 让 LoginPage 处理
if (urlToken) {
return <Navigate to={`/?token=${encodeURIComponent(urlToken)}`} replace />;
}
if (!token) {
return <Navigate to="/" replace />;
}
return <Outlet />;

View File

@@ -54,31 +54,21 @@ async function fetchApi<T>(path: string, options: RequestInit = {}): Promise<T>
// ─── Auth ───
interface LoginResponse {
access_token: string;
token_type: string;
user_id: string;
is_new_user: boolean;
export interface TokenPayload {
sub: string; // user_id
openid: string;
mobile: string;
exp: number;
}
export async function loginByPhone(phone: string, smsCode: string): Promise<LoginResponse> {
return fetchApi<LoginResponse>("/auth/phone-login", {
method: "POST",
body: JSON.stringify({ phone, sms_code: smsCode }),
});
/** 解析 JWT payload(base64 解码,不验证签名) */
export function decodeToken(token: string): TokenPayload {
return JSON.parse(atob(token.split('.')[1]));
}
export async function loginByWechat(code: string): Promise<LoginResponse> {
return fetchApi<LoginResponse>("/auth/wechat-login", {
method: "POST",
body: JSON.stringify({ code }),
});
}
export async function loginByDemo(): Promise<LoginResponse> {
return fetchApi<LoginResponse>("/auth/demo-login", {
method: "POST",
});
/** 开发环境:获取测试账号 token */
export async function devLogin(): Promise<{ token: string; user_id: string; openid: string; mobile: string }> {
return fetchApi("/auth/dev-login", { method: "POST" });
}
// ─── User Profile ───