203 lines
9.8 KiB
Python
203 lines
9.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
|
|
from app.services.sms import generate_sms_code, verify_sms_code
|
|
|
|
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()
|
|
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
|