From ce4ed6213b1aef4d2ee3b990927c6ed6a14f8cc0 Mon Sep 17 00:00:00 2001 From: nelso Date: Sat, 6 Jun 2026 19:32:24 +0800 Subject: [PATCH] Initial certificate system --- .env.example | 7 + .gitignore | 10 + README.md | 67 ++ backend/Dockerfile | 20 + backend/alembic.ini | 38 + backend/alembic/env.py | 45 + backend/alembic/script.py.mako | 23 + .../alembic/versions/20260601_0001_initial.py | 188 ++++ .../20260602_0002_project_defaults.py | 34 + backend/app/__init__.py | 1 + backend/app/api/__init__.py | 1 + backend/app/api/deps.py | 33 + backend/app/api/router.py | 26 + backend/app/api/routes/__init__.py | 1 + backend/app/api/routes/admin_certificates.py | 195 ++++ backend/app/api/routes/admin_dashboard.py | 22 + backend/app/api/routes/admin_exports.py | 84 ++ backend/app/api/routes/admin_imports.py | 362 +++++++ backend/app/api/routes/admin_learners.py | 126 +++ backend/app/api/routes/admin_logs.py | 115 ++ backend/app/api/routes/admin_projects.py | 82 ++ backend/app/api/routes/auth.py | 21 + backend/app/api/routes/health.py | 8 + backend/app/api/routes/public.py | 138 +++ backend/app/assets/certificate-template.jpg | Bin 0 -> 92779 bytes backend/app/cli.py | 30 + backend/app/core/__init__.py | 1 + backend/app/core/config.py | 33 + backend/app/core/paths.py | 15 + backend/app/core/security.py | 71 ++ backend/app/db/__init__.py | 1 + backend/app/db/base.py | 5 + backend/app/db/init_db.py | 50 + backend/app/db/session.py | 17 + backend/app/main.py | 21 + backend/app/models/__init__.py | 19 + backend/app/models/admin.py | 27 + backend/app/models/certificate.py | 59 ++ backend/app/models/import_batch.py | 35 + backend/app/models/learner.py | 44 + backend/app/models/log.py | 19 + backend/app/schemas/__init__.py | 1 + backend/app/schemas/auth.py | 13 + backend/app/schemas/certificate.py | 39 + backend/app/schemas/import_batch.py | 18 + backend/app/schemas/learner.py | 31 + backend/app/schemas/log.py | 21 + backend/app/schemas/project.py | 57 + backend/app/services/__init__.py | 1 + backend/app/services/captcha.py | 54 + backend/app/services/certificate_number.py | 30 + backend/app/services/logs.py | 148 +++ backend/app/services/pdf.py | 192 ++++ backend/app/services/rate_limit.py | 12 + .../templates/certificate_placeholder.html | 36 + backend/requirements.txt | 11 + backend/tests/test_captcha.py | 9 + backend/tests/test_certificate_number.py | 15 + backend/tests/test_import_rules.py | 34 + backend/tests/test_security.py | 15 + certificate-template-lab/README.md | 25 + certificate-template-lab/app.js | 465 +++++++++ certificate-template-lab/assets/original.jpg | Bin 0 -> 92779 bytes certificate-template-lab/index.html | 117 +++ certificate-template-lab/style.css | 246 +++++ deploy/nginx.prod.conf | 43 + deploy/proxy_params | 7 + docker-compose.yml | 42 + docs/decision-log.md | 102 ++ docs/deployment-checklist.md | 56 + docs/mvp-scope.md | 49 + docs/usage-deployment-manual.md | 146 +++ docs/产品视觉设计说明.md | 94 ++ docs/操作日志分页需求.md | 59 ++ docs/操作日志强化需求.md | 100 ++ docs/阿里云正式部署详细操作手册.md | 792 ++++++++++++++ frontend/Dockerfile | 14 + frontend/index.html | 12 + frontend/nginx.conf | 19 + frontend/package.json | 25 + frontend/src/App.vue | 3 + frontend/src/api.ts | 106 ++ frontend/src/design.css | 217 ++++ frontend/src/download.ts | 10 + frontend/src/main.ts | 11 + frontend/src/router/index.ts | 42 + frontend/src/views/AdminCertificates.vue | 216 ++++ frontend/src/views/AdminHome.vue | 106 ++ frontend/src/views/AdminImports.vue | 166 +++ frontend/src/views/AdminLearners.vue | 163 +++ frontend/src/views/AdminLogin.vue | 82 ++ frontend/src/views/AdminLogs.vue | 290 ++++++ frontend/src/views/AdminProjects.vue | 199 ++++ frontend/src/views/AdminShell.vue | 113 ++ frontend/src/views/CertificateQuery.vue | 450 ++++++++ frontend/src/views/CertificateView.vue | 168 +++ frontend/src/views/VerifyView.vue | 69 ++ frontend/tsconfig.json | 16 + frontend/vite.config.ts | 16 + local_app/server.py | 980 ++++++++++++++++++ .../static/assets/certificate-template.jpg | Bin 0 -> 92779 bytes local_app/static/index.html | 434 ++++++++ reset-local-data.bat | 8 + start-local.bat | 18 + start-local.ps1 | 6 + 105 files changed, 9133 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 README.md create mode 100644 backend/Dockerfile create mode 100644 backend/alembic.ini create mode 100644 backend/alembic/env.py create mode 100644 backend/alembic/script.py.mako create mode 100644 backend/alembic/versions/20260601_0001_initial.py create mode 100644 backend/alembic/versions/20260602_0002_project_defaults.py create mode 100644 backend/app/__init__.py create mode 100644 backend/app/api/__init__.py create mode 100644 backend/app/api/deps.py create mode 100644 backend/app/api/router.py create mode 100644 backend/app/api/routes/__init__.py create mode 100644 backend/app/api/routes/admin_certificates.py create mode 100644 backend/app/api/routes/admin_dashboard.py create mode 100644 backend/app/api/routes/admin_exports.py create mode 100644 backend/app/api/routes/admin_imports.py create mode 100644 backend/app/api/routes/admin_learners.py create mode 100644 backend/app/api/routes/admin_logs.py create mode 100644 backend/app/api/routes/admin_projects.py create mode 100644 backend/app/api/routes/auth.py create mode 100644 backend/app/api/routes/health.py create mode 100644 backend/app/api/routes/public.py create mode 100644 backend/app/assets/certificate-template.jpg create mode 100644 backend/app/cli.py create mode 100644 backend/app/core/__init__.py create mode 100644 backend/app/core/config.py create mode 100644 backend/app/core/paths.py create mode 100644 backend/app/core/security.py create mode 100644 backend/app/db/__init__.py create mode 100644 backend/app/db/base.py create mode 100644 backend/app/db/init_db.py create mode 100644 backend/app/db/session.py create mode 100644 backend/app/main.py create mode 100644 backend/app/models/__init__.py create mode 100644 backend/app/models/admin.py create mode 100644 backend/app/models/certificate.py create mode 100644 backend/app/models/import_batch.py create mode 100644 backend/app/models/learner.py create mode 100644 backend/app/models/log.py create mode 100644 backend/app/schemas/__init__.py create mode 100644 backend/app/schemas/auth.py create mode 100644 backend/app/schemas/certificate.py create mode 100644 backend/app/schemas/import_batch.py create mode 100644 backend/app/schemas/learner.py create mode 100644 backend/app/schemas/log.py create mode 100644 backend/app/schemas/project.py create mode 100644 backend/app/services/__init__.py create mode 100644 backend/app/services/captcha.py create mode 100644 backend/app/services/certificate_number.py create mode 100644 backend/app/services/logs.py create mode 100644 backend/app/services/pdf.py create mode 100644 backend/app/services/rate_limit.py create mode 100644 backend/app/templates/certificate_placeholder.html create mode 100644 backend/requirements.txt create mode 100644 backend/tests/test_captcha.py create mode 100644 backend/tests/test_certificate_number.py create mode 100644 backend/tests/test_import_rules.py create mode 100644 backend/tests/test_security.py create mode 100644 certificate-template-lab/README.md create mode 100644 certificate-template-lab/app.js create mode 100644 certificate-template-lab/assets/original.jpg create mode 100644 certificate-template-lab/index.html create mode 100644 certificate-template-lab/style.css create mode 100644 deploy/nginx.prod.conf create mode 100644 deploy/proxy_params create mode 100644 docker-compose.yml create mode 100644 docs/decision-log.md create mode 100644 docs/deployment-checklist.md create mode 100644 docs/mvp-scope.md create mode 100644 docs/usage-deployment-manual.md create mode 100644 docs/产品视觉设计说明.md create mode 100644 docs/操作日志分页需求.md create mode 100644 docs/操作日志强化需求.md create mode 100644 docs/阿里云正式部署详细操作手册.md create mode 100644 frontend/Dockerfile create mode 100644 frontend/index.html create mode 100644 frontend/nginx.conf create mode 100644 frontend/package.json create mode 100644 frontend/src/App.vue create mode 100644 frontend/src/api.ts create mode 100644 frontend/src/design.css create mode 100644 frontend/src/download.ts create mode 100644 frontend/src/main.ts create mode 100644 frontend/src/router/index.ts create mode 100644 frontend/src/views/AdminCertificates.vue create mode 100644 frontend/src/views/AdminHome.vue create mode 100644 frontend/src/views/AdminImports.vue create mode 100644 frontend/src/views/AdminLearners.vue create mode 100644 frontend/src/views/AdminLogin.vue create mode 100644 frontend/src/views/AdminLogs.vue create mode 100644 frontend/src/views/AdminProjects.vue create mode 100644 frontend/src/views/AdminShell.vue create mode 100644 frontend/src/views/CertificateQuery.vue create mode 100644 frontend/src/views/CertificateView.vue create mode 100644 frontend/src/views/VerifyView.vue create mode 100644 frontend/tsconfig.json create mode 100644 frontend/vite.config.ts create mode 100644 local_app/server.py create mode 100644 local_app/static/assets/certificate-template.jpg create mode 100644 local_app/static/index.html create mode 100644 reset-local-data.bat create mode 100644 start-local.bat create mode 100644 start-local.ps1 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 0000000000000000000000000000000000000000..f9a986da5b0e7d37d746d16748ea8a721c78fc27 GIT binary patch literal 92779 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%0DN(a-eSPUk}Ky5cW>|sB6C;%_njZ`f4x7U%Ezp0 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

ad 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..f9a986da5b0e7d37d746d16748ea8a721c78fc27 GIT binary patch literal 92779 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
4xy8g&%#v63!*X(d^x(t79YF9 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%0DN(a-eSPUk}Ky5cW>|sB6C;%_njZ`f4x7U%Ezp0 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

ad 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..f9a986da5b0e7d37d746d16748ea8a721c78fc27 GIT binary patch literal 92779 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
4xy8g&%#v63!*X(d^x(t79YF9 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%0DN(a-eSPUk}Ky5cW>|sB6C;%_njZ`f4x7U%Ezp0 zLM-G-Uui|ajxSE77poW-&ETEJk;6Ygf9a53)L6rIjGE=W#aBM^+PkP@oaKe^ol23t7x%cL1>_xq>`K`sQBvI%ah*!Gp5(qF2=Zz8#( z=uUhrAM$;HV84WntcD9d9uLd8sXnkfL~VYsiJ$*V9bz zul04m{TV`*77Y>{BIy6PhYTnE;kT2_&sfgIMDrIv5x(90L@0E<)Ku5u+N4jZ_8G>=d}X)}kQUO* zrrf5fj{uGy(>X~m_Z8Ay>VadS0pvH#eLgtW(Msa#8&t2;hR-GS6&8u!tW}eKmoKIc z1U|oJ>HCR*`&3K|)l5D{X<>a102Q?I`sj(9>2L;~K!m%YL*MlHMh~Dry%-Nl$&Sk| zrvqY@64~ML>B5As1!^j2Hy!s)E=dF#mlW7)Isf}C_H77npcn3H z9-__n=itjiLcCWY#%`ju1|dol>o9Szv7;~4ZKWmyn}1wN^=BJHqLPYQtHS#*+!;N- z$@;9ZJ(%@7?7$Z>QYQJ%o6ESc;7Ua)g=KFBNz_0u0@52{%+uAoem=K}fNR5x@Sg~; zv~+IMa-8KugkA&ZHsav_&TJj~`w+j=;5I#zffI>7S7LP=C-SAI@vIY$Dnm{8c%}C2W9L1t1B~^+$5oX)Ltv0K)zWx(I3Vwt+0pUQH zIn5tyL}G%c>XEqnD5 z4YxmBitp4aH{5-K*X0Y%IqhST)bJbitqaYUYOn5TKL*cHp6tHJl2T^+5G8__c!)5zbmr!w88CZ{u+_! zs{K{)6>d+H%iKNnD?T|9hleL#Ld(1VZ^DyMrRi|Bj*Embd7m-1@H!>+C96`^W85i) zTkCuF={y-+wGYaEw6u_4;w{ zo_YDYz<_ZgT|29?E>KBBRj1iuQvR8WEYc9!t{ycJZKUJGA#H2;^%4ptVZdSN<3k%i z-EZi1^ElHbc@)1LYtD0?LwJ2h8KoSzS+}ukY62ptfqpb6f)cX{LT;BJoAcw^;Xp$4p zuWdKLUDP5z1M8F~&=Pll9irYKMLaGxNS3-d36RZWfh^7oEFEs2-6@0B-2Hg*zMHYNfDRTU4sHJ{8*~JiEi4oo;BrYn`@OUYJJu$Itwd* zZ_9I_hHNYok~mx05zdN0rXI67{b`)+vIbs`B}nz+UYyZU)DYJ~x5$SOA}ui6A=Hk5 zq~W%u$mCLh*LM?e&mq>;OZ*IW@SvgT-|B5?*UzJ=4NA4(4)k00si*#?Y zK>)GrxbWM6wdl%ab`+gETaP)VkgcR+N8{{4cH)`mD`O6kwK=6(>D2&1Uh3p>D~j+A z(g!q(Ly4jN@;1#Q1zWJr$a884uZ=~4t!d} zt@!d>({S7Ps&{(LT*Kj~gFF4&+6yL+vm~LscF7X*HgMz_#K0p>1!jApqRaG(qN&RF z6(NmxS8;>(9BH5mw5!PYS(CHUHcPp?Mv}?PSrdz4%O5%0c>1))1tk$OY(;peX+c?L z0L_h~Y@c_CLit8ZO#`)TNcp|#DXQkKe3duz3p9<=1~?3weON?txl8G?A5Tc#!54C( z38SIkxTnK&Pxly&%y=oH22Bw^HQBfEy29L5r9C(1-kz4eE1i=4kvXn&f`4OGWG!UO zPb}AO0U{)UaMqbr##m;$(SzuISZ3^0p&To(oK2pNlq`BgZBN(K6+bXv#xY|{ zGh>9{H=MP?btZ5{OCls+Tw{a_+Aq~Lo3Qqt+q#D`ceAO2Lxnq8S7M+M>O;>CVJ&4= z)Xmu6KEvl9t){RxU)svCNUpP3xiUb@CMSf^`fAz8^F^!1nFP^QQ`|Y<(dugBXqF8M z#7Zxr>L)^)8LsQTHL+)+mdVo4`+d!oUJOj$a~`NXem4R=fb7iNOl%A;MhviJqBF2g2nT^6qzBy>1P2t zI#uqoFx!bxGu4yQB8MfhS#jc}Ins8-cKhLp)*Z7tn3K)(l|9EQm^~La_iQY&O`%bN z$#$-D7^Y$L$Me?-QIfBTeR-B~5-meRw?f#PW7z#1mY3e{OCfl_kQ}V>GMM!w0I(;R zr$jmy)@gbg#ZArihzhGZmwB&8#J!snE@4G%Udln`Z#rruw3ll1u+xjsXPHfHDZ7+b zlJxYi#or^QAmb&@K0?YyHC`WRYHIqVH}M>)t%%T39P0oRPn$NAP(BfR`uil4cqd%P zAvZfXE_SAwJ?GILA(HOyqQZI^wXyaIK;f+Pv6t{*1koLTDi_`}+e)dGoysoP=x@GH z3BBkm6y^K$TAm7JZXih?YsdOb%E(o{Syo`j#aXu8&V{_(%T6wFw&QH^B>?eAL?I0b zySwE+%MsI_IZ<|CcAe1iZr*m)w()9Azf@CcY!uPVGF%=LalJ=u*qfVapfo|7)|FLu z6d>6|X`NhQPJDT6RF&%1%|rK z#C=cqqU71xr}>J4rq0T>>G^D?I>bT^-&+Uo z3=|0_{83RQDpPO$a<1|Z<=Ag$k)7;t<`^LJ@xyIUpn{H^>*!YCC-S$?*wk)t=KmX%|gZ z>zI8%V@XSMW$=C>(dtXAz(-dSJ9gBdk6Swk4H+?`8fPt8YEKryUK^M}8Veb7%|$0c zmRqVWI7@Unm8}|Ed2cxQ0QAUqxEEu~#~x(Jk+ds9*c80A8laXBn@`h^ffwsTSxWYtA^tyos!{ykW zvt4*3VJZ4DdBL>yt)eY(eB1mD2{S8p)O!wru6~Es=$V6dwG# zz3jPtlK_c>Dg*8+83y3=7<48i1%Unx=gIJLj%RlKawbiiW92qY>}rF74BVp;j@uDM zJ(Z3%Gf`ws8fC%jog8ces_E^}1`uGI)k@+lt1t_mUB%ml2}euJJtEn-#;?tWRP1P} zM{E#L2f4v9U|AeOC;Qad52#cIBOaGhc$;yRFb z(lLJ`+=kb@uKwY`((W=M#jWsQk1F^${9?pLd*?L))`qq~9cV(P^tewxGg-fE{S1(& zeXZdLZFC>fmy9BTGNPLvA-PKJ@4bB_;Vb+pa%9U55k_p9DLb+Gv{`$^*H60CHUKIR z@?&<(p_rR%)7w(#;fS0PthmZcV=F*9I%NW<_j-50T#yRh1JL}jjnus9n9k5GaMSo_ zHeU`op@%ByQf0WA5)qG#^-s486l3&Lr%OjJRS-gt9wa5U8vz< z!MOZL>lKw8vy({63G8}#l=<)zd#`W9*8?WscpGexNfQ>u1Mlo8UEZxd-fv&QU-!E z()6QpU65ve?mDN+&cpap5fO|Mh42eGb)e5-7}vaIp>YVqDu~%5b^dwE+43i9`imj`!DJA~XS8 zetcCEGS|4bV+hf!O6}n~pU~RfE-x?>5-!Q(OVU4|ny)TUsV8fg(aWlSs>P*T`%O*HF0HZ$=jAqP724Nlrv8!6Rrt2{E+EEqxl>7@ z_hW;{qte+N5`9kTp($uI?!BAqPpeM*00kG{X{>eUY*Pt~GR;!2cdcx3I+zw=U(RBz9~%I+=ZTepwOEnGF1Sdzj_3p8w&&UO~+36KrU9PCQe znJUZ~Z0rwsF5moyloUJdw79w6N%stqvPG04jb#nYwS^`Ux_^X#$bSBl3>M#8TANbXTMex`HSLPl>7AdrypG3a1w35 zVQQ-EbC`lj97C$No!?vdg(mjH5GlXIT2JT3C1LL_zq zn^CLl2XR*d05o&AtkP4n(s(tZGpI@154_-V*Sbo*Xe2L8uq%IdS z*{s)lqj-6zsx$j7DU7{ZEY8f)7o91!-7-d&uSCqNvm5OzPY`XFST|DC++8bXwm-}= z80f>-!=h|19#^%iKG)<#b2#hPz7*;5!Iu|&tqEEjK-9v{(_!^h?FZdzs20%K`Bcj2 zRAyssM*?wc>Gh$z9cm$-R@40nyJoQhzr4JX3vSg-7`JjGAGy*(Q?+fGCF?WR`4@aSmz3N~A%i3ea^@UeFwrudS5ZM`5nvIZ(h_S7fbQw7;XW$u$N#$c<{v zfWCTTE;hB@aP0VO)ffR7wWgNjBAY!`MpXNBY1sGXKK?GNTzLdtkEZnDs`3XL2u|tJ zoxiQKT+W2KAP9)B7t65`slg#cZTo6d91Xe$jI1**c-(9;5W@VZMhRu8hKPD6W(sno z<8qlW$5M@ z+=Hy7-lV;Uvh%z<6?6PheRj~PcFL>i0WL>1dBw-l&z)n0N0#HU1rr?)J78tITnr$= z<*{JTdz5m({g7i(qC6yM<>Q=L* zYXU=2`ariIX)8ed`|{~K`d1EF^(qRxvIY|4y^xvX^K48M>N%5&llAF=*Q90}Y;cqq zEPb-ij&GaJ<}MZ(Qv^_xm82O9tAv2A3mbSu%zY7e6<`GxDwV@i&(?j!*yXvQs}!@t z=;U{WEO=S5H6=ALM&LOBZaMqJgvyb1@m2j*IRuO9@o-cyzRKqVb840Qf?=*zuHj<# z6j+N3TJ19Z(FJruO;L@>n$a>l&zBcPnmxYf{&uS`4TM;8p@iI%rK8ji7V{{Q6_(f%vlS@efw|V) z3Rato^AFcXlQO-2yL9^_Z$uB&k*_ZR&G0Bwk_50v(s+Rwo8dkp?`Qf6F6q`@A*aEe2LOjm$*g&bfXEbXsf4WNNjOYt~l5~_( zV};_zBo6zIRS=qVoRqYQ>6H$U`IIu>jWx&0YzVi`>}m)P&*UXrt=eljvs z5ITtgqVWMV*N5o%4BxeNRu&KL_R|(oXrJW}u&f@o8C&axzHeqGtnrSYk;=x8k(%N% z;{6p4^k_FIG0L4X5Llo#_uV%Zp<0u3>2PBGS`eDXb7b5IuUVZ!V?MS@ehA~Z%FHhG z=ypsP^k9&lE2+7&T}7SEBThiaQ+L5PoVVh5wZYlH=x(G0Xr|hwU>D=Te8kBqwGB%g zMOc7foReHLmCmH;@A+@LJrutfvwj``fwWFr9ZK-i+A@FEHMa^(G`|9V#u`0^9@NFa zTbxQa;?7?XL*^3JG#~Uib<~t174_a(73&JQ0~mZmqziBLcd3xmb5~v+N;2u@qU+?> zAJ*zJ8@r-E&I^#v?*i6*o177@R5^!x%km|e*+_haR5aVKOg3K+^Eu2kN9vxV&txT% zR&;0>XXfVDPisVrSyPnc#Ylb3sp(?sz-lP74}CZ33d)IZs{K)H3J#UvjF??1uhX`1 zX$&AxRhykrn5Xi66+W~|xln2ea{|MF_xF)$iAVCqL$#n6J&yOKkUfRtgcrZTovyPLGsS8&WTV~=q-9=Q4`cX3SXezPl9!Sj z;pkz$6N6?jy0)Q9G6|+RC!K>VcKOZOjDDWCVIknBYSncTDB^QWNzrN6;8oSx+czsR zRhrRf+=vPgrHD*;y;C0$_!^?&E}z}l@acdRvRM5f)SPlJZ)?uezbmKQ!-+|~k-+PL zS_{L_d(iiNg87`~w)QMv(xHVlZBjZC8UguEbxPztQ)|~YJtdUfBr?-6fghoKtkf^y z!nkqpkgv#=aCnW|{rZ8fMK7bBjnLpYl(@QKVzNV#DXSn%HDnHdkW9aGf;ixekL*2Z zjaIb7fv*)tp?uJm+vCxrJWb$iN8mDtI{=p(x3*SU(V50~PK+YBo5O2~q=Uwy&4F(> zH&m{+b{b zNXW@rwn|>CPW9`}T977$YjiJm2FA?!Zr~+@aSqr*agdQ1XKTJ;TQZRkgq5d=ir*j) z*+b#Xg%=)@-P&zy@zdg@n-!t5k2Or}KTYs?FfhOvXzKABQ6FKf1?9DoHmo+kx*K_1 z7F^2~hzT7&=dp0?O(hm2wvqLhM_$%Q(ydr0L~0 zep)Py*Sf%^!E6^CAWdUR+1`h>^$aV3ueWO<(N!U~3fQpr6G6oplVWyT#WEkW@sqjP z?3|vG6FgA>bPO1;K`UzwL82z=e10o8HS1_5E_}@hy$vLEo$c6mn50+y`&#q+U3mT{hyVtN>V4a4GHLaI~rM`q9GA)c+b zk&j3v8<$m+NVFO_wSJbB&C{YjC8@chFKs(HYQ)h!+7q21i58aN6M&TH!O|0vZ*eD> zsU_j{306`L831xCj~HbK7t)AGd&P-Ov)x03%)vc%ZMdOp6)XWIrTXfWZ zg04A^$Qs1gl%G>yAG=2rKYhA;fEg2zxpgP-?5W%}Ib(q@B_q|Z=Pz2H;|s-712>_3259b`E{U@3d!sm2>@|BHWjRXSCE-&jU_7Fiav&@g@Akep2V@z#;XIb@`}D)T#)qcV zVG>Dt`;tOTlu8|7O1$I1#r?}_@ek{-#(^&CK~`3KVt|vFO&2or}b5oXqtt-Gry*#MQ)5Q|E>5=044K>0i#Z&@)5n9AZ

ad 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 literal 0 HcmV?d00001 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"