diff --git a/.env.example b/.env.example index 0cbf0eb..9e78afa 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/README.md b/README.md index ff5f847..6c87e86 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/backend/alembic/versions/20260602_0002_project_defaults.py b/backend/alembic/versions/20260602_0002_project_defaults.py index cde1677..088e56a 100644 --- a/backend/alembic/versions/20260602_0002_project_defaults.py +++ b/backend/alembic/versions/20260602_0002_project_defaults.py @@ -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: diff --git a/backend/alembic/versions/20260609_0003_add_pdf_last_accessed_at.py b/backend/alembic/versions/20260609_0003_add_pdf_last_accessed_at.py new file mode 100644 index 0000000..51c8766 --- /dev/null +++ b/backend/alembic/versions/20260609_0003_add_pdf_last_accessed_at.py @@ -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 ### diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py index ba9ce25..ad1a5d3 100644 --- a/backend/app/api/deps.py +++ b/backend/app/api/deps.py @@ -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 diff --git a/backend/app/api/router.py b/backend/app/api/router.py index e3a5ce0..ad072f2 100644 --- a/backend/app/api/router.py +++ b/backend/app/api/router.py @@ -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"]) diff --git a/backend/app/api/routes/admin_pdf_cleanup.py b/backend/app/api/routes/admin_pdf_cleanup.py new file mode 100644 index 0000000..b943035 --- /dev/null +++ b/backend/app/api/routes/admin_pdf_cleanup.py @@ -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} 不存在或清理失败", + ) diff --git a/backend/app/assets/certificate-template.jpg b/backend/app/assets/certificate-template.jpg deleted file mode 100644 index f9a986d..0000000 Binary files a/backend/app/assets/certificate-template.jpg and /dev/null differ diff --git a/backend/app/assets/certificate-template.png b/backend/app/assets/certificate-template.png new file mode 100644 index 0000000..0b48005 Binary files /dev/null and b/backend/app/assets/certificate-template.png differ diff --git a/backend/app/core/config.py b/backend/app/core/config.py index adc81d2..8a6a510 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -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: diff --git a/backend/app/main.py b/backend/app/main.py index 9abe7b0..724a3ba 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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, diff --git a/backend/app/models/certificate.py b/backend/app/models/certificate.py index 44592f0..a432a9b 100644 --- a/backend/app/models/certificate.py +++ b/backend/app/models/certificate.py @@ -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) diff --git a/backend/app/services/pdf.py b/backend/app/services/pdf.py index b49f464..c09bb2b 100644 --- a/backend/app/services/pdf.py +++ b/backend/app/services/pdf.py @@ -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 diff --git a/backend/app/services/pdf_cleanup.py b/backend/app/services/pdf_cleanup.py new file mode 100644 index 0000000..1ded67f --- /dev/null +++ b/backend/app/services/pdf_cleanup.py @@ -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 diff --git a/backend/docs/aliyun-sms-deployment.md b/backend/docs/aliyun-sms-deployment.md index 4573926..8029631 100644 --- a/backend/docs/aliyun-sms-deployment.md +++ b/backend/docs/aliyun-sms-deployment.md @@ -1,5 +1,7 @@ # 阿里云短信服务部署指南 +> 注意:这份文档是早期短信接入草稿,里面包含“修改 config.py / 替换 sms.py”等旧步骤。当前项目已经内置 `SMS_MODE=local/aliyun` 和阿里云短信发送逻辑,正式部署请以 `docs/正式部署操作手册.md` 的“短信验证码配置”章节为准。 + ## 前置条件 - 阿里云账号(已实名认证) diff --git a/backend/requirements.txt b/backend/requirements.txt index 32e79d1..b2b4052 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -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 diff --git a/backend/tests/test_import_rules.py b/backend/tests/test_import_rules.py index 483df0e..f45a0d1 100644 --- a/backend/tests/test_import_rules.py +++ b/backend/tests/test_import_rules.py @@ -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"}) diff --git a/certificate-template-lab/README.md b/certificate-template-lab/README.md index ddd5e25..be3244a 100644 --- a/certificate-template-lab/README.md +++ b/certificate-template-lab/README.md @@ -19,7 +19,7 @@ http://127.0.0.1:8020 ## 当前用途 -- 以 `assets/original.jpg` 作为证书底图。 +- 以 `assets/original.png` 作为高清证书底图。 - 擦除可变文字区域并重新绘制学员、课程、日期、单位、证书编号等信息。 - 支持下载 PNG,用于先确认视觉效果。 - 后续确认版式后,再把这套渲染参数迁移到主系统的 PDF/图片生成流程。 diff --git a/certificate-template-lab/app.js b/certificate-template-lab/app.js index 845a3ff..c609838 100644 --- a/certificate-template-lab/app.js +++ b/certificate-template-lab/app.js @@ -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() { diff --git a/certificate-template-lab/assets/original.jpg b/certificate-template-lab/assets/original.jpg deleted file mode 100644 index f9a986d..0000000 Binary files a/certificate-template-lab/assets/original.jpg and /dev/null differ diff --git a/certificate-template-lab/assets/original.png b/certificate-template-lab/assets/original.png new file mode 100644 index 0000000..0b48005 Binary files /dev/null and b/certificate-template-lab/assets/original.png differ diff --git a/certificate-template-lab/index.html b/certificate-template-lab/index.html index fb2efcd..b051f1d 100644 --- a/certificate-template-lab/index.html +++ b/certificate-template-lab/index.html @@ -99,7 +99,7 @@
-

1024 x 759

+

3437 x 2551 / 1024 design coordinates

实时预览

加载中 diff --git a/docs/deployment-checklist.md b/docs/deployment-checklist.md index 3c4eb6a..9240fb0 100644 --- a/docs/deployment-checklist.md +++ b/docs/deployment-checklist.md @@ -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 可生成,二维码可打开核验页。 diff --git a/docs/正式部署操作手册.md b/docs/正式部署操作手册.md new file mode 100644 index 0000000..6a9d1ac --- /dev/null +++ b/docs/正式部署操作手册.md @@ -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。 diff --git a/frontend/src/views/AdminCertificates.vue b/frontend/src/views/AdminCertificates.vue index 0775472..235824a 100644 --- a/frontend/src/views/AdminCertificates.vue +++ b/frontend/src/views/AdminCertificates.vue @@ -49,22 +49,24 @@
- - - - - - + + + + + + - + @@ -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; +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index 89433ee..48345d6 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -8,6 +8,7 @@ "jsx": "preserve", "resolveJsonModule": true, "isolatedModules": true, + "skipLibCheck": true, "noEmit": true, "lib": ["ES2022", "DOM", "DOM.Iterable"], "types": [] diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index f55e9af..470cce9 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -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/, "") } diff --git a/local_app/server.py b/local_app/server.py index c070a63..40a6199 100644 --- a/local_app/server.py +++ b/local_app/server.py @@ -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 diff --git a/local_app/static/assets/certificate-template.jpg b/local_app/static/assets/certificate-template.jpg deleted file mode 100644 index f9a986d..0000000 Binary files a/local_app/static/assets/certificate-template.jpg and /dev/null differ diff --git a/local_app/static/assets/certificate-template.png b/local_app/static/assets/certificate-template.png new file mode 100644 index 0000000..0b48005 Binary files /dev/null and b/local_app/static/assets/certificate-template.png differ diff --git a/local_app/static/index.html b/local_app/static/index.html index 6d2cfe1..3caf51f 100644 --- a/local_app/static/index.html +++ b/local_app/static/index.html @@ -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 `${esc(value)}`; } function table(rows, cols, actions) { - if (!rows.length) return `

暂无数据

`; - return `${cols.map(c=>``).join("")}${actions?"":""}` + - rows.map(r => `${cols.map(c=>``).join("")}${actions?``:""}`).join("") + `
${c[1]}操作
${c[2] ? c[2](r[c[0]], r) : esc(r[c[0]])}
${actions(r)}
`; + if (!rows.length) return `

暂无数据

`; + return `
${cols.map(c=>``).join("")}${actions?"":""}` + + rows.map(r => `${cols.map(c=>``).join("")}${actions?``:""}`).join("") + `
${c[1]}操作
${c[2] ? c[2](r[c[0]], r) : esc(r[c[0]])}
${actions(r)}
`; } 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 = ""; } }