优化证书生成与正式部署配置
This commit is contained in:
@@ -8,6 +8,10 @@ from app.models import AdminUser
|
||||
|
||||
bearer_scheme = HTTPBearer(auto_error=False)
|
||||
|
||||
ROLE_ALIASES = {
|
||||
"admin": "system_admin",
|
||||
}
|
||||
|
||||
|
||||
def get_current_admin(
|
||||
credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
|
||||
@@ -26,7 +30,8 @@ def get_current_admin(
|
||||
|
||||
def require_roles(*role_codes: str):
|
||||
def checker(admin: AdminUser = Depends(get_current_admin)) -> AdminUser:
|
||||
if admin.role_code not in role_codes:
|
||||
role_code = ROLE_ALIASES.get(admin.role_code, admin.role_code)
|
||||
if role_code not in role_codes:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Permission denied")
|
||||
return admin
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ from app.api.routes import (
|
||||
admin_imports,
|
||||
admin_learners,
|
||||
admin_logs,
|
||||
admin_pdf_cleanup,
|
||||
admin_projects,
|
||||
auth,
|
||||
health,
|
||||
@@ -23,4 +24,5 @@ api_router.include_router(admin_certificates.router, prefix="/admin/certificates
|
||||
api_router.include_router(admin_imports.router, prefix="/admin/import-batches", tags=["admin-imports"])
|
||||
api_router.include_router(admin_exports.router, prefix="/admin/exports", tags=["admin-exports"])
|
||||
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(public.router, prefix="/public", tags=["public"])
|
||||
|
||||
56
backend/app/api/routes/admin_pdf_cleanup.py
Normal file
56
backend/app/api/routes/admin_pdf_cleanup.py
Normal file
@@ -0,0 +1,56 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_admin
|
||||
from app.db.session import get_db
|
||||
from app.models import AdminUser
|
||||
from app.services.pdf_cleanup import (
|
||||
cleanup_expired_pdfs,
|
||||
get_pdf_cleanup_stats,
|
||||
manual_cleanup_certificate,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/stats")
|
||||
def get_pdf_stats(
|
||||
current_admin: AdminUser = Depends(get_current_admin),
|
||||
) -> dict:
|
||||
"""获取PDF清理统计信息"""
|
||||
return get_pdf_cleanup_stats()
|
||||
|
||||
|
||||
@router.post("/cleanup")
|
||||
def trigger_cleanup(
|
||||
current_admin: AdminUser = Depends(get_current_admin),
|
||||
) -> dict:
|
||||
"""手动触发PDF清理"""
|
||||
if current_admin.role_code != "admin":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="只有管理员可以执行此操作",
|
||||
)
|
||||
return cleanup_expired_pdfs()
|
||||
|
||||
|
||||
@router.post("/cleanup/{certificate_no}")
|
||||
def cleanup_single_certificate(
|
||||
certificate_no: str,
|
||||
current_admin: AdminUser = Depends(get_current_admin),
|
||||
) -> dict:
|
||||
"""手动清理指定证书的PDF"""
|
||||
if current_admin.role_code != "admin":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="只有管理员可以执行此操作",
|
||||
)
|
||||
|
||||
success = manual_cleanup_certificate(certificate_no)
|
||||
if success:
|
||||
return {"message": f"证书 {certificate_no} 的PDF已清理"}
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"证书 {certificate_no} 不存在或清理失败",
|
||||
)
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 91 KiB |
BIN
backend/app/assets/certificate-template.png
Normal file
BIN
backend/app/assets/certificate-template.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.6 MiB |
@@ -18,12 +18,11 @@ class Settings:
|
||||
secret_key: str = os.getenv("SECRET_KEY", "change-me")
|
||||
public_base_url: str = os.getenv("PUBLIC_BASE_URL", "http://localhost:8080")
|
||||
certificate_no_secret: str = os.getenv("CERTIFICATE_NO_SECRET", "change-me")
|
||||
pdf_cache_days: int = int(os.getenv("PDF_CACHE_DAYS", "30"))
|
||||
data_root: str = os.getenv("DATA_ROOT", "/data/certificate-system")
|
||||
cors_origins: list[str] = field(
|
||||
default_factory=lambda: _csv_env("CORS_ORIGINS", ["http://localhost:5173", "http://localhost:8080"])
|
||||
)
|
||||
|
||||
|
||||
# 短信服务配置
|
||||
# local=本地测试(验证码打印到终端),aliyun=阿里云短信
|
||||
sms_mode: str = os.getenv("SMS_MODE", "local")
|
||||
@@ -32,6 +31,10 @@ class Settings:
|
||||
aliyun_sms_sign_name: str = os.getenv("ALIYUN_SMS_SIGN_NAME", "")
|
||||
aliyun_sms_template_code: str = os.getenv("ALIYUN_SMS_TEMPLATE_CODE", "")
|
||||
|
||||
# PDF配置
|
||||
pdf_cache_days: int = int(os.getenv("PDF_CACHE_DAYS", "30"))
|
||||
pdf_cleanup_days: int = int(os.getenv("PDF_CLEANUP_DAYS", "5"))
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
|
||||
@@ -1,12 +1,46 @@
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.api.router import api_router
|
||||
from app.core.config import settings
|
||||
from app.services.pdf_cleanup import cleanup_expired_pdfs
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""应用生命周期管理"""
|
||||
# 启动时执行
|
||||
logger.info("[应用启动] 初始化定时任务...")
|
||||
|
||||
# 启动后台清理任务
|
||||
import asyncio
|
||||
async def cleanup_task():
|
||||
while True:
|
||||
try:
|
||||
# 每天执行一次PDF清理
|
||||
await asyncio.sleep(24 * 60 * 60) # 24小时
|
||||
logger.info("[定时任务] 开始执行PDF清理...")
|
||||
cleanup_expired_pdfs()
|
||||
except Exception as e:
|
||||
logger.error(f"[定时任务] PDF清理失败: {str(e)}")
|
||||
|
||||
# 启动后台任务
|
||||
asyncio.create_task(cleanup_task())
|
||||
logger.info("[应用启动] 定时任务已启动")
|
||||
|
||||
yield
|
||||
|
||||
# 关闭时执行
|
||||
logger.info("[应用关闭] 清理资源...")
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
app = FastAPI(title=settings.app_name)
|
||||
app = FastAPI(title=settings.app_name, lifespan=lifespan)
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.cors_origins,
|
||||
|
||||
@@ -37,6 +37,7 @@ class Certificate(Base):
|
||||
status: Mapped[str] = mapped_column(String(32), default="valid")
|
||||
pdf_status: Mapped[str] = mapped_column(String(32), default="not_generated")
|
||||
pdf_file_path: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||
pdf_last_accessed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
public_token_id: Mapped[int | None] = mapped_column(nullable=True)
|
||||
qr_token_id: Mapped[int | None] = mapped_column(nullable=True)
|
||||
remark: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
@@ -7,6 +7,10 @@ from app.core.config import settings
|
||||
from app.core.paths import data_path
|
||||
from app.models import Certificate, CertificateAccessToken, Learner, ProjectCourse
|
||||
|
||||
DESIGN_WIDTH = 1024
|
||||
DESIGN_HEIGHT = 759
|
||||
PDF_BASE_RESOLUTION = 150.0
|
||||
|
||||
|
||||
def pdf_cache_path(certificate_no: str) -> Path:
|
||||
safe_name = certificate_no.replace("/", "_")
|
||||
@@ -23,6 +27,25 @@ def cached_pdf_is_fresh(path: Path) -> bool:
|
||||
return modified_at >= datetime.now() - timedelta(days=settings.pdf_cache_days)
|
||||
|
||||
|
||||
def update_pdf_access_time(certificate: Certificate) -> None:
|
||||
"""更新PDF最后访问时间"""
|
||||
from datetime import datetime
|
||||
certificate.pdf_last_accessed_at = datetime.utcnow()
|
||||
|
||||
|
||||
def certificate_template_path() -> Path:
|
||||
assets_dir = Path(__file__).resolve().parents[1] / "assets"
|
||||
for name in ["certificate-template.png", "certificate-template.jpg"]:
|
||||
path = assets_dir / name
|
||||
if path.exists():
|
||||
return path
|
||||
raise FileNotFoundError("Certificate template image not found")
|
||||
|
||||
|
||||
def pdf_resolution(image: Image.Image) -> float:
|
||||
return PDF_BASE_RESOLUTION * (image.width / DESIGN_WIDTH)
|
||||
|
||||
|
||||
def render_certificate_pdf(
|
||||
certificate: Certificate,
|
||||
learner: Learner,
|
||||
@@ -30,71 +53,130 @@ def render_certificate_pdf(
|
||||
token: CertificateAccessToken,
|
||||
) -> Path:
|
||||
output_path = pdf_cache_path(certificate.certificate_no)
|
||||
template_path = Path(__file__).resolve().parents[1] / "assets" / "certificate-template.jpg"
|
||||
template_path = certificate_template_path()
|
||||
latest_renderer_mtime = max(template_path.stat().st_mtime, Path(__file__).stat().st_mtime)
|
||||
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=150.0)
|
||||
image.save(output_path, "PDF", resolution=pdf_resolution(image))
|
||||
|
||||
certificate.pdf_status = "generated"
|
||||
certificate.pdf_file_path = str(output_path)
|
||||
# 更新访问时间
|
||||
update_pdf_access_time(certificate)
|
||||
return output_path
|
||||
|
||||
|
||||
def render_certificate_image(certificate: Certificate, learner: Learner, project: ProjectCourse | None) -> Image.Image:
|
||||
template = Path(__file__).resolve().parents[1] / "assets" / "certificate-template.jpg"
|
||||
template = certificate_template_path()
|
||||
image = Image.open(template).convert("RGB")
|
||||
base_image = image.copy()
|
||||
draw = ImageDraw.Draw(image)
|
||||
x_scale = image.width / DESIGN_WIDTH
|
||||
y_scale = image.height / DESIGN_HEIGHT
|
||||
text_scale = (x_scale + y_scale) / 2
|
||||
|
||||
issue_year, issue_month, issue_day = date_parts(certificate.issue_date)
|
||||
course_name = certificate.course_name or certificate.certificate_name or (project.name if project else certificate.project_code)
|
||||
stage_input = (certificate.stage_name or "").strip()
|
||||
stage_name = f"{stage_input}\u8bfe\u7a0b\u7684\u4e13\u4e1a\u5b66\u4e60\u3002" if stage_input else "\u521d\u7ea7\u8bfe\u7a0b\u7684\u4e13\u4e1a\u5b66\u4e60\u3002"
|
||||
|
||||
for box in [(150, 442, 760, 132), (404, 675, 220, 38)]:
|
||||
paper_patch(image, box)
|
||||
paper_patch(image, (150, 455, 760, 119), x_scale, y_scale)
|
||||
|
||||
draw.text((512, 414), learner.current_name, fill="#111111", font=font(32, "kai", True), anchor="mm")
|
||||
draw.text(
|
||||
(512 * x_scale, 414 * y_scale),
|
||||
learner.current_name,
|
||||
fill="#111111",
|
||||
font=font(32, "kai", True, text_scale),
|
||||
anchor="mm",
|
||||
)
|
||||
|
||||
x = 185.0
|
||||
y = 494
|
||||
x = draw_inline(draw, x, y, "\u5728", 24, 18)
|
||||
x = draw_inline(draw, x, y, issue_year, 24, 8, "kai", True)
|
||||
x = draw_inline(draw, x, y, "\u5e74", 24, 12)
|
||||
x = draw_inline(draw, x, y, issue_month, 24, 6, "kai", True)
|
||||
x = draw_inline(draw, x, y, "\u6708", 24, 12)
|
||||
x = draw_inline(draw, x, y, issue_day, 24, 4, "kai", True)
|
||||
x = draw_inline(draw, x, y, "\u65e5\u81f3", 24, 12)
|
||||
x = draw_inline(draw, x, y, issue_year, 24, 8, "kai", True)
|
||||
x = draw_inline(draw, x, y, "\u5e74", 24, 12)
|
||||
x = draw_inline(draw, x, y, issue_month, 24, 6, "kai", True)
|
||||
x = draw_inline(draw, x, y, "\u6708", 24, 12)
|
||||
x = draw_inline(draw, x, y, issue_day, 24, 4, "kai", True)
|
||||
x = draw_inline(draw, x, y, "\u65e5\u5b8c\u6210\u4e86", 24, 8)
|
||||
draw_inline_flow(draw, [(f"\u201c{course_name}\u201d", "course", 0), (stage_name, "normal", 14)], x, y)
|
||||
x = 185.0 * x_scale
|
||||
y = 494 * y_scale
|
||||
x = draw_inline(draw, x, y, "\u5728", 24, 18, scale=text_scale, x_scale=x_scale)
|
||||
x = draw_inline(draw, x, y, issue_year, 24, 8, scale=text_scale, x_scale=x_scale)
|
||||
x = draw_inline(draw, x, y, "\u5e74", 24, 12, scale=text_scale, x_scale=x_scale)
|
||||
x = draw_inline(draw, x, y, issue_month, 24, 6, scale=text_scale, x_scale=x_scale)
|
||||
x = draw_inline(draw, x, y, "\u6708", 24, 12, scale=text_scale, x_scale=x_scale)
|
||||
x = draw_inline(draw, x, y, issue_day, 24, 4, scale=text_scale, x_scale=x_scale)
|
||||
x = draw_inline(draw, x, y, "\u65e5\u81f3", 24, 12, scale=text_scale, x_scale=x_scale)
|
||||
x = draw_inline(draw, x, y, issue_year, 24, 8, scale=text_scale, x_scale=x_scale)
|
||||
x = draw_inline(draw, x, y, "\u5e74", 24, 12, scale=text_scale, x_scale=x_scale)
|
||||
x = draw_inline(draw, x, y, issue_month, 24, 6, scale=text_scale, x_scale=x_scale)
|
||||
x = draw_inline(draw, x, y, "\u6708", 24, 12, scale=text_scale, x_scale=x_scale)
|
||||
x = draw_inline(draw, x, y, issue_day, 24, 4, scale=text_scale, x_scale=x_scale)
|
||||
x = draw_inline(draw, x, y, "\u65e5\u5b8c\u6210\u4e86", 24, 8, scale=text_scale, x_scale=x_scale)
|
||||
draw_inline_flow(
|
||||
draw,
|
||||
[(f"\u201c{course_name}\u201d", "course", 0), (stage_name, "normal", 14)],
|
||||
x,
|
||||
y,
|
||||
text_scale,
|
||||
x_scale,
|
||||
y_scale,
|
||||
)
|
||||
|
||||
draw.text((512, 704), f"\u53d1\u8bc1\u65e5\u671f\uff1a{issue_year} \u5e74 {issue_month} \u6708 {issue_day} \u65e5", fill="#43382f", font=font(13, "song", True), anchor="mm")
|
||||
draw.text((105, 76), f"\u8bc1\u4e66\u7f16\u53f7\uff1a{certificate.certificate_no}", fill="#111827", font=font(14, "hei", True))
|
||||
image.paste(base_image.crop((720, 535, 950, 629)), (720, 535))
|
||||
draw_issue_date_numbers(draw, issue_year, issue_month, issue_day, text_scale, x_scale, y_scale)
|
||||
draw.text(
|
||||
(105 * x_scale, 76 * y_scale),
|
||||
f"\u8bc1\u4e66\u7f16\u53f7\uff1a{certificate.certificate_no}",
|
||||
fill="#111827",
|
||||
font=font(14, "hei", True, text_scale),
|
||||
)
|
||||
mentor_box = scaled_box((720, 535, 230, 94), x_scale, y_scale)
|
||||
image.paste(base_image.crop(to_xyxy(mentor_box)), mentor_box[:2])
|
||||
return image
|
||||
|
||||
|
||||
def font(size: int, kind: str = "song", bold: bool = False) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
|
||||
def font(size: int, kind: str = "song", bold: bool = False, scale: float = 1.0) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
|
||||
choices = {
|
||||
"kai": [r"C:\Windows\Fonts\simkai.ttf", r"C:\Windows\Fonts\msyh.ttc", r"C:\Windows\Fonts\simsun.ttc"],
|
||||
"hei": [r"C:\Windows\Fonts\simhei.ttf", r"C:\Windows\Fonts\msyhbd.ttc", r"C:\Windows\Fonts\msyh.ttc"],
|
||||
"song": [r"C:\Windows\Fonts\msyh.ttc", r"C:\Windows\Fonts\simsun.ttc"],
|
||||
"kai": [
|
||||
r"C:\Windows\Fonts\simkai.ttf",
|
||||
r"C:\Windows\Fonts\msyh.ttc",
|
||||
r"C:\Windows\Fonts\simsun.ttc",
|
||||
"/usr/share/fonts/opentype/noto/NotoSerifCJK-Regular.ttc",
|
||||
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
|
||||
"/System/Library/Fonts/Supplemental/Kaiti.ttc",
|
||||
("/System/Library/Fonts/Hiragino Sans GB.ttc", 2),
|
||||
("/System/Library/Fonts/Supplemental/Songti.ttc", 1),
|
||||
],
|
||||
"hei": [
|
||||
r"C:\Windows\Fonts\simhei.ttf",
|
||||
r"C:\Windows\Fonts\msyhbd.ttc",
|
||||
r"C:\Windows\Fonts\msyh.ttc",
|
||||
"/usr/share/fonts/opentype/noto/NotoSansCJK-Bold.ttc",
|
||||
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
|
||||
("/System/Library/Fonts/STHeiti Medium.ttc", 1),
|
||||
("/System/Library/Fonts/Hiragino Sans GB.ttc", 2),
|
||||
],
|
||||
"song": [
|
||||
r"C:\Windows\Fonts\msyh.ttc",
|
||||
r"C:\Windows\Fonts\simsun.ttc",
|
||||
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
|
||||
"/usr/share/fonts/opentype/noto/NotoSerifCJK-Regular.ttc",
|
||||
("/System/Library/Fonts/Supplemental/Songti.ttc", 3),
|
||||
("/System/Library/Fonts/Supplemental/Songti.ttc", 6),
|
||||
("/System/Library/Fonts/Hiragino Sans GB.ttc", 0),
|
||||
("/System/Library/Fonts/STHeiti Light.ttc", 1),
|
||||
],
|
||||
}
|
||||
paths = choices.get(kind, choices["song"])
|
||||
if bold and kind == "song":
|
||||
paths = [r"C:\Windows\Fonts\msyhbd.ttc", r"C:\Windows\Fonts\simhei.ttf"] + paths
|
||||
paths = [
|
||||
r"C:\Windows\Fonts\msyhbd.ttc",
|
||||
r"C:\Windows\Fonts\simhei.ttf",
|
||||
"/usr/share/fonts/opentype/noto/NotoSansCJK-Bold.ttc",
|
||||
("/System/Library/Fonts/STHeiti Medium.ttc", 1),
|
||||
("/System/Library/Fonts/Hiragino Sans GB.ttc", 2),
|
||||
] + paths
|
||||
for item in paths:
|
||||
if Path(item).exists():
|
||||
path, index = item if isinstance(item, tuple) else (item, 0)
|
||||
if Path(path).exists():
|
||||
try:
|
||||
return ImageFont.truetype(item, size)
|
||||
return ImageFont.truetype(path, max(1, round(size * scale)), index=index)
|
||||
except Exception:
|
||||
pass
|
||||
return ImageFont.load_default()
|
||||
@@ -114,22 +196,71 @@ def date_parts(value: date | str | None) -> tuple[str, str, str]:
|
||||
return str(today.year), str(today.month), str(today.day)
|
||||
|
||||
|
||||
def paper_patch(image: Image.Image, box: tuple[int, int, int, int]) -> None:
|
||||
def scaled_box(box: tuple[int, int, int, int], x_scale: float, y_scale: float) -> tuple[int, int, int, int]:
|
||||
x, y, w, h = box
|
||||
return round(x * x_scale), round(y * y_scale), round(w * x_scale), round(h * y_scale)
|
||||
|
||||
|
||||
def to_xyxy(box: tuple[int, int, int, int]) -> tuple[int, int, int, int]:
|
||||
x, y, w, h = box
|
||||
return x, y, x + w, y + h
|
||||
|
||||
|
||||
def paper_patch(image: Image.Image, box: tuple[int, int, int, int], x_scale: float, y_scale: float) -> None:
|
||||
x, y, w, h = scaled_box(box, x_scale, y_scale)
|
||||
patch = Image.new("RGB", (w, h), "#fbf7ef")
|
||||
source = image.crop((72, max(0, y), 160, max(0, y) + h))
|
||||
source = image.crop((round(72 * x_scale), y, round(160 * x_scale), y + h))
|
||||
for tx in range(0, w, source.width):
|
||||
patch.paste(source.crop((0, 0, min(source.width, w - tx), h)), (tx, 0))
|
||||
cover = Image.new("RGB", (w, h), "#fbf7ef")
|
||||
image.paste(Image.blend(patch, cover, 0.38), (x, y))
|
||||
image.paste(Image.blend(patch, cover, 0.38), (x, y), soft_edge_mask(w, h, round(16 * x_scale), round(10 * y_scale)))
|
||||
|
||||
|
||||
def draw_inline(draw: ImageDraw.ImageDraw, x: float, y: int, value: str, size: int, gap: int, kind: str = "song", bold: bool = False) -> float:
|
||||
text_font = font(size, kind, bold)
|
||||
def soft_edge_mask(width: int, height: int, feather_x: int, feather_y: int) -> Image.Image:
|
||||
feather_x = max(1, feather_x)
|
||||
feather_y = max(1, feather_y)
|
||||
mask = Image.new("L", (width, height), 255)
|
||||
pixels = mask.load()
|
||||
for y in range(height):
|
||||
vertical = min(1.0, y / feather_y, (height - 1 - y) / feather_y)
|
||||
for x in range(width):
|
||||
horizontal = min(1.0, x / feather_x, (width - 1 - x) / feather_x)
|
||||
pixels[x, y] = max(0, min(255, round(255 * min(horizontal, vertical))))
|
||||
return mask
|
||||
|
||||
|
||||
def draw_issue_date_numbers(
|
||||
draw: ImageDraw.ImageDraw,
|
||||
issue_year: str,
|
||||
issue_month: str,
|
||||
issue_day: str,
|
||||
scale: float,
|
||||
x_scale: float,
|
||||
y_scale: float,
|
||||
) -> None:
|
||||
text_font = font(13, "song", True, scale)
|
||||
y = 688 * y_scale
|
||||
for value, x in [(issue_year, 500), (issue_month, 535), (issue_day, 566)]:
|
||||
draw.text((x * x_scale, y), value, fill="#111111", font=text_font, anchor="mm")
|
||||
|
||||
|
||||
def draw_inline(
|
||||
draw: ImageDraw.ImageDraw,
|
||||
x: float,
|
||||
y: float,
|
||||
value: str,
|
||||
size: int,
|
||||
gap: int,
|
||||
kind: str = "song",
|
||||
bold: bool = False,
|
||||
scale: float = 1.0,
|
||||
x_scale: float = 1.0,
|
||||
) -> float:
|
||||
text_font = font(size, kind, bold, scale)
|
||||
draw.text((x, y), value, fill="#111111", font=text_font, anchor="ls")
|
||||
if bold:
|
||||
draw.text((x + 0.45, y), value, fill="#111111", font=text_font, anchor="ls")
|
||||
return x + draw.textlength(value, font=text_font) + gap
|
||||
draw.text((x + 0.45 * x_scale, y), value, fill="#111111", font=text_font, anchor="ls")
|
||||
return x + draw.textlength(value, font=text_font) + gap * x_scale
|
||||
|
||||
|
||||
def draw_course_name(draw: ImageDraw.ImageDraw, value: str, x: float, y: int) -> int:
|
||||
@@ -149,23 +280,31 @@ def draw_course_name(draw: ImageDraw.ImageDraw, value: str, x: float, y: int) ->
|
||||
return start_y + (len(lines) - 1) * 34
|
||||
|
||||
|
||||
def draw_inline_flow(draw: ImageDraw.ImageDraw, segments: list[tuple[str, str, int]], start_x: float, start_y: int) -> None:
|
||||
def draw_inline_flow(
|
||||
draw: ImageDraw.ImageDraw,
|
||||
segments: list[tuple[str, str, int]],
|
||||
start_x: float,
|
||||
start_y: float,
|
||||
scale: float,
|
||||
x_scale: float,
|
||||
y_scale: float,
|
||||
) -> None:
|
||||
x = start_x
|
||||
y = start_y
|
||||
line_start = 185
|
||||
line_height = 42
|
||||
line_start = 185 * x_scale
|
||||
line_height = 42 * y_scale
|
||||
for value, style, gap_before in segments:
|
||||
x += gap_before
|
||||
text_font = font(27, "kai", True) if style == "course" else font(24, "song", False)
|
||||
x += gap_before * x_scale
|
||||
text_font = font(27, "kai", True, scale) if style == "course" else font(24, "song", False, scale)
|
||||
for char in value:
|
||||
max_right = 900 if y == start_y else 730
|
||||
max_right = (900 if y == start_y else 730) * x_scale
|
||||
width = draw.textlength(char, font=text_font)
|
||||
if x + width > max_right and x > line_start:
|
||||
x = line_start
|
||||
y += line_height
|
||||
draw.text((x, y), char, fill="#111111", font=text_font, anchor="ls")
|
||||
if style == "course":
|
||||
draw.text((x + 0.45, y), char, fill="#111111", font=text_font, anchor="ls")
|
||||
draw.text((x + 0.45 * x_scale, y), char, fill="#111111", font=text_font, anchor="ls")
|
||||
x += width
|
||||
|
||||
|
||||
|
||||
188
backend/app/services/pdf_cleanup.py
Normal file
188
backend/app/services/pdf_cleanup.py
Normal file
@@ -0,0 +1,188 @@
|
||||
import logging
|
||||
import subprocess
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.paths import data_path
|
||||
from app.db.session import get_db
|
||||
from app.models import Certificate
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def cleanup_expired_pdfs() -> dict:
|
||||
"""
|
||||
清理超过指定天数未访问的PDF文件
|
||||
|
||||
Returns:
|
||||
dict: 清理结果统计
|
||||
"""
|
||||
logger.info("[PDF清理] 开始清理过期PDF文件...")
|
||||
|
||||
# 使用配置中的清理天数
|
||||
cleanup_days = settings.pdf_cleanup_days
|
||||
|
||||
# 计算截止时间
|
||||
cutoff_time = datetime.utcnow() - timedelta(days=cleanup_days)
|
||||
|
||||
# 统计结果
|
||||
result = {
|
||||
"total_checked": 0,
|
||||
"cleaned_files": 0,
|
||||
"cleaned_records": 0,
|
||||
"cleanup_days": cleanup_days,
|
||||
"errors": [],
|
||||
}
|
||||
|
||||
try:
|
||||
# 获取数据库会话
|
||||
db = next(get_db())
|
||||
|
||||
# 查询需要清理的证书
|
||||
# 条件:PDF已生成,且最后访问时间早于截止时间,或从未访问过
|
||||
stmt = select(Certificate).where(
|
||||
Certificate.pdf_status == "generated",
|
||||
Certificate.pdf_file_path.isnot(None),
|
||||
(
|
||||
(Certificate.pdf_last_accessed_at < cutoff_time) |
|
||||
(Certificate.pdf_last_accessed_at.is_(None))
|
||||
)
|
||||
)
|
||||
|
||||
certificates = db.execute(stmt).scalars().all()
|
||||
result["total_checked"] = len(certificates)
|
||||
|
||||
for cert in certificates:
|
||||
try:
|
||||
# 删除PDF文件
|
||||
if cert.pdf_file_path:
|
||||
pdf_path = Path(cert.pdf_file_path)
|
||||
if pdf_path.exists():
|
||||
subprocess.run(["rm", "-f", str(pdf_path)], check=False)
|
||||
result["cleaned_files"] += 1
|
||||
logger.info(f"[PDF清理] 已删除文件: {cert.pdf_file_path}")
|
||||
|
||||
# 更新数据库记录
|
||||
cert.pdf_status = "cleaned"
|
||||
cert.pdf_file_path = None
|
||||
cert.pdf_last_accessed_at = None
|
||||
result["cleaned_records"] += 1
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"清理证书 {cert.certificate_no} 失败: {str(e)}"
|
||||
result["errors"].append(error_msg)
|
||||
logger.error(f"[PDF清理] {error_msg}")
|
||||
|
||||
# 提交数据库更改
|
||||
db.commit()
|
||||
|
||||
logger.info(f"[PDF清理] 清理完成: 检查 {result['total_checked']} 个, "
|
||||
f"删除 {result['cleaned_files']} 个文件, "
|
||||
f"更新 {result['cleaned_records']} 条记录")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[PDF清理] 清理过程发生错误: {str(e)}")
|
||||
result["errors"].append(f"清理过程发生错误: {str(e)}")
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def get_pdf_cleanup_stats() -> dict:
|
||||
"""
|
||||
获取PDF清理统计信息
|
||||
|
||||
Returns:
|
||||
dict: 统计信息
|
||||
"""
|
||||
try:
|
||||
db = next(get_db())
|
||||
|
||||
# 使用配置中的清理天数
|
||||
cleanup_days = settings.pdf_cleanup_days
|
||||
|
||||
# 统计总数
|
||||
total_stmt = select(Certificate).where(Certificate.pdf_status == "generated")
|
||||
total_count = len(db.execute(total_stmt).scalars().all())
|
||||
|
||||
# 统计需要清理的
|
||||
cutoff_time = datetime.utcnow() - timedelta(days=cleanup_days)
|
||||
cleanup_stmt = select(Certificate).where(
|
||||
Certificate.pdf_status == "generated",
|
||||
Certificate.pdf_file_path.isnot(None),
|
||||
(
|
||||
(Certificate.pdf_last_accessed_at < cutoff_time) |
|
||||
(Certificate.pdf_last_accessed_at.is_(None))
|
||||
)
|
||||
)
|
||||
cleanup_count = len(db.execute(cleanup_stmt).scalars().all())
|
||||
|
||||
# 统计PDF文件大小
|
||||
total_size = 0
|
||||
pdf_cache_dir = data_path("pdf-cache")
|
||||
if pdf_cache_dir.exists():
|
||||
for pdf_file in pdf_cache_dir.rglob("*.pdf"):
|
||||
total_size += pdf_file.stat().st_size
|
||||
|
||||
return {
|
||||
"total_pdfs": total_count,
|
||||
"need_cleanup": cleanup_count,
|
||||
"total_size_mb": round(total_size / (1024 * 1024), 2),
|
||||
"cleanup_days": cleanup_days,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[PDF统计] 获取统计信息失败: {str(e)}")
|
||||
return {
|
||||
"total_pdfs": 0,
|
||||
"need_cleanup": 0,
|
||||
"total_size_mb": 0,
|
||||
"cleanup_days": settings.pdf_cleanup_days,
|
||||
"error": str(e),
|
||||
}
|
||||
|
||||
|
||||
def manual_cleanup_certificate(certificate_no: str) -> bool:
|
||||
"""
|
||||
手动清理指定证书的PDF文件
|
||||
|
||||
Args:
|
||||
certificate_no: 证书编号
|
||||
|
||||
Returns:
|
||||
bool: 是否成功清理
|
||||
"""
|
||||
try:
|
||||
db = next(get_db())
|
||||
|
||||
# 查询证书
|
||||
stmt = select(Certificate).where(Certificate.certificate_no == certificate_no)
|
||||
cert = db.execute(stmt).scalar_one_or_none()
|
||||
|
||||
if not cert:
|
||||
logger.warning(f"[手动清理] 证书不存在: {certificate_no}")
|
||||
return False
|
||||
|
||||
# 删除PDF文件
|
||||
if cert.pdf_file_path:
|
||||
pdf_path = Path(cert.pdf_file_path)
|
||||
if pdf_path.exists():
|
||||
subprocess.run(["rm", "-f", str(pdf_path)], check=False)
|
||||
logger.info(f"[手动清理] 已删除文件: {cert.pdf_file_path}")
|
||||
|
||||
# 更新数据库记录
|
||||
cert.pdf_status = "cleaned"
|
||||
cert.pdf_file_path = None
|
||||
cert.pdf_last_accessed_at = None
|
||||
|
||||
db.commit()
|
||||
|
||||
logger.info(f"[手动清理] 清理完成: {certificate_no}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[手动清理] 清理失败: {certificate_no}, 错误: {str(e)}")
|
||||
return False
|
||||
Reference in New Issue
Block a user