from fastapi import APIRouter, Depends, HTTPException, Request, status from fastapi.responses import FileResponse from pydantic import BaseModel, Field, model_validator from sqlalchemy.orm import Session from app.core.security import hash_token from app.db.session import get_db from app.models import Certificate, CertificateAccessToken, Learner, ProjectCourse from app.services.captcha import make_captcha, verify_captcha from app.services.logs import log_action, mask_phone from app.services.pdf import PdfGenerationBusy, render_certificate_pdf from app.services.rate_limit import hit_too_often from app.services.sms import generate_sms_code, verify_sms_code from app.services.system_settings import get_pdf_generation_concurrency_limit router = APIRouter() DISCLAIMER = "本证书仅用于证明学员完成相关培训课程/阶段学习,不代表国家学历、学位、职业资格或职业技能等级认证。" class SendSmsRequest(BaseModel): phone: str = Field(min_length=1, max_length=32) captcha_id: str = Field(min_length=1, max_length=128) captcha_code: str = Field(min_length=1, max_length=16) class CertificateSearchRequest(BaseModel): certificate_no: str | None = Field(default=None, max_length=64) phone: str | None = Field(default=None, max_length=32) name: str = Field(min_length=1, max_length=64) captcha_id: str = Field(min_length=1, max_length=128) captcha_code: str = Field(min_length=1, max_length=16) @model_validator(mode="after") def certificate_no_or_phone_required(self) -> "CertificateSearchRequest": if not (self.certificate_no or "").strip() and not (self.phone or "").strip(): raise ValueError("请填写证书编号或手机号其中一项") return self class SmsSearchRequest(BaseModel): phone: str = Field(min_length=1, max_length=32) sms_id: str = Field(min_length=1, max_length=128) sms_code: str = Field(min_length=1, max_length=16) @router.get("/captcha") def get_captcha() -> dict[str, str]: return make_captcha() @router.post("/sms/send") def send_sms_code( payload: SendSmsRequest, request: Request, ) -> dict[str, str]: """发送短信验证码""" client_ip = request.client.host if request.client else "unknown" if hit_too_often(f"sms:{client_ip}", limit=5, window_seconds=60): raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail="发送过于频繁,请稍后再试") if hit_too_often(f"sms:{payload.phone}", limit=3, window_seconds=300): raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail="该手机号发送过于频繁,请稍后再试") if not verify_captcha(payload.captcha_id, payload.captcha_code): raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="图形验证码错误或已过期,请重新输入") sms_id = generate_sms_code(payload.phone) return {"sms_id": sms_id, "message": "验证码已发送"} @router.post("/certificates/search") def search_certificate( payload: CertificateSearchRequest, request: Request, db: Session = Depends(get_db), ) -> dict[str, object]: client_ip = request.client.host if request.client else "unknown" if hit_too_often(f"search:{client_ip}", limit=30, window_seconds=60): raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail="访问过于频繁,请稍后再试") if not verify_captcha(payload.captcha_id, payload.captcha_code): raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="验证码错误或已过期,请重新输入") name = payload.name.strip() certificate_no = (payload.certificate_no or "").strip().upper() phone = (payload.phone or "").strip() query = db.query(Certificate, Learner).join(Learner, Learner.id == Certificate.learner_id) if certificate_no: result = query.filter(Certificate.certificate_no == certificate_no).filter(Learner.current_name == name).all() else: result = ( query.filter(Learner.phone == phone) .filter(Learner.current_name == name) .order_by(Certificate.issue_date.desc(), Certificate.id.desc()) .all() ) if not result: log_action(db, None, "public_search_certificate", "public_access", detail={"mode": "certificate_no" if certificate_no else "phone", "name": name, "certificate_no": certificate_no or None, "phone": mask_phone(phone), "matched_count": 0, "result": "not_found"}) db.commit() raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="未查询到匹配的证书信息,请核对后重试") log_action(db, None, "public_search_certificate", "public_access", result[0][0].certificate_no, {"mode": "certificate_no" if certificate_no else "phone", "name": name, "certificate_no": certificate_no or None, "phone": mask_phone(phone), "matched_count": len(result), "result": "matched"}) db.commit() return {"items": [public_certificate_payload(db, certificate, learner) for certificate, learner in result]} @router.post("/certificates/search-by-sms") def search_certificate_by_sms( payload: SmsSearchRequest, request: Request, db: Session = Depends(get_db), ) -> dict[str, object]: """通过手机号和短信验证码查询证书""" client_ip = request.client.host if request.client else "unknown" if hit_too_often(f"search_sms:{client_ip}", limit=30, window_seconds=60): raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail="访问过于频繁,请稍后再试") if not verify_sms_code(payload.sms_id, payload.sms_code): raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="验证码错误或已过期,请重新获取") phone = payload.phone.strip() query = db.query(Certificate, Learner).join(Learner, Learner.id == Certificate.learner_id) result = ( query.filter(Learner.phone == phone) .order_by(Certificate.issue_date.desc(), Certificate.id.desc()) .all() ) if not result: log_action(db, None, "public_search_certificate_sms", "public_access", detail={"mode": "phone_sms", "phone": mask_phone(phone), "matched_count": 0, "result": "not_found"}) db.commit() raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="未查询到匹配的证书信息,请核对手机号后重试") log_action(db, None, "public_search_certificate_sms", "public_access", result[0][0].certificate_no, {"mode": "phone_sms", "phone": mask_phone(phone), "matched_count": len(result), "result": "matched"}) db.commit() return {"items": [public_certificate_payload(db, certificate, learner) for certificate, learner in result]} @router.get("/certificates/token/{token}") def get_certificate_by_token(token: str, db: Session = Depends(get_db)) -> dict[str, object]: access_token = get_active_token(db, token) certificate, learner = get_certificate_and_learner(db, access_token.certificate_id) return public_certificate_payload(db, certificate, learner) @router.get("/certificates/token/{token}/download") def download_certificate_pdf(token: str, db: Session = Depends(get_db)) -> FileResponse: access_token = get_active_token(db, token) certificate, learner = get_certificate_and_learner(db, access_token.certificate_id) if certificate.status != "valid": raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="证书已作废,不支持下载正常 PDF") project = db.query(ProjectCourse).filter(ProjectCourse.code == certificate.project_code).first() try: pdf_path = render_certificate_pdf( certificate, learner, project, access_token, concurrency_limit=get_pdf_generation_concurrency_limit(db), ) except PdfGenerationBusy as exc: raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail=str(exc)) from exc log_action(db, None, "public_download_certificate", "certificate", certificate.id, {"certificate_no": certificate.certificate_no, "learner_name": learner.current_name, "project_code": certificate.project_code}) db.commit() return FileResponse(pdf_path, media_type="application/pdf", filename=f"{certificate.certificate_no}.pdf") def public_certificate_payload(db: Session, certificate: Certificate, learner: Learner) -> dict[str, object]: token = db.get(CertificateAccessToken, certificate.public_token_id) if certificate.public_token_id else None token_value = token.token_value if token and token.status == "active" else None return { "certificate_no": certificate.certificate_no, "learner_name": learner.current_name, "certificate_name": certificate.certificate_name, "project_code": certificate.project_code, "course_name": certificate.course_name, "stage_name": certificate.stage_name, "issue_date": certificate.issue_date.isoformat(), "issuer_name": certificate.issuer_name, "status": certificate.status, "pdf_status": certificate.pdf_status, "public_token": token_value, "public_url": f"/cert/{token_value}" if token_value else None, "download_url": f"/api/public/certificates/token/{token_value}/download" if token_value and certificate.status == "valid" else None, "can_download_pdf": certificate.status == "valid" and bool(token_value), "disclaimer": DISCLAIMER, } def get_active_token(db: Session, token: str) -> CertificateAccessToken: access_token = ( db.query(CertificateAccessToken) .filter(CertificateAccessToken.token_hash == hash_token(token)) .filter(CertificateAccessToken.status == "active") .first() ) if not access_token: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="链接无效或已失效") return access_token def get_certificate_and_learner(db: Session, certificate_id: int) -> tuple[Certificate, Learner]: result = ( db.query(Certificate, Learner) .join(Learner, Learner.id == Certificate.learner_id) .filter(Certificate.id == certificate_id) .first() ) if not result: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="证书不存在") return result