diff --git a/.gitignore b/.gitignore index b5b059c..ed25400 100644 --- a/.gitignore +++ b/.gitignore @@ -27,7 +27,7 @@ uploads/ # Claude Code .claude/ - +.trae/ # Dependencies node_modules/ diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index ad1183f..963cf19 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -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: - wechat_data = await code_to_session(req.code) - except Exception as e: - raise HTTPException(status_code=400, detail=str(e)) +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) 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 + user = User( + openid=DEV_OPENID, + name="开发测试用户", + phone="13800000000", + ) + await user.insert() - 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 "", ) diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py index 358d6d3..1d706d7 100644 --- a/backend/app/schemas/auth.py +++ b/backend/app/schemas/auth.py @@ -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 diff --git a/backend/serv.sh b/backend/serv.sh index d97102e..a8b476d 100755 --- a/backend/serv.sh +++ b/backend/serv.sh @@ -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" } diff --git a/frontend/src/app/context/UserContext.tsx b/frontend/src/app/context/UserContext.tsx index 2f8b886..ca61f56 100644 --- a/frontend/src/app/context/UserContext.tsx +++ b/frontend/src/app/context/UserContext.tsx @@ -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) => void; updateUserRemote: (data: Partial) => Promise; setToken: (token: string) => void; + setTokenAndUser: (token: string, info: BasicUserInfo) => void; fetchProfile: () => Promise; logout: () => void; } @@ -224,17 +232,28 @@ export function UserProvider({ children }: { children: ReactNode }) { } }, [updateUser]); - const fetchProfile = useCallback(async () => { + const fetchProfile = useCallback(async (): Promise => { 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 ( - + {children} ); diff --git a/frontend/src/app/pages/LoginPage.tsx b/frontend/src/app/pages/LoginPage.tsx index ac81719..452f0a4 100644 --- a/frontend/src/app/pages/LoginPage.tsx +++ b/frontend/src/app/pages/LoginPage.tsx @@ -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 { - 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 | 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); - } - } catch (err: unknown) { - const message = err instanceof Error ? err.message : "登录失败,请重试"; - setError(message); - } finally { - setIsLoading(false); - } - }; - - // 发送验证码 - const handleSendSms = () => { - if (phoneNumber.length !== 11) { - setError("请输入正确的11位手机号"); + try { + (window as any).wx?.miniProgram?.postMessage({ type: "NEED_TOKEN" }); + } catch { /* ignore */ } + setError("正在获取登录信息..."); 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 (
- -
- - - -

- 孕育无忧 -

-

您的专属孕期健康助手

-
- +
- - {!showPhoneLogin ? ( - /* ─── 主登录页 ─── */ - - {/* 错误提示 */} - {error && ( - - {error} - - )} - - {/* 手机号登录按钮 */} - - - {/* 分隔线 */} -
-
- 或 -
-
- - {/* 一键体验 */} - - -

- 游客模式数据不保留,建议使用手机号登录 -

- - ) : ( - /* ─── 手机号登录表单 ─── */ - - {/* 返回 */} - - -

手机号登录

-

登录后数据将绑定到您的手机号

- - {/* 错误提示 */} - {error && ( - - {error} - - )} - - {/* 手机号输入 */} -
- - 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" - /> -
- - {/* 验证码输入 */} -
- -
- 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" - /> - -
- {smsSent && ( -

- 验证码已发送,请输入 123456 -

- )} -
- - {/* 登录按钮 */} - -
- )} - - -

- 登录即表示同意 - - 用户协议 - - 和 - - 隐私政策 - -

+ - + {error ? ( +

{error}

+ ) : ( +

加载中...

+ )} +
); } diff --git a/frontend/src/app/routes.tsx b/frontend/src/app/routes.tsx index 14586b2..782e35e 100644 --- a/frontend/src/app/routes.tsx +++ b/frontend/src/app/routes.tsx @@ -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 ; + } + + if (!token) { return ; } return ; diff --git a/frontend/src/app/services/api.ts b/frontend/src/app/services/api.ts index 3df9a7e..cb3adb8 100644 --- a/frontend/src/app/services/api.ts +++ b/frontend/src/app/services/api.ts @@ -54,31 +54,21 @@ async function fetchApi(path: string, options: RequestInit = {}): Promise // ─── 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 { - return fetchApi("/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 { - return fetchApi("/auth/wechat-login", { - method: "POST", - body: JSON.stringify({ code }), - }); -} - -export async function loginByDemo(): Promise { - return fetchApi("/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 ───