Initial certificate system
This commit is contained in:
20
backend/Dockerfile
Normal file
20
backend/Dockerfile
Normal 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
38
backend/alembic.ini
Normal 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
45
backend/alembic/env.py
Normal 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()
|
||||
23
backend/alembic/script.py.mako
Normal file
23
backend/alembic/script.py.mako
Normal 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"}
|
||||
188
backend/alembic/versions/20260601_0001_initial.py
Normal file
188
backend/alembic/versions/20260601_0001_initial.py
Normal 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")
|
||||
34
backend/alembic/versions/20260602_0002_project_defaults.py
Normal file
34
backend/alembic/versions/20260602_0002_project_defaults.py
Normal 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
1
backend/app/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
1
backend/app/api/__init__.py
Normal file
1
backend/app/api/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
33
backend/app/api/deps.py
Normal file
33
backend/app/api/deps.py
Normal 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
26
backend/app/api/router.py
Normal 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"])
|
||||
1
backend/app/api/routes/__init__.py
Normal file
1
backend/app/api/routes/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
195
backend/app/api/routes/admin_certificates.py
Normal file
195
backend/app/api/routes/admin_certificates.py
Normal 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
|
||||
22
backend/app/api/routes/admin_dashboard.py
Normal file
22
backend/app/api/routes/admin_dashboard.py
Normal 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(),
|
||||
}
|
||||
84
backend/app/api/routes/admin_exports.py
Normal file
84
backend/app/api/routes/admin_exports.py
Normal 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:]}"
|
||||
362
backend/app/api/routes/admin_imports.py
Normal file
362
backend/app/api/routes/admin_imports.py
Normal 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
|
||||
126
backend/app/api/routes/admin_learners.py
Normal file
126
backend/app/api/routes/admin_learners.py
Normal 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}
|
||||
115
backend/app/api/routes/admin_logs.py
Normal file
115
backend/app/api/routes/admin_logs.py
Normal 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]
|
||||
82
backend/app/api/routes/admin_projects.py
Normal file
82
backend/app/api/routes/admin_projects.py
Normal 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
|
||||
21
backend/app/api/routes/auth.py
Normal file
21
backend/app/api/routes/auth.py
Normal 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,
|
||||
)
|
||||
8
backend/app/api/routes/health.py
Normal file
8
backend/app/api/routes/health.py
Normal file
@@ -0,0 +1,8 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
def health_check() -> dict[str, str]:
|
||||
return {"status": "ok"}
|
||||
138
backend/app/api/routes/public.py
Normal file
138
backend/app/api/routes/public.py
Normal 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
|
||||
BIN
backend/app/assets/certificate-template.jpg
Normal file
BIN
backend/app/assets/certificate-template.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 91 KiB |
30
backend/app/cli.py
Normal file
30
backend/app/cli.py
Normal 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()
|
||||
1
backend/app/core/__init__.py
Normal file
1
backend/app/core/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
33
backend/app/core/config.py
Normal file
33
backend/app/core/config.py
Normal 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
15
backend/app/core/paths.py
Normal 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
|
||||
71
backend/app/core/security.py
Normal file
71
backend/app/core/security.py
Normal 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)
|
||||
1
backend/app/db/__init__.py
Normal file
1
backend/app/db/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
5
backend/app/db/base.py
Normal file
5
backend/app/db/base.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
50
backend/app/db/init_db.py
Normal file
50
backend/app/db/init_db.py
Normal 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
17
backend/app/db/session.py
Normal 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
21
backend/app/main.py
Normal 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()
|
||||
19
backend/app/models/__init__.py
Normal file
19
backend/app/models/__init__.py
Normal 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",
|
||||
]
|
||||
27
backend/app/models/admin.py
Normal file
27
backend/app/models/admin.py
Normal 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)
|
||||
59
backend/app/models/certificate.py
Normal file
59
backend/app/models/certificate.py
Normal 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)
|
||||
35
backend/app/models/import_batch.py
Normal file
35
backend/app/models/import_batch.py
Normal 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)
|
||||
44
backend/app/models/learner.py
Normal file
44
backend/app/models/learner.py
Normal 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
19
backend/app/models/log.py
Normal 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)
|
||||
1
backend/app/schemas/__init__.py
Normal file
1
backend/app/schemas/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
13
backend/app/schemas/auth.py
Normal file
13
backend/app/schemas/auth.py
Normal 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
|
||||
39
backend/app/schemas/certificate.py
Normal file
39
backend/app/schemas/certificate.py
Normal 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}
|
||||
18
backend/app/schemas/import_batch.py
Normal file
18
backend/app/schemas/import_batch.py
Normal 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}
|
||||
31
backend/app/schemas/learner.py
Normal file
31
backend/app/schemas/learner.py
Normal 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}
|
||||
21
backend/app/schemas/log.py
Normal file
21
backend/app/schemas/log.py
Normal 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}
|
||||
57
backend/app/schemas/project.py
Normal file
57
backend/app/schemas/project.py
Normal 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}
|
||||
1
backend/app/services/__init__.py
Normal file
1
backend/app/services/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
54
backend/app/services/captcha.py
Normal file
54
backend/app/services/captcha.py
Normal 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")
|
||||
30
backend/app/services/certificate_number.py
Normal file
30
backend/app/services/certificate_number.py
Normal 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}"
|
||||
148
backend/app/services/logs.py
Normal file
148
backend/app/services/logs.py
Normal 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
192
backend/app/services/pdf.py
Normal 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)
|
||||
12
backend/app/services/rate_limit.py
Normal file
12
backend/app/services/rate_limit.py
Normal 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
|
||||
36
backend/app/templates/certificate_placeholder.html
Normal file
36
backend/app/templates/certificate_placeholder.html
Normal 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
11
backend/requirements.txt
Normal 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
|
||||
9
backend/tests/test_captcha.py
Normal file
9
backend/tests/test_captcha.py
Normal 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")
|
||||
15
backend/tests/test_certificate_number.py
Normal file
15
backend/tests/test_certificate_number.py
Normal 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
|
||||
34
backend/tests/test_import_rules.py
Normal file
34
backend/tests/test_import_rules.py
Normal 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")
|
||||
15
backend/tests/test_security.py
Normal file
15
backend/tests/test_security.py
Normal 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"
|
||||
Reference in New Issue
Block a user