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)