新增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_logs,
admin_pdf_cleanup, admin_pdf_cleanup,
admin_projects, admin_projects,
admin_settings,
auth, auth,
health, health,
public, 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_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_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_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"]) 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.schemas.certificate import CertificateCreate, CertificateOut
from app.services.certificate_number import build_certificate_no from app.services.certificate_number import build_certificate_no
from app.services.logs import log_action 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() router = APIRouter()
@@ -143,7 +144,16 @@ def download_certificate(
project = db.query(ProjectCourse).filter(ProjectCourse.code == certificate.project_code).first() project = db.query(ProjectCourse).filter(ProjectCourse.code == certificate.project_code).first()
if not learner or not token: if not learner or not token:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Certificate data is incomplete") 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() db.commit()
return FileResponse(pdf_path, media_type="application/pdf", filename=f"{certificate.certificate_no}.pdf") 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.models import Certificate, CertificateAccessToken, Learner, ProjectCourse
from app.services.captcha import make_captcha, verify_captcha from app.services.captcha import make_captcha, verify_captcha
from app.services.logs import log_action, mask_phone 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.rate_limit import hit_too_often
from app.services.sms import generate_sms_code, verify_sms_code from app.services.sms import generate_sms_code, verify_sms_code
from app.services.system_settings import get_pdf_generation_concurrency_limit
router = APIRouter() 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") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="证书已作废,不支持下载正常 PDF")
project = db.query(ProjectCourse).filter(ProjectCourse.code == certificate.project_code).first() 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}) 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() db.commit()
return FileResponse(pdf_path, media_type="application/pdf", filename=f"{certificate.certificate_no}.pdf") 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.base import Base
from app.db.session import engine from app.db.session import engine
from app.models import AdminUser, ProjectCourse, Role from app.models import AdminUser, ProjectCourse, Role
from app.services.system_settings import ensure_default_settings
def create_tables() -> None: def create_tables() -> None:
@@ -47,4 +48,5 @@ def seed_defaults(db: Session, admin_username: str, admin_password: str) -> None
role_code="system_admin", role_code="system_admin",
) )
) )
ensure_default_settings(db)
db.commit() 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.import_batch import ImportBatch, ImportBatchRow
from app.models.learner import Learner, LearnerNameHistory, LearnerPhoneHistory from app.models.learner import Learner, LearnerNameHistory, LearnerPhoneHistory
from app.models.log import OperationLog from app.models.log import OperationLog
from app.models.setting import SystemSetting
__all__ = [ __all__ = [
"AdminUser", "AdminUser",
@@ -16,4 +17,5 @@ __all__ = [
"OperationLog", "OperationLog",
"ProjectCourse", "ProjectCourse",
"Role", "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", "export_certificates": "\u5bfc\u51fa\u8bc1\u4e66",
"create_database_backup": "\u521b\u5efa\u6570\u636e\u5e93\u5907\u4efd", "create_database_backup": "\u521b\u5efa\u6570\u636e\u5e93\u5907\u4efd",
"restore_database_backup": "\u56de\u6eda\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_search_certificate": "\u516c\u5f00\u67e5\u8be2\u8bc1\u4e66",
"public_download_certificate": "\u516c\u5f00\u4e0b\u8f7d\u8bc1\u4e66", "public_download_certificate": "\u516c\u5f00\u4e0b\u8f7d\u8bc1\u4e66",
} }
@@ -33,6 +34,7 @@ OBJECT_LABELS = {
"certificate": "\u8bc1\u4e66", "certificate": "\u8bc1\u4e66",
"import_batch": "\u5bfc\u5165\u6279\u6b21", "import_batch": "\u5bfc\u5165\u6279\u6b21",
"database_backup": "\u6570\u636e\u5e93\u5907\u4efd", "database_backup": "\u6570\u636e\u5e93\u5907\u4efd",
"system_setting": "\u7cfb\u7edf\u8bbe\u7f6e",
"public_access": "\u516c\u5f00\u8bbf\u95ee", "public_access": "\u516c\u5f00\u8bbf\u95ee",
} }
HIGH_RISK_ACTIONS = { HIGH_RISK_ACTIONS = {
@@ -48,6 +50,7 @@ MEDIUM_RISK_ACTIONS = {
"confirm_import_batch", "confirm_import_batch",
"create_certificate", "create_certificate",
"create_database_backup", "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 filename = detail.get("filename") or object_id
size_label = detail.get("size_label") size_label = detail.get("size_label")
return f"{label}\uff1a{filename}" + (f"\uff08{size_label}\uff09" if size_label else "") 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": if action == "public_search_certificate":
mode = "\u8bc1\u4e66\u7f16\u53f7" if detail.get("mode") == "certificate_no" else "\u624b\u673a\u53f7" 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" 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 datetime import date, datetime, timedelta
from pathlib import Path from pathlib import Path
from time import monotonic
from PIL import Image, ImageDraw, ImageFont from PIL import Image, ImageDraw, ImageFont
@@ -10,6 +12,35 @@ from app.models import Certificate, CertificateAccessToken, Learner, ProjectCour
DESIGN_WIDTH = 1024 DESIGN_WIDTH = 1024
DESIGN_HEIGHT = 759 DESIGN_HEIGHT = 759
PDF_BASE_RESOLUTION = 150.0 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: def pdf_cache_path(certificate_no: str) -> Path:
@@ -51,6 +82,7 @@ def render_certificate_pdf(
learner: Learner, learner: Learner,
project: ProjectCourse | None, project: ProjectCourse | None,
token: CertificateAccessToken, token: CertificateAccessToken,
concurrency_limit: int = 2,
) -> Path: ) -> Path:
output_path = pdf_cache_path(certificate.certificate_no) output_path = pdf_cache_path(certificate.certificate_no)
template_path = certificate_template_path() template_path = certificate_template_path()
@@ -60,8 +92,15 @@ def render_certificate_pdf(
update_pdf_access_time(certificate) update_pdf_access_time(certificate)
return output_path return output_path
image = render_certificate_image(certificate, learner, project) pdf_generation_limiter.acquire(max(1, concurrency_limit))
image.save(output_path, "PDF", resolution=pdf_resolution(image)) 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_status = "generated"
certificate.pdf_file_path = str(output_path) 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

View File

@@ -36,6 +36,7 @@ docker compose exec backend python -m app.cli init-db --admin-username admin --a
- 项目代码已维护,例如 `DBY``QSX` - 项目代码已维护,例如 `DBY``QSX`
- 发证单位名称已确认。 - 发证单位名称已确认。
- 系统设置里的 `PDF 生成并发限制` 已确认2 核 4G 建议保持默认 `2`
- Excel 导入模板已下载并试填。 - Excel 导入模板已下载并试填。
- 占位证书模板已确认可临时使用。 - 占位证书模板已确认可临时使用。

View File

@@ -44,7 +44,8 @@ reset-local-data.bat
4. 进入“证书管理”手动新增证书支持单张预览、PDF 下载、作废、重置直达链接和导出。 4. 进入“证书管理”手动新增证书支持单张预览、PDF 下载、作废、重置直达链接和导出。
5. 进入“Excel 导入”,下载模板、上传 Excel、查看校验结果确认后正式导入。模板只需要填写姓名、手机号、项目代码、发证日期证书名称、课程名称、阶段名称、发证单位从项目配置自动带出。 5. 进入“Excel 导入”,下载模板、上传 Excel、查看校验结果确认后正式导入。模板只需要填写姓名、手机号、项目代码、发证日期证书名称、课程名称、阶段名称、发证单位从项目配置自动带出。
“确认导入”表示把已校验通过的临时数据正式写入学员和证书表。导入记录支持下载原文件、下载错误报告和删除;删除导入记录只删除上传文件、错误报告和批次记录,不删除已经生成的学员和证书。 “确认导入”表示把已校验通过的临时数据正式写入学员和证书表。导入记录支持下载原文件、下载错误报告和删除;删除导入记录只删除上传文件、错误报告和批次记录,不删除已经生成的学员和证书。
6. 进入“操作日志”,查看关键后台操作。日志动作已转成中文显示 6. 进入“系统设置”,确认 `PDF 生成并发限制`。2 核 4G 服务器建议保持默认值 `2`
7. 进入“操作日志”,查看关键后台操作。日志动作已转成中文显示。
## 公开查询流程 ## 公开查询流程
@@ -62,6 +63,8 @@ PDF 由服务端按需生成,并使用 30 天缓存。这样可以保证模板
后台 PDF 下载不能用简单的 `window.location` 直接跳转,因为后台接口需要登录态。如果直接打开下载地址,浏览器不会自动带上前端保存的 Authorization token容易出现下载失败。当前后台已改为通过带 token 的请求下载 blob 文件,再在浏览器里保存。 后台 PDF 下载不能用简单的 `window.location` 直接跳转,因为后台接口需要登录态。如果直接打开下载地址,浏览器不会自动带上前端保存的 Authorization token容易出现下载失败。当前后台已改为通过带 token 的请求下载 blob 文件,再在浏览器里保存。
首次生成 PDF 会占用 CPU。后台“系统设置”里的 `PDF 生成并发限制` 默认是 `2`,用于防止多人同时首次下载 PDF 时把 2 核 4G 服务器打满。前端下载和预览时会显示“PDF正在排队或生成”避免用户误以为页面卡死。
## 正式部署准备 ## 正式部署准备
- Linux 服务器,建议 2 核 4G 起步。 - Linux 服务器,建议 2 核 4G 起步。

View File

@@ -394,6 +394,7 @@ docker compose exec backend python -m app.cli init-db --admin-username admin --a
- 创建基础角色。 - 创建基础角色。
- 创建默认管理员。 - 创建默认管理员。
- 初始化默认项目配置。 - 初始化默认项目配置。
- 初始化系统设置,`PDF 生成并发限制` 默认值为 `2`
以后升级代码时,一般执行: 以后升级代码时,一般执行:

View File

@@ -367,6 +367,7 @@ docker compose exec backend python -m app.cli init-db --admin-username admin --a
- 创建基础角色。 - 创建基础角色。
- 创建默认管理员。 - 创建默认管理员。
- 初始化默认项目配置。 - 初始化默认项目配置。
- 初始化系统设置,`PDF 生成并发限制` 默认值为 `2`
如果后续只是升级代码,一般执行: 如果后续只是升级代码,一般执行:

View File

@@ -119,3 +119,8 @@ export interface RestoreDatabaseBackupResult {
restored_backup: DatabaseBackup; restored_backup: DatabaseBackup;
pre_restore_backup: DatabaseBackup; pre_restore_backup: DatabaseBackup;
} }
export interface PdfSettings {
pdf_generation_concurrency_limit: number;
updated_at: string | null;
}

View File

@@ -2,9 +2,26 @@ import { http } from "./api";
export async function downloadFile(url: string, filename: string) { export async function downloadFile(url: string, filename: string) {
const { data } = await http.get(url, { responseType: "blob" }); const { data } = await http.get(url, { responseType: "blob" });
saveBlob(data, filename);
}
export function saveBlob(data: Blob, filename: string) {
const link = document.createElement("a"); const link = document.createElement("a");
link.href = URL.createObjectURL(data); link.href = URL.createObjectURL(data);
link.download = filename; link.download = filename;
link.click(); link.click();
URL.revokeObjectURL(link.href); URL.revokeObjectURL(link.href);
} }
export async function apiErrorMessage(error: any, fallback: string) {
const data = error?.response?.data;
if (data instanceof Blob) {
const text = await data.text();
try {
return JSON.parse(text).detail || fallback;
} catch {
return text || fallback;
}
}
return data?.detail || fallback;
}

View File

@@ -8,6 +8,7 @@ import AdminLogin from "../views/AdminLogin.vue";
import AdminLogs from "../views/AdminLogs.vue"; import AdminLogs from "../views/AdminLogs.vue";
import AdminLearners from "../views/AdminLearners.vue"; import AdminLearners from "../views/AdminLearners.vue";
import AdminProjects from "../views/AdminProjects.vue"; import AdminProjects from "../views/AdminProjects.vue";
import AdminSettings from "../views/AdminSettings.vue";
import AdminShell from "../views/AdminShell.vue"; import AdminShell from "../views/AdminShell.vue";
import CertificateQuery from "../views/CertificateQuery.vue"; import CertificateQuery from "../views/CertificateQuery.vue";
import CertificateView from "../views/CertificateView.vue"; import CertificateView from "../views/CertificateView.vue";
@@ -28,7 +29,8 @@ export const router = createRouter({
{ path: "certificates", component: AdminCertificates }, { path: "certificates", component: AdminCertificates },
{ path: "imports", component: AdminImports }, { path: "imports", component: AdminImports },
{ path: "logs", component: AdminLogs }, { path: "logs", component: AdminLogs },
{ path: "backups", component: AdminBackups } { path: "backups", component: AdminBackups },
{ path: "settings", component: AdminSettings }
] ]
}, },
{ path: "/query", component: CertificateQuery }, { path: "/query", component: CertificateQuery },

View File

@@ -63,7 +63,7 @@
<template #default="{ row }"> <template #default="{ row }">
<div class="table-actions"> <div class="table-actions">
<el-button size="small" @click="previewCertificate(row)">预览</el-button> <el-button size="small" @click="previewCertificate(row)">预览</el-button>
<el-button size="small" @click="downloadCertificate(row)">下载</el-button> <el-button size="small" :loading="downloadingCertificateId === row.id" @click="downloadCertificate(row)">下载</el-button>
<el-button size="small" @click="regenerateToken(row)">重置链接</el-button> <el-button size="small" @click="regenerateToken(row)">重置链接</el-button>
<el-button v-if="row.status === 'valid'" size="small" type="danger" @click="voidCertificate(row)">作废</el-button> <el-button v-if="row.status === 'valid'" size="small" type="danger" @click="voidCertificate(row)">作废</el-button>
</div> </div>
@@ -94,11 +94,11 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ElMessage, ElMessageBox } from "element-plus"; import { ElLoading, ElMessage, ElMessageBox } from "element-plus";
import { computed, onMounted, reactive, ref } from "vue"; import { computed, onMounted, reactive, ref } from "vue";
import { http, type AdminCertificate, type ProjectCourse } from "../api"; import { http, type AdminCertificate, type ProjectCourse } from "../api";
import { downloadFile } from "../download"; import { apiErrorMessage, downloadFile } from "../download";
const certificates = ref<AdminCertificate[]>([]); const certificates = ref<AdminCertificate[]>([]);
const projects = ref<ProjectCourse[]>([]); const projects = ref<ProjectCourse[]>([]);
@@ -106,6 +106,7 @@ const keyword = ref("");
const statusValue = ref(""); const statusValue = ref("");
const previewVisible = ref(false); const previewVisible = ref(false);
const preview = ref<any>(null); const preview = ref<any>(null);
const downloadingCertificateId = ref<number | null>(null);
const form = reactive({ const form = reactive({
learner_id: undefined as number | undefined, learner_id: undefined as number | undefined,
project_code: "", project_code: "",
@@ -159,7 +160,20 @@ async function previewCertificate(row: AdminCertificate) {
} }
async function downloadCertificate(row: AdminCertificate) { async function downloadCertificate(row: AdminCertificate) {
await downloadFile(`/admin/certificates/${row.id}/download`, `${row.certificate_no}.pdf`); downloadingCertificateId.value = row.id;
const loading = ElLoading.service({
lock: true,
text: "PDF正在排队或生成请稍候...",
background: "rgba(255, 255, 255, 0.72)",
});
try {
await downloadFile(`/admin/certificates/${row.id}/download`, `${row.certificate_no}.pdf`);
} catch (error: any) {
ElMessage.error(await apiErrorMessage(error, "PDF生成失败请稍后再试"));
} finally {
loading.close();
downloadingCertificateId.value = null;
}
} }
async function voidCertificate(row: AdminCertificate) { async function voidCertificate(row: AdminCertificate) {

View File

@@ -25,6 +25,7 @@
<el-option label="证书" value="certificate" /> <el-option label="证书" value="certificate" />
<el-option label="导入批次" value="import_batch" /> <el-option label="导入批次" value="import_batch" />
<el-option label="数据库备份" value="database_backup" /> <el-option label="数据库备份" value="database_backup" />
<el-option label="系统设置" value="system_setting" />
<el-option label="公开访问" value="public_access" /> <el-option label="公开访问" value="public_access" />
</el-select> </el-select>
<el-select v-model="filters.risk" clearable placeholder="风险等级"> <el-select v-model="filters.risk" clearable placeholder="风险等级">
@@ -122,6 +123,7 @@ const actionOptions = [
{ value: "export_certificates", label: "导出证书" }, { value: "export_certificates", label: "导出证书" },
{ value: "create_database_backup", label: "创建数据库备份" }, { value: "create_database_backup", label: "创建数据库备份" },
{ value: "restore_database_backup", label: "回滚数据库备份" }, { value: "restore_database_backup", label: "回滚数据库备份" },
{ value: "update_pdf_settings", label: "修改PDF生成设置" },
{ value: "public_search_certificate", label: "公开查询证书" }, { value: "public_search_certificate", label: "公开查询证书" },
{ value: "public_download_certificate", label: "公开下载证书" }, { value: "public_download_certificate", label: "公开下载证书" },
]; ];

View File

@@ -0,0 +1,114 @@
<template>
<section>
<header class="page-head">
<div>
<h2>系统设置</h2>
<p>调整会影响正式服务运行的后台参数</p>
</div>
<el-button @click="loadSettings">刷新</el-button>
</header>
<el-card class="panel" shadow="never">
<div class="setting-head">
<div>
<h3>PDF 生成并发限制</h3>
<p>限制同时生成证书 PDF 的数量2核4G服务器建议保持默认值 2</p>
</div>
<el-tag type="info">默认 2</el-tag>
</div>
<el-form class="settings-form" label-position="top">
<el-form-item label="同时生成 PDF 数量">
<el-input-number v-model="form.pdf_generation_concurrency_limit" :min="1" :max="10" :step="1" />
<div class="form-tip">
缓存命中的 PDF 下载不占用生成名额首次生成会排队等待前台会显示生成中提示
</div>
</el-form-item>
<el-button type="primary" :loading="saving" @click="saveSettings">保存设置</el-button>
</el-form>
</el-card>
</section>
</template>
<script setup lang="ts">
import { ElMessage } from "element-plus";
import { onMounted, reactive, ref } from "vue";
import { http, type PdfSettings } from "../api";
const saving = ref(false);
const form = reactive({
pdf_generation_concurrency_limit: 2,
});
async function loadSettings() {
const { data } = await http.get<PdfSettings>("/admin/settings/pdf");
form.pdf_generation_concurrency_limit = data.pdf_generation_concurrency_limit;
}
async function saveSettings() {
saving.value = true;
try {
const { data } = await http.put<PdfSettings>("/admin/settings/pdf", {
pdf_generation_concurrency_limit: form.pdf_generation_concurrency_limit,
});
form.pdf_generation_concurrency_limit = data.pdf_generation_concurrency_limit;
ElMessage.success("系统设置已保存");
} finally {
saving.value = false;
}
}
onMounted(loadSettings);
</script>
<style scoped>
.page-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
margin-bottom: 16px;
}
.page-head p,
.setting-head p {
margin: 6px 0 0;
color: #64748b;
}
.panel {
border-radius: 8px;
}
.setting-head {
display: flex;
justify-content: space-between;
gap: 16px;
margin-bottom: 18px;
}
.setting-head h3 {
margin: 0;
font-size: 18px;
}
.settings-form {
max-width: 520px;
}
.form-tip {
margin-top: 8px;
color: #64748b;
font-size: 13px;
line-height: 1.7;
}
@media (max-width: 900px) {
.page-head,
.setting-head {
align-items: stretch;
flex-direction: column;
}
}
</style>

View File

@@ -13,6 +13,7 @@
<el-menu-item index="/admin/imports">Excel 导入</el-menu-item> <el-menu-item index="/admin/imports">Excel 导入</el-menu-item>
<el-menu-item index="/admin/logs">操作日志</el-menu-item> <el-menu-item index="/admin/logs">操作日志</el-menu-item>
<el-menu-item index="/admin/backups">备份管理</el-menu-item> <el-menu-item index="/admin/backups">备份管理</el-menu-item>
<el-menu-item index="/admin/settings">系统设置</el-menu-item>
</el-menu> </el-menu>
<el-button class="logout" @click="logout">退出登录</el-button> <el-button class="logout" @click="logout">退出登录</el-button>
</aside> </aside>

View File

@@ -97,7 +97,14 @@
<div class="card-actions"> <div class="card-actions">
<el-button @click="preview(item)">预览</el-button> <el-button @click="preview(item)">预览</el-button>
<el-button type="primary" :disabled="!item.can_download_pdf" @click="downloadPdf(item)">下载 PDF</el-button> <el-button
type="primary"
:disabled="!item.can_download_pdf"
:loading="downloadingCertificateNo === item.certificate_no"
@click="downloadPdf(item)"
>
下载 PDF
</el-button>
</div> </div>
</article> </article>
</section> </section>
@@ -111,6 +118,10 @@
<div v-if="previewPdfUrl" class="pdf-frame"> <div v-if="previewPdfUrl" class="pdf-frame">
<embed :src="previewPdfUrl" type="application/pdf" /> <embed :src="previewPdfUrl" type="application/pdf" />
</div> </div>
<div v-else-if="previewLoading" class="pdf-loading">
<el-icon class="is-loading"><Loading /></el-icon>
<p>PDF正在排队或生成请稍候...</p>
</div>
<div v-else-if="current" class="preview-card"> <div v-else-if="current" class="preview-card">
<p class="preview-kicker">Training Completion Certificate</p> <p class="preview-kicker">Training Completion Certificate</p>
<h2>{{ current.certificate_name }}</h2> <h2>{{ current.certificate_name }}</h2>
@@ -126,16 +137,26 @@
</div> </div>
<template #footer> <template #footer>
<el-button @click="closePreview">关闭</el-button> <el-button @click="closePreview">关闭</el-button>
<el-button v-if="current?.can_download_pdf" type="primary" @click="downloadPdf(current)">下载 PDF</el-button> <el-button
v-if="current?.can_download_pdf"
type="primary"
:loading="downloadingCertificateNo === current.certificate_no"
@click="downloadPdf(current)"
>
下载 PDF
</el-button>
</template> </template>
</el-dialog> </el-dialog>
</main> </main>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { Loading } from "@element-plus/icons-vue";
import { ElLoading, ElMessage } from "element-plus";
import { onMounted, onUnmounted, reactive, ref, watch } from "vue"; import { onMounted, onUnmounted, reactive, ref, watch } from "vue";
import { http, type Captcha, type PublicCertificate } from "../api"; import { http, type Captcha, type PublicCertificate } from "../api";
import { apiErrorMessage, saveBlob } from "../download";
interface SearchResponse { interface SearchResponse {
items: PublicCertificate[]; items: PublicCertificate[];
@@ -159,6 +180,8 @@ const results = ref<PublicCertificate[]>([]);
const previewVisible = ref(false); const previewVisible = ref(false);
const current = ref<PublicCertificate | null>(null); const current = ref<PublicCertificate | null>(null);
const previewPdfUrl = ref(""); const previewPdfUrl = ref("");
const previewLoading = ref(false);
const downloadingCertificateNo = ref("");
const smsId = ref(""); const smsId = ref("");
const sendingSms = ref(false); const sendingSms = ref(false);
const smsCooldown = ref(0); const smsCooldown = ref(0);
@@ -285,12 +308,20 @@ async function submitQuery() {
async function preview(item: PublicCertificate) { async function preview(item: PublicCertificate) {
current.value = item; current.value = item;
clearPreviewUrl(); clearPreviewUrl();
if (item.can_download_pdf && item.download_url) {
const url = item.download_url.replace(/^\/api/, "");
const { data } = await http.get(url, { responseType: "blob" });
previewPdfUrl.value = URL.createObjectURL(data);
}
previewVisible.value = true; previewVisible.value = true;
if (item.can_download_pdf && item.download_url) {
previewLoading.value = true;
try {
const url = item.download_url.replace(/^\/api/, "");
const { data } = await http.get(url, { responseType: "blob" });
previewPdfUrl.value = URL.createObjectURL(data);
} catch (error: any) {
message.value = await apiErrorMessage(error, "PDF生成失败请稍后再试");
messageType.value = "error";
} finally {
previewLoading.value = false;
}
}
} }
function clearPreviewUrl() { function clearPreviewUrl() {
@@ -304,8 +335,24 @@ function closePreview() {
previewVisible.value = false; previewVisible.value = false;
} }
function downloadPdf(item: PublicCertificate) { async function downloadPdf(item: PublicCertificate) {
if (item.download_url) window.location.href = item.download_url; if (!item.download_url) return;
downloadingCertificateNo.value = item.certificate_no;
const loading = ElLoading.service({
lock: true,
text: "PDF正在排队或生成请稍候...",
background: "rgba(255, 255, 255, 0.72)",
});
try {
const url = item.download_url.replace(/^\/api/, "");
const { data } = await http.get(url, { responseType: "blob" });
saveBlob(data, `${item.certificate_no}.pdf`);
} catch (error: any) {
ElMessage.error(await apiErrorMessage(error, "PDF生成失败请稍后再试"));
} finally {
loading.close();
downloadingCertificateNo.value = "";
}
} }
onMounted(loadCaptcha); onMounted(loadCaptcha);
@@ -520,6 +567,20 @@ h1 {
height: 100%; height: 100%;
} }
.pdf-loading {
min-height: 240px;
display: grid;
place-items: center;
align-content: center;
gap: 12px;
color: #64748b;
}
.pdf-loading .el-icon {
color: #16817e;
font-size: 28px;
}
.preview-kicker { .preview-kicker {
margin: 0 0 10px; margin: 0 0 10px;
color: #16817e; color: #16817e;

View File

@@ -26,23 +26,28 @@
<dd>{{ certificate.issuer_name }}</dd> <dd>{{ certificate.issuer_name }}</dd>
</div> </div>
</dl> </dl>
<el-button v-if="certificate.can_download_pdf" type="primary" class="download" @click="downloadPdf">下载 PDF 证书</el-button> <el-button v-if="certificate.can_download_pdf" type="primary" class="download" :loading="downloading" @click="downloadPdf">
下载 PDF 证书
</el-button>
</article> </article>
</section> </section>
</main> </main>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ElLoading, ElMessage } from "element-plus";
import { onMounted, ref } from "vue"; import { onMounted, ref } from "vue";
import { useRoute } from "vue-router"; import { useRoute } from "vue-router";
import { http, type PublicCertificate } from "../api"; import { http, type PublicCertificate } from "../api";
import { apiErrorMessage, saveBlob } from "../download";
const route = useRoute(); const route = useRoute();
const token = String(route.params.token); const token = String(route.params.token);
const certificate = ref<PublicCertificate | null>(null); const certificate = ref<PublicCertificate | null>(null);
const message = ref(""); const message = ref("");
const messageType = ref<"success" | "warning" | "error">("success"); const messageType = ref<"success" | "warning" | "error">("success");
const downloading = ref(false);
function statusText(status: string) { function statusText(status: string) {
return status === "valid" ? "有效" : "已作废"; return status === "valid" ? "有效" : "已作废";
@@ -60,8 +65,23 @@ async function loadCertificate() {
} }
} }
function downloadPdf() { async function downloadPdf() {
window.location.href = `/api/public/certificates/token/${token}/download`; if (!certificate.value) return;
downloading.value = true;
const loading = ElLoading.service({
lock: true,
text: "PDF正在排队或生成请稍候...",
background: "rgba(255, 255, 255, 0.72)",
});
try {
const { data } = await http.get(`/public/certificates/token/${token}/download`, { responseType: "blob" });
saveBlob(data, `${certificate.value.certificate_no}.pdf`);
} catch (error: any) {
ElMessage.error(await apiErrorMessage(error, "PDF生成失败请稍后再试"));
} finally {
loading.close();
downloading.value = false;
}
} }
onMounted(loadCertificate); onMounted(loadCertificate);