feat: openid-login + avatar_url 修复
- 后端: 新增 /auth/openid-login 接口(调 CRMEB 补齐用户信息) - 后端: config.py 新增 crmeb_base_url / crmeb_api_key 配置 - 后端: api.ts 新增 openid-login 接口声明 - 前端: LoginPage 改为走 openid-login(openid 单参登录) - 前端: UserContext 修复 avatar_url 未映射导致头像不显示的问题
This commit is contained in:
@@ -32,7 +32,11 @@ class Settings(BaseSettings):
|
|||||||
max_file_size: int = 10 * 1024 * 1024 # 10MB
|
max_file_size: int = 10 * 1024 * 1024 # 10MB
|
||||||
|
|
||||||
# CORS
|
# CORS
|
||||||
cors_origins: str = "*" # 多个域名用逗号分隔,如 https://a.com,https://b.com
|
cors_origins: str = "*"
|
||||||
|
|
||||||
|
# CRMEB internal API
|
||||||
|
crmeb_base_url: str = ""
|
||||||
|
crmeb_api_key: str = ""
|
||||||
|
|
||||||
# Server
|
# Server
|
||||||
host: str = "0.0.0.0"
|
host: str = "0.0.0.0"
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import os
|
|||||||
|
|
||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, HTTPException
|
||||||
from jose import jwt
|
from jose import jwt
|
||||||
|
import httpx
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
@@ -101,6 +102,57 @@ async def params_login(req: ParamsLoginRequest) -> TokenResponse:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class OpenidRequest(BaseModel):
|
||||||
|
openid: str
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/openid-login")
|
||||||
|
async def openid_login(req: OpenidRequest) -> TokenResponse:
|
||||||
|
"""仅传 openid → 调 CRMEB 获取用户信息 → 创建/更新 User → 返回 token"""
|
||||||
|
if not settings.crmeb_base_url or not settings.crmeb_api_key:
|
||||||
|
raise HTTPException(status_code=500, detail="CRMEB 未配置")
|
||||||
|
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
resp = await client.get(
|
||||||
|
f"{settings.crmeb_base_url}/api/internal/user-info",
|
||||||
|
params={"openid": req.openid},
|
||||||
|
headers={"X-Internal-Key": settings.crmeb_api_key},
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
data = resp.json()
|
||||||
|
|
||||||
|
if data.get("code") != 200:
|
||||||
|
raise HTTPException(status_code=400, detail="获取用户信息失败")
|
||||||
|
|
||||||
|
info = data["data"]
|
||||||
|
|
||||||
|
# 查找或创建用户
|
||||||
|
user = await User.find_one(User.openid == req.openid)
|
||||||
|
if not user:
|
||||||
|
user = User(
|
||||||
|
openid=req.openid,
|
||||||
|
name=info.get("nickname", "") or "",
|
||||||
|
phone=info.get("phone") or None,
|
||||||
|
avatar_url=info.get("avatar") or None,
|
||||||
|
)
|
||||||
|
await user.insert()
|
||||||
|
else:
|
||||||
|
user.name = info.get("nickname", "") or user.name
|
||||||
|
if info.get("phone"):
|
||||||
|
user.phone = info["phone"]
|
||||||
|
if info.get("avatar"):
|
||||||
|
user.avatar_url = info["avatar"]
|
||||||
|
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")
|
@router.post("/dev-login")
|
||||||
async def dev_login() -> TokenResponse:
|
async def dev_login() -> TokenResponse:
|
||||||
"""开发环境:使用固定测试账号,不依赖微信"""
|
"""开发环境:使用固定测试账号,不依赖微信"""
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export interface UserProfile {
|
|||||||
name: string;
|
name: string;
|
||||||
age: number | null;
|
age: number | null;
|
||||||
phone: string;
|
phone: string;
|
||||||
|
avatar_url: string | null;
|
||||||
lastPeriodDate: string; // YYYY-MM-DD
|
lastPeriodDate: string; // YYYY-MM-DD
|
||||||
dueDate: string;
|
dueDate: string;
|
||||||
currentWeek: number;
|
currentWeek: number;
|
||||||
@@ -35,6 +36,7 @@ const DEFAULT_PROFILE: UserProfile = {
|
|||||||
name: "",
|
name: "",
|
||||||
age: null,
|
age: null,
|
||||||
phone: "",
|
phone: "",
|
||||||
|
avatar_url: null,
|
||||||
lastPeriodDate: "",
|
lastPeriodDate: "",
|
||||||
dueDate: "",
|
dueDate: "",
|
||||||
currentWeek: 0,
|
currentWeek: 0,
|
||||||
@@ -154,6 +156,7 @@ function mapDtoToProfile(dto: UserProfileDTO): UserProfile {
|
|||||||
name: dto.name || "",
|
name: dto.name || "",
|
||||||
age: dto.age,
|
age: dto.age,
|
||||||
phone: dto.phone || "",
|
phone: dto.phone || "",
|
||||||
|
avatar_url: dto.avatar_url || null,
|
||||||
lastPeriodDate: lmpDate,
|
lastPeriodDate: lmpDate,
|
||||||
dueDate,
|
dueDate,
|
||||||
currentWeek,
|
currentWeek,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useNavigate } from "react-router";
|
|||||||
import { motion } from "motion/react";
|
import { motion } from "motion/react";
|
||||||
import { Heart } from "lucide-react";
|
import { Heart } from "lucide-react";
|
||||||
import { useUser } from "../context/UserContext";
|
import { useUser } from "../context/UserContext";
|
||||||
import { devLogin, createDummyToken, updateUserProfile } from "../services/api";
|
import { devLogin, fetchApi } from "../services/api";
|
||||||
import { debugLog, clearDebugLogs } from "../services/debug";
|
import { debugLog, clearDebugLogs } from "../services/debug";
|
||||||
|
|
||||||
export function LoginPage() {
|
export function LoginPage() {
|
||||||
@@ -20,39 +20,23 @@ export function LoginPage() {
|
|||||||
|
|
||||||
const openidParam = params.get('openid');
|
const openidParam = params.get('openid');
|
||||||
if (openidParam && openidParam.trim()) {
|
if (openidParam && openidParam.trim()) {
|
||||||
const uid = parseInt(params.get('uid') || '0');
|
debugLog(`openid: ${openidParam}`);
|
||||||
const phone = params.get('phone') || '';
|
fetchApi("/auth/openid-login", {
|
||||||
const token = createDummyToken(uid, openidParam, phone);
|
method: "POST",
|
||||||
debugLog(`dummy token: ${token.substring(0, 50)}...`);
|
body: JSON.stringify({ openid: openidParam }),
|
||||||
|
}).then((res: any) => {
|
||||||
try {
|
debugLog(`openid-login OK: id=${res.user_id}, phone=${res.mobile}`);
|
||||||
const payload = JSON.parse(atob(token.split('.')[1]));
|
setTokenAndUser(res.token, { id: res.user_id, openid: res.openid, phone: res.mobile });
|
||||||
debugLog(`payload: uid=${payload.uid}, openid=${payload.openid}, phone=${payload.phone}`);
|
fetchProfile().then((dto) => {
|
||||||
} catch (e: any) {
|
if (dto) debugLog(`fetchProfile: name=${dto.name}, phone=${dto.phone}, onboarded=${dto.is_onboarded}`);
|
||||||
debugLog(`decode error: ${e.message}`);
|
navigate(dto?.is_onboarded ? "/home" : "/onboarding", { replace: true });
|
||||||
}
|
}).catch((e: any) => {
|
||||||
|
debugLog(`fetchProfile error: ${e?.message}`);
|
||||||
setTokenAndUser(token, { id: String(uid), openid: openidParam, phone });
|
setError("登录已失效,请在小程序重新进入");
|
||||||
debugLog(`setTokenAndUser: id=${uid}, openid=${openidParam}, phone=${phone}`);
|
});
|
||||||
|
|
||||||
// 同步昵称和头像到后端
|
|
||||||
const nameParam = params.get('name');
|
|
||||||
const avatarParam = params.get('avatar');
|
|
||||||
if (nameParam || avatarParam) {
|
|
||||||
const updateData: Record<string, unknown> = {};
|
|
||||||
if (nameParam) updateData.name = decodeURIComponent(nameParam);
|
|
||||||
if (avatarParam) updateData.avatar_url = decodeURIComponent(avatarParam);
|
|
||||||
updateUserProfile(updateData).then(() => {
|
|
||||||
debugLog(`synced: name=${nameParam}, avatar=${(avatarParam || '').substring(0, 30)}...`);
|
|
||||||
}).catch((e) => debugLog(`sync error: ${e.message}`));
|
|
||||||
}
|
|
||||||
|
|
||||||
fetchProfile().then((dto) => {
|
|
||||||
if (dto) debugLog(`fetchProfile: name=${dto.name}, phone=${dto.phone}, onboarded=${dto.is_onboarded}`);
|
|
||||||
navigate(dto?.is_onboarded ? "/home" : "/onboarding", { replace: true });
|
|
||||||
}).catch((e: any) => {
|
}).catch((e: any) => {
|
||||||
debugLog(`fetchProfile error: ${e?.message}`);
|
debugLog(`openid-login error: ${e?.message}`);
|
||||||
setError("登录已失效,请在小程序重新进入");
|
setError("登录失败: " + (e?.message || ""));
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,8 +26,8 @@ export function setToken(token: string) {
|
|||||||
export function clearToken() {
|
export function clearToken() {
|
||||||
localStorage.removeItem(TOKEN_KEY);
|
localStorage.removeItem(TOKEN_KEY);
|
||||||
}
|
}
|
||||||
|
/** 通用 API 请求封装 */
|
||||||
async function fetchApi<T>(path: string, options: RequestInit = {}): Promise<T> {
|
export async function fetchApi<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||||
const token = getToken();
|
const token = getToken();
|
||||||
const headers: Record<string, string> = {
|
const headers: Record<string, string> = {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
|
|||||||
Reference in New Issue
Block a user