新增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

@@ -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))