310 lines
12 KiB
Python
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)
|