Files
certificate-system/backend/app/api/routes/public.py
2026-06-06 19:32:24 +08:00

139 lines
6.8 KiB
Python

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 render_certificate_pdf
from app.services.rate_limit import hit_too_often
router = APIRouter()
DISCLAIMER = "本证书仅用于证明学员完成相关培训课程/阶段学习,不代表国家学历、学位、职业资格或职业技能等级认证。"
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
@router.get("/captcha")
def get_captcha() -> dict[str, str]:
return make_captcha()
@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.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()
pdf_path = render_certificate_pdf(certificate, learner, project, access_token)
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