Initial certificate system

This commit is contained in:
nelso
2026-06-06 19:32:24 +08:00
commit ce4ed6213b
105 changed files with 9133 additions and 0 deletions

20
backend/Dockerfile Normal file
View File

@@ -0,0 +1,20 @@
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl fonts-noto-cjk \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt \
&& python -m playwright install --with-deps chromium
COPY app ./app
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

38
backend/alembic.ini Normal file
View File

@@ -0,0 +1,38 @@
[alembic]
script_location = alembic
prepend_sys_path = .
sqlalchemy.url = sqlite:///./dev.db
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

45
backend/alembic/env.py Normal file
View File

@@ -0,0 +1,45 @@
from logging.config import fileConfig
from alembic import context
from sqlalchemy import engine_from_config, pool
from app.core.config import settings
from app.db.base import Base
import app.models # noqa: F401
config = context.config
config.set_main_option("sqlalchemy.url", settings.database_url)
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = Base.metadata
def run_migrations_offline() -> None:
context.configure(
url=settings.database_url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@@ -0,0 +1,23 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

View File

@@ -0,0 +1,188 @@
"""initial schema
Revision ID: 20260601_0001
Revises:
Create Date: 2026-06-01
"""
from alembic import op
import sqlalchemy as sa
revision = "20260601_0001"
down_revision = None
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"roles",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("code", sa.String(64), nullable=False),
sa.Column("name", sa.String(64), nullable=False),
sa.Column("created_at", sa.DateTime(), nullable=False),
)
op.create_index("ix_roles_code", "roles", ["code"], unique=True)
op.create_table(
"admin_users",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("username", sa.String(64), nullable=False),
sa.Column("password_hash", sa.String(255), nullable=False),
sa.Column("role_code", sa.String(64), nullable=False),
sa.Column("status", sa.String(32), nullable=False),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False),
)
op.create_index("ix_admin_users_username", "admin_users", ["username"], unique=True)
op.create_index("ix_admin_users_role_code", "admin_users", ["role_code"])
op.create_table(
"learners",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("phone", sa.String(32), nullable=False),
sa.Column("current_name", sa.String(64), nullable=False),
sa.Column("id_type", sa.String(32), nullable=True),
sa.Column("id_no_encrypted", sa.String(256), nullable=True),
sa.Column("id_no_masked", sa.String(64), nullable=True),
sa.Column("student_no", sa.String(64), nullable=True),
sa.Column("status", sa.String(32), nullable=False),
sa.Column("remark", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False),
)
op.create_index("ix_learners_phone", "learners", ["phone"], unique=True)
op.create_index("ix_learners_current_name", "learners", ["current_name"])
op.create_table(
"learner_name_histories",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("learner_id", sa.Integer(), nullable=False),
sa.Column("name", sa.String(64), nullable=False),
sa.Column("source", sa.String(64), nullable=False),
sa.Column("created_at", sa.DateTime(), nullable=False),
)
op.create_index("ix_learner_name_histories_learner_id", "learner_name_histories", ["learner_id"])
op.create_table(
"learner_phone_histories",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("learner_id", sa.Integer(), nullable=False),
sa.Column("old_phone", sa.String(32), nullable=False),
sa.Column("new_phone", sa.String(32), nullable=False),
sa.Column("reason", sa.String(255), nullable=False),
sa.Column("changed_by", sa.Integer(), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False),
)
op.create_index("ix_learner_phone_histories_learner_id", "learner_phone_histories", ["learner_id"])
op.create_table(
"project_courses",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("code", sa.String(16), nullable=False),
sa.Column("name", sa.String(128), nullable=False),
sa.Column("status", sa.String(32), nullable=False),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False),
)
op.create_index("ix_project_courses_code", "project_courses", ["code"], unique=True)
op.create_table(
"certificates",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("learner_id", sa.Integer(), nullable=False),
sa.Column("project_code", sa.String(16), nullable=False),
sa.Column("certificate_no", sa.String(64), nullable=False),
sa.Column("certificate_name", sa.String(128), nullable=False),
sa.Column("class_name", sa.String(128), nullable=True),
sa.Column("course_name", sa.String(128), nullable=True),
sa.Column("stage_name", sa.String(128), nullable=True),
sa.Column("issue_date", sa.Date(), nullable=False),
sa.Column("issuer_name", sa.String(128), nullable=False),
sa.Column("status", sa.String(32), nullable=False),
sa.Column("pdf_status", sa.String(32), nullable=False),
sa.Column("pdf_file_path", sa.String(512), nullable=True),
sa.Column("public_token_id", sa.Integer(), nullable=True),
sa.Column("qr_token_id", sa.Integer(), nullable=True),
sa.Column("remark", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False),
)
op.create_index("ix_certificates_learner_id", "certificates", ["learner_id"])
op.create_index("ix_certificates_project_code", "certificates", ["project_code"])
op.create_index("ix_certificates_certificate_no", "certificates", ["certificate_no"], unique=True)
op.create_table(
"certificate_access_tokens",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("certificate_id", sa.Integer(), nullable=False),
sa.Column("token_hash", sa.String(128), nullable=False),
sa.Column("token_value", sa.String(128), nullable=False),
sa.Column("token_type", sa.String(32), nullable=False),
sa.Column("status", sa.String(32), nullable=False),
sa.Column("created_reason", sa.String(128), nullable=False),
sa.Column("revoked_at", sa.DateTime(), nullable=True),
sa.Column("last_access_at", sa.DateTime(), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False),
)
op.create_index("ix_certificate_access_tokens_certificate_id", "certificate_access_tokens", ["certificate_id"])
op.create_index("ix_certificate_access_tokens_token_hash", "certificate_access_tokens", ["token_hash"], unique=True)
op.create_index("ix_certificate_access_tokens_token_value", "certificate_access_tokens", ["token_value"], unique=True)
op.create_table(
"import_batches",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("filename", sa.String(255), nullable=False),
sa.Column("file_path", sa.String(512), nullable=False),
sa.Column("status", sa.String(32), nullable=False),
sa.Column("total_rows", sa.Integer(), nullable=False),
sa.Column("valid_rows", sa.Integer(), nullable=False),
sa.Column("failed_rows", sa.Integer(), nullable=False),
sa.Column("conflict_rows", sa.Integer(), nullable=False),
sa.Column("error_report_path", sa.String(512), nullable=True),
sa.Column("created_by", sa.Integer(), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False),
)
op.create_index("ix_import_batches_created_by", "import_batches", ["created_by"])
op.create_table(
"import_batch_rows",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("batch_id", sa.Integer(), nullable=False),
sa.Column("row_no", sa.Integer(), nullable=False),
sa.Column("status", sa.String(32), nullable=False),
sa.Column("error_message", sa.Text(), nullable=True),
sa.Column("raw_json", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False),
)
op.create_index("ix_import_batch_rows_batch_id", "import_batch_rows", ["batch_id"])
op.create_table(
"operation_logs",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("admin_user_id", sa.Integer(), nullable=True),
sa.Column("action", sa.String(64), nullable=False),
sa.Column("object_type", sa.String(64), nullable=False),
sa.Column("object_id", sa.String(64), nullable=True),
sa.Column("ip_address", sa.String(64), nullable=True),
sa.Column("detail_json", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False),
)
op.create_index("ix_operation_logs_admin_user_id", "operation_logs", ["admin_user_id"])
op.create_index("ix_operation_logs_action", "operation_logs", ["action"])
op.create_index("ix_operation_logs_object_type", "operation_logs", ["object_type"])
def downgrade() -> None:
op.drop_table("operation_logs")
op.drop_table("import_batch_rows")
op.drop_table("import_batches")
op.drop_table("certificate_access_tokens")
op.drop_table("certificates")
op.drop_table("project_courses")
op.drop_table("learner_phone_histories")
op.drop_table("learner_name_histories")
op.drop_table("learners")
op.drop_table("admin_users")
op.drop_table("roles")

View File

@@ -0,0 +1,34 @@
"""add project certificate defaults
Revision ID: 20260602_0002
Revises: 20260601_0001
Create Date: 2026-06-02
"""
from alembic import op
import sqlalchemy as sa
revision = "20260602_0002"
down_revision = "20260601_0001"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("project_courses", sa.Column("default_certificate_name", sa.String(length=128), nullable=True))
op.add_column("project_courses", sa.Column("default_course_name", sa.String(length=128), nullable=True))
op.add_column("project_courses", sa.Column("default_stage_name", sa.String(length=128), nullable=True))
op.add_column("project_courses", sa.Column("default_issuer_name", sa.String(length=128), nullable=True))
op.execute("update project_courses set default_certificate_name='培训结业证书' where default_certificate_name is null")
op.execute("update project_courses set default_course_name=name where default_course_name is null")
op.execute("update project_courses set default_issuer_name='本公司' where default_issuer_name is null")
op.alter_column("project_courses", "default_certificate_name", nullable=False)
op.alter_column("project_courses", "default_issuer_name", nullable=False)
def downgrade() -> None:
op.drop_column("project_courses", "default_issuer_name")
op.drop_column("project_courses", "default_stage_name")
op.drop_column("project_courses", "default_course_name")
op.drop_column("project_courses", "default_certificate_name")

1
backend/app/__init__.py Normal file
View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1 @@

33
backend/app/api/deps.py Normal file
View File

@@ -0,0 +1,33 @@
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy.orm import Session
from app.core.security import decode_access_token
from app.db.session import get_db
from app.models import AdminUser
bearer_scheme = HTTPBearer(auto_error=False)
def get_current_admin(
credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
db: Session = Depends(get_db),
) -> AdminUser:
if credentials is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
payload = decode_access_token(credentials.credentials)
if not payload:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
admin = db.get(AdminUser, int(payload["sub"]))
if not admin or admin.status != "active":
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Inactive user")
return admin
def require_roles(*role_codes: str):
def checker(admin: AdminUser = Depends(get_current_admin)) -> AdminUser:
if admin.role_code not in role_codes:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Permission denied")
return admin
return checker

26
backend/app/api/router.py Normal file
View File

@@ -0,0 +1,26 @@
from fastapi import APIRouter
from app.api.routes import (
admin_certificates,
admin_dashboard,
admin_exports,
admin_imports,
admin_learners,
admin_logs,
admin_projects,
auth,
health,
public,
)
api_router = APIRouter()
api_router.include_router(health.router, tags=["health"])
api_router.include_router(auth.router, prefix="/admin/auth", tags=["admin-auth"])
api_router.include_router(admin_projects.router, prefix="/admin/projects", tags=["admin-projects"])
api_router.include_router(admin_dashboard.router, prefix="/admin/dashboard", tags=["admin-dashboard"])
api_router.include_router(admin_learners.router, prefix="/admin/learners", tags=["admin-learners"])
api_router.include_router(admin_certificates.router, prefix="/admin/certificates", tags=["admin-certificates"])
api_router.include_router(admin_imports.router, prefix="/admin/import-batches", tags=["admin-imports"])
api_router.include_router(admin_exports.router, prefix="/admin/exports", tags=["admin-exports"])
api_router.include_router(admin_logs.router, prefix="/admin/logs", tags=["admin-logs"])
api_router.include_router(public.router, prefix="/public", tags=["public"])

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,195 @@
from fastapi import APIRouter, Depends, HTTPException, Query, status
from fastapi.responses import FileResponse
from sqlalchemy import or_
from sqlalchemy.orm import Session
from app.api.deps import require_roles
from app.core.security import generate_public_token, hash_token
from app.db.session import get_db
from app.models import AdminUser, Certificate, CertificateAccessToken, Learner, ProjectCourse
from app.schemas.certificate import CertificateCreate, CertificateOut
from app.services.certificate_number import build_certificate_no
from app.services.logs import log_action
from app.services.pdf import render_certificate_pdf
router = APIRouter()
def _create_token(db: Session, certificate_id: int, token_type: str) -> int:
raw_token = generate_public_token()
token = CertificateAccessToken(
certificate_id=certificate_id,
token_hash=hash_token(raw_token),
token_value=raw_token,
token_type=token_type,
)
db.add(token)
db.flush()
return token.id
@router.get("", response_model=list[CertificateOut])
def list_certificates(
keyword: str | None = Query(default=None),
status_value: str | None = Query(default=None, alias="status"),
db: Session = Depends(get_db),
_: AdminUser = Depends(require_roles("system_admin", "certificate_admin", "readonly")),
) -> list[Certificate]:
query = db.query(Certificate).order_by(Certificate.id.desc())
if keyword:
like = f"%{keyword}%"
query = query.filter(
or_(
Certificate.certificate_no.like(like),
Certificate.certificate_name.like(like),
Certificate.project_code.like(like),
Certificate.course_name.like(like),
Certificate.stage_name.like(like),
)
)
if status_value:
query = query.filter(Certificate.status == status_value)
return query.limit(100).all()
@router.post("", response_model=CertificateOut, status_code=status.HTTP_201_CREATED)
def create_certificate(
payload: CertificateCreate,
db: Session = Depends(get_db),
admin: AdminUser = Depends(require_roles("system_admin", "certificate_admin")),
) -> Certificate:
learner = db.get(Learner, payload.learner_id)
if not learner:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Learner not found")
project = db.query(ProjectCourse).filter(ProjectCourse.code == payload.project_code, ProjectCourse.status == "active").first()
if not project:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Project code is inactive or missing")
certificate = Certificate(
learner_id=payload.learner_id,
project_code=payload.project_code,
certificate_no="PENDING",
certificate_name=project.name,
class_name=payload.class_name,
course_name=payload.course_name or project.default_course_name or project.name,
stage_name=payload.stage_name or project.default_stage_name,
issue_date=payload.issue_date,
issuer_name=payload.issuer_name or project.default_issuer_name,
remark=payload.remark,
)
db.add(certificate)
db.flush()
certificate.certificate_no = build_certificate_no(certificate.id, certificate.project_code, certificate.issue_date)
certificate.public_token_id = _create_token(db, certificate.id, "public_link")
certificate.qr_token_id = _create_token(db, certificate.id, "qr_verify")
log_action(db, admin, "create_certificate", "certificate", certificate.id, {"certificate_no": certificate.certificate_no, "learner_name": learner.current_name, "project_code": certificate.project_code, "issue_date": certificate.issue_date})
db.commit()
db.refresh(certificate)
return certificate
@router.get("/{certificate_id}", response_model=CertificateOut)
def get_certificate(
certificate_id: int,
db: Session = Depends(get_db),
_: AdminUser = Depends(require_roles("system_admin", "certificate_admin", "readonly")),
) -> Certificate:
certificate = db.get(Certificate, certificate_id)
if not certificate:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Certificate not found")
return certificate
@router.get("/{certificate_id}/preview")
def preview_certificate(
certificate_id: int,
db: Session = Depends(get_db),
_: AdminUser = Depends(require_roles("system_admin", "certificate_admin", "readonly")),
) -> dict[str, object]:
certificate = db.get(Certificate, certificate_id)
if not certificate:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Certificate not found")
learner = db.get(Learner, certificate.learner_id)
public_token = db.get(CertificateAccessToken, certificate.public_token_id) if certificate.public_token_id else None
qr_token = db.get(CertificateAccessToken, certificate.qr_token_id) if certificate.qr_token_id else None
return {
"certificate_no": certificate.certificate_no,
"learner_name": learner.current_name if learner else "",
"certificate_name": certificate.certificate_name,
"project_code": certificate.project_code,
"course_name": certificate.course_name,
"stage_name": certificate.stage_name,
"issue_date": certificate.issue_date.isoformat(),
"issuer_name": certificate.issuer_name,
"status": certificate.status,
"public_url": f"/cert/{public_token.token_value}" if public_token else "",
"verify_url": f"/verify/{qr_token.token_value}" if qr_token else "",
}
@router.get("/{certificate_id}/download")
def download_certificate(
certificate_id: int,
db: Session = Depends(get_db),
_: AdminUser = Depends(require_roles("system_admin", "certificate_admin")),
) -> FileResponse:
certificate = db.get(Certificate, certificate_id)
if not certificate:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Certificate not found")
if certificate.status != "valid":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Voided certificate cannot download normal PDF")
learner = db.get(Learner, certificate.learner_id)
token = db.get(CertificateAccessToken, certificate.qr_token_id or certificate.public_token_id)
project = db.query(ProjectCourse).filter(ProjectCourse.code == certificate.project_code).first()
if not learner or not token:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Certificate data is incomplete")
pdf_path = render_certificate_pdf(certificate, learner, project, token)
db.commit()
return FileResponse(pdf_path, media_type="application/pdf", filename=f"{certificate.certificate_no}.pdf")
@router.post("/{certificate_id}/void", response_model=CertificateOut)
def void_certificate(
certificate_id: int,
db: Session = Depends(get_db),
admin: AdminUser = Depends(require_roles("system_admin", "certificate_admin")),
) -> Certificate:
certificate = db.get(Certificate, certificate_id)
if not certificate:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Certificate not found")
certificate.status = "voided"
certificate.pdf_status = "not_generated"
certificate.pdf_file_path = None
learner = db.get(Learner, certificate.learner_id)
log_action(db, admin, "void_certificate", "certificate", certificate.id, {"certificate_no": certificate.certificate_no, "learner_name": learner.current_name if learner else "", "previous_status": "valid", "status": "voided"})
db.commit()
db.refresh(certificate)
return certificate
@router.post("/{certificate_id}/token/regenerate", response_model=CertificateOut)
def regenerate_public_token(
certificate_id: int,
db: Session = Depends(get_db),
admin: AdminUser = Depends(require_roles("system_admin", "certificate_admin")),
) -> Certificate:
certificate = db.get(Certificate, certificate_id)
if not certificate:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Certificate not found")
if certificate.public_token_id:
old_token = db.get(CertificateAccessToken, certificate.public_token_id)
if old_token:
old_token.status = "revoked"
certificate.public_token_id = _create_token(db, certificate.id, "public_link")
learner = db.get(Learner, certificate.learner_id)
log_action(
db,
admin,
"regenerate_public_token",
"certificate",
certificate.id,
{"certificate_no": certificate.certificate_no, "learner_name": learner.current_name if learner else ""},
)
db.commit()
db.refresh(certificate)
return certificate

View File

@@ -0,0 +1,22 @@
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from app.api.deps import require_roles
from app.db.session import get_db
from app.models import AdminUser, Certificate, ImportBatch, Learner
router = APIRouter()
@router.get("/summary")
def get_summary(
db: Session = Depends(get_db),
_: AdminUser = Depends(require_roles("system_admin", "certificate_admin", "readonly")),
) -> dict[str, int]:
return {
"learner_count": db.query(Learner).count(),
"certificate_count": db.query(Certificate).count(),
"valid_certificate_count": db.query(Certificate).filter(Certificate.status == "valid").count(),
"voided_certificate_count": db.query(Certificate).filter(Certificate.status == "voided").count(),
"recent_import_count": db.query(ImportBatch).count(),
}

View File

@@ -0,0 +1,84 @@
from fastapi import APIRouter, Depends, Query
from fastapi.responses import StreamingResponse
from openpyxl import Workbook
from sqlalchemy.orm import Session
from app.api.deps import require_roles
from app.core.config import settings
from app.core.paths import data_path
from app.db.session import get_db
from app.models import AdminUser, Certificate, CertificateAccessToken, Learner
from app.services.logs import log_action
router = APIRouter()
@router.get("/certificates")
def export_certificates(
project_code: str | None = Query(default=None),
status_value: str | None = Query(default=None, alias="status"),
db: Session = Depends(get_db),
admin: AdminUser = Depends(require_roles("system_admin", "certificate_admin")),
) -> StreamingResponse:
query = (
db.query(Certificate, Learner, CertificateAccessToken)
.join(Learner, Learner.id == Certificate.learner_id)
.join(CertificateAccessToken, CertificateAccessToken.id == Certificate.public_token_id)
.order_by(Certificate.id.desc())
)
if project_code:
query = query.filter(Certificate.project_code == project_code.strip().upper())
if status_value:
query = query.filter(Certificate.status == status_value)
workbook = Workbook()
sheet = workbook.active
sheet.title = "证书导出"
sheet.append(
[
"姓名",
"手机号",
"证书编号",
"证书对外直达链接",
"项目代码",
"证书名称",
"课程名称",
"阶段名称",
"发证日期",
"证书状态",
"PDF状态",
]
)
for certificate, learner, token in query.limit(50_000).all():
sheet.append(
[
learner.current_name,
_mask_phone(learner.phone),
certificate.certificate_no,
f"{settings.public_base_url}/cert/{token.token_value}",
certificate.project_code,
certificate.certificate_name,
certificate.course_name,
certificate.stage_name,
certificate.issue_date.isoformat(),
certificate.status,
certificate.pdf_status,
]
)
export_path = data_path("exports") / "certificates-export.xlsx"
workbook.save(export_path)
log_action(db, admin, "export_certificates", "certificate", detail={"project_code": project_code, "status": status_value})
db.commit()
file_handle = export_path.open("rb")
return StreamingResponse(
file_handle,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": 'attachment; filename="certificates-export.xlsx"'},
)
def _mask_phone(phone: str) -> str:
if len(phone) < 7:
return phone
return f"{phone[:3]}****{phone[-4:]}"

View File

@@ -0,0 +1,362 @@
import json
import shutil
from datetime import date, datetime
from pathlib import Path
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
from fastapi.responses import FileResponse, StreamingResponse
from openpyxl import Workbook, load_workbook
from sqlalchemy.orm import Session
from app.api.deps import require_roles
from app.core.paths import data_path
from app.core.security import generate_public_token, hash_token
from app.db.session import get_db
from app.models import (
AdminUser,
Certificate,
CertificateAccessToken,
ImportBatch,
ImportBatchRow,
Learner,
LearnerNameHistory,
ProjectCourse,
)
from app.schemas.import_batch import ImportBatchOut
from app.services.certificate_number import build_certificate_no
from app.services.logs import log_action
router = APIRouter()
COL_NAME = "\u59d3\u540d"
COL_PHONE = "\u624b\u673a\u53f7"
COL_PROJECT = "\u9879\u76ee\u4ee3\u7801"
COL_ISSUE_DATE = "\u53d1\u8bc1\u65e5\u671f"
TEMPLATE_HEADERS = [
COL_NAME,
COL_PHONE,
COL_PROJECT,
COL_ISSUE_DATE,
]
REQUIRED_HEADERS = [COL_NAME, COL_PHONE, COL_PROJECT, COL_ISSUE_DATE]
@router.get("/template")
def download_template(_: AdminUser = Depends(require_roles("system_admin", "certificate_admin"))) -> StreamingResponse:
workbook = Workbook()
sheet = workbook.active
sheet.title = "\u8bc1\u4e66\u5bfc\u5165\u6a21\u677f"
sheet.append(TEMPLATE_HEADERS)
stream_path = data_path("exports") / "certificate-import-template.xlsx"
workbook.save(stream_path)
file_handle = stream_path.open("rb")
return StreamingResponse(
file_handle,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": 'attachment; filename="certificate-import-template.xlsx"'},
)
@router.post("", response_model=ImportBatchOut, status_code=status.HTTP_201_CREATED)
def upload_import_file(
file: UploadFile = File(...),
db: Session = Depends(get_db),
admin: AdminUser = Depends(require_roles("system_admin", "certificate_admin")),
) -> ImportBatch:
if not file.filename or not file.filename.lower().endswith(".xlsx"):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Only .xlsx files are supported")
upload_path = data_path("uploads") / file.filename
with upload_path.open("wb") as target:
shutil.copyfileobj(file.file, target)
batch = ImportBatch(filename=file.filename, file_path=str(upload_path), created_by=admin.id)
db.add(batch)
db.flush()
validate_batch(db, batch, upload_path)
log_action(db, admin, "upload_import_file", "import_batch", batch.id, {"filename": file.filename, "total_rows": batch.total_rows, "valid_rows": batch.valid_rows, "failed_rows": batch.failed_rows, "status": batch.status})
db.commit()
db.refresh(batch)
return batch
@router.get("", response_model=list[ImportBatchOut])
def list_import_batches(
db: Session = Depends(get_db),
_: AdminUser = Depends(require_roles("system_admin", "certificate_admin", "readonly")),
) -> list[ImportBatch]:
return db.query(ImportBatch).order_by(ImportBatch.id.desc()).limit(50).all()
@router.get("/{batch_id}/error-report")
def download_error_report(
batch_id: int,
db: Session = Depends(get_db),
_: AdminUser = Depends(require_roles("system_admin", "certificate_admin")),
) -> FileResponse:
batch = db.get(ImportBatch, batch_id)
if not batch or not batch.error_report_path:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Error report not found")
return FileResponse(
batch.error_report_path,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
filename=f"import-errors-{batch.id}.xlsx",
)
@router.get("/{batch_id}/file")
def download_source_file(
batch_id: int,
db: Session = Depends(get_db),
_: AdminUser = Depends(require_roles("system_admin", "certificate_admin")),
) -> FileResponse:
batch = db.get(ImportBatch, batch_id)
if not batch or not Path(batch.file_path).exists():
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Source file not found")
return FileResponse(
batch.file_path,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
filename=batch.filename,
)
@router.delete("/{batch_id}")
def delete_import_batch(
batch_id: int,
db: Session = Depends(get_db),
admin: AdminUser = Depends(require_roles("system_admin", "certificate_admin")),
) -> dict[str, bool]:
batch = db.get(ImportBatch, batch_id)
if not batch:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Import batch not found")
for file_name in [batch.file_path, batch.error_report_path]:
if file_name:
path = Path(file_name)
if path.exists():
path.unlink()
db.query(ImportBatchRow).filter(ImportBatchRow.batch_id == batch.id).delete()
db.delete(batch)
log_action(db, admin, "delete_import_batch", "import_batch", batch.id, {"filename": batch.filename, "status": batch.status, "total_rows": batch.total_rows})
db.commit()
return {"ok": True}
@router.post("/{batch_id}/confirm", response_model=ImportBatchOut)
def confirm_import_batch(
batch_id: int,
db: Session = Depends(get_db),
admin: AdminUser = Depends(require_roles("system_admin", "certificate_admin")),
) -> ImportBatch:
batch = db.get(ImportBatch, batch_id)
if not batch:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Import batch not found")
if batch.status not in {"validated", "imported"}:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Import batch is not ready")
if batch.status == "imported":
return batch
rows = db.query(ImportBatchRow).filter(ImportBatchRow.batch_id == batch.id, ImportBatchRow.status == "valid").all()
ok_rows = 0
failed_rows = 0
for row in rows:
row_data = json.loads(row.raw_json or "{}")
learner = upsert_learner(db, row_data)
issue_date = parse_issue_date(row_data[COL_ISSUE_DATE])
project_code = str(row_data[COL_PROJECT]).strip().upper()
project = db.query(ProjectCourse).filter(ProjectCourse.code == project_code, ProjectCourse.status == "active").first()
if not project:
row.status = "failed"
row.error_message = f"Project code is inactive or missing: {project_code}"
failed_rows += 1
continue
duplicate = find_duplicate_certificate(db, learner.id, project, issue_date)
if duplicate:
row.status = "skipped"
row.error_message = "\u5df2\u5b58\u5728\uff0c\u65e0\u9700\u5904\u7406"
ok_rows += 1
continue
certificate = Certificate(
learner_id=learner.id,
project_code=project.code,
certificate_no="PENDING",
certificate_name=project.default_certificate_name,
course_name=project.default_course_name,
stage_name=project.default_stage_name,
issue_date=issue_date,
issuer_name=project.default_issuer_name,
remark=None,
)
db.add(certificate)
db.flush()
certificate.certificate_no = build_certificate_no(certificate.id, certificate.project_code, certificate.issue_date)
certificate.public_token_id = create_access_token(db, certificate.id, "public_link")
certificate.qr_token_id = create_access_token(db, certificate.id, "qr_verify")
row.status = "imported"
ok_rows += 1
batch.status = "imported" if ok_rows else "failed"
if failed_rows:
batch.failed_rows = (batch.failed_rows or 0) + failed_rows
log_action(db, admin, "confirm_import_batch", "import_batch", batch.id, {"filename": batch.filename, "valid_rows": batch.valid_rows, "imported_rows": ok_rows, "failed_rows": failed_rows, "status": batch.status})
db.commit()
db.refresh(batch)
return batch
def validate_batch(db: Session, batch: ImportBatch, upload_path: Path) -> None:
workbook = load_workbook(upload_path, read_only=True, data_only=True)
sheet = workbook.active
header = [cell.value for cell in next(sheet.iter_rows(min_row=1, max_row=1))]
header_index = {name: idx for idx, name in enumerate(header)}
missing = [name for name in TEMPLATE_HEADERS if name not in header_index]
if missing:
batch.status = "failed"
batch.failed_rows = 1
db.add(ImportBatchRow(batch_id=batch.id, row_no=1, status="failed", error_message=f"Missing columns: {missing}"))
return
active_codes = {row[0] for row in db.query(ProjectCourse.code).filter(ProjectCourse.status == "active").all()}
total = valid = failed = 0
for row_no, row in enumerate(sheet.iter_rows(min_row=2, values_only=True), start=2):
if not any(row):
continue
total += 1
row_data = {name: row[header_index[name]] for name in TEMPLATE_HEADERS}
errors = row_errors(row_data, active_codes)
if errors:
failed += 1
db.add(
ImportBatchRow(
batch_id=batch.id,
row_no=row_no,
status="failed",
error_message="; ".join(errors),
raw_json=json.dumps(row_data, ensure_ascii=False, default=str),
)
)
else:
valid += 1
db.add(
ImportBatchRow(
batch_id=batch.id,
row_no=row_no,
status="valid",
raw_json=json.dumps(row_data, ensure_ascii=False, default=str),
)
)
batch.total_rows = total
batch.valid_rows = valid
batch.failed_rows = failed
batch.status = "validated"
if failed:
batch.error_report_path = str(write_error_report(db, batch.id))
def row_errors(row_data: dict[str, object], active_codes: set[str]) -> list[str]:
errors = []
for name in REQUIRED_HEADERS:
if not row_data.get(name):
errors.append(f"{name} is required")
project_code = str(row_data.get(COL_PROJECT) or "").strip().upper()
if project_code and project_code not in active_codes:
errors.append("Project code is inactive or missing")
if row_data.get(COL_ISSUE_DATE) and not date_is_valid(row_data[COL_ISSUE_DATE]):
errors.append("Issue date format is invalid")
return errors
def upsert_learner(db: Session, row_data: dict[str, object]) -> Learner:
phone = str(row_data[COL_PHONE]).strip()
name = str(row_data[COL_NAME]).strip()
learner = db.query(Learner).filter(Learner.phone == phone).first()
if learner:
if learner.current_name != name:
db.add(LearnerNameHistory(learner_id=learner.id, name=name, source="import"))
learner.current_name = name
return learner
learner = Learner(phone=phone, current_name=name)
db.add(learner)
db.flush()
db.add(LearnerNameHistory(learner_id=learner.id, name=name, source="import"))
return learner
def get_active_project(db: Session, project_code: str) -> ProjectCourse:
project = db.query(ProjectCourse).filter(ProjectCourse.code == project_code, ProjectCourse.status == "active").first()
if not project:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"Project code is inactive or missing: {project_code}")
return project
def find_duplicate_certificate(db: Session, learner_id: int, project: ProjectCourse, issue_date: date) -> Certificate | None:
return (
db.query(Certificate)
.filter(Certificate.learner_id == learner_id)
.filter(Certificate.project_code == project.code)
.filter(Certificate.certificate_name == project.default_certificate_name)
.filter(Certificate.course_name == project.default_course_name)
.filter(Certificate.stage_name == project.default_stage_name)
.filter(Certificate.issue_date == issue_date)
.first()
)
def create_access_token(db: Session, certificate_id: int, token_type: str) -> int:
raw_token = generate_public_token()
access_token = CertificateAccessToken(
certificate_id=certificate_id,
token_hash=hash_token(raw_token),
token_value=raw_token,
token_type=token_type,
)
db.add(access_token)
db.flush()
return access_token.id
def optional_text(value: object) -> str | None:
if value is None:
return None
text = str(value).strip()
return text or None
def parse_issue_date(value: object) -> date:
if isinstance(value, datetime):
return value.date()
if isinstance(value, date):
return value
text = str(value).strip()
for fmt in ["%Y-%m-%d", "%Y/%m/%d", "%Y.%m.%d"]:
try:
return datetime.strptime(text, fmt).date()
except ValueError:
continue
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"Invalid issue date: {text}")
def date_is_valid(value: object) -> bool:
try:
parse_issue_date(value)
return True
except HTTPException:
return False
def write_error_report(db: Session, batch_id: int) -> Path:
workbook = Workbook()
sheet = workbook.active
sheet.title = "\u9519\u8bef\u62a5\u544a"
sheet.append(["\u884c\u53f7", "\u9519\u8bef\u539f\u56e0", "\u539f\u59cb\u6570\u636e"])
rows = db.query(ImportBatchRow).filter(ImportBatchRow.batch_id == batch_id, ImportBatchRow.status == "failed").all()
for row in rows:
sheet.append([row.row_no, row.error_message, row.raw_json])
report_path = data_path("error-reports") / f"import-errors-{batch_id}.xlsx"
workbook.save(report_path)
return report_path

View File

@@ -0,0 +1,126 @@
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import or_
from sqlalchemy.orm import Session
from app.api.deps import require_roles
from app.db.session import get_db
from app.models import AdminUser, Learner, LearnerNameHistory
from app.schemas.learner import LearnerCreate, LearnerOut, LearnerUpdate
from app.services.logs import diff_values, log_action, mask_phone
router = APIRouter()
@router.get("", response_model=list[LearnerOut])
def list_learners(
keyword: str | None = Query(default=None),
db: Session = Depends(get_db),
_: AdminUser = Depends(require_roles("system_admin", "certificate_admin", "readonly")),
) -> list[Learner]:
query = db.query(Learner).filter(Learner.status != "deleted").order_by(Learner.id.desc())
if keyword:
like = f"%{keyword}%"
query = query.filter(or_(Learner.current_name.like(like), Learner.phone.like(like), Learner.student_no.like(like)))
return query.limit(100).all()
@router.post("", response_model=LearnerOut, status_code=status.HTTP_201_CREATED)
def create_learner(
payload: LearnerCreate,
db: Session = Depends(get_db),
admin: AdminUser = Depends(require_roles("system_admin", "certificate_admin")),
) -> Learner:
existing = db.query(Learner).filter(Learner.phone == payload.phone).first()
if existing:
if existing.current_name != payload.current_name:
db.add(LearnerNameHistory(learner_id=existing.id, name=payload.current_name, source="manual"))
existing.current_name = payload.current_name
existing.student_no = payload.student_no
existing.remark = payload.remark
log_action(db, admin, "update_learner", "learner", existing.id, {"name": existing.current_name, "phone": mask_phone(existing.phone)})
db.commit()
db.refresh(existing)
return existing
learner = Learner(
phone=payload.phone,
current_name=payload.current_name,
student_no=payload.student_no,
remark=payload.remark,
)
db.add(learner)
db.commit()
db.refresh(learner)
db.add(LearnerNameHistory(learner_id=learner.id, name=learner.current_name, source="manual"))
log_action(db, admin, "create_learner", "learner", learner.id, {"name": learner.current_name, "phone": mask_phone(learner.phone), "status": learner.status})
db.commit()
return learner
@router.get("/{learner_id}", response_model=LearnerOut)
def get_learner(
learner_id: int,
db: Session = Depends(get_db),
_: AdminUser = Depends(require_roles("system_admin", "certificate_admin", "readonly")),
) -> Learner:
learner = db.get(Learner, learner_id)
if not learner:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Learner not found")
return learner
@router.put("/{learner_id}", response_model=LearnerOut)
def update_learner(
learner_id: int,
payload: LearnerUpdate,
db: Session = Depends(get_db),
admin: AdminUser = Depends(require_roles("system_admin", "certificate_admin")),
) -> Learner:
learner = db.get(Learner, learner_id)
if not learner:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Learner not found")
duplicate = db.query(Learner).filter(Learner.phone == payload.phone, Learner.id != learner_id).first()
if duplicate:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Phone already exists")
before = {
"phone": learner.phone,
"current_name": learner.current_name,
"student_no": learner.student_no,
"status": learner.status,
"remark": learner.remark,
}
if learner.current_name != payload.current_name:
db.add(LearnerNameHistory(learner_id=learner.id, name=payload.current_name, source="manual"))
learner.phone = payload.phone
learner.current_name = payload.current_name
learner.student_no = payload.student_no
learner.status = payload.status
learner.remark = payload.remark
after = {
"phone": learner.phone,
"current_name": learner.current_name,
"student_no": learner.student_no,
"status": learner.status,
"remark": learner.remark,
}
changes = diff_values(before, after, list(after.keys()))
if "phone" in changes:
changes["phone"] = {"before": mask_phone(changes["phone"]["before"]), "after": mask_phone(changes["phone"]["after"])}
log_action(db, admin, "update_learner", "learner", learner.id, {"name": learner.current_name, "phone": mask_phone(learner.phone), "changes": changes})
db.commit()
db.refresh(learner)
return learner
@router.delete("/{learner_id}")
def delete_learner(
learner_id: int,
db: Session = Depends(get_db),
admin: AdminUser = Depends(require_roles("system_admin", "certificate_admin")),
) -> dict[str, bool]:
learner = db.get(Learner, learner_id)
if not learner:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Learner not found")
learner.status = "deleted"
log_action(db, admin, "delete_learner", "learner", learner.id, {"name": learner.current_name, "phone": mask_phone(learner.phone)})
db.commit()
return {"ok": True}

View File

@@ -0,0 +1,115 @@
from fastapi import APIRouter, Depends, Query
from fastapi.responses import StreamingResponse
from openpyxl import Workbook
from sqlalchemy.orm import Session
from app.api.deps import require_roles
from app.core.paths import data_path
from app.db.session import get_db
from app.models import AdminUser, OperationLog
from app.schemas.log import OperationLogOut
from app.services.logs import HIGH_RISK_LOG_RETENTION_DAYS, LOG_RETENTION_DAYS, cleanup_expired_logs, format_log
router = APIRouter()
@router.get("", response_model=list[OperationLogOut])
def list_logs(
action: str | None = Query(default=None),
object_type: str | None = Query(default=None),
risk: str | None = Query(default=None),
keyword: str | None = Query(default=None),
page: int = Query(default=1, ge=1),
page_size: int = Query(default=20, ge=1, le=100),
db: Session = Depends(get_db),
_: AdminUser = Depends(require_roles("system_admin", "certificate_admin", "readonly")),
) -> dict[str, object]:
items = _query_logs(db, action, object_type, risk, keyword)
total = len(items)
total_pages = max(1, (total + page_size - 1) // page_size)
page = min(page, total_pages)
start = (page - 1) * page_size
return {
"items": items[start:start + page_size],
"total": total,
"page": page,
"page_size": page_size,
"total_pages": total_pages,
}
@router.get("/export")
def export_logs(
action: str | None = Query(default=None),
object_type: str | None = Query(default=None),
risk: str | None = Query(default=None),
keyword: str | None = Query(default=None),
db: Session = Depends(get_db),
_: AdminUser = Depends(require_roles("system_admin", "certificate_admin", "readonly")),
) -> StreamingResponse:
items = _query_logs(db, action, object_type, risk, keyword)
workbook = Workbook()
sheet = workbook.active
sheet.title = "\u64cd\u4f5c\u65e5\u5fd7"
sheet.append(["\u65f6\u95f4", "\u64cd\u4f5c\u4ebaID", "\u64cd\u4f5c", "\u5bf9\u8c61", "\u5bf9\u8c61ID", "\u98ce\u9669\u7b49\u7ea7", "\u6458\u8981", "\u8be6\u60c5"])
for item in items:
sheet.append([
item.get("created_at"),
item.get("admin_user_id"),
item.get("action_label"),
item.get("object_label"),
item.get("object_id"),
item.get("risk_label"),
item.get("summary"),
item.get("detail_json"),
])
for column in "ABCDEFGH":
sheet.column_dimensions[column].width = 22 if column != "H" else 60
export_path = data_path("exports") / "operation-logs.xlsx"
workbook.save(export_path)
file_handle = export_path.open("rb")
return StreamingResponse(
file_handle,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": 'attachment; filename="operation-logs.xlsx"'},
)
@router.post("/cleanup")
def cleanup_logs(
db: Session = Depends(get_db),
_: AdminUser = Depends(require_roles("system_admin", "certificate_admin")),
) -> dict[str, int]:
removed = cleanup_expired_logs(db)
db.commit()
return {
"removed": removed,
"normal_retention_days": LOG_RETENTION_DAYS,
"high_risk_retention_days": HIGH_RISK_LOG_RETENTION_DAYS,
}
def _query_logs(
db: Session,
action: str | None,
object_type: str | None,
risk: str | None,
keyword: str | None,
) -> list[dict[str, object]]:
query = db.query(OperationLog).order_by(OperationLog.id.desc())
cleanup_expired_logs(db)
db.commit()
if action:
query = query.filter(OperationLog.action == action)
if object_type:
query = query.filter(OperationLog.object_type == object_type)
items = [format_log(item) for item in query.limit(500).all()]
if risk:
items = [item for item in items if item["risk"] == risk]
if keyword:
word = keyword.strip().lower()
items = [
item for item in items
if word in " ".join(str(item.get(key, "")) for key in ["object_id", "summary", "detail_json", "action_label", "object_label"]).lower()
]
return items[:200]

View File

@@ -0,0 +1,82 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.api.deps import require_roles
from app.db.session import get_db
from app.models import AdminUser, ProjectCourse
from app.schemas.project import ProjectCourseCreate, ProjectCourseOut, ProjectCourseUpdate
from app.services.logs import diff_values, log_action
router = APIRouter()
@router.get("", response_model=list[ProjectCourseOut])
def list_projects(
db: Session = Depends(get_db),
_: AdminUser = Depends(require_roles("system_admin", "certificate_admin", "readonly")),
) -> list[ProjectCourse]:
return db.query(ProjectCourse).order_by(ProjectCourse.code.asc()).all()
@router.post("", response_model=ProjectCourseOut, status_code=status.HTTP_201_CREATED)
def create_project(
payload: ProjectCourseCreate,
db: Session = Depends(get_db),
admin: AdminUser = Depends(require_roles("system_admin", "certificate_admin")),
) -> ProjectCourse:
if db.query(ProjectCourse).filter(ProjectCourse.code == payload.code).first():
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="项目代码已存在")
project = ProjectCourse(
code=payload.code,
name=payload.name,
default_certificate_name=payload.name,
default_course_name=payload.default_course_name,
default_stage_name=payload.default_stage_name,
default_issuer_name=payload.default_issuer_name,
)
db.add(project)
log_action(db, admin, "create_project", "project_course", detail={"code": payload.code, "name": payload.name, "status": project.status})
db.commit()
db.refresh(project)
return project
@router.put("/{project_id}", response_model=ProjectCourseOut)
def update_project(
project_id: int,
payload: ProjectCourseUpdate,
db: Session = Depends(get_db),
admin: AdminUser = Depends(require_roles("system_admin", "certificate_admin")),
) -> ProjectCourse:
project = db.get(ProjectCourse, project_id)
if not project:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在")
before = {
"name": project.name,
"default_course_name": project.default_course_name,
"default_stage_name": project.default_stage_name,
"default_issuer_name": project.default_issuer_name,
"status": project.status,
}
if payload.name is not None:
project.name = payload.name
project.default_certificate_name = payload.name
if payload.default_course_name is not None:
project.default_course_name = payload.default_course_name
if payload.default_stage_name is not None:
project.default_stage_name = payload.default_stage_name
if payload.default_issuer_name is not None:
project.default_issuer_name = payload.default_issuer_name
if payload.status is not None:
project.status = payload.status
after = {
"name": project.name,
"default_course_name": project.default_course_name,
"default_stage_name": project.default_stage_name,
"default_issuer_name": project.default_issuer_name,
"status": project.status,
}
log_action(db, admin, "update_project", "project_course", project.id, {"code": project.code, "name": project.name, "changes": diff_values(before, after, list(after.keys()))})
db.commit()
db.refresh(project)
return project

View File

@@ -0,0 +1,21 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.core.security import create_access_token, verify_password
from app.db.session import get_db
from app.models import AdminUser
from app.schemas.auth import LoginRequest, LoginResponse
router = APIRouter()
@router.post("/login", response_model=LoginResponse)
def login(payload: LoginRequest, db: Session = Depends(get_db)) -> LoginResponse:
admin = db.query(AdminUser).filter(AdminUser.username == payload.username).first()
if not admin or admin.status != "active" or not verify_password(payload.password, admin.password_hash):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="账号或密码错误,请重试")
return LoginResponse(
access_token=create_access_token(str(admin.id), admin.role_code),
role_code=admin.role_code,
username=admin.username,
)

View File

@@ -0,0 +1,8 @@
from fastapi import APIRouter
router = APIRouter()
@router.get("/health")
def health_check() -> dict[str, str]:
return {"status": "ok"}

View File

@@ -0,0 +1,138 @@
from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi.responses import FileResponse
from pydantic import BaseModel, Field, model_validator
from sqlalchemy.orm import Session
from app.core.security import hash_token
from app.db.session import get_db
from app.models import Certificate, CertificateAccessToken, Learner, ProjectCourse
from app.services.captcha import make_captcha, verify_captcha
from app.services.logs import log_action, mask_phone
from app.services.pdf import render_certificate_pdf
from app.services.rate_limit import hit_too_often
router = APIRouter()
DISCLAIMER = "本证书仅用于证明学员完成相关培训课程/阶段学习,不代表国家学历、学位、职业资格或职业技能等级认证。"
class CertificateSearchRequest(BaseModel):
certificate_no: str | None = Field(default=None, max_length=64)
phone: str | None = Field(default=None, max_length=32)
name: str = Field(min_length=1, max_length=64)
captcha_id: str = Field(min_length=1, max_length=128)
captcha_code: str = Field(min_length=1, max_length=16)
@model_validator(mode="after")
def certificate_no_or_phone_required(self) -> "CertificateSearchRequest":
if not (self.certificate_no or "").strip() and not (self.phone or "").strip():
raise ValueError("请填写证书编号或手机号其中一项")
return self
@router.get("/captcha")
def get_captcha() -> dict[str, str]:
return make_captcha()
@router.post("/certificates/search")
def search_certificate(
payload: CertificateSearchRequest,
request: Request,
db: Session = Depends(get_db),
) -> dict[str, object]:
client_ip = request.client.host if request.client else "unknown"
if hit_too_often(f"search:{client_ip}", limit=30, window_seconds=60):
raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail="访问过于频繁,请稍后再试")
if not verify_captcha(payload.captcha_id, payload.captcha_code):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="验证码错误或已过期,请重新输入")
name = payload.name.strip()
certificate_no = (payload.certificate_no or "").strip().upper()
phone = (payload.phone or "").strip()
query = db.query(Certificate, Learner).join(Learner, Learner.id == Certificate.learner_id)
if certificate_no:
result = query.filter(Certificate.certificate_no == certificate_no).filter(Learner.current_name == name).all()
else:
result = (
query.filter(Learner.phone == phone)
.filter(Learner.current_name == name)
.order_by(Certificate.issue_date.desc(), Certificate.id.desc())
.all()
)
if not result:
log_action(db, None, "public_search_certificate", "public_access", detail={"mode": "certificate_no" if certificate_no else "phone", "name": name, "certificate_no": certificate_no or None, "phone": mask_phone(phone), "matched_count": 0, "result": "not_found"})
db.commit()
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="未查询到匹配的证书信息,请核对后重试")
log_action(db, None, "public_search_certificate", "public_access", result[0][0].certificate_no, {"mode": "certificate_no" if certificate_no else "phone", "name": name, "certificate_no": certificate_no or None, "phone": mask_phone(phone), "matched_count": len(result), "result": "matched"})
db.commit()
return {"items": [public_certificate_payload(db, certificate, learner) for certificate, learner in result]}
@router.get("/certificates/token/{token}")
def get_certificate_by_token(token: str, db: Session = Depends(get_db)) -> dict[str, object]:
access_token = get_active_token(db, token)
certificate, learner = get_certificate_and_learner(db, access_token.certificate_id)
return public_certificate_payload(db, certificate, learner)
@router.get("/certificates/token/{token}/download")
def download_certificate_pdf(token: str, db: Session = Depends(get_db)) -> FileResponse:
access_token = get_active_token(db, token)
certificate, learner = get_certificate_and_learner(db, access_token.certificate_id)
if certificate.status != "valid":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="证书已作废,不支持下载正常 PDF")
project = db.query(ProjectCourse).filter(ProjectCourse.code == certificate.project_code).first()
pdf_path = render_certificate_pdf(certificate, learner, project, access_token)
log_action(db, None, "public_download_certificate", "certificate", certificate.id, {"certificate_no": certificate.certificate_no, "learner_name": learner.current_name, "project_code": certificate.project_code})
db.commit()
return FileResponse(pdf_path, media_type="application/pdf", filename=f"{certificate.certificate_no}.pdf")
def public_certificate_payload(db: Session, certificate: Certificate, learner: Learner) -> dict[str, object]:
token = db.get(CertificateAccessToken, certificate.public_token_id) if certificate.public_token_id else None
token_value = token.token_value if token and token.status == "active" else None
return {
"certificate_no": certificate.certificate_no,
"learner_name": learner.current_name,
"certificate_name": certificate.certificate_name,
"project_code": certificate.project_code,
"course_name": certificate.course_name,
"stage_name": certificate.stage_name,
"issue_date": certificate.issue_date.isoformat(),
"issuer_name": certificate.issuer_name,
"status": certificate.status,
"pdf_status": certificate.pdf_status,
"public_token": token_value,
"public_url": f"/cert/{token_value}" if token_value else None,
"download_url": f"/api/public/certificates/token/{token_value}/download" if token_value and certificate.status == "valid" else None,
"can_download_pdf": certificate.status == "valid" and bool(token_value),
"disclaimer": DISCLAIMER,
}
def get_active_token(db: Session, token: str) -> CertificateAccessToken:
access_token = (
db.query(CertificateAccessToken)
.filter(CertificateAccessToken.token_hash == hash_token(token))
.filter(CertificateAccessToken.status == "active")
.first()
)
if not access_token:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="链接无效或已失效")
return access_token
def get_certificate_and_learner(db: Session, certificate_id: int) -> tuple[Certificate, Learner]:
result = (
db.query(Certificate, Learner)
.join(Learner, Learner.id == Certificate.learner_id)
.filter(Certificate.id == certificate_id)
.first()
)
if not result:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="证书不存在")
return result

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

30
backend/app/cli.py Normal file
View File

@@ -0,0 +1,30 @@
import argparse
from alembic import command
from alembic.config import Config
from app.db.init_db import create_tables, seed_defaults
from app.db.session import SessionLocal
def main() -> None:
parser = argparse.ArgumentParser(description="Certificate system management commands")
subparsers = parser.add_subparsers(dest="command", required=True)
init_parser = subparsers.add_parser("init-db")
init_parser.add_argument("--admin-username", default="admin")
init_parser.add_argument("--admin-password", required=True)
args = parser.parse_args()
if args.command == "init-db":
try:
command.upgrade(Config("alembic.ini"), "head")
except Exception:
create_tables()
with SessionLocal() as db:
seed_defaults(db, args.admin_username, args.admin_password)
print("Database initialized")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,33 @@
import os
from dataclasses import dataclass, field
from functools import lru_cache
def _csv_env(name: str, default: list[str]) -> list[str]:
raw = os.getenv(name)
if not raw:
return default
return [item.strip() for item in raw.split(",") if item.strip()]
@dataclass(frozen=True)
class Settings:
app_name: str = os.getenv("APP_NAME", "Certificate Management System")
app_env: str = os.getenv("APP_ENV", "development")
database_url: str = os.getenv("DATABASE_URL", "sqlite:///./dev.db")
secret_key: str = os.getenv("SECRET_KEY", "change-me")
public_base_url: str = os.getenv("PUBLIC_BASE_URL", "http://localhost:8080")
certificate_no_secret: str = os.getenv("CERTIFICATE_NO_SECRET", "change-me")
pdf_cache_days: int = int(os.getenv("PDF_CACHE_DAYS", "30"))
data_root: str = os.getenv("DATA_ROOT", "/data/certificate-system")
cors_origins: list[str] = field(
default_factory=lambda: _csv_env("CORS_ORIGINS", ["http://localhost:5173", "http://localhost:8080"])
)
@lru_cache
def get_settings() -> Settings:
return Settings()
settings = get_settings()

15
backend/app/core/paths.py Normal file
View File

@@ -0,0 +1,15 @@
from pathlib import Path
from app.core.config import settings
DATA_ROOT = Path(settings.data_root)
def ensure_data_dirs() -> None:
for name in ["uploads", "error-reports", "exports", "pdf-cache"]:
(DATA_ROOT / name).mkdir(parents=True, exist_ok=True)
def data_path(name: str) -> Path:
ensure_data_dirs()
return DATA_ROOT / name

View File

@@ -0,0 +1,71 @@
import base64
import hashlib
import hmac
import json
import secrets
import time
from typing import Any
from app.core.config import settings
def hash_password(password: str, salt: str | None = None) -> str:
password_salt = salt or secrets.token_hex(16)
digest = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), password_salt.encode("utf-8"), 120_000)
return f"pbkdf2_sha256${password_salt}${digest.hex()}"
def verify_password(password: str, password_hash: str) -> bool:
try:
scheme, salt, expected = password_hash.split("$", 2)
except ValueError:
return False
if scheme != "pbkdf2_sha256":
return False
actual = hash_password(password, salt).split("$", 2)[2]
return hmac.compare_digest(actual, expected)
def _b64encode(data: bytes) -> str:
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
def _b64decode(data: str) -> bytes:
padding = "=" * (-len(data) % 4)
return base64.urlsafe_b64decode((data + padding).encode("ascii"))
def create_access_token(subject: str, role_code: str, expires_in: int = 60 * 60 * 8) -> str:
header = {"alg": "HS256", "typ": "JWT"}
payload = {"sub": subject, "role": role_code, "exp": int(time.time()) + expires_in}
signing_input = ".".join(
[
_b64encode(json.dumps(header, separators=(",", ":")).encode("utf-8")),
_b64encode(json.dumps(payload, separators=(",", ":")).encode("utf-8")),
]
)
signature = hmac.new(settings.secret_key.encode("utf-8"), signing_input.encode("ascii"), hashlib.sha256).digest()
return f"{signing_input}.{_b64encode(signature)}"
def decode_access_token(token: str) -> dict[str, Any] | None:
try:
header_b64, payload_b64, signature_b64 = token.split(".", 2)
signing_input = f"{header_b64}.{payload_b64}"
expected = hmac.new(settings.secret_key.encode("utf-8"), signing_input.encode("ascii"), hashlib.sha256).digest()
if not hmac.compare_digest(_b64encode(expected), signature_b64):
return None
payload = json.loads(_b64decode(payload_b64))
if int(payload.get("exp", 0)) < int(time.time()):
return None
return payload
except Exception:
return None
def hash_token(token: str) -> str:
return hashlib.sha256(f"{settings.secret_key}:{token}".encode("utf-8")).hexdigest()
def generate_public_token() -> str:
return secrets.token_urlsafe(32)

View File

@@ -0,0 +1 @@

5
backend/app/db/base.py Normal file
View File

@@ -0,0 +1,5 @@
from sqlalchemy.orm import DeclarativeBase
class Base(DeclarativeBase):
pass

50
backend/app/db/init_db.py Normal file
View File

@@ -0,0 +1,50 @@
from sqlalchemy.orm import Session
from app.core.security import hash_password
from app.db.base import Base
from app.db.session import engine
from app.models import AdminUser, ProjectCourse, Role
def create_tables() -> None:
import app.models # noqa: F401
Base.metadata.create_all(bind=engine)
def seed_defaults(db: Session, admin_username: str, admin_password: str) -> None:
roles = [
("system_admin", "系统管理员"),
("certificate_admin", "证书管理员"),
("readonly", "只读查看人员"),
]
for code, name in roles:
if not db.query(Role).filter(Role.code == code).first():
db.add(Role(code=code, name=name))
projects = [
("DBY", "大本营"),
("QSX", "七三线"),
]
for code, name in projects:
if not db.query(ProjectCourse).filter(ProjectCourse.code == code).first():
db.add(
ProjectCourse(
code=code,
name=name,
default_certificate_name="培训结业证书",
default_course_name=name,
default_stage_name=None,
default_issuer_name="本公司",
)
)
if not db.query(AdminUser).filter(AdminUser.username == admin_username).first():
db.add(
AdminUser(
username=admin_username,
password_hash=hash_password(admin_password),
role_code="system_admin",
)
)
db.commit()

17
backend/app/db/session.py Normal file
View File

@@ -0,0 +1,17 @@
from collections.abc import Generator
from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker
from app.core.config import settings
engine = create_engine(settings.database_url, pool_pre_ping=True)
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)
def get_db() -> Generator[Session, None, None]:
db = SessionLocal()
try:
yield db
finally:
db.close()

21
backend/app/main.py Normal file
View File

@@ -0,0 +1,21 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.api.router import api_router
from app.core.config import settings
def create_app() -> FastAPI:
app = FastAPI(title=settings.app_name)
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(api_router)
return app
app = create_app()

View File

@@ -0,0 +1,19 @@
from app.models.admin import AdminUser, Role
from app.models.certificate import Certificate, CertificateAccessToken, ProjectCourse
from app.models.import_batch import ImportBatch, ImportBatchRow
from app.models.learner import Learner, LearnerNameHistory, LearnerPhoneHistory
from app.models.log import OperationLog
__all__ = [
"AdminUser",
"Certificate",
"CertificateAccessToken",
"ImportBatch",
"ImportBatchRow",
"Learner",
"LearnerNameHistory",
"LearnerPhoneHistory",
"OperationLog",
"ProjectCourse",
"Role",
]

View File

@@ -0,0 +1,27 @@
from datetime import datetime
from sqlalchemy import DateTime, String
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
class Role(Base):
__tablename__ = "roles"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
code: Mapped[str] = mapped_column(String(64), unique=True, index=True)
name: Mapped[str] = mapped_column(String(64))
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
class AdminUser(Base):
__tablename__ = "admin_users"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
username: Mapped[str] = mapped_column(String(64), unique=True, index=True)
password_hash: Mapped[str] = mapped_column(String(255))
role_code: Mapped[str] = mapped_column(String(64), index=True)
status: Mapped[str] = mapped_column(String(32), default="active")
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)

View File

@@ -0,0 +1,59 @@
from datetime import date, datetime
from sqlalchemy import Date, DateTime, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
class ProjectCourse(Base):
__tablename__ = "project_courses"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
code: Mapped[str] = mapped_column(String(16), unique=True, index=True)
name: Mapped[str] = mapped_column(String(128))
default_certificate_name: Mapped[str] = mapped_column(String(128), default="培训结业证书")
default_course_name: Mapped[str | None] = mapped_column(String(128), nullable=True)
default_stage_name: Mapped[str | None] = mapped_column(String(128), nullable=True)
default_issuer_name: Mapped[str] = mapped_column(String(128), default="本公司")
status: Mapped[str] = mapped_column(String(32), default="active")
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
class Certificate(Base):
__tablename__ = "certificates"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
learner_id: Mapped[int] = mapped_column(index=True)
project_code: Mapped[str] = mapped_column(String(16), index=True)
certificate_no: Mapped[str] = mapped_column(String(64), unique=True, index=True)
certificate_name: Mapped[str] = mapped_column(String(128))
class_name: Mapped[str | None] = mapped_column(String(128), nullable=True)
course_name: Mapped[str | None] = mapped_column(String(128), nullable=True)
stage_name: Mapped[str | None] = mapped_column(String(128), nullable=True)
issue_date: Mapped[date] = mapped_column(Date)
issuer_name: Mapped[str] = mapped_column(String(128))
status: Mapped[str] = mapped_column(String(32), default="valid")
pdf_status: Mapped[str] = mapped_column(String(32), default="not_generated")
pdf_file_path: Mapped[str | None] = mapped_column(String(512), nullable=True)
public_token_id: Mapped[int | None] = mapped_column(nullable=True)
qr_token_id: Mapped[int | None] = mapped_column(nullable=True)
remark: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
class CertificateAccessToken(Base):
__tablename__ = "certificate_access_tokens"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
certificate_id: Mapped[int] = mapped_column(index=True)
token_hash: Mapped[str] = mapped_column(String(128), unique=True, index=True)
token_value: Mapped[str] = mapped_column(String(128), unique=True, index=True)
token_type: Mapped[str] = mapped_column(String(32))
status: Mapped[str] = mapped_column(String(32), default="active")
created_reason: Mapped[str] = mapped_column(String(128), default="initial")
revoked_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
last_access_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)

View File

@@ -0,0 +1,35 @@
from datetime import datetime
from sqlalchemy import DateTime, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
class ImportBatch(Base):
__tablename__ = "import_batches"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
filename: Mapped[str] = mapped_column(String(255))
file_path: Mapped[str] = mapped_column(String(512))
status: Mapped[str] = mapped_column(String(32), default="uploaded")
total_rows: Mapped[int] = mapped_column(Integer, default=0)
valid_rows: Mapped[int] = mapped_column(Integer, default=0)
failed_rows: Mapped[int] = mapped_column(Integer, default=0)
conflict_rows: Mapped[int] = mapped_column(Integer, default=0)
error_report_path: Mapped[str | None] = mapped_column(String(512), nullable=True)
created_by: Mapped[int | None] = mapped_column(nullable=True, index=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
class ImportBatchRow(Base):
__tablename__ = "import_batch_rows"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
batch_id: Mapped[int] = mapped_column(index=True)
row_no: Mapped[int] = mapped_column(Integer)
status: Mapped[str] = mapped_column(String(32))
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
raw_json: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)

View File

@@ -0,0 +1,44 @@
from datetime import datetime
from sqlalchemy import DateTime, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
class Learner(Base):
__tablename__ = "learners"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
phone: Mapped[str] = mapped_column(String(32), unique=True, index=True)
current_name: Mapped[str] = mapped_column(String(64), index=True)
id_type: Mapped[str | None] = mapped_column(String(32), nullable=True)
id_no_encrypted: Mapped[str | None] = mapped_column(String(256), nullable=True)
id_no_masked: Mapped[str | None] = mapped_column(String(64), nullable=True)
student_no: Mapped[str | None] = mapped_column(String(64), nullable=True)
status: Mapped[str] = mapped_column(String(32), default="active")
remark: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
class LearnerNameHistory(Base):
__tablename__ = "learner_name_histories"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
learner_id: Mapped[int] = mapped_column(index=True)
name: Mapped[str] = mapped_column(String(64))
source: Mapped[str] = mapped_column(String(64), default="import")
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
class LearnerPhoneHistory(Base):
__tablename__ = "learner_phone_histories"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
learner_id: Mapped[int] = mapped_column(index=True)
old_phone: Mapped[str] = mapped_column(String(32))
new_phone: Mapped[str] = mapped_column(String(32))
reason: Mapped[str] = mapped_column(String(255))
changed_by: Mapped[int | None] = mapped_column(nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)

19
backend/app/models/log.py Normal file
View File

@@ -0,0 +1,19 @@
from datetime import datetime
from sqlalchemy import DateTime, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
class OperationLog(Base):
__tablename__ = "operation_logs"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
admin_user_id: Mapped[int | None] = mapped_column(nullable=True, index=True)
action: Mapped[str] = mapped_column(String(64), index=True)
object_type: Mapped[str] = mapped_column(String(64), index=True)
object_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
ip_address: Mapped[str | None] = mapped_column(String(64), nullable=True)
detail_json: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,13 @@
from pydantic import BaseModel, Field
class LoginRequest(BaseModel):
username: str = Field(min_length=1, max_length=64)
password: str = Field(min_length=1, max_length=128)
class LoginResponse(BaseModel):
access_token: str
token_type: str = "bearer"
role_code: str
username: str

View File

@@ -0,0 +1,39 @@
from datetime import date, datetime
from pydantic import BaseModel, Field, field_validator
class CertificateCreate(BaseModel):
learner_id: int
project_code: str = Field(min_length=2, max_length=16)
certificate_name: str | None = Field(default=None, max_length=128)
class_name: str | None = Field(default=None, max_length=128)
course_name: str | None = Field(default=None, max_length=128)
stage_name: str | None = Field(default=None, max_length=128)
issue_date: date
issuer_name: str = Field(min_length=1, max_length=128)
remark: str | None = None
@field_validator("project_code")
@classmethod
def normalize_project_code(cls, value: str) -> str:
return value.strip().upper()
class CertificateOut(BaseModel):
id: int
learner_id: int
project_code: str
certificate_no: str
certificate_name: str
class_name: str | None
course_name: str | None
stage_name: str | None
issue_date: date
issuer_name: str
status: str
pdf_status: str
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}

View File

@@ -0,0 +1,18 @@
from datetime import datetime
from pydantic import BaseModel
class ImportBatchOut(BaseModel):
id: int
filename: str
status: str
total_rows: int
valid_rows: int
failed_rows: int
conflict_rows: int
error_report_path: str | None
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}

View File

@@ -0,0 +1,31 @@
from datetime import datetime
from pydantic import BaseModel, Field
class LearnerCreate(BaseModel):
phone: str = Field(min_length=6, max_length=32)
current_name: str = Field(min_length=1, max_length=64)
student_no: str | None = Field(default=None, max_length=64)
remark: str | None = None
class LearnerUpdate(BaseModel):
phone: str = Field(min_length=6, max_length=32)
current_name: str = Field(min_length=1, max_length=64)
student_no: str | None = Field(default=None, max_length=64)
status: str = Field(default="active", pattern="^(active|disabled|deleted)$")
remark: str | None = None
class LearnerOut(BaseModel):
id: int
phone: str
current_name: str
student_no: str | None
status: str
remark: str | None
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}

View File

@@ -0,0 +1,21 @@
from datetime import datetime
from pydantic import BaseModel
class OperationLogOut(BaseModel):
id: int
admin_user_id: int | None
action: str
action_label: str | None = None
object_type: str
object_label: str | None = None
object_id: str | None
ip_address: str | None
detail_json: str | None
created_at: datetime
risk: str | None = None
risk_label: str | None = None
summary: str | None = None
model_config = {"from_attributes": True}

View File

@@ -0,0 +1,57 @@
from datetime import datetime
from pydantic import BaseModel, Field, field_validator
class ProjectCourseCreate(BaseModel):
code: str = Field(min_length=2, max_length=16)
name: str = Field(min_length=1, max_length=128)
default_certificate_name: str | None = Field(default=None, max_length=128)
default_course_name: str | None = Field(default=None, max_length=128)
default_stage_name: str | None = Field(default=None, max_length=128)
default_issuer_name: str = Field(default="本公司", min_length=1, max_length=128)
@field_validator("code")
@classmethod
def normalize_code(cls, value: str) -> str:
return value.strip().upper()
@field_validator("default_certificate_name", "default_course_name", "default_stage_name")
@classmethod
def empty_to_none(cls, value: str | None) -> str | None:
if value is None:
return None
text = value.strip()
return text or None
class ProjectCourseUpdate(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=128)
default_certificate_name: str | None = Field(default=None, max_length=128)
default_course_name: str | None = Field(default=None, max_length=128)
default_stage_name: str | None = Field(default=None, max_length=128)
default_issuer_name: str | None = Field(default=None, min_length=1, max_length=128)
status: str | None = Field(default=None, pattern="^(active|disabled)$")
@field_validator("default_certificate_name", "default_course_name", "default_stage_name")
@classmethod
def empty_to_none(cls, value: str | None) -> str | None:
if value is None:
return None
text = value.strip()
return text or None
class ProjectCourseOut(BaseModel):
id: int
code: str
name: str
default_certificate_name: str
default_course_name: str | None
default_stage_name: str | None
default_issuer_name: str
status: str
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,54 @@
import base64
import io
import random
import secrets
import string
import time
from PIL import Image, ImageDraw, ImageFont
CAPTCHA_TTL_SECONDS = 300
_captchas: dict[str, tuple[str, float]] = {}
def make_captcha() -> dict[str, str]:
clean_expired_captchas()
captcha_id = secrets.token_urlsafe(16)
code = "".join(random.choice(string.ascii_uppercase + string.digits) for _ in range(4))
_captchas[captcha_id] = (code, time.time() + CAPTCHA_TTL_SECONDS)
return {"captcha_id": captcha_id, "image": draw_captcha_image(code)}
def verify_captcha(captcha_id: str, captcha_code: str) -> bool:
clean_expired_captchas()
record = _captchas.pop(captcha_id, None)
if not record:
return False
expected, expires_at = record
if expires_at < time.time():
return False
return expected.upper() == captcha_code.strip().upper()
def clean_expired_captchas() -> None:
now = time.time()
expired = [captcha_id for captcha_id, (_, expires_at) in _captchas.items() if expires_at < now]
for captcha_id in expired:
_captchas.pop(captcha_id, None)
def draw_captcha_image(code: str) -> str:
width, height = 132, 44
image = Image.new("RGB", (width, height), "#f8fafc")
draw = ImageDraw.Draw(image)
font = ImageFont.load_default()
for i, char in enumerate(code):
draw.text((18 + i * 26, 13 + random.randint(-2, 2)), char, fill="#1f2937", font=font)
for _ in range(8):
x1, y1 = random.randint(0, width), random.randint(0, height)
x2, y2 = random.randint(0, width), random.randint(0, height)
draw.line((x1, y1, x2, y2), fill="#cbd5e1", width=1)
buffer = io.BytesIO()
image.save(buffer, format="PNG")
return "data:image/png;base64," + base64.b64encode(buffer.getvalue()).decode("ascii")

View File

@@ -0,0 +1,30 @@
import hashlib
from datetime import date
from app.core.config import settings
ALPHABET = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ"
def _to_base32ish(value: int, length: int) -> str:
chars: list[str] = []
base = len(ALPHABET)
while value:
value, remainder = divmod(value, base)
chars.append(ALPHABET[remainder])
encoded = "".join(reversed(chars)) or ALPHABET[0]
return encoded[-length:].rjust(length, ALPHABET[0])
def _digest_int(payload: str) -> int:
digest = hashlib.sha256(payload.encode("utf-8")).digest()
return int.from_bytes(digest[:8], "big")
def build_certificate_no(sequence_id: int, project_code: str, issue_date: date | None = None) -> str:
year = (issue_date or date.today()).year
normalized_project = project_code.strip().upper()
seed = f"{settings.certificate_no_secret}:{year}:{normalized_project}:{sequence_id}"
short_code = _to_base32ish(_digest_int(seed), 7)
check_code = _to_base32ish(_digest_int(f"check:{seed}:{short_code}"), 2)
return f"PX{year}-{normalized_project}-{short_code}-{check_code}"

View File

@@ -0,0 +1,148 @@
import json
from datetime import datetime, timedelta
from sqlalchemy.orm import Session
from app.models import AdminUser, OperationLog
LOG_RETENTION_DAYS = 180
HIGH_RISK_LOG_RETENTION_DAYS = 365
ACTION_LABELS = {
"create_project": "\u65b0\u589e\u9879\u76ee",
"update_project": "\u4fee\u6539\u9879\u76ee",
"create_learner": "\u65b0\u589e\u5b66\u5458",
"update_learner": "\u4fee\u6539\u5b66\u5458",
"delete_learner": "\u5220\u9664\u5b66\u5458",
"create_certificate": "\u65b0\u589e\u8bc1\u4e66",
"void_certificate": "\u4f5c\u5e9f\u8bc1\u4e66",
"regenerate_public_token": "\u91cd\u7f6e\u8bc1\u4e66\u94fe\u63a5",
"upload_import_file": "\u4e0a\u4f20\u5bfc\u5165\u6587\u4ef6",
"confirm_import_batch": "\u786e\u8ba4\u5bfc\u5165",
"delete_import_batch": "\u5220\u9664\u5bfc\u5165\u6279\u6b21",
"export_certificates": "\u5bfc\u51fa\u8bc1\u4e66",
"public_search_certificate": "\u516c\u5f00\u67e5\u8be2\u8bc1\u4e66",
"public_download_certificate": "\u516c\u5f00\u4e0b\u8f7d\u8bc1\u4e66",
}
OBJECT_LABELS = {
"project": "\u9879\u76ee",
"project_course": "\u9879\u76ee",
"learner": "\u5b66\u5458",
"certificate": "\u8bc1\u4e66",
"import_batch": "\u5bfc\u5165\u6279\u6b21",
"public_access": "\u516c\u5f00\u8bbf\u95ee",
}
HIGH_RISK_ACTIONS = {"void_certificate", "delete_import_batch", "regenerate_public_token", "delete_learner"}
MEDIUM_RISK_ACTIONS = {"update_project", "update_learner", "confirm_import_batch", "create_certificate"}
def log_action(
db: Session,
admin: AdminUser | None,
action: str,
object_type: str,
object_id: int | str | None = None,
detail: dict[str, object] | None = None,
) -> None:
db.add(
OperationLog(
admin_user_id=admin.id if admin else None,
action=action,
object_type=object_type,
object_id=str(object_id) if object_id is not None else None,
detail_json=json.dumps(detail, ensure_ascii=False, default=str) if detail else None,
)
)
def mask_phone(phone: object) -> str:
raw = "".join(ch for ch in str(phone or "") if ch.isdigit())
if len(raw) >= 7:
return raw[:3] + "****" + raw[-4:]
return raw or "-"
def log_risk(action: str) -> str:
if action in HIGH_RISK_ACTIONS:
return "high"
if action in MEDIUM_RISK_ACTIONS:
return "medium"
return "low"
def log_risk_text(risk: str) -> str:
return {"high": "\u9ad8\u98ce\u9669", "medium": "\u4e2d\u98ce\u9669", "low": "\u4f4e\u98ce\u9669"}.get(risk, risk)
def diff_values(before: dict[str, object], after: dict[str, object], fields: list[str]) -> dict[str, dict[str, object]]:
changes: dict[str, dict[str, object]] = {}
for field in fields:
if before.get(field) != after.get(field):
changes[field] = {"before": before.get(field), "after": after.get(field)}
return changes
def log_summary(action: str, object_type: str, object_id: object, detail: dict[str, object]) -> str:
label = ACTION_LABELS.get(action, action)
if action in {"create_project", "update_project"}:
return f"{label}\uff1a{detail.get('name') or detail.get('code') or object_id}"
if action in {"create_learner", "update_learner", "delete_learner"}:
phone = detail.get("phone")
return f"{label}\uff1a{detail.get('name') or object_id}" + (f"\uff08{phone}\uff09" if phone else "")
if action in {"create_certificate", "void_certificate", "regenerate_public_token"}:
learner_name = detail.get("learner_name")
return f"{label}\uff1a{detail.get('certificate_no') or object_id}" + (f"\uff08{learner_name}\uff09" if learner_name else "")
if action in {"upload_import_file", "delete_import_batch"}:
return f"{label}\uff1a{detail.get('filename') or object_id}"
if action == "confirm_import_batch":
count = detail.get("imported_rows", detail.get("valid_rows", 0))
return f"{label}\uff1a\u6279\u6b21 {object_id}\uff0c\u5bfc\u5165 {count} \u884c"
if action == "public_search_certificate":
mode = "\u8bc1\u4e66\u7f16\u53f7" if detail.get("mode") == "certificate_no" else "\u624b\u673a\u53f7"
return f"{label}\uff1a{detail.get('name') or '-'} \u7528{mode}\u67e5\u8be2\uff0c\u5339\u914d {detail.get('matched_count', 0)} \u6761"
if action == "public_download_certificate":
return f"{label}\uff1a{detail.get('certificate_no') or object_id}\uff08{detail.get('learner_name') or '-'}\uff09"
return f"{label}\uff1a{OBJECT_LABELS.get(object_type, object_type)} {object_id or ''}".strip()
def format_log(log: OperationLog) -> dict[str, object]:
detail: dict[str, object] = {}
if log.detail_json:
try:
detail = json.loads(log.detail_json)
except Exception:
detail = {"raw": log.detail_json}
risk = log_risk(log.action)
return {
"id": log.id,
"admin_user_id": log.admin_user_id,
"action": log.action,
"action_label": ACTION_LABELS.get(log.action, log.action),
"object_type": log.object_type,
"object_label": OBJECT_LABELS.get(log.object_type, log.object_type),
"object_id": log.object_id,
"ip_address": log.ip_address,
"detail_json": log.detail_json,
"created_at": log.created_at,
"risk": risk,
"risk_label": log_risk_text(risk),
"summary": log_summary(log.action, log.object_type, log.object_id, detail),
}
def cleanup_expired_logs(db: Session) -> int:
normal_before = datetime.utcnow() - timedelta(days=LOG_RETENTION_DAYS)
high_before = datetime.utcnow() - timedelta(days=HIGH_RISK_LOG_RETENTION_DAYS)
normal_deleted = (
db.query(OperationLog)
.filter(OperationLog.created_at < normal_before)
.filter(OperationLog.action.notin_(HIGH_RISK_ACTIONS))
.delete(synchronize_session=False)
)
high_deleted = (
db.query(OperationLog)
.filter(OperationLog.created_at < high_before)
.filter(OperationLog.action.in_(HIGH_RISK_ACTIONS))
.delete(synchronize_session=False)
)
return int(normal_deleted or 0) + int(high_deleted or 0)

192
backend/app/services/pdf.py Normal file
View File

@@ -0,0 +1,192 @@
from datetime import date, datetime, timedelta
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont
from app.core.config import settings
from app.core.paths import data_path
from app.models import Certificate, CertificateAccessToken, Learner, ProjectCourse
def pdf_cache_path(certificate_no: str) -> Path:
safe_name = certificate_no.replace("/", "_")
year = safe_name[2:6] if len(safe_name) >= 6 else "misc"
folder = data_path("pdf-cache") / year
folder.mkdir(parents=True, exist_ok=True)
return folder / f"{safe_name}.pdf"
def cached_pdf_is_fresh(path: Path) -> bool:
if not path.exists():
return False
modified_at = datetime.fromtimestamp(path.stat().st_mtime)
return modified_at >= datetime.now() - timedelta(days=settings.pdf_cache_days)
def render_certificate_pdf(
certificate: Certificate,
learner: Learner,
project: ProjectCourse | None,
token: CertificateAccessToken,
) -> Path:
output_path = pdf_cache_path(certificate.certificate_no)
template_path = Path(__file__).resolve().parents[1] / "assets" / "certificate-template.jpg"
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:
return output_path
image = render_certificate_image(certificate, learner, project)
image.save(output_path, "PDF", resolution=150.0)
certificate.pdf_status = "generated"
certificate.pdf_file_path = str(output_path)
return output_path
def render_certificate_image(certificate: Certificate, learner: Learner, project: ProjectCourse | None) -> Image.Image:
template = Path(__file__).resolve().parents[1] / "assets" / "certificate-template.jpg"
image = Image.open(template).convert("RGB")
base_image = image.copy()
draw = ImageDraw.Draw(image)
issue_year, issue_month, issue_day = date_parts(certificate.issue_date)
course_name = certificate.course_name or certificate.certificate_name or (project.name if project else certificate.project_code)
stage_name = certificate.stage_name or "\u521d\u7ea7\u8bfe\u7a0b\u7684\u4e13\u4e1a\u5b66\u4e60\u3002"
for box in [(150, 442, 760, 132), (404, 675, 220, 38)]:
paper_patch(image, box)
draw.text((512, 414), learner.current_name, fill="#111111", font=font(32, "kai", True), anchor="mm")
x = 185.0
y = 494
x = draw_inline(draw, x, y, "\u5728", 24, 18)
x = draw_inline(draw, x, y, issue_year, 24, 8, "kai", True)
x = draw_inline(draw, x, y, "\u5e74", 24, 12)
x = draw_inline(draw, x, y, issue_month, 24, 6, "kai", True)
x = draw_inline(draw, x, y, "\u6708", 24, 12)
x = draw_inline(draw, x, y, issue_day, 24, 4, "kai", True)
x = draw_inline(draw, x, y, "\u65e5\u81f3", 24, 12)
x = draw_inline(draw, x, y, issue_year, 24, 8, "kai", True)
x = draw_inline(draw, x, y, "\u5e74", 24, 12)
x = draw_inline(draw, x, y, issue_month, 24, 6, "kai", True)
x = draw_inline(draw, x, y, "\u6708", 24, 12)
x = draw_inline(draw, x, y, issue_day, 24, 4, "kai", True)
x = draw_inline(draw, x, y, "\u65e5\u5b8c\u6210\u4e86", 24, 8)
draw_inline_flow(draw, [(f"\u201c{course_name}\u201d", "course", 0), (stage_name, "normal", 14)], x, y)
draw.text((512, 704), f"\u53d1\u8bc1\u65e5\u671f\uff1a{issue_year} \u5e74 {issue_month} \u6708 {issue_day} \u65e5", fill="#43382f", font=font(13, "song", True), anchor="mm")
draw.text((105, 76), f"\u8bc1\u4e66\u7f16\u53f7\uff1a{certificate.certificate_no}", fill="#111827", font=font(14, "hei", True))
image.paste(base_image.crop((720, 535, 950, 629)), (720, 535))
return image
def font(size: int, kind: str = "song", bold: bool = False) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
choices = {
"kai": [r"C:\Windows\Fonts\simkai.ttf", r"C:\Windows\Fonts\msyh.ttc", r"C:\Windows\Fonts\simsun.ttc"],
"hei": [r"C:\Windows\Fonts\simhei.ttf", r"C:\Windows\Fonts\msyhbd.ttc", r"C:\Windows\Fonts\msyh.ttc"],
"song": [r"C:\Windows\Fonts\msyh.ttc", r"C:\Windows\Fonts\simsun.ttc"],
}
paths = choices.get(kind, choices["song"])
if bold and kind == "song":
paths = [r"C:\Windows\Fonts\msyhbd.ttc", r"C:\Windows\Fonts\simhei.ttf"] + paths
for item in paths:
if Path(item).exists():
try:
return ImageFont.truetype(item, size)
except Exception:
pass
return ImageFont.load_default()
def date_parts(value: date | str | None) -> tuple[str, str, str]:
if isinstance(value, date):
return str(value.year), str(value.month), str(value.day)
raw = str(value or "").strip()
for fmt in ("%Y-%m-%d", "%Y/%m/%d"):
try:
d = datetime.strptime(raw[:10], fmt)
return str(d.year), str(d.month), str(d.day)
except ValueError:
pass
today = date.today()
return str(today.year), str(today.month), str(today.day)
def paper_patch(image: Image.Image, box: tuple[int, int, int, int]) -> None:
x, y, w, h = box
patch = Image.new("RGB", (w, h), "#fbf7ef")
source = image.crop((72, max(0, y), 160, max(0, y) + h))
for tx in range(0, w, source.width):
patch.paste(source.crop((0, 0, min(source.width, w - tx), h)), (tx, 0))
cover = Image.new("RGB", (w, h), "#fbf7ef")
image.paste(Image.blend(patch, cover, 0.38), (x, y))
def draw_inline(draw: ImageDraw.ImageDraw, x: float, y: int, value: str, size: int, gap: int, kind: str = "song", bold: bool = False) -> float:
text_font = font(size, kind, bold)
draw.text((x, y), value, fill="#111111", font=text_font, anchor="ls")
if bold:
draw.text((x + 0.45, y), value, fill="#111111", font=text_font, anchor="ls")
return x + draw.textlength(value, font=text_font) + gap
def draw_course_name(draw: ImageDraw.ImageDraw, value: str, x: float, y: int) -> int:
max_width = 900 - x
text_font = font(27, "kai", True)
if draw.textlength(value, font=text_font) <= max_width:
draw.text((x, y), value, fill="#111111", font=text_font)
draw.text((x + 0.45, y), value, fill="#111111", font=text_font)
return y
start_y = y + 34
lines = split_by_width(draw, value, text_font, 690)[:2]
for index, line in enumerate(lines):
line_y = start_y + index * 34
draw.text((185, line_y), line, fill="#111111", font=text_font)
draw.text((185.45, line_y), line, fill="#111111", font=text_font)
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:
x = start_x
y = start_y
line_start = 185
line_height = 42
for value, style, gap_before in segments:
x += gap_before
text_font = font(27, "kai", True) if style == "course" else font(24, "song", False)
for char in value:
max_right = 900 if y == start_y else 730
width = draw.textlength(char, font=text_font)
if x + width > max_right and x > line_start:
x = line_start
y += line_height
draw.text((x, y), char, fill="#111111", font=text_font, anchor="ls")
if style == "course":
draw.text((x + 0.45, y), char, fill="#111111", font=text_font, anchor="ls")
x += width
def split_by_width(draw: ImageDraw.ImageDraw, value: str, text_font: ImageFont.ImageFont, max_width: int) -> list[str]:
lines: list[str] = []
line = ""
for char in value:
next_line = line + char
if line and draw.textlength(next_line, font=text_font) > max_width:
lines.append(line)
line = char
else:
line = next_line
if line:
lines.append(line)
return lines
def draw_fit(draw: ImageDraw.ImageDraw, pos: tuple[int, int], value: str, text_font: ImageFont.ImageFont, fill: str, max_width: int) -> None:
font_obj = text_font
size = getattr(text_font, "size", 24)
while draw.textlength(value, font=font_obj) > max_width and size > 12:
size -= 1
font_obj = font(size, "song", True)
draw.text(pos, value, fill=fill, font=font_obj)

View File

@@ -0,0 +1,12 @@
import time
_hits: dict[str, list[float]] = {}
def hit_too_often(key: str, limit: int, window_seconds: int) -> bool:
now = time.time()
start = now - window_seconds
recent = [value for value in _hits.get(key, []) if value >= start]
recent.append(now)
_hits[key] = recent
return len(recent) > limit

View File

@@ -0,0 +1,36 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<title>Training Completion Certificate</title>
<style>
body { margin: 0; color: #1f2937; font-family: "Noto Sans CJK SC", "Microsoft YaHei", Arial, sans-serif; }
.page { position: relative; width: 1123px; height: 794px; box-sizing: border-box; padding: 72px; border: 18px solid #c99a3b; }
.title { margin-top: 18px; text-align: center; font-size: 52px; letter-spacing: 8px; font-weight: 700; }
.subtitle { margin-top: 14px; text-align: center; font-size: 21px; color: #6b7280; }
.content { margin-top: 72px; font-size: 28px; line-height: 2; }
.name { display: inline-block; min-width: 180px; padding: 0 28px; border-bottom: 2px solid #111827; text-align: center; font-size: 36px; font-weight: 700; }
.meta { margin-top: 54px; display: grid; grid-template-columns: 1fr 1fr; gap: 18px 32px; font-size: 20px; }
.qr { position: absolute; right: 72px; bottom: 104px; width: 112px; height: 112px; object-fit: contain; }
.disclaimer { position: absolute; left: 72px; right: 72px; bottom: 44px; font-size: 16px; line-height: 1.6; color: #6b7280; }
</style>
</head>
<body>
<div class="page">
<div class="title">培训结业证书</div>
<div class="subtitle">占位模板,正式 Logo、排版和公章后续替换</div>
<div class="content">
兹证明 <span class="name">{{ learner_name }}</span> 已完成 {{ project_name }} {{ course_name }} {{ stage_name }} 相关培训。
</div>
<div class="meta">
<div>证书编号:{{ certificate_no }}</div>
<div>发证日期:{{ issue_date }}</div>
<div>发证单位:{{ issuer_name }}</div>
</div>
{% if qr_code_data_url %}<img class="qr" src="{{ qr_code_data_url }}" alt="二维码" />{% endif %}
<div class="disclaimer">
本证书仅用于证明学员完成相关培训课程/阶段学习,不代表国家学历、学位、职业资格或职业技能等级认证。
</div>
</div>
</body>
</html>

11
backend/requirements.txt Normal file
View File

@@ -0,0 +1,11 @@
fastapi==0.115.6
uvicorn[standard]==0.34.0
sqlalchemy==2.0.36
alembic==1.14.0
pymysql==1.1.1
python-multipart==0.0.20
openpyxl==3.1.5
qrcode[pil]==8.0
playwright==1.49.1
jinja2==3.1.5
pytest==8.3.4

View File

@@ -0,0 +1,9 @@
from app.services.captcha import make_captcha, verify_captcha
def test_captcha_can_only_be_used_once():
captcha = make_captcha()
captcha_id = captcha["captcha_id"]
# The real code is intentionally not exposed. This test locks in one-time consumption behavior.
assert not verify_captcha(captcha_id, "bad")
assert not verify_captcha(captcha_id, "bad")

View File

@@ -0,0 +1,15 @@
import re
from datetime import date
from app.services.certificate_number import build_certificate_no
def test_certificate_number_shape():
number = build_certificate_no(123, "dby", date(2026, 6, 1))
assert re.fullmatch(r"PX2026-DBY-[23456789ABCDEFGHJKLMNPQRSTUVWXYZ]{7}-[23456789ABCDEFGHJKLMNPQRSTUVWXYZ]{2}", number)
def test_certificate_number_changes_with_sequence():
first = build_certificate_no(123, "DBY", date(2026, 6, 1))
second = build_certificate_no(124, "DBY", date(2026, 6, 1))
assert first != second

View File

@@ -0,0 +1,34 @@
from datetime import date
from app.api.routes.admin_imports import (
COL_CERT_NAME,
COL_ISSUE_DATE,
COL_NAME,
COL_PHONE,
COL_PROJECT,
COL_ISSUER,
date_is_valid,
parse_issue_date,
row_errors,
)
def test_parse_issue_date_accepts_common_formats():
assert parse_issue_date("2026-06-01") == date(2026, 6, 1)
assert parse_issue_date("2026/06/01") == date(2026, 6, 1)
def test_row_errors_require_project_code_to_exist():
row = {
COL_NAME: "张三",
COL_PHONE: "13800000000",
COL_PROJECT: "BAD",
COL_CERT_NAME: "结业证书",
COL_ISSUE_DATE: "2026-06-01",
COL_ISSUER: "测试公司",
}
assert "Project code is inactive or missing" in row_errors(row, {"DBY"})
def test_date_is_valid_rejects_bad_text():
assert not date_is_valid("not-a-date")

View File

@@ -0,0 +1,15 @@
from app.core.security import create_access_token, decode_access_token, hash_password, verify_password
def test_password_hash_roundtrip():
password_hash = hash_password("Secret123!")
assert verify_password("Secret123!", password_hash)
assert not verify_password("wrong", password_hash)
def test_access_token_roundtrip():
token = create_access_token("7", "system_admin", expires_in=60)
payload = decode_access_token(token)
assert payload is not None
assert payload["sub"] == "7"
assert payload["role"] == "system_admin"