新增PDF生成并发设置

This commit is contained in:
2026-06-23 12:54:14 +08:00
parent 529e175d2c
commit 1e9404cc7d
27 changed files with 541 additions and 24 deletions

View File

@@ -0,0 +1,49 @@
"""add system settings
Revision ID: 20260623_0004
Revises: 20260609_0003
Create Date: 2026-06-23
"""
from datetime import datetime
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "20260623_0004"
down_revision: Union[str, None] = "20260609_0003"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"system_settings",
sa.Column("key", sa.String(length=128), nullable=False),
sa.Column("value", sa.Text(), nullable=False),
sa.Column("description", sa.String(length=255), nullable=True),
sa.Column("updated_at", sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint("key"),
)
op.bulk_insert(
sa.table(
"system_settings",
sa.column("key", sa.String),
sa.column("value", sa.Text),
sa.column("description", sa.String),
sa.column("updated_at", sa.DateTime),
),
[
{
"key": "pdf_generation_concurrency_limit",
"value": "2",
"description": "PDF证书同时生成数量限制",
"updated_at": datetime.utcnow(),
}
],
)
def downgrade() -> None:
op.drop_table("system_settings")

View File

@@ -10,6 +10,7 @@ from app.api.routes import (
admin_logs,
admin_pdf_cleanup,
admin_projects,
admin_settings,
auth,
health,
public,
@@ -27,4 +28,5 @@ api_router.include_router(admin_exports.router, prefix="/admin/exports", tags=["
api_router.include_router(admin_logs.router, prefix="/admin/logs", tags=["admin-logs"])
api_router.include_router(admin_pdf_cleanup.router, prefix="/admin/pdf-cleanup", tags=["admin-pdf-cleanup"])
api_router.include_router(admin_backups.router, prefix="/admin/backups", tags=["admin-backups"])
api_router.include_router(admin_settings.router, prefix="/admin/settings", tags=["admin-settings"])
api_router.include_router(public.router, prefix="/public", tags=["public"])

View File

@@ -10,7 +10,8 @@ from app.models import AdminUser, Certificate, CertificateAccessToken, Learner,
from app.schemas.certificate import CertificateCreate, CertificateOut
from app.services.certificate_number import build_certificate_no
from app.services.logs import log_action
from app.services.pdf import render_certificate_pdf
from app.services.pdf import PdfGenerationBusy, render_certificate_pdf
from app.services.system_settings import get_pdf_generation_concurrency_limit
router = APIRouter()
@@ -143,7 +144,16 @@ def download_certificate(
project = db.query(ProjectCourse).filter(ProjectCourse.code == certificate.project_code).first()
if not learner or not token:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Certificate data is incomplete")
pdf_path = render_certificate_pdf(certificate, learner, project, token)
try:
pdf_path = render_certificate_pdf(
certificate,
learner,
project,
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
db.commit()
return FileResponse(pdf_path, media_type="application/pdf", filename=f"{certificate.certificate_no}.pdf")

View File

@@ -0,0 +1,44 @@
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from app.api.deps import require_roles
from app.db.session import get_db
from app.models import AdminUser
from app.schemas.setting import PdfSettingsOut, PdfSettingsUpdate
from app.services.logs import log_action
from app.services.system_settings import get_pdf_settings, update_pdf_generation_concurrency_limit
router = APIRouter()
@router.get("/pdf", response_model=PdfSettingsOut)
def get_pdf_generation_settings(
db: Session = Depends(get_db),
_: AdminUser = Depends(require_roles("system_admin")),
) -> dict[str, object]:
settings = get_pdf_settings(db)
db.commit()
return settings
@router.put("/pdf", response_model=PdfSettingsOut)
def update_pdf_generation_settings(
payload: PdfSettingsUpdate,
db: Session = Depends(get_db),
admin: AdminUser = Depends(require_roles("system_admin")),
) -> dict[str, object]:
before = get_pdf_settings(db)
after = update_pdf_generation_concurrency_limit(db, payload.pdf_generation_concurrency_limit)
log_action(
db,
admin,
"update_pdf_settings",
"system_setting",
"pdf_generation_concurrency_limit",
detail={
"before": before["pdf_generation_concurrency_limit"],
"after": after["pdf_generation_concurrency_limit"],
},
)
db.commit()
return after

View File

@@ -8,9 +8,10 @@ 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.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()
@@ -150,7 +151,16 @@ def download_certificate_pdf(token: str, db: Session = Depends(get_db)) -> FileR
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)
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")

View File

@@ -4,6 +4,7 @@ from app.core.security import hash_password
from app.db.base import Base
from app.db.session import engine
from app.models import AdminUser, ProjectCourse, Role
from app.services.system_settings import ensure_default_settings
def create_tables() -> None:
@@ -47,4 +48,5 @@ def seed_defaults(db: Session, admin_username: str, admin_password: str) -> None
role_code="system_admin",
)
)
ensure_default_settings(db)
db.commit()

View File

@@ -3,6 +3,7 @@ from app.models.certificate import Certificate, CertificateAccessToken, ProjectC
from app.models.import_batch import ImportBatch, ImportBatchRow
from app.models.learner import Learner, LearnerNameHistory, LearnerPhoneHistory
from app.models.log import OperationLog
from app.models.setting import SystemSetting
__all__ = [
"AdminUser",
@@ -16,4 +17,5 @@ __all__ = [
"OperationLog",
"ProjectCourse",
"Role",
"SystemSetting",
]

View File

@@ -0,0 +1,15 @@
from datetime import datetime
from sqlalchemy import DateTime, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
class SystemSetting(Base):
__tablename__ = "system_settings"
key: Mapped[str] = mapped_column(String(128), primary_key=True)
value: Mapped[str] = mapped_column(Text)
description: Mapped[str | None] = mapped_column(String(255), nullable=True)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)

View File

@@ -0,0 +1,12 @@
from datetime import datetime
from pydantic import BaseModel, Field
class PdfSettingsOut(BaseModel):
pdf_generation_concurrency_limit: int
updated_at: datetime | None = None
class PdfSettingsUpdate(BaseModel):
pdf_generation_concurrency_limit: int = Field(ge=1, le=10)

View File

@@ -23,6 +23,7 @@ ACTION_LABELS = {
"export_certificates": "\u5bfc\u51fa\u8bc1\u4e66",
"create_database_backup": "\u521b\u5efa\u6570\u636e\u5e93\u5907\u4efd",
"restore_database_backup": "\u56de\u6eda\u6570\u636e\u5e93\u5907\u4efd",
"update_pdf_settings": "\u4fee\u6539PDF\u751f\u6210\u8bbe\u7f6e",
"public_search_certificate": "\u516c\u5f00\u67e5\u8be2\u8bc1\u4e66",
"public_download_certificate": "\u516c\u5f00\u4e0b\u8f7d\u8bc1\u4e66",
}
@@ -33,6 +34,7 @@ OBJECT_LABELS = {
"certificate": "\u8bc1\u4e66",
"import_batch": "\u5bfc\u5165\u6279\u6b21",
"database_backup": "\u6570\u636e\u5e93\u5907\u4efd",
"system_setting": "\u7cfb\u7edf\u8bbe\u7f6e",
"public_access": "\u516c\u5f00\u8bbf\u95ee",
}
HIGH_RISK_ACTIONS = {
@@ -48,6 +50,7 @@ MEDIUM_RISK_ACTIONS = {
"confirm_import_batch",
"create_certificate",
"create_database_backup",
"update_pdf_settings",
}
@@ -116,6 +119,8 @@ def log_summary(action: str, object_type: str, object_id: object, detail: dict[s
filename = detail.get("filename") or object_id
size_label = detail.get("size_label")
return f"{label}\uff1a{filename}" + (f"\uff08{size_label}\uff09" if size_label else "")
if action == "update_pdf_settings":
return f"{label}\uff1a\u5e76\u53d1\u6570 {detail.get('before')} -> {detail.get('after')}"
if action == "public_search_certificate":
mode = "\u8bc1\u4e66\u7f16\u53f7" if detail.get("mode") == "certificate_no" else "\u624b\u673a\u53f7"
return f"{label}\uff1a{detail.get('name') or '-'} \u7528{mode}\u67e5\u8be2\uff0c\u5339\u914d {detail.get('matched_count', 0)} \u6761"

View File

@@ -1,5 +1,7 @@
import threading
from datetime import date, datetime, timedelta
from pathlib import Path
from time import monotonic
from PIL import Image, ImageDraw, ImageFont
@@ -10,6 +12,35 @@ from app.models import Certificate, CertificateAccessToken, Learner, ProjectCour
DESIGN_WIDTH = 1024
DESIGN_HEIGHT = 759
PDF_BASE_RESOLUTION = 150.0
PDF_GENERATION_WAIT_TIMEOUT_SECONDS = 120
class PdfGenerationBusy(RuntimeError):
pass
class PdfGenerationLimiter:
def __init__(self) -> None:
self._condition = threading.Condition()
self._active = 0
def acquire(self, limit: int, timeout_seconds: int = PDF_GENERATION_WAIT_TIMEOUT_SECONDS) -> None:
deadline = monotonic() + timeout_seconds
with self._condition:
while self._active >= limit:
remaining = deadline - monotonic()
if remaining <= 0:
raise PdfGenerationBusy("当前PDF生成任务较多请稍后重试")
self._condition.wait(remaining)
self._active += 1
def release(self) -> None:
with self._condition:
self._active = max(0, self._active - 1)
self._condition.notify_all()
pdf_generation_limiter = PdfGenerationLimiter()
def pdf_cache_path(certificate_no: str) -> Path:
@@ -51,6 +82,7 @@ def render_certificate_pdf(
learner: Learner,
project: ProjectCourse | None,
token: CertificateAccessToken,
concurrency_limit: int = 2,
) -> Path:
output_path = pdf_cache_path(certificate.certificate_no)
template_path = certificate_template_path()
@@ -60,8 +92,15 @@ def render_certificate_pdf(
update_pdf_access_time(certificate)
return output_path
image = render_certificate_image(certificate, learner, project)
image.save(output_path, "PDF", resolution=pdf_resolution(image))
pdf_generation_limiter.acquire(max(1, concurrency_limit))
try:
if cached_pdf_is_fresh(output_path) and output_path.stat().st_mtime >= latest_renderer_mtime:
update_pdf_access_time(certificate)
return output_path
image = render_certificate_image(certificate, learner, project)
image.save(output_path, "PDF", resolution=pdf_resolution(image))
finally:
pdf_generation_limiter.release()
certificate.pdf_status = "generated"
certificate.pdf_file_path = str(output_path)

View File

@@ -0,0 +1,64 @@
from datetime import datetime
from sqlalchemy.orm import Session
from app.models import SystemSetting
PDF_GENERATION_CONCURRENCY_KEY = "pdf_generation_concurrency_limit"
DEFAULT_PDF_GENERATION_CONCURRENCY = 2
MIN_PDF_GENERATION_CONCURRENCY = 1
MAX_PDF_GENERATION_CONCURRENCY = 10
def ensure_default_settings(db: Session) -> None:
if not db.get(SystemSetting, PDF_GENERATION_CONCURRENCY_KEY):
db.add(
SystemSetting(
key=PDF_GENERATION_CONCURRENCY_KEY,
value=str(DEFAULT_PDF_GENERATION_CONCURRENCY),
description="PDF证书同时生成数量限制",
)
)
def get_pdf_generation_concurrency_limit(db: Session) -> int:
setting = db.get(SystemSetting, PDF_GENERATION_CONCURRENCY_KEY)
return _coerce_limit(setting.value if setting else None)
def get_pdf_settings(db: Session) -> dict[str, object]:
ensure_default_settings(db)
setting = db.get(SystemSetting, PDF_GENERATION_CONCURRENCY_KEY)
return {
"pdf_generation_concurrency_limit": get_pdf_generation_concurrency_limit(db),
"updated_at": setting.updated_at if setting else None,
}
def update_pdf_generation_concurrency_limit(db: Session, value: int) -> dict[str, object]:
limit = _coerce_limit(value)
ensure_default_settings(db)
setting = db.get(SystemSetting, PDF_GENERATION_CONCURRENCY_KEY)
if setting is None:
setting = SystemSetting(
key=PDF_GENERATION_CONCURRENCY_KEY,
value=str(limit),
description="PDF证书同时生成数量限制",
)
db.add(setting)
else:
setting.value = str(limit)
setting.updated_at = datetime.utcnow()
db.flush()
return {
"pdf_generation_concurrency_limit": limit,
"updated_at": setting.updated_at,
}
def _coerce_limit(value: object) -> int:
try:
limit = int(value)
except (TypeError, ValueError):
limit = DEFAULT_PDF_GENERATION_CONCURRENCY
return max(MIN_PDF_GENERATION_CONCURRENCY, min(MAX_PDF_GENERATION_CONCURRENCY, limit))

View File

@@ -0,0 +1,13 @@
import pytest
from app.services.pdf import PdfGenerationBusy, PdfGenerationLimiter
def test_pdf_generation_limiter_times_out_when_full():
limiter = PdfGenerationLimiter()
limiter.acquire(limit=1)
with pytest.raises(PdfGenerationBusy):
limiter.acquire(limit=1, timeout_seconds=0)
limiter.release()
limiter.acquire(limit=1, timeout_seconds=0)
limiter.release()

View File

@@ -0,0 +1,8 @@
from app.services.system_settings import _coerce_limit
def test_pdf_concurrency_limit_is_clamped():
assert _coerce_limit(None) == 2
assert _coerce_limit(0) == 1
assert _coerce_limit(6) == 6
assert _coerce_limit(99) == 10