commit ce4ed6213b1aef4d2ee3b990927c6ed6a14f8cc0 Author: nelso Date: Sat Jun 6 19:32:24 2026 +0800 Initial certificate system diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..0cbf0eb --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e8209ed --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +.env +__pycache__/ +*.pyc +.venv/ +node_modules/ +dist/ +.DS_Store +dev.db +*.log +local_app/data/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..21aa4e7 --- /dev/null +++ b/README.md @@ -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) diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..433f3e7 --- /dev/null +++ b/backend/Dockerfile @@ -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"] diff --git a/backend/alembic.ini b/backend/alembic.ini new file mode 100644 index 0000000..e69e820 --- /dev/null +++ b/backend/alembic.ini @@ -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 diff --git a/backend/alembic/env.py b/backend/alembic/env.py new file mode 100644 index 0000000..f8d4a12 --- /dev/null +++ b/backend/alembic/env.py @@ -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() diff --git a/backend/alembic/script.py.mako b/backend/alembic/script.py.mako new file mode 100644 index 0000000..aed88d8 --- /dev/null +++ b/backend/alembic/script.py.mako @@ -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"} diff --git a/backend/alembic/versions/20260601_0001_initial.py b/backend/alembic/versions/20260601_0001_initial.py new file mode 100644 index 0000000..b2ab15a --- /dev/null +++ b/backend/alembic/versions/20260601_0001_initial.py @@ -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") diff --git a/backend/alembic/versions/20260602_0002_project_defaults.py b/backend/alembic/versions/20260602_0002_project_defaults.py new file mode 100644 index 0000000..cde1677 --- /dev/null +++ b/backend/alembic/versions/20260602_0002_project_defaults.py @@ -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") diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/app/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/app/api/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py new file mode 100644 index 0000000..ba9ce25 --- /dev/null +++ b/backend/app/api/deps.py @@ -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 diff --git a/backend/app/api/router.py b/backend/app/api/router.py new file mode 100644 index 0000000..e3a5ce0 --- /dev/null +++ b/backend/app/api/router.py @@ -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"]) diff --git a/backend/app/api/routes/__init__.py b/backend/app/api/routes/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/app/api/routes/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/app/api/routes/admin_certificates.py b/backend/app/api/routes/admin_certificates.py new file mode 100644 index 0000000..472a73f --- /dev/null +++ b/backend/app/api/routes/admin_certificates.py @@ -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 diff --git a/backend/app/api/routes/admin_dashboard.py b/backend/app/api/routes/admin_dashboard.py new file mode 100644 index 0000000..e60490b --- /dev/null +++ b/backend/app/api/routes/admin_dashboard.py @@ -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(), + } diff --git a/backend/app/api/routes/admin_exports.py b/backend/app/api/routes/admin_exports.py new file mode 100644 index 0000000..29fcdbc --- /dev/null +++ b/backend/app/api/routes/admin_exports.py @@ -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:]}" diff --git a/backend/app/api/routes/admin_imports.py b/backend/app/api/routes/admin_imports.py new file mode 100644 index 0000000..d142f6f --- /dev/null +++ b/backend/app/api/routes/admin_imports.py @@ -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 diff --git a/backend/app/api/routes/admin_learners.py b/backend/app/api/routes/admin_learners.py new file mode 100644 index 0000000..0b17ed0 --- /dev/null +++ b/backend/app/api/routes/admin_learners.py @@ -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} diff --git a/backend/app/api/routes/admin_logs.py b/backend/app/api/routes/admin_logs.py new file mode 100644 index 0000000..e263e03 --- /dev/null +++ b/backend/app/api/routes/admin_logs.py @@ -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] diff --git a/backend/app/api/routes/admin_projects.py b/backend/app/api/routes/admin_projects.py new file mode 100644 index 0000000..0aa72a0 --- /dev/null +++ b/backend/app/api/routes/admin_projects.py @@ -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 diff --git a/backend/app/api/routes/auth.py b/backend/app/api/routes/auth.py new file mode 100644 index 0000000..470d588 --- /dev/null +++ b/backend/app/api/routes/auth.py @@ -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, + ) diff --git a/backend/app/api/routes/health.py b/backend/app/api/routes/health.py new file mode 100644 index 0000000..5c05c8b --- /dev/null +++ b/backend/app/api/routes/health.py @@ -0,0 +1,8 @@ +from fastapi import APIRouter + +router = APIRouter() + + +@router.get("/health") +def health_check() -> dict[str, str]: + return {"status": "ok"} diff --git a/backend/app/api/routes/public.py b/backend/app/api/routes/public.py new file mode 100644 index 0000000..f772e61 --- /dev/null +++ b/backend/app/api/routes/public.py @@ -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 diff --git a/backend/app/assets/certificate-template.jpg b/backend/app/assets/certificate-template.jpg new file mode 100644 index 0000000..f9a986d Binary files /dev/null and b/backend/app/assets/certificate-template.jpg differ diff --git a/backend/app/cli.py b/backend/app/cli.py new file mode 100644 index 0000000..656723b --- /dev/null +++ b/backend/app/cli.py @@ -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() diff --git a/backend/app/core/__init__.py b/backend/app/core/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/app/core/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/app/core/config.py b/backend/app/core/config.py new file mode 100644 index 0000000..add4553 --- /dev/null +++ b/backend/app/core/config.py @@ -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() diff --git a/backend/app/core/paths.py b/backend/app/core/paths.py new file mode 100644 index 0000000..0ef372d --- /dev/null +++ b/backend/app/core/paths.py @@ -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 diff --git a/backend/app/core/security.py b/backend/app/core/security.py new file mode 100644 index 0000000..e9f6935 --- /dev/null +++ b/backend/app/core/security.py @@ -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) diff --git a/backend/app/db/__init__.py b/backend/app/db/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/app/db/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/app/db/base.py b/backend/app/db/base.py new file mode 100644 index 0000000..fa2b68a --- /dev/null +++ b/backend/app/db/base.py @@ -0,0 +1,5 @@ +from sqlalchemy.orm import DeclarativeBase + + +class Base(DeclarativeBase): + pass diff --git a/backend/app/db/init_db.py b/backend/app/db/init_db.py new file mode 100644 index 0000000..23899f8 --- /dev/null +++ b/backend/app/db/init_db.py @@ -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() diff --git a/backend/app/db/session.py b/backend/app/db/session.py new file mode 100644 index 0000000..35b8095 --- /dev/null +++ b/backend/app/db/session.py @@ -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() diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..9abe7b0 --- /dev/null +++ b/backend/app/main.py @@ -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() diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..85c3aa3 --- /dev/null +++ b/backend/app/models/__init__.py @@ -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", +] diff --git a/backend/app/models/admin.py b/backend/app/models/admin.py new file mode 100644 index 0000000..2711331 --- /dev/null +++ b/backend/app/models/admin.py @@ -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) diff --git a/backend/app/models/certificate.py b/backend/app/models/certificate.py new file mode 100644 index 0000000..44592f0 --- /dev/null +++ b/backend/app/models/certificate.py @@ -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) diff --git a/backend/app/models/import_batch.py b/backend/app/models/import_batch.py new file mode 100644 index 0000000..3676387 --- /dev/null +++ b/backend/app/models/import_batch.py @@ -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) diff --git a/backend/app/models/learner.py b/backend/app/models/learner.py new file mode 100644 index 0000000..bdf71c9 --- /dev/null +++ b/backend/app/models/learner.py @@ -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) diff --git a/backend/app/models/log.py b/backend/app/models/log.py new file mode 100644 index 0000000..17ff46f --- /dev/null +++ b/backend/app/models/log.py @@ -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) diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/app/schemas/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py new file mode 100644 index 0000000..d4f78d1 --- /dev/null +++ b/backend/app/schemas/auth.py @@ -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 diff --git a/backend/app/schemas/certificate.py b/backend/app/schemas/certificate.py new file mode 100644 index 0000000..35a66ab --- /dev/null +++ b/backend/app/schemas/certificate.py @@ -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} diff --git a/backend/app/schemas/import_batch.py b/backend/app/schemas/import_batch.py new file mode 100644 index 0000000..812ac65 --- /dev/null +++ b/backend/app/schemas/import_batch.py @@ -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} diff --git a/backend/app/schemas/learner.py b/backend/app/schemas/learner.py new file mode 100644 index 0000000..85721f3 --- /dev/null +++ b/backend/app/schemas/learner.py @@ -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} diff --git a/backend/app/schemas/log.py b/backend/app/schemas/log.py new file mode 100644 index 0000000..5862ec8 --- /dev/null +++ b/backend/app/schemas/log.py @@ -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} diff --git a/backend/app/schemas/project.py b/backend/app/schemas/project.py new file mode 100644 index 0000000..8fd8527 --- /dev/null +++ b/backend/app/schemas/project.py @@ -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} diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/app/services/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/app/services/captcha.py b/backend/app/services/captcha.py new file mode 100644 index 0000000..07df890 --- /dev/null +++ b/backend/app/services/captcha.py @@ -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") diff --git a/backend/app/services/certificate_number.py b/backend/app/services/certificate_number.py new file mode 100644 index 0000000..7985c04 --- /dev/null +++ b/backend/app/services/certificate_number.py @@ -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}" diff --git a/backend/app/services/logs.py b/backend/app/services/logs.py new file mode 100644 index 0000000..07a3a4d --- /dev/null +++ b/backend/app/services/logs.py @@ -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) diff --git a/backend/app/services/pdf.py b/backend/app/services/pdf.py new file mode 100644 index 0000000..a544a29 --- /dev/null +++ b/backend/app/services/pdf.py @@ -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) diff --git a/backend/app/services/rate_limit.py b/backend/app/services/rate_limit.py new file mode 100644 index 0000000..e1e8f1b --- /dev/null +++ b/backend/app/services/rate_limit.py @@ -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 diff --git a/backend/app/templates/certificate_placeholder.html b/backend/app/templates/certificate_placeholder.html new file mode 100644 index 0000000..d98d660 --- /dev/null +++ b/backend/app/templates/certificate_placeholder.html @@ -0,0 +1,36 @@ + + + + + Training Completion Certificate + + + +
+
培训结业证书
+
占位模板,正式 Logo、排版和公章后续替换
+
+ 兹证明 {{ learner_name }} 已完成 {{ project_name }} {{ course_name }} {{ stage_name }} 相关培训。 +
+
+
证书编号:{{ certificate_no }}
+
发证日期:{{ issue_date }}
+
发证单位:{{ issuer_name }}
+
+ {% if qr_code_data_url %}二维码{% endif %} +
+ 本证书仅用于证明学员完成相关培训课程/阶段学习,不代表国家学历、学位、职业资格或职业技能等级认证。 +
+
+ + diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..32e79d1 --- /dev/null +++ b/backend/requirements.txt @@ -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 diff --git a/backend/tests/test_captcha.py b/backend/tests/test_captcha.py new file mode 100644 index 0000000..88901c1 --- /dev/null +++ b/backend/tests/test_captcha.py @@ -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") diff --git a/backend/tests/test_certificate_number.py b/backend/tests/test_certificate_number.py new file mode 100644 index 0000000..b4b7291 --- /dev/null +++ b/backend/tests/test_certificate_number.py @@ -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 diff --git a/backend/tests/test_import_rules.py b/backend/tests/test_import_rules.py new file mode 100644 index 0000000..483df0e --- /dev/null +++ b/backend/tests/test_import_rules.py @@ -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") diff --git a/backend/tests/test_security.py b/backend/tests/test_security.py new file mode 100644 index 0000000..9b04c45 --- /dev/null +++ b/backend/tests/test_security.py @@ -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" diff --git a/certificate-template-lab/README.md b/certificate-template-lab/README.md new file mode 100644 index 0000000..ddd5e25 --- /dev/null +++ b/certificate-template-lab/README.md @@ -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/图片生成流程。 diff --git a/certificate-template-lab/app.js b/certificate-template-lab/app.js new file mode 100644 index 0000000..845a3ff --- /dev/null +++ b/certificate-template-lab/app.js @@ -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, "_"); + } +})(); diff --git a/certificate-template-lab/assets/original.jpg b/certificate-template-lab/assets/original.jpg new file mode 100644 index 0000000..f9a986d Binary files /dev/null and b/certificate-template-lab/assets/original.jpg differ diff --git a/certificate-template-lab/index.html b/certificate-template-lab/index.html new file mode 100644 index 0000000..fb2efcd --- /dev/null +++ b/certificate-template-lab/index.html @@ -0,0 +1,117 @@ + + + + + + 结业证书模板调试 + + + +
+ + +
+
+
+

1024 x 759

+

实时预览

+
+ 加载中 +
+
+ +
+
+
+ + + + + + diff --git a/certificate-template-lab/style.css b/certificate-template-lab/style.css new file mode 100644 index 0000000..2d27ae2 --- /dev/null +++ b/certificate-template-lab/style.css @@ -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; + } +} diff --git a/deploy/nginx.prod.conf b/deploy/nginx.prod.conf new file mode 100644 index 0000000..fe81809 --- /dev/null +++ b/deploy/nginx.prod.conf @@ -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; + } +} diff --git a/deploy/proxy_params b/deploy/proxy_params new file mode 100644 index 0000000..bd8ea26 --- /dev/null +++ b/deploy/proxy_params @@ -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; diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..047e1e1 --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/docs/decision-log.md b/docs/decision-log.md new file mode 100644 index 0000000..1ef8097 --- /dev/null +++ b/docs/decision-log.md @@ -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. diff --git a/docs/deployment-checklist.md b/docs/deployment-checklist.md new file mode 100644 index 0000000..850c19d --- /dev/null +++ b/docs/deployment-checklist.md @@ -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` 不纳入长期备份。 diff --git a/docs/mvp-scope.md b/docs/mvp-scope.md new file mode 100644 index 0000000..9907014 --- /dev/null +++ b/docs/mvp-scope.md @@ -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 中填写,由系统确认导入后自动生成。 diff --git a/docs/usage-deployment-manual.md b/docs/usage-deployment-manual.md new file mode 100644 index 0000000..288fefa --- /dev/null +++ b/docs/usage-deployment-manual.md @@ -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、公章和证书文案。 +- 生产管理员账号和强密码。 +- 服务器部署后的联调和验收数据导入演练。 diff --git a/docs/产品视觉设计说明.md b/docs/产品视觉设计说明.md new file mode 100644 index 0000000..a93d77a --- /dev/null +++ b/docs/产品视觉设计说明.md @@ -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,保证本地验收看到的视觉方向与正式版一致。 diff --git a/docs/操作日志分页需求.md b/docs/操作日志分页需求.md new file mode 100644 index 0000000..5ebd381 --- /dev/null +++ b/docs/操作日志分页需求.md @@ -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 页”,避免页码过多时挤压布局。 +- 上下各放一组分页控件,避免用户滚动到底部后还要回到顶部翻页。 diff --git a/docs/操作日志强化需求.md b/docs/操作日志强化需求.md new file mode 100644 index 0000000..7bd64f4 --- /dev/null +++ b/docs/操作日志强化需求.md @@ -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 为空,用“公开访问”对象类型区分。 diff --git a/docs/阿里云正式部署详细操作手册.md b/docs/阿里云正式部署详细操作手册.md new file mode 100644 index 0000000..42cda76 --- /dev/null +++ b/docs/阿里云正式部署详细操作手册.md @@ -0,0 +1,792 @@ +# 阿里云正式部署详细操作手册 + +本文档用于把“培训结业证书管理与查询系统”部署到阿里云正式环境。当前日期:2026-06-05。 + +## 1. 先回答:数据库要不要单独买 + +可以有两种方案。 + +### 方案 A:数据库和应用都放在同一台 ECS 上 + +做法:一台阿里云 ECS 上运行 Docker Compose,里面同时跑: + +- MySQL +- 后端 FastAPI +- 前端 Nginx/Vue + +优点: + +- 成本低。 +- 部署简单。 +- 一期快速上线足够用。 + +缺点: + +- ECS 故障时,应用和数据库一起受影响。 +- 数据库备份、恢复、监控需要自己认真配置。 +- 后期迁移到 RDS 时需要再做一次数据库迁移。 + +适合: + +- 一期快速上线。 +- 访问量不大。 +- 预算有限。 +- 可以接受自己维护数据库备份。 + +### 方案 B:ECS 跑应用,阿里云 RDS MySQL 跑数据库 + +做法: + +- ECS 只跑前端和后端。 +- MySQL 使用阿里云 RDS MySQL。 + +优点: + +- 数据库更稳定。 +- 阿里云 RDS 提供备份、恢复、监控、容灾等能力。 +- 后期维护成本低。 +- 更接近正式生产系统。 + +缺点: + +- 成本更高。 +- 需要配置 RDS 白名单、账号、数据库连接串。 + +适合: + +- 正式长期使用。 +- 证书数据比较重要。 +- 不希望自己维护 MySQL 备份和恢复。 + +### 我的建议 + +如果你现在要尽快上线,一期可以先用方案 A:一台 ECS + Docker Compose 自带 MySQL。 + +如果这个系统会长期对外使用,证书数据又比较重要,建议正式上线直接用方案 B:ECS + 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,也可以用压缩包。 + +### 方式 A:Git 拉代码 + +```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 也可以,但一定要配置每日备份。 diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..525e75c --- /dev/null +++ b/frontend/Dockerfile @@ -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 diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..5ca5b8f --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + 培训结业证书管理与查询系统 + + +
+ + + diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..f2c6256 --- /dev/null +++ b/frontend/nginx.conf @@ -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; + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..e5eb012 --- /dev/null +++ b/frontend/package.json @@ -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" + } +} diff --git a/frontend/src/App.vue b/frontend/src/App.vue new file mode 100644 index 0000000..98240ae --- /dev/null +++ b/frontend/src/App.vue @@ -0,0 +1,3 @@ + diff --git a/frontend/src/api.ts b/frontend/src/api.ts new file mode 100644 index 0000000..6a83e14 --- /dev/null +++ b/frontend/src/api.ts @@ -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; +} diff --git a/frontend/src/design.css b/frontend/src/design.css new file mode 100644 index 0000000..3e1c494 --- /dev/null +++ b/frontend/src/design.css @@ -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; + } +} diff --git a/frontend/src/download.ts b/frontend/src/download.ts new file mode 100644 index 0000000..304953a --- /dev/null +++ b/frontend/src/download.ts @@ -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); +} diff --git a/frontend/src/main.ts b/frontend/src/main.ts new file mode 100644 index 0000000..58857f4 --- /dev/null +++ b/frontend/src/main.ts @@ -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"); diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts new file mode 100644 index 0000000..970991b --- /dev/null +++ b/frontend/src/router/index.ts @@ -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"; + } +}); diff --git a/frontend/src/views/AdminCertificates.vue b/frontend/src/views/AdminCertificates.vue new file mode 100644 index 0000000..20b7938 --- /dev/null +++ b/frontend/src/views/AdminCertificates.vue @@ -0,0 +1,216 @@ + + + + + diff --git a/frontend/src/views/AdminHome.vue b/frontend/src/views/AdminHome.vue new file mode 100644 index 0000000..721efef --- /dev/null +++ b/frontend/src/views/AdminHome.vue @@ -0,0 +1,106 @@ + + + + + diff --git a/frontend/src/views/AdminImports.vue b/frontend/src/views/AdminImports.vue new file mode 100644 index 0000000..d72e1e0 --- /dev/null +++ b/frontend/src/views/AdminImports.vue @@ -0,0 +1,166 @@ + + + + + diff --git a/frontend/src/views/AdminLearners.vue b/frontend/src/views/AdminLearners.vue new file mode 100644 index 0000000..b4f22b6 --- /dev/null +++ b/frontend/src/views/AdminLearners.vue @@ -0,0 +1,163 @@ + + + + + diff --git a/frontend/src/views/AdminLogin.vue b/frontend/src/views/AdminLogin.vue new file mode 100644 index 0000000..99c3e4c --- /dev/null +++ b/frontend/src/views/AdminLogin.vue @@ -0,0 +1,82 @@ + + + + + diff --git a/frontend/src/views/AdminLogs.vue b/frontend/src/views/AdminLogs.vue new file mode 100644 index 0000000..ab8ef48 --- /dev/null +++ b/frontend/src/views/AdminLogs.vue @@ -0,0 +1,290 @@ + + + + + diff --git a/frontend/src/views/AdminProjects.vue b/frontend/src/views/AdminProjects.vue new file mode 100644 index 0000000..8f0c919 --- /dev/null +++ b/frontend/src/views/AdminProjects.vue @@ -0,0 +1,199 @@ + + + + + diff --git a/frontend/src/views/AdminShell.vue b/frontend/src/views/AdminShell.vue new file mode 100644 index 0000000..b50a561 --- /dev/null +++ b/frontend/src/views/AdminShell.vue @@ -0,0 +1,113 @@ + + + + + diff --git a/frontend/src/views/CertificateQuery.vue b/frontend/src/views/CertificateQuery.vue new file mode 100644 index 0000000..2f193d0 --- /dev/null +++ b/frontend/src/views/CertificateQuery.vue @@ -0,0 +1,450 @@ + + + + + diff --git a/frontend/src/views/CertificateView.vue b/frontend/src/views/CertificateView.vue new file mode 100644 index 0000000..24a8f96 --- /dev/null +++ b/frontend/src/views/CertificateView.vue @@ -0,0 +1,168 @@ + + + + + diff --git a/frontend/src/views/VerifyView.vue b/frontend/src/views/VerifyView.vue new file mode 100644 index 0000000..e2c929a --- /dev/null +++ b/frontend/src/views/VerifyView.vue @@ -0,0 +1,69 @@ + + + + + diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..89433ee --- /dev/null +++ b/frontend/tsconfig.json @@ -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"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..f55e9af --- /dev/null +++ b/frontend/vite.config.ts @@ -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/, "") + } + } + } +}); diff --git a/local_app/server.py b/local_app/server.py new file mode 100644 index 0000000..f4bbabc --- /dev/null +++ b/local_app/server.py @@ -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() diff --git a/local_app/static/assets/certificate-template.jpg b/local_app/static/assets/certificate-template.jpg new file mode 100644 index 0000000..f9a986d Binary files /dev/null and b/local_app/static/assets/certificate-template.jpg differ diff --git a/local_app/static/index.html b/local_app/static/index.html new file mode 100644 index 0000000..7cca5a6 --- /dev/null +++ b/local_app/static/index.html @@ -0,0 +1,434 @@ + + + + + + 培训结业证书管理与查询系统 + + + + + + + + + + + + + + diff --git a/reset-local-data.bat b/reset-local-data.bat new file mode 100644 index 0000000..c36e5f9 --- /dev/null +++ b/reset-local-data.bat @@ -0,0 +1,8 @@ +@echo off +setlocal +cd /d "%~dp0" +if exist "local_app\data\local.db" del /f /q "local_app\data\local.db" +if exist "local_app\data\batch-*.json" del /f /q "local_app\data\batch-*.json" +if exist "local_app\data\pdf-cache" rmdir /s /q "local_app\data\pdf-cache" +echo Local test data has been reset. +pause diff --git a/start-local.bat b/start-local.bat new file mode 100644 index 0000000..52e52bc --- /dev/null +++ b/start-local.bat @@ -0,0 +1,18 @@ +@echo off +setlocal +cd /d "%~dp0" + +set "PYTHON_EXE=C:\Users\nelso\.cache\codex-runtimes\codex-primary-runtime\dependencies\python\python.exe" +if not exist "%PYTHON_EXE%" set "PYTHON_EXE=python" + +echo Starting local certificate system... +echo. +echo Admin URL: http://127.0.0.1:8000/admin/login +echo Query URL: http://127.0.0.1:8000/query +echo Account: admin / Admin123! +echo. +echo Keep this window open while testing. Press Ctrl+C to stop. +echo. + +"%PYTHON_EXE%" local_app\server.py +pause diff --git a/start-local.ps1 b/start-local.ps1 new file mode 100644 index 0000000..06b4950 --- /dev/null +++ b/start-local.ps1 @@ -0,0 +1,6 @@ +$ErrorActionPreference = "Stop" +$Python = "C:\Users\nelso\.cache\codex-runtimes\codex-primary-runtime\dependencies\python\python.exe" +if (-not (Test-Path $Python)) { + $Python = "python" +} +& $Python ".\local_app\server.py"