JhHardwareWRS_BackPoint/app/routers/auth.py
2026-06-24 15:19:14 +08:00

310 lines
12 KiB
Python

from datetime import timedelta
from math import asin, cos, radians, sin, sqrt
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.orm import Session, selectinload
from app.database import get_db
from app.deps import current_user
from app.models import AttendancePoint, PersonRole, Personnel, Role
from app.schemas import (
LoginSelectionResponse,
PersonnelOut,
RoleSelectionRequest,
SwitchRoleRequest,
TokenResponse,
WechatLoginRequest,
)
from app.security import create_token, verify_token
from app.services.attendance_points import clean_point_name, set_person_attendance_points
from app.services.serializers import personnel_out, temporary_expired
from app.services.wechat import WechatConfigError, get_phone_number
from app.timezone import now
router = APIRouter(prefix="/api/auth", tags=["auth"])
TEMPORARY_WORKER_HOURS = 24
EARTH_RADIUS_METERS = 6371000
def _distance_meters(lat1: float, lng1: float, lat2: float, lng2: float) -> float:
delta_lat = radians(lat2 - lat1)
delta_lng = radians(lng2 - lng1)
first_lat = radians(lat1)
second_lat = radians(lat2)
value = (
sin(delta_lat / 2) * sin(delta_lat / 2)
+ cos(first_lat) * cos(second_lat) * sin(delta_lng / 2) * sin(delta_lng / 2)
)
return 2 * EARTH_RADIUS_METERS * asin(sqrt(min(1, value)))
def _require_temporary_attendance_point(
db: Session,
point_name: str | None,
latitude: float | None,
longitude: float | None,
) -> str:
name = clean_point_name(point_name)
if not name:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="创建临时工前请先进入考勤点范围")
if latitude is None or longitude is None:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="创建临时工需要校验当前位置")
point = db.get(AttendancePoint, name)
if point is None or not point.is_active:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"没有该考勤点:{name}")
if point.latitude is None or point.longitude is None:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"考勤点【{name}】未配置经纬度")
distance = _distance_meters(float(latitude), float(longitude), float(point.latitude), float(point.longitude))
radius = int(point.radius_meters or 500)
if distance > radius:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"当前位置距离考勤点【{name}】约{round(distance)}米,超过{radius}米范围,不能创建临时工",
)
return name
def _token_for(person: Personnel, role: Role) -> TokenResponse:
token = create_token({"sub": person.phone, "name": person.name, "role": str(role)})
return TokenResponse(access_token=token, user=personnel_out(person, role))
def _selection_response(person: Personnel) -> LoginSelectionResponse:
roles = [item.role for item in person.roles]
selection_token = create_token({
"type": "role_selection",
"sub": person.phone,
"roles": [str(role) for role in roles],
})
return LoginSelectionResponse(
needs_selection=True,
selection_token=selection_token,
matched_people=[personnel_out(person, role) for role in roles],
)
def _temporary_worker_token(phone: str) -> str:
return create_token({
"type": "temporary_worker_confirmation",
"sub": phone,
})
def _temporary_worker_response(phone: str) -> LoginSelectionResponse:
return LoginSelectionResponse(
needs_selection=False,
needs_temporary_worker=True,
temporary_token=_temporary_worker_token(phone),
temporary_phone=phone,
)
def _phone_from_temporary_token(token: str | None) -> str:
if not token:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="缺少临时工确认凭证")
try:
payload = verify_token(token)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="临时工确认已失效") from exc
if payload.get("type") != "temporary_worker_confirmation" or not payload.get("sub"):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="无效的临时工确认凭证")
return str(payload["sub"])
def _create_or_renew_temporary_worker(
db: Session,
phone: str,
name: str,
attendance_point_name: str,
) -> Personnel:
cleaned_name = str(name or "").strip()
if not cleaned_name:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="请输入临时工姓名")
person = db.scalar(
select(Personnel)
.options(selectinload(Personnel.roles), selectinload(Personnel.attendance_points))
.where(Personnel.phone == phone)
)
if person is None:
person = Personnel(phone=phone, name=cleaned_name)
db.add(person)
db.flush()
person.name = cleaned_name
person.is_temporary = True
person.temporary_expires_at = now() + timedelta(hours=TEMPORARY_WORKER_HOURS)
if db.get(PersonRole, {"phone": phone, "role": Role.worker}) is None:
db.add(PersonRole(phone=phone, role=Role.worker))
set_person_attendance_points(db, phone, [attendance_point_name])
db.commit()
refreshed = db.scalar(
select(Personnel)
.options(selectinload(Personnel.roles), selectinload(Personnel.attendance_points))
.where(Personnel.phone == phone)
)
if refreshed is None:
raise HTTPException(status_code=500, detail="临时工创建失败")
return refreshed
def _login_for_phone(
db: Session,
phone: str,
selected_role: Role | None = None,
) -> LoginSelectionResponse:
person = db.scalar(
select(Personnel)
.options(selectinload(Personnel.roles), selectinload(Personnel.attendance_points))
.where(Personnel.phone == phone)
)
if person is None or not person.roles:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="手机号不在人员清单中")
if temporary_expired(person):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="临时工已过期,请微信授权续期")
roles = [item.role for item in person.roles]
if selected_role is not None:
if selected_role not in roles:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="该手机号没有此角色")
token = _token_for(person, selected_role)
return LoginSelectionResponse(
needs_selection=False,
access_token=token.access_token,
user=token.user,
)
if len(roles) > 1:
return _selection_response(person)
token = _token_for(person, roles[0])
return LoginSelectionResponse(
needs_selection=False,
access_token=token.access_token,
user=token.user,
)
@router.post("/wechat-login", response_model=LoginSelectionResponse)
async def wechat_login(
payload: WechatLoginRequest,
db: Session = Depends(get_db),
) -> LoginSelectionResponse:
if payload.create_temporary_worker:
phone = _phone_from_temporary_token(payload.temporary_token)
attendance_point_name = _require_temporary_attendance_point(
db,
payload.temporary_attendance_point_name,
payload.temporary_latitude,
payload.temporary_longitude,
)
person = _create_or_renew_temporary_worker(db, phone, payload.temporary_name or "", attendance_point_name)
token = _token_for(person, Role.worker)
return LoginSelectionResponse(
needs_selection=False,
access_token=token.access_token,
user=token.user,
temporary_expires_at=person.temporary_expires_at,
)
phone = payload.phone
if not phone and payload.phone_code:
try:
phone = await get_phone_number(payload.phone_code)
except WechatConfigError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except RuntimeError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
candidates = [item.strip() for item in payload.phone_candidates if item.strip()]
if phone:
candidates.insert(0, phone.strip())
candidates = list(dict.fromkeys(candidates))
if not candidates:
raise HTTPException(status_code=400, detail="缺少手机号或手机号授权凭证")
people = db.scalars(
select(Personnel)
.options(selectinload(Personnel.roles), selectinload(Personnel.attendance_points))
.where(Personnel.phone.in_(candidates))
).all()
if not people:
return _temporary_worker_response(candidates[0])
if len(people) > 1 and phone not in [person.phone for person in people]:
return LoginSelectionResponse(
needs_selection=True,
matched_people=[
personnel_out(person, role.role)
for person in people
for role in person.roles
],
)
selected = next((person for person in people if person.phone == phone), people[0])
if not selected.roles:
return _temporary_worker_response(selected.phone)
if temporary_expired(selected):
return _temporary_worker_response(selected.phone)
return _login_for_phone(db, selected.phone, payload.selected_role)
@router.post("/select-role", response_model=TokenResponse)
def select_role(payload: RoleSelectionRequest, db: Session = Depends(get_db)) -> TokenResponse:
try:
token_payload = verify_token(payload.selection_token)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="角色选择已失效") from exc
if token_payload.get("type") != "role_selection":
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="无效的角色选择凭证")
if str(payload.role) not in token_payload.get("roles", []):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="该手机号没有此角色")
person = db.get(Personnel, token_payload.get("sub"))
if person is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="人员不存在")
if temporary_expired(person):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="临时工已过期,请微信授权续期")
if db.get(PersonRole, {"phone": person.phone, "role": payload.role}) is None:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="角色不存在")
return _token_for(person, payload.role)
@router.get("/me", response_model=PersonnelOut)
def get_me(
user: Personnel = Depends(current_user),
db: Session = Depends(get_db),
) -> PersonnelOut:
person = db.scalar(
select(Personnel)
.options(selectinload(Personnel.roles), selectinload(Personnel.attendance_points))
.where(Personnel.phone == user.phone)
)
if person is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="人员不存在")
if temporary_expired(person):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="临时工已过期,请微信授权续期")
return personnel_out(person, user.role)
@router.post("/switch-role", response_model=TokenResponse)
def switch_role(
payload: SwitchRoleRequest,
user: Personnel = Depends(current_user),
db: Session = Depends(get_db),
) -> TokenResponse:
person = db.scalar(
select(Personnel)
.options(selectinload(Personnel.roles), selectinload(Personnel.attendance_points))
.where(Personnel.phone == user.phone)
)
if person is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="人员不存在")
if temporary_expired(person):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="临时工已过期,请微信授权续期")
if db.get(PersonRole, {"phone": person.phone, "role": payload.role}) is None:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="该手机号没有此角色")
return _token_for(person, payload.role)