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:
@@ -7,3 +7,6 @@ OSS_ACCESS_KEY_ID="your-oss-access-key-id"
|
|||||||
OSS_ACCESS_KEY_SECRET="your-oss-access-key-secret"
|
OSS_ACCESS_KEY_SECRET="your-oss-access-key-secret"
|
||||||
OSS_ENDPOINT="your-oss-endpoint"
|
OSS_ENDPOINT="your-oss-endpoint"
|
||||||
OSS_BUCKET_NAME="your-oss-bucket-name"
|
OSS_BUCKET_NAME="your-oss-bucket-name"
|
||||||
|
WECHAT_MINIAPP_APPID="your-miniapp-appid"
|
||||||
|
WECHAT_MINIAPP_SECRET="your-miniapp-secret"
|
||||||
|
CUSTOMER_JWT_EXPIRES_HOURS=720
|
||||||
|
|||||||
38
app/auth.py
38
app/auth.py
@@ -6,7 +6,7 @@ from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from .config import get_settings
|
from .config import get_settings
|
||||||
from .database import get_db
|
from .database import get_db
|
||||||
from .models import AdminUser
|
from .models import AdminUser, Customer
|
||||||
|
|
||||||
|
|
||||||
bearer = HTTPBearer(auto_error=False)
|
bearer = HTTPBearer(auto_error=False)
|
||||||
@@ -24,6 +24,7 @@ def create_token(user: AdminUser) -> str:
|
|||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
payload = {
|
payload = {
|
||||||
|
"typ": "admin",
|
||||||
"sub": user.id,
|
"sub": user.id,
|
||||||
"email": user.email,
|
"email": user.email,
|
||||||
"role": user.role,
|
"role": user.role,
|
||||||
@@ -33,6 +34,18 @@ def create_token(user: AdminUser) -> str:
|
|||||||
return jwt.encode(payload, settings.jwt_secret, algorithm="HS256")
|
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(
|
def require_admin(
|
||||||
request: Request,
|
request: Request,
|
||||||
credentials: HTTPAuthorizationCredentials | None = Depends(bearer),
|
credentials: HTTPAuthorizationCredentials | None = Depends(bearer),
|
||||||
@@ -44,6 +57,8 @@ def require_admin(
|
|||||||
payload = jwt.decode(credentials.credentials, get_settings().jwt_secret, algorithms=["HS256"])
|
payload = jwt.decode(credentials.credentials, get_settings().jwt_secret, algorithms=["HS256"])
|
||||||
except jwt.PyJWTError as exc:
|
except jwt.PyJWTError as exc:
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="请先登录后台") from 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_id = payload.get("sub")
|
||||||
user = db.get(AdminUser, user_id) if user_id else None
|
user = db.get(AdminUser, user_id) if user_id else None
|
||||||
if not user or not user.isActive:
|
if not user or not user.isActive:
|
||||||
@@ -52,5 +67,26 @@ def require_admin(
|
|||||||
return user
|
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:
|
def get_actor_id(request: Request) -> str | None:
|
||||||
return getattr(request.state, "actor_id", 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_access_key_secret: str | None = Field(default=None)
|
||||||
oss_endpoint: str | None = Field(default=None)
|
oss_endpoint: str | None = Field(default=None)
|
||||||
oss_bucket_name: 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")
|
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 import or_, select
|
||||||
from sqlalchemy.orm import Session, selectinload
|
from sqlalchemy.orm import Session, selectinload
|
||||||
from .shared import site_config
|
from .shared import site_config
|
||||||
|
from ..auth import create_customer_token, require_customer
|
||||||
from ..database import get_db
|
from ..database import get_db
|
||||||
from ..models import Destination, DestinationAlias, Lead, Product
|
from ..models import Customer, Destination, DestinationAlias, Lead, Product
|
||||||
from ..schemas import LeadCreateIn, ProductQuery, ProductStatus
|
from ..schemas import LeadCreateIn, PhoneLoginIn, ProductQuery, ProductStatus
|
||||||
from ..serializers import destination_dict, public_product_dict
|
from ..serializers import destination_dict, public_product_dict
|
||||||
|
from ..wechat import WechatApiError, WechatConfigError, exchange_phone_code
|
||||||
|
|
||||||
|
|
||||||
router = APIRouter()
|
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")
|
@router.get("/health")
|
||||||
def health():
|
def health():
|
||||||
return {"ok": True, "service": "miniapp-api"}
|
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]}
|
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)
|
@router.post("/api/public/leads", status_code=status.HTTP_201_CREATED)
|
||||||
def create_lead(body: LeadCreateIn, db: Session = Depends(get_db)):
|
def create_lead(body: LeadCreateIn, db: Session = Depends(get_db)):
|
||||||
lead = Lead(**body.model_dump(exclude_none=True), status="new")
|
lead = Lead(**body.model_dump(exclude_none=True), status="new")
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ class LoginIn(BaseModel):
|
|||||||
password: str = Field(min_length=6)
|
password: str = Field(min_length=6)
|
||||||
|
|
||||||
|
|
||||||
|
class PhoneLoginIn(BaseModel):
|
||||||
|
code: str = Field(min_length=1, max_length=256)
|
||||||
|
|
||||||
|
|
||||||
class ProductImageIn(BaseModel):
|
class ProductImageIn(BaseModel):
|
||||||
url: str = ""
|
url: str = ""
|
||||||
alt: str | None = None
|
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()
|
||||||
@@ -5,11 +5,12 @@ from fastapi.testclient import TestClient
|
|||||||
from pydantic import ValidationError
|
from pydantic import ValidationError
|
||||||
|
|
||||||
from app import serializers
|
from app import serializers
|
||||||
from app.auth import hash_password, require_admin
|
from app.auth import create_customer_token, hash_password, require_admin
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.main import create_app
|
from app.main import create_app
|
||||||
from app.models import AdminUser, Campaign, CtaBanner, DemandFeatureCard, DemandForm, DemandHero, DemandRecommendation, DemandRecommendationProduct, Destination, DestinationAlias, DestinationHero, DestinationRegion, HeroSlide, HotelGroup, Lead, MapImage, MediaAsset, Product, ProductImage, RouteSection, RouteSectionProduct, ThemeCard, VehicleOption
|
from app.models import AdminUser, Campaign, CtaBanner, Customer, DemandFeatureCard, DemandForm, DemandHero, DemandRecommendation, DemandRecommendationProduct, Destination, DestinationAlias, DestinationHero, DestinationRegion, HeroSlide, HotelGroup, Lead, MapImage, MediaAsset, Product, ProductImage, RouteSection, RouteSectionProduct, ThemeCard, VehicleOption
|
||||||
from app.routers import admin as admin_router
|
from app.routers import admin as admin_router
|
||||||
|
from app.routers import public as public_router
|
||||||
from app.routers.admin import normalize_detail_sections, normalize_images
|
from app.routers.admin import normalize_detail_sections, normalize_images
|
||||||
from app.routers.shared import site_config
|
from app.routers.shared import site_config
|
||||||
from app.schemas import (
|
from app.schemas import (
|
||||||
@@ -475,6 +476,65 @@ def test_public_leads_endpoint_accepts_date_only_and_returns_minimal_response():
|
|||||||
assert fake_db.added[0].travelDate == datetime(2027, 1, 1)
|
assert fake_db.added[0].travelDate == datetime(2027, 1, 1)
|
||||||
|
|
||||||
|
|
||||||
|
def test_public_phone_login_creates_customer_and_returns_masked_session(monkeypatch):
|
||||||
|
monkeypatch.setattr(public_router, "exchange_phone_code", lambda code: "10000000000", raising=False)
|
||||||
|
fake_db = FakeDb(scalar_values=[None])
|
||||||
|
app = create_app()
|
||||||
|
app.dependency_overrides[get_db] = lambda: fake_db
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = TestClient(app).post("/api/public/auth/phone-login", json={"code": "phone-code"})
|
||||||
|
finally:
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
body = response.json()
|
||||||
|
assert body["token"]
|
||||||
|
assert body["customer"] == {"id": "customer-test-id", "phoneMasked": "100****0000"}
|
||||||
|
assert fake_db.added[0].phone == "10000000000"
|
||||||
|
assert fake_db.committed
|
||||||
|
|
||||||
|
|
||||||
|
def test_public_phone_login_requires_wechat_configuration(monkeypatch):
|
||||||
|
def missing_config(_code):
|
||||||
|
raise public_router.WechatConfigError("微信小程序登录未配置")
|
||||||
|
|
||||||
|
monkeypatch.setattr(public_router, "exchange_phone_code", missing_config, raising=False)
|
||||||
|
app = create_app()
|
||||||
|
app.dependency_overrides[get_db] = lambda: FakeDb()
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = TestClient(app).post("/api/public/auth/phone-login", json={"code": "phone-code"})
|
||||||
|
finally:
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
assert response.status_code == 503
|
||||||
|
assert response.json() == {"message": "微信小程序登录未配置"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_public_me_returns_customer_for_customer_token():
|
||||||
|
customer = Customer(id="customer-test", phone="10000000000")
|
||||||
|
fake_db = FakeDb(get_result=customer)
|
||||||
|
app = create_app()
|
||||||
|
app.dependency_overrides[get_db] = lambda: fake_db
|
||||||
|
token = create_customer_token(customer)
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = TestClient(app).get("/api/public/auth/me", headers={"Authorization": f"Bearer {token}"})
|
||||||
|
finally:
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"id": "customer-test", "phoneMasked": "100****0000"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_public_me_requires_customer_token():
|
||||||
|
response = TestClient(create_app()).get("/api/public/auth/me")
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
assert response.json() == {"message": "请先登录"}
|
||||||
|
|
||||||
|
|
||||||
def test_admin_destinations_endpoint_uses_precomputed_product_counts():
|
def test_admin_destinations_endpoint_uses_precomputed_product_counts():
|
||||||
destination = make_destination()
|
destination = make_destination()
|
||||||
fake_db = FakeDb(scalar_results=[[destination]], execute_results=[[(destination.id, 7)]])
|
fake_db = FakeDb(scalar_results=[[destination]], execute_results=[[(destination.id, 7)]])
|
||||||
|
|||||||
@@ -13,3 +13,15 @@ def test_oss_settings_are_loaded_from_prefixed_environment(monkeypatch):
|
|||||||
assert settings.oss_access_key_secret == "example-access-key-secret"
|
assert settings.oss_access_key_secret == "example-access-key-secret"
|
||||||
assert settings.oss_endpoint == "oss-cn-example.aliyuncs.com"
|
assert settings.oss_endpoint == "oss-cn-example.aliyuncs.com"
|
||||||
assert settings.oss_bucket_name == "example-bucket"
|
assert settings.oss_bucket_name == "example-bucket"
|
||||||
|
|
||||||
|
|
||||||
|
def test_wechat_miniapp_settings_are_loaded_from_environment(monkeypatch):
|
||||||
|
monkeypatch.setenv("WECHAT_MINIAPP_APPID", "wx-test-appid")
|
||||||
|
monkeypatch.setenv("WECHAT_MINIAPP_SECRET", "test-secret")
|
||||||
|
monkeypatch.setenv("CUSTOMER_JWT_EXPIRES_HOURS", "24")
|
||||||
|
|
||||||
|
settings = Settings(_env_file=None)
|
||||||
|
|
||||||
|
assert settings.wechat_miniapp_appid == "wx-test-appid"
|
||||||
|
assert settings.wechat_miniapp_secret == "test-secret"
|
||||||
|
assert settings.customer_jwt_expires_hours == 24
|
||||||
|
|||||||
Reference in New Issue
Block a user