Files
2026-06-23 12:54:14 +08:00

213 lines
10 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 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