Initial certificate system

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

7
.env.example Normal file
View File

@@ -0,0 +1,7 @@
APP_ENV=development
DATABASE_URL=mysql+pymysql://certificate:certificate@mysql:3306/certificate_system
SECRET_KEY=change-me
PUBLIC_BASE_URL=http://localhost:8080
CERTIFICATE_NO_SECRET=change-me
PDF_CACHE_DAYS=30
DATA_ROOT=/data/certificate-system

10
.gitignore vendored Normal file
View File

@@ -0,0 +1,10 @@
.env
__pycache__/
*.pyc
.venv/
node_modules/
dist/
.DS_Store
dev.db
*.log
local_app/data/

67
README.md Normal file
View File

@@ -0,0 +1,67 @@
# 培训结业证书管理与查询系统
一期目标:快速上线一个可导入、可管理、可查询、可核验、可下载 PDF 证书的闭环系统。
## 本地验收启动
请优先使用这个文件:
```bat
start-local.bat
```
也可以在命令行运行:
```bat
cd /d C:\Users\nelso\Documents\projects\certificate-system
start-local.bat
```
启动后保持窗口不要关闭,然后打开:
```text
http://127.0.0.1:8000/admin/login
```
默认账号:
```text
admin / Admin123!
```
公开查询页:
```text
http://127.0.0.1:8000/query
```
如果 `.ps1` 被记事本打开,不用管它,改用 `start-local.bat`。如果想清空本地测试数据,运行:
```bat
reset-local-data.bat
```
当前本地验收版已支持:
- 学员手动新增、查询、修改、删除。
- 项目新增、搜索、修改、启用、停用。
- 证书手动新增、后台预览、后台下载、作废、重置链接。
- Excel 模板下载、导入、确认导入、导出证书。
- 操作日志中文显示。
- 公开查询、证书直达页、PDF 下载。
## 正式工程
- 后端Python 3.12 + FastAPI + SQLAlchemy + Alembic
- 前端Vue 3 + TypeScript + Vite + Element Plus
- 数据库MySQL 8
- 部署Docker Compose + Nginx
本地验收版在 `local_app/`,用于快速验证业务闭环;正式部署仍使用 `backend/``frontend/``docker-compose.yml`
## 文档
- [开发决策记录](docs/decision-log.md)
- [MVP 范围说明](docs/mvp-scope.md)
- [部署与使用手册](docs/usage-deployment-manual.md)
- [上线检查清单](docs/deployment-checklist.md)

20
backend/Dockerfile Normal file
View File

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

38
backend/alembic.ini Normal file
View File

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

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

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

View File

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

View File

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

View File

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

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

@@ -0,0 +1 @@

View File

@@ -0,0 +1 @@

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

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

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

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

View File

@@ -0,0 +1 @@

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

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

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

View File

@@ -0,0 +1 @@

View File

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

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

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

View File

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

View File

@@ -0,0 +1 @@

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

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

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

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

@@ -0,0 +1 @@

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1 @@

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

11
backend/requirements.txt Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,25 @@
# 结业证书模板调试模块
这个目录是证书模板的独立验证模块,暂时不接主系统数据库。
## 启动
在项目根目录执行:
```powershell
$py = "C:\Users\nelso\.cache\codex-runtimes\codex-primary-runtime\dependencies\python\python.exe"
& $py -m http.server 8020 -d .\certificate-template-lab
```
然后访问:
```text
http://127.0.0.1:8020
```
## 当前用途
-`assets/original.jpg` 作为证书底图。
- 擦除可变文字区域并重新绘制学员、课程、日期、单位、证书编号等信息。
- 支持下载 PNG用于先确认视觉效果。
- 后续确认版式后,再把这套渲染参数迁移到主系统的 PDF/图片生成流程。

View File

@@ -0,0 +1,465 @@
(function () {
const canvas = document.getElementById("certificateCanvas");
const ctx = canvas.getContext("2d");
const state = document.getElementById("renderState");
const qrScratch = document.getElementById("qrScratch");
const W = canvas.width;
const H = canvas.height;
const text = {
loading: "\u52a0\u8f7d\u4e2d",
rendering: "\u6e32\u67d3\u4e2d",
ready: "\u5df2\u66f4\u65b0",
imageFailed: "\u539f\u56fe\u52a0\u8f7d\u5931\u8d25",
title: "\u7ed3\u4e1a\u8bc1\u4e66",
proves: "\u7279\u6b64\u8bc1\u660e",
inWord: "\u5728",
year: "\u5e74",
month: "\u6708",
day: "\u65e5",
toWord: "\u81f3",
finished: "\u65e5\u5b8c\u6210\u4e86",
issueDate: "\u53d1\u8bc1\u65e5\u671f\uff1a",
certNo: "\u8bc1\u4e66\u7f16\u53f7\uff1a",
fallbackStudent: "\u5b66\u5458",
filePrefix: "\u7ed3\u4e1a\u8bc1\u4e66"
};
const defaults = {
studentName: "\u5f20\u4e09",
courseName: "\u667a\u6167\u8d4b\u80fd\u7597\u6108\u5e08",
stageName: "\u521d\u7ea7\u8bfe\u7a0b\u7684\u4e13\u4e1a\u5b66\u4e60\u3002",
certificateNo: "HY20260602DYC001",
startYear: "2026",
startMonth: "6",
startDay: "1",
endYear: "2026",
endMonth: "6",
endDay: "2",
issueDate: "2026 \u5e74 6 \u6708 2 \u65e5",
mentorLabel: "\u5bfc\u5e08\uff1a",
mentorName: "\u5362\u6167",
issuerLeft: "\u6df1\u5733\u5e02\u6167\u6108\u6587\u5316\u79d1\u6280\u6709\u9650\u516c\u53f8",
issuerRight: "\u56fd\u9645\u751f\u547d\u667a\u6167\u542f\u8499\u7814\u7a76\u9662",
verifyUrl: "https://example.com/cert/HY20260602DYC001"
};
const controls = {};
Object.keys(defaults).forEach((key) => {
controls[key] = document.getElementById(key);
});
controls.showOriginal = document.getElementById("showOriginal");
controls.showDebug = document.getElementById("showDebug");
const original = new Image();
original.src = "./assets/original.jpg";
original.onload = render;
original.onerror = () => {
state.textContent = text.imageFailed;
render();
};
let renderTimer = null;
let qrText = "";
bindEvents();
render();
function bindEvents() {
Object.values(controls).forEach((input) => {
if (!input) return;
input.addEventListener("input", scheduleRender);
input.addEventListener("change", scheduleRender);
});
document.getElementById("resetBtn").addEventListener("click", () => {
Object.entries(defaults).forEach(([key, value]) => {
controls[key].value = value;
});
controls.showOriginal.checked = true;
controls.showDebug.checked = false;
render();
});
document.getElementById("downloadBtn").addEventListener("click", () => {
render();
const link = document.createElement("a");
const name = safeFileName(value("studentName") || text.fallbackStudent);
link.download = `${text.filePrefix}_${name}.png`;
link.href = canvas.toDataURL("image/png", 1);
link.click();
});
}
function scheduleRender() {
window.clearTimeout(renderTimer);
renderTimer = window.setTimeout(render, 80);
}
function value(key) {
return (controls[key] && controls[key].value || "").trim();
}
function render() {
try {
state.textContent = text.rendering;
ctx.clearRect(0, 0, W, H);
const usingOriginal = controls.showOriginal.checked && original.complete && original.naturalWidth;
if (usingOriginal) {
ctx.drawImage(original, 0, 0, W, H);
eraseEditableAreas();
} else {
drawFallbackBase();
}
drawStudentName();
drawMainText();
drawIssueDate();
drawCertificateNo();
if (usingOriginal) {
drawOriginalMentorLayer();
} else {
drawMentor();
drawFooter();
drawQr();
}
if (controls.showDebug.checked) {
drawDebugAreas();
}
state.textContent = text.ready;
} catch (err) {
console.error("Certificate render failed:", err);
state.textContent = "渲染异常";
}
}
const areas = [
{ x: 150, y: 442, w: 760, h: 132, pad: 16 },
{ x: 404, y: 675, w: 220, h: 38, pad: 10 }
];
function eraseEditableAreas() {
areas.forEach((area) => eraseWithPaper(area));
}
function eraseWithPaper(area) {
if (!original.complete || !original.naturalWidth) return;
const pad = area.pad || 8;
const sourceX = 72;
const sourceW = 88;
const patch = document.createElement("canvas");
patch.width = area.w + pad * 2;
patch.height = area.h + pad * 2;
const p = patch.getContext("2d");
for (let x = 0; x < patch.width; x += sourceW) {
const tileW = Math.min(sourceW, patch.width - x);
p.drawImage(
original,
sourceX,
Math.max(0, area.y - pad),
tileW,
patch.height,
x,
0,
tileW,
patch.height
);
}
p.globalCompositeOperation = "destination-in";
p.globalCompositeOperation = "source-over";
p.globalAlpha = 0.38;
p.fillStyle = "#fbf7ef";
p.fillRect(0, 0, patch.width, patch.height);
p.globalAlpha = 1;
p.globalCompositeOperation = "destination-in";
const g = p.createLinearGradient(0, 0, 0, patch.height);
g.addColorStop(0, "rgba(0,0,0,0)");
g.addColorStop(0.04, "rgba(0,0,0,1)");
g.addColorStop(0.96, "rgba(0,0,0,1)");
g.addColorStop(1, "rgba(0,0,0,0)");
p.fillStyle = g;
p.fillRect(0, 0, patch.width, patch.height);
p.globalCompositeOperation = "destination-in";
const gx = p.createLinearGradient(0, 0, patch.width, 0);
gx.addColorStop(0, "rgba(0,0,0,0)");
gx.addColorStop(0.035, "rgba(0,0,0,1)");
gx.addColorStop(0.965, "rgba(0,0,0,1)");
gx.addColorStop(1, "rgba(0,0,0,0)");
p.fillStyle = gx;
p.fillRect(0, 0, patch.width, patch.height);
ctx.drawImage(patch, area.x - pad, area.y - pad);
}
function drawFallbackBase() {
ctx.fillStyle = "#fbf7ef";
ctx.fillRect(0, 0, W, H);
ctx.strokeStyle = "#25344f";
ctx.lineWidth = 12;
ctx.strokeRect(6, 6, W - 12, H - 12);
ctx.strokeStyle = "#a98732";
ctx.lineWidth = 2;
ctx.strokeRect(42, 38, W - 84, H - 76);
ctx.textAlign = "center";
ctx.fillStyle = "#111";
ctx.font = '700 52px "SimSun", serif';
ctx.fillText(text.title, W / 2, 196);
}
function drawSubTitle() {
ctx.textAlign = "center";
ctx.fillStyle = "#49341f";
ctx.font = '700 17px "SimSun", "Microsoft YaHei", serif';
ctx.fillText(text.proves, W / 2, 329);
ctx.fillStyle = "#555";
ctx.font = '13px Georgia, serif';
ctx.fillText("This Certifies That", W / 2, 351);
}
function drawStudentName() {
const name = value("studentName");
if (!name) return;
ctx.textAlign = "center";
ctx.fillStyle = "#111";
ctx.font = '700 32px "KaiTi", "STKaiti", "SimSun", serif';
ctx.fillText(name, W / 2, 414);
}
function drawMainText() {
const line2 = value("stageName");
ctx.fillStyle = "#111";
ctx.textAlign = "left";
let x = 185;
const y = 494;
x = drawInlineText(text.inWord, x, y, 24, 400, 18);
x = drawInlineText(value("startYear"), x, y, 24, 900, 8, "dateNumber");
x = drawInlineText(text.year, x, y, 24, 400, 12);
x = drawInlineText(value("startMonth"), x, y, 24, 900, 6, "dateNumber");
x = drawInlineText(text.month, x, y, 24, 400, 12);
x = drawInlineText(value("startDay"), x, y, 24, 900, 4, "dateNumber");
x = drawInlineText(`${text.day}${text.toWord}`, x, y, 24, 400, 12);
x = drawInlineText(value("endYear"), x, y, 24, 900, 8, "dateNumber");
x = drawInlineText(text.year, x, y, 24, 400, 12);
x = drawInlineText(value("endMonth"), x, y, 24, 900, 6, "dateNumber");
x = drawInlineText(text.month, x, y, 24, 400, 12);
x = drawInlineText(value("endDay"), x, y, 24, 900, 4, "dateNumber");
x = drawInlineText(`${text.day}\u5b8c\u6210\u4e86`, x, y, 24, 400, 8);
drawInlineFlow([
{ text: `\u201c${value("courseName")}\u201d`, style: "course" },
{ text: line2, style: "normal", gapBefore: 14 },
], x, y);
}
function drawInlineFlow(segments, startX, startY) {
let x = startX;
let y = startY;
const lineStart = 185;
const lineHeight = 42;
segments.forEach((segment) => {
if (segment.gapBefore) x += segment.gapBefore;
Array.from(segment.text || "").forEach((char) => {
const style = inlineStyle(segment.style);
ctx.font = style.font;
const maxRight = y === startY ? 900 : 730;
const width = ctx.measureText(char).width;
if (x + width > maxRight && x > lineStart) {
x = lineStart;
y += lineHeight;
}
ctx.fillText(char, x, y);
if (segment.style === "course") ctx.fillText(char, x + 0.45, y);
x += width;
});
});
}
function inlineStyle(style) {
if (style === "course") {
return { font: '900 27px "KaiTi", "STKaiti", "Kaiti SC", "Noto Serif SC", serif' };
}
return { font: '400 24px "SimSun", "Noto Serif SC", serif' };
}
function drawInlineText(raw, x, y, size, weight, gap, style) {
const textValue = raw || "";
const useKaiti = style === true || style === "dateNumber";
const family = useKaiti
? '"KaiTi", "STKaiti", "Kaiti SC", "Noto Serif SC", serif'
: '"SimSun", "Noto Serif SC", serif';
ctx.font = `${weight} ${size}px ${family}`;
ctx.fillText(textValue, x, y);
if (style === true || style === "dateNumber") {
ctx.fillText(textValue, x + 0.45, y);
}
return x + ctx.measureText(textValue).width + gap;
}
function drawMentor() {
const label = value("mentorLabel");
const name = value("mentorName");
if (!label && !name) return;
ctx.fillStyle = "#111";
ctx.textAlign = "left";
ctx.font = '700 21px "SimSun", "Microsoft YaHei", serif';
ctx.fillText(label, 740, 586);
ctx.save();
ctx.translate(812, 594);
ctx.rotate(-0.05);
ctx.fillStyle = "#1b1b1b";
ctx.font = '700 34px "KaiTi", "STKaiti", "SimSun", serif';
ctx.fillText(name, 0, 0);
ctx.restore();
}
function drawOriginalMentorLayer() {
if (!original.complete || !original.naturalWidth) return;
ctx.drawImage(original, 720, 535, 230, 94, 720, 535, 230, 94);
}
function drawFooter() {
ctx.fillStyle = "#111";
ctx.font = '700 20px "SimSun", "Microsoft YaHei", serif';
ctx.textAlign = "left";
drawTextFit(value("issuerLeft"), 190, 643, 300, 20);
ctx.textAlign = "right";
drawTextFit(value("issuerRight"), 835, 643, 320, 20);
ctx.textAlign = "center";
drawIssueDate();
}
function drawIssueDate() {
ctx.textAlign = "center";
ctx.fillStyle = "#43382f";
ctx.font = '700 13px "SimSun", serif';
ctx.fillText(`${text.issueDate}${value("issueDate")}`, W / 2, 704);
}
function drawCertificateNo() {
const no = value("certificateNo");
if (!no) return;
ctx.textAlign = "left";
ctx.fillStyle = "#111827";
ctx.font = '700 14px Arial, "Microsoft YaHei", sans-serif';
ctx.fillText(`${text.certNo}${no}`, 105, 76);
}
function drawQr() {
const x = W / 2 - 33;
const y = 602;
const box = 66;
const qrNode = getQrNode(box);
ctx.save();
ctx.fillStyle = "#fff";
ctx.fillRect(x - 5, y - 5, box + 10, box + 10);
ctx.strokeStyle = "#d3c6a4";
ctx.strokeRect(x - 5, y - 5, box + 10, box + 10);
if (qrNode) {
try {
ctx.drawImage(qrNode, x, y, box, box);
} catch (err) {
console.warn("QR image is not ready yet, using fallback.", err);
drawQrFallbackPixels(x, y);
window.setTimeout(render, 120);
}
} else {
drawQrFallbackPixels(x, y);
}
ctx.fillStyle = "#1f67ad";
ctx.fillRect(x + 28, y + 28, 10, 10);
ctx.restore();
}
function getQrNode(size) {
if (!window.QRCode || !qrScratch) return null;
const nextText = value("verifyUrl") || "https://example.com";
if (nextText !== qrText || !qrScratch.firstChild) {
qrText = nextText;
qrScratch.innerHTML = "";
new QRCode(qrScratch, {
text: nextText,
width: size,
height: size,
colorDark: "#111111",
colorLight: "#ffffff",
correctLevel: QRCode.CorrectLevel.H
});
}
const canvasNode = qrScratch.querySelector("canvas");
if (canvasNode) return canvasNode;
const imgNode = qrScratch.querySelector("img");
if (imgNode && imgNode.complete && imgNode.naturalWidth) return imgNode;
return null;
}
function drawQrFallbackPixels(x, y) {
ctx.fillStyle = "#111";
const seed = hash(value("verifyUrl"));
const cell = 3;
for (let row = 0; row < 22; row++) {
for (let col = 0; col < 22; col++) {
const fixed = finder(col, row) || finder(col - 15, row) || finder(col, row - 15);
const on = fixed || ((seed + row * 17 + col * 31 + row * col) % 7 < 3);
if (on) ctx.fillRect(x + col * cell, y + row * cell, cell, cell);
}
}
}
function finder(col, row) {
if (col < 0 || row < 0 || col > 6 || row > 6) return false;
return col === 0 || row === 0 || col === 6 || row === 6 || (col >= 2 && col <= 4 && row >= 2 && row <= 4);
}
function hash(raw) {
let n = 0;
for (let i = 0; i < raw.length; i++) {
n = (n * 31 + raw.charCodeAt(i)) >>> 0;
}
return n;
}
function drawTextFit(raw, x, y, maxWidth, size) {
let fontSize = size;
const textValue = raw || "";
do {
ctx.font = ctx.font.replace(/\d+px/, `${fontSize}px`);
fontSize--;
} while (ctx.measureText(textValue).width > maxWidth && fontSize > 12);
ctx.fillText(textValue, x, y);
}
function drawDebugAreas() {
ctx.save();
ctx.strokeStyle = "#f04438";
ctx.lineWidth = 2;
ctx.setLineDash([6, 5]);
areas.forEach((area) => ctx.strokeRect(area.x, area.y, area.w, area.h));
ctx.restore();
}
function safeFileName(raw) {
return raw.replace(/[\\/:*?"<>|]/g, "_");
}
})();

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

View File

@@ -0,0 +1,117 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>&#32467;&#19994;&#35777;&#20070;&#27169;&#26495;&#35843;&#35797;</title>
<link rel="stylesheet" href="./style.css">
</head>
<body>
<main class="page">
<aside class="panel">
<div class="panel-head">
<p class="eyebrow">Certificate Lab</p>
<h1>&#32467;&#19994;&#35777;&#20070;&#27169;&#26495;&#35843;&#35797;</h1>
<p class="hint">&#36825;&#37324;&#20808;&#21333;&#29420;&#35843;&#35777;&#20070;&#35270;&#35273;&#65292;&#19981;&#25509;&#20027;&#31995;&#32479;&#25968;&#25454;&#12290;&#25913;&#23383;&#27573;&#21518;&#21491;&#20391;&#20250;&#23454;&#26102;&#37325;&#32472;&#12290;</p>
</div>
<section class="group">
<h2>&#23398;&#21592;&#19982;&#35838;&#31243;</h2>
<label>&#23398;&#21592;&#22995;&#21517;
<input id="studentName" value="&#24352;&#19977;">
</label>
<label>&#35838;&#31243;&#21517;&#31216;
<input id="courseName" value="&#26234;&#24935;&#36171;&#33021;&#30103;&#24840;&#24072;">
</label>
<label>&#35838;&#31243;&#38454;&#27573;
<input id="stageName" value="&#21021;&#32423;&#35838;&#31243;&#30340;&#19987;&#19994;&#23398;&#20064;&#12290;">
</label>
<label>&#35777;&#20070;&#32534;&#21495;
<input id="certificateNo" value="HY20260602DYC001">
</label>
</section>
<section class="group">
<h2>&#26085;&#26399;</h2>
<div class="grid">
<label>&#24320;&#22987;&#24180;
<input id="startYear" value="2026">
</label>
<label>&#24320;&#22987;&#26376;
<input id="startMonth" value="6">
</label>
<label>&#24320;&#22987;&#26085;
<input id="startDay" value="1">
</label>
<label>&#32467;&#26463;&#24180;
<input id="endYear" value="2026">
</label>
<label>&#32467;&#26463;&#26376;
<input id="endMonth" value="6">
</label>
<label>&#32467;&#26463;&#26085;
<input id="endDay" value="2">
</label>
</div>
<label>&#21457;&#35777;&#26085;&#26399;
<input id="issueDate" value="2026 &#24180; 6 &#26376; 2 &#26085;">
</label>
</section>
<section class="group muted-group">
<h2>&#24213;&#37096;&#22270;&#23618;</h2>
<p class="group-note">&#23548;&#24072;&#12289;&#26426;&#26500;&#21644;&#20108;&#32500;&#30721;&#20808;&#20351;&#29992;&#21407;&#22270;&#12290;&#23548;&#24072;&#20250;&#20316;&#20026;&#26368;&#19978;&#23618;&#22270;&#23618;&#35206;&#30422;&#12290;</p>
<label>&#23548;&#24072;&#31216;&#35859;
<input id="mentorLabel" value="&#23548;&#24072;&#65306;" disabled>
</label>
<label>&#23548;&#24072;&#22995;&#21517;
<input id="mentorName" value="&#21346;&#24935;" disabled>
</label>
<label>&#24038;&#20391;&#21333;&#20301;
<input id="issuerLeft" value="&#28145;&#22323;&#24066;&#24935;&#24840;&#25991;&#21270;&#31185;&#25216;&#26377;&#38480;&#20844;&#21496;" disabled>
</label>
<label>&#21491;&#20391;&#21333;&#20301;
<input id="issuerRight" value="&#22269;&#38469;&#29983;&#21629;&#26234;&#24935;&#21551;&#33945;&#30740;&#31350;&#38498;" disabled>
</label>
<label>&#26597;&#35810;&#38142;&#25509;
<input id="verifyUrl" value="https://example.com/cert/HY20260602DYC001" disabled>
</label>
</section>
<section class="group">
<h2>&#26174;&#31034;&#24320;&#20851;</h2>
<label class="check">
<input type="checkbox" id="showOriginal" checked>
&#20351;&#29992;&#21407;&#22270;&#20316;&#20026;&#24213;&#22270;
</label>
<label class="check">
<input type="checkbox" id="showDebug">
&#26174;&#31034;&#25830;&#38500;&#21306;&#22495;
</label>
</section>
<div class="actions">
<button id="resetBtn" type="button">&#24674;&#22797;&#31034;&#20363;</button>
<button id="downloadBtn" type="button">&#19979;&#36733; PNG</button>
</div>
</aside>
<section class="preview">
<div class="preview-head">
<div>
<p class="eyebrow">1024 x 759</p>
<h2>&#23454;&#26102;&#39044;&#35272;</h2>
</div>
<span id="renderState">&#21152;&#36733;&#20013;</span>
</div>
<div class="canvas-shell">
<canvas id="certificateCanvas" width="1024" height="759"></canvas>
</div>
</section>
</main>
<div id="qrScratch" aria-hidden="true"></div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js"></script>
<script src="./app.js"></script>
</body>
</html>

View File

@@ -0,0 +1,246 @@
:root {
color-scheme: light;
--bg: #f5f8fb;
--panel: #ffffff;
--line: #d9e2ec;
--text: #172033;
--muted: #657184;
--blue: #2f6fbd;
--blue-dark: #215493;
--gold: #a98732;
--shadow: 0 18px 45px rgba(30, 52, 82, .12);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
font-family: "Microsoft YaHei", "PingFang SC", Arial, sans-serif;
color: var(--text);
background:
linear-gradient(120deg, rgba(47, 111, 189, .08), transparent 38%),
var(--bg);
}
.page {
display: grid;
grid-template-columns: 360px minmax(0, 1fr);
gap: 22px;
min-height: 100vh;
padding: 22px;
}
.panel,
.preview {
background: rgba(255, 255, 255, .92);
border: 1px solid rgba(217, 226, 236, .9);
box-shadow: var(--shadow);
}
.panel {
display: flex;
flex-direction: column;
overflow: hidden;
border-radius: 8px;
}
.panel-head {
padding: 22px 22px 16px;
border-bottom: 1px solid var(--line);
}
.eyebrow {
margin: 0 0 6px;
color: var(--gold);
font-size: 12px;
font-weight: 700;
letter-spacing: .08em;
text-transform: uppercase;
}
h1,
h2 {
margin: 0;
letter-spacing: 0;
}
h1 {
font-size: 22px;
}
h2 {
font-size: 15px;
}
.hint {
margin: 8px 0 0;
color: var(--muted);
font-size: 13px;
line-height: 1.6;
}
.group {
padding: 16px 22px;
border-bottom: 1px solid var(--line);
}
.group h2 {
margin-bottom: 12px;
}
.group-note {
margin: -4px 0 12px;
color: var(--muted);
font-size: 12px;
line-height: 1.6;
}
.muted-group input:disabled {
color: #8b96a8;
background: #f2f5f8;
cursor: not-allowed;
}
label {
display: grid;
gap: 6px;
margin-bottom: 12px;
color: #3a4658;
font-size: 13px;
font-weight: 600;
}
input {
width: 100%;
border: 1px solid #cdd8e6;
border-radius: 6px;
padding: 10px 11px;
color: var(--text);
font: inherit;
font-size: 14px;
outline: none;
background: #fff;
}
input:focus {
border-color: var(--blue);
box-shadow: 0 0 0 3px rgba(47, 111, 189, .14);
}
.grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10px;
}
.check {
grid-template-columns: auto 1fr;
align-items: center;
gap: 9px;
margin-bottom: 8px;
font-weight: 500;
}
.check input {
width: 16px;
height: 16px;
margin: 0;
}
.actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
padding: 16px 22px 22px;
margin-top: auto;
}
button {
border: 0;
border-radius: 6px;
padding: 11px 12px;
cursor: pointer;
font: inherit;
font-size: 14px;
font-weight: 700;
}
#resetBtn {
color: var(--blue-dark);
background: #e8f1fc;
}
#downloadBtn {
color: #fff;
background: var(--blue);
}
.preview {
display: flex;
flex-direction: column;
min-width: 0;
border-radius: 8px;
overflow: hidden;
}
.preview-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 18px 22px;
border-bottom: 1px solid var(--line);
}
#renderState {
color: var(--blue-dark);
background: #e8f1fc;
border-radius: 999px;
padding: 5px 10px;
font-size: 12px;
font-weight: 700;
}
.canvas-shell {
display: grid;
place-items: center;
flex: 1;
padding: 28px;
overflow: auto;
background:
linear-gradient(45deg, #eef3f8 25%, transparent 25%),
linear-gradient(-45deg, #eef3f8 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #eef3f8 75%),
linear-gradient(-45deg, transparent 75%, #eef3f8 75%);
background-size: 24px 24px;
background-position: 0 0, 0 12px, 12px -12px, -12px 0;
}
canvas {
display: block;
width: min(100%, 1024px);
height: auto;
background: #faf7ef;
box-shadow: 0 18px 40px rgba(28, 42, 62, .18);
}
#qrScratch {
position: fixed;
left: -9999px;
top: -9999px;
width: 96px;
height: 96px;
overflow: hidden;
}
@media (max-width: 1000px) {
.page {
grid-template-columns: 1fr;
}
.panel {
max-height: none;
}
}

43
deploy/nginx.prod.conf Normal file
View File

@@ -0,0 +1,43 @@
limit_req_zone $binary_remote_addr zone=public_query:10m rate=20r/m;
limit_req_zone $binary_remote_addr zone=pdf_download:10m rate=30r/m;
server {
listen 80;
server_name example.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name example.com;
ssl_certificate /etc/nginx/certs/fullchain.pem;
ssl_certificate_key /etc/nginx/certs/privkey.pem;
client_max_body_size 20m;
root /usr/share/nginx/html;
index index.html;
location /api/public/certificates/search {
limit_req zone=public_query burst=10 nodelay;
proxy_pass http://backend:8000/public/certificates/search;
include /etc/nginx/proxy_params;
}
location ~ ^/api/public/certificates/token/.+/download$ {
limit_req zone=pdf_download burst=10 nodelay;
proxy_pass http://backend:8000$request_uri;
rewrite ^/api/(.*)$ /$1 break;
include /etc/nginx/proxy_params;
}
location /api/ {
proxy_pass http://backend:8000/;
include /etc/nginx/proxy_params;
}
location / {
try_files $uri $uri/ /index.html;
}
}

7
deploy/proxy_params Normal file
View File

@@ -0,0 +1,7 @@
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 30s;
proxy_send_timeout 120s;
proxy_read_timeout 120s;

42
docker-compose.yml Normal file
View File

@@ -0,0 +1,42 @@
services:
mysql:
image: mysql:8.4
environment:
MYSQL_ROOT_PASSWORD: root-password
MYSQL_DATABASE: certificate_system
MYSQL_USER: certificate
MYSQL_PASSWORD: certificate
volumes:
- mysql-data:/var/lib/mysql
ports:
- "3306:3306"
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
interval: 10s
timeout: 5s
retries: 5
backend:
build:
context: ./backend
env_file:
- .env
volumes:
- certificate-data:/data/certificate-system
depends_on:
mysql:
condition: service_healthy
ports:
- "8000:8000"
frontend:
build:
context: ./frontend
depends_on:
- backend
ports:
- "8080:80"
volumes:
mysql-data:
certificate-data:

102
docs/decision-log.md Normal file
View File

@@ -0,0 +1,102 @@
# 开发决策记录
本文档记录开发过程中由 Codex 先行拍板的实现决定。后续如果业务方有不同意见,可以按条目调整。
## 已确认决策
### D001 PDF 生成策略
- 决定PDF 由服务端生成,不在用户浏览器生成。
- 策略:按需生成 + 30 天缓存。
- 原因:证书属于正式凭证,服务端生成更利于统一版式、审计、作废状态控制和后续模板替换。
### D002 证书编号格式
- 决定:`EPX{YYYY}-{项目代码}-{7位短码}-{2位校验码}`
- 示例:`EPX2026-DBY-K7M9Q2R-83`
- 说明:内部数据库继续使用 `BIGINT` 主键,对外编号使用短码和校验码,降低枚举和手输错误风险。
### D003 公开查询方式
- 决定:一期公开查询支持两种方式:证书编号 + 姓名 + 图形验证码,或手机号 + 姓名 + 图形验证码。
- 说明:手机号 + 姓名可能匹配多张证书,查询结果按列表展示。
- 风控:继续保留图形验证码和基础频率限制,降低批量撞库风险。
### D004 作废证书 PDF
- 决定:一期作废证书不允许下载正常 PDF。
- 展示:查询页、直达页、二维码核验页统一显示作废状态。
### D005 证书模板
- 决定:一期先使用占位模板。
- 要求:模板文件与业务逻辑分离,后续替换 Logo、公章、排版、正式文案时不改核心业务代码。
### D006 更正和补发
- 决定:一期不实现复杂更正/补发流程。
- 一期保留证书编辑、作废、重置链接等主链路操作。
## Codex 先行决定
### C001 项目代码维护方式
- 决定:后台提供项目/课程代码配置,导入 Excel 使用项目代码匹配,不允许随意生成编号中的项目代码。
- 原因:避免同一项目出现多个拼写,确保证书编号稳定。
### C002 一期部署方式
- 决定:单机 Docker Compose 部署。
- 组件Nginx、前端静态资源、FastAPI 后端、MySQL。
- 原因:符合低并发和快速上线目标,减少运维复杂度。
### C003 文件目录
- 决定:使用 `/data/certificate-system/uploads``error-reports``exports``pdf-cache`
- 原因便于备份策略区分PDF 缓存不进入长期备份。
### C004 初始化管理员
- 决定:通过初始化命令创建第一个系统管理员。
- 原因:避免在公开页面暴露注册入口。
### C005 访问 token 一期存储
- 决定:一期在 `certificate_access_tokens` 中同时保存 token 哈希和 token 原值。
- 原因:后台导出必须能生成证书对外直达链接;纯哈希无法反推出链接。
- 风险:数据库泄露时 token 原值可被直接使用。
- 后续优化:将 token 原值改为应用层加密存储,或仅在生成时写入导出文件并提供链接重置机制。
### C006 验证码和限流
- 决定:一期使用后端进程内存保存图形验证码和简单 IP 频率计数。
- 原因:一期单机部署,不引入 Redis满足快速上线和低并发目标。
- 后续优化:访问量上来后迁移到 Redis并把限流前移到 Nginx 或网关层。
### C007 后台 UI 风格
- 决定:后台采用浅色、清爽、偏办公工具的视觉风格。
- 原因:这个系统是日常管理工具,用户需要快速查找和处理数据,不适合厚重的深色或营销化页面。
### C008 后台 PDF 下载方式
- 决定:后台证书 PDF 下载使用带 Authorization token 的 blob 请求。
- 原因:受保护的后台接口不能依赖普通链接跳转,否则浏览器不会带前端保存的登录 token容易导致下载失败。
### C009 Excel 导入字段
- 决定:导入模板只保留姓名、手机号、项目代码、发证日期。
- 原因:证书名称、课程名称、阶段名称、发证单位属于项目配置,不应该在每一行 Excel 里重复填写。
- 备注:导入备注字段一期移除;批量导入后的备注缺少明确展示和业务使用场景。
### C010 导入批次文件管理
- 决定:后台保留导入批次列表,并支持下载原始 Excel、下载错误报告、删除批次记录。
- 删除范围:只删除上传文件、错误报告和批次记录,不删除已经确认导入后生成的学员和证书。
- 原因:导入文件会占用服务器存储,但导入后的业务数据应作为正式数据继续保留。
### C011 Certificate Rendering Template
- Decision: keep `certificate-template-lab` as a standalone visual tuning page.
- Decision: production PDF rendering now uses the approved certificate image as the base layer, then renders learner/course/date/certificate number dynamically with Pillow.
- Current limitation: the data model only has `issue_date`; until separate training start/end dates are added, the rendered course date range uses `issue_date` for both start and end.
- Current limitation: mentor, issuer names, and QR area remain from the approved base image for visual consistency.

View File

@@ -0,0 +1,56 @@
# 上线检查清单
## 服务器
- Docker 已安装。
- Docker Compose 已安装。
- 域名已解析到服务器。
- HTTPS 证书已准备。
- `/data/certificate-system` 已创建并挂载到数据盘。
## 环境变量
- `SECRET_KEY` 已改成强随机值。
- `CERTIFICATE_NO_SECRET` 已改成强随机值。
- `PUBLIC_BASE_URL` 已改成正式域名。
- `DATABASE_URL` 指向生产 MySQL。
- `DATA_ROOT` 指向生产数据目录。
## 数据库
- 已执行迁移:
```bash
docker compose exec backend alembic upgrade head
```
- 已初始化管理员:
```bash
docker compose exec backend python -m app.cli init-db --admin-username admin --admin-password "强密码"
```
## 业务配置
- 项目代码已维护,例如 `DBY``QSX`
- 发证单位名称已确认。
- Excel 导入模板已下载并试填。
- 占位证书模板已确认可临时使用。
## 验收动作
- 后台可登录。
- 可新增学员。
- 可新增证书并生成编号。
- 可上传 Excel 并确认导入。
- 可导出证书和直达链接。
- 公开查询必须输入验证码。
- 作废证书不能下载正常 PDF。
- 直达链接重置后旧链接失效。
- PDF 可生成,二维码可打开核验页。
## 备份
- MySQL 每日备份已配置。
- `uploads``error-reports``exports` 已纳入备份策略。
- `pdf-cache` 不纳入长期备份。

49
docs/mvp-scope.md Normal file
View File

@@ -0,0 +1,49 @@
# MVP 范围说明
## 一期必须完成
1. 后台账号登录和基础角色权限。
2. 项目/课程代码配置。
3. 学员管理,手机号作为主标识,支持曾用名和手机号变更记录。
4. 证书管理,支持一个学员多张证书。
5. Excel 批量导入,导入前校验,导入后生成证书编号和公开访问 token。
6. 证书编号系统自动生成,格式为 `PX{YYYY}-{项目代码}-{7位短码}-{2位校验码}`
7. 批量导出证书数据,包含证书编号和对外直达链接。
8. 公开查询:证书编号 + 姓名 + 图形验证码。
9. 证书直达页:通过不可枚举 token 查看单张证书。
10. 二维码核验页:展示真实性、状态和必要证书信息。
11. PDF 下载:服务端按需生成,占位模板,缓存 30 天。
12. 操作日志和导出日志。
## 一期不做
1. 手机号公开查询。
2. 复杂更正/补发流程。
3. 证书审批流。
4. 可视化证书模板编辑器。
5. 正式 Logo、公章、电子签章。
6. Redis、Celery、RabbitMQ、Kubernetes、多实例。
7. 对接报名系统、学习系统或 CRM。
## 默认业务规则
- 证书作废后,公开页面展示作废状态,不允许下载正常 PDF。
- 公开页面不展示完整手机号、证件号、后台备注、导入记录。
- 证书直达链接使用随机 token不由证书编号推导。
- 导出完整手机号或证件号需要高权限,并记录日志。
## Excel 导入模板字段
一期模板字段:
1. 姓名
2. 手机号
3. 项目代码
4. 证书名称
5. 课程名称
6. 阶段名称
7. 发证日期
8. 发证单位
9. 备注
证书编号不在 Excel 中填写,由系统确认导入后自动生成。

View File

@@ -0,0 +1,146 @@
# 部署与使用手册
本文档用于一期本地验收和正式部署交接。
## 本地验收版
适合在没有 Docker、npm 环境时先完整跑通业务流程。
```bat
cd /d C:\Users\nelso\Documents\projects\certificate-system
start-local.bat
```
访问地址:
```text
后台http://127.0.0.1:8000/admin/login
公开查询http://127.0.0.1:8000/query
```
默认账号:
```text
admin / Admin123!
```
本地数据保存在:
```text
local_app/data/local.db
```
清空本地验收数据:
```bat
reset-local-data.bat
```
## 后台使用流程
1. 登录 `/admin/login`
2. 进入“项目管理”,维护 `DBY``QSX` 等项目代码。项目支持搜索、修改、启用和停用。
3. 进入“学员管理”,手动新增、查询、修改或删除学员。
4. 进入“证书管理”手动新增证书支持单张预览、PDF 下载、作废、重置直达链接和导出。
5. 进入“Excel 导入”,下载模板、上传 Excel、查看校验结果确认后正式导入。模板只需要填写姓名、手机号、项目代码、发证日期证书名称、课程名称、阶段名称、发证单位从项目配置自动带出。
“确认导入”表示把已校验通过的临时数据正式写入学员和证书表。导入记录支持下载原文件、下载错误报告和删除;删除导入记录只删除上传文件、错误报告和批次记录,不删除已经生成的学员和证书。
6. 进入“操作日志”,查看关键后台操作。日志动作已转成中文显示。
## 公开查询流程
1. 用户进入 `/query`
2. 输入证书编号 + 姓名 + 图形验证码,或手机号 + 姓名 + 图形验证码。
3. 查询成功后展示证书状态和证书信息;手机号查询可能返回多张证书。
4. 有效证书可下载 PDF。
5. 作废证书只展示作废状态,不提供正常 PDF 下载。
## PDF 下载说明
PDF 由服务端按需生成,并使用 30 天缓存。这样可以保证模板、二维码、作废状态和审计逻辑统一。
后台 PDF 下载不能用简单的 `window.location` 直接跳转,因为后台接口需要登录态。如果直接打开下载地址,浏览器不会自动带上前端保存的 Authorization token容易出现下载失败。当前后台已改为通过带 token 的请求下载 blob 文件,再在浏览器里保存。
## 正式部署准备
- Linux 服务器,建议 2 核 4G 起步。
- 域名和 HTTPS 证书。
- Docker 和 Docker Compose。
- MySQL 8可以使用 Compose 内置 MySQL 或云数据库。
默认数据目录:
```text
/data/certificate-system
```
建议子目录:
```text
uploads
error-reports
exports
pdf-cache
```
`pdf-cache` 是 30 天缓存,不需要作为长期档案备份。
## 环境变量
复制 `.env.example``.env`,至少修改:
```text
APP_ENV=production
DATABASE_URL=mysql+pymysql://certificate:password@mysql:3306/certificate_system
SECRET_KEY=change-me
PUBLIC_BASE_URL=https://your-domain.com
CERTIFICATE_NO_SECRET=change-me
PDF_CACHE_DAYS=30
DATA_ROOT=/data/certificate-system
```
## Docker 部署
```bash
docker compose up -d --build
```
首次初始化:
```bash
docker compose exec backend python -m app.cli init-db --admin-username admin --admin-password "请替换为强密码"
```
单独执行数据库迁移:
```bash
docker compose exec backend alembic upgrade head
```
后端测试:
```bash
docker compose exec backend pytest
```
## 当前已实现
- 后台登录。
- 后台概览统计。
- 项目新增、搜索、修改、启用、停用。
- 学员手动新增、查询、修改、删除。
- 证书新增、查询、后台预览、后台下载、作废、重置直达链接。
- Excel 模板下载、上传校验、错误报告下载、确认导入。导入模板字段为姓名、手机号、项目代码、发证日期。
- 导入批次原文件下载和导入批次删除。
- 导入后自动生成证书编号、直达链接 token、二维码 token。
- 证书导出,包含证书编号和对外直达链接。
- 公开查询:证书编号 + 姓名 + 图形验证码,或手机号 + 姓名 + 图形验证码。
- 证书直达页和二维码核验页。
- PDF 服务端按需生成,缓存 30 天。
- 关键操作日志写入和中文展示。
## 后续上线前确认
- 生产域名、HTTPS 和 Nginx 限流参数。
- PDF 正式模板、Logo、公章和证书文案。
- 生产管理员账号和强密码。
- 服务器部署后的联调和验收数据导入演练。

View File

@@ -0,0 +1,94 @@
# 产品视觉设计说明
## 设计目标
本系统是一套证书管理和公开查询产品,视觉上应当清爽、可信、便于长期办公使用。后台页面优先保证信息密度、扫描效率和操作稳定感;公开查询页优先保证用户能快速完成查询、预览和下载。
## 设计方向
- 不做营销式首页。
- 不做重装饰背景。
- 不使用沉重深色大面积背景。
- 不使用老气的灰褐色或暗沉配色。
- 使用浅色背景、白色工作区、克制青绿色主色。
- 通过边框、间距、字体粗细和轻微阴影建立层级。
## 颜色
主色:
```text
#16817e
```
用于:
- 主按钮。
- 激活导航。
- 有效状态。
- 链接。
背景:
```text
#f7fafb -> #eef4f6
```
用于页面底色,保持清爽但不纯白刺眼。
边框:
```text
#dbe6ea
#c8d7dd
```
用于卡片、输入框、表格边界。
文字:
```text
#172033 主文字
#64748b 次级文字
```
风险色:
```text
#c9413a 高风险/危险操作
#b7791f 中风险/警告
```
## 后台布局
- 左侧导航固定 248px。
- 工作区使用 28px 页面内边距。
- 页面标题区采用左标题、右主操作按钮布局。
- 表单区域和列表区域分成独立白色面板。
- 卡片圆角控制在 10px 左右,不使用过大的圆角。
- 分页右对齐,符合后台数据列表习惯。
## 公开查询页
- 查询卡片是第一屏核心。
- 不放多余介绍文字。
- 查询方式使用分段选择。
- 查询结果使用证书卡片展示。
- 结果卡保留证书编号、姓名、项目、课程/阶段、发证日期、发证单位。
- 预览和下载按钮放在卡片右下方。
## 表格和控件
- 表头使用浅灰蓝背景。
- 表格 hover 只做轻微背景变化。
- 输入框和下拉框高度统一。
- 下拉框箭头不得贴边。
- 主要按钮使用青绿色,危险按钮使用红色。
- 状态标签使用圆角胶囊,但尺寸克制。
## 自行决策记录
- 选择青绿色作为主色,因为它比蓝色更接近当前证书产品的清爽和可信气质,也不会显得像通用后台模板。
- 没有加入大面积插画、渐变装饰或复杂动效,避免影响后台扫描效率。
- 正式版新增全局 `design.css`,统一 Element Plus 控件和通用布局。
- 本地验证版同步更新内联 CSS保证本地验收看到的视觉方向与正式版一致。

View File

@@ -0,0 +1,59 @@
# 操作日志分页需求
## 背景
操作日志会持续增长,如果一次性展示全部日志,页面会变长,查找和点击都不方便。日志列表需要分页展示,并且页码数量较多时不能把所有页码全部铺开。
## 功能目标
1. 操作日志列表每页显示 20 条。
2. 日志列表上方和下方都显示分页控件。
3. 支持上一页、下一页和指定页码跳转。
4. 页码超过 10 页时,远离当前页的页码用省略号折叠。
5. 分页需要和现有筛选条件联动:
- 操作类型。
- 对象类型。
- 风险等级。
- 关键词。
6. 切换筛选条件后,从第 1 页重新查询。
7. 导出日志不受当前页限制,仍按当前筛选条件导出全部匹配日志。
## 页码展示规则
分页控件始终显示:
- 第 1 页。
- 最后一页。
- 当前页前后 2 页。
- 中间缺失页码用 `...` 表示。
示例:
- 当前第 1 页,共 3 页:`1 2 3`
- 当前第 6 页,共 20 页:`1 ... 4 5 6 7 8 ... 20`
- 当前第 19 页,共 20 页:`1 ... 17 18 19 20`
## 接口约定
日志列表接口支持:
- `page`:页码,从 1 开始,默认 1。
- `page_size`:每页数量,默认 20。
返回:
```json
{
"items": [],
"total": 0,
"page": 1,
"page_size": 20,
"total_pages": 1
}
```
## 自行决策记录
- 默认每页 20 条,兼顾浏览效率和页面密度。
- 页码折叠规则采用“首页 + 尾页 + 当前页前后 2 页”,避免页码过多时挤压布局。
- 上下各放一组分页控件,避免用户滚动到底部后还要回到顶部翻页。

View File

@@ -0,0 +1,100 @@
# 操作日志强化需求
## 背景
当前后台已经具备基础操作日志能力,可以记录部分后台动作并在页面查看。随着证书、学员、项目、导入文件等管理动作增多,日志需要更方便排查问题、追踪责任和留档审计。
## 一期目标
快速增强后台操作日志的可用性,不做复杂审计系统,但要满足日常运营排查和关键动作追踪。
## 功能需求
1. 日志列表支持筛选和搜索:
- 按操作类型筛选。
- 按对象类型筛选。
- 按风险等级筛选。
- 按关键词搜索对象 ID、摘要、详情内容。
2. 日志显示更易读:
- 操作名显示中文。
- 对象类型显示中文。
- 增加操作摘要例如“作废证书PX2026-xxx”“删除导入文件test.xlsx”。
- 高风险动作增加醒目标记。
3. 日志详情弹窗:
- 展示操作时间、操作人、操作、对象、对象 ID、风险等级。
- 展示摘要。
- 展示完整详情 JSON便于排查。
4. 关键动作记录更多上下文:
- 新增/修改/停用项目记录项目代码、名称、状态变化。
- 新增/修改/删除学员记录姓名、脱敏手机号、状态变化。
- 新增/作废/重置链接证书记录证书编号和学员信息。
- 上传/确认/删除导入批次记录文件名、导入统计。
5. 日志导出:
- 支持导出 Excel。
- 导出内容包含时间、操作人、操作、对象、对象 ID、风险等级、摘要、详情。
## 风险等级规则
高风险:
- 作废证书。
- 删除导入批次/导入原文件。
- 重置证书公开链接。
- 停用项目。
- 删除学员。
中风险:
- 修改项目。
- 修改学员。
- 确认导入。
- 新增证书。
低风险:
- 新增项目。
- 新增学员。
- 上传导入文件。
- 导出数据。
## 暂不做
- 日志删除功能。
- 日志归档策略。
- 复杂审计报表。
- 登录失败风控和异常 IP 告警。
## 日志保留与清理策略
一期采用自动清理过期日志,不开放任意删除单条日志,避免审计证据被误删。
- 普通后台操作日志保留 180 天。
- 学员公开查询、公开下载日志保留 180 天。
- 高风险后台操作日志保留 365 天,包括作废证书、删除导入批次、重置证书公开链接、删除学员。
- 系统启动和进入日志管理时可以触发过期清理。
- 后台日志页提供“清理过期日志”按钮,只清理超过保留周期的日志,不删除未过期日志。
## 公开访问日志
需要记录学员/外部用户主动查询和下载电子证书的行为,用于排查“用户是否查过/下载过”的问题。
- 公开查询证书:
- 记录查询方式:证书编号或手机号。
- 记录姓名。
- 手机号脱敏。
- 记录匹配数量。
- 记录失败查询,但不记录验证码。
- 公开下载证书:
- 记录证书编号。
- 记录学员姓名。
- 记录项目代码。
- 不记录公开 token 原文。
## 自行决策记录
- 一期优先增强后台日常可用性,不引入新的日志表结构。
- 风险等级先由操作类型在服务端/前端映射计算,避免为当前版本增加迁移复杂度。
- 敏感信息在摘要里脱敏,完整详情尽量不写入明文手机号或 token。
- 公开查询/下载先进入同一张操作日志表,操作人 ID 为空,用“公开访问”对象类型区分。

View File

@@ -0,0 +1,792 @@
# 阿里云正式部署详细操作手册
本文档用于把“培训结业证书管理与查询系统”部署到阿里云正式环境。当前日期2026-06-05。
## 1. 先回答:数据库要不要单独买
可以有两种方案。
### 方案 A数据库和应用都放在同一台 ECS 上
做法:一台阿里云 ECS 上运行 Docker Compose里面同时跑
- MySQL
- 后端 FastAPI
- 前端 Nginx/Vue
优点:
- 成本低。
- 部署简单。
- 一期快速上线足够用。
缺点:
- ECS 故障时,应用和数据库一起受影响。
- 数据库备份、恢复、监控需要自己认真配置。
- 后期迁移到 RDS 时需要再做一次数据库迁移。
适合:
- 一期快速上线。
- 访问量不大。
- 预算有限。
- 可以接受自己维护数据库备份。
### 方案 BECS 跑应用,阿里云 RDS MySQL 跑数据库
做法:
- ECS 只跑前端和后端。
- MySQL 使用阿里云 RDS MySQL。
优点:
- 数据库更稳定。
- 阿里云 RDS 提供备份、恢复、监控、容灾等能力。
- 后期维护成本低。
- 更接近正式生产系统。
缺点:
- 成本更高。
- 需要配置 RDS 白名单、账号、数据库连接串。
适合:
- 正式长期使用。
- 证书数据比较重要。
- 不希望自己维护 MySQL 备份和恢复。
### 我的建议
如果你现在要尽快上线,一期可以先用方案 A一台 ECS + Docker Compose 自带 MySQL。
如果这个系统会长期对外使用,证书数据又比较重要,建议正式上线直接用方案 BECS + RDS MySQL。证书系统的数据价值主要在学员、证书、导入记录、操作日志这些数据丢了很麻烦所以 RDS 更稳。
阿里云官方文档里RDS 是托管型关系型数据库服务,支持 MySQL并提供备份、恢复、监控、容灾等能力。ECS 上也可以安装 Docker 和 Docker Compose 来统一管理多服务部署。
参考:
- 阿里云 RDS MySQL 文档https://help.aliyun.com/zh/rds/apsaradb-rds-for-mysql/product-overview/
- 阿里云 ECS 安装 Docker 文档https://help.aliyun.com/zh/ecs/use-cases/install-and-use-docker/
- Docker Compose 官方文档https://docs.docker.com/compose/
## 2. 正式版和本地版的区别
### 本地验证版
路径:
```text
local_app/
```
启动方式:
```bat
start-local.bat
```
访问地址:
```text
http://127.0.0.1:8000/admin/login
```
用途:
- 本地快速验收。
- 不依赖 MySQL、Node、Docker。
- 用 SQLite 存测试数据。
- 不建议正式部署使用。
### 正式部署版
路径:
```text
backend/
frontend/
docker-compose.yml
deploy/
```
技术:
- 后端FastAPI
- 前端Vue 3 + Element Plus
- 数据库MySQL 8
- 部署Docker Compose
正式部署时不要运行:
```text
local_app/server.py
```
正式部署要运行:
```bash
docker compose up -d --build
```
## 3. 阿里云购买建议
### ECS 建议
一期建议:
- 地域:选择主要用户所在地区,例如华东、华南。
- 系统Ubuntu 22.04 LTS 或 Alibaba Cloud Linux 3。
- 配置2 核 4G 起步。
- 磁盘:系统盘 40G 起步,建议额外挂一块数据盘 50G 或以上。
- 带宽:按量或固定 3M 起步,后面看访问量调整。
如果要把 MySQL 也放 ECS 上,建议:
- 2 核 4G 是最低可用。
- 更稳一点用 2 核 8G。
- 数据盘独立挂载到 `/data`
### RDS 建议
如果买 RDS MySQL
- 数据库版本MySQL 8.0。
- 网络:和 ECS 同地域、同 VPC。
- 规格:小规格起步即可,后期扩容。
- 存储20G 起步。
- 备份:开启自动备份。
- 白名单:只允许 ECS 内网 IP 访问,不要开放公网访问。
## 4. 域名和备案
如果要通过正式域名访问,例如:
```text
https://cert.example.com
```
你需要:
1. 购买域名。
2. 如果服务器在中国大陆,通常需要完成 ICP 备案。
3. 在域名 DNS 里添加解析:
```text
类型A
主机记录cert
记录值:你的 ECS 公网 IP
```
如果暂时没有域名,也可以先用:
```text
http://ECS公网IP:8080
```
但正式对外建议使用域名和 HTTPS。
## 5. ECS 初始化
下面以 Ubuntu 22.04 为例。
### 5.1 登录服务器
在本地终端执行:
```bash
ssh root@你的ECS公网IP
```
### 5.2 更新系统
```bash
apt update
apt upgrade -y
```
### 5.3 安装常用工具
```bash
apt install -y git curl vim unzip ca-certificates gnupg lsb-release
```
### 5.4 安装 Docker
可以按阿里云 ECS Docker 文档安装。Ubuntu 常用命令如下:
```bash
apt install -y docker.io docker-compose-plugin
systemctl enable docker
systemctl start docker
docker --version
docker compose version
```
如果 `docker compose version` 能显示版本号,说明 Compose 可用。
### 5.5 创建数据目录
```bash
mkdir -p /data/certificate-system
mkdir -p /data/certificate-system/uploads
mkdir -p /data/certificate-system/error-reports
mkdir -p /data/certificate-system/exports
mkdir -p /data/certificate-system/pdf-cache
```
## 6. 上传项目代码
你可以用 Git也可以用压缩包。
### 方式 AGit 拉代码
```bash
cd /opt
git clone 你的代码仓库地址 certificate-system
cd /opt/certificate-system
```
### 方式 B本地打包上传
在本地把项目压缩后上传到服务器:
```bash
scp certificate-system.zip root@你的ECS公网IP:/opt/
```
服务器上解压:
```bash
cd /opt
unzip certificate-system.zip
cd /opt/certificate-system
```
## 7. 配置环境变量
在服务器项目目录下:
```bash
cd /opt/certificate-system
cp .env.example .env
vim .env
```
### 7.1 自建 MySQL 方案的 `.env`
如果用 Docker Compose 里的 MySQL
```env
APP_ENV=production
DATABASE_URL=mysql+pymysql://certificate:请改成强密码@mysql:3306/certificate_system
SECRET_KEY=请改成一串很长的随机字符串
PUBLIC_BASE_URL=https://你的域名
CERTIFICATE_NO_SECRET=请改成另一串很长的随机字符串
PDF_CACHE_DAYS=30
DATA_ROOT=/data/certificate-system
CORS_ORIGINS=https://你的域名
```
同时要修改 `docker-compose.yml` 里的 MySQL 密码,保证和 `DATABASE_URL` 一致:
```yaml
MYSQL_ROOT_PASSWORD: 请改成root强密码
MYSQL_DATABASE: certificate_system
MYSQL_USER: certificate
MYSQL_PASSWORD: 请改成强密码
```
### 7.2 RDS MySQL 方案的 `.env`
如果用阿里云 RDS
```env
APP_ENV=production
DATABASE_URL=mysql+pymysql://RDS账号:RDS密码@RDS内网地址:3306/certificate_system
SECRET_KEY=请改成一串很长的随机字符串
PUBLIC_BASE_URL=https://你的域名
CERTIFICATE_NO_SECRET=请改成另一串很长的随机字符串
PDF_CACHE_DAYS=30
DATA_ROOT=/data/certificate-system
CORS_ORIGINS=https://你的域名
```
RDS 注意事项:
1. ECS 和 RDS 要在同一个 VPC。
2. RDS 白名单里加入 ECS 的内网 IP。
3. 不建议开放 RDS 公网访问。
4. RDS 里提前创建数据库:
```text
certificate_system
```
如果使用 RDS建议把 `docker-compose.yml` 里的 `mysql` 服务去掉,或者保留但不启动。最简单的做法是先保留 compose 文件,但后端 `DATABASE_URL` 指向 RDS实际业务不会用 compose 里的 MySQL。
## 8. 首次启动
在项目目录执行:
```bash
docker compose up -d --build
```
查看容器状态:
```bash
docker compose ps
```
查看后端日志:
```bash
docker compose logs -f backend
```
查看前端日志:
```bash
docker compose logs -f frontend
```
## 9. 初始化数据库
首次部署需要执行:
```bash
docker compose exec backend python -m app.cli init-db --admin-username admin --admin-password "请换成强密码"
```
这个命令会:
- 执行数据库迁移。
- 创建基础角色。
- 创建默认管理员。
- 初始化默认项目配置。
如果后续只是升级代码,一般执行:
```bash
docker compose exec backend alembic upgrade head
```
## 10. 访问系统
如果还没有配置域名,可以访问:
```text
http://ECS公网IP:8080/admin/login
```
如果已经配置域名和 HTTPS
```text
https://你的域名/admin/login
```
公开查询页:
```text
https://你的域名/query
```
证书直达链接类似:
```text
https://你的域名/cert/xxxx
```
二维码核验链接类似:
```text
https://你的域名/verify/xxxx
```
## 11. 安全组设置
在阿里云 ECS 安全组里建议开放:
```text
22 SSH仅允许你的办公 IP 更安全
80 HTTP
443 HTTPS
8080 如果临时不用 Nginx/HTTPS可以开放正式建议关闭
```
不建议公网开放:
```text
3306 MySQL
8000 后端接口
```
正式情况下,后端只让前端 Nginx 或内网访问。
## 12. HTTPS 和 Nginx
### 12.1 临时上线
可以先用前端容器暴露的 8080
```text
http://ECS公网IP:8080
```
但这不适合长期正式使用。
### 12.2 正式上线
推荐:
- ECS 上安装 Nginx。
- Nginx 监听 80/443。
- 443 配 HTTPS 证书。
- Nginx 反向代理到前端容器 8080。
项目里有参考配置:
```text
deploy/nginx.prod.conf
```
需要修改:
```nginx
server_name example.com;
ssl_certificate /etc/nginx/certs/fullchain.pem;
ssl_certificate_key /etc/nginx/certs/privkey.pem;
```
改成你的域名和证书路径。
Nginx 重新加载:
```bash
nginx -t
systemctl reload nginx
```
## 13. 正式上线前检查
### 13.1 后台登录
打开:
```text
https://你的域名/admin/login
```
确认:
- 可以登录。
- 默认密码已经改成强密码。
### 13.2 项目管理
确认:
- 能新增项目。
- 能修改项目。
- 能停用/启用项目。
- 能搜索项目。
### 13.3 学员管理
确认:
- 能新增学员。
- 能修改学员。
- 能删除学员。
- 手机号查询正常。
### 13.4 证书管理
确认:
- 能新增证书。
- 证书编号自动生成。
- 证书名称使用项目名称。
- 能预览证书。
- 能下载 PDF。
- 证书日期、课程标题、导师、二维码显示正常。
- 能作废证书。
- 作废证书不能下载正常 PDF。
### 13.5 Excel 导入
确认模板字段只有:
```text
姓名
手机号
项目代码
发证日期
```
确认:
- 能上传 Excel。
- 能下载原文件。
- 能删除导入批次。
- 能确认导入。
- 确认导入后状态变成已导入。
- 不能重复确认导入。
### 13.6 公开查询
确认:
- 证书编号 + 姓名 + 验证码可以查。
- 手机号 + 姓名 + 验证码可以查。
- 查询结果可以预览和下载。
- 公开查询和公开下载会进入操作日志。
### 13.7 操作日志
确认:
- 日志中文显示。
- 风险等级显示。
- 支持筛选。
- 支持分页,每页 20 条。
- 页码右对齐。
- 上方和下方都有分页。
- 页码多时有省略号。
- 支持导出日志。
- 支持清理过期日志。
## 14. 数据备份
### 14.1 如果使用 RDS
建议:
- 开启自动备份。
- 开启日志备份。
- 定期测试恢复。
- RDS 白名单只允许 ECS 内网 IP。
### 14.2 如果使用 ECS 自建 MySQL
至少每天备份一次数据库。
创建备份目录:
```bash
mkdir -p /data/backups/mysql
```
手动备份:
```bash
docker compose exec mysql mysqldump -ucertificate -p certificate_system > /data/backups/mysql/certificate_system_$(date +%F).sql
```
注意:这个命令会提示输入 MySQL 密码。
也可以写定时任务:
```bash
crontab -e
```
加入:
```cron
30 2 * * * cd /opt/certificate-system && docker compose exec -T mysql mysqldump -ucertificate -p你的密码 certificate_system > /data/backups/mysql/certificate_system_$(date +\%F).sql
```
建议再加一个删除 30 天前备份的任务:
```cron
10 3 * * * find /data/backups/mysql -name "certificate_system_*.sql" -mtime +30 -delete
```
### 14.3 文件数据备份
需要备份:
```text
/data/certificate-system/uploads
/data/certificate-system/error-reports
/data/certificate-system/exports
```
不需要长期备份:
```text
/data/certificate-system/pdf-cache
```
PDF 可以重新生成,缓存目录不是核心数据。
## 15. 日志保留策略
当前系统建议:
- 普通后台操作日志保留 180 天。
- 公开查询和公开下载日志保留 180 天。
- 高风险后台操作日志保留 365 天。
后台提供“清理过期日志”,只清理超过保留周期的日志。
## 16. 日常运维命令
查看容器:
```bash
docker compose ps
```
查看后端日志:
```bash
docker compose logs -f backend
```
查看前端日志:
```bash
docker compose logs -f frontend
```
重启后端:
```bash
docker compose restart backend
```
重启全部服务:
```bash
docker compose restart
```
更新代码后重新构建:
```bash
docker compose up -d --build
docker compose exec backend alembic upgrade head
```
停止服务:
```bash
docker compose down
```
不要随便执行:
```bash
docker compose down -v
```
`-v` 会删除 Docker volume可能导致数据库数据丢失。
## 17. 常见问题
### 17.1 页面打不开
检查:
```bash
docker compose ps
docker compose logs -f frontend
```
确认安全组是否开放 8080、80 或 443。
### 17.2 后台接口 502
检查后端:
```bash
docker compose logs -f backend
```
检查数据库连接:
```bash
docker compose exec backend python -m app.cli init-db --admin-username admin --admin-password "临时测试密码"
```
如果数据库连不上,这个命令会报错。
### 17.3 PDF 中文乱码或字体不对
后端 Dockerfile 已安装:
```text
fonts-noto-cjk
```
如果换系统或自定义镜像,要确认 CJK 字体存在。
### 17.4 公开链接域名不对
检查 `.env`
```env
PUBLIC_BASE_URL=https://你的域名
```
修改后重启后端:
```bash
docker compose restart backend
```
### 17.5 数据库密码改了但服务连不上
确认三处一致:
1. `.env` 里的 `DATABASE_URL`
2. `docker-compose.yml` 里的 `MYSQL_PASSWORD`
3. MySQL 实际用户密码
如果已经初始化过 MySQL volume再改 `MYSQL_PASSWORD` 不一定会自动改数据库里的旧密码。需要进入 MySQL 手动改密码,或者在未上线前清空 volume 重新初始化。
## 18. 推荐上线顺序
1. 购买 ECS。
2. 决定数据库方案:先 ECS 自建 MySQL或直接 RDS。
3. 配置域名解析。
4. 安装 Docker 和 Compose。
5. 上传项目代码。
6. 修改 `.env`
7. 如果用 RDS配置 RDS 数据库、账号、白名单。
8. 执行 `docker compose up -d --build`
9. 执行 `python -m app.cli init-db`
10. 登录后台。
11. 配置项目。
12. 新增测试学员和测试证书。
13. 验证公开查询和 PDF 下载。
14. 配置 Nginx 和 HTTPS。
15. 配置备份。
16. 正式导入业务数据。
## 19. 一期推荐选择
如果你希望明天就能上线验证:
```text
一台 2核4G 或 2核8G ECS
Docker Compose 自带 MySQL
域名 + HTTPS
每天 mysqldump 备份
```
如果你希望正式长期稳定:
```text
一台 ECS 跑前后端
阿里云 RDS MySQL 跑数据库
RDS 自动备份
域名 + HTTPS
```
我的最终建议:如果预算允许,正式生产用 RDS如果先快速上线ECS 自建 MySQL 也可以,但一定要配置每日备份。

14
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,14 @@
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm install
COPY . .
RUN npm run build
FROM nginx:1.27-alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80

12
frontend/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>培训结业证书管理与查询系统</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

19
frontend/nginx.conf Normal file
View File

@@ -0,0 +1,19 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location /api/ {
proxy_pass http://backend:8000/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / {
try_files $uri $uri/ /index.html;
}
}

25
frontend/package.json Normal file
View File

@@ -0,0 +1,25 @@
{
"name": "certificate-system-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.1",
"@vitejs/plugin-vue": "^5.2.1",
"axios": "^1.7.9",
"element-plus": "^2.9.1",
"pinia": "^2.3.0",
"vue": "^3.5.13",
"vue-router": "^4.5.0"
},
"devDependencies": {
"typescript": "^5.7.2",
"vite": "^6.0.5",
"vue-tsc": "^2.2.0"
}
}

3
frontend/src/App.vue Normal file
View File

@@ -0,0 +1,3 @@
<template>
<router-view />
</template>

106
frontend/src/api.ts Normal file
View File

@@ -0,0 +1,106 @@
import axios from "axios";
export const http = axios.create({
baseURL: "/api"
});
http.interceptors.request.use((config) => {
const token = localStorage.getItem("admin_token");
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
export interface PublicCertificate {
certificate_no: string;
learner_name: string;
certificate_name: string;
project_code: string;
course_name: string | null;
stage_name: string | null;
issue_date: string;
issuer_name: string;
status: string;
pdf_status: string;
public_token: string | null;
public_url: string | null;
download_url: string | null;
can_download_pdf: boolean;
disclaimer: string;
}
export interface Captcha {
captcha_id: string;
image: string;
}
export interface ProjectCourse {
id: number;
code: string;
name: string;
default_certificate_name: string;
default_course_name: string | null;
default_stage_name: string | null;
default_issuer_name: string;
status: string;
created_at: string;
updated_at: string;
}
export interface Learner {
id: number;
phone: string;
current_name: string;
student_no: string | null;
status: string;
remark: string | null;
created_at: string;
updated_at: string;
}
export interface AdminCertificate {
id: number;
learner_id: number;
project_code: string;
certificate_no: string;
certificate_name: string;
class_name: string | null;
course_name: string | null;
stage_name: string | null;
issue_date: string;
issuer_name: string;
status: string;
pdf_status: string;
created_at: string;
updated_at: string;
}
export interface ImportBatch {
id: number;
filename: string;
status: string;
total_rows: number;
valid_rows: number;
failed_rows: number;
conflict_rows: number;
error_report_path: string | null;
created_at: string;
updated_at: string;
}
export interface OperationLog {
id: number;
admin_user_id: number | null;
action: string;
action_label: string;
object_type: string;
object_label: string;
object_id: string | null;
ip_address: string | null;
detail_json: string | null;
risk: string;
risk_label: string;
summary: string;
created_at: string;
}

217
frontend/src/design.css Normal file
View File

@@ -0,0 +1,217 @@
:root {
--app-bg: #f5f8fa;
--app-surface: #ffffff;
--app-surface-soft: #f9fbfc;
--app-border: #dbe6ea;
--app-border-strong: #c8d7dd;
--app-text: #172033;
--app-muted: #64748b;
--app-primary: #16817e;
--app-primary-strong: #0f6f6b;
--app-primary-soft: #e6f5f3;
--app-blue-soft: #eef5fb;
--app-gold: #b88a2d;
--app-danger: #c9413a;
--app-warning: #b7791f;
--app-success: #16817e;
--app-shadow: 0 14px 36px rgba(42, 64, 78, 0.08);
--app-shadow-soft: 0 8px 22px rgba(42, 64, 78, 0.055);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
color: var(--app-text);
background:
linear-gradient(180deg, #f7fafb 0%, #eef4f6 100%);
font-family:
Inter, "Microsoft YaHei", "PingFang SC", "Helvetica Neue", Arial, sans-serif;
}
button,
input,
textarea,
select {
font: inherit;
}
.page-head {
display: flex;
justify-content: space-between;
align-items: flex-end;
gap: 18px;
margin-bottom: 18px;
}
.page-head h2 {
margin: 0;
color: var(--app-text);
font-size: 24px;
font-weight: 750;
letter-spacing: 0;
}
.page-head p {
margin: 7px 0 0;
color: var(--app-muted);
line-height: 1.6;
}
.panel,
.el-card.panel,
.el-card.stat {
border: 1px solid var(--app-border);
border-radius: 10px;
background: var(--app-surface);
box-shadow: var(--app-shadow-soft);
}
.el-card {
--el-card-border-color: var(--app-border);
--el-card-border-radius: 10px;
}
.el-button {
--el-button-border-radius: 7px;
font-weight: 600;
}
.el-button--primary {
--el-button-bg-color: var(--app-primary);
--el-button-border-color: var(--app-primary);
--el-button-hover-bg-color: var(--app-primary-strong);
--el-button-hover-border-color: var(--app-primary-strong);
--el-button-active-bg-color: var(--app-primary-strong);
--el-button-active-border-color: var(--app-primary-strong);
}
.el-input__wrapper,
.el-select__wrapper,
.el-textarea__inner {
border-radius: 8px;
box-shadow: 0 0 0 1px var(--app-border) inset;
}
.el-input__wrapper:hover,
.el-select__wrapper:hover,
.el-textarea__inner:hover {
box-shadow: 0 0 0 1px var(--app-border-strong) inset;
}
.el-input__wrapper.is-focus,
.el-select__wrapper.is-focused,
.el-textarea__inner:focus {
box-shadow: 0 0 0 1px var(--app-primary) inset, 0 0 0 3px rgba(22, 129, 126, 0.12);
}
.el-form-item__label {
color: #415466;
font-weight: 650;
}
.el-table {
--el-table-border-color: #e5edf0;
--el-table-header-bg-color: #f6fafb;
--el-table-header-text-color: #52677a;
--el-table-row-hover-bg-color: #f8fcfc;
border-radius: 8px;
overflow: hidden;
}
.el-table th.el-table__cell {
font-weight: 700;
}
.el-tag {
border-radius: 999px;
font-weight: 650;
}
.el-dialog {
border-radius: 12px;
}
.el-dialog__header {
padding: 22px 24px 12px;
margin: 0;
}
.el-dialog__body {
padding: 14px 24px 22px;
}
.el-dialog__title {
color: var(--app-text);
font-size: 20px;
font-weight: 750;
}
.el-segmented {
--el-segmented-item-selected-bg-color: var(--app-primary);
--el-segmented-item-selected-color: #ffffff;
border: 1px solid var(--app-border);
background: #f4f8fa;
}
.form-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 14px;
}
.form-grid .el-form-item {
margin-bottom: 0;
}
.toolbar,
.filters {
align-items: center;
}
.pager {
border-top: 1px solid #edf2f4;
padding-top: 12px;
}
.stats {
align-items: stretch;
}
.stat {
transition: border-color 0.15s ease, box-shadow 0.15s ease, transform 0.15s ease;
}
.stat:hover {
border-color: var(--app-border-strong);
box-shadow: var(--app-shadow);
transform: translateY(-1px);
}
a {
color: var(--app-primary-strong);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
@media (max-width: 1100px) {
.form-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 720px) {
.page-head {
display: grid;
align-items: start;
}
.form-grid {
grid-template-columns: 1fr;
}
}

10
frontend/src/download.ts Normal file
View File

@@ -0,0 +1,10 @@
import { http } from "./api";
export async function downloadFile(url: string, filename: string) {
const { data } = await http.get(url, { responseType: "blob" });
const link = document.createElement("a");
link.href = URL.createObjectURL(data);
link.download = filename;
link.click();
URL.revokeObjectURL(link.href);
}

11
frontend/src/main.ts Normal file
View File

@@ -0,0 +1,11 @@
import "element-plus/dist/index.css";
import "./design.css";
import ElementPlus from "element-plus";
import { createPinia } from "pinia";
import { createApp } from "vue";
import App from "./App.vue";
import { router } from "./router";
createApp(App).use(createPinia()).use(router).use(ElementPlus).mount("#app");

View File

@@ -0,0 +1,42 @@
import { createRouter, createWebHistory } from "vue-router";
import AdminCertificates from "../views/AdminCertificates.vue";
import AdminHome from "../views/AdminHome.vue";
import AdminImports from "../views/AdminImports.vue";
import AdminLogin from "../views/AdminLogin.vue";
import AdminLogs from "../views/AdminLogs.vue";
import AdminLearners from "../views/AdminLearners.vue";
import AdminProjects from "../views/AdminProjects.vue";
import AdminShell from "../views/AdminShell.vue";
import CertificateQuery from "../views/CertificateQuery.vue";
import CertificateView from "../views/CertificateView.vue";
import VerifyView from "../views/VerifyView.vue";
export const router = createRouter({
history: createWebHistory(),
routes: [
{ path: "/", redirect: "/query" },
{ path: "/admin/login", component: AdminLogin },
{
path: "/admin",
component: AdminShell,
children: [
{ path: "", component: AdminHome },
{ path: "projects", component: AdminProjects },
{ path: "learners", component: AdminLearners },
{ path: "certificates", component: AdminCertificates },
{ path: "imports", component: AdminImports },
{ path: "logs", component: AdminLogs }
]
},
{ path: "/query", component: CertificateQuery },
{ path: "/cert/:token", component: CertificateView },
{ path: "/verify/:token", component: VerifyView }
]
});
router.beforeEach((to) => {
if (to.path.startsWith("/admin") && to.path !== "/admin/login" && !localStorage.getItem("admin_token")) {
return "/admin/login";
}
});

View File

@@ -0,0 +1,216 @@
<template>
<section>
<header class="page-head">
<div>
<h2>证书管理</h2>
<p>支持单张新增预览下载作废和链接重置</p>
</div>
<el-button type="primary" @click="createCertificate">新增证书</el-button>
</header>
<el-card class="panel" shadow="never">
<el-form class="form-grid" label-position="top">
<el-form-item label="学员ID">
<el-input v-model.number="form.learner_id" />
</el-form-item>
<el-form-item label="项目代码">
<el-select v-model="form.project_code" filterable>
<el-option v-for="item in activeProjects" :key="item.code" :label="`${item.code} ${item.name}`" :value="item.code" />
</el-select>
</el-form-item>
<el-form-item label="发证日期">
<el-date-picker v-model="form.issue_date" type="date" value-format="YYYY-MM-DD" />
</el-form-item>
<el-form-item label="课程名称">
<el-input v-model.trim="form.course_name" />
</el-form-item>
<el-form-item label="阶段名称">
<el-input v-model.trim="form.stage_name" />
</el-form-item>
<el-form-item label="发证单位">
<el-input v-model.trim="form.issuer_name" />
</el-form-item>
<el-form-item label="备注">
<el-input v-model.trim="form.remark" />
</el-form-item>
</el-form>
</el-card>
<el-card class="panel" shadow="never">
<div class="toolbar">
<el-input v-model.trim="keyword" placeholder="按证书编号、名称、项目代码搜索" clearable @keyup.enter="loadCertificates" />
<el-select v-model="statusValue" clearable placeholder="状态">
<el-option label="有效" value="valid" />
<el-option label="已作废" value="voided" />
</el-select>
<el-button @click="loadCertificates">搜索</el-button>
<el-button @click="exportCertificates">导出</el-button>
</div>
<el-table :data="certificates" border>
<el-table-column prop="certificate_no" label="证书编号" width="220" />
<el-table-column prop="learner_id" label="学员ID" width="90" />
<el-table-column prop="project_code" label="项目" width="90" />
<el-table-column prop="certificate_name" label="证书名称" min-width="180" />
<el-table-column prop="issue_date" label="发证日期" width="120" />
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag :type="row.status === 'valid' ? 'success' : 'danger'">{{ statusText(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="330">
<template #default="{ row }">
<el-button size="small" @click="previewCertificate(row)">预览</el-button>
<el-button size="small" @click="downloadCertificate(row)">下载</el-button>
<el-button size="small" @click="regenerateToken(row)">重置链接</el-button>
<el-button v-if="row.status === 'valid'" size="small" type="danger" @click="voidCertificate(row)">作废</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<el-dialog v-model="previewVisible" title="证书预览" width="680px">
<el-descriptions v-if="preview" :column="1" border>
<el-descriptions-item label="证书编号">{{ preview.certificate_no }}</el-descriptions-item>
<el-descriptions-item label="学员姓名">{{ preview.learner_name }}</el-descriptions-item>
<el-descriptions-item label="证书名称">{{ preview.certificate_name }}</el-descriptions-item>
<el-descriptions-item label="项目/课程/阶段">
{{ preview.project_code }} {{ preview.course_name || "" }} {{ preview.stage_name || "" }}
</el-descriptions-item>
<el-descriptions-item label="发证日期">{{ preview.issue_date }}</el-descriptions-item>
<el-descriptions-item label="发证单位">{{ preview.issuer_name }}</el-descriptions-item>
<el-descriptions-item label="公开链接">
<a :href="preview.public_url" target="_blank" rel="noreferrer">{{ preview.public_url }}</a>
</el-descriptions-item>
<el-descriptions-item label="核验链接">
<a :href="preview.verify_url" target="_blank" rel="noreferrer">{{ preview.verify_url }}</a>
</el-descriptions-item>
</el-descriptions>
</el-dialog>
</section>
</template>
<script setup lang="ts">
import { ElMessage, ElMessageBox } from "element-plus";
import { computed, onMounted, reactive, ref } from "vue";
import { http, type AdminCertificate, type ProjectCourse } from "../api";
import { downloadFile } from "../download";
const certificates = ref<AdminCertificate[]>([]);
const projects = ref<ProjectCourse[]>([]);
const keyword = ref("");
const statusValue = ref("");
const previewVisible = ref(false);
const preview = ref<any>(null);
const form = reactive({
learner_id: undefined as number | undefined,
project_code: "",
course_name: "",
stage_name: "",
issue_date: "",
issuer_name: "",
remark: "",
});
const activeProjects = computed(() => projects.value.filter((item) => item.status === "active"));
function statusText(status: string) {
return status === "valid" ? "有效" : "已作废";
}
async function loadProjects() {
const { data } = await http.get<ProjectCourse[]>("/admin/projects");
projects.value = data;
}
async function loadCertificates() {
const { data } = await http.get<AdminCertificate[]>("/admin/certificates", {
params: { keyword: keyword.value || undefined, status: statusValue.value || undefined },
});
certificates.value = data;
}
async function createCertificate() {
if (!form.learner_id || !form.project_code || !form.issue_date || !form.issuer_name) {
ElMessage.warning("请填写学员ID、项目、发证日期和发证单位");
return;
}
await http.post("/admin/certificates", {
learner_id: form.learner_id,
project_code: form.project_code,
course_name: form.course_name || null,
stage_name: form.stage_name || null,
issue_date: form.issue_date,
issuer_name: form.issuer_name,
remark: form.remark || null,
});
ElMessage.success("证书已创建");
await loadCertificates();
}
async function previewCertificate(row: AdminCertificate) {
const { data } = await http.get(`/admin/certificates/${row.id}/preview`);
preview.value = data;
previewVisible.value = true;
}
async function downloadCertificate(row: AdminCertificate) {
await downloadFile(`/admin/certificates/${row.id}/download`, `${row.certificate_no}.pdf`);
}
async function voidCertificate(row: AdminCertificate) {
await ElMessageBox.confirm(`确认作废证书 ${row.certificate_no}`, "作废证书", { type: "warning" });
await http.post(`/admin/certificates/${row.id}/void`);
await loadCertificates();
}
async function regenerateToken(row: AdminCertificate) {
await ElMessageBox.confirm(`重新生成 ${row.certificate_no} 的直达链接?旧链接会失效。`, "重置链接", { type: "warning" });
await http.post(`/admin/certificates/${row.id}/token/regenerate`);
ElMessage.success("直达链接已重置");
}
function exportCertificates() {
downloadFile("/admin/exports/certificates", "certificates-export.xlsx");
}
onMounted(async () => {
await Promise.all([loadProjects(), loadCertificates()]);
});
</script>
<style scoped>
.page-head,
.toolbar {
display: flex;
gap: 12px;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.page-head p {
margin: 6px 0 0;
color: #64748b;
}
.panel {
margin-bottom: 16px;
border-radius: 8px;
}
.form-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
}
.toolbar {
justify-content: flex-start;
}
.toolbar .el-input {
max-width: 340px;
}
</style>

View File

@@ -0,0 +1,106 @@
<template>
<section>
<header class="page-head">
<div>
<h2>一期工作台</h2>
<p>快速查看证书导入和状态数据</p>
</div>
<el-button type="primary" @click="downloadTemplate">下载导入模板</el-button>
</header>
<div class="stats">
<el-card v-for="item in cards" :key="item.label" class="stat" shadow="never">
<span>{{ item.label }}</span>
<strong>{{ item.value }}</strong>
</el-card>
</div>
</section>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive } from "vue";
import { useRouter } from "vue-router";
import { http } from "../api";
import { downloadFile } from "../download";
const router = useRouter();
const summary = reactive({
learner_count: 0,
certificate_count: 0,
valid_certificate_count: 0,
voided_certificate_count: 0,
recent_import_count: 0,
});
const cards = computed(() => [
{ label: "学员总数", value: summary.learner_count },
{ label: "证书总数", value: summary.certificate_count },
{ label: "有效证书", value: summary.valid_certificate_count },
{ label: "作废证书", value: summary.voided_certificate_count },
{ label: "近期导入", value: summary.recent_import_count },
]);
async function loadSummary() {
try {
const { data } = await http.get("/admin/dashboard/summary");
Object.assign(summary, data);
} catch {
localStorage.removeItem("admin_token");
await router.push("/admin/login");
}
}
function downloadTemplate() {
downloadFile("/admin/import-batches/template", "certificate-import-template.xlsx");
}
onMounted(loadSummary);
</script>
<style scoped>
.page-head {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 18px;
}
.page-head p {
margin: 6px 0 0;
color: #64748b;
}
.stats {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 14px;
}
.stat {
border: 1px solid #dbe6ea;
border-radius: 10px;
background: linear-gradient(180deg, #ffffff, #f8fbfc);
box-shadow: 0 8px 22px rgba(42, 64, 78, 0.055);
}
.stat span {
color: #64748b;
font-size: 13px;
font-weight: 650;
}
.stat strong {
display: block;
margin-top: 8px;
font-size: 30px;
font-weight: 800;
color: #172033;
}
@media (max-width: 1100px) {
.stats {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
</style>

View File

@@ -0,0 +1,166 @@
<template>
<section>
<header class="page-head">
<div>
<h2>Excel 导入</h2>
<p>上传后先校验确认导入后才会正式写入学员和证书数据</p>
</div>
<el-button @click="downloadTemplate">下载模板</el-button>
</header>
<el-upload
class="upload"
drag
:auto-upload="false"
:show-file-list="true"
:limit="1"
accept=".xlsx"
@change="pickFile"
@remove="selectedFile = null"
>
<el-icon class="upload-icon"><UploadFilled /></el-icon>
<div> Excel 文件拖到这里或点击选择文件</div>
</el-upload>
<el-button type="primary" :disabled="!selectedFile" :loading="uploading" @click="uploadFile">上传并校验</el-button>
<el-card class="panel" shadow="never">
<el-table :data="batches" border>
<el-table-column prop="id" label="批次ID" width="90" />
<el-table-column prop="filename" label="文件名" min-width="220" />
<el-table-column label="状态" width="120">
<template #default="{ row }">{{ statusText(row.status) }}</template>
</el-table-column>
<el-table-column prop="total_rows" label="总行数" width="100" />
<el-table-column prop="valid_rows" label="可导入" width="100" />
<el-table-column prop="failed_rows" label="失败" width="100" />
<el-table-column prop="created_at" label="上传时间" width="190" />
<el-table-column label="操作" width="380">
<template #default="{ row }">
<el-button size="small" @click="downloadSource(row)">下载原文件</el-button>
<el-button
v-if="row.status === 'validated'"
size="small"
type="primary"
:loading="confirmingId === row.id"
@click="confirmBatch(row)"
>
确认导入
</el-button>
<el-button v-if="row.error_report_path" size="small" @click="downloadErrorReport(row)">错误报告</el-button>
<el-button size="small" type="danger" @click="deleteBatch(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
</section>
</template>
<script setup lang="ts">
import { UploadFilled } from "@element-plus/icons-vue";
import { ElMessage, ElMessageBox, type UploadFile } from "element-plus";
import { onMounted, ref } from "vue";
import { http, type ImportBatch } from "../api";
import { downloadFile } from "../download";
const batches = ref<ImportBatch[]>([]);
const selectedFile = ref<File | null>(null);
const uploading = ref(false);
const confirmingId = ref<number | null>(null);
function statusText(status: string) {
const labels: Record<string, string> = {
uploaded: "已上传",
validated: "已校验",
imported: "已导入",
failed: "校验失败",
};
return labels[status] || status;
}
function pickFile(file: UploadFile) {
selectedFile.value = file.raw || null;
}
function downloadTemplate() {
downloadFile("/admin/import-batches/template", "certificate-import-template.xlsx");
}
async function loadBatches() {
const { data } = await http.get<ImportBatch[]>("/admin/import-batches");
batches.value = data;
}
async function uploadFile() {
if (!selectedFile.value) return;
uploading.value = true;
try {
const body = new FormData();
body.append("file", selectedFile.value);
await http.post("/admin/import-batches", body);
ElMessage.success("文件已上传并完成校验,请检查结果后点击确认导入");
selectedFile.value = null;
await loadBatches();
} finally {
uploading.value = false;
}
}
async function confirmBatch(row: ImportBatch) {
await ElMessageBox.confirm(`确认把批次 ${row.id}${row.valid_rows} 行校验通过的数据写入系统?`, "确认导入", { type: "warning" });
confirmingId.value = row.id;
try {
const { data } = await http.post<ImportBatch>(`/admin/import-batches/${row.id}/confirm`);
row.status = data.status;
ElMessage.success(`导入完成,当前状态:${statusText(data.status)}`);
await loadBatches();
} finally {
confirmingId.value = null;
}
}
function downloadSource(row: ImportBatch) {
downloadFile(`/admin/import-batches/${row.id}/file`, row.filename);
}
function downloadErrorReport(row: ImportBatch) {
downloadFile(`/admin/import-batches/${row.id}/error-report`, `import-errors-${row.id}.xlsx`);
}
async function deleteBatch(row: ImportBatch) {
await ElMessageBox.confirm(`确认删除导入批次 ${row.id}?这只删除上传文件和导入记录,不会删除已经生成的学员和证书。`, "删除导入记录", { type: "warning" });
await http.delete(`/admin/import-batches/${row.id}`);
ElMessage.success("导入记录已删除");
await loadBatches();
}
onMounted(loadBatches);
</script>
<style scoped>
.page-head {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.page-head p {
margin: 6px 0 0;
color: #64748b;
}
.upload {
margin-bottom: 12px;
}
.upload-icon {
font-size: 36px;
color: #208a87;
}
.panel {
margin-top: 20px;
border-radius: 8px;
}
</style>

View File

@@ -0,0 +1,163 @@
<template>
<section>
<header class="page-head">
<div>
<h2>学员管理</h2>
<p>支持手动新增修改删除和查询学员也可以继续通过 Excel 批量导入</p>
</div>
<el-button type="primary" @click="saveLearner">{{ editingId ? "保存修改" : "新增学员" }}</el-button>
</header>
<el-card class="panel" shadow="never">
<el-form class="form-grid" label-position="top">
<el-form-item label="姓名">
<el-input v-model.trim="form.current_name" />
</el-form-item>
<el-form-item label="手机号">
<el-input v-model.trim="form.phone" />
</el-form-item>
<el-form-item label="学员编号">
<el-input v-model.trim="form.student_no" />
</el-form-item>
<el-form-item label="状态">
<el-select v-model="form.status">
<el-option label="启用" value="active" />
<el-option label="停用" value="disabled" />
</el-select>
</el-form-item>
<el-form-item label="备注" class="wide">
<el-input v-model.trim="form.remark" />
</el-form-item>
<el-form-item label=" ">
<el-button @click="resetForm">清空</el-button>
</el-form-item>
</el-form>
</el-card>
<el-card class="panel" shadow="never">
<div class="toolbar">
<el-input v-model.trim="keyword" placeholder="按姓名、手机号、学员编号搜索" clearable @keyup.enter="loadLearners" />
<el-button @click="loadLearners">搜索</el-button>
</div>
<el-table :data="learners" border>
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="current_name" label="姓名" width="140" />
<el-table-column prop="phone" label="手机号" width="160" />
<el-table-column prop="student_no" label="学员编号" width="150" />
<el-table-column label="状态" width="110">
<template #default="{ row }">
<el-tag :type="row.status === 'active' ? 'success' : 'info'">{{ statusText(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="remark" label="备注" />
<el-table-column label="操作" width="160">
<template #default="{ row }">
<el-button size="small" @click="editLearner(row)">修改</el-button>
<el-button size="small" type="danger" @click="deleteLearner(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
</section>
</template>
<script setup lang="ts">
import { ElMessage, ElMessageBox } from "element-plus";
import { onMounted, reactive, ref } from "vue";
import { http, type Learner } from "../api";
const learners = ref<Learner[]>([]);
const keyword = ref("");
const editingId = ref<number | null>(null);
const form = reactive({ current_name: "", phone: "", student_no: "", status: "active", remark: "" });
function statusText(status: string) {
return status === "active" ? "启用" : "停用";
}
async function loadLearners() {
const { data } = await http.get<Learner[]>("/admin/learners", { params: { keyword: keyword.value || undefined } });
learners.value = data;
}
function editLearner(row: Learner) {
editingId.value = row.id;
Object.assign(form, {
current_name: row.current_name,
phone: row.phone,
student_no: row.student_no || "",
status: row.status,
remark: row.remark || "",
});
}
function resetForm() {
editingId.value = null;
Object.assign(form, { current_name: "", phone: "", student_no: "", status: "active", remark: "" });
}
async function saveLearner() {
if (!form.current_name || !form.phone) {
ElMessage.warning("姓名和手机号必填");
return;
}
const payload = { ...form, student_no: form.student_no || null, remark: form.remark || null };
if (editingId.value) {
await http.put(`/admin/learners/${editingId.value}`, payload);
} else {
await http.post("/admin/learners", payload);
}
ElMessage.success("学员已保存");
resetForm();
await loadLearners();
}
async function deleteLearner(row: Learner) {
await ElMessageBox.confirm(`确认删除学员 ${row.current_name}`, "删除学员", { type: "warning" });
await http.delete(`/admin/learners/${row.id}`);
ElMessage.success("学员已删除");
await loadLearners();
}
onMounted(loadLearners);
</script>
<style scoped>
.page-head,
.toolbar {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
margin-bottom: 16px;
}
.page-head p {
margin: 6px 0 0;
color: #64748b;
}
.panel {
margin-bottom: 16px;
border-radius: 8px;
}
.form-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
}
.wide {
grid-column: span 3;
}
.toolbar {
justify-content: flex-start;
}
.toolbar .el-input {
max-width: 380px;
}
</style>

View File

@@ -0,0 +1,82 @@
<template>
<main class="login-page">
<section class="login-box">
<h1>后台登录</h1>
<el-form label-position="top">
<el-form-item label="账号">
<el-input v-model.trim="form.username" />
</el-form-item>
<el-form-item label="密码">
<el-input v-model="form.password" type="password" show-password @keyup.enter="login" />
</el-form-item>
<el-button type="primary" class="submit" :loading="loading" @click="login">登录</el-button>
</el-form>
<el-alert v-if="message" class="message" :title="message" type="error" :closable="false" />
</section>
</main>
</template>
<script setup lang="ts">
import { reactive, ref } from "vue";
import { useRouter } from "vue-router";
import { http } from "../api";
const router = useRouter();
const loading = ref(false);
const message = ref("");
const form = reactive({
username: "",
password: "",
});
async function login() {
loading.value = true;
message.value = "";
try {
const { data } = await http.post("/admin/auth/login", form);
localStorage.setItem("admin_token", data.access_token);
await router.push("/admin");
} catch (error: any) {
message.value = error?.response?.data?.detail || "登录失败,请重试";
} finally {
loading.value = false;
}
}
</script>
<style scoped>
.login-page {
min-height: 100vh;
display: grid;
place-items: center;
background: linear-gradient(180deg, #f7fafb 0%, #eef4f6 100%);
padding: 24px;
}
.login-box {
width: min(440px, 100%);
padding: 32px;
border: 1px solid #dbe6ea;
border-radius: 14px;
background: #ffffff;
box-shadow: 0 18px 48px rgba(42, 64, 78, 0.09);
}
h1 {
margin: 0 0 22px;
color: #172033;
font-size: 26px;
font-weight: 800;
}
.submit {
width: 100%;
height: 38px;
font-weight: 700;
}
.message {
margin-top: 16px;
}
</style>

View File

@@ -0,0 +1,290 @@
<template>
<section>
<header class="page-head">
<div>
<h2>操作日志</h2>
<p>记录后台关键动作支持筛选导出和查看完整详情</p>
</div>
<div class="head-actions">
<el-button @click="loadLogs">刷新</el-button>
<el-button @click="cleanupLogs">清理过期日志</el-button>
<el-button type="primary" @click="exportLogs">导出日志</el-button>
</div>
</header>
<el-card class="panel" shadow="never">
<p class="retention-note">日志保留策略普通后台日志公开查询和公开下载日志保留 180 高风险后台操作保留 365 </p>
<div class="filters">
<el-input v-model.trim="filters.keyword" clearable placeholder="搜索摘要、对象ID、详情" @keyup.enter="loadLogs" />
<el-select v-model="filters.action" clearable placeholder="操作类型">
<el-option v-for="item in actionOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
<el-select v-model="filters.object_type" clearable placeholder="对象类型">
<el-option label="项目" value="project_course" />
<el-option label="学员" value="learner" />
<el-option label="证书" value="certificate" />
<el-option label="导入批次" value="import_batch" />
<el-option label="公开访问" value="public_access" />
</el-select>
<el-select v-model="filters.risk" clearable placeholder="风险等级">
<el-option label="高风险" value="high" />
<el-option label="中风险" value="medium" />
<el-option label="低风险" value="low" />
</el-select>
<el-button type="primary" @click="loadLogs">搜索</el-button>
<el-button @click="resetFilters">重置</el-button>
</div>
<div v-if="totalPages > 1" class="pager">
<el-button :disabled="page <= 1" @click="loadLogs(page - 1)">上一页</el-button>
<template v-for="item in pageItems" :key="item">
<span v-if="item === '...'" class="ellipsis">...</span>
<el-button v-else :type="item === page ? 'primary' : 'default'" @click="loadLogs(Number(item))">{{ item }}</el-button>
</template>
<el-button :disabled="page >= totalPages" @click="loadLogs(page + 1)">下一页</el-button>
<span class="total"> {{ total }} </span>
</div>
<el-table :data="logs" border>
<el-table-column prop="created_at" label="时间" width="190" />
<el-table-column prop="admin_user_id" label="操作人ID" width="100" />
<el-table-column prop="action_label" label="操作" width="150" />
<el-table-column prop="object_label" label="对象" width="100" />
<el-table-column prop="object_id" label="对象ID" width="110" />
<el-table-column label="风险" width="100">
<template #default="{ row }">
<el-tag :type="riskTag(row.risk)">{{ row.risk_label }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="summary" label="摘要" min-width="260" show-overflow-tooltip />
<el-table-column label="操作" width="90">
<template #default="{ row }">
<el-button size="small" @click="openDetail(row)">详情</el-button>
</template>
</el-table-column>
</el-table>
<div v-if="totalPages > 1" class="pager bottom">
<el-button :disabled="page <= 1" @click="loadLogs(page - 1)">上一页</el-button>
<template v-for="item in pageItems" :key="`bottom-${item}`">
<span v-if="item === '...'" class="ellipsis">...</span>
<el-button v-else :type="item === page ? 'primary' : 'default'" @click="loadLogs(Number(item))">{{ item }}</el-button>
</template>
<el-button :disabled="page >= totalPages" @click="loadLogs(page + 1)">下一页</el-button>
<span class="total"> {{ total }} </span>
</div>
</el-card>
<el-dialog v-model="detailVisible" title="操作日志详情" width="720px">
<el-descriptions v-if="current" :column="1" border>
<el-descriptions-item label="时间">{{ current.created_at }}</el-descriptions-item>
<el-descriptions-item label="操作人ID">{{ current.admin_user_id || "-" }}</el-descriptions-item>
<el-descriptions-item label="操作">{{ current.action_label }}</el-descriptions-item>
<el-descriptions-item label="对象">{{ current.object_label }} #{{ current.object_id || "-" }}</el-descriptions-item>
<el-descriptions-item label="风险等级">
<el-tag :type="riskTag(current.risk)">{{ current.risk_label }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="摘要">{{ current.summary }}</el-descriptions-item>
</el-descriptions>
<pre class="detail-json">{{ prettyDetail }}</pre>
</el-dialog>
</section>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from "vue";
import { http, type OperationLog } from "../api";
import { downloadFile } from "../download";
const logs = ref<OperationLog[]>([]);
const current = ref<OperationLog | null>(null);
const detailVisible = ref(false);
const filters = reactive({ keyword: "", action: "", object_type: "", risk: "" });
const page = ref(1);
const pageSize = 20;
const total = ref(0);
const totalPages = ref(1);
const actionOptions = [
{ value: "create_project", label: "新增项目" },
{ value: "update_project", label: "修改项目" },
{ value: "create_learner", label: "新增学员" },
{ value: "update_learner", label: "修改学员" },
{ value: "delete_learner", label: "删除学员" },
{ value: "create_certificate", label: "新增证书" },
{ value: "void_certificate", label: "作废证书" },
{ value: "regenerate_public_token", label: "重置证书链接" },
{ value: "upload_import_file", label: "上传导入文件" },
{ value: "confirm_import_batch", label: "确认导入" },
{ value: "delete_import_batch", label: "删除导入批次" },
{ value: "export_certificates", label: "导出证书" },
{ value: "public_search_certificate", label: "公开查询证书" },
{ value: "public_download_certificate", label: "公开下载证书" },
];
const prettyDetail = computed(() => {
if (!current.value?.detail_json) return "{}";
try {
return JSON.stringify(JSON.parse(current.value.detail_json), null, 2);
} catch {
return current.value.detail_json;
}
});
const pageItems = computed<Array<number | string>>(() => compactPages(page.value, totalPages.value));
function queryParams() {
const params = new URLSearchParams();
Object.entries(filters).forEach(([key, value]) => {
if (value) params.set(key, value);
});
params.set("page", String(page.value));
params.set("page_size", String(pageSize));
return params.toString();
}
function compactPages(current: number, totalPageCount: number) {
const pages = new Set<number>();
pages.add(1);
for (let item = current - 2; item <= current + 2; item += 1) {
if (item > 1 && item < totalPageCount) pages.add(item);
}
if (totalPageCount > 1) pages.add(totalPageCount);
const sorted = Array.from(pages).sort((a, b) => a - b);
const result: Array<number | string> = [];
sorted.forEach((item, index) => {
if (index > 0 && item - sorted[index - 1] > 1) result.push("...");
result.push(item);
});
return result;
}
function riskTag(risk?: string | null) {
if (risk === "high") return "danger";
if (risk === "medium") return "warning";
return "success";
}
function openDetail(row: OperationLog) {
current.value = row;
detailVisible.value = true;
}
function resetFilters() {
filters.keyword = "";
filters.action = "";
filters.object_type = "";
filters.risk = "";
loadLogs(1);
}
async function loadLogs(nextPage = 1) {
page.value = nextPage;
const query = queryParams();
const { data } = await http.get<{ items: OperationLog[]; total: number; page: number; page_size: number; total_pages: number }>(`/admin/logs${query ? `?${query}` : ""}`);
logs.value = data.items;
total.value = data.total;
page.value = data.page;
totalPages.value = data.total_pages;
}
async function exportLogs() {
const query = queryParams();
await downloadFile(`/admin/logs/export${query ? `?${query}` : ""}`, "operation-logs.xlsx");
}
async function cleanupLogs() {
await http.post("/admin/logs/cleanup");
await loadLogs();
}
onMounted(loadLogs);
</script>
<style scoped>
.page-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
margin-bottom: 16px;
}
.page-head p {
margin: 6px 0 0;
color: #64748b;
}
.head-actions {
display: flex;
gap: 8px;
}
.panel {
border-radius: 8px;
}
.retention-note {
margin: 0 0 12px;
color: #64748b;
font-size: 13px;
}
.filters {
display: grid;
grid-template-columns: minmax(220px, 1fr) repeat(3, 160px) auto auto;
gap: 10px;
margin-bottom: 14px;
}
.filters :deep(.el-select__wrapper) {
padding-left: 14px;
padding-right: 14px;
}
.filters :deep(.el-select__suffix) {
margin-left: 14px;
}
.pager {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 6px;
flex-wrap: wrap;
margin: 12px 0;
}
.pager.bottom {
margin-top: 14px;
margin-bottom: 0;
}
.ellipsis,
.total {
color: #64748b;
font-size: 13px;
}
.ellipsis {
padding: 0 4px;
}
.detail-json {
margin: 14px 0 0;
max-height: 360px;
overflow: auto;
white-space: pre-wrap;
border: 1px solid #dfe9ec;
border-radius: 8px;
background: #f8fafc;
padding: 14px;
}
@media (max-width: 1100px) {
.filters {
grid-template-columns: 1fr 1fr;
}
}
</style>

View File

@@ -0,0 +1,199 @@
<template>
<section>
<header class="page-head">
<div>
<h2>项目管理</h2>
<p>维护项目代码和导入时使用的证书默认信息</p>
</div>
<el-button type="primary" @click="saveProject">{{ editingId ? "保存修改" : "新增项目" }}</el-button>
</header>
<el-card class="panel" shadow="never">
<el-form class="form-grid" label-position="top">
<el-form-item label="项目代码">
<el-input v-model.trim="form.code" :disabled="!!editingId" maxlength="16" />
</el-form-item>
<el-form-item label="项目名称">
<el-input v-model.trim="form.name" maxlength="128" />
</el-form-item>
<el-form-item label="默认发证单位">
<el-input v-model.trim="form.default_issuer_name" maxlength="128" />
</el-form-item>
<el-form-item label="默认课程名称">
<el-input v-model.trim="form.default_course_name" maxlength="128" />
</el-form-item>
<el-form-item label="默认阶段名称">
<el-input v-model.trim="form.default_stage_name" maxlength="128" />
</el-form-item>
<el-form-item label="状态">
<el-select v-model="form.status">
<el-option label="启用" value="active" />
<el-option label="停用" value="disabled" />
</el-select>
</el-form-item>
<el-form-item label=" ">
<el-button @click="resetForm">清空</el-button>
</el-form-item>
</el-form>
</el-card>
<el-card class="panel" shadow="never">
<div class="toolbar">
<el-input v-model.trim="keyword" placeholder="搜索项目代码、名称、课程、阶段或状态" clearable />
</div>
<el-table :data="projects" border>
<el-table-column prop="code" label="项目代码" width="120" />
<el-table-column prop="name" label="项目名称" width="150" />
<el-table-column prop="default_course_name" label="课程名称" min-width="140" />
<el-table-column prop="default_stage_name" label="阶段名称" min-width="120" />
<el-table-column prop="default_issuer_name" label="发证单位" min-width="140" />
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag :type="row.status === 'active' ? 'success' : 'info'">
{{ statusText(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="180" fixed="right">
<template #default="{ row }">
<el-button size="small" @click="editProject(row)">修改</el-button>
<el-button size="small" @click="toggleStatus(row)">
{{ row.status === "active" ? "停用" : "启用" }}
</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
</section>
</template>
<script setup lang="ts">
import { ElMessage } from "element-plus";
import { computed, onMounted, reactive, ref } from "vue";
import { http, type ProjectCourse } from "../api";
const allProjects = ref<ProjectCourse[]>([]);
const keyword = ref("");
const editingId = ref<number | null>(null);
const form = reactive({
code: "",
name: "",
default_course_name: "",
default_stage_name: "",
default_issuer_name: "本公司",
status: "active",
});
const projects = computed(() => {
const value = keyword.value.toLowerCase();
if (!value) return allProjects.value;
return allProjects.value.filter((item) => {
const text = [
item.code,
item.name,
item.default_course_name || "",
item.default_stage_name || "",
item.default_issuer_name,
statusText(item.status),
].join(" ").toLowerCase();
return text.includes(value);
});
});
function statusText(status: string) {
return status === "active" ? "启用" : "停用";
}
async function loadProjects() {
const { data } = await http.get<ProjectCourse[]>("/admin/projects");
allProjects.value = data;
}
function editProject(row: ProjectCourse) {
editingId.value = row.id;
Object.assign(form, {
code: row.code,
name: row.name,
default_course_name: row.default_course_name || "",
default_stage_name: row.default_stage_name || "",
default_issuer_name: row.default_issuer_name,
status: row.status,
});
}
function resetForm() {
editingId.value = null;
Object.assign(form, {
code: "",
name: "",
default_course_name: "",
default_stage_name: "",
default_issuer_name: "本公司",
status: "active",
});
}
function projectPayload() {
return {
code: form.code,
name: form.name,
default_course_name: form.default_course_name || null,
default_stage_name: form.default_stage_name || null,
default_issuer_name: form.default_issuer_name,
status: form.status,
};
}
async function saveProject() {
if (!form.code || !form.name || !form.default_issuer_name) {
ElMessage.warning("项目代码、项目名称和发证单位必填");
return;
}
if (editingId.value) {
await http.put(`/admin/projects/${editingId.value}`, projectPayload());
} else {
await http.post("/admin/projects", projectPayload());
}
ElMessage.success("项目已保存");
resetForm();
await loadProjects();
}
async function toggleStatus(row: ProjectCourse) {
await http.put(`/admin/projects/${row.id}`, { status: row.status === "active" ? "disabled" : "active" });
await loadProjects();
}
onMounted(loadProjects);
</script>
<style scoped>
.page-head,
.toolbar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.page-head p {
margin: 6px 0 0;
color: #64748b;
}
.panel {
margin-bottom: 16px;
border-radius: 8px;
}
.form-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
}
.toolbar .el-input {
max-width: 420px;
}
</style>

View File

@@ -0,0 +1,113 @@
<template>
<main class="admin-shell">
<aside class="sidebar">
<div class="brand">
<h1>证书管理</h1>
<p>培训结业证书后台</p>
</div>
<el-menu :default-active="route.path" router>
<el-menu-item index="/admin">数据概览</el-menu-item>
<el-menu-item index="/admin/projects">项目管理</el-menu-item>
<el-menu-item index="/admin/learners">学员管理</el-menu-item>
<el-menu-item index="/admin/certificates">证书管理</el-menu-item>
<el-menu-item index="/admin/imports">Excel 导入</el-menu-item>
<el-menu-item index="/admin/logs">操作日志</el-menu-item>
</el-menu>
<el-button class="logout" @click="logout">退出登录</el-button>
</aside>
<section class="workspace">
<router-view />
</section>
</main>
</template>
<script setup lang="ts">
import { useRoute, useRouter } from "vue-router";
const route = useRoute();
const router = useRouter();
function logout() {
localStorage.removeItem("admin_token");
router.push("/admin/login");
}
</script>
<style scoped>
.admin-shell {
min-height: 100vh;
display: grid;
grid-template-columns: 248px 1fr;
background: linear-gradient(180deg, #f7fafb 0%, #eef4f6 100%);
color: #172033;
}
.sidebar {
position: sticky;
top: 0;
height: 100vh;
background: rgba(255, 255, 255, 0.94);
border-right: 1px solid #e1ebef;
padding: 24px 14px;
box-shadow: 8px 0 26px rgba(42, 64, 78, 0.045);
}
.brand {
padding: 0 12px 20px;
border-bottom: 1px solid #e1ebef;
margin-bottom: 18px;
}
.brand h1 {
font-size: 21px;
font-weight: 800;
margin: 0;
letter-spacing: 0;
}
.brand p {
margin: 7px 0 0;
color: #6b8795;
font-size: 13px;
}
:deep(.el-menu) {
border-right: 0;
}
:deep(.el-menu-item) {
height: 42px;
margin: 4px 0;
border-radius: 8px;
color: #42576a;
font-weight: 650;
}
:deep(.el-menu-item.is-active),
:deep(.el-menu-item:hover) {
background: #e6f5f3;
color: #116d6b;
}
.logout {
width: calc(100% - 16px);
margin: 20px 8px 0;
}
.workspace {
min-width: 0;
padding: 28px;
overflow: auto;
}
@media (max-width: 900px) {
.admin-shell {
grid-template-columns: 1fr;
}
.sidebar {
position: static;
height: auto;
}
}
</style>

View File

@@ -0,0 +1,450 @@
<template>
<main class="public-page">
<section class="query-panel">
<div class="heading">
<p>电子证书查询</p>
<h1>培训结业证书查询</h1>
</div>
<el-form label-position="top">
<el-form-item label="查询方式">
<el-segmented v-model="queryMode" :options="queryOptions" />
</el-form-item>
<el-form-item v-if="queryMode === 'certificate'" label="证书编号">
<el-input v-model.trim="form.certificateNo" placeholder="例如 PX2026-DBY-K7M9Q2R-83" />
</el-form-item>
<el-form-item v-else label="手机号">
<el-input v-model.trim="form.phone" placeholder="请输入证书对应手机号" />
</el-form-item>
<el-form-item label="姓名">
<el-input v-model.trim="form.name" placeholder="请输入证书对应姓名" />
</el-form-item>
<el-form-item label="图形验证码">
<div class="captcha-row">
<el-input v-model.trim="form.captcha" placeholder="请输入验证码" />
<button class="captcha-button" type="button" @click="loadCaptcha">
<img v-if="captchaImage" :src="captchaImage" alt="验证码" />
<span v-else>刷新</span>
</button>
</div>
</el-form-item>
<el-button type="primary" class="submit" :loading="loading" @click="submitQuery">查询</el-button>
</el-form>
<el-alert v-if="message" class="notice" :title="message" :type="messageType" :closable="false" />
<section v-if="results.length" class="results">
<div class="results-head">
<div>
<h2>查询结果</h2>
<p>共找到 {{ results.length }} 张证书</p>
</div>
</div>
<article v-for="item in results" :key="item.certificate_no" class="certificate-card">
<div class="card-top">
<div>
<span :class="['status', item.status === 'valid' ? 'ok' : 'bad']">{{ statusText(item.status) }}</span>
<h3>{{ item.certificate_name }}</h3>
</div>
<p class="number">{{ item.certificate_no }}</p>
</div>
<dl class="meta">
<div>
<dt>学员姓名</dt>
<dd>{{ item.learner_name }}</dd>
</div>
<div>
<dt>项目代码</dt>
<dd>{{ item.project_code }}</dd>
</div>
<div>
<dt>课程/阶段</dt>
<dd>{{ courseText(item) }}</dd>
</div>
<div>
<dt>发证日期</dt>
<dd>{{ item.issue_date }}</dd>
</div>
<div>
<dt>发证单位</dt>
<dd>{{ item.issuer_name }}</dd>
</div>
</dl>
<div class="card-actions">
<el-button @click="preview(item)">预览</el-button>
<el-button type="primary" :disabled="!item.can_download_pdf" @click="downloadPdf(item)">下载 PDF</el-button>
</div>
</article>
</section>
<p class="disclaimer">
本证书仅用于证明学员完成相关培训课程/阶段学习不代表国家学历学位职业资格或职业技能等级认证
</p>
</section>
<el-dialog v-model="previewVisible" title="证书预览" width="720px" @closed="clearPreviewUrl">
<div v-if="previewPdfUrl" class="pdf-frame">
<embed :src="previewPdfUrl" type="application/pdf" />
</div>
<div v-else-if="current" class="preview-card">
<p class="preview-kicker">Training Completion Certificate</p>
<h2>{{ current.certificate_name }}</h2>
<p class="preview-name">{{ current.learner_name }}</p>
<p class="preview-copy">
已完成 {{ current.course_name || current.project_code }} {{ current.stage_name || "" }} 相关培训学习
</p>
<div class="preview-meta">
<span>证书编号{{ current.certificate_no }}</span>
<span>发证日期{{ current.issue_date }}</span>
<span>发证单位{{ current.issuer_name }}</span>
</div>
</div>
<template #footer>
<el-button @click="closePreview">关闭</el-button>
<el-button v-if="current?.can_download_pdf" type="primary" @click="downloadPdf(current)">下载 PDF</el-button>
</template>
</el-dialog>
</main>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref, watch } from "vue";
import { http, type Captcha, type PublicCertificate } from "../api";
interface SearchResponse {
items: PublicCertificate[];
}
const queryOptions = [
{ label: "证书编号查询", value: "certificate" },
{ label: "手机号查询", value: "phone" },
];
const queryMode = ref<"certificate" | "phone">("certificate");
const form = reactive({ certificateNo: "", phone: "", name: "", captcha: "" });
const captchaId = ref("");
const captchaImage = ref("");
const loading = ref(false);
const message = ref("");
const messageType = ref<"success" | "warning" | "error">("warning");
const results = ref<PublicCertificate[]>([]);
const previewVisible = ref(false);
const current = ref<PublicCertificate | null>(null);
const previewPdfUrl = ref("");
function statusText(status: string) {
return status === "valid" ? "有效" : "已作废";
}
function courseText(item: PublicCertificate) {
return [item.course_name, item.stage_name].filter(Boolean).join(" / ") || "-";
}
watch(queryMode, () => {
message.value = "";
results.value = [];
});
async function loadCaptcha() {
const { data } = await http.get<Captcha>("/public/captcha");
captchaId.value = data.captcha_id;
captchaImage.value = data.image;
form.captcha = "";
}
async function submitQuery() {
loading.value = true;
message.value = "";
results.value = [];
try {
const { data } = await http.post<SearchResponse>("/public/certificates/search", {
certificate_no: queryMode.value === "certificate" ? form.certificateNo : null,
phone: queryMode.value === "phone" ? form.phone : null,
name: form.name,
captcha_id: captchaId.value,
captcha_code: form.captcha,
});
results.value = data.items;
const hasVoided = data.items.some((item) => item.status !== "valid");
message.value = hasVoided ? "已查询到证书,其中包含作废证书,请核对状态。" : "已查询到匹配证书。";
messageType.value = hasVoided ? "warning" : "success";
} catch (error: any) {
message.value = error?.response?.data?.detail || "查询失败,请稍后再试";
messageType.value = "error";
await loadCaptcha();
} finally {
loading.value = false;
}
}
async function preview(item: PublicCertificate) {
current.value = item;
clearPreviewUrl();
if (item.can_download_pdf && item.download_url) {
const url = item.download_url.replace(/^\/api/, "");
const { data } = await http.get(url, { responseType: "blob" });
previewPdfUrl.value = URL.createObjectURL(data);
}
previewVisible.value = true;
}
function clearPreviewUrl() {
if (previewPdfUrl.value) {
URL.revokeObjectURL(previewPdfUrl.value);
previewPdfUrl.value = "";
}
}
function closePreview() {
previewVisible.value = false;
}
function downloadPdf(item: PublicCertificate) {
if (item.download_url) window.location.href = item.download_url;
}
onMounted(loadCaptcha);
</script>
<style scoped>
.public-page {
min-height: 100vh;
display: grid;
place-items: start center;
background: linear-gradient(180deg, #f7fafb 0%, #eef4f6 100%);
padding: 42px 20px;
}
.query-panel {
width: min(820px, 100%);
background: #ffffff;
border: 1px solid #dbe6ea;
border-radius: 14px;
padding: 32px;
box-shadow: 0 18px 48px rgba(42, 64, 78, 0.09);
}
.heading {
margin-bottom: 22px;
}
.heading p {
margin: 0 0 6px;
color: #16817e;
font-size: 13px;
font-weight: 700;
}
h1,
h2,
h3 {
margin: 0;
}
h1 {
font-size: 30px;
color: #172033;
letter-spacing: 0;
}
.captcha-row {
display: grid;
grid-template-columns: 1fr 132px;
gap: 10px;
width: 100%;
}
.captcha-button {
height: 34px;
padding: 0;
border: 1px solid #dbe6ea;
border-radius: 8px;
background: #ffffff;
cursor: pointer;
overflow: hidden;
}
.captcha-button img {
width: 100%;
height: 100%;
display: block;
}
.submit {
width: 100%;
height: 38px;
font-weight: 700;
}
.notice,
.results {
margin-top: 20px;
}
.results-head {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.results-head p {
margin: 4px 0 0;
color: #64748b;
font-size: 13px;
}
.certificate-card {
border: 1px solid #dbe6ea;
border-radius: 10px;
padding: 20px;
margin-bottom: 14px;
background: linear-gradient(180deg, #ffffff, #f8fbfc);
box-shadow: 0 8px 22px rgba(42, 64, 78, 0.05);
}
.card-top {
display: flex;
justify-content: space-between;
gap: 16px;
align-items: flex-start;
padding-bottom: 14px;
border-bottom: 1px solid #e8eef1;
}
.status {
display: inline-flex;
height: 24px;
align-items: center;
border-radius: 999px;
padding: 0 9px;
margin-bottom: 10px;
font-size: 12px;
}
.status.ok {
color: #0f6f6b;
background: #e6f5f3;
}
.status.bad {
color: #991b1b;
background: #fee2e2;
}
.number {
margin: 0;
color: #475569;
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
font-size: 13px;
padding-top: 4px;
}
.meta {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px 22px;
margin: 16px 0;
}
.meta div {
min-width: 0;
}
.meta dt {
color: #64748b;
font-size: 12px;
font-weight: 650;
margin-bottom: 4px;
}
.meta dd {
margin: 0;
color: #172033;
font-size: 14px;
}
.card-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
}
.disclaimer {
margin: 20px 0 0;
color: #6b7280;
font-size: 13px;
line-height: 1.6;
padding-top: 16px;
border-top: 1px solid #e8eef1;
}
.preview-card {
border: 1px solid #dfe9ec;
border-radius: 10px;
padding: 32px;
text-align: center;
background: linear-gradient(180deg, #ffffff, #f8fbfc);
}
.pdf-frame {
height: 70vh;
border: 1px solid #dfe9ec;
border-radius: 10px;
overflow: hidden;
background: #f8fafc;
}
.pdf-frame embed {
width: 100%;
height: 100%;
}
.preview-kicker {
margin: 0 0 10px;
color: #16817e;
font-size: 12px;
letter-spacing: 0;
text-transform: uppercase;
}
.preview-name {
margin: 22px 0 10px;
font-size: 28px;
font-weight: 700;
}
.preview-copy {
margin: 0 auto 24px;
max-width: 520px;
color: #475569;
line-height: 1.8;
}
.preview-meta {
display: grid;
gap: 8px;
color: #64748b;
font-size: 13px;
}
@media (max-width: 640px) {
.query-panel {
padding: 22px;
}
.card-top,
.card-actions {
display: grid;
justify-content: stretch;
}
.meta {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,168 @@
<template>
<main class="public-page">
<section class="certificate-page">
<p class="kicker">电子证书</p>
<h1>证书公开展示</h1>
<el-alert v-if="message" class="notice" :title="message" :type="messageType" :closable="false" />
<article v-if="certificate" class="certificate-card">
<span :class="['status', certificate.status === 'valid' ? 'ok' : 'bad']">{{ statusText(certificate.status) }}</span>
<h2>{{ certificate.certificate_name }}</h2>
<p class="name">{{ certificate.learner_name }}</p>
<p class="copy">
已完成 {{ certificate.course_name || certificate.project_code }} {{ certificate.stage_name || "" }} 相关培训学习
</p>
<dl class="meta">
<div>
<dt>证书编号</dt>
<dd>{{ certificate.certificate_no }}</dd>
</div>
<div>
<dt>发证日期</dt>
<dd>{{ certificate.issue_date }}</dd>
</div>
<div>
<dt>发证单位</dt>
<dd>{{ certificate.issuer_name }}</dd>
</div>
</dl>
<el-button v-if="certificate.can_download_pdf" type="primary" class="download" @click="downloadPdf">下载 PDF 证书</el-button>
</article>
</section>
</main>
</template>
<script setup lang="ts">
import { onMounted, ref } from "vue";
import { useRoute } from "vue-router";
import { http, type PublicCertificate } from "../api";
const route = useRoute();
const token = String(route.params.token);
const certificate = ref<PublicCertificate | null>(null);
const message = ref("");
const messageType = ref<"success" | "warning" | "error">("success");
function statusText(status: string) {
return status === "valid" ? "有效" : "已作废";
}
async function loadCertificate() {
try {
const { data } = await http.get<PublicCertificate>(`/public/certificates/token/${token}`);
certificate.value = data;
message.value = data.status === "valid" ? "该证书真实有效。" : "该证书已作废,请联系发证单位核实。";
messageType.value = data.status === "valid" ? "success" : "warning";
} catch (error: any) {
message.value = error?.response?.data?.detail || "链接无效或已失效";
messageType.value = "error";
}
}
function downloadPdf() {
window.location.href = `/api/public/certificates/token/${token}/download`;
}
onMounted(loadCertificate);
</script>
<style scoped>
.public-page {
min-height: 100vh;
background: #f7fafc;
padding: 36px 20px;
}
.certificate-page {
max-width: 760px;
margin: 0 auto;
background: #ffffff;
border: 1px solid #e1ebef;
border-radius: 12px;
padding: 30px;
box-shadow: 0 16px 46px rgba(76, 102, 118, 0.08);
}
.kicker {
margin: 0 0 6px;
color: #208a87;
font-size: 13px;
font-weight: 700;
}
h1,
h2 {
margin: 0;
}
h1 {
font-size: 28px;
}
.notice {
margin-top: 20px;
}
.certificate-card {
margin-top: 20px;
border: 1px solid #dfe9ec;
border-radius: 10px;
padding: 34px;
text-align: center;
background: #fbfefe;
}
.status {
display: inline-flex;
height: 24px;
align-items: center;
border-radius: 999px;
padding: 0 9px;
margin-bottom: 16px;
font-size: 12px;
}
.status.ok {
color: #116d6b;
background: #dff5f3;
}
.status.bad {
color: #991b1b;
background: #fee2e2;
}
.name {
margin: 24px 0 10px;
font-size: 30px;
font-weight: 700;
}
.copy {
max-width: 560px;
margin: 0 auto 24px;
color: #475569;
line-height: 1.8;
}
.meta {
display: grid;
gap: 10px;
margin: 0;
color: #64748b;
}
.meta dt {
font-size: 12px;
}
.meta dd {
margin: 3px 0 0;
color: #172033;
}
.download {
margin-top: 24px;
}
</style>

View File

@@ -0,0 +1,69 @@
<template>
<main class="public-page">
<section class="verify-page">
<h1>证书核验</h1>
<el-alert :title="message" :type="messageType" :closable="false" />
<el-descriptions v-if="certificate" :column="1" border class="details">
<el-descriptions-item label="核验结果">{{ certificate.status === "valid" ? "该证书真实有效" : "该证书已作废" }}</el-descriptions-item>
<el-descriptions-item label="证书状态">{{ certificate.status }}</el-descriptions-item>
<el-descriptions-item label="证书编号">{{ certificate.certificate_no }}</el-descriptions-item>
<el-descriptions-item label="学员姓名">{{ certificate.learner_name }}</el-descriptions-item>
<el-descriptions-item label="证书名称">{{ certificate.certificate_name }}</el-descriptions-item>
<el-descriptions-item label="发证单位">{{ certificate.issuer_name }}</el-descriptions-item>
</el-descriptions>
</section>
</main>
</template>
<script setup lang="ts">
import { onMounted, ref } from "vue";
import { useRoute } from "vue-router";
import { http, type PublicCertificate } from "../api";
const route = useRoute();
const token = String(route.params.token);
const certificate = ref<PublicCertificate | null>(null);
const message = ref("正在核验证书信息");
const messageType = ref<"success" | "warning" | "error" | "info">("info");
async function loadCertificate() {
try {
const { data } = await http.get<PublicCertificate>(`/public/certificates/token/${token}`);
certificate.value = data;
message.value = data.status === "valid" ? "该证书真实有效。" : "该证书已作废,请联系发证单位核实。";
messageType.value = data.status === "valid" ? "success" : "warning";
} catch (error: any) {
message.value = error?.response?.data?.detail || "链接无效或已失效";
messageType.value = "error";
}
}
onMounted(loadCertificate);
</script>
<style scoped>
.public-page {
min-height: 100vh;
background: #f7f8fb;
padding: 32px;
}
.verify-page {
max-width: 680px;
margin: 0 auto;
background: #ffffff;
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 28px;
}
h1 {
margin: 0 0 20px;
font-size: 24px;
}
.details {
margin-top: 20px;
}
</style>

16
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"jsx": "preserve",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"types": []
},
"include": ["src/**/*.ts", "src/**/*.vue"]
}

16
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,16 @@
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
export default defineConfig({
plugins: [vue()],
server: {
port: 5173,
proxy: {
"/api": {
target: "http://localhost:8000",
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, "")
}
}
}
});

980
local_app/server.py Normal file
View File

@@ -0,0 +1,980 @@
import base64
import hashlib
import hmac
import io
import json
import secrets
import sqlite3
import time
import traceback
from datetime import date, datetime
from datetime import timedelta
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import parse_qs, unquote, urlparse
from openpyxl import Workbook, load_workbook
from PIL import Image, ImageDraw, ImageFont
from reportlab.lib.pagesizes import A4, landscape
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont
from reportlab.pdfgen import canvas
ROOT = Path(__file__).resolve().parent
DATA = ROOT / "data"
DB_PATH = DATA / "local.db"
SECRET = "local-dev-secret"
ALPHABET = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ"
CAPTCHAS: dict[str, tuple[str, float]] = {}
LOG_RETENTION_DAYS = 180
HIGH_RISK_LOG_RETENTION_DAYS = 365
COL_NAME = "\u59d3\u540d"
COL_PHONE = "\u624b\u673a\u53f7"
COL_PROJECT = "\u9879\u76ee\u4ee3\u7801"
COL_ISSUE_DATE = "\u53d1\u8bc1\u65e5\u671f"
COLS = [COL_NAME, COL_PHONE, COL_PROJECT, COL_ISSUE_DATE]
DISCLAIMER = "\u672c\u8bc1\u4e66\u4ec5\u7528\u4e8e\u8bc1\u660e\u5b66\u5458\u5b8c\u6210\u76f8\u5173\u57f9\u8bad\u8bfe\u7a0b/\u9636\u6bb5\u5b66\u4e60\uff0c\u4e0d\u4ee3\u8868\u56fd\u5bb6\u5b66\u5386\u3001\u5b66\u4f4d\u3001\u804c\u4e1a\u8d44\u683c\u6216\u804c\u4e1a\u6280\u80fd\u7b49\u7ea7\u8ba4\u8bc1\u3002"
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",
"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 db() -> sqlite3.Connection:
DATA.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
return conn
def init_db() -> None:
with db() as conn:
conn.executescript(
"""
create table if not exists admins(id integer primary key, username text unique, password_hash text);
create table if not exists projects(
id integer primary key, code text unique, name text,
default_certificate_name text, default_course_name text, default_stage_name text, default_issuer_name text,
status text
);
create table if not exists learners(id integer primary key, phone text unique, current_name text, student_no text, status text, remark text, created_at text, updated_at text);
create table if not exists certificates(
id integer primary key, learner_id integer, project_code text, certificate_no text unique, certificate_name text,
course_name text, stage_name text, issue_date text, issuer_name text, status text, pdf_status text,
public_token text, qr_token text, remark text, created_at text, updated_at text
);
create table if not exists import_batches(id integer primary key, filename text, status text, total_rows integer, valid_rows integer, failed_rows integer, error_report_path text, created_at text, updated_at text);
create table if not exists logs(id integer primary key, admin_user_id integer, action text, object_type text, object_id text, detail_json text, created_at text);
"""
)
if not conn.execute("select 1 from admins where username='admin'").fetchone():
conn.execute("insert into admins(username,password_hash) values(?,?)", ("admin", password_hash("Admin123!")))
ensure_project_columns(conn)
for code, name in [("DBY", "\u5927\u672c\u8425"), ("QSX", "\u4e03\u4e09\u7ebf")]:
if not conn.execute("select 1 from projects where code=?", (code,)).fetchone():
conn.execute(
"insert into projects(code,name,default_certificate_name,default_course_name,default_stage_name,default_issuer_name,status) values(?,?,?,?,?,?,?)",
(code, name, name, name, None, "\u672c\u516c\u53f8", "active"),
)
conn.execute("update projects set default_certificate_name=name, default_course_name=coalesce(default_course_name,name), default_issuer_name=coalesce(default_issuer_name,?)", ("\u672c\u516c\u53f8",))
cleanup_expired_logs(conn)
def ensure_project_columns(conn: sqlite3.Connection) -> None:
columns = {row["name"] for row in conn.execute("pragma table_info(projects)").fetchall()}
for name in ["default_certificate_name", "default_course_name", "default_stage_name", "default_issuer_name"]:
if name not in columns:
conn.execute(f"alter table projects add column {name} text")
def password_hash(password: str) -> str:
salt = secrets.token_hex(8)
digest = hashlib.pbkdf2_hmac("sha256", password.encode(), salt.encode(), 120_000).hex()
return f"{salt}${digest}"
def check_password(password: str, stored: str) -> bool:
salt, digest = stored.split("$", 1)
actual = hashlib.pbkdf2_hmac("sha256", password.encode(), salt.encode(), 120_000).hex()
return hmac.compare_digest(actual, digest)
def make_token(admin_id: int) -> str:
payload = f"{admin_id}:{int(time.time()) + 86400}:{secrets.token_hex(8)}"
sig = hmac.new(SECRET.encode(), payload.encode(), hashlib.sha256).hexdigest()
return base64.urlsafe_b64encode(f"{payload}:{sig}".encode()).decode()
def token_admin_id(token: str) -> int | None:
try:
raw = base64.urlsafe_b64decode(token.encode()).decode()
admin_id, exp, nonce, sig = raw.split(":", 3)
payload = f"{admin_id}:{exp}:{nonce}"
expected = hmac.new(SECRET.encode(), payload.encode(), hashlib.sha256).hexdigest()
if hmac.compare_digest(expected, sig) and int(exp) > time.time():
return int(admin_id)
except Exception:
return None
return None
def now() -> str:
return datetime.now().isoformat(timespec="seconds")
def log(conn: sqlite3.Connection, admin_id: int | None, action: str, object_type: str, object_id: object = None, detail: dict | None = None) -> None:
conn.execute(
"insert into logs(admin_user_id,action,object_type,object_id,detail_json,created_at) values(?,?,?,?,?,?)",
(admin_id, action, object_type, str(object_id) if object_id is not None else None, json.dumps(detail or {}, ensure_ascii=False), now()),
)
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_detail(before: sqlite3.Row | None, after: dict, fields: list[str]) -> dict[str, dict[str, object]]:
changes: dict[str, dict[str, object]] = {}
for field in fields:
old_value = before[field] if before else None
new_value = after.get(field, old_value)
if old_value != new_value:
changes[field] = {"before": old_value, "after": new_value}
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_row(row: sqlite3.Row) -> dict:
detail: dict[str, object] = {}
if row["detail_json"]:
try:
detail = json.loads(row["detail_json"])
except Exception:
detail = {"raw": row["detail_json"]}
risk = log_risk(row["action"])
payload = row_dict(row)
payload.update({
"action_label": ACTION_LABELS.get(row["action"], row["action"]),
"object_label": OBJECT_LABELS.get(row["object_type"], row["object_type"]),
"risk": risk,
"risk_label": log_risk_text(risk),
"summary": log_summary(row["action"], row["object_type"], row["object_id"], detail),
})
return payload
def filtered_operation_logs(conn: sqlite3.Connection, query: dict[str, list[str]]) -> list[dict]:
action = (query.get("action") or [""])[0]
object_type = (query.get("object_type") or [""])[0]
risk = (query.get("risk") or [""])[0]
keyword = (query.get("keyword") or [""])[0].strip().lower()
items = [format_log_row(row) for row in conn.execute("select * from logs order by id desc limit 500").fetchall()]
if action:
items = [item for item in items if item["action"] == action]
if object_type:
items = [item for item in items if item["object_type"] == object_type]
if risk:
items = [item for item in items if item["risk"] == risk]
if keyword:
items = [
item for item in items
if keyword in " ".join(str(item.get(key, "")) for key in ["object_id", "summary", "detail_json", "action_label", "object_label"]).lower()
]
return items
def list_operation_logs(conn: sqlite3.Connection, query: dict[str, list[str]]) -> dict[str, object]:
items = filtered_operation_logs(conn, query)
page = max(1, int((query.get("page") or ["1"])[0] or "1"))
page_size = max(1, min(100, int((query.get("page_size") or ["20"])[0] or "20")))
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,
}
def cleanup_expired_logs(conn: sqlite3.Connection) -> int:
normal_before = (datetime.now() - timedelta(days=LOG_RETENTION_DAYS)).isoformat(timespec="seconds")
high_before = (datetime.now() - timedelta(days=HIGH_RISK_LOG_RETENTION_DAYS)).isoformat(timespec="seconds")
high_actions = tuple(HIGH_RISK_ACTIONS)
normal_sql = f"delete from logs where created_at < ? and action not in ({','.join('?' for _ in high_actions)})"
cur = conn.execute(normal_sql, (normal_before, *high_actions))
removed = cur.rowcount if cur.rowcount != -1 else 0
high_sql = f"delete from logs where created_at < ? and action in ({','.join('?' for _ in high_actions)})"
cur = conn.execute(high_sql, (high_before, *high_actions))
removed += cur.rowcount if cur.rowcount != -1 else 0
return removed
def row_dict(row: sqlite3.Row | None) -> dict | None:
if row is None:
return None
return {key: row[key] for key in row.keys()}
def rows(conn: sqlite3.Connection, sql: str, args: tuple = ()) -> list[dict]:
return [row_dict(row) for row in conn.execute(sql, args).fetchall()]
def digest_int(text: str) -> int:
return int.from_bytes(hashlib.sha256(text.encode()).digest()[:8], "big")
def base_code(value: int, length: int) -> str:
chars = []
while value:
value, rem = divmod(value, len(ALPHABET))
chars.append(ALPHABET[rem])
return ("".join(reversed(chars)) or ALPHABET[0])[-length:].rjust(length, ALPHABET[0])
def certificate_no(cert_id: int, project: str, issue_date: str) -> str:
year = issue_date[:4] if issue_date else str(date.today().year)
seed = f"{SECRET}:{year}:{project}:{cert_id}"
short = base_code(digest_int(seed), 7)
check = base_code(digest_int(f"check:{seed}:{short}"), 2)
return f"PX{year}-{project}-{short}-{check}"
def json_bytes(value) -> bytes:
return json.dumps(value, ensure_ascii=False, default=str).encode("utf-8")
def make_captcha() -> dict:
code = "".join(secrets.choice("ABCDEFGHJKLMNPQRSTUVWXYZ23456789") for _ in range(4))
captcha_id = secrets.token_urlsafe(12)
CAPTCHAS[captcha_id] = (code, time.time() + 300)
image = Image.new("RGB", (132, 44), "#f8fafc")
draw = ImageDraw.Draw(image)
font = ImageFont.load_default()
for i, ch in enumerate(code):
draw.text((18 + i * 26, 14), ch, fill="#111827", font=font)
buf = io.BytesIO()
image.save(buf, "PNG")
return {"captcha_id": captcha_id, "image": "data:image/png;base64," + base64.b64encode(buf.getvalue()).decode()}
def verify_captcha(captcha_id: str, code: str) -> bool:
record = CAPTCHAS.pop(captcha_id, None)
return bool(record and record[1] > time.time() and record[0].upper() == code.strip().upper())
def parse_multipart(body: bytes, content_type: str) -> tuple[str, bytes]:
boundary = content_type.split("boundary=", 1)[1].encode()
parts = body.split(b"--" + boundary)
for part in parts:
if b"filename=" not in part:
continue
head, data = part.split(b"\r\n\r\n", 1)
filename = head.split(b'filename="', 1)[1].split(b'"', 1)[0].decode(errors="ignore")
return filename, data.rsplit(b"\r\n", 1)[0]
raise ValueError("file not found")
def public_payload(conn: sqlite3.Connection, cert: sqlite3.Row) -> dict:
learner = conn.execute("select * from learners where id=?", (cert["learner_id"],)).fetchone()
return {
"certificate_no": cert["certificate_no"],
"learner_name": learner["current_name"] if learner else "",
"certificate_name": cert["certificate_name"],
"project_code": cert["project_code"],
"course_name": cert["course_name"],
"stage_name": cert["stage_name"],
"issue_date": cert["issue_date"],
"issuer_name": cert["issuer_name"],
"status": cert["status"],
"pdf_status": cert["pdf_status"],
"public_token": cert["public_token"],
"public_url": f"/cert/{cert['public_token']}",
"download_url": f"/api/public/certificates/token/{cert['public_token']}/download" if cert["status"] == "valid" else None,
"can_download_pdf": cert["status"] == "valid",
"disclaimer": DISCLAIMER,
}
def font_name() -> str:
for path in [r"C:\Windows\Fonts\msyh.ttc", r"C:\Windows\Fonts\simsun.ttc"]:
if Path(path).exists():
try:
pdfmetrics.registerFont(TTFont("LocalCJK", path))
return "LocalCJK"
except Exception:
pass
return "Helvetica"
def pil_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: str) -> tuple[str, str, str]:
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
if len(raw) >= 10 and raw[0:4].isdigit():
return raw[0:4], raw[5:7].lstrip("0") or raw[5:7], raw[8:10].lstrip("0") or raw[8:10]
today = date.today()
return str(today.year), str(today.month), str(today.day)
def draw_fit(draw: ImageDraw.ImageDraw, pos: tuple[int, int], text: str, font: ImageFont.ImageFont, fill: str, max_width: int) -> None:
font_obj = font
size = getattr(font, "size", 24)
while draw.textlength(text, font=font_obj) > max_width and size > 12:
size -= 1
font_obj = pil_font(size, "song", True)
draw.text(pos, text, fill=fill, font=font_obj)
def draw_inline(draw: ImageDraw.ImageDraw, x: float, y: int, text: str, size: int, gap: int, kind: str = "song", bold: bool = False) -> float:
font = pil_font(size, kind, bold)
draw.text((x, y), text, fill="#111111", font=font, anchor="ls")
if bold:
draw.text((x + 0.45, y), text, fill="#111111", font=font, anchor="ls")
return x + draw.textlength(text, font=font) + gap
def draw_course_name(draw: ImageDraw.ImageDraw, text: str, x: float, y: int) -> int:
max_width = 900 - x
text_font = pil_font(27, "kai", True)
if draw.textlength(text, font=text_font) <= max_width:
draw.text((x, y), text, fill="#111111", font=text_font)
draw.text((x + 0.45, y), text, fill="#111111", font=text_font)
return y
start_y = y + 34
lines = split_by_width(draw, text, 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 text, style, gap_before in segments:
x += gap_before
text_font = pil_font(27, "kai", True) if style == "course" else pil_font(24, "song", False)
for char in text:
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, text: str, text_font: ImageFont.ImageFont, max_width: int) -> list[str]:
lines: list[str] = []
line = ""
for char in text:
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 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")
patch = Image.blend(patch, cover, 0.38)
image.paste(patch, (x, y))
def render_certificate_image(conn: sqlite3.Connection, cert: sqlite3.Row) -> Image.Image:
template = ROOT / "static" / "assets" / "certificate-template.jpg"
image = Image.open(template).convert("RGB")
base_image = image.copy()
draw = ImageDraw.Draw(image)
learner = conn.execute("select * from learners where id=?", (cert["learner_id"],)).fetchone()
learner_name = (learner["current_name"] if learner else "") or ""
issue_year, issue_month, issue_day = date_parts(cert["issue_date"])
for box in [(150, 442, 760, 132), (404, 675, 220, 38)]:
paper_patch(image, box)
draw.text((512, 414), learner_name, fill="#111111", font=pil_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)
course_text = f"\u201c{cert['course_name'] or cert['certificate_name'] or cert['project_code']}\u201d"
stage = cert["stage_name"] or "\u521d\u7ea7\u8bfe\u7a0b\u7684\u4e13\u4e1a\u5b66\u4e60\u3002"
draw_inline_flow(draw, [(course_text, "course", 0), (stage, "normal", 14)], x, y)
issue_label = f"\u53d1\u8bc1\u65e5\u671f\uff1a{issue_year} \u5e74 {issue_month} \u6708 {issue_day} \u65e5"
draw.text((512, 704), issue_label, fill="#43382f", font=pil_font(13, "song", True), anchor="mm")
draw.text((105, 76), f"\u8bc1\u4e66\u7f16\u53f7\uff1a{cert['certificate_no']}", fill="#111827", font=pil_font(14, "hei", True))
image.paste(base_image.crop((720, 535, 950, 629)), (720, 535))
return image
def build_pdf(conn: sqlite3.Connection, cert: sqlite3.Row) -> Path:
pdf_dir = DATA / "pdf-cache"
pdf_dir.mkdir(exist_ok=True)
path = pdf_dir / f"{cert['certificate_no']}.pdf"
template = ROOT / "static" / "assets" / "certificate-template.jpg"
renderer_mtime = Path(__file__).stat().st_mtime
if path.exists() and path.stat().st_mtime >= max(template.stat().st_mtime, renderer_mtime):
return path
image = render_certificate_image(conn, cert)
image.save(path, "PDF", resolution=150.0)
conn.execute("update certificates set pdf_status=? where id=?", ("generated", cert["id"]))
return path
def validate_import(conn: sqlite3.Connection, file_path: Path, filename: str) -> dict:
wb = load_workbook(file_path, data_only=True)
ws = wb.active
header = [cell.value for cell in ws[1]]
valid_rows = []
failed = 0
for row in ws.iter_rows(min_row=2, values_only=True):
if not any(row):
continue
item = dict(zip(header, row))
missing = [c for c in COLS if not item.get(c)]
project = conn.execute("select 1 from projects where code=? and status='active'", (str(item.get(COL_PROJECT, "")).upper(),)).fetchone()
if missing or not project:
failed += 1
else:
valid_rows.append(item)
cur = conn.execute(
"insert into import_batches(filename,status,total_rows,valid_rows,failed_rows,error_report_path,created_at,updated_at) values(?,?,?,?,?,?,?,?)",
(filename, "validated", len(valid_rows) + failed, len(valid_rows), failed, None, now(), now()),
)
batch_id = cur.lastrowid
(DATA / f"batch-{batch_id}.json").write_text(json.dumps(valid_rows, ensure_ascii=False, default=str), encoding="utf-8")
return row_dict(conn.execute("select * from import_batches where id=?", (batch_id,)).fetchone())
def import_batch(conn: sqlite3.Connection, batch_id: int, admin_id: int):
batch = conn.execute("select * from import_batches where id=?", (batch_id,)).fetchone()
if not batch:
raise ValueError("Import batch not found")
if batch["status"] == "imported":
return row_dict(batch)
if batch["status"] != "validated":
raise ValueError("Import batch is not ready")
rows_data = json.loads((DATA / f"batch-{batch_id}.json").read_text(encoding="utf-8"))
ok_rows = 0
failed_rows = 0
for item in rows_data:
phone = str(item[COL_PHONE])
learner = conn.execute("select * from learners where phone=?", (phone,)).fetchone()
if learner:
learner_id = learner["id"]
conn.execute("update learners set current_name=?,updated_at=? where id=?", (item[COL_NAME], now(), learner_id))
else:
cur = conn.execute(
"insert into learners(phone,current_name,status,created_at,updated_at) values(?,?,?,?,?)",
(phone, item[COL_NAME], "active", now(), now()),
)
learner_id = cur.lastrowid
issue = str(item[COL_ISSUE_DATE])[:10].replace("/", "-")
project = conn.execute("select * from projects where code=? and status='active'", (str(item[COL_PROJECT]).upper(),)).fetchone()
if not project:
failed_rows += 1
continue
duplicate = conn.execute(
"""
select 1 from certificates
where learner_id=?
and project_code=?
and certificate_name=?
and coalesce(course_name,'')=?
and coalesce(stage_name,'')=?
and issue_date=?
""",
(
learner_id,
project["code"],
project["name"],
project["default_course_name"] or "",
project["default_stage_name"] or "",
issue,
),
).fetchone()
if duplicate:
ok_rows += 1
continue
cur = conn.execute(
"insert into certificates(learner_id,project_code,certificate_no,certificate_name,course_name,stage_name,issue_date,issuer_name,status,pdf_status,public_token,qr_token,remark,created_at,updated_at) values(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
(learner_id, project["code"], "PENDING", project["name"], project["default_course_name"], project["default_stage_name"], issue, project["default_issuer_name"], "valid", "not_generated", secrets.token_urlsafe(24), secrets.token_urlsafe(24), None, now(), now()),
)
cert_id = cur.lastrowid
conn.execute("update certificates set certificate_no=? where id=?", (certificate_no(cert_id, str(item[COL_PROJECT]).upper(), issue), cert_id))
ok_rows += 1
next_status = "imported" if ok_rows else "failed"
conn.execute("update import_batches set status=?,failed_rows=?,updated_at=? where id=?", (next_status, failed_rows or batch["failed_rows"], now(), batch_id))
log(conn, admin_id, "confirm_import_batch", "import_batch", batch_id, {"filename": batch["filename"], "imported_rows": ok_rows, "failed_rows": failed_rows})
return row_dict(conn.execute("select * from import_batches where id=?", (batch_id,)).fetchone())
def export_certs(conn: sqlite3.Connection) -> bytes:
wb = Workbook()
ws = wb.active
ws.append([COL_NAME, COL_PHONE, "\u8bc1\u4e66\u7f16\u53f7", "\u8bc1\u4e66\u5bf9\u5916\u76f4\u8fbe\u94fe\u63a5", COL_PROJECT, "\u8bc1\u4e66\u540d\u79f0", COL_ISSUE_DATE, "\u8bc1\u4e66\u72b6\u6001"])
certs = conn.execute("select c.*,l.current_name,l.phone from certificates c join learners l on l.id=c.learner_id order by c.id desc").fetchall()
for row in certs:
phone = row["phone"] or ""
ws.append([row["current_name"], phone[:3] + "****" + phone[-4:] if len(phone) >= 7 else phone, row["certificate_no"], f"http://localhost:8000/cert/{row['public_token']}", row["project_code"], row["certificate_name"], row["issue_date"], row["status"]])
buf = io.BytesIO()
wb.save(buf)
return buf.getvalue()
def export_logs(items: list[dict]) -> bytes:
wb = Workbook()
ws = wb.active
ws.title = "\u64cd\u4f5c\u65e5\u5fd7"
ws.append(["\u65f6\u95f4", "\u64cd\u4f5c\u4ebaID", "\u64cd\u4f5c", "\u5bf9\u8c61", "\u5bf9\u8c61ID", "\u98ce\u9669\u7b49\u7ea7", "\u6458\u8981", "\u8be6\u60c5"])
for item in items:
ws.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":
ws.column_dimensions[column].width = 22 if column != "H" else 60
buf = io.BytesIO()
wb.save(buf)
return buf.getvalue()
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
self.route()
def do_POST(self):
self.route()
def do_PUT(self):
self.route()
def do_DELETE(self):
self.route()
def route(self):
parsed = urlparse(self.path)
path = parsed.path
try:
if path.startswith("/api/"):
return self.handle_api(path, parse_qs(parsed.query))
if path in ["/", "/admin", "/admin/login", "/query"] or path.startswith("/cert/") or path.startswith("/verify/"):
return self.send_file(ROOT / "static" / "index.html", "text/html; charset=utf-8")
target = ROOT / "static" / path.lstrip("/")
if target.exists():
return self.send_file(target, "application/octet-stream")
return self.error(HTTPStatus.NOT_FOUND, "Not found")
except Exception as exc:
(DATA / "last-error.log").write_text(traceback.format_exc(), encoding="utf-8")
return self.error(HTTPStatus.INTERNAL_SERVER_ERROR, str(exc))
def handle_api(self, path: str, query: dict):
body = self.read_body()
admin_id = self.admin_id()
with db() as conn:
if path == "/api/health":
return self.json({"status": "ok"})
if path == "/api/admin/auth/login":
data = json.loads(body or b"{}")
admin = conn.execute("select * from admins where username=?", (data.get("username"),)).fetchone()
if not admin or not check_password(data.get("password", ""), admin["password_hash"]):
return self.error(401, "\u8d26\u53f7\u6216\u5bc6\u7801\u9519\u8bef\uff0c\u8bf7\u91cd\u8bd5")
return self.json({"access_token": make_token(admin["id"]), "token_type": "bearer", "username": admin["username"], "role_code": "system_admin"})
if path == "/api/public/captcha":
return self.json(make_captcha())
if path == "/api/public/certificates/search":
data = json.loads(body or b"{}")
if not verify_captcha(data.get("captcha_id", ""), data.get("captcha_code", "")):
return self.error(400, "\u9a8c\u8bc1\u7801\u9519\u8bef\u6216\u5df2\u8fc7\u671f\uff0c\u8bf7\u91cd\u65b0\u8f93\u5165")
name = data.get("name", "").strip()
query_certificate_no = data.get("certificate_no", "").strip().upper()
phone = data.get("phone", "").strip()
if not name or (not query_certificate_no and not phone):
return self.error(400, "\u8bf7\u8f93\u5165\u59d3\u540d\uff0c\u5e76\u586b\u5199\u8bc1\u4e66\u7f16\u53f7\u6216\u624b\u673a\u53f7\u5176\u4e2d\u4e00\u9879")
if query_certificate_no:
result = rows(conn,
"select c.* from certificates c join learners l on l.id=c.learner_id where c.certificate_no=? and l.current_name=?",
(query_certificate_no, name),
)
else:
result = rows(conn,
"select c.* from certificates c join learners l on l.id=c.learner_id where l.phone=? and l.current_name=? order by c.issue_date desc,c.id desc",
(phone, name),
)
if not result:
log(conn, None, "public_search_certificate", "public_access", None, {"mode": "certificate_no" if query_certificate_no else "phone", "name": name, "certificate_no": query_certificate_no or None, "phone": mask_phone(phone), "matched_count": 0, "result": "not_found"})
return self.error(404, "\u672a\u67e5\u8be2\u5230\u5339\u914d\u7684\u6709\u6548\u8bc1\u4e66\u4fe1\u606f\uff0c\u8bf7\u6838\u5bf9\u540e\u91cd\u8bd5")
log(conn, None, "public_search_certificate", "public_access", result[0]["certificate_no"], {"mode": "certificate_no" if query_certificate_no else "phone", "name": name, "certificate_no": query_certificate_no or None, "phone": mask_phone(phone), "matched_count": len(result), "result": "matched"})
return self.json({"items": [public_payload(conn, item) for item in result]})
if path.startswith("/api/public/certificates/token/"):
token = unquote(path.split("/")[5])
cert = conn.execute("select * from certificates where public_token=? or qr_token=?", (token, token)).fetchone()
if not cert:
return self.error(404, "\u94fe\u63a5\u65e0\u6548\u6216\u5df2\u5931\u6548")
if path.endswith("/download"):
if cert["status"] != "valid":
return self.error(403, "\u8bc1\u4e66\u5df2\u4f5c\u5e9f\uff0c\u4e0d\u652f\u6301\u4e0b\u8f7d\u6b63\u5e38 PDF")
learner = conn.execute("select * from learners where id=?", (cert["learner_id"],)).fetchone()
log(conn, None, "public_download_certificate", "certificate", cert["id"], {"certificate_no": cert["certificate_no"], "learner_name": learner["current_name"] if learner else "", "project_code": cert["project_code"]})
return self.send_file(build_pdf(conn, cert), "application/pdf", f"{cert['certificate_no']}.pdf")
return self.json(public_payload(conn, cert))
if not admin_id:
return self.error(401, "Not authenticated")
if path == "/api/admin/dashboard/summary":
return self.json({
"learner_count": conn.execute("select count(*) from learners where status!='deleted'").fetchone()[0],
"certificate_count": conn.execute("select count(*) from certificates").fetchone()[0],
"valid_certificate_count": conn.execute("select count(*) from certificates where status='valid'").fetchone()[0],
"voided_certificate_count": conn.execute("select count(*) from certificates where status='voided'").fetchone()[0],
"recent_import_count": conn.execute("select count(*) from import_batches").fetchone()[0],
})
if path == "/api/admin/projects" and self.command == "GET":
return self.json(rows(conn, "select * from projects order by code"))
if path == "/api/admin/projects" and self.command == "POST":
data = json.loads(body)
conn.execute(
"insert into projects(code,name,default_certificate_name,default_course_name,default_stage_name,default_issuer_name,status) values(?,?,?,?,?,?,?)",
(
data["code"].strip().upper(),
data["name"].strip(),
data["name"].strip(),
data.get("default_course_name"),
data.get("default_stage_name"),
data.get("default_issuer_name") or "\u672c\u516c\u53f8",
"active",
),
)
log(conn, admin_id, "create_project", "project", data["code"], {"code": data["code"].strip().upper(), "name": data["name"].strip(), "status": "active"})
return self.json({"ok": True}, 201)
if path.startswith("/api/admin/projects/") and self.command == "PUT":
project_id = int(path.rsplit("/", 1)[1])
data = json.loads(body)
project = conn.execute("select * from projects where id=?", (project_id,)).fetchone()
if not project:
return self.error(404, "Project not found")
next_data = {
"name": data.get("name", project["name"]),
"default_course_name": data.get("default_course_name", project["default_course_name"]),
"default_stage_name": data.get("default_stage_name", project["default_stage_name"]),
"default_issuer_name": data.get("default_issuer_name", project["default_issuer_name"]),
"status": data.get("status", project["status"]),
}
conn.execute(
"update projects set name=?,default_certificate_name=?,default_course_name=?,default_stage_name=?,default_issuer_name=?,status=? where id=?",
(
next_data["name"],
next_data["name"],
next_data["default_course_name"],
next_data["default_stage_name"],
next_data["default_issuer_name"],
next_data["status"],
project_id,
),
)
log(conn, admin_id, "update_project", "project", project_id, {"code": project["code"], "name": next_data["name"], "changes": diff_detail(project, next_data, ["name", "default_course_name", "default_stage_name", "default_issuer_name", "status"])})
return self.json(row_dict(conn.execute("select * from projects where id=?", (project_id,)).fetchone()))
if path == "/api/admin/learners" and self.command == "GET":
keyword = (query.get("keyword") or [""])[0]
if keyword:
return self.json(rows(conn, "select * from learners where status!='deleted' and (current_name like ? or phone like ? or student_no like ?) order by id desc", (f"%{keyword}%", f"%{keyword}%", f"%{keyword}%")))
return self.json(rows(conn, "select * from learners where status!='deleted' order by id desc limit 200"))
if path == "/api/admin/learners" and self.command == "POST":
data = json.loads(body)
cur = conn.execute(
"insert into learners(phone,current_name,student_no,status,remark,created_at,updated_at) values(?,?,?,?,?,?,?)",
(data["phone"].strip(), data["current_name"].strip(), data.get("student_no"), "active", data.get("remark"), now(), now()),
)
log(conn, admin_id, "create_learner", "learner", cur.lastrowid, {"name": data["current_name"].strip(), "phone": mask_phone(data["phone"]), "status": "active"})
return self.json(row_dict(conn.execute("select * from learners where id=?", (cur.lastrowid,)).fetchone()), 201)
if path.startswith("/api/admin/learners/"):
learner_id = int(path.rsplit("/", 1)[1])
if self.command == "GET":
learner = row_dict(conn.execute("select * from learners where id=?", (learner_id,)).fetchone())
return self.json(learner) if learner else self.error(404, "Learner not found")
if self.command == "PUT":
data = json.loads(body)
learner = conn.execute("select * from learners where id=?", (learner_id,)).fetchone()
if not learner:
return self.error(404, "Learner not found")
next_data = {
"phone": data.get("phone", learner["phone"]),
"current_name": data.get("current_name", learner["current_name"]),
"student_no": data.get("student_no", learner["student_no"]),
"status": data.get("status", learner["status"]),
"remark": data.get("remark", learner["remark"]),
}
conn.execute(
"update learners set phone=?,current_name=?,student_no=?,status=?,remark=?,updated_at=? where id=?",
(next_data["phone"], next_data["current_name"], next_data["student_no"], next_data["status"], next_data["remark"], now(), learner_id),
)
changes = diff_detail(learner, next_data, ["phone", "current_name", "student_no", "status", "remark"])
if "phone" in changes:
changes["phone"] = {"before": mask_phone(changes["phone"]["before"]), "after": mask_phone(changes["phone"]["after"])}
log(conn, admin_id, "update_learner", "learner", learner_id, {"name": next_data["current_name"], "phone": mask_phone(next_data["phone"]), "changes": changes})
return self.json(row_dict(conn.execute("select * from learners where id=?", (learner_id,)).fetchone()))
if self.command == "DELETE":
learner = conn.execute("select * from learners where id=?", (learner_id,)).fetchone()
conn.execute("update learners set status='deleted',updated_at=? where id=?", (now(), learner_id))
log(conn, admin_id, "delete_learner", "learner", learner_id, {"name": learner["current_name"] if learner else learner_id, "phone": mask_phone(learner["phone"] if learner else "")})
return self.json({"ok": True})
if path == "/api/admin/certificates" and self.command == "GET":
return self.json(rows(conn, "select c.*,l.current_name learner_name from certificates c left join learners l on l.id=c.learner_id order by c.id desc limit 200"))
if path == "/api/admin/certificates" and self.command == "POST":
data = json.loads(body)
project = conn.execute("select * from projects where code=? and status='active'", (data["project_code"].strip().upper(),)).fetchone()
if not project:
return self.error(400, "Project code is inactive or missing")
cur = conn.execute(
"insert into certificates(learner_id,project_code,certificate_no,certificate_name,course_name,stage_name,issue_date,issuer_name,status,pdf_status,public_token,qr_token,remark,created_at,updated_at) values(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
(data["learner_id"], project["code"], "PENDING", project["name"], data.get("course_name") or project["default_course_name"], data.get("stage_name") or project["default_stage_name"], data["issue_date"], data.get("issuer_name") or project["default_issuer_name"], "valid", "not_generated", secrets.token_urlsafe(24), secrets.token_urlsafe(24), data.get("remark"), now(), now()),
)
no = certificate_no(cur.lastrowid, project["code"], data["issue_date"])
conn.execute("update certificates set certificate_no=? where id=?", (no, cur.lastrowid))
learner = conn.execute("select * from learners where id=?", (data["learner_id"],)).fetchone()
log(conn, admin_id, "create_certificate", "certificate", cur.lastrowid, {"certificate_no": no, "learner_name": learner["current_name"] if learner else "", "project_code": project["code"], "issue_date": data["issue_date"]})
return self.json(row_dict(conn.execute("select * from certificates where id=?", (cur.lastrowid,)).fetchone()), 201)
if path.startswith("/api/admin/certificates/"):
parts = path.split("/")
cert_id = int(parts[4])
cert = conn.execute("select * from certificates where id=?", (cert_id,)).fetchone()
if not cert:
return self.error(404, "Certificate not found")
if self.command == "GET" and len(parts) == 5:
payload = row_dict(cert)
payload["public_url"] = f"http://127.0.0.1:8000/cert/{cert['public_token']}"
payload["verify_url"] = f"http://127.0.0.1:8000/verify/{cert['qr_token']}"
return self.json(payload)
if path.endswith("/preview"):
return self.json(public_payload(conn, cert))
if path.endswith("/download"):
if cert["status"] != "valid":
return self.error(403, "\u8bc1\u4e66\u5df2\u4f5c\u5e9f\uff0c\u4e0d\u652f\u6301\u4e0b\u8f7d\u6b63\u5e38 PDF")
return self.send_file(build_pdf(conn, cert), "application/pdf", f"{cert['certificate_no']}.pdf")
if path.endswith("/void"):
conn.execute("update certificates set status='voided',pdf_status='not_generated' where id=?", (cert_id,))
learner = conn.execute("select * from learners where id=?", (cert["learner_id"],)).fetchone()
log(conn, admin_id, "void_certificate", "certificate", cert_id, {"certificate_no": cert["certificate_no"], "learner_name": learner["current_name"] if learner else "", "previous_status": cert["status"], "status": "voided"})
return self.json(row_dict(conn.execute("select * from certificates where id=?", (cert_id,)).fetchone()))
if path.endswith("/token/regenerate"):
conn.execute("update certificates set public_token=? where id=?", (secrets.token_urlsafe(24), cert_id))
learner = conn.execute("select * from learners where id=?", (cert["learner_id"],)).fetchone()
log(conn, admin_id, "regenerate_public_token", "certificate", cert_id, {"certificate_no": cert["certificate_no"], "learner_name": learner["current_name"] if learner else ""})
return self.json(row_dict(conn.execute("select * from certificates where id=?", (cert_id,)).fetchone()))
if path == "/api/admin/import-batches/template":
wb = Workbook()
ws = wb.active
ws.append(COLS)
buf = io.BytesIO()
wb.save(buf)
return self.bytes(buf.getvalue(), "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "certificate-import-template.xlsx")
if path == "/api/admin/import-batches" and self.command == "GET":
return self.json(rows(conn, "select * from import_batches order by id desc"))
if path == "/api/admin/import-batches" and self.command == "POST":
filename, content = parse_multipart(body, self.headers.get("Content-Type", ""))
uploads = DATA / "uploads"
uploads.mkdir(exist_ok=True)
file_path = uploads / filename
file_path.write_bytes(content)
batch = validate_import(conn, file_path, filename)
log(conn, admin_id, "upload_import_file", "import_batch", batch["id"], {"filename": filename, "total_rows": batch["total_rows"], "valid_rows": batch["valid_rows"], "failed_rows": batch["failed_rows"], "status": batch["status"]})
return self.json(batch, 201)
if path.endswith("/confirm") and path.startswith("/api/admin/import-batches/"):
batch_id = int(path.split("/")[4])
return self.json(import_batch(conn, batch_id, admin_id))
if path.endswith("/file") and path.startswith("/api/admin/import-batches/"):
batch_id = int(path.split("/")[4])
batch = conn.execute("select * from import_batches where id=?", (batch_id,)).fetchone()
if not batch:
return self.error(404, "Import batch not found")
file_path = DATA / "uploads" / batch["filename"]
if not file_path.exists():
return self.error(404, "\u539f\u59cb\u6587\u4ef6\u4e0d\u5b58\u5728")
return self.send_file(file_path, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", batch["filename"])
if path.startswith("/api/admin/import-batches/") and self.command == "DELETE":
batch_id = int(path.rsplit("/", 1)[1])
batch = conn.execute("select * from import_batches where id=?", (batch_id,)).fetchone()
if not batch:
return self.error(404, "Import batch not found")
for file_path in [DATA / "uploads" / batch["filename"], DATA / f"batch-{batch_id}.json"]:
if file_path.exists():
file_path.unlink()
conn.execute("delete from import_batches where id=?", (batch_id,))
log(conn, admin_id, "delete_import_batch", "import_batch", batch_id, {"filename": batch["filename"], "status": batch["status"], "total_rows": batch["total_rows"]})
return self.json({"ok": True})
if path == "/api/admin/exports/certificates":
return self.bytes(export_certs(conn), "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "certificates-export.xlsx")
if path == "/api/admin/logs/export":
return self.bytes(export_logs(filtered_operation_logs(conn, query)), "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "operation-logs.xlsx")
if path == "/api/admin/logs/cleanup" and self.command == "POST":
removed = cleanup_expired_logs(conn)
return self.json({"removed": removed, "normal_retention_days": LOG_RETENTION_DAYS, "high_risk_retention_days": HIGH_RISK_LOG_RETENTION_DAYS})
if path == "/api/admin/logs":
cleanup_expired_logs(conn)
return self.json(list_operation_logs(conn, query))
return self.error(404, "Not found")
def admin_id(self) -> int | None:
auth = self.headers.get("Authorization", "")
return token_admin_id(auth[7:]) if auth.startswith("Bearer ") else None
def read_body(self) -> bytes:
return self.rfile.read(int(self.headers.get("Content-Length", "0") or "0"))
def json(self, value, status_code=200):
self.bytes(json_bytes(value), "application/json; charset=utf-8", status_code=status_code)
def bytes(self, data: bytes, content_type: str, filename: str | None = None, status_code=200):
self.send_response(status_code)
self.send_header("Content-Type", content_type)
self.send_header("Cache-Control", "no-store")
if filename:
self.send_header("Content-Disposition", f'attachment; filename="{filename}"')
self.end_headers()
self.wfile.write(data)
def send_file(self, path: Path, content_type: str, filename: str | None = None):
self.bytes(path.read_bytes(), content_type, filename)
def error(self, status_code, message):
self.bytes(json_bytes({"detail": str(message)}), "application/json; charset=utf-8", status_code=status_code)
def main():
init_db()
server = ThreadingHTTPServer(("127.0.0.1", 8000), Handler)
print("Local app running at http://127.0.0.1:8000")
print("Admin: admin / Admin123!")
server.serve_forever()
if __name__ == "__main__":
main()

Some files were not shown because too many files have changed in this diff Show More