hku-class/backend/app/api/wechat.py
2026-05-23 14:52:29 +08:00

187 lines
6.8 KiB
Python

from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.core.deps import get_current_user
from app.db.database import get_db
from app.db.models import ClassMembership, Class_, User
from app.schemas.user import TokenResponse, build_user_out
from app.schemas.wechat import (
WeChatBindRequest,
WeChatCurrentBindRequest,
WeChatLoginRequest,
WeChatLoginResponse,
WeChatPhoneUpdateRequest,
)
from app.services.member_activation_service import validate_registration
from app.services.wechat_service import (
build_wechat_login_result,
create_bind_token,
decode_bind_token,
exchange_code_for_session,
exchange_phone_code,
get_user_by_openid,
mark_wechat_bound,
)
router = APIRouter(prefix="/api/wechat", tags=["wechat"])
async def _find_approved_member_for_wechat_bind(
db: AsyncSession,
invite_code: str,
student_id: str,
) -> tuple[User, int] | None:
result = await db.execute(
select(User, ClassMembership.class_id)
.join(ClassMembership, ClassMembership.user_id == User.id)
.join(Class_, Class_.id == ClassMembership.class_id)
.options(
selectinload(User.memberships),
selectinload(User.memberships).selectinload(ClassMembership.class_),
)
.where(
Class_.invite_code == invite_code,
User.student_id == student_id,
User.status == "approved",
)
)
row = result.first()
if row is None:
return None
return row[0], row[1]
@router.post("/login", response_model=WeChatLoginResponse)
async def wechat_login(req: WeChatLoginRequest, db: AsyncSession = Depends(get_db)):
session = await exchange_code_for_session(req.code)
openid = session["openid"]
unionid = session.get("unionid")
user = await get_user_by_openid(db, openid)
if user is None:
return WeChatLoginResponse(
binding_required=True,
bind_token=create_bind_token(openid, unionid),
)
if user.status == "inactive":
raise HTTPException(status_code=403, detail="账号尚未激活")
if user.status == "disabled":
raise HTTPException(status_code=403, detail="账号已被禁用")
return WeChatLoginResponse(**build_wechat_login_result(user))
@router.post("/bind", response_model=TokenResponse)
async def wechat_bind(req: WeChatBindRequest, db: AsyncSession = Depends(get_db)):
openid, unionid = decode_bind_token(req.bind_token)
existing = await get_user_by_openid(db, openid)
if existing is not None and existing.status != "inactive":
raise HTTPException(status_code=400, detail="该微信已绑定账号")
phone = await exchange_phone_code(req.phone_code)
activation_target = await validate_registration(
db,
req.invite_code.strip().upper(),
req.student_id.strip(),
)
approved_target = None
if activation_target is None:
approved_target = await _find_approved_member_for_wechat_bind(
db,
req.invite_code.strip().upper(),
req.student_id.strip(),
)
if activation_target is None and approved_target is None:
raise HTTPException(status_code=400, detail="邀请码或学号无效")
user, class_id = activation_target or approved_target
if existing is not None and existing.id != user.id:
raise HTTPException(status_code=400, detail="该微信已绑定其他账号")
if user.wechat_openid and user.wechat_openid != openid:
raise HTTPException(status_code=400, detail="该账号已绑定其他微信")
if user.phone and user.phone != phone:
raise HTTPException(status_code=400, detail="手机号与预留手机号不一致")
other_phone_result = await db.execute(
select(User).where(User.phone == phone, User.id != user.id, User.status != "inactive")
)
if other_phone_result.scalar_one_or_none() is not None:
raise HTTPException(status_code=400, detail="该手机号已绑定其他账号")
mark_wechat_bound(user, openid, unionid, phone)
await db.commit()
result = await db.execute(
select(User)
.options(
selectinload(User.memberships),
selectinload(User.memberships).selectinload(ClassMembership.class_),
)
.where(User.id == user.id)
)
bound_user = result.scalar_one()
bound_user.set_active_membership(class_id)
login_result = build_wechat_login_result(bound_user, class_id)
return TokenResponse(token=login_result["token"], user=login_result["user"])
@router.post("/phone", response_model=TokenResponse)
async def update_wechat_phone(
req: WeChatPhoneUpdateRequest,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
phone = await exchange_phone_code(req.phone_code)
other_result = await db.execute(
select(User).where(User.phone == phone, User.id != user.id, User.status != "inactive")
)
if other_result.scalar_one_or_none() is not None:
raise HTTPException(status_code=400, detail="该手机号已绑定其他账号")
user.phone = phone
from datetime import datetime, timezone
user.phone_verified_at = datetime.now(timezone.utc).replace(tzinfo=None)
await db.commit()
await db.refresh(user)
return TokenResponse(
token=build_wechat_login_result(user)["token"],
user=build_user_out(user),
)
@router.post("/bind-current", response_model=TokenResponse)
async def bind_current_user_wechat(
req: WeChatCurrentBindRequest,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
session = await exchange_code_for_session(req.code)
openid = session["openid"]
unionid = session.get("unionid")
existing = await get_user_by_openid(db, openid)
if existing is not None and existing.id != user.id:
raise HTTPException(status_code=400, detail="该微信已绑定其他账号")
phone = await exchange_phone_code(req.phone_code)
other_result = await db.execute(
select(User).where(User.phone == phone, User.id != user.id, User.status != "inactive")
)
if other_result.scalar_one_or_none() is not None:
raise HTTPException(status_code=400, detail="该手机号已绑定其他账号")
mark_wechat_bound(user, openid, unionid, phone)
await db.commit()
result = await db.execute(
select(User)
.options(
selectinload(User.memberships),
selectinload(User.memberships).selectinload(ClassMembership.class_),
)
.where(User.id == user.id)
)
bound_user = result.scalar_one()
login_result = build_wechat_login_result(bound_user)
return TokenResponse(token=login_result["token"], user=login_result["user"])