新增PDF生成并发设置
This commit is contained in:
49
backend/alembic/versions/20260623_0004_system_settings.py
Normal file
49
backend/alembic/versions/20260623_0004_system_settings.py
Normal 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")
|
||||
@@ -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"])
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
44
backend/app/api/routes/admin_settings.py
Normal file
44
backend/app/api/routes/admin_settings.py
Normal 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
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
15
backend/app/models/setting.py
Normal file
15
backend/app/models/setting.py
Normal 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)
|
||||
12
backend/app/schemas/setting.py
Normal file
12
backend/app/schemas/setting.py
Normal 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)
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
64
backend/app/services/system_settings.py
Normal file
64
backend/app/services/system_settings.py
Normal 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))
|
||||
13
backend/tests/test_pdf_limiter.py
Normal file
13
backend/tests/test_pdf_limiter.py
Normal 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()
|
||||
8
backend/tests/test_system_settings.py
Normal file
8
backend/tests/test_system_settings.py
Normal 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
|
||||
Reference in New Issue
Block a user