优化证书生成与正式部署配置

This commit is contained in:
2026-06-15 18:23:46 +08:00
parent f1d7491288
commit 668e0403af
31 changed files with 1603 additions and 145 deletions

View File

@@ -5,3 +5,11 @@ PUBLIC_BASE_URL=http://localhost:8080
CERTIFICATE_NO_SECRET=change-me CERTIFICATE_NO_SECRET=change-me
PDF_CACHE_DAYS=30 PDF_CACHE_DAYS=30
DATA_ROOT=/data/certificate-system DATA_ROOT=/data/certificate-system
CORS_ORIGINS=http://localhost:5173,http://localhost:8080
# local=本地测试验证码打印到后端日志aliyun=阿里云短信
SMS_MODE=local
ALIYUN_ACCESS_KEY_ID=
ALIYUN_ACCESS_KEY_SECRET=
ALIYUN_SMS_SIGN_NAME=
ALIYUN_SMS_TEMPLATE_CODE=

View File

@@ -63,5 +63,6 @@ reset-local-data.bat
- [开发决策记录](docs/decision-log.md) - [开发决策记录](docs/decision-log.md)
- [MVP 范围说明](docs/mvp-scope.md) - [MVP 范围说明](docs/mvp-scope.md)
- [正式部署操作手册](docs/正式部署操作手册.md)
- [部署与使用手册](docs/usage-deployment-manual.md) - [部署与使用手册](docs/usage-deployment-manual.md)
- [上线检查清单](docs/deployment-checklist.md) - [上线检查清单](docs/deployment-checklist.md)

View File

@@ -23,8 +23,6 @@ def upgrade() -> None:
op.execute("update project_courses set default_certificate_name='培训结业证书' where default_certificate_name is null") op.execute("update project_courses set default_certificate_name='培训结业证书' where default_certificate_name is null")
op.execute("update project_courses set default_course_name=name where default_course_name is null") op.execute("update project_courses set default_course_name=name where default_course_name is null")
op.execute("update project_courses set default_issuer_name='本公司' where default_issuer_name is null") op.execute("update project_courses set default_issuer_name='本公司' where default_issuer_name is null")
op.alter_column("project_courses", "default_certificate_name", nullable=False)
op.alter_column("project_courses", "default_issuer_name", nullable=False)
def downgrade() -> None: def downgrade() -> None:

View File

@@ -0,0 +1,30 @@
"""add pdf_last_accessed_at
Revision ID: 20260609_0003
Revises: 20260602_0002
Create Date: 2026-06-09 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '20260609_0003'
down_revision: Union[str, None] = '20260602_0002'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('certificates', sa.Column('pdf_last_accessed_at', sa.DateTime(), nullable=True))
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('certificates', 'pdf_last_accessed_at')
# ### end Alembic commands ###

View File

@@ -8,6 +8,10 @@ from app.models import AdminUser
bearer_scheme = HTTPBearer(auto_error=False) bearer_scheme = HTTPBearer(auto_error=False)
ROLE_ALIASES = {
"admin": "system_admin",
}
def get_current_admin( def get_current_admin(
credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme), credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
@@ -26,7 +30,8 @@ def get_current_admin(
def require_roles(*role_codes: str): def require_roles(*role_codes: str):
def checker(admin: AdminUser = Depends(get_current_admin)) -> AdminUser: 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") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Permission denied")
return admin return admin

View File

@@ -7,6 +7,7 @@ from app.api.routes import (
admin_imports, admin_imports,
admin_learners, admin_learners,
admin_logs, admin_logs,
admin_pdf_cleanup,
admin_projects, admin_projects,
auth, auth,
health, 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_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_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_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"]) api_router.include_router(public.router, prefix="/public", tags=["public"])

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

View File

@@ -18,12 +18,11 @@ class Settings:
secret_key: str = os.getenv("SECRET_KEY", "change-me") secret_key: str = os.getenv("SECRET_KEY", "change-me")
public_base_url: str = os.getenv("PUBLIC_BASE_URL", "http://localhost:8080") public_base_url: str = os.getenv("PUBLIC_BASE_URL", "http://localhost:8080")
certificate_no_secret: str = os.getenv("CERTIFICATE_NO_SECRET", "change-me") 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") data_root: str = os.getenv("DATA_ROOT", "/data/certificate-system")
cors_origins: list[str] = field( cors_origins: list[str] = field(
default_factory=lambda: _csv_env("CORS_ORIGINS", ["http://localhost:5173", "http://localhost:8080"]) default_factory=lambda: _csv_env("CORS_ORIGINS", ["http://localhost:5173", "http://localhost:8080"])
) )
# 短信服务配置 # 短信服务配置
# local=本地测试验证码打印到终端aliyun=阿里云短信 # local=本地测试验证码打印到终端aliyun=阿里云短信
sms_mode: str = os.getenv("SMS_MODE", "local") 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_sign_name: str = os.getenv("ALIYUN_SMS_SIGN_NAME", "")
aliyun_sms_template_code: str = os.getenv("ALIYUN_SMS_TEMPLATE_CODE", "") 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 @lru_cache
def get_settings() -> Settings: def get_settings() -> Settings:

View File

@@ -1,12 +1,46 @@
import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from app.api.router import api_router from app.api.router import api_router
from app.core.config import settings 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: def create_app() -> FastAPI:
app = FastAPI(title=settings.app_name) app = FastAPI(title=settings.app_name, lifespan=lifespan)
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=settings.cors_origins, allow_origins=settings.cors_origins,

View File

@@ -37,6 +37,7 @@ class Certificate(Base):
status: Mapped[str] = mapped_column(String(32), default="valid") status: Mapped[str] = mapped_column(String(32), default="valid")
pdf_status: Mapped[str] = mapped_column(String(32), default="not_generated") pdf_status: Mapped[str] = mapped_column(String(32), default="not_generated")
pdf_file_path: Mapped[str | None] = mapped_column(String(512), nullable=True) 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) public_token_id: Mapped[int | None] = mapped_column(nullable=True)
qr_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) remark: Mapped[str | None] = mapped_column(Text, nullable=True)

View File

@@ -7,6 +7,10 @@ from app.core.config import settings
from app.core.paths import data_path from app.core.paths import data_path
from app.models import Certificate, CertificateAccessToken, Learner, ProjectCourse 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: def pdf_cache_path(certificate_no: str) -> Path:
safe_name = certificate_no.replace("/", "_") 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) 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( def render_certificate_pdf(
certificate: Certificate, certificate: Certificate,
learner: Learner, learner: Learner,
@@ -30,71 +53,130 @@ def render_certificate_pdf(
token: CertificateAccessToken, token: CertificateAccessToken,
) -> Path: ) -> Path:
output_path = pdf_cache_path(certificate.certificate_no) 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) 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: if cached_pdf_is_fresh(output_path) and output_path.stat().st_mtime >= latest_renderer_mtime:
# 更新访问时间
update_pdf_access_time(certificate)
return output_path return output_path
image = render_certificate_image(certificate, learner, project) 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_status = "generated"
certificate.pdf_file_path = str(output_path) certificate.pdf_file_path = str(output_path)
# 更新访问时间
update_pdf_access_time(certificate)
return output_path return output_path
def render_certificate_image(certificate: Certificate, learner: Learner, project: ProjectCourse | None) -> Image.Image: 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") image = Image.open(template).convert("RGB")
base_image = image.copy() base_image = image.copy()
draw = ImageDraw.Draw(image) 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) 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) 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_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" 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, (150, 455, 760, 119), x_scale, y_scale)
paper_patch(image, box)
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 x = 185.0 * x_scale
y = 494 y = 494 * y_scale
x = draw_inline(draw, x, y, "\u5728", 24, 18) 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, "kai", True) 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) 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, "kai", True) 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) 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, "kai", True) 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) 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, "kai", True) 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) 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, "kai", True) 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) 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, "kai", True) 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) 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) 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_issue_date_numbers(draw, issue_year, issue_month, issue_day, text_scale, x_scale, y_scale)
draw.text((105, 76), f"\u8bc1\u4e66\u7f16\u53f7\uff1a{certificate.certificate_no}", fill="#111827", font=font(14, "hei", True)) draw.text(
image.paste(base_image.crop((720, 535, 950, 629)), (720, 535)) (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 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 = { choices = {
"kai": [r"C:\Windows\Fonts\simkai.ttf", r"C:\Windows\Fonts\msyh.ttc", r"C:\Windows\Fonts\simsun.ttc"], "kai": [
"hei": [r"C:\Windows\Fonts\simhei.ttf", r"C:\Windows\Fonts\msyhbd.ttc", r"C:\Windows\Fonts\msyh.ttc"], r"C:\Windows\Fonts\simkai.ttf",
"song": [r"C:\Windows\Fonts\msyh.ttc", r"C:\Windows\Fonts\simsun.ttc"], 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"]) paths = choices.get(kind, choices["song"])
if bold and kind == "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: for item in paths:
if Path(item).exists(): path, index = item if isinstance(item, tuple) else (item, 0)
if Path(path).exists():
try: try:
return ImageFont.truetype(item, size) return ImageFont.truetype(path, max(1, round(size * scale)), index=index)
except Exception: except Exception:
pass pass
return ImageFont.load_default() 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) 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 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") 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): for tx in range(0, w, source.width):
patch.paste(source.crop((0, 0, min(source.width, w - tx), h)), (tx, 0)) patch.paste(source.crop((0, 0, min(source.width, w - tx), h)), (tx, 0))
cover = Image.new("RGB", (w, h), "#fbf7ef") 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: def soft_edge_mask(width: int, height: int, feather_x: int, feather_y: int) -> Image.Image:
text_font = font(size, kind, bold) 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") draw.text((x, y), value, fill="#111111", font=text_font, anchor="ls")
if bold: if bold:
draw.text((x + 0.45, y), value, fill="#111111", font=text_font, anchor="ls") 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 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: 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 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 x = start_x
y = start_y y = start_y
line_start = 185 line_start = 185 * x_scale
line_height = 42 line_height = 42 * y_scale
for value, style, gap_before in segments: for value, style, gap_before in segments:
x += gap_before x += gap_before * x_scale
text_font = font(27, "kai", True) if style == "course" else font(24, "song", False) text_font = font(27, "kai", True, scale) if style == "course" else font(24, "song", False, scale)
for char in value: 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) width = draw.textlength(char, font=text_font)
if x + width > max_right and x > line_start: if x + width > max_right and x > line_start:
x = line_start x = line_start
y += line_height y += line_height
draw.text((x, y), char, fill="#111111", font=text_font, anchor="ls") draw.text((x, y), char, fill="#111111", font=text_font, anchor="ls")
if style == "course": 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 x += width

View 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

View File

@@ -1,5 +1,7 @@
# 阿里云短信服务部署指南 # 阿里云短信服务部署指南
> 注意:这份文档是早期短信接入草稿,里面包含“修改 config.py / 替换 sms.py”等旧步骤。当前项目已经内置 `SMS_MODE=local/aliyun` 和阿里云短信发送逻辑,正式部署请以 `docs/正式部署操作手册.md` 的“短信验证码配置”章节为准。
## 前置条件 ## 前置条件
- 阿里云账号(已实名认证) - 阿里云账号(已实名认证)

View File

@@ -9,3 +9,4 @@ qrcode[pil]==8.0
playwright==1.49.1 playwright==1.49.1
jinja2==3.1.5 jinja2==3.1.5
pytest==8.3.4 pytest==8.3.4
alibabacloud-dysmsapi20170525==4.5.1

View File

@@ -1,12 +1,10 @@
from datetime import date from datetime import date
from app.api.routes.admin_imports import ( from app.api.routes.admin_imports import (
COL_CERT_NAME,
COL_ISSUE_DATE, COL_ISSUE_DATE,
COL_NAME, COL_NAME,
COL_PHONE, COL_PHONE,
COL_PROJECT, COL_PROJECT,
COL_ISSUER,
date_is_valid, date_is_valid,
parse_issue_date, parse_issue_date,
row_errors, row_errors,
@@ -23,9 +21,7 @@ def test_row_errors_require_project_code_to_exist():
COL_NAME: "张三", COL_NAME: "张三",
COL_PHONE: "13800000000", COL_PHONE: "13800000000",
COL_PROJECT: "BAD", COL_PROJECT: "BAD",
COL_CERT_NAME: "结业证书",
COL_ISSUE_DATE: "2026-06-01", COL_ISSUE_DATE: "2026-06-01",
COL_ISSUER: "测试公司",
} }
assert "Project code is inactive or missing" in row_errors(row, {"DBY"}) assert "Project code is inactive or missing" in row_errors(row, {"DBY"})

View File

@@ -19,7 +19,7 @@ http://127.0.0.1:8020
## 当前用途 ## 当前用途
-`assets/original.jpg` 作为证书底图。 -`assets/original.png` 作为高清证书底图。
- 擦除可变文字区域并重新绘制学员、课程、日期、单位、证书编号等信息。 - 擦除可变文字区域并重新绘制学员、课程、日期、单位、证书编号等信息。
- 支持下载 PNG用于先确认视觉效果。 - 支持下载 PNG用于先确认视觉效果。
- 后续确认版式后,再把这套渲染参数迁移到主系统的 PDF/图片生成流程。 - 后续确认版式后,再把这套渲染参数迁移到主系统的 PDF/图片生成流程。

View File

@@ -4,8 +4,10 @@
const state = document.getElementById("renderState"); const state = document.getElementById("renderState");
const qrScratch = document.getElementById("qrScratch"); const qrScratch = document.getElementById("qrScratch");
const W = canvas.width; const W = 1024;
const H = canvas.height; const H = 759;
let scaleX = 1;
let scaleY = 1;
const text = { const text = {
loading: "\u52a0\u8f7d\u4e2d", loading: "\u52a0\u8f7d\u4e2d",
@@ -53,8 +55,11 @@
controls.showDebug = document.getElementById("showDebug"); controls.showDebug = document.getElementById("showDebug");
const original = new Image(); const original = new Image();
original.src = "./assets/original.jpg"; original.src = "./assets/original.png";
original.onload = render; original.onload = () => {
syncCanvasSize(true);
render();
};
original.onerror = () => { original.onerror = () => {
state.textContent = text.imageFailed; state.textContent = text.imageFailed;
render(); render();
@@ -104,9 +109,12 @@
function render() { function render() {
try { try {
state.textContent = text.rendering; state.textContent = text.rendering;
ctx.clearRect(0, 0, W, H);
const usingOriginal = controls.showOriginal.checked && original.complete && original.naturalWidth; const usingOriginal = controls.showOriginal.checked && original.complete && original.naturalWidth;
syncCanvasSize(usingOriginal);
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.setTransform(scaleX, 0, 0, scaleY, 0, 0);
if (usingOriginal) { if (usingOriginal) {
ctx.drawImage(original, 0, 0, W, H); ctx.drawImage(original, 0, 0, W, H);
@@ -115,7 +123,7 @@
drawFallbackBase(); drawFallbackBase();
} }
drawStudentName(); drawStudentName();
drawMainText(); drawMainText();
drawIssueDate(); drawIssueDate();
drawCertificateNo(); drawCertificateNo();
@@ -139,9 +147,17 @@
} }
} }
function syncCanvasSize(usingOriginal) {
const nextWidth = usingOriginal ? original.naturalWidth : W;
const nextHeight = usingOriginal ? original.naturalHeight : H;
if (canvas.width !== nextWidth) canvas.width = nextWidth;
if (canvas.height !== nextHeight) canvas.height = nextHeight;
scaleX = nextWidth / W;
scaleY = nextHeight / H;
}
const areas = [ const areas = [
{ x: 150, y: 442, w: 760, h: 132, pad: 16 }, { x: 150, y: 455, w: 760, h: 119, pad: 16 }
{ x: 404, y: 675, w: 220, h: 38, pad: 10 }
]; ];
function eraseEditableAreas() { function eraseEditableAreas() {
@@ -152,11 +168,12 @@
if (!original.complete || !original.naturalWidth) return; if (!original.complete || !original.naturalWidth) return;
const pad = area.pad || 8; const pad = area.pad || 8;
const sourceX = 72; const sourceX = Math.round((area.sourceX || 72) * scaleX);
const sourceW = 88; const sourceY = Math.round((area.sourceY || Math.max(0, area.y - pad)) * scaleY);
const sourceW = Math.round(88 * scaleX);
const patch = document.createElement("canvas"); const patch = document.createElement("canvas");
patch.width = area.w + pad * 2; patch.width = Math.round((area.w + pad * 2) * scaleX);
patch.height = area.h + pad * 2; patch.height = Math.round((area.h + pad * 2) * scaleY);
const p = patch.getContext("2d"); const p = patch.getContext("2d");
for (let x = 0; x < patch.width; x += sourceW) { for (let x = 0; x < patch.width; x += sourceW) {
@@ -164,7 +181,7 @@
p.drawImage( p.drawImage(
original, original,
sourceX, sourceX,
Math.max(0, area.y - pad), sourceY,
tileW, tileW,
patch.height, patch.height,
x, x,
@@ -176,7 +193,7 @@
p.globalCompositeOperation = "destination-in"; p.globalCompositeOperation = "destination-in";
p.globalCompositeOperation = "source-over"; p.globalCompositeOperation = "source-over";
p.globalAlpha = 0.38; p.globalAlpha = area.blendAlpha || 0.38;
p.fillStyle = "#fbf7ef"; p.fillStyle = "#fbf7ef";
p.fillRect(0, 0, patch.width, patch.height); p.fillRect(0, 0, patch.width, patch.height);
p.globalAlpha = 1; p.globalAlpha = 1;
@@ -199,7 +216,10 @@
p.fillStyle = gx; p.fillStyle = gx;
p.fillRect(0, 0, patch.width, patch.height); p.fillRect(0, 0, patch.width, patch.height);
ctx.drawImage(patch, area.x - pad, area.y - pad); ctx.save();
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.drawImage(patch, Math.round((area.x - pad) * scaleX), Math.round((area.y - pad) * scaleY));
ctx.restore();
} }
function drawFallbackBase() { function drawFallbackBase() {
@@ -244,17 +264,17 @@
let x = 185; let x = 185;
const y = 494; const y = 494;
x = drawInlineText(text.inWord, x, y, 24, 400, 18); x = drawInlineText(text.inWord, x, y, 24, 400, 18);
x = drawInlineText(value("startYear"), x, y, 24, 900, 8, "dateNumber"); x = drawInlineText(value("startYear"), x, y, 24, 400, 8);
x = drawInlineText(text.year, x, y, 24, 400, 12); x = drawInlineText(text.year, x, y, 24, 400, 12);
x = drawInlineText(value("startMonth"), x, y, 24, 900, 6, "dateNumber"); x = drawInlineText(value("startMonth"), x, y, 24, 400, 6);
x = drawInlineText(text.month, x, y, 24, 400, 12); x = drawInlineText(text.month, x, y, 24, 400, 12);
x = drawInlineText(value("startDay"), x, y, 24, 900, 4, "dateNumber"); x = drawInlineText(value("startDay"), x, y, 24, 400, 4);
x = drawInlineText(`${text.day}${text.toWord}`, x, y, 24, 400, 12); x = drawInlineText(`${text.day}${text.toWord}`, x, y, 24, 400, 12);
x = drawInlineText(value("endYear"), x, y, 24, 900, 8, "dateNumber"); x = drawInlineText(value("endYear"), x, y, 24, 400, 8);
x = drawInlineText(text.year, x, y, 24, 400, 12); x = drawInlineText(text.year, x, y, 24, 400, 12);
x = drawInlineText(value("endMonth"), x, y, 24, 900, 6, "dateNumber"); x = drawInlineText(value("endMonth"), x, y, 24, 400, 6);
x = drawInlineText(text.month, x, y, 24, 400, 12); x = drawInlineText(text.month, x, y, 24, 400, 12);
x = drawInlineText(value("endDay"), x, y, 24, 900, 4, "dateNumber"); x = drawInlineText(value("endDay"), x, y, 24, 400, 4);
x = drawInlineText(`${text.day}\u5b8c\u6210\u4e86`, x, y, 24, 400, 8); x = drawInlineText(`${text.day}\u5b8c\u6210\u4e86`, x, y, 24, 400, 8);
drawInlineFlow([ drawInlineFlow([
{ text: `\u201c${value("courseName")}\u201d`, style: "course" }, { text: `\u201c${value("courseName")}\u201d`, style: "course" },
@@ -346,9 +366,12 @@
function drawIssueDate() { function drawIssueDate() {
ctx.textAlign = "center"; ctx.textAlign = "center";
ctx.fillStyle = "#43382f"; ctx.fillStyle = "#111";
ctx.font = '700 13px "SimSun", serif'; ctx.font = '700 13px "SimSun", serif';
ctx.fillText(`${text.issueDate}${value("issueDate")}`, W / 2, 704); const parts = (value("issueDate").match(/\d+/g) || []);
ctx.fillText(parts[0] || "", 500, 688);
ctx.fillText(parts[1] || "", 535, 688);
ctx.fillText(parts[2] || "", 566, 688);
} }
function drawCertificateNo() { function drawCertificateNo() {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

View File

@@ -99,7 +99,7 @@
<section class="preview"> <section class="preview">
<div class="preview-head"> <div class="preview-head">
<div> <div>
<p class="eyebrow">1024 x 759</p> <p class="eyebrow">3437 x 2551 / 1024 design coordinates</p>
<h2>&#23454;&#26102;&#39044;&#35272;</h2> <h2>&#23454;&#26102;&#39044;&#35272;</h2>
</div> </div>
<span id="renderState">&#21152;&#36733;&#20013;</span> <span id="renderState">&#21152;&#36733;&#20013;</span>

View File

@@ -15,6 +15,8 @@
- `PUBLIC_BASE_URL` 已改成正式域名。 - `PUBLIC_BASE_URL` 已改成正式域名。
- `DATABASE_URL` 指向生产 MySQL。 - `DATABASE_URL` 指向生产 MySQL。
- `DATA_ROOT` 指向生产数据目录。 - `DATA_ROOT` 指向生产数据目录。
- `CORS_ORIGINS` 已改成正式域名。
- 如果正式启用短信验证码,`SMS_MODE=aliyun`,且阿里云短信签名、模板 Code、AccessKey 已配置。
## 数据库 ## 数据库
@@ -47,6 +49,7 @@ docker compose exec backend python -m app.cli init-db --admin-username admin --a
- 公开查询支持两种方式: - 公开查询支持两种方式:
- 证书编号查询:必须输入姓名和图形验证码。 - 证书编号查询:必须输入姓名和图形验证码。
- 短信验证码查询:必须输入手机号和短信验证码。 - 短信验证码查询:必须输入手机号和短信验证码。
- 阿里云短信测试手机号可收到验证码,后端日志没有 `未安装依赖``发送失败`
- 作废证书不能下载正常 PDF。 - 作废证书不能下载正常 PDF。
- 直达链接重置后旧链接失效。 - 直达链接重置后旧链接失效。
- PDF 可生成,二维码可打开核验页。 - PDF 可生成,二维码可打开核验页。

View File

@@ -0,0 +1,783 @@
# 证书系统正式部署操作手册
本文档用于把“培训结业证书管理与查询系统”部署到正式服务器,并完成短信验证码配置、上线验收、日常运维和回滚准备。
适用范围:
- 正式工程:`backend/``frontend/``docker-compose.yml`
- 不适用于本地验收版:`local_app/`
## 1. 部署架构
推荐一期使用单机 Docker Compose
```text
用户浏览器
-> 域名 HTTPS
-> 宿主机 Nginx
-> frontend 容器,端口 8080内置 Nginx负责前端页面和 /api 代理
-> backend 容器,端口 8000FastAPI
-> MySQL 8Compose 内置 MySQL 或阿里云 RDS MySQL
```
正式环境不要直接运行:
```bash
python local_app/server.py
```
正式环境只运行:
```bash
docker compose up -d --build
```
## 2. 服务器准备
建议配置:
- 操作系统Ubuntu 22.04 LTS 或同类 Linux。
- 规格2 核 4G 起步。
- 磁盘:建议至少 40G最好把 `/data` 放到数据盘。
- 域名:已解析到服务器公网 IP。
- HTTPS 证书:已准备好证书文件。
安装 Docker
```bash
apt update
apt install -y docker.io docker-compose-plugin
systemctl enable docker
systemctl start docker
docker --version
docker compose version
```
创建数据目录:
```bash
mkdir -p /data/certificate-system/uploads
mkdir -p /data/certificate-system/error-reports
mkdir -p /data/certificate-system/exports
mkdir -p /data/certificate-system/pdf-cache
mkdir -p /data/backups/mysql
```
## 3. 上传代码
推荐目录:
```text
/opt/certificate-system
```
如果用 Git
```bash
cd /opt
git clone <你的仓库地址> certificate-system
cd /opt/certificate-system
```
如果手动上传压缩包:
```bash
cd /opt
unzip certificate-system.zip
cd /opt/certificate-system
```
## 4. 配置 .env
Docker Compose 使用项目根目录的 `.env`,不是 `backend/.env`
```bash
cd /opt/certificate-system
cp .env.example .env
vim .env
```
### 4.1 使用 Compose 内置 MySQL
`.env` 示例:
```env
APP_ENV=production
DATABASE_URL=mysql+pymysql://certificate:请改成业务库强密码@mysql:3306/certificate_system
SECRET_KEY=请改成至少32字节随机字符串
PUBLIC_BASE_URL=https://你的域名
CERTIFICATE_NO_SECRET=请改成另一串随机字符串
PDF_CACHE_DAYS=30
PDF_CLEANUP_DAYS=5
DATA_ROOT=/data/certificate-system
CORS_ORIGINS=https://你的域名
SMS_MODE=local
ALIYUN_ACCESS_KEY_ID=
ALIYUN_ACCESS_KEY_SECRET=
ALIYUN_SMS_SIGN_NAME=
ALIYUN_SMS_TEMPLATE_CODE=
```
生成随机密钥:
```bash
openssl rand -hex 32
openssl rand -hex 32
```
同时修改 `docker-compose.yml` 里的 MySQL 密码,保证和 `DATABASE_URL` 一致:
```yaml
MYSQL_ROOT_PASSWORD: 请改成root强密码
MYSQL_DATABASE: certificate_system
MYSQL_USER: certificate
MYSQL_PASSWORD: 请改成业务库强密码
```
### 4.2 使用阿里云 RDS MySQL
`.env` 示例:
```env
APP_ENV=production
DATABASE_URL=mysql+pymysql://RDS账号:RDS密码@RDS内网地址:3306/certificate_system
SECRET_KEY=请改成至少32字节随机字符串
PUBLIC_BASE_URL=https://你的域名
CERTIFICATE_NO_SECRET=请改成另一串随机字符串
PDF_CACHE_DAYS=30
PDF_CLEANUP_DAYS=5
DATA_ROOT=/data/certificate-system
CORS_ORIGINS=https://你的域名
SMS_MODE=local
ALIYUN_ACCESS_KEY_ID=
ALIYUN_ACCESS_KEY_SECRET=
ALIYUN_SMS_SIGN_NAME=
ALIYUN_SMS_TEMPLATE_CODE=
```
RDS 检查点:
- ECS 和 RDS 在同一个 VPC。
- RDS 白名单加入 ECS 内网 IP。
- RDS 中已创建数据库 `certificate_system`
- 不建议开放 RDS 公网访问。
如果使用 RDS可以保留 `docker-compose.yml` 里的 `mysql` 服务但不使用;更干净的做法是拆一个生产 compose 文件去掉 `mysql`,一期也可以先保持现状,只要 `DATABASE_URL` 指向 RDS业务后端就不会连接 Compose 内置 MySQL。
## 5. 短信验证码配置
系统支持两种短信模式:
```env
SMS_MODE=local
```
本地测试模式。验证码会打印在后端日志里,不会真的发短信。
```env
SMS_MODE=aliyun
```
阿里云短信模式。系统会调用阿里云短信服务发送验证码。
### 5.1 开通阿里云短信服务
在阿里云控制台操作:
1. 登录阿里云控制台。
2. 搜索“短信服务”,开通短信服务。
3. 进入“国内消息”。
4. 申请短信签名。
5. 申请短信模板。
6. 创建 RAM 用户或 RAM 角色,并授予短信服务发送权限。
7. 创建 AccessKey保存 `AccessKey ID``AccessKey Secret`
安全建议:
- 不要使用主账号 AccessKey。
- 使用 RAM 用户,权限只给短信服务需要的最小权限。
- AccessKey Secret 只会显示一次,要保存到密码管理工具或服务器安全配置中。
### 5.2 申请短信签名
签名是短信开头的 `【xxx】`
建议填写:
```text
签名名称:与你公司/品牌/备案主体一致,例如 慧愈文化
适用场景:验证码
签名来源:企事业单位全称或简称
申请说明:用于学员查询培训结业证书时进行手机号验证码验证
```
审核通过后,把签名名称填到:
```env
ALIYUN_SMS_SIGN_NAME=审核通过的签名名称
```
注意:这里不要写方括号,例如写 `慧愈文化`,不要写 `【慧愈文化】`
### 5.3 申请短信模板
当前代码发送的模板变量是:
```json
{"code":"123456"}
```
所以阿里云模板必须包含变量:
```text
${code}
```
推荐模板内容:
```text
您的验证码是${code}5分钟内有效请勿泄露给他人。
```
审核通过后,拿到模板 Code例如
```text
SMS_123456789
```
填到:
```env
ALIYUN_SMS_TEMPLATE_CODE=SMS_123456789
```
### 5.4 配置生产 .env
正式发短信时,将 `.env` 改成:
```env
SMS_MODE=aliyun
ALIYUN_ACCESS_KEY_ID=你的AccessKeyID
ALIYUN_ACCESS_KEY_SECRET=你的AccessKeySecret
ALIYUN_SMS_SIGN_NAME=你的短信签名
ALIYUN_SMS_TEMPLATE_CODE=你的短信模板Code
```
配置完成后必须重建后端镜像,因为阿里云 SDK 是后端依赖:
```bash
docker compose up -d --build backend
docker compose restart frontend
```
或者整体重建:
```bash
docker compose up -d --build
```
### 5.5 验证短信配置
先看配置是否进入容器:
```bash
docker compose exec backend env | grep -E "SMS_MODE|ALIYUN"
```
直接从后端容器发一条测试短信到自己的手机号:
```bash
docker compose exec backend python - <<'PY'
from app.services.sms import send_sms
phone = "请改成你的手机号"
ok = send_sms(phone, "123456")
print("send result:", ok)
PY
```
再看日志:
```bash
docker compose logs -f backend | grep -E "阿里云短信|短信验证码|未安装依赖"
```
成功时通常能看到:
```text
[阿里云短信] 发送成功: 138xxxx0000
```
然后做完整业务验证:
1. 打开 `https://你的域名/query`
2. 切到“短信验证码查询”。
3. 输入测试手机号。
4. 获取验证码。
5. 输入短信验证码。
6. 查询证书。
### 5.6 短信常见问题
如果日志出现:
```text
未安装依赖
```
说明镜像里没有安装阿里云 SDK。确认 `backend/requirements.txt` 包含:
```text
alibabacloud-dysmsapi20170525==4.5.1
```
然后执行:
```bash
docker compose up -d --build backend
```
如果发送失败,优先检查:
- `SMS_MODE` 是否等于 `aliyun`
- `ALIYUN_ACCESS_KEY_ID` 是否正确。
- `ALIYUN_ACCESS_KEY_SECRET` 是否正确。
- `ALIYUN_SMS_SIGN_NAME` 是否是审核通过的签名名称,不带 `【】`
- `ALIYUN_SMS_TEMPLATE_CODE` 是否是审核通过的模板 Code。
- 模板变量是否是 `${code}`
- 阿里云账户是否欠费。
- RAM 用户是否有短信发送权限。
- 手机号是否触发阿里云风控。
系统自身限制:
- 同一 IP每分钟最多 5 次发送短信。
- 同一手机号5 分钟最多 3 次发送短信。
- 短信验证码有效期5 分钟。
- 当前验证码保存在后端进程内存中。单机部署没问题;如果后续扩成多个 backend 实例,需要把验证码存储迁移到 Redis 或数据库,否则不同实例之间验证码不共享。
## 6. 首次启动
在项目根目录:
```bash
cd /opt/certificate-system
docker compose up -d --build
docker compose ps
```
看后端日志:
```bash
docker compose logs -f backend
```
看前端日志:
```bash
docker compose logs -f frontend
```
## 7. 初始化数据库
首次部署执行:
```bash
docker compose exec backend python -m app.cli init-db --admin-username admin --admin-password "请换成强密码"
```
这个命令会:
- 执行 Alembic 数据库迁移。
- 创建基础角色。
- 创建默认管理员。
- 初始化默认项目配置。
以后升级代码时,一般执行:
```bash
docker compose exec backend alembic upgrade head
```
## 8. 配置 HTTPS 和域名
### 8.1 临时访问
如果只做内部验收,可以临时访问:
```text
http://服务器IP:8080/admin/login
```
此时安全组需要开放 `8080`
### 8.2 正式访问
正式建议宿主机 Nginx 负责 HTTPS反向代理到前端容器 `127.0.0.1:8080`
宿主机 Nginx 示例:
```nginx
server {
listen 80;
server_name 你的域名;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name 你的域名;
ssl_certificate /etc/nginx/certs/fullchain.pem;
ssl_certificate_key /etc/nginx/certs/privkey.pem;
client_max_body_size 20m;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 30s;
proxy_send_timeout 120s;
proxy_read_timeout 120s;
}
}
```
检查并重载:
```bash
nginx -t
systemctl reload nginx
```
安全组建议开放:
```text
22 SSH最好限制办公 IP
80 HTTP
443 HTTPS
```
正式环境不建议公网开放:
```text
3306 MySQL
8000 后端
8080 前端容器端口
```
如果宿主机 Nginx 已经代理到 `8080`,可以在安全组关闭公网 `8080`
## 9. 上线验收
### 9.1 后台登录
访问:
```text
https://你的域名/admin/login
```
检查:
- 管理员账号可以登录。
- 默认密码已改为强密码。
- 进入“数据概览”没有 401、403、502。
### 9.2 项目管理
检查:
- 能新增项目。
- 能修改项目。
- 能启用/停用项目。
- 默认课程名称、阶段名称、发证单位保存后生效。
### 9.3 学员管理
检查:
- 能新增学员。
- 手机号格式正确。
- 能修改学员。
- 能删除测试学员。
### 9.4 证书管理和 PDF
检查:
- 能新增证书。
- 证书编号自动生成。
- 能预览证书。
- 能下载 PDF。
- PDF 底图、姓名、日期、课程、导师、二维码清晰。
- 作废证书后不能下载正常 PDF。
- 重置链接后旧链接失效。
### 9.5 Excel 导入
检查模板字段:
```text
姓名
手机号
项目代码
发证日期
```
检查:
- 能下载模板。
- 能上传 Excel。
- 错误数据能生成错误报告。
- 校验通过后能确认导入。
- 确认导入后能生成学员和证书。
- 能下载导入原文件。
### 9.6 公开查询
检查:
- `/query` 可以打开。
- 证书编号 + 姓名 + 图形验证码可以查询。
- 手机号 + 短信验证码可以查询。
- 有效证书可以下载 PDF。
- 作废证书显示作废状态。
- 证书直达链接 `/cert/xxx` 可以打开。
- 二维码核验链接 `/verify/xxx` 可以打开。
## 10. 日常运维命令
查看容器:
```bash
docker compose ps
```
查看后端日志:
```bash
docker compose logs -f backend
```
查看前端日志:
```bash
docker compose logs -f frontend
```
重启后端:
```bash
docker compose restart backend
```
重启全部服务:
```bash
docker compose restart
```
更新代码:
```bash
cd /opt/certificate-system
git pull
docker compose up -d --build
docker compose exec backend alembic upgrade head
```
停止服务:
```bash
docker compose down
```
不要随便执行:
```bash
docker compose down -v
```
`-v` 会删除 Docker volume可能导致 Compose 内置 MySQL 数据丢失。
## 11. 备份
### 11.1 Compose 内置 MySQL
手动备份:
```bash
mkdir -p /data/backups/mysql
cd /opt/certificate-system
docker compose exec -T mysql mysqldump -ucertificate -p你的业务库密码 certificate_system > /data/backups/mysql/certificate_system_$(date +%F).sql
```
定时备份:
```bash
crontab -e
```
加入:
```cron
30 2 * * * cd /opt/certificate-system && docker compose exec -T mysql mysqldump -ucertificate -p你的业务库密码 certificate_system > /data/backups/mysql/certificate_system_$(date +\%F).sql
10 3 * * * find /data/backups/mysql -name "certificate_system_*.sql" -mtime +30 -delete
```
### 11.2 RDS MySQL
建议:
- 开启自动备份。
- 上线前手动创建一次快照。
- 大版本升级前手动创建一次快照。
- 保留至少 7 到 30 天备份。
### 11.3 文件数据
需要备份:
```text
/data/certificate-system/uploads
/data/certificate-system/error-reports
/data/certificate-system/exports
```
可以不做长期备份:
```text
/data/certificate-system/pdf-cache
```
PDF 缓存可以重新生成,核心数据仍以数据库和上传原文件为准。
## 12. 回滚
上线前记录当前版本:
```bash
cd /opt/certificate-system
git rev-parse HEAD
```
普通代码回滚:
```bash
cd /opt/certificate-system
git checkout 上一个稳定commit
docker compose up -d --build
```
如果本次上线执行过数据库迁移,先不要盲目回滚数据库。正确顺序:
1. 上线前先备份数据库。
2. 如果代码回滚后仍能兼容新表结构,只回滚代码。
3. 如果迁移破坏兼容性,从备份恢复数据库。
恢复 Compose MySQL 备份示例:
```bash
cat /data/backups/mysql/certificate_system_YYYY-MM-DD.sql | docker compose exec -T mysql mysql -ucertificate -p你的业务库密码 certificate_system
```
## 13. 常见问题排查
### 13.1 页面打不开
```bash
docker compose ps
docker compose logs -f frontend
```
检查:
- 前端容器是否启动。
- 服务器安全组是否开放 80/443。
- 域名是否解析到服务器。
- 宿主机 Nginx 是否重载成功。
### 13.2 后台接口 502
```bash
docker compose logs -f backend
docker compose ps
```
检查:
- backend 容器是否启动。
- 数据库是否连接成功。
- `DATABASE_URL` 是否正确。
- MySQL 密码是否和 `docker-compose.yml` 一致。
### 13.3 登录后 401 或 403
检查:
- `.env``SECRET_KEY` 是否重启前后变化。
- 管理员账号是否 active。
- 角色是否存在。
- 是否执行过 `init-db`
### 13.4 PDF 中文乱码或不清楚
检查:
- `backend/Dockerfile` 是否安装 `fonts-noto-cjk`
- `backend/app/assets/certificate-template.png` 是否存在。
- 是否重建了后端镜像。
命令:
```bash
docker compose exec backend ls -lh /app/app/assets
docker compose up -d --build backend
```
### 13.5 短信收不到
先确认是不是还在本地模式:
```bash
docker compose exec backend env | grep SMS_MODE
```
如果输出:
```text
SMS_MODE=local
```
说明不会真实发送短信,只会打印到日志。
如果是阿里云模式:
```bash
docker compose logs -f backend | grep -E "阿里云短信|未安装依赖|发送失败"
```
重点检查签名、模板、AccessKey、账户余额和 RAM 权限。
## 14. 交接清单
交接给运维或客户前,至少交付:
- 服务器 IP。
- 域名。
- HTTPS 证书位置。
- 项目部署目录:`/opt/certificate-system`
- 数据目录:`/data/certificate-system`
- 数据库类型Compose MySQL 或 RDS。
- 数据库备份策略。
- 管理员账号,不要明文写在文档里。
- `.env` 变量说明,不要泄露真实密钥。
- 阿里云短信签名名称和模板 Code。
- 阿里云 RAM 用户权限说明。
- 回滚版本 commit。

View File

@@ -49,22 +49,24 @@
</div> </div>
<el-table :data="certificates" border> <el-table :data="certificates" border>
<el-table-column prop="certificate_no" label="证书编号" width="220" /> <el-table-column prop="certificate_no" label="证书编号" width="180" show-overflow-tooltip />
<el-table-column prop="learner_id" label="学员ID" width="90" /> <el-table-column prop="learner_id" label="学员ID" width="76" />
<el-table-column prop="project_code" label="项目" width="90" /> <el-table-column prop="project_code" label="项目" width="72" />
<el-table-column prop="certificate_name" label="证书名称" min-width="180" /> <el-table-column prop="certificate_name" label="证书名称" min-width="140" show-overflow-tooltip />
<el-table-column prop="issue_date" label="发证日期" width="120" /> <el-table-column prop="issue_date" label="发证日期" width="108" />
<el-table-column label="状态" width="100"> <el-table-column label="状态" width="80">
<template #default="{ row }"> <template #default="{ row }">
<el-tag :type="row.status === 'valid' ? 'success' : 'danger'">{{ statusText(row.status) }}</el-tag> <el-tag :type="row.status === 'valid' ? 'success' : 'danger'">{{ statusText(row.status) }}</el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="操作" width="330"> <el-table-column label="操作" width="270">
<template #default="{ row }"> <template #default="{ row }">
<el-button size="small" @click="previewCertificate(row)">预览</el-button> <div class="table-actions">
<el-button size="small" @click="downloadCertificate(row)">下载</el-button> <el-button size="small" @click="previewCertificate(row)">预览</el-button>
<el-button size="small" @click="regenerateToken(row)">重置链接</el-button> <el-button size="small" @click="downloadCertificate(row)">下载</el-button>
<el-button v-if="row.status === 'valid'" size="small" type="danger" @click="voidCertificate(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>
</div>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
@@ -221,4 +223,14 @@ onMounted(async () => {
line-height: 1.6; line-height: 1.6;
margin-top: 4px; margin-top: 4px;
} }
.table-actions {
display: flex;
gap: 8px;
flex-wrap: nowrap;
}
.table-actions :deep(.el-button + .el-button) {
margin-left: 0;
}
</style> </style>

View File

@@ -8,6 +8,7 @@
"jsx": "preserve", "jsx": "preserve",
"resolveJsonModule": true, "resolveJsonModule": true,
"isolatedModules": true, "isolatedModules": true,
"skipLibCheck": true,
"noEmit": true, "noEmit": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"], "lib": ["ES2022", "DOM", "DOM.Iterable"],
"types": [] "types": []

View File

@@ -1,13 +1,15 @@
import { defineConfig } from "vite"; import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue"; import vue from "@vitejs/plugin-vue";
const apiProxyTarget = process.env.VITE_API_PROXY_TARGET || "http://localhost:8000";
export default defineConfig({ export default defineConfig({
plugins: [vue()], plugins: [vue()],
server: { server: {
port: 5173, port: 5173,
proxy: { proxy: {
"/api": { "/api": {
target: "http://localhost:8000", target: apiProxyTarget,
changeOrigin: true, changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, "") rewrite: (path) => path.replace(/^\/api/, "")
} }

View File

@@ -31,6 +31,9 @@ CAPTCHAS: dict[str, tuple[str, float]] = {}
SMS_CODES: dict[str, tuple[str, float]] = {} SMS_CODES: dict[str, tuple[str, float]] = {}
LOG_RETENTION_DAYS = 180 LOG_RETENTION_DAYS = 180
HIGH_RISK_LOG_RETENTION_DAYS = 365 HIGH_RISK_LOG_RETENTION_DAYS = 365
DESIGN_WIDTH = 1024
DESIGN_HEIGHT = 759
PDF_BASE_RESOLUTION = 150.0
COL_NAME = "\u59d3\u540d" COL_NAME = "\u59d3\u540d"
COL_PHONE = "\u624b\u673a\u53f7" COL_PHONE = "\u624b\u673a\u53f7"
@@ -440,38 +443,51 @@ def font_name() -> str:
return "Helvetica" return "Helvetica"
def pil_font(size: int, kind: str = "song", bold: bool = False) -> ImageFont.FreeTypeFont | ImageFont.ImageFont: def pil_font(size: int, kind: str = "song", bold: bool = False, scale: float = 1.0) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
choices = { choices = {
"kai": [ "kai": [
r"C:\Windows\Fonts\simkai.ttf", r"C:\Windows\Fonts\msyh.ttc", r"C:\Windows\Fonts\simsun.ttc", 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/Supplemental/Kaiti.ttc",
("/System/Library/Fonts/Hiragino Sans GB.ttc", 2),
"/System/Library/Fonts/PingFang.ttc", "/System/Library/Fonts/PingFang.ttc",
"/System/Library/Fonts/Supplemental/Songti.ttc", ("/System/Library/Fonts/Supplemental/Songti.ttc", 1),
], ],
"hei": [ "hei": [
r"C:\Windows\Fonts\simhei.ttf", r"C:\Windows\Fonts\msyhbd.ttc", r"C:\Windows\Fonts\msyh.ttc", r"C:\Windows\Fonts\simhei.ttf", r"C:\Windows\Fonts\msyhbd.ttc", r"C:\Windows\Fonts\msyh.ttc",
"/System/Library/Fonts/STHeiti Medium.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),
"/System/Library/Fonts/Supplemental/Heiti.ttc", "/System/Library/Fonts/Supplemental/Heiti.ttc",
"/System/Library/Fonts/PingFang.ttc", "/System/Library/Fonts/PingFang.ttc",
], ],
"song": [ "song": [
r"C:\Windows\Fonts\msyh.ttc", r"C:\Windows\Fonts\simsun.ttc", r"C:\Windows\Fonts\msyh.ttc", r"C:\Windows\Fonts\simsun.ttc",
"/System/Library/Fonts/Supplemental/Songti.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/PingFang.ttc", "/System/Library/Fonts/PingFang.ttc",
"/System/Library/Fonts/STHeiti Light.ttc", ("/System/Library/Fonts/STHeiti Light.ttc", 1),
], ],
} }
paths = choices.get(kind, choices["song"]) paths = choices.get(kind, choices["song"])
if bold and kind == "song": if bold and kind == "song":
paths = [ paths = [
r"C:\Windows\Fonts\msyhbd.ttc", r"C:\Windows\Fonts\simhei.ttf", r"C:\Windows\Fonts\msyhbd.ttc", r"C:\Windows\Fonts\simhei.ttf",
"/System/Library/Fonts/STHeiti Medium.ttc", "/usr/share/fonts/opentype/noto/NotoSansCJK-Bold.ttc",
("/System/Library/Fonts/STHeiti Medium.ttc", 1),
("/System/Library/Fonts/Hiragino Sans GB.ttc", 2),
"/System/Library/Fonts/Supplemental/Heiti.ttc", "/System/Library/Fonts/Supplemental/Heiti.ttc",
] + paths ] + paths
for item in paths: for item in paths:
if Path(item).exists(): path, index = item if isinstance(item, tuple) else (item, 0)
if Path(path).exists():
try: try:
return ImageFont.truetype(item, size) return ImageFont.truetype(path, max(1, round(size * scale)), index=index)
except Exception: except Exception:
pass pass
return ImageFont.load_default() return ImageFont.load_default()
@@ -500,12 +516,23 @@ def draw_fit(draw: ImageDraw.ImageDraw, pos: tuple[int, int], text: str, font: I
draw.text(pos, text, fill=fill, font=font_obj) draw.text(pos, text, fill=fill, font=font_obj)
def draw_inline(draw: ImageDraw.ImageDraw, x: float, y: int, text: str, size: int, gap: int, kind: str = "song", bold: bool = False) -> float: def draw_inline(
font = pil_font(size, kind, bold) draw: ImageDraw.ImageDraw,
x: float,
y: float,
text: str,
size: int,
gap: int,
kind: str = "song",
bold: bool = False,
scale: float = 1.0,
x_scale: float = 1.0,
) -> float:
font = pil_font(size, kind, bold, scale)
draw.text((x, y), text, fill="#111111", font=font, anchor="ls") draw.text((x, y), text, fill="#111111", font=font, anchor="ls")
if bold: if bold:
draw.text((x + 0.45, y), text, fill="#111111", font=font, anchor="ls") draw.text((x + 0.45 * x_scale, y), text, fill="#111111", font=font, anchor="ls")
return x + draw.textlength(text, font=font) + gap return x + draw.textlength(text, font=font) + gap * x_scale
def draw_course_name(draw: ImageDraw.ImageDraw, text: str, x: float, y: int) -> int: def draw_course_name(draw: ImageDraw.ImageDraw, text: str, x: float, y: int) -> int:
@@ -525,23 +552,31 @@ def draw_course_name(draw: ImageDraw.ImageDraw, text: str, x: float, y: int) ->
return start_y + (len(lines) - 1) * 34 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 x = start_x
y = start_y y = start_y
line_start = 185 line_start = 185 * x_scale
line_height = 42 line_height = 42 * y_scale
for text, style, gap_before in segments: for text, style, gap_before in segments:
x += gap_before x += gap_before * x_scale
text_font = pil_font(27, "kai", True) if style == "course" else pil_font(24, "song", False) text_font = pil_font(27, "kai", True, scale) if style == "course" else pil_font(24, "song", False, scale)
for char in text: for char in text:
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) width = draw.textlength(char, font=text_font)
if x + width > max_right and x > line_start: if x + width > max_right and x > line_start:
x = line_start x = line_start
y += line_height y += line_height
draw.text((x, y), char, fill="#111111", font=text_font, anchor="ls") draw.text((x, y), char, fill="#111111", font=text_font, anchor="ls")
if style == "course": 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 x += width
@@ -560,55 +595,119 @@ def split_by_width(draw: ImageDraw.ImageDraw, text: str, text_font: ImageFont.Im
return lines return lines
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 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") 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): for tx in range(0, w, source.width):
patch.paste(source.crop((0, 0, min(source.width, w - tx), h)), (tx, 0)) patch.paste(source.crop((0, 0, min(source.width, w - tx), h)), (tx, 0))
cover = Image.new("RGB", (w, h), "#fbf7ef") cover = Image.new("RGB", (w, h), "#fbf7ef")
patch = Image.blend(patch, cover, 0.38) patch = Image.blend(patch, cover, 0.38)
image.paste(patch, (x, y)) image.paste(patch, (x, y), soft_edge_mask(w, h, round(16 * x_scale), round(10 * y_scale)))
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 = pil_font(13, "song", True, scale)
y = 688 * y_scale
for text, x in [(issue_year, 500), (issue_month, 535), (issue_day, 566)]:
draw.text((x * x_scale, y), text, fill="#111111", font=text_font, anchor="mm")
def certificate_template_path() -> Path:
assets_dir = ROOT / "static" / "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_image(conn: sqlite3.Connection, cert: sqlite3.Row) -> Image.Image: def render_certificate_image(conn: sqlite3.Connection, cert: sqlite3.Row) -> Image.Image:
template = ROOT / "static" / "assets" / "certificate-template.jpg" template = certificate_template_path()
image = Image.open(template).convert("RGB") image = Image.open(template).convert("RGB")
base_image = image.copy() base_image = image.copy()
draw = ImageDraw.Draw(image) draw = ImageDraw.Draw(image)
x_scale = image.width / DESIGN_WIDTH
y_scale = image.height / DESIGN_HEIGHT
text_scale = (x_scale + y_scale) / 2
learner = conn.execute("select * from learners where id=?", (cert["learner_id"],)).fetchone() learner = conn.execute("select * from learners where id=?", (cert["learner_id"],)).fetchone()
learner_name = (learner["current_name"] if learner else "") or "" learner_name = (learner["current_name"] if learner else "") or ""
issue_year, issue_month, issue_day = date_parts(cert["issue_date"]) issue_year, issue_month, issue_day = date_parts(cert["issue_date"])
for box in [(150, 442, 760, 132), (404, 675, 220, 38)]: paper_patch(image, (150, 455, 760, 119), x_scale, y_scale)
paper_patch(image, box)
draw.text((512, 414), learner_name, fill="#111111", font=pil_font(32, "kai", True), anchor="mm") draw.text(
(512 * x_scale, 414 * y_scale),
learner_name,
fill="#111111",
font=pil_font(32, "kai", True, text_scale),
anchor="mm",
)
x = 185.0 x = 185.0 * x_scale
y = 494 y = 494 * y_scale
x = draw_inline(draw, x, y, "\u5728", 24, 18) 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, "kai", True) 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) 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, "kai", True) 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) 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, "kai", True) 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) 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, "kai", True) 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) 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, "kai", True) 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) 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, "kai", True) 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) x = draw_inline(draw, x, y, "\u65e5\u5b8c\u6210\u4e86", 24, 8, scale=text_scale, x_scale=x_scale)
course_text = f"\u201c{cert['course_name'] or cert['certificate_name'] or cert['project_code']}\u201d" course_text = f"\u201c{cert['course_name'] or cert['certificate_name'] or cert['project_code']}\u201d"
stage_input = (cert["stage_name"] or "").strip() stage_input = (cert["stage_name"] or "").strip()
stage = 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" stage = 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"
draw_inline_flow(draw, [(course_text, "course", 0), (stage, "normal", 14)], x, y) draw_inline_flow(draw, [(course_text, "course", 0), (stage, "normal", 14)], x, y, text_scale, x_scale, y_scale)
issue_label = f"\u53d1\u8bc1\u65e5\u671f\uff1a{issue_year} \u5e74 {issue_month} \u6708 {issue_day} \u65e5" draw_issue_date_numbers(draw, issue_year, issue_month, issue_day, text_scale, x_scale, y_scale)
draw.text((512, 704), issue_label, fill="#43382f", font=pil_font(13, "song", True), anchor="mm") draw.text(
draw.text((105, 76), f"\u8bc1\u4e66\u7f16\u53f7\uff1a{cert['certificate_no']}", fill="#111827", font=pil_font(14, "hei", True)) (105 * x_scale, 76 * y_scale),
image.paste(base_image.crop((720, 535, 950, 629)), (720, 535)) f"\u8bc1\u4e66\u7f16\u53f7\uff1a{cert['certificate_no']}",
fill="#111827",
font=pil_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 return image
@@ -616,12 +715,12 @@ def build_pdf(conn: sqlite3.Connection, cert: sqlite3.Row) -> Path:
pdf_dir = DATA / "pdf-cache" pdf_dir = DATA / "pdf-cache"
pdf_dir.mkdir(exist_ok=True) pdf_dir.mkdir(exist_ok=True)
path = pdf_dir / f"{cert['certificate_no']}.pdf" path = pdf_dir / f"{cert['certificate_no']}.pdf"
template = ROOT / "static" / "assets" / "certificate-template.jpg" template = certificate_template_path()
renderer_mtime = Path(__file__).stat().st_mtime renderer_mtime = Path(__file__).stat().st_mtime
if path.exists() and path.stat().st_mtime >= max(template.stat().st_mtime, renderer_mtime): if path.exists() and path.stat().st_mtime >= max(template.stat().st_mtime, renderer_mtime):
return path return path
image = render_certificate_image(conn, cert) image = render_certificate_image(conn, cert)
image.save(path, "PDF", resolution=150.0) image.save(path, "PDF", resolution=pdf_resolution(image))
conn.execute("update certificates set pdf_status=? where id=?", ("generated", cert["id"])) conn.execute("update certificates set pdf_status=? where id=?", ("generated", cert["id"]))
return path return path

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

View File

@@ -702,6 +702,64 @@
gap: 12px; gap: 12px;
margin-bottom: 16px; margin-bottom: 16px;
} }
.toolbar input {
flex: 1;
min-width: 240px;
max-width: 460px;
}
.toolbar select {
min-width: 140px;
}
.table-wrap {
width: 100%;
overflow-x: auto;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--surface);
}
table {
width: 100%;
min-width: 680px;
border-collapse: collapse;
}
th,
td {
padding: 12px 14px;
border-bottom: 1px solid var(--border);
text-align: left;
vertical-align: middle;
white-space: nowrap;
}
th {
background: var(--surface-soft);
color: var(--muted);
font-size: 13px;
font-weight: 700;
}
td {
color: var(--text);
font-size: 14px;
}
tbody tr:last-child td {
border-bottom: 0;
}
tbody tr:hover {
background: var(--primary-light);
}
td:last-child,
th:last-child {
min-width: 168px;
}
.actions {
flex-wrap: nowrap;
}
.actions button {
flex-shrink: 0;
height: 36px;
padding: 0 14px;
border-radius: 10px;
font-size: 14px;
}
.pager { .pager {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -775,7 +833,7 @@
.actions { .actions {
display: flex; display: flex;
gap: 8px; gap: 8px;
flex-wrap: wrap; flex-wrap: nowrap;
} }
.dialog { .dialog {
position: fixed; position: fixed;
@@ -836,6 +894,19 @@
.side { position: static; } .side { position: static; }
.form-grid, .stats { grid-template-columns: 1fr; } .form-grid, .stats { grid-template-columns: 1fr; }
.cert-meta { grid-template-columns: 1fr; } .cert-meta { grid-template-columns: 1fr; }
.main {
padding: 20px;
}
.toolbar {
align-items: stretch;
flex-direction: column;
}
.toolbar input,
.toolbar select,
.toolbar button {
width: 100%;
max-width: none;
}
} }
/* 图形验证码弹窗 */ /* 图形验证码弹窗 */
@@ -1130,9 +1201,9 @@
function esc(v) { return String(v ?? "").replace(/[&<>"']/g, s => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[s])); } function esc(v) { return String(v ?? "").replace(/[&<>"']/g, s => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[s])); }
function statusPill(value) { return `<span class="pill ${value === "voided" || value === "disabled" || value === "deleted" ? "bad" : ""}">${esc(value)}</span>`; } function statusPill(value) { return `<span class="pill ${value === "voided" || value === "disabled" || value === "deleted" ? "bad" : ""}">${esc(value)}</span>`; }
function table(rows, cols, actions) { function table(rows, cols, actions) {
if (!rows.length) return `<div class="panel"><p class="muted">暂无数据</p></div>`; if (!rows.length) return `<p class="muted">暂无数据</p>`;
return `<table><thead><tr>${cols.map(c=>`<th>${c[1]}</th>`).join("")}${actions?"<th>操作</th>":""}</tr></thead><tbody>` + return `<div class="table-wrap"><table><thead><tr>${cols.map(c=>`<th>${c[1]}</th>`).join("")}${actions?"<th>操作</th>":""}</tr></thead><tbody>` +
rows.map(r => `<tr>${cols.map(c=>`<td>${c[2] ? c[2](r[c[0]], r) : esc(r[c[0]])}</td>`).join("")}${actions?`<td><div class="actions">${actions(r)}</div></td>`:""}</tr>`).join("") + `</tbody></table>`; rows.map(r => `<tr>${cols.map(c=>`<td>${c[2] ? c[2](r[c[0]], r) : esc(r[c[0]])}</td>`).join("")}${actions?`<td><div class="actions">${actions(r)}</div></td>`:""}</tr>`).join("") + `</tbody></table></div>`;
} }
function showDialog(title, html) { dialogTitle.textContent = title; dialogBody.innerHTML = html; dialog.classList.remove("hidden"); } function showDialog(title, html) { dialogTitle.textContent = title; dialogBody.innerHTML = html; dialog.classList.remove("hidden"); }
function closeDialog() { dialog.classList.add("hidden"); if (previewObjectUrl) { URL.revokeObjectURL(previewObjectUrl); previewObjectUrl = ""; } } function closeDialog() { dialog.classList.add("hidden"); if (previewObjectUrl) { URL.revokeObjectURL(previewObjectUrl); previewObjectUrl = ""; } }