优化证书生成与正式部署配置
This commit is contained in:
@@ -5,3 +5,11 @@ PUBLIC_BASE_URL=http://localhost:8080
|
||||
CERTIFICATE_NO_SECRET=change-me
|
||||
PDF_CACHE_DAYS=30
|
||||
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=
|
||||
|
||||
@@ -63,5 +63,6 @@ reset-local-data.bat
|
||||
|
||||
- [开发决策记录](docs/decision-log.md)
|
||||
- [MVP 范围说明](docs/mvp-scope.md)
|
||||
- [正式部署操作手册](docs/正式部署操作手册.md)
|
||||
- [部署与使用手册](docs/usage-deployment-manual.md)
|
||||
- [上线检查清单](docs/deployment-checklist.md)
|
||||
|
||||
@@ -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_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.alter_column("project_courses", "default_certificate_name", nullable=False)
|
||||
op.alter_column("project_courses", "default_issuer_name", nullable=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
|
||||
@@ -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 ###
|
||||
@@ -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
|
||||
@@ -1,5 +1,7 @@
|
||||
# 阿里云短信服务部署指南
|
||||
|
||||
> 注意:这份文档是早期短信接入草稿,里面包含“修改 config.py / 替换 sms.py”等旧步骤。当前项目已经内置 `SMS_MODE=local/aliyun` 和阿里云短信发送逻辑,正式部署请以 `docs/正式部署操作手册.md` 的“短信验证码配置”章节为准。
|
||||
|
||||
## 前置条件
|
||||
|
||||
- 阿里云账号(已实名认证)
|
||||
|
||||
@@ -9,3 +9,4 @@ qrcode[pil]==8.0
|
||||
playwright==1.49.1
|
||||
jinja2==3.1.5
|
||||
pytest==8.3.4
|
||||
alibabacloud-dysmsapi20170525==4.5.1
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
from datetime import date
|
||||
|
||||
from app.api.routes.admin_imports import (
|
||||
COL_CERT_NAME,
|
||||
COL_ISSUE_DATE,
|
||||
COL_NAME,
|
||||
COL_PHONE,
|
||||
COL_PROJECT,
|
||||
COL_ISSUER,
|
||||
date_is_valid,
|
||||
parse_issue_date,
|
||||
row_errors,
|
||||
@@ -23,9 +21,7 @@ def test_row_errors_require_project_code_to_exist():
|
||||
COL_NAME: "张三",
|
||||
COL_PHONE: "13800000000",
|
||||
COL_PROJECT: "BAD",
|
||||
COL_CERT_NAME: "结业证书",
|
||||
COL_ISSUE_DATE: "2026-06-01",
|
||||
COL_ISSUER: "测试公司",
|
||||
}
|
||||
assert "Project code is inactive or missing" in row_errors(row, {"DBY"})
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ http://127.0.0.1:8020
|
||||
|
||||
## 当前用途
|
||||
|
||||
- 以 `assets/original.jpg` 作为证书底图。
|
||||
- 以 `assets/original.png` 作为高清证书底图。
|
||||
- 擦除可变文字区域并重新绘制学员、课程、日期、单位、证书编号等信息。
|
||||
- 支持下载 PNG,用于先确认视觉效果。
|
||||
- 后续确认版式后,再把这套渲染参数迁移到主系统的 PDF/图片生成流程。
|
||||
|
||||
@@ -4,8 +4,10 @@
|
||||
const state = document.getElementById("renderState");
|
||||
const qrScratch = document.getElementById("qrScratch");
|
||||
|
||||
const W = canvas.width;
|
||||
const H = canvas.height;
|
||||
const W = 1024;
|
||||
const H = 759;
|
||||
let scaleX = 1;
|
||||
let scaleY = 1;
|
||||
|
||||
const text = {
|
||||
loading: "\u52a0\u8f7d\u4e2d",
|
||||
@@ -53,8 +55,11 @@
|
||||
controls.showDebug = document.getElementById("showDebug");
|
||||
|
||||
const original = new Image();
|
||||
original.src = "./assets/original.jpg";
|
||||
original.onload = render;
|
||||
original.src = "./assets/original.png";
|
||||
original.onload = () => {
|
||||
syncCanvasSize(true);
|
||||
render();
|
||||
};
|
||||
original.onerror = () => {
|
||||
state.textContent = text.imageFailed;
|
||||
render();
|
||||
@@ -104,9 +109,12 @@
|
||||
function render() {
|
||||
try {
|
||||
state.textContent = text.rendering;
|
||||
ctx.clearRect(0, 0, W, H);
|
||||
|
||||
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) {
|
||||
ctx.drawImage(original, 0, 0, W, H);
|
||||
@@ -115,7 +123,7 @@
|
||||
drawFallbackBase();
|
||||
}
|
||||
|
||||
drawStudentName();
|
||||
drawStudentName();
|
||||
drawMainText();
|
||||
drawIssueDate();
|
||||
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 = [
|
||||
{ x: 150, y: 442, w: 760, h: 132, pad: 16 },
|
||||
{ x: 404, y: 675, w: 220, h: 38, pad: 10 }
|
||||
{ x: 150, y: 455, w: 760, h: 119, pad: 16 }
|
||||
];
|
||||
|
||||
function eraseEditableAreas() {
|
||||
@@ -152,11 +168,12 @@
|
||||
if (!original.complete || !original.naturalWidth) return;
|
||||
|
||||
const pad = area.pad || 8;
|
||||
const sourceX = 72;
|
||||
const sourceW = 88;
|
||||
const sourceX = Math.round((area.sourceX || 72) * scaleX);
|
||||
const sourceY = Math.round((area.sourceY || Math.max(0, area.y - pad)) * scaleY);
|
||||
const sourceW = Math.round(88 * scaleX);
|
||||
const patch = document.createElement("canvas");
|
||||
patch.width = area.w + pad * 2;
|
||||
patch.height = area.h + pad * 2;
|
||||
patch.width = Math.round((area.w + pad * 2) * scaleX);
|
||||
patch.height = Math.round((area.h + pad * 2) * scaleY);
|
||||
const p = patch.getContext("2d");
|
||||
|
||||
for (let x = 0; x < patch.width; x += sourceW) {
|
||||
@@ -164,7 +181,7 @@
|
||||
p.drawImage(
|
||||
original,
|
||||
sourceX,
|
||||
Math.max(0, area.y - pad),
|
||||
sourceY,
|
||||
tileW,
|
||||
patch.height,
|
||||
x,
|
||||
@@ -176,7 +193,7 @@
|
||||
|
||||
p.globalCompositeOperation = "destination-in";
|
||||
p.globalCompositeOperation = "source-over";
|
||||
p.globalAlpha = 0.38;
|
||||
p.globalAlpha = area.blendAlpha || 0.38;
|
||||
p.fillStyle = "#fbf7ef";
|
||||
p.fillRect(0, 0, patch.width, patch.height);
|
||||
p.globalAlpha = 1;
|
||||
@@ -199,7 +216,10 @@
|
||||
p.fillStyle = gx;
|
||||
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() {
|
||||
@@ -244,17 +264,17 @@
|
||||
let x = 185;
|
||||
const y = 494;
|
||||
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(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(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(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(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(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);
|
||||
drawInlineFlow([
|
||||
{ text: `\u201c${value("courseName")}\u201d`, style: "course" },
|
||||
@@ -346,9 +366,12 @@
|
||||
|
||||
function drawIssueDate() {
|
||||
ctx.textAlign = "center";
|
||||
ctx.fillStyle = "#43382f";
|
||||
ctx.fillStyle = "#111";
|
||||
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() {
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 91 KiB |
BIN
certificate-template-lab/assets/original.png
Normal file
BIN
certificate-template-lab/assets/original.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.6 MiB |
@@ -99,7 +99,7 @@
|
||||
<section class="preview">
|
||||
<div class="preview-head">
|
||||
<div>
|
||||
<p class="eyebrow">1024 x 759</p>
|
||||
<p class="eyebrow">3437 x 2551 / 1024 design coordinates</p>
|
||||
<h2>实时预览</h2>
|
||||
</div>
|
||||
<span id="renderState">加载中</span>
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
- `PUBLIC_BASE_URL` 已改成正式域名。
|
||||
- `DATABASE_URL` 指向生产 MySQL。
|
||||
- `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 可生成,二维码可打开核验页。
|
||||
|
||||
783
docs/正式部署操作手册.md
Normal file
783
docs/正式部署操作手册.md
Normal file
@@ -0,0 +1,783 @@
|
||||
# 证书系统正式部署操作手册
|
||||
|
||||
本文档用于把“培训结业证书管理与查询系统”部署到正式服务器,并完成短信验证码配置、上线验收、日常运维和回滚准备。
|
||||
|
||||
适用范围:
|
||||
|
||||
- 正式工程:`backend/`、`frontend/`、`docker-compose.yml`
|
||||
- 不适用于本地验收版:`local_app/`
|
||||
|
||||
## 1. 部署架构
|
||||
|
||||
推荐一期使用单机 Docker Compose:
|
||||
|
||||
```text
|
||||
用户浏览器
|
||||
-> 域名 HTTPS
|
||||
-> 宿主机 Nginx
|
||||
-> frontend 容器,端口 8080,内置 Nginx,负责前端页面和 /api 代理
|
||||
-> backend 容器,端口 8000,FastAPI
|
||||
-> MySQL 8,Compose 内置 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。
|
||||
@@ -49,22 +49,24 @@
|
||||
</div>
|
||||
|
||||
<el-table :data="certificates" border>
|
||||
<el-table-column prop="certificate_no" label="证书编号" width="220" />
|
||||
<el-table-column prop="learner_id" label="学员ID" width="90" />
|
||||
<el-table-column prop="project_code" label="项目" width="90" />
|
||||
<el-table-column prop="certificate_name" label="证书名称" min-width="180" />
|
||||
<el-table-column prop="issue_date" label="发证日期" width="120" />
|
||||
<el-table-column label="状态" width="100">
|
||||
<el-table-column prop="certificate_no" label="证书编号" width="180" show-overflow-tooltip />
|
||||
<el-table-column prop="learner_id" label="学员ID" width="76" />
|
||||
<el-table-column prop="project_code" label="项目" width="72" />
|
||||
<el-table-column prop="certificate_name" label="证书名称" min-width="140" show-overflow-tooltip />
|
||||
<el-table-column prop="issue_date" label="发证日期" width="108" />
|
||||
<el-table-column label="状态" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 'valid' ? 'success' : 'danger'">{{ statusText(row.status) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="330">
|
||||
<el-table-column label="操作" width="270">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" @click="previewCertificate(row)">预览</el-button>
|
||||
<el-button size="small" @click="downloadCertificate(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 class="table-actions">
|
||||
<el-button size="small" @click="previewCertificate(row)">预览</el-button>
|
||||
<el-button size="small" @click="downloadCertificate(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>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
@@ -221,4 +223,14 @@ onMounted(async () => {
|
||||
line-height: 1.6;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.table-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.table-actions :deep(.el-button + .el-button) {
|
||||
margin-left: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"jsx": "preserve",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"types": []
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { defineConfig } from "vite";
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
|
||||
const apiProxyTarget = process.env.VITE_API_PROXY_TARGET || "http://localhost:8000";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: "http://localhost:8000",
|
||||
target: apiProxyTarget,
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => path.replace(/^\/api/, "")
|
||||
}
|
||||
|
||||
@@ -31,6 +31,9 @@ CAPTCHAS: dict[str, tuple[str, float]] = {}
|
||||
SMS_CODES: dict[str, tuple[str, float]] = {}
|
||||
LOG_RETENTION_DAYS = 180
|
||||
HIGH_RISK_LOG_RETENTION_DAYS = 365
|
||||
DESIGN_WIDTH = 1024
|
||||
DESIGN_HEIGHT = 759
|
||||
PDF_BASE_RESOLUTION = 150.0
|
||||
|
||||
COL_NAME = "\u59d3\u540d"
|
||||
COL_PHONE = "\u624b\u673a\u53f7"
|
||||
@@ -440,38 +443,51 @@ def font_name() -> str:
|
||||
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 = {
|
||||
"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/PingFang.ttc",
|
||||
"/System/Library/Fonts/Supplemental/Songti.ttc",
|
||||
("/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",
|
||||
"/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/PingFang.ttc",
|
||||
],
|
||||
"song": [
|
||||
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/STHeiti Light.ttc",
|
||||
("/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",
|
||||
"/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",
|
||||
] + 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()
|
||||
@@ -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)
|
||||
|
||||
|
||||
def draw_inline(draw: ImageDraw.ImageDraw, x: float, y: int, text: str, size: int, gap: int, kind: str = "song", bold: bool = False) -> float:
|
||||
font = pil_font(size, kind, bold)
|
||||
def draw_inline(
|
||||
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")
|
||||
if bold:
|
||||
draw.text((x + 0.45, y), text, fill="#111111", font=font, anchor="ls")
|
||||
return x + draw.textlength(text, font=font) + gap
|
||||
draw.text((x + 0.45 * x_scale, y), text, fill="#111111", font=font, anchor="ls")
|
||||
return x + draw.textlength(text, font=font) + gap * x_scale
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
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 text, style, gap_before in segments:
|
||||
x += gap_before
|
||||
text_font = pil_font(27, "kai", True) if style == "course" else pil_font(24, "song", False)
|
||||
x += gap_before * x_scale
|
||||
text_font = pil_font(27, "kai", True, scale) if style == "course" else pil_font(24, "song", False, scale)
|
||||
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)
|
||||
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
|
||||
|
||||
|
||||
@@ -560,55 +595,119 @@ def split_by_width(draw: ImageDraw.ImageDraw, text: str, text_font: ImageFont.Im
|
||||
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
|
||||
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")
|
||||
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:
|
||||
template = ROOT / "static" / "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
|
||||
learner = conn.execute("select * from learners where id=?", (cert["learner_id"],)).fetchone()
|
||||
learner_name = (learner["current_name"] if learner else "") or ""
|
||||
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, box)
|
||||
paper_patch(image, (150, 455, 760, 119), x_scale, y_scale)
|
||||
|
||||
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
|
||||
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)
|
||||
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)
|
||||
course_text = f"\u201c{cert['course_name'] or cert['certificate_name'] or cert['project_code']}\u201d"
|
||||
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"
|
||||
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.text((512, 704), issue_label, fill="#43382f", font=pil_font(13, "song", True), anchor="mm")
|
||||
draw.text((105, 76), f"\u8bc1\u4e66\u7f16\u53f7\uff1a{cert['certificate_no']}", fill="#111827", font=pil_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{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
|
||||
|
||||
|
||||
@@ -616,12 +715,12 @@ def build_pdf(conn: sqlite3.Connection, cert: sqlite3.Row) -> Path:
|
||||
pdf_dir = DATA / "pdf-cache"
|
||||
pdf_dir.mkdir(exist_ok=True)
|
||||
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
|
||||
if path.exists() and path.stat().st_mtime >= max(template.stat().st_mtime, renderer_mtime):
|
||||
return path
|
||||
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"]))
|
||||
return path
|
||||
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 91 KiB |
BIN
local_app/static/assets/certificate-template.png
Normal file
BIN
local_app/static/assets/certificate-template.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.6 MiB |
@@ -702,6 +702,64 @@
|
||||
gap: 12px;
|
||||
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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -775,7 +833,7 @@
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
.dialog {
|
||||
position: fixed;
|
||||
@@ -836,6 +894,19 @@
|
||||
.side { position: static; }
|
||||
.form-grid, .stats { 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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[s])); }
|
||||
function statusPill(value) { return `<span class="pill ${value === "voided" || value === "disabled" || value === "deleted" ? "bad" : ""}">${esc(value)}</span>`; }
|
||||
function table(rows, cols, actions) {
|
||||
if (!rows.length) return `<div class="panel"><p class="muted">暂无数据</p></div>`;
|
||||
return `<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>`;
|
||||
if (!rows.length) return `<p class="muted">暂无数据</p>`;
|
||||
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></div>`;
|
||||
}
|
||||
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 = ""; } }
|
||||
|
||||
Reference in New Issue
Block a user