feat(auth): add wechat miniapp customer phone login
Implement Wechat Mini Program phone number login flow for end customers: - add PhoneLoginIn Pydantic request schema - create wechat.py module for Wechat API interactions and phone code exchange - add customer JWT utilities and require_customer authentication dependency - add new public API endpoints: /api/public/auth/phone-login and /api/public/auth/me - add required environment config variables and update example .env - add comprehensive test cases for the new auth flow and endpoints
This commit is contained in:
38
app/auth.py
38
app/auth.py
@@ -6,7 +6,7 @@ from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from sqlalchemy.orm import Session
|
||||
from .config import get_settings
|
||||
from .database import get_db
|
||||
from .models import AdminUser
|
||||
from .models import AdminUser, Customer
|
||||
|
||||
|
||||
bearer = HTTPBearer(auto_error=False)
|
||||
@@ -24,6 +24,7 @@ def create_token(user: AdminUser) -> str:
|
||||
settings = get_settings()
|
||||
now = datetime.now(timezone.utc)
|
||||
payload = {
|
||||
"typ": "admin",
|
||||
"sub": user.id,
|
||||
"email": user.email,
|
||||
"role": user.role,
|
||||
@@ -33,6 +34,18 @@ def create_token(user: AdminUser) -> str:
|
||||
return jwt.encode(payload, settings.jwt_secret, algorithm="HS256")
|
||||
|
||||
|
||||
def create_customer_token(customer: Customer) -> str:
|
||||
settings = get_settings()
|
||||
now = datetime.now(timezone.utc)
|
||||
payload = {
|
||||
"typ": "customer",
|
||||
"sub": customer.id,
|
||||
"iat": now,
|
||||
"exp": now + timedelta(hours=settings.customer_jwt_expires_hours),
|
||||
}
|
||||
return jwt.encode(payload, settings.jwt_secret, algorithm="HS256")
|
||||
|
||||
|
||||
def require_admin(
|
||||
request: Request,
|
||||
credentials: HTTPAuthorizationCredentials | None = Depends(bearer),
|
||||
@@ -44,6 +57,8 @@ def require_admin(
|
||||
payload = jwt.decode(credentials.credentials, get_settings().jwt_secret, algorithms=["HS256"])
|
||||
except jwt.PyJWTError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="请先登录后台") from exc
|
||||
if payload.get("typ", "admin") != "admin":
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="请先登录后台")
|
||||
user_id = payload.get("sub")
|
||||
user = db.get(AdminUser, user_id) if user_id else None
|
||||
if not user or not user.isActive:
|
||||
@@ -52,5 +67,26 @@ def require_admin(
|
||||
return user
|
||||
|
||||
|
||||
def require_customer(
|
||||
request: Request,
|
||||
credentials: HTTPAuthorizationCredentials | None = Depends(bearer),
|
||||
db: Session = Depends(get_db),
|
||||
) -> Customer:
|
||||
if credentials is None:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="请先登录")
|
||||
try:
|
||||
payload = jwt.decode(credentials.credentials, get_settings().jwt_secret, algorithms=["HS256"])
|
||||
except jwt.PyJWTError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="请先登录") from exc
|
||||
if payload.get("typ") != "customer":
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="请先登录")
|
||||
customer_id = payload.get("sub")
|
||||
customer = db.get(Customer, customer_id) if customer_id else None
|
||||
if not customer:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="请先登录")
|
||||
request.state.actor_id = customer.id
|
||||
return customer
|
||||
|
||||
|
||||
def get_actor_id(request: Request) -> str | None:
|
||||
return getattr(request.state, "actor_id", None)
|
||||
|
||||
@@ -15,6 +15,9 @@ class Settings(BaseSettings):
|
||||
oss_access_key_secret: str | None = Field(default=None)
|
||||
oss_endpoint: str | None = Field(default=None)
|
||||
oss_bucket_name: str | None = Field(default=None)
|
||||
wechat_miniapp_appid: str | None = Field(default=None)
|
||||
wechat_miniapp_secret: str | None = Field(default=None)
|
||||
customer_jwt_expires_hours: int = Field(default=720)
|
||||
|
||||
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
|
||||
|
||||
|
||||
@@ -2,15 +2,28 @@ from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import or_, select
|
||||
from sqlalchemy.orm import Session, selectinload
|
||||
from .shared import site_config
|
||||
from ..auth import create_customer_token, require_customer
|
||||
from ..database import get_db
|
||||
from ..models import Destination, DestinationAlias, Lead, Product
|
||||
from ..schemas import LeadCreateIn, ProductQuery, ProductStatus
|
||||
from ..models import Customer, Destination, DestinationAlias, Lead, Product
|
||||
from ..schemas import LeadCreateIn, PhoneLoginIn, ProductQuery, ProductStatus
|
||||
from ..serializers import destination_dict, public_product_dict
|
||||
from ..wechat import WechatApiError, WechatConfigError, exchange_phone_code
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def mask_phone(phone: str) -> str:
|
||||
normalized = phone.strip()
|
||||
if len(normalized) <= 7:
|
||||
return "***"
|
||||
return f"{normalized[:3]}****{normalized[-4:]}"
|
||||
|
||||
|
||||
def public_customer_dict(customer: Customer) -> dict:
|
||||
return {"id": customer.id, "phoneMasked": mask_phone(customer.phone)}
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
def health():
|
||||
return {"ok": True, "service": "miniapp-api"}
|
||||
@@ -83,6 +96,30 @@ def list_destinations(db: Session = Depends(get_db)):
|
||||
return {"items": [destination_dict(destination) for destination in destinations]}
|
||||
|
||||
|
||||
@router.post("/api/public/auth/phone-login")
|
||||
def phone_login(body: PhoneLoginIn, db: Session = Depends(get_db)):
|
||||
try:
|
||||
phone = exchange_phone_code(body.code)
|
||||
except WechatConfigError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=str(exc)) from exc
|
||||
except WechatApiError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
||||
|
||||
customer = db.scalar(select(Customer).where(Customer.phone == phone))
|
||||
if not customer:
|
||||
customer = Customer(phone=phone)
|
||||
db.add(customer)
|
||||
db.flush()
|
||||
db.commit()
|
||||
db.refresh(customer)
|
||||
return {"token": create_customer_token(customer), "customer": public_customer_dict(customer)}
|
||||
|
||||
|
||||
@router.get("/api/public/auth/me")
|
||||
def current_customer(customer: Customer = Depends(require_customer)):
|
||||
return public_customer_dict(customer)
|
||||
|
||||
|
||||
@router.post("/api/public/leads", status_code=status.HTTP_201_CREATED)
|
||||
def create_lead(body: LeadCreateIn, db: Session = Depends(get_db)):
|
||||
lead = Lead(**body.model_dump(exclude_none=True), status="new")
|
||||
|
||||
@@ -12,6 +12,10 @@ class LoginIn(BaseModel):
|
||||
password: str = Field(min_length=6)
|
||||
|
||||
|
||||
class PhoneLoginIn(BaseModel):
|
||||
code: str = Field(min_length=1, max_length=256)
|
||||
|
||||
|
||||
class ProductImageIn(BaseModel):
|
||||
url: str = ""
|
||||
alt: str | None = None
|
||||
|
||||
82
app/wechat.py
Normal file
82
app/wechat.py
Normal file
@@ -0,0 +1,82 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
import httpx
|
||||
|
||||
from .config import Settings, get_settings
|
||||
|
||||
|
||||
class WechatConfigError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class WechatApiError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
_access_token_cache: dict[str, object] = {"appid": None, "token": None, "expires_at": 0.0}
|
||||
|
||||
|
||||
def _require_miniapp_settings(settings: Settings) -> tuple[str, str]:
|
||||
if not settings.wechat_miniapp_appid or not settings.wechat_miniapp_secret:
|
||||
raise WechatConfigError("微信小程序登录未配置")
|
||||
return settings.wechat_miniapp_appid, settings.wechat_miniapp_secret
|
||||
|
||||
|
||||
def _wechat_json(response: httpx.Response) -> dict:
|
||||
try:
|
||||
return response.json()
|
||||
except ValueError as exc:
|
||||
raise WechatApiError("微信登录服务返回异常") from exc
|
||||
|
||||
|
||||
def get_access_token(settings: Settings | None = None) -> str:
|
||||
settings = settings or get_settings()
|
||||
appid, secret = _require_miniapp_settings(settings)
|
||||
now = time.time()
|
||||
cached_token = _access_token_cache.get("token")
|
||||
if _access_token_cache.get("appid") == appid and isinstance(cached_token, str) and now < float(_access_token_cache.get("expires_at", 0)):
|
||||
return cached_token
|
||||
|
||||
try:
|
||||
response = httpx.get(
|
||||
"https://api.weixin.qq.com/cgi-bin/token",
|
||||
params={"grant_type": "client_credential", "appid": appid, "secret": secret},
|
||||
timeout=8,
|
||||
)
|
||||
except httpx.HTTPError as exc:
|
||||
raise WechatApiError("微信登录服务暂时不可用") from exc
|
||||
|
||||
data = _wechat_json(response)
|
||||
token = data.get("access_token")
|
||||
if not isinstance(token, str) or not token:
|
||||
raise WechatApiError("微信登录服务暂时不可用")
|
||||
expires_in = data.get("expires_in")
|
||||
ttl = int(expires_in) if isinstance(expires_in, int | str) and str(expires_in).isdigit() else 7200
|
||||
_access_token_cache.update({"appid": appid, "token": token, "expires_at": now + max(ttl - 300, 60)})
|
||||
return token
|
||||
|
||||
|
||||
def exchange_phone_code(code: str, settings: Settings | None = None) -> str:
|
||||
access_token = get_access_token(settings)
|
||||
try:
|
||||
response = httpx.post(
|
||||
"https://api.weixin.qq.com/wxa/business/getuserphonenumber",
|
||||
params={"access_token": access_token},
|
||||
json={"code": code},
|
||||
timeout=8,
|
||||
)
|
||||
except httpx.HTTPError as exc:
|
||||
raise WechatApiError("手机号授权服务暂时不可用") from exc
|
||||
|
||||
data = _wechat_json(response)
|
||||
if data.get("errcode", 0) != 0:
|
||||
raise WechatApiError("手机号授权失败,请重试")
|
||||
phone_info = data.get("phone_info")
|
||||
if not isinstance(phone_info, dict):
|
||||
raise WechatApiError("手机号授权服务返回异常")
|
||||
phone_number = phone_info.get("phoneNumber") or phone_info.get("purePhoneNumber")
|
||||
if not isinstance(phone_number, str) or not phone_number.strip():
|
||||
raise WechatApiError("手机号授权服务返回异常")
|
||||
return phone_number.strip()
|
||||
Reference in New Issue
Block a user