优化证书生成与正式部署配置
This commit is contained in:
@@ -5,3 +5,11 @@ PUBLIC_BASE_URL=http://localhost:8080
|
|||||||
CERTIFICATE_NO_SECRET=change-me
|
CERTIFICATE_NO_SECRET=change-me
|
||||||
PDF_CACHE_DAYS=30
|
PDF_CACHE_DAYS=30
|
||||||
DATA_ROOT=/data/certificate-system
|
DATA_ROOT=/data/certificate-system
|
||||||
|
CORS_ORIGINS=http://localhost:5173,http://localhost:8080
|
||||||
|
|
||||||
|
# local=本地测试,验证码打印到后端日志;aliyun=阿里云短信
|
||||||
|
SMS_MODE=local
|
||||||
|
ALIYUN_ACCESS_KEY_ID=
|
||||||
|
ALIYUN_ACCESS_KEY_SECRET=
|
||||||
|
ALIYUN_SMS_SIGN_NAME=
|
||||||
|
ALIYUN_SMS_TEMPLATE_CODE=
|
||||||
|
|||||||
@@ -63,5 +63,6 @@ reset-local-data.bat
|
|||||||
|
|
||||||
- [开发决策记录](docs/decision-log.md)
|
- [开发决策记录](docs/decision-log.md)
|
||||||
- [MVP 范围说明](docs/mvp-scope.md)
|
- [MVP 范围说明](docs/mvp-scope.md)
|
||||||
|
- [正式部署操作手册](docs/正式部署操作手册.md)
|
||||||
- [部署与使用手册](docs/usage-deployment-manual.md)
|
- [部署与使用手册](docs/usage-deployment-manual.md)
|
||||||
- [上线检查清单](docs/deployment-checklist.md)
|
- [上线检查清单](docs/deployment-checklist.md)
|
||||||
|
|||||||
@@ -23,8 +23,6 @@ def upgrade() -> None:
|
|||||||
op.execute("update project_courses set default_certificate_name='培训结业证书' where default_certificate_name is null")
|
op.execute("update project_courses set default_certificate_name='培训结业证书' where default_certificate_name is null")
|
||||||
op.execute("update project_courses set default_course_name=name where default_course_name is null")
|
op.execute("update project_courses set default_course_name=name where default_course_name is null")
|
||||||
op.execute("update project_courses set default_issuer_name='本公司' where default_issuer_name is null")
|
op.execute("update project_courses set default_issuer_name='本公司' where default_issuer_name is null")
|
||||||
op.alter_column("project_courses", "default_certificate_name", nullable=False)
|
|
||||||
op.alter_column("project_courses", "default_issuer_name", nullable=False)
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
def downgrade() -> None:
|
||||||
|
|||||||
@@ -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)
|
bearer_scheme = HTTPBearer(auto_error=False)
|
||||||
|
|
||||||
|
ROLE_ALIASES = {
|
||||||
|
"admin": "system_admin",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def get_current_admin(
|
def get_current_admin(
|
||||||
credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
|
credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
|
||||||
@@ -26,7 +30,8 @@ def get_current_admin(
|
|||||||
|
|
||||||
def require_roles(*role_codes: str):
|
def require_roles(*role_codes: str):
|
||||||
def checker(admin: AdminUser = Depends(get_current_admin)) -> AdminUser:
|
def checker(admin: AdminUser = Depends(get_current_admin)) -> AdminUser:
|
||||||
if admin.role_code not in role_codes:
|
role_code = ROLE_ALIASES.get(admin.role_code, admin.role_code)
|
||||||
|
if role_code not in role_codes:
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Permission denied")
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Permission denied")
|
||||||
return admin
|
return admin
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from app.api.routes import (
|
|||||||
admin_imports,
|
admin_imports,
|
||||||
admin_learners,
|
admin_learners,
|
||||||
admin_logs,
|
admin_logs,
|
||||||
|
admin_pdf_cleanup,
|
||||||
admin_projects,
|
admin_projects,
|
||||||
auth,
|
auth,
|
||||||
health,
|
health,
|
||||||
@@ -23,4 +24,5 @@ api_router.include_router(admin_certificates.router, prefix="/admin/certificates
|
|||||||
api_router.include_router(admin_imports.router, prefix="/admin/import-batches", tags=["admin-imports"])
|
api_router.include_router(admin_imports.router, prefix="/admin/import-batches", tags=["admin-imports"])
|
||||||
api_router.include_router(admin_exports.router, prefix="/admin/exports", tags=["admin-exports"])
|
api_router.include_router(admin_exports.router, prefix="/admin/exports", tags=["admin-exports"])
|
||||||
api_router.include_router(admin_logs.router, prefix="/admin/logs", tags=["admin-logs"])
|
api_router.include_router(admin_logs.router, prefix="/admin/logs", tags=["admin-logs"])
|
||||||
|
api_router.include_router(admin_pdf_cleanup.router, prefix="/admin/pdf-cleanup", tags=["admin-pdf-cleanup"])
|
||||||
api_router.include_router(public.router, prefix="/public", tags=["public"])
|
api_router.include_router(public.router, prefix="/public", tags=["public"])
|
||||||
|
|||||||
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")
|
secret_key: str = os.getenv("SECRET_KEY", "change-me")
|
||||||
public_base_url: str = os.getenv("PUBLIC_BASE_URL", "http://localhost:8080")
|
public_base_url: str = os.getenv("PUBLIC_BASE_URL", "http://localhost:8080")
|
||||||
certificate_no_secret: str = os.getenv("CERTIFICATE_NO_SECRET", "change-me")
|
certificate_no_secret: str = os.getenv("CERTIFICATE_NO_SECRET", "change-me")
|
||||||
pdf_cache_days: int = int(os.getenv("PDF_CACHE_DAYS", "30"))
|
|
||||||
data_root: str = os.getenv("DATA_ROOT", "/data/certificate-system")
|
data_root: str = os.getenv("DATA_ROOT", "/data/certificate-system")
|
||||||
cors_origins: list[str] = field(
|
cors_origins: list[str] = field(
|
||||||
default_factory=lambda: _csv_env("CORS_ORIGINS", ["http://localhost:5173", "http://localhost:8080"])
|
default_factory=lambda: _csv_env("CORS_ORIGINS", ["http://localhost:5173", "http://localhost:8080"])
|
||||||
)
|
)
|
||||||
|
|
||||||
# 短信服务配置
|
# 短信服务配置
|
||||||
# local=本地测试(验证码打印到终端),aliyun=阿里云短信
|
# local=本地测试(验证码打印到终端),aliyun=阿里云短信
|
||||||
sms_mode: str = os.getenv("SMS_MODE", "local")
|
sms_mode: str = os.getenv("SMS_MODE", "local")
|
||||||
@@ -32,6 +31,10 @@ class Settings:
|
|||||||
aliyun_sms_sign_name: str = os.getenv("ALIYUN_SMS_SIGN_NAME", "")
|
aliyun_sms_sign_name: str = os.getenv("ALIYUN_SMS_SIGN_NAME", "")
|
||||||
aliyun_sms_template_code: str = os.getenv("ALIYUN_SMS_TEMPLATE_CODE", "")
|
aliyun_sms_template_code: str = os.getenv("ALIYUN_SMS_TEMPLATE_CODE", "")
|
||||||
|
|
||||||
|
# PDF配置
|
||||||
|
pdf_cache_days: int = int(os.getenv("PDF_CACHE_DAYS", "30"))
|
||||||
|
pdf_cleanup_days: int = int(os.getenv("PDF_CLEANUP_DAYS", "5"))
|
||||||
|
|
||||||
|
|
||||||
@lru_cache
|
@lru_cache
|
||||||
def get_settings() -> Settings:
|
def get_settings() -> Settings:
|
||||||
|
|||||||
@@ -1,12 +1,46 @@
|
|||||||
|
import logging
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
from app.api.router import api_router
|
from app.api.router import api_router
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
|
from app.services.pdf_cleanup import cleanup_expired_pdfs
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
"""应用生命周期管理"""
|
||||||
|
# 启动时执行
|
||||||
|
logger.info("[应用启动] 初始化定时任务...")
|
||||||
|
|
||||||
|
# 启动后台清理任务
|
||||||
|
import asyncio
|
||||||
|
async def cleanup_task():
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
# 每天执行一次PDF清理
|
||||||
|
await asyncio.sleep(24 * 60 * 60) # 24小时
|
||||||
|
logger.info("[定时任务] 开始执行PDF清理...")
|
||||||
|
cleanup_expired_pdfs()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[定时任务] PDF清理失败: {str(e)}")
|
||||||
|
|
||||||
|
# 启动后台任务
|
||||||
|
asyncio.create_task(cleanup_task())
|
||||||
|
logger.info("[应用启动] 定时任务已启动")
|
||||||
|
|
||||||
|
yield
|
||||||
|
|
||||||
|
# 关闭时执行
|
||||||
|
logger.info("[应用关闭] 清理资源...")
|
||||||
|
|
||||||
|
|
||||||
def create_app() -> FastAPI:
|
def create_app() -> FastAPI:
|
||||||
app = FastAPI(title=settings.app_name)
|
app = FastAPI(title=settings.app_name, lifespan=lifespan)
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=settings.cors_origins,
|
allow_origins=settings.cors_origins,
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ class Certificate(Base):
|
|||||||
status: Mapped[str] = mapped_column(String(32), default="valid")
|
status: Mapped[str] = mapped_column(String(32), default="valid")
|
||||||
pdf_status: Mapped[str] = mapped_column(String(32), default="not_generated")
|
pdf_status: Mapped[str] = mapped_column(String(32), default="not_generated")
|
||||||
pdf_file_path: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
pdf_file_path: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||||
|
pdf_last_accessed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||||
public_token_id: Mapped[int | None] = mapped_column(nullable=True)
|
public_token_id: Mapped[int | None] = mapped_column(nullable=True)
|
||||||
qr_token_id: Mapped[int | None] = mapped_column(nullable=True)
|
qr_token_id: Mapped[int | None] = mapped_column(nullable=True)
|
||||||
remark: Mapped[str | None] = mapped_column(Text, nullable=True)
|
remark: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ from app.core.config import settings
|
|||||||
from app.core.paths import data_path
|
from app.core.paths import data_path
|
||||||
from app.models import Certificate, CertificateAccessToken, Learner, ProjectCourse
|
from app.models import Certificate, CertificateAccessToken, Learner, ProjectCourse
|
||||||
|
|
||||||
|
DESIGN_WIDTH = 1024
|
||||||
|
DESIGN_HEIGHT = 759
|
||||||
|
PDF_BASE_RESOLUTION = 150.0
|
||||||
|
|
||||||
|
|
||||||
def pdf_cache_path(certificate_no: str) -> Path:
|
def pdf_cache_path(certificate_no: str) -> Path:
|
||||||
safe_name = certificate_no.replace("/", "_")
|
safe_name = certificate_no.replace("/", "_")
|
||||||
@@ -23,6 +27,25 @@ def cached_pdf_is_fresh(path: Path) -> bool:
|
|||||||
return modified_at >= datetime.now() - timedelta(days=settings.pdf_cache_days)
|
return modified_at >= datetime.now() - timedelta(days=settings.pdf_cache_days)
|
||||||
|
|
||||||
|
|
||||||
|
def update_pdf_access_time(certificate: Certificate) -> None:
|
||||||
|
"""更新PDF最后访问时间"""
|
||||||
|
from datetime import datetime
|
||||||
|
certificate.pdf_last_accessed_at = datetime.utcnow()
|
||||||
|
|
||||||
|
|
||||||
|
def certificate_template_path() -> Path:
|
||||||
|
assets_dir = Path(__file__).resolve().parents[1] / "assets"
|
||||||
|
for name in ["certificate-template.png", "certificate-template.jpg"]:
|
||||||
|
path = assets_dir / name
|
||||||
|
if path.exists():
|
||||||
|
return path
|
||||||
|
raise FileNotFoundError("Certificate template image not found")
|
||||||
|
|
||||||
|
|
||||||
|
def pdf_resolution(image: Image.Image) -> float:
|
||||||
|
return PDF_BASE_RESOLUTION * (image.width / DESIGN_WIDTH)
|
||||||
|
|
||||||
|
|
||||||
def render_certificate_pdf(
|
def render_certificate_pdf(
|
||||||
certificate: Certificate,
|
certificate: Certificate,
|
||||||
learner: Learner,
|
learner: Learner,
|
||||||
@@ -30,71 +53,130 @@ def render_certificate_pdf(
|
|||||||
token: CertificateAccessToken,
|
token: CertificateAccessToken,
|
||||||
) -> Path:
|
) -> Path:
|
||||||
output_path = pdf_cache_path(certificate.certificate_no)
|
output_path = pdf_cache_path(certificate.certificate_no)
|
||||||
template_path = Path(__file__).resolve().parents[1] / "assets" / "certificate-template.jpg"
|
template_path = certificate_template_path()
|
||||||
latest_renderer_mtime = max(template_path.stat().st_mtime, Path(__file__).stat().st_mtime)
|
latest_renderer_mtime = max(template_path.stat().st_mtime, Path(__file__).stat().st_mtime)
|
||||||
if cached_pdf_is_fresh(output_path) and output_path.stat().st_mtime >= latest_renderer_mtime:
|
if cached_pdf_is_fresh(output_path) and output_path.stat().st_mtime >= latest_renderer_mtime:
|
||||||
|
# 更新访问时间
|
||||||
|
update_pdf_access_time(certificate)
|
||||||
return output_path
|
return output_path
|
||||||
|
|
||||||
image = render_certificate_image(certificate, learner, project)
|
image = render_certificate_image(certificate, learner, project)
|
||||||
image.save(output_path, "PDF", resolution=150.0)
|
image.save(output_path, "PDF", resolution=pdf_resolution(image))
|
||||||
|
|
||||||
certificate.pdf_status = "generated"
|
certificate.pdf_status = "generated"
|
||||||
certificate.pdf_file_path = str(output_path)
|
certificate.pdf_file_path = str(output_path)
|
||||||
|
# 更新访问时间
|
||||||
|
update_pdf_access_time(certificate)
|
||||||
return output_path
|
return output_path
|
||||||
|
|
||||||
|
|
||||||
def render_certificate_image(certificate: Certificate, learner: Learner, project: ProjectCourse | None) -> Image.Image:
|
def render_certificate_image(certificate: Certificate, learner: Learner, project: ProjectCourse | None) -> Image.Image:
|
||||||
template = Path(__file__).resolve().parents[1] / "assets" / "certificate-template.jpg"
|
template = certificate_template_path()
|
||||||
image = Image.open(template).convert("RGB")
|
image = Image.open(template).convert("RGB")
|
||||||
base_image = image.copy()
|
base_image = image.copy()
|
||||||
draw = ImageDraw.Draw(image)
|
draw = ImageDraw.Draw(image)
|
||||||
|
x_scale = image.width / DESIGN_WIDTH
|
||||||
|
y_scale = image.height / DESIGN_HEIGHT
|
||||||
|
text_scale = (x_scale + y_scale) / 2
|
||||||
|
|
||||||
issue_year, issue_month, issue_day = date_parts(certificate.issue_date)
|
issue_year, issue_month, issue_day = date_parts(certificate.issue_date)
|
||||||
course_name = certificate.course_name or certificate.certificate_name or (project.name if project else certificate.project_code)
|
course_name = certificate.course_name or certificate.certificate_name or (project.name if project else certificate.project_code)
|
||||||
stage_input = (certificate.stage_name or "").strip()
|
stage_input = (certificate.stage_name or "").strip()
|
||||||
stage_name = f"{stage_input}\u8bfe\u7a0b\u7684\u4e13\u4e1a\u5b66\u4e60\u3002" if stage_input else "\u521d\u7ea7\u8bfe\u7a0b\u7684\u4e13\u4e1a\u5b66\u4e60\u3002"
|
stage_name = f"{stage_input}\u8bfe\u7a0b\u7684\u4e13\u4e1a\u5b66\u4e60\u3002" if stage_input else "\u521d\u7ea7\u8bfe\u7a0b\u7684\u4e13\u4e1a\u5b66\u4e60\u3002"
|
||||||
|
|
||||||
for box in [(150, 442, 760, 132), (404, 675, 220, 38)]:
|
paper_patch(image, (150, 455, 760, 119), x_scale, y_scale)
|
||||||
paper_patch(image, box)
|
|
||||||
|
|
||||||
draw.text((512, 414), learner.current_name, fill="#111111", font=font(32, "kai", True), anchor="mm")
|
draw.text(
|
||||||
|
(512 * x_scale, 414 * y_scale),
|
||||||
|
learner.current_name,
|
||||||
|
fill="#111111",
|
||||||
|
font=font(32, "kai", True, text_scale),
|
||||||
|
anchor="mm",
|
||||||
|
)
|
||||||
|
|
||||||
x = 185.0
|
x = 185.0 * x_scale
|
||||||
y = 494
|
y = 494 * y_scale
|
||||||
x = draw_inline(draw, x, y, "\u5728", 24, 18)
|
x = draw_inline(draw, x, y, "\u5728", 24, 18, scale=text_scale, x_scale=x_scale)
|
||||||
x = draw_inline(draw, x, y, issue_year, 24, 8, "kai", True)
|
x = draw_inline(draw, x, y, issue_year, 24, 8, scale=text_scale, x_scale=x_scale)
|
||||||
x = draw_inline(draw, x, y, "\u5e74", 24, 12)
|
x = draw_inline(draw, x, y, "\u5e74", 24, 12, scale=text_scale, x_scale=x_scale)
|
||||||
x = draw_inline(draw, x, y, issue_month, 24, 6, "kai", True)
|
x = draw_inline(draw, x, y, issue_month, 24, 6, scale=text_scale, x_scale=x_scale)
|
||||||
x = draw_inline(draw, x, y, "\u6708", 24, 12)
|
x = draw_inline(draw, x, y, "\u6708", 24, 12, scale=text_scale, x_scale=x_scale)
|
||||||
x = draw_inline(draw, x, y, issue_day, 24, 4, "kai", True)
|
x = draw_inline(draw, x, y, issue_day, 24, 4, scale=text_scale, x_scale=x_scale)
|
||||||
x = draw_inline(draw, x, y, "\u65e5\u81f3", 24, 12)
|
x = draw_inline(draw, x, y, "\u65e5\u81f3", 24, 12, scale=text_scale, x_scale=x_scale)
|
||||||
x = draw_inline(draw, x, y, issue_year, 24, 8, "kai", True)
|
x = draw_inline(draw, x, y, issue_year, 24, 8, scale=text_scale, x_scale=x_scale)
|
||||||
x = draw_inline(draw, x, y, "\u5e74", 24, 12)
|
x = draw_inline(draw, x, y, "\u5e74", 24, 12, scale=text_scale, x_scale=x_scale)
|
||||||
x = draw_inline(draw, x, y, issue_month, 24, 6, "kai", True)
|
x = draw_inline(draw, x, y, issue_month, 24, 6, scale=text_scale, x_scale=x_scale)
|
||||||
x = draw_inline(draw, x, y, "\u6708", 24, 12)
|
x = draw_inline(draw, x, y, "\u6708", 24, 12, scale=text_scale, x_scale=x_scale)
|
||||||
x = draw_inline(draw, x, y, issue_day, 24, 4, "kai", True)
|
x = draw_inline(draw, x, y, issue_day, 24, 4, scale=text_scale, x_scale=x_scale)
|
||||||
x = draw_inline(draw, x, y, "\u65e5\u5b8c\u6210\u4e86", 24, 8)
|
x = draw_inline(draw, x, y, "\u65e5\u5b8c\u6210\u4e86", 24, 8, scale=text_scale, x_scale=x_scale)
|
||||||
draw_inline_flow(draw, [(f"\u201c{course_name}\u201d", "course", 0), (stage_name, "normal", 14)], x, y)
|
draw_inline_flow(
|
||||||
|
draw,
|
||||||
|
[(f"\u201c{course_name}\u201d", "course", 0), (stage_name, "normal", 14)],
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
text_scale,
|
||||||
|
x_scale,
|
||||||
|
y_scale,
|
||||||
|
)
|
||||||
|
|
||||||
draw.text((512, 704), f"\u53d1\u8bc1\u65e5\u671f\uff1a{issue_year} \u5e74 {issue_month} \u6708 {issue_day} \u65e5", fill="#43382f", font=font(13, "song", True), anchor="mm")
|
draw_issue_date_numbers(draw, issue_year, issue_month, issue_day, text_scale, x_scale, y_scale)
|
||||||
draw.text((105, 76), f"\u8bc1\u4e66\u7f16\u53f7\uff1a{certificate.certificate_no}", fill="#111827", font=font(14, "hei", True))
|
draw.text(
|
||||||
image.paste(base_image.crop((720, 535, 950, 629)), (720, 535))
|
(105 * x_scale, 76 * y_scale),
|
||||||
|
f"\u8bc1\u4e66\u7f16\u53f7\uff1a{certificate.certificate_no}",
|
||||||
|
fill="#111827",
|
||||||
|
font=font(14, "hei", True, text_scale),
|
||||||
|
)
|
||||||
|
mentor_box = scaled_box((720, 535, 230, 94), x_scale, y_scale)
|
||||||
|
image.paste(base_image.crop(to_xyxy(mentor_box)), mentor_box[:2])
|
||||||
return image
|
return image
|
||||||
|
|
||||||
|
|
||||||
def font(size: int, kind: str = "song", bold: bool = False) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
|
def font(size: int, kind: str = "song", bold: bool = False, scale: float = 1.0) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
|
||||||
choices = {
|
choices = {
|
||||||
"kai": [r"C:\Windows\Fonts\simkai.ttf", r"C:\Windows\Fonts\msyh.ttc", r"C:\Windows\Fonts\simsun.ttc"],
|
"kai": [
|
||||||
"hei": [r"C:\Windows\Fonts\simhei.ttf", r"C:\Windows\Fonts\msyhbd.ttc", r"C:\Windows\Fonts\msyh.ttc"],
|
r"C:\Windows\Fonts\simkai.ttf",
|
||||||
"song": [r"C:\Windows\Fonts\msyh.ttc", r"C:\Windows\Fonts\simsun.ttc"],
|
r"C:\Windows\Fonts\msyh.ttc",
|
||||||
|
r"C:\Windows\Fonts\simsun.ttc",
|
||||||
|
"/usr/share/fonts/opentype/noto/NotoSerifCJK-Regular.ttc",
|
||||||
|
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
|
||||||
|
"/System/Library/Fonts/Supplemental/Kaiti.ttc",
|
||||||
|
("/System/Library/Fonts/Hiragino Sans GB.ttc", 2),
|
||||||
|
("/System/Library/Fonts/Supplemental/Songti.ttc", 1),
|
||||||
|
],
|
||||||
|
"hei": [
|
||||||
|
r"C:\Windows\Fonts\simhei.ttf",
|
||||||
|
r"C:\Windows\Fonts\msyhbd.ttc",
|
||||||
|
r"C:\Windows\Fonts\msyh.ttc",
|
||||||
|
"/usr/share/fonts/opentype/noto/NotoSansCJK-Bold.ttc",
|
||||||
|
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
|
||||||
|
("/System/Library/Fonts/STHeiti Medium.ttc", 1),
|
||||||
|
("/System/Library/Fonts/Hiragino Sans GB.ttc", 2),
|
||||||
|
],
|
||||||
|
"song": [
|
||||||
|
r"C:\Windows\Fonts\msyh.ttc",
|
||||||
|
r"C:\Windows\Fonts\simsun.ttc",
|
||||||
|
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
|
||||||
|
"/usr/share/fonts/opentype/noto/NotoSerifCJK-Regular.ttc",
|
||||||
|
("/System/Library/Fonts/Supplemental/Songti.ttc", 3),
|
||||||
|
("/System/Library/Fonts/Supplemental/Songti.ttc", 6),
|
||||||
|
("/System/Library/Fonts/Hiragino Sans GB.ttc", 0),
|
||||||
|
("/System/Library/Fonts/STHeiti Light.ttc", 1),
|
||||||
|
],
|
||||||
}
|
}
|
||||||
paths = choices.get(kind, choices["song"])
|
paths = choices.get(kind, choices["song"])
|
||||||
if bold and kind == "song":
|
if bold and kind == "song":
|
||||||
paths = [r"C:\Windows\Fonts\msyhbd.ttc", r"C:\Windows\Fonts\simhei.ttf"] + paths
|
paths = [
|
||||||
|
r"C:\Windows\Fonts\msyhbd.ttc",
|
||||||
|
r"C:\Windows\Fonts\simhei.ttf",
|
||||||
|
"/usr/share/fonts/opentype/noto/NotoSansCJK-Bold.ttc",
|
||||||
|
("/System/Library/Fonts/STHeiti Medium.ttc", 1),
|
||||||
|
("/System/Library/Fonts/Hiragino Sans GB.ttc", 2),
|
||||||
|
] + paths
|
||||||
for item in paths:
|
for item in paths:
|
||||||
if Path(item).exists():
|
path, index = item if isinstance(item, tuple) else (item, 0)
|
||||||
|
if Path(path).exists():
|
||||||
try:
|
try:
|
||||||
return ImageFont.truetype(item, size)
|
return ImageFont.truetype(path, max(1, round(size * scale)), index=index)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
return ImageFont.load_default()
|
return ImageFont.load_default()
|
||||||
@@ -114,22 +196,71 @@ def date_parts(value: date | str | None) -> tuple[str, str, str]:
|
|||||||
return str(today.year), str(today.month), str(today.day)
|
return str(today.year), str(today.month), str(today.day)
|
||||||
|
|
||||||
|
|
||||||
def paper_patch(image: Image.Image, box: tuple[int, int, int, int]) -> None:
|
def scaled_box(box: tuple[int, int, int, int], x_scale: float, y_scale: float) -> tuple[int, int, int, int]:
|
||||||
x, y, w, h = box
|
x, y, w, h = box
|
||||||
|
return round(x * x_scale), round(y * y_scale), round(w * x_scale), round(h * y_scale)
|
||||||
|
|
||||||
|
|
||||||
|
def to_xyxy(box: tuple[int, int, int, int]) -> tuple[int, int, int, int]:
|
||||||
|
x, y, w, h = box
|
||||||
|
return x, y, x + w, y + h
|
||||||
|
|
||||||
|
|
||||||
|
def paper_patch(image: Image.Image, box: tuple[int, int, int, int], x_scale: float, y_scale: float) -> None:
|
||||||
|
x, y, w, h = scaled_box(box, x_scale, y_scale)
|
||||||
patch = Image.new("RGB", (w, h), "#fbf7ef")
|
patch = Image.new("RGB", (w, h), "#fbf7ef")
|
||||||
source = image.crop((72, max(0, y), 160, max(0, y) + h))
|
source = image.crop((round(72 * x_scale), y, round(160 * x_scale), y + h))
|
||||||
for tx in range(0, w, source.width):
|
for tx in range(0, w, source.width):
|
||||||
patch.paste(source.crop((0, 0, min(source.width, w - tx), h)), (tx, 0))
|
patch.paste(source.crop((0, 0, min(source.width, w - tx), h)), (tx, 0))
|
||||||
cover = Image.new("RGB", (w, h), "#fbf7ef")
|
cover = Image.new("RGB", (w, h), "#fbf7ef")
|
||||||
image.paste(Image.blend(patch, cover, 0.38), (x, y))
|
image.paste(Image.blend(patch, cover, 0.38), (x, y), soft_edge_mask(w, h, round(16 * x_scale), round(10 * y_scale)))
|
||||||
|
|
||||||
|
|
||||||
def draw_inline(draw: ImageDraw.ImageDraw, x: float, y: int, value: str, size: int, gap: int, kind: str = "song", bold: bool = False) -> float:
|
def soft_edge_mask(width: int, height: int, feather_x: int, feather_y: int) -> Image.Image:
|
||||||
text_font = font(size, kind, bold)
|
feather_x = max(1, feather_x)
|
||||||
|
feather_y = max(1, feather_y)
|
||||||
|
mask = Image.new("L", (width, height), 255)
|
||||||
|
pixels = mask.load()
|
||||||
|
for y in range(height):
|
||||||
|
vertical = min(1.0, y / feather_y, (height - 1 - y) / feather_y)
|
||||||
|
for x in range(width):
|
||||||
|
horizontal = min(1.0, x / feather_x, (width - 1 - x) / feather_x)
|
||||||
|
pixels[x, y] = max(0, min(255, round(255 * min(horizontal, vertical))))
|
||||||
|
return mask
|
||||||
|
|
||||||
|
|
||||||
|
def draw_issue_date_numbers(
|
||||||
|
draw: ImageDraw.ImageDraw,
|
||||||
|
issue_year: str,
|
||||||
|
issue_month: str,
|
||||||
|
issue_day: str,
|
||||||
|
scale: float,
|
||||||
|
x_scale: float,
|
||||||
|
y_scale: float,
|
||||||
|
) -> None:
|
||||||
|
text_font = font(13, "song", True, scale)
|
||||||
|
y = 688 * y_scale
|
||||||
|
for value, x in [(issue_year, 500), (issue_month, 535), (issue_day, 566)]:
|
||||||
|
draw.text((x * x_scale, y), value, fill="#111111", font=text_font, anchor="mm")
|
||||||
|
|
||||||
|
|
||||||
|
def draw_inline(
|
||||||
|
draw: ImageDraw.ImageDraw,
|
||||||
|
x: float,
|
||||||
|
y: float,
|
||||||
|
value: str,
|
||||||
|
size: int,
|
||||||
|
gap: int,
|
||||||
|
kind: str = "song",
|
||||||
|
bold: bool = False,
|
||||||
|
scale: float = 1.0,
|
||||||
|
x_scale: float = 1.0,
|
||||||
|
) -> float:
|
||||||
|
text_font = font(size, kind, bold, scale)
|
||||||
draw.text((x, y), value, fill="#111111", font=text_font, anchor="ls")
|
draw.text((x, y), value, fill="#111111", font=text_font, anchor="ls")
|
||||||
if bold:
|
if bold:
|
||||||
draw.text((x + 0.45, y), value, fill="#111111", font=text_font, anchor="ls")
|
draw.text((x + 0.45 * x_scale, y), value, fill="#111111", font=text_font, anchor="ls")
|
||||||
return x + draw.textlength(value, font=text_font) + gap
|
return x + draw.textlength(value, font=text_font) + gap * x_scale
|
||||||
|
|
||||||
|
|
||||||
def draw_course_name(draw: ImageDraw.ImageDraw, value: str, x: float, y: int) -> int:
|
def draw_course_name(draw: ImageDraw.ImageDraw, value: str, x: float, y: int) -> int:
|
||||||
@@ -149,23 +280,31 @@ def draw_course_name(draw: ImageDraw.ImageDraw, value: str, x: float, y: int) ->
|
|||||||
return start_y + (len(lines) - 1) * 34
|
return start_y + (len(lines) - 1) * 34
|
||||||
|
|
||||||
|
|
||||||
def draw_inline_flow(draw: ImageDraw.ImageDraw, segments: list[tuple[str, str, int]], start_x: float, start_y: int) -> None:
|
def draw_inline_flow(
|
||||||
|
draw: ImageDraw.ImageDraw,
|
||||||
|
segments: list[tuple[str, str, int]],
|
||||||
|
start_x: float,
|
||||||
|
start_y: float,
|
||||||
|
scale: float,
|
||||||
|
x_scale: float,
|
||||||
|
y_scale: float,
|
||||||
|
) -> None:
|
||||||
x = start_x
|
x = start_x
|
||||||
y = start_y
|
y = start_y
|
||||||
line_start = 185
|
line_start = 185 * x_scale
|
||||||
line_height = 42
|
line_height = 42 * y_scale
|
||||||
for value, style, gap_before in segments:
|
for value, style, gap_before in segments:
|
||||||
x += gap_before
|
x += gap_before * x_scale
|
||||||
text_font = font(27, "kai", True) if style == "course" else font(24, "song", False)
|
text_font = font(27, "kai", True, scale) if style == "course" else font(24, "song", False, scale)
|
||||||
for char in value:
|
for char in value:
|
||||||
max_right = 900 if y == start_y else 730
|
max_right = (900 if y == start_y else 730) * x_scale
|
||||||
width = draw.textlength(char, font=text_font)
|
width = draw.textlength(char, font=text_font)
|
||||||
if x + width > max_right and x > line_start:
|
if x + width > max_right and x > line_start:
|
||||||
x = line_start
|
x = line_start
|
||||||
y += line_height
|
y += line_height
|
||||||
draw.text((x, y), char, fill="#111111", font=text_font, anchor="ls")
|
draw.text((x, y), char, fill="#111111", font=text_font, anchor="ls")
|
||||||
if style == "course":
|
if style == "course":
|
||||||
draw.text((x + 0.45, y), char, fill="#111111", font=text_font, anchor="ls")
|
draw.text((x + 0.45 * x_scale, y), char, fill="#111111", font=text_font, anchor="ls")
|
||||||
x += width
|
x += width
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
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
|
playwright==1.49.1
|
||||||
jinja2==3.1.5
|
jinja2==3.1.5
|
||||||
pytest==8.3.4
|
pytest==8.3.4
|
||||||
|
alibabacloud-dysmsapi20170525==4.5.1
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
from datetime import date
|
from datetime import date
|
||||||
|
|
||||||
from app.api.routes.admin_imports import (
|
from app.api.routes.admin_imports import (
|
||||||
COL_CERT_NAME,
|
|
||||||
COL_ISSUE_DATE,
|
COL_ISSUE_DATE,
|
||||||
COL_NAME,
|
COL_NAME,
|
||||||
COL_PHONE,
|
COL_PHONE,
|
||||||
COL_PROJECT,
|
COL_PROJECT,
|
||||||
COL_ISSUER,
|
|
||||||
date_is_valid,
|
date_is_valid,
|
||||||
parse_issue_date,
|
parse_issue_date,
|
||||||
row_errors,
|
row_errors,
|
||||||
@@ -23,9 +21,7 @@ def test_row_errors_require_project_code_to_exist():
|
|||||||
COL_NAME: "张三",
|
COL_NAME: "张三",
|
||||||
COL_PHONE: "13800000000",
|
COL_PHONE: "13800000000",
|
||||||
COL_PROJECT: "BAD",
|
COL_PROJECT: "BAD",
|
||||||
COL_CERT_NAME: "结业证书",
|
|
||||||
COL_ISSUE_DATE: "2026-06-01",
|
COL_ISSUE_DATE: "2026-06-01",
|
||||||
COL_ISSUER: "测试公司",
|
|
||||||
}
|
}
|
||||||
assert "Project code is inactive or missing" in row_errors(row, {"DBY"})
|
assert "Project code is inactive or missing" in row_errors(row, {"DBY"})
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ http://127.0.0.1:8020
|
|||||||
|
|
||||||
## 当前用途
|
## 当前用途
|
||||||
|
|
||||||
- 以 `assets/original.jpg` 作为证书底图。
|
- 以 `assets/original.png` 作为高清证书底图。
|
||||||
- 擦除可变文字区域并重新绘制学员、课程、日期、单位、证书编号等信息。
|
- 擦除可变文字区域并重新绘制学员、课程、日期、单位、证书编号等信息。
|
||||||
- 支持下载 PNG,用于先确认视觉效果。
|
- 支持下载 PNG,用于先确认视觉效果。
|
||||||
- 后续确认版式后,再把这套渲染参数迁移到主系统的 PDF/图片生成流程。
|
- 后续确认版式后,再把这套渲染参数迁移到主系统的 PDF/图片生成流程。
|
||||||
|
|||||||
@@ -4,8 +4,10 @@
|
|||||||
const state = document.getElementById("renderState");
|
const state = document.getElementById("renderState");
|
||||||
const qrScratch = document.getElementById("qrScratch");
|
const qrScratch = document.getElementById("qrScratch");
|
||||||
|
|
||||||
const W = canvas.width;
|
const W = 1024;
|
||||||
const H = canvas.height;
|
const H = 759;
|
||||||
|
let scaleX = 1;
|
||||||
|
let scaleY = 1;
|
||||||
|
|
||||||
const text = {
|
const text = {
|
||||||
loading: "\u52a0\u8f7d\u4e2d",
|
loading: "\u52a0\u8f7d\u4e2d",
|
||||||
@@ -53,8 +55,11 @@
|
|||||||
controls.showDebug = document.getElementById("showDebug");
|
controls.showDebug = document.getElementById("showDebug");
|
||||||
|
|
||||||
const original = new Image();
|
const original = new Image();
|
||||||
original.src = "./assets/original.jpg";
|
original.src = "./assets/original.png";
|
||||||
original.onload = render;
|
original.onload = () => {
|
||||||
|
syncCanvasSize(true);
|
||||||
|
render();
|
||||||
|
};
|
||||||
original.onerror = () => {
|
original.onerror = () => {
|
||||||
state.textContent = text.imageFailed;
|
state.textContent = text.imageFailed;
|
||||||
render();
|
render();
|
||||||
@@ -104,9 +109,12 @@
|
|||||||
function render() {
|
function render() {
|
||||||
try {
|
try {
|
||||||
state.textContent = text.rendering;
|
state.textContent = text.rendering;
|
||||||
ctx.clearRect(0, 0, W, H);
|
|
||||||
|
|
||||||
const usingOriginal = controls.showOriginal.checked && original.complete && original.naturalWidth;
|
const usingOriginal = controls.showOriginal.checked && original.complete && original.naturalWidth;
|
||||||
|
syncCanvasSize(usingOriginal);
|
||||||
|
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
||||||
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
|
ctx.setTransform(scaleX, 0, 0, scaleY, 0, 0);
|
||||||
|
|
||||||
if (usingOriginal) {
|
if (usingOriginal) {
|
||||||
ctx.drawImage(original, 0, 0, W, H);
|
ctx.drawImage(original, 0, 0, W, H);
|
||||||
@@ -115,7 +123,7 @@
|
|||||||
drawFallbackBase();
|
drawFallbackBase();
|
||||||
}
|
}
|
||||||
|
|
||||||
drawStudentName();
|
drawStudentName();
|
||||||
drawMainText();
|
drawMainText();
|
||||||
drawIssueDate();
|
drawIssueDate();
|
||||||
drawCertificateNo();
|
drawCertificateNo();
|
||||||
@@ -139,9 +147,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function syncCanvasSize(usingOriginal) {
|
||||||
|
const nextWidth = usingOriginal ? original.naturalWidth : W;
|
||||||
|
const nextHeight = usingOriginal ? original.naturalHeight : H;
|
||||||
|
if (canvas.width !== nextWidth) canvas.width = nextWidth;
|
||||||
|
if (canvas.height !== nextHeight) canvas.height = nextHeight;
|
||||||
|
scaleX = nextWidth / W;
|
||||||
|
scaleY = nextHeight / H;
|
||||||
|
}
|
||||||
|
|
||||||
const areas = [
|
const areas = [
|
||||||
{ x: 150, y: 442, w: 760, h: 132, pad: 16 },
|
{ x: 150, y: 455, w: 760, h: 119, pad: 16 }
|
||||||
{ x: 404, y: 675, w: 220, h: 38, pad: 10 }
|
|
||||||
];
|
];
|
||||||
|
|
||||||
function eraseEditableAreas() {
|
function eraseEditableAreas() {
|
||||||
@@ -152,11 +168,12 @@
|
|||||||
if (!original.complete || !original.naturalWidth) return;
|
if (!original.complete || !original.naturalWidth) return;
|
||||||
|
|
||||||
const pad = area.pad || 8;
|
const pad = area.pad || 8;
|
||||||
const sourceX = 72;
|
const sourceX = Math.round((area.sourceX || 72) * scaleX);
|
||||||
const sourceW = 88;
|
const sourceY = Math.round((area.sourceY || Math.max(0, area.y - pad)) * scaleY);
|
||||||
|
const sourceW = Math.round(88 * scaleX);
|
||||||
const patch = document.createElement("canvas");
|
const patch = document.createElement("canvas");
|
||||||
patch.width = area.w + pad * 2;
|
patch.width = Math.round((area.w + pad * 2) * scaleX);
|
||||||
patch.height = area.h + pad * 2;
|
patch.height = Math.round((area.h + pad * 2) * scaleY);
|
||||||
const p = patch.getContext("2d");
|
const p = patch.getContext("2d");
|
||||||
|
|
||||||
for (let x = 0; x < patch.width; x += sourceW) {
|
for (let x = 0; x < patch.width; x += sourceW) {
|
||||||
@@ -164,7 +181,7 @@
|
|||||||
p.drawImage(
|
p.drawImage(
|
||||||
original,
|
original,
|
||||||
sourceX,
|
sourceX,
|
||||||
Math.max(0, area.y - pad),
|
sourceY,
|
||||||
tileW,
|
tileW,
|
||||||
patch.height,
|
patch.height,
|
||||||
x,
|
x,
|
||||||
@@ -176,7 +193,7 @@
|
|||||||
|
|
||||||
p.globalCompositeOperation = "destination-in";
|
p.globalCompositeOperation = "destination-in";
|
||||||
p.globalCompositeOperation = "source-over";
|
p.globalCompositeOperation = "source-over";
|
||||||
p.globalAlpha = 0.38;
|
p.globalAlpha = area.blendAlpha || 0.38;
|
||||||
p.fillStyle = "#fbf7ef";
|
p.fillStyle = "#fbf7ef";
|
||||||
p.fillRect(0, 0, patch.width, patch.height);
|
p.fillRect(0, 0, patch.width, patch.height);
|
||||||
p.globalAlpha = 1;
|
p.globalAlpha = 1;
|
||||||
@@ -199,7 +216,10 @@
|
|||||||
p.fillStyle = gx;
|
p.fillStyle = gx;
|
||||||
p.fillRect(0, 0, patch.width, patch.height);
|
p.fillRect(0, 0, patch.width, patch.height);
|
||||||
|
|
||||||
ctx.drawImage(patch, area.x - pad, area.y - pad);
|
ctx.save();
|
||||||
|
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
||||||
|
ctx.drawImage(patch, Math.round((area.x - pad) * scaleX), Math.round((area.y - pad) * scaleY));
|
||||||
|
ctx.restore();
|
||||||
}
|
}
|
||||||
|
|
||||||
function drawFallbackBase() {
|
function drawFallbackBase() {
|
||||||
@@ -244,17 +264,17 @@
|
|||||||
let x = 185;
|
let x = 185;
|
||||||
const y = 494;
|
const y = 494;
|
||||||
x = drawInlineText(text.inWord, x, y, 24, 400, 18);
|
x = drawInlineText(text.inWord, x, y, 24, 400, 18);
|
||||||
x = drawInlineText(value("startYear"), x, y, 24, 900, 8, "dateNumber");
|
x = drawInlineText(value("startYear"), x, y, 24, 400, 8);
|
||||||
x = drawInlineText(text.year, x, y, 24, 400, 12);
|
x = drawInlineText(text.year, x, y, 24, 400, 12);
|
||||||
x = drawInlineText(value("startMonth"), x, y, 24, 900, 6, "dateNumber");
|
x = drawInlineText(value("startMonth"), x, y, 24, 400, 6);
|
||||||
x = drawInlineText(text.month, x, y, 24, 400, 12);
|
x = drawInlineText(text.month, x, y, 24, 400, 12);
|
||||||
x = drawInlineText(value("startDay"), x, y, 24, 900, 4, "dateNumber");
|
x = drawInlineText(value("startDay"), x, y, 24, 400, 4);
|
||||||
x = drawInlineText(`${text.day}${text.toWord}`, x, y, 24, 400, 12);
|
x = drawInlineText(`${text.day}${text.toWord}`, x, y, 24, 400, 12);
|
||||||
x = drawInlineText(value("endYear"), x, y, 24, 900, 8, "dateNumber");
|
x = drawInlineText(value("endYear"), x, y, 24, 400, 8);
|
||||||
x = drawInlineText(text.year, x, y, 24, 400, 12);
|
x = drawInlineText(text.year, x, y, 24, 400, 12);
|
||||||
x = drawInlineText(value("endMonth"), x, y, 24, 900, 6, "dateNumber");
|
x = drawInlineText(value("endMonth"), x, y, 24, 400, 6);
|
||||||
x = drawInlineText(text.month, x, y, 24, 400, 12);
|
x = drawInlineText(text.month, x, y, 24, 400, 12);
|
||||||
x = drawInlineText(value("endDay"), x, y, 24, 900, 4, "dateNumber");
|
x = drawInlineText(value("endDay"), x, y, 24, 400, 4);
|
||||||
x = drawInlineText(`${text.day}\u5b8c\u6210\u4e86`, x, y, 24, 400, 8);
|
x = drawInlineText(`${text.day}\u5b8c\u6210\u4e86`, x, y, 24, 400, 8);
|
||||||
drawInlineFlow([
|
drawInlineFlow([
|
||||||
{ text: `\u201c${value("courseName")}\u201d`, style: "course" },
|
{ text: `\u201c${value("courseName")}\u201d`, style: "course" },
|
||||||
@@ -346,9 +366,12 @@
|
|||||||
|
|
||||||
function drawIssueDate() {
|
function drawIssueDate() {
|
||||||
ctx.textAlign = "center";
|
ctx.textAlign = "center";
|
||||||
ctx.fillStyle = "#43382f";
|
ctx.fillStyle = "#111";
|
||||||
ctx.font = '700 13px "SimSun", serif';
|
ctx.font = '700 13px "SimSun", serif';
|
||||||
ctx.fillText(`${text.issueDate}${value("issueDate")}`, W / 2, 704);
|
const parts = (value("issueDate").match(/\d+/g) || []);
|
||||||
|
ctx.fillText(parts[0] || "", 500, 688);
|
||||||
|
ctx.fillText(parts[1] || "", 535, 688);
|
||||||
|
ctx.fillText(parts[2] || "", 566, 688);
|
||||||
}
|
}
|
||||||
|
|
||||||
function drawCertificateNo() {
|
function drawCertificateNo() {
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 91 KiB |
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">
|
<section class="preview">
|
||||||
<div class="preview-head">
|
<div class="preview-head">
|
||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">1024 x 759</p>
|
<p class="eyebrow">3437 x 2551 / 1024 design coordinates</p>
|
||||||
<h2>实时预览</h2>
|
<h2>实时预览</h2>
|
||||||
</div>
|
</div>
|
||||||
<span id="renderState">加载中</span>
|
<span id="renderState">加载中</span>
|
||||||
|
|||||||
@@ -15,6 +15,8 @@
|
|||||||
- `PUBLIC_BASE_URL` 已改成正式域名。
|
- `PUBLIC_BASE_URL` 已改成正式域名。
|
||||||
- `DATABASE_URL` 指向生产 MySQL。
|
- `DATABASE_URL` 指向生产 MySQL。
|
||||||
- `DATA_ROOT` 指向生产数据目录。
|
- `DATA_ROOT` 指向生产数据目录。
|
||||||
|
- `CORS_ORIGINS` 已改成正式域名。
|
||||||
|
- 如果正式启用短信验证码,`SMS_MODE=aliyun`,且阿里云短信签名、模板 Code、AccessKey 已配置。
|
||||||
|
|
||||||
## 数据库
|
## 数据库
|
||||||
|
|
||||||
@@ -47,6 +49,7 @@ docker compose exec backend python -m app.cli init-db --admin-username admin --a
|
|||||||
- 公开查询支持两种方式:
|
- 公开查询支持两种方式:
|
||||||
- 证书编号查询:必须输入姓名和图形验证码。
|
- 证书编号查询:必须输入姓名和图形验证码。
|
||||||
- 短信验证码查询:必须输入手机号和短信验证码。
|
- 短信验证码查询:必须输入手机号和短信验证码。
|
||||||
|
- 阿里云短信测试手机号可收到验证码,后端日志没有 `未安装依赖` 或 `发送失败`。
|
||||||
- 作废证书不能下载正常 PDF。
|
- 作废证书不能下载正常 PDF。
|
||||||
- 直达链接重置后旧链接失效。
|
- 直达链接重置后旧链接失效。
|
||||||
- PDF 可生成,二维码可打开核验页。
|
- PDF 可生成,二维码可打开核验页。
|
||||||
|
|||||||
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>
|
</div>
|
||||||
|
|
||||||
<el-table :data="certificates" border>
|
<el-table :data="certificates" border>
|
||||||
<el-table-column prop="certificate_no" label="证书编号" width="220" />
|
<el-table-column prop="certificate_no" label="证书编号" width="180" show-overflow-tooltip />
|
||||||
<el-table-column prop="learner_id" label="学员ID" width="90" />
|
<el-table-column prop="learner_id" label="学员ID" width="76" />
|
||||||
<el-table-column prop="project_code" label="项目" width="90" />
|
<el-table-column prop="project_code" label="项目" width="72" />
|
||||||
<el-table-column prop="certificate_name" label="证书名称" min-width="180" />
|
<el-table-column prop="certificate_name" label="证书名称" min-width="140" show-overflow-tooltip />
|
||||||
<el-table-column prop="issue_date" label="发证日期" width="120" />
|
<el-table-column prop="issue_date" label="发证日期" width="108" />
|
||||||
<el-table-column label="状态" width="100">
|
<el-table-column label="状态" width="80">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-tag :type="row.status === 'valid' ? 'success' : 'danger'">{{ statusText(row.status) }}</el-tag>
|
<el-tag :type="row.status === 'valid' ? 'success' : 'danger'">{{ statusText(row.status) }}</el-tag>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="操作" width="330">
|
<el-table-column label="操作" width="270">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-button size="small" @click="previewCertificate(row)">预览</el-button>
|
<div class="table-actions">
|
||||||
<el-button size="small" @click="downloadCertificate(row)">下载</el-button>
|
<el-button size="small" @click="previewCertificate(row)">预览</el-button>
|
||||||
<el-button size="small" @click="regenerateToken(row)">重置链接</el-button>
|
<el-button size="small" @click="downloadCertificate(row)">下载</el-button>
|
||||||
<el-button v-if="row.status === 'valid'" size="small" type="danger" @click="voidCertificate(row)">作废</el-button>
|
<el-button size="small" @click="regenerateToken(row)">重置链接</el-button>
|
||||||
|
<el-button v-if="row.status === 'valid'" size="small" type="danger" @click="voidCertificate(row)">作废</el-button>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
@@ -221,4 +223,14 @@ onMounted(async () => {
|
|||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.table-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-actions :deep(.el-button + .el-button) {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
"jsx": "preserve",
|
"jsx": "preserve",
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
"types": []
|
"types": []
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
import { defineConfig } from "vite";
|
import { defineConfig } from "vite";
|
||||||
import vue from "@vitejs/plugin-vue";
|
import vue from "@vitejs/plugin-vue";
|
||||||
|
|
||||||
|
const apiProxyTarget = process.env.VITE_API_PROXY_TARGET || "http://localhost:8000";
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [vue()],
|
plugins: [vue()],
|
||||||
server: {
|
server: {
|
||||||
port: 5173,
|
port: 5173,
|
||||||
proxy: {
|
proxy: {
|
||||||
"/api": {
|
"/api": {
|
||||||
target: "http://localhost:8000",
|
target: apiProxyTarget,
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
rewrite: (path) => path.replace(/^\/api/, "")
|
rewrite: (path) => path.replace(/^\/api/, "")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,9 @@ CAPTCHAS: dict[str, tuple[str, float]] = {}
|
|||||||
SMS_CODES: dict[str, tuple[str, float]] = {}
|
SMS_CODES: dict[str, tuple[str, float]] = {}
|
||||||
LOG_RETENTION_DAYS = 180
|
LOG_RETENTION_DAYS = 180
|
||||||
HIGH_RISK_LOG_RETENTION_DAYS = 365
|
HIGH_RISK_LOG_RETENTION_DAYS = 365
|
||||||
|
DESIGN_WIDTH = 1024
|
||||||
|
DESIGN_HEIGHT = 759
|
||||||
|
PDF_BASE_RESOLUTION = 150.0
|
||||||
|
|
||||||
COL_NAME = "\u59d3\u540d"
|
COL_NAME = "\u59d3\u540d"
|
||||||
COL_PHONE = "\u624b\u673a\u53f7"
|
COL_PHONE = "\u624b\u673a\u53f7"
|
||||||
@@ -440,38 +443,51 @@ def font_name() -> str:
|
|||||||
return "Helvetica"
|
return "Helvetica"
|
||||||
|
|
||||||
|
|
||||||
def pil_font(size: int, kind: str = "song", bold: bool = False) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
|
def pil_font(size: int, kind: str = "song", bold: bool = False, scale: float = 1.0) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
|
||||||
choices = {
|
choices = {
|
||||||
"kai": [
|
"kai": [
|
||||||
r"C:\Windows\Fonts\simkai.ttf", r"C:\Windows\Fonts\msyh.ttc", r"C:\Windows\Fonts\simsun.ttc",
|
r"C:\Windows\Fonts\simkai.ttf", r"C:\Windows\Fonts\msyh.ttc", r"C:\Windows\Fonts\simsun.ttc",
|
||||||
|
"/usr/share/fonts/opentype/noto/NotoSerifCJK-Regular.ttc",
|
||||||
|
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
|
||||||
"/System/Library/Fonts/Supplemental/Kaiti.ttc",
|
"/System/Library/Fonts/Supplemental/Kaiti.ttc",
|
||||||
|
("/System/Library/Fonts/Hiragino Sans GB.ttc", 2),
|
||||||
"/System/Library/Fonts/PingFang.ttc",
|
"/System/Library/Fonts/PingFang.ttc",
|
||||||
"/System/Library/Fonts/Supplemental/Songti.ttc",
|
("/System/Library/Fonts/Supplemental/Songti.ttc", 1),
|
||||||
],
|
],
|
||||||
"hei": [
|
"hei": [
|
||||||
r"C:\Windows\Fonts\simhei.ttf", r"C:\Windows\Fonts\msyhbd.ttc", r"C:\Windows\Fonts\msyh.ttc",
|
r"C:\Windows\Fonts\simhei.ttf", r"C:\Windows\Fonts\msyhbd.ttc", r"C:\Windows\Fonts\msyh.ttc",
|
||||||
"/System/Library/Fonts/STHeiti Medium.ttc",
|
"/usr/share/fonts/opentype/noto/NotoSansCJK-Bold.ttc",
|
||||||
|
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
|
||||||
|
("/System/Library/Fonts/STHeiti Medium.ttc", 1),
|
||||||
|
("/System/Library/Fonts/Hiragino Sans GB.ttc", 2),
|
||||||
"/System/Library/Fonts/Supplemental/Heiti.ttc",
|
"/System/Library/Fonts/Supplemental/Heiti.ttc",
|
||||||
"/System/Library/Fonts/PingFang.ttc",
|
"/System/Library/Fonts/PingFang.ttc",
|
||||||
],
|
],
|
||||||
"song": [
|
"song": [
|
||||||
r"C:\Windows\Fonts\msyh.ttc", r"C:\Windows\Fonts\simsun.ttc",
|
r"C:\Windows\Fonts\msyh.ttc", r"C:\Windows\Fonts\simsun.ttc",
|
||||||
"/System/Library/Fonts/Supplemental/Songti.ttc",
|
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
|
||||||
|
"/usr/share/fonts/opentype/noto/NotoSerifCJK-Regular.ttc",
|
||||||
|
("/System/Library/Fonts/Supplemental/Songti.ttc", 3),
|
||||||
|
("/System/Library/Fonts/Supplemental/Songti.ttc", 6),
|
||||||
|
("/System/Library/Fonts/Hiragino Sans GB.ttc", 0),
|
||||||
"/System/Library/Fonts/PingFang.ttc",
|
"/System/Library/Fonts/PingFang.ttc",
|
||||||
"/System/Library/Fonts/STHeiti Light.ttc",
|
("/System/Library/Fonts/STHeiti Light.ttc", 1),
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
paths = choices.get(kind, choices["song"])
|
paths = choices.get(kind, choices["song"])
|
||||||
if bold and kind == "song":
|
if bold and kind == "song":
|
||||||
paths = [
|
paths = [
|
||||||
r"C:\Windows\Fonts\msyhbd.ttc", r"C:\Windows\Fonts\simhei.ttf",
|
r"C:\Windows\Fonts\msyhbd.ttc", r"C:\Windows\Fonts\simhei.ttf",
|
||||||
"/System/Library/Fonts/STHeiti Medium.ttc",
|
"/usr/share/fonts/opentype/noto/NotoSansCJK-Bold.ttc",
|
||||||
|
("/System/Library/Fonts/STHeiti Medium.ttc", 1),
|
||||||
|
("/System/Library/Fonts/Hiragino Sans GB.ttc", 2),
|
||||||
"/System/Library/Fonts/Supplemental/Heiti.ttc",
|
"/System/Library/Fonts/Supplemental/Heiti.ttc",
|
||||||
] + paths
|
] + paths
|
||||||
for item in paths:
|
for item in paths:
|
||||||
if Path(item).exists():
|
path, index = item if isinstance(item, tuple) else (item, 0)
|
||||||
|
if Path(path).exists():
|
||||||
try:
|
try:
|
||||||
return ImageFont.truetype(item, size)
|
return ImageFont.truetype(path, max(1, round(size * scale)), index=index)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
return ImageFont.load_default()
|
return ImageFont.load_default()
|
||||||
@@ -500,12 +516,23 @@ def draw_fit(draw: ImageDraw.ImageDraw, pos: tuple[int, int], text: str, font: I
|
|||||||
draw.text(pos, text, fill=fill, font=font_obj)
|
draw.text(pos, text, fill=fill, font=font_obj)
|
||||||
|
|
||||||
|
|
||||||
def draw_inline(draw: ImageDraw.ImageDraw, x: float, y: int, text: str, size: int, gap: int, kind: str = "song", bold: bool = False) -> float:
|
def draw_inline(
|
||||||
font = pil_font(size, kind, bold)
|
draw: ImageDraw.ImageDraw,
|
||||||
|
x: float,
|
||||||
|
y: float,
|
||||||
|
text: str,
|
||||||
|
size: int,
|
||||||
|
gap: int,
|
||||||
|
kind: str = "song",
|
||||||
|
bold: bool = False,
|
||||||
|
scale: float = 1.0,
|
||||||
|
x_scale: float = 1.0,
|
||||||
|
) -> float:
|
||||||
|
font = pil_font(size, kind, bold, scale)
|
||||||
draw.text((x, y), text, fill="#111111", font=font, anchor="ls")
|
draw.text((x, y), text, fill="#111111", font=font, anchor="ls")
|
||||||
if bold:
|
if bold:
|
||||||
draw.text((x + 0.45, y), text, fill="#111111", font=font, anchor="ls")
|
draw.text((x + 0.45 * x_scale, y), text, fill="#111111", font=font, anchor="ls")
|
||||||
return x + draw.textlength(text, font=font) + gap
|
return x + draw.textlength(text, font=font) + gap * x_scale
|
||||||
|
|
||||||
|
|
||||||
def draw_course_name(draw: ImageDraw.ImageDraw, text: str, x: float, y: int) -> int:
|
def draw_course_name(draw: ImageDraw.ImageDraw, text: str, x: float, y: int) -> int:
|
||||||
@@ -525,23 +552,31 @@ def draw_course_name(draw: ImageDraw.ImageDraw, text: str, x: float, y: int) ->
|
|||||||
return start_y + (len(lines) - 1) * 34
|
return start_y + (len(lines) - 1) * 34
|
||||||
|
|
||||||
|
|
||||||
def draw_inline_flow(draw: ImageDraw.ImageDraw, segments: list[tuple[str, str, int]], start_x: float, start_y: int) -> None:
|
def draw_inline_flow(
|
||||||
|
draw: ImageDraw.ImageDraw,
|
||||||
|
segments: list[tuple[str, str, int]],
|
||||||
|
start_x: float,
|
||||||
|
start_y: float,
|
||||||
|
scale: float,
|
||||||
|
x_scale: float,
|
||||||
|
y_scale: float,
|
||||||
|
) -> None:
|
||||||
x = start_x
|
x = start_x
|
||||||
y = start_y
|
y = start_y
|
||||||
line_start = 185
|
line_start = 185 * x_scale
|
||||||
line_height = 42
|
line_height = 42 * y_scale
|
||||||
for text, style, gap_before in segments:
|
for text, style, gap_before in segments:
|
||||||
x += gap_before
|
x += gap_before * x_scale
|
||||||
text_font = pil_font(27, "kai", True) if style == "course" else pil_font(24, "song", False)
|
text_font = pil_font(27, "kai", True, scale) if style == "course" else pil_font(24, "song", False, scale)
|
||||||
for char in text:
|
for char in text:
|
||||||
max_right = 900 if y == start_y else 730
|
max_right = (900 if y == start_y else 730) * x_scale
|
||||||
width = draw.textlength(char, font=text_font)
|
width = draw.textlength(char, font=text_font)
|
||||||
if x + width > max_right and x > line_start:
|
if x + width > max_right and x > line_start:
|
||||||
x = line_start
|
x = line_start
|
||||||
y += line_height
|
y += line_height
|
||||||
draw.text((x, y), char, fill="#111111", font=text_font, anchor="ls")
|
draw.text((x, y), char, fill="#111111", font=text_font, anchor="ls")
|
||||||
if style == "course":
|
if style == "course":
|
||||||
draw.text((x + 0.45, y), char, fill="#111111", font=text_font, anchor="ls")
|
draw.text((x + 0.45 * x_scale, y), char, fill="#111111", font=text_font, anchor="ls")
|
||||||
x += width
|
x += width
|
||||||
|
|
||||||
|
|
||||||
@@ -560,55 +595,119 @@ def split_by_width(draw: ImageDraw.ImageDraw, text: str, text_font: ImageFont.Im
|
|||||||
return lines
|
return lines
|
||||||
|
|
||||||
|
|
||||||
def paper_patch(image: Image.Image, box: tuple[int, int, int, int]) -> None:
|
def scaled_box(box: tuple[int, int, int, int], x_scale: float, y_scale: float) -> tuple[int, int, int, int]:
|
||||||
x, y, w, h = box
|
x, y, w, h = box
|
||||||
|
return round(x * x_scale), round(y * y_scale), round(w * x_scale), round(h * y_scale)
|
||||||
|
|
||||||
|
|
||||||
|
def to_xyxy(box: tuple[int, int, int, int]) -> tuple[int, int, int, int]:
|
||||||
|
x, y, w, h = box
|
||||||
|
return x, y, x + w, y + h
|
||||||
|
|
||||||
|
|
||||||
|
def paper_patch(image: Image.Image, box: tuple[int, int, int, int], x_scale: float, y_scale: float) -> None:
|
||||||
|
x, y, w, h = scaled_box(box, x_scale, y_scale)
|
||||||
patch = Image.new("RGB", (w, h), "#fbf7ef")
|
patch = Image.new("RGB", (w, h), "#fbf7ef")
|
||||||
source = image.crop((72, max(0, y), 160, max(0, y) + h))
|
source = image.crop((round(72 * x_scale), y, round(160 * x_scale), y + h))
|
||||||
for tx in range(0, w, source.width):
|
for tx in range(0, w, source.width):
|
||||||
patch.paste(source.crop((0, 0, min(source.width, w - tx), h)), (tx, 0))
|
patch.paste(source.crop((0, 0, min(source.width, w - tx), h)), (tx, 0))
|
||||||
cover = Image.new("RGB", (w, h), "#fbf7ef")
|
cover = Image.new("RGB", (w, h), "#fbf7ef")
|
||||||
patch = Image.blend(patch, cover, 0.38)
|
patch = Image.blend(patch, cover, 0.38)
|
||||||
image.paste(patch, (x, y))
|
image.paste(patch, (x, y), soft_edge_mask(w, h, round(16 * x_scale), round(10 * y_scale)))
|
||||||
|
|
||||||
|
|
||||||
|
def soft_edge_mask(width: int, height: int, feather_x: int, feather_y: int) -> Image.Image:
|
||||||
|
feather_x = max(1, feather_x)
|
||||||
|
feather_y = max(1, feather_y)
|
||||||
|
mask = Image.new("L", (width, height), 255)
|
||||||
|
pixels = mask.load()
|
||||||
|
for y in range(height):
|
||||||
|
vertical = min(1.0, y / feather_y, (height - 1 - y) / feather_y)
|
||||||
|
for x in range(width):
|
||||||
|
horizontal = min(1.0, x / feather_x, (width - 1 - x) / feather_x)
|
||||||
|
pixels[x, y] = max(0, min(255, round(255 * min(horizontal, vertical))))
|
||||||
|
return mask
|
||||||
|
|
||||||
|
|
||||||
|
def draw_issue_date_numbers(
|
||||||
|
draw: ImageDraw.ImageDraw,
|
||||||
|
issue_year: str,
|
||||||
|
issue_month: str,
|
||||||
|
issue_day: str,
|
||||||
|
scale: float,
|
||||||
|
x_scale: float,
|
||||||
|
y_scale: float,
|
||||||
|
) -> None:
|
||||||
|
text_font = pil_font(13, "song", True, scale)
|
||||||
|
y = 688 * y_scale
|
||||||
|
for text, x in [(issue_year, 500), (issue_month, 535), (issue_day, 566)]:
|
||||||
|
draw.text((x * x_scale, y), text, fill="#111111", font=text_font, anchor="mm")
|
||||||
|
|
||||||
|
|
||||||
|
def certificate_template_path() -> Path:
|
||||||
|
assets_dir = ROOT / "static" / "assets"
|
||||||
|
for name in ["certificate-template.png", "certificate-template.jpg"]:
|
||||||
|
path = assets_dir / name
|
||||||
|
if path.exists():
|
||||||
|
return path
|
||||||
|
raise FileNotFoundError("Certificate template image not found")
|
||||||
|
|
||||||
|
|
||||||
|
def pdf_resolution(image: Image.Image) -> float:
|
||||||
|
return PDF_BASE_RESOLUTION * (image.width / DESIGN_WIDTH)
|
||||||
|
|
||||||
|
|
||||||
def render_certificate_image(conn: sqlite3.Connection, cert: sqlite3.Row) -> Image.Image:
|
def render_certificate_image(conn: sqlite3.Connection, cert: sqlite3.Row) -> Image.Image:
|
||||||
template = ROOT / "static" / "assets" / "certificate-template.jpg"
|
template = certificate_template_path()
|
||||||
image = Image.open(template).convert("RGB")
|
image = Image.open(template).convert("RGB")
|
||||||
base_image = image.copy()
|
base_image = image.copy()
|
||||||
draw = ImageDraw.Draw(image)
|
draw = ImageDraw.Draw(image)
|
||||||
|
x_scale = image.width / DESIGN_WIDTH
|
||||||
|
y_scale = image.height / DESIGN_HEIGHT
|
||||||
|
text_scale = (x_scale + y_scale) / 2
|
||||||
learner = conn.execute("select * from learners where id=?", (cert["learner_id"],)).fetchone()
|
learner = conn.execute("select * from learners where id=?", (cert["learner_id"],)).fetchone()
|
||||||
learner_name = (learner["current_name"] if learner else "") or ""
|
learner_name = (learner["current_name"] if learner else "") or ""
|
||||||
issue_year, issue_month, issue_day = date_parts(cert["issue_date"])
|
issue_year, issue_month, issue_day = date_parts(cert["issue_date"])
|
||||||
|
|
||||||
for box in [(150, 442, 760, 132), (404, 675, 220, 38)]:
|
paper_patch(image, (150, 455, 760, 119), x_scale, y_scale)
|
||||||
paper_patch(image, box)
|
|
||||||
|
|
||||||
draw.text((512, 414), learner_name, fill="#111111", font=pil_font(32, "kai", True), anchor="mm")
|
draw.text(
|
||||||
|
(512 * x_scale, 414 * y_scale),
|
||||||
|
learner_name,
|
||||||
|
fill="#111111",
|
||||||
|
font=pil_font(32, "kai", True, text_scale),
|
||||||
|
anchor="mm",
|
||||||
|
)
|
||||||
|
|
||||||
x = 185.0
|
x = 185.0 * x_scale
|
||||||
y = 494
|
y = 494 * y_scale
|
||||||
x = draw_inline(draw, x, y, "\u5728", 24, 18)
|
x = draw_inline(draw, x, y, "\u5728", 24, 18, scale=text_scale, x_scale=x_scale)
|
||||||
x = draw_inline(draw, x, y, issue_year, 24, 8, "kai", True)
|
x = draw_inline(draw, x, y, issue_year, 24, 8, scale=text_scale, x_scale=x_scale)
|
||||||
x = draw_inline(draw, x, y, "\u5e74", 24, 12)
|
x = draw_inline(draw, x, y, "\u5e74", 24, 12, scale=text_scale, x_scale=x_scale)
|
||||||
x = draw_inline(draw, x, y, issue_month, 24, 6, "kai", True)
|
x = draw_inline(draw, x, y, issue_month, 24, 6, scale=text_scale, x_scale=x_scale)
|
||||||
x = draw_inline(draw, x, y, "\u6708", 24, 12)
|
x = draw_inline(draw, x, y, "\u6708", 24, 12, scale=text_scale, x_scale=x_scale)
|
||||||
x = draw_inline(draw, x, y, issue_day, 24, 4, "kai", True)
|
x = draw_inline(draw, x, y, issue_day, 24, 4, scale=text_scale, x_scale=x_scale)
|
||||||
x = draw_inline(draw, x, y, "\u65e5\u81f3", 24, 12)
|
x = draw_inline(draw, x, y, "\u65e5\u81f3", 24, 12, scale=text_scale, x_scale=x_scale)
|
||||||
x = draw_inline(draw, x, y, issue_year, 24, 8, "kai", True)
|
x = draw_inline(draw, x, y, issue_year, 24, 8, scale=text_scale, x_scale=x_scale)
|
||||||
x = draw_inline(draw, x, y, "\u5e74", 24, 12)
|
x = draw_inline(draw, x, y, "\u5e74", 24, 12, scale=text_scale, x_scale=x_scale)
|
||||||
x = draw_inline(draw, x, y, issue_month, 24, 6, "kai", True)
|
x = draw_inline(draw, x, y, issue_month, 24, 6, scale=text_scale, x_scale=x_scale)
|
||||||
x = draw_inline(draw, x, y, "\u6708", 24, 12)
|
x = draw_inline(draw, x, y, "\u6708", 24, 12, scale=text_scale, x_scale=x_scale)
|
||||||
x = draw_inline(draw, x, y, issue_day, 24, 4, "kai", True)
|
x = draw_inline(draw, x, y, issue_day, 24, 4, scale=text_scale, x_scale=x_scale)
|
||||||
x = draw_inline(draw, x, y, "\u65e5\u5b8c\u6210\u4e86", 24, 8)
|
x = draw_inline(draw, x, y, "\u65e5\u5b8c\u6210\u4e86", 24, 8, scale=text_scale, x_scale=x_scale)
|
||||||
course_text = f"\u201c{cert['course_name'] or cert['certificate_name'] or cert['project_code']}\u201d"
|
course_text = f"\u201c{cert['course_name'] or cert['certificate_name'] or cert['project_code']}\u201d"
|
||||||
stage_input = (cert["stage_name"] or "").strip()
|
stage_input = (cert["stage_name"] or "").strip()
|
||||||
stage = f"{stage_input}\u8bfe\u7a0b\u7684\u4e13\u4e1a\u5b66\u4e60\u3002" if stage_input else "\u521d\u7ea7\u8bfe\u7a0b\u7684\u4e13\u4e1a\u5b66\u4e60\u3002"
|
stage = f"{stage_input}\u8bfe\u7a0b\u7684\u4e13\u4e1a\u5b66\u4e60\u3002" if stage_input else "\u521d\u7ea7\u8bfe\u7a0b\u7684\u4e13\u4e1a\u5b66\u4e60\u3002"
|
||||||
draw_inline_flow(draw, [(course_text, "course", 0), (stage, "normal", 14)], x, y)
|
draw_inline_flow(draw, [(course_text, "course", 0), (stage, "normal", 14)], x, y, text_scale, x_scale, y_scale)
|
||||||
|
|
||||||
issue_label = f"\u53d1\u8bc1\u65e5\u671f\uff1a{issue_year} \u5e74 {issue_month} \u6708 {issue_day} \u65e5"
|
draw_issue_date_numbers(draw, issue_year, issue_month, issue_day, text_scale, x_scale, y_scale)
|
||||||
draw.text((512, 704), issue_label, fill="#43382f", font=pil_font(13, "song", True), anchor="mm")
|
draw.text(
|
||||||
draw.text((105, 76), f"\u8bc1\u4e66\u7f16\u53f7\uff1a{cert['certificate_no']}", fill="#111827", font=pil_font(14, "hei", True))
|
(105 * x_scale, 76 * y_scale),
|
||||||
image.paste(base_image.crop((720, 535, 950, 629)), (720, 535))
|
f"\u8bc1\u4e66\u7f16\u53f7\uff1a{cert['certificate_no']}",
|
||||||
|
fill="#111827",
|
||||||
|
font=pil_font(14, "hei", True, text_scale),
|
||||||
|
)
|
||||||
|
mentor_box = scaled_box((720, 535, 230, 94), x_scale, y_scale)
|
||||||
|
image.paste(base_image.crop(to_xyxy(mentor_box)), mentor_box[:2])
|
||||||
return image
|
return image
|
||||||
|
|
||||||
|
|
||||||
@@ -616,12 +715,12 @@ def build_pdf(conn: sqlite3.Connection, cert: sqlite3.Row) -> Path:
|
|||||||
pdf_dir = DATA / "pdf-cache"
|
pdf_dir = DATA / "pdf-cache"
|
||||||
pdf_dir.mkdir(exist_ok=True)
|
pdf_dir.mkdir(exist_ok=True)
|
||||||
path = pdf_dir / f"{cert['certificate_no']}.pdf"
|
path = pdf_dir / f"{cert['certificate_no']}.pdf"
|
||||||
template = ROOT / "static" / "assets" / "certificate-template.jpg"
|
template = certificate_template_path()
|
||||||
renderer_mtime = Path(__file__).stat().st_mtime
|
renderer_mtime = Path(__file__).stat().st_mtime
|
||||||
if path.exists() and path.stat().st_mtime >= max(template.stat().st_mtime, renderer_mtime):
|
if path.exists() and path.stat().st_mtime >= max(template.stat().st_mtime, renderer_mtime):
|
||||||
return path
|
return path
|
||||||
image = render_certificate_image(conn, cert)
|
image = render_certificate_image(conn, cert)
|
||||||
image.save(path, "PDF", resolution=150.0)
|
image.save(path, "PDF", resolution=pdf_resolution(image))
|
||||||
conn.execute("update certificates set pdf_status=? where id=?", ("generated", cert["id"]))
|
conn.execute("update certificates set pdf_status=? where id=?", ("generated", cert["id"]))
|
||||||
return path
|
return path
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 91 KiB |
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;
|
gap: 12px;
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
.toolbar input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 240px;
|
||||||
|
max-width: 460px;
|
||||||
|
}
|
||||||
|
.toolbar select {
|
||||||
|
min-width: 140px;
|
||||||
|
}
|
||||||
|
.table-wrap {
|
||||||
|
width: 100%;
|
||||||
|
overflow-x: auto;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 680px;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
th,
|
||||||
|
td {
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
text-align: left;
|
||||||
|
vertical-align: middle;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
th {
|
||||||
|
background: var(--surface-soft);
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
td {
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
tbody tr:last-child td {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
tbody tr:hover {
|
||||||
|
background: var(--primary-light);
|
||||||
|
}
|
||||||
|
td:last-child,
|
||||||
|
th:last-child {
|
||||||
|
min-width: 168px;
|
||||||
|
}
|
||||||
|
.actions {
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
}
|
||||||
|
.actions button {
|
||||||
|
flex-shrink: 0;
|
||||||
|
height: 36px;
|
||||||
|
padding: 0 14px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
.pager {
|
.pager {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -775,7 +833,7 @@
|
|||||||
.actions {
|
.actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
flex-wrap: wrap;
|
flex-wrap: nowrap;
|
||||||
}
|
}
|
||||||
.dialog {
|
.dialog {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
@@ -836,6 +894,19 @@
|
|||||||
.side { position: static; }
|
.side { position: static; }
|
||||||
.form-grid, .stats { grid-template-columns: 1fr; }
|
.form-grid, .stats { grid-template-columns: 1fr; }
|
||||||
.cert-meta { grid-template-columns: 1fr; }
|
.cert-meta { grid-template-columns: 1fr; }
|
||||||
|
.main {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
.toolbar {
|
||||||
|
align-items: stretch;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.toolbar input,
|
||||||
|
.toolbar select,
|
||||||
|
.toolbar button {
|
||||||
|
width: 100%;
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 图形验证码弹窗 */
|
/* 图形验证码弹窗 */
|
||||||
@@ -1130,9 +1201,9 @@
|
|||||||
function esc(v) { return String(v ?? "").replace(/[&<>"']/g, s => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[s])); }
|
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 statusPill(value) { return `<span class="pill ${value === "voided" || value === "disabled" || value === "deleted" ? "bad" : ""}">${esc(value)}</span>`; }
|
||||||
function table(rows, cols, actions) {
|
function table(rows, cols, actions) {
|
||||||
if (!rows.length) return `<div class="panel"><p class="muted">暂无数据</p></div>`;
|
if (!rows.length) return `<p class="muted">暂无数据</p>`;
|
||||||
return `<table><thead><tr>${cols.map(c=>`<th>${c[1]}</th>`).join("")}${actions?"<th>操作</th>":""}</tr></thead><tbody>` +
|
return `<div class="table-wrap"><table><thead><tr>${cols.map(c=>`<th>${c[1]}</th>`).join("")}${actions?"<th>操作</th>":""}</tr></thead><tbody>` +
|
||||||
rows.map(r => `<tr>${cols.map(c=>`<td>${c[2] ? c[2](r[c[0]], r) : esc(r[c[0]])}</td>`).join("")}${actions?`<td><div class="actions">${actions(r)}</div></td>`:""}</tr>`).join("") + `</tbody></table>`;
|
rows.map(r => `<tr>${cols.map(c=>`<td>${c[2] ? c[2](r[c[0]], r) : esc(r[c[0]])}</td>`).join("")}${actions?`<td><div class="actions">${actions(r)}</div></td>`:""}</tr>`).join("") + `</tbody></table></div>`;
|
||||||
}
|
}
|
||||||
function showDialog(title, html) { dialogTitle.textContent = title; dialogBody.innerHTML = html; dialog.classList.remove("hidden"); }
|
function showDialog(title, html) { dialogTitle.textContent = title; dialogBody.innerHTML = html; dialog.classList.remove("hidden"); }
|
||||||
function closeDialog() { dialog.classList.add("hidden"); if (previewObjectUrl) { URL.revokeObjectURL(previewObjectUrl); previewObjectUrl = ""; } }
|
function closeDialog() { dialog.classList.add("hidden"); if (previewObjectUrl) { URL.revokeObjectURL(previewObjectUrl); previewObjectUrl = ""; } }
|
||||||
|
|||||||
Reference in New Issue
Block a user