Initial certificate system
This commit is contained in:
138
backend/app/api/routes/public.py
Normal file
138
backend/app/api/routes/public.py
Normal file
@@ -0,0 +1,138 @@
|
||||
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
|
||||
Reference in New Issue
Block a user