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:
2026-05-27 22:24:14 +08:00
parent bceb4f4619
commit 96a938e11b
5 changed files with 79 additions and 36 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -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",